lib/provider.ex

defmodule Toml.Provider do
  @moduledoc """
  This module provides an implementation of both the Distilery and Elixir
  config provider behaviours, so that TOML files can be used for configuration
  in releases.

  ## Distillery Usage

  Add the following to your `rel/config.exs`

      release :myapp do
        # ...snip...
        set config_providers: [
          {Toml.Provider, [path: "${XDG_CONFIG_DIR}/myapp.toml", transforms: [...]]}
        ]
      end

  ## Elixir Usage

      config_providers: [
        {Toml.Provider, [
          path: {:system, "XDG_CONFIG_DIR", "myapp.toml"},
          transforms: [...]
        ]}
      ]

  This will result in `Toml.Provider` being invoked during boot, at which point it
  will evaluate the given path and read the TOML file it finds. If one is not
  found, or is not accessible, the provider will raise an error, and the boot
  sequence will terminate unsuccessfully. If it succeeds, it persists settings in
  the file to the application environment (i.e. you access it via
  `Application.get_env/2`).

  The config provider expects a certain format to the TOML file, namely that
  keys at the root of the document are tables which correspond to applications
  which need to be configured. If it encounters keys at the root of the document
  which are not tables, they are ignored.

  ## Options

  The same options that `Toml.parse/2` accepts are able to be provided to `Toml.Provider`,
  but there are two main differences:

    * `:path` (required) - sets the path to the TOML file to load config from
    * `:keys` - defaults to `:atoms`, but can be set to `:atoms!` if desired, all other
      key types are ignored, as it results in an invalid config structure

  """
  has_config_api? = Version.match?(Version.parse!(System.version()), ">= 1.9.0")

  if has_config_api? do
    @behaviour Config.Provider
  end

  @doc false
  def init(opts) when is_list(opts) do
    opts =
      case Keyword.get(opts, :keys) do
        a when a in [:atoms, :atoms!] ->
          opts

        _ ->
          Keyword.put(opts, :keys, :atoms)
      end

    if is_distillery_env?() do
      # When running under Distillery, init performs load
      load([], opts)
      opts
    else
      # With 1.9 releases, init just preps arguments for `load`
      opts
    end
  end

  @doc false
  def load(config, opts) when is_list(opts) do
    path = Keyword.fetch!(opts, :path)
    # path expansion should happen in load rather than init, otherwise
    # in 1.9 releases using a {:system, env_var, path} tuple an error
    # will be raised if the env var is not set in the build environment
    with {:ok, path} <- expand_path(path) do
      map = Toml.decode_file!(path, opts)
      persist(config, to_keyword(map))
    else
      {:error, reason} ->
        exit(reason)
    end
  end

  @doc false
  def get([app | keypath]) do
    config = Application.get_all_env(app)

    case get_in(config, keypath) do
      nil ->
        nil

      val ->
        {:ok, val}
    end
  end

  if has_config_api? do
    defp persist(config, keyword) when is_list(keyword) do
      config = Config.Reader.merge(config, keyword)
      Application.put_all_env(config, persistent: true)
      config
    end
  else
    defp persist(config, keyword) when is_list(keyword) do
      # For each app
      for {app, app_config} <- keyword do
        # Get base config
        base = Application.get_all_env(app)
        base = deep_merge(base, Keyword.get(config, app, []))
        # Merge this app's TOML config over the base config
        merged = deep_merge(base, app_config)
        # Persist key/value pairs for this app
        for {k, v} <- merged do
          Application.put_env(app, k, v, persistent: true)
        end

        # Return merged config
        {app, merged}
      end
    end

    defp deep_merge(a, b) when is_list(a) and is_list(b) do
      if Keyword.keyword?(a) and Keyword.keyword?(b) do
        Keyword.merge(a, b, &deep_merge/3)
      else
        b
      end
    end

    defp deep_merge(_k, a, b) when is_list(a) and is_list(b) do
      if Keyword.keyword?(a) and Keyword.keyword?(b) do
        Keyword.merge(a, b, &deep_merge/3)
      else
        b
      end
    end

    defp deep_merge(_k, a, b) when is_map(a) and is_map(b) do
      Map.merge(a, b, &deep_merge/3)
    end

    defp deep_merge(_k, _a, b), do: b
  end

  # At the top level, convert the map to a keyword list of keyword lists
  # Keys with no children (i.e. keys which are not tables) are dropped
  defp to_keyword(map) when is_map(map) do
    for {k, v} <- map, v2 = to_keyword2(v), is_list(v2), into: [] do
      {k, v2}
    end
  end

  # For all other values, convert tables to keywords
  defp to_keyword2(map) when is_map(map) do
    Enum.map(map, fn {k, v} -> {k, to_keyword2(v)} end)
  end

  # And leave all other values untouched
  defp to_keyword2(term), do: term

  def expand_path(path) when is_binary(path) do
    case expand_path(path, <<>>) do
      {:ok, p} ->
        {:ok, Path.expand(p)}

      {:error, _} = err ->
        err
    end
  end

  if has_config_api? do
    def expand_path(path) do
      {:ok, Config.Provider.resolve_config_path!(path)}
    end
  end

  defp expand_path(<<>>, acc),
    do: {:ok, acc}

  defp expand_path(<<?$, ?\{, rest::binary>>, acc) do
    case expand_var(rest) do
      {:ok, var, rest} ->
        expand_path(rest, acc <> var)

      {:error, _} = err ->
        err
    end
  end

  defp expand_path(<<c::utf8, rest::binary>>, acc) do
    expand_path(rest, <<acc::binary, c::utf8>>)
  end

  defp expand_var(bin),
    do: expand_var(bin, <<>>)

  defp expand_var(<<>>, _acc),
    do: {:error, :unclosed_var_expansion}

  defp expand_var(<<?\}, rest::binary>>, acc),
    do: {:ok, System.get_env(acc) || "", rest}

  defp expand_var(<<c::utf8, rest::binary>>, acc) do
    expand_var(rest, <<acc::binary, c::utf8>>)
  end

  defp is_distillery_env? do
    if Version.match?(Version.parse!(System.version()), ">= 1.9.0") do
      Code.ensure_loaded?(Distillery.Releases.Config.Provider)
    else
      true
    end
  rescue
    _ ->
      case :erlang.phash2(1, 1) do
        0 -> true
        _ -> false
      end
  end
end