lib/nerves/package.ex

defmodule Nerves.Package do
  @moduledoc """
  Defines a Nerves package struct and helper functions.

  A Nerves package is a Mix application that defines the configuration for a
  Nerves system or Nerves toolchain. For more details, see the Nerves
  [system documentation](https://hexdocs.pm/nerves/systems.html#package-configuration)
  """

  defstruct app: nil,
            path: nil,
            dep: nil,
            env: [],
            type: nil,
            version: nil,
            platform: nil,
            build_runner: nil,
            compilers: [],
            dep_opts: [],
            config: []

  alias __MODULE__
  alias Nerves.Artifact

  @type t :: %__MODULE__{
          app: atom,
          path: binary,
          env: Keyword.t(),
          type:
            :system
            | :package
            | :toolchain,
          dep:
            :project
            | :path
            | :hex
            | :git,
          platform: atom,
          build_runner: atom,
          compilers: [atom],
          dep_opts: Keyword.t(),
          version: Version.t(),
          config: Keyword.t()
        }

  @package_config "nerves.exs"
  @required [:type, :version, :platform]

  @doc """
  Loads the package config and parses it into a `%Package{}`
  """
  @spec load_config({app :: atom, path :: String.t()}) :: Nerves.Package.t()
  def load_config({app, path}) do
    config = config(app, path)
    version = config[:version]
    type = config[:nerves_package][:type]
    compilers = config[:compilers] || Mix.compilers()
    env = config[:nerves_package][:env] || %{}

    unless type do
      Mix.shell().error(
        "The Nerves package #{app} does not define a type.\n\n" <>
          "Verify that the key exists in '#{config_path(path)}'.\n"
      )

      exit({:shutdown, 1})
    end

    platform = config[:nerves_package][:platform]
    build_runner = Artifact.build_runner(config)
    config = Enum.reject(config[:nerves_package], fn {k, _v} -> k in @required end)

    dep_opts =
      mix_dep_load(env: Mix.env())
      |> Enum.find(%{}, &(&1.app == app))
      |> Map.get(:opts, [])
      |> Keyword.get(:nerves, [])

    %Package{
      app: app,
      type: type,
      env: env,
      platform: platform,
      build_runner: build_runner,
      compilers: compilers,
      dep_opts: dep_opts,
      dep: dep_type(app),
      path: path,
      version: version,
      config: config
    }
  end

  @doc """
  Starts an interactive shell with the working directory set
  to the package path
  """
  @spec shell(Nerves.Package.t()) :: :ok
  def shell(nil) do
    Mix.raise("Package is not loaded in your Nerves Environment.")
  end

  def shell(%{platform: nil, app: app}) do
    Mix.raise("Cannot start shell for #{app}")
  end

  def shell(pkg) do
    pkg.build_runner.shell(pkg)
  end

  @doc """
  Takes the path to the package and returns the path to its package config.
  """
  @spec config_path(String.t()) :: String.t()
  def config_path(path) do
    Path.join(path, @package_config)
  end

  def config(app, path) do
    project_config =
      if app == Mix.Project.config()[:app] do
        Mix.Project.config()
      else
        Mix.Project.in_project(app, path, fn _mod ->
          Mix.Project.config()
        end)
      end

    nerves_package =
      case project_config[:nerves_package] do
        nil ->
          # TODO: Deprecated. Clean up after 1.0
          Mix.shell().raise("""
          Nerves configuration has moved from nerves.exs to mix.exs.

          Use `mix nerves.new` to regenerate your project's mix.exs and merge
          your code into the new project.
          """)

        nerves_package ->
          nerves_package
      end

    Keyword.put(project_config, :nerves_package, nerves_package)
  end

  defp dep_type(pkg) do
    deps_paths = Mix.Project.deps_paths()

    case Map.get(deps_paths, pkg) do
      nil ->
        :project

      path ->
        deps_path =
          File.cwd!()
          |> Path.join(Mix.Project.config()[:deps_path])
          |> Path.expand()

        if String.starts_with?(path, deps_path) do
          :hex
        else
          :path
        end
    end
  end

  # Elixir 1.7 deprecated Mix.Dep.loaded/1
  # Use Mix.Dep.load_on_environment/1 if available
  defp mix_dep_load(opts) do
    fun =
      if :erlang.function_exported(Mix.Dep, :load_on_environment, 1) do
        :load_on_environment
      else
        :loaded
      end

    apply(Mix.Dep, fun, [opts])
  end
end