lib/di.ex

defmodule DI do
  @moduledoc """
  Dependency injection

  ## Usage

  Include DI in the module that requires dependency injection with `use DI`.
  Any place in that module that might need a dependency injected can then use
  `di(<module>)` to allow another module to be injected. The module passed to
  `di/1` will be used if another module isn't injected.

  ```elixir
  defmodule MyInterface do
    use DI

    def some_command, do: di(__MODULE__).some_command
  end 
  ```

  ### Configuration

  DI can be configured in the project's `config.exs`.

  **Opts**
  - `compile` - (false) - Sets the mappings at compile time and doesn't start \
                          the process that allows them to be modified at runtime. \
                          This method is more secure and more performant. \
                          Compiling is intended for production and runtime is \
                          intended for unit tests.
  - `mappings` - `[]`   - A two element tuple of the modules to map from and to: \
                          `{from, to}`

  **Example**


  ```elixir
  config :<app>, di: %{
    compile: true,
    mappings: [
      {OriginalModule, InjectedModule},
    ]
  }
  ```

  ### Runtime

  Dependencies can be injected at runtime with `inject/2`. This is intended for
  unit testing, but not necessarily limited to it. Runtime mappings will be
  less performant compared to compiled mappings, as each lookup goes through
  a read-optimized ETS table.

  ```elixir
  DI.inject(OriginalModule, InjectedModule)
  ```

  Modules can also be defined directly in a block, which can be helpful if they
  are only needed for certain tests.

  ```elixir
  DI.inject(Port, quote do
    def open(_name, _opts), do: self()

    def close(_port), do: :ok

    def command(_port, _data), do: :ok
  end)
  ```
  """

  defmacro __using__(_) do
    quote do
      @doc false
      defdelegate di(module), to: DI
    end
  end

  @compile? !!Application.compile_env(:resolve, :di, [])[:compile]

  @mappings \
    Application.compile_env(:resolve, :di, %{})
    |> Map.get(:mappings, [])
    |> Enum.into(%{})

  @doc """
  Flag a module as eligible for dependency injection.

  Defaults to `module` unless a new dependency is injected in its place.
  """
  @spec di(module :: module) :: module
  def di(module), do: di(module, @compile?)

  defp di(module, _compile? = true) do
    @mappings[module] || module
  end

  defp di(module, _compile? = false) do
    ensure_ets_is_running()

    case :ets.lookup(:di, module) do
      []                     -> @mappings[module] || module
      [{_, injected_module}] -> injected_module
    end
  end

  @doc """
  Inject a module in place of another one.
  """
  @spec inject(target_module :: module, injected_module :: module) :: any
  def inject(target_module, injected_module) when is_atom(injected_module) do
    ensure_ets_is_running()

    :ets.insert(:di, {target_module, injected_module})

    :ok
  end

  def inject(target_module, module_body) do
    unique_number = System.unique_integer([:positive])

    {:module, injected_module, _, _} =
      Module.create(:"Mock#{unique_number}", module_body, Macro.Env.location(__ENV__))

    inject(target_module, injected_module)
  end

  @doc """
  Revert this dependency to the original module.

  This function is idempotent and will not fail if DI already points to the
  original module.
  """
  @spec revert(module :: module) :: any
  def revert(module) do
    ensure_ets_is_running()

    :ets.delete(:di, module)

    :ok
  end

  defp ensure_ets_is_running do
    case :ets.whereis(:di) do
      :undefined -> :ets.new(:di, [:public, :named_table, read_concurrency: true])
      table_id   -> table_id
    end
  end
end