lib/mix/nerves/utils.ex

defmodule Mix.Nerves.Utils do
  alias Nerves.Utils.WSL

  def shell(cmd, args, opts \\ []) do
    stream = opts[:stream] || IO.binstream(:standard_io, :line)
    std_err = opts[:stderr_to_stdout] || true
    env = Keyword.get(opts, :env, []) ++ [{"PATH", sanitize_path()}]

    opts =
      opts
      |> Keyword.drop([:into, :stderr_to_stdout, :stream])
      |> Keyword.put(:env, env)

    Nerves.Port.cmd(cmd, args, [into: stream, stderr_to_stdout: std_err] ++ opts)
  end

  def debug_info(msg) do
    if System.get_env("NERVES_DEBUG") == "1" do
      Mix.shell().info(msg)
    end
  end

  @spec check_nerves_system_is_set!() :: String.t()
  def check_nerves_system_is_set! do
    var_name = "NERVES_SYSTEM"
    System.get_env(var_name) || raise_env_var_missing(var_name)
  end

  @spec check_nerves_toolchain_is_set! :: String.t()
  def check_nerves_toolchain_is_set! do
    var_name = "NERVES_TOOLCHAIN"
    System.get_env(var_name) || raise_env_var_missing(var_name)
  end

  defp get_devs do
    {result, 0} =
      if WSL.running_on_wsl?() do
        WSL.get_fwup_devices()
      else
        Nerves.Port.cmd("fwup", ["--detect"])
      end

    if result == "" do
      Mix.raise("Could not auto detect your SD card")
    end

    result
    |> String.trim()
    |> String.split("\n")
    |> Enum.map(&parse_dev/1)
  end

  defp parse_dev(line) do
    [dev, bytes | _rest] = String.split(line, ",")
    {dev, bytes}
  end

  def prompt_dev() do
    case get_devs() do
      [{dev, bytes}] ->
        choice =
          Mix.shell().yes?("Use #{bytes_to_gigabytes(bytes)} GiB memory card found at #{dev}?")

        if choice do
          dev
        else
          Mix.raise("Aborted")
        end

      devs ->
        choices =
          devs
          |> Enum.zip(0..length(devs))
          |> Enum.reduce([], fn {{dev, bytes}, idx}, acc ->
            ["#{idx}) #{bytes_to_gigabytes(bytes)} GiB found at #{dev}" | acc]
          end)
          |> Enum.reverse()

        choice =
          Mix.shell().prompt(
            "Discovered devices:\n#{Enum.join(choices, "\n")}\nWhich device do you want to burn to?"
          )
          |> String.trim()

        idx =
          case Integer.parse(choice) do
            {idx, _} -> idx
            _ -> Mix.raise("Invalid selection #{choice}")
          end

        case Enum.fetch(devs, idx) do
          {:ok, {dev, _}} -> dev
          _ -> Mix.raise("Invalid selection #{choice}")
        end
    end
  end

  def bytes_to_gigabytes(bytes) when is_binary(bytes) do
    {bytes, _} = Integer.parse(bytes)
    bytes_to_gigabytes(bytes)
  end

  def bytes_to_gigabytes(bytes) do
    gb = bytes / 1024 / 1024 / 1024
    Float.round(gb, 2)
  end

  def set_provisioning(nil), do: :ok

  def set_provisioning(app) when is_atom(app) do
    _ = Application.load(app)

    Application.get_env(app, :nerves_provisioning)
    |> set_provisioning()
  end

  def set_provisioning(provisioning) when is_binary(provisioning) do
    path = Path.expand(provisioning)
    System.put_env("NERVES_PROVISIONING", path)
  end

  def set_provisioning(_) do
    Mix.raise("""
      Unexpected Mix config for :nerves, :firmware, :provisioning.
      Provisioning should be a relative string path to a provisioning.conf
      based off the root of the project or an atom of an application that
      provides a provisioning.conf.

      For example:

        config :nerves, :firmware,
          provisioning: "config/provisioning.conf"

      or

        config :nerves, :firmware,
          provisioning: :nerves_hub

    """)
  end

  @spec mix_target() :: atom()
  def mix_target do
    if function_exported?(Mix, :target, 0) do
      apply(Mix, :target, [])
    else
      (System.get_env("MIX_TARGET") || "host")
      |> String.to_atom()
    end
  end

  def sanitize_path() do
    System.get_env("PATH")
    |> String.replace("::", ":")
  end

  @spec parse_version(String.t()) :: {:error, String.t()} | {:ok, Version.t()}
  def parse_version(vsn) do
    cond do
      # Strict semver
      Regex.match?(
        ~r/^((([0-9]+)\.([0-9]+)\.([0-9]+)(?:-([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?)$/,
        vsn
      ) ->
        {:ok, Version.parse!(vsn)}

      # x.x
      Regex.match?(~r/^([0-9]+)\.([0-9]+)$/, vsn) ->
        {:ok, Version.parse!(vsn <> ".0")}

      # x.x.x.x
      Regex.match?(~r/^([0-9]+)\.([0-9]+)\.([0-9]+)\.([0-9]+)$/, vsn) ->
        [major, minor, patch | _tail] = String.split(vsn, ".")

        vsn = Enum.join([major, minor, patch], ".")
        {:ok, Version.parse!(vsn)}

      # unknown
      true ->
        {:error, "Unable to Version.parse #{inspect(vsn)}"}
    end
  end

  @spec raise_env_var_missing(String.t()) :: no_return()
  defp raise_env_var_missing(name) do
    Mix.raise("""
    Environment variable $#{name} is not set.

    This variable is usually set for you by Nerves when you specify the
    $MIX_TARGET. It is unusual to need to specify it yourself.

    Some things to check:

    1. In your `mix.exs`, is the value that you have in $MIX_TARGET in the
      `@all_targets` list? If you're not using `@all_targets`, then the
      $MIX_TARGET should appear in the `:targets` option for `:nerves_runtime`
      and other packages that run on the target.

    2. Do you have a dependency on a Nerves system for the target? For example,
      `{:nerves_system_rpi0, "~> 1.8", runtime: false, targets: :rpi0}`

    3. Is there a typo? For example, is $MIX_TARGET set to `rpi1` when it should
      be `rpi`.

    4. Is there a typo in the package name of the system? For example, if you
      have a custom system, `:nerves_system_my_board`, does the spelling of the
      system in the dependency in your `mix.exs` match the spelling in your
      system project's `mix.exs`?

    For build examples in the Nerves documentation, please see
    https://hexdocs.pm/nerves/getting-started.html#create-the-firmware-bundle
    """)
  end
end