lib/enux.ex

defmodule Enux do
  @moduledoc """
  utility package for loading, validating and documenting your app's configuration variables from env, json, jsonc and toml files at runtime and injecting them into your environment


  ## Installation

  ```
  defp deps do
    [
      {:enux, "~> 1.3"},

      # if you want to load `.jsonc` files, you should have this
      # you can also use this for `.json` files
      {:jsonc, "~> 0.8"},

      # if you want to load `.json` files, you should have either this
      {:json, "~> 1.4"}
      # or this
      {:jason, "~> 1.3"}
      # or this
      {:jaxon, "~> 2.0"}
      # or this
      {:thoas, "~> 0.2"}
      # or this
      {:jsone, "~> 1.7"}
      # or this
      {:jiffy, "~> 1.1"}
      # or this
      {:poison, "~> 5.0"}

      # if you want to load `.toml` files, you should have either this
      {:toml, "~> 0.6"}
      # or this
      {:tomerl, "~> 0.5"}
      # or this
      {:tomlex, "~> 0.0"}
    ]
  end
  ```

  ## Usage

  In elixir 1.11, `config/runtime.exs` was introduced. This is a file that is executed exactly before your application starts.
  This is a proper place to load any configuration variables into your app. If this file does not exist in your project directory,
  create it and add these lines to it:
  ```
  import Config
  env = Enux.load()
  config :otp_app, env
  ```
  When you start your application, you can access your configuration variables using `Applicatoin.get_env`.
  If you need to url encode your configuration values, just pass `url_encoded: true` to `Enux.load`.

  You should have either [poison](https://hex.pm/packages/poison) or [jason](https://hex.pm/packages/jason) or [jaxon](https://hex.pm/packages/jaxon)
  or [thoas](https://hex.pm/packages/thoas) or [jsone](https://hex.pm/packages/jsone) or [jiffy](https://hex.pm/packages/jiffy) or [json](https://hex.pm/packages/json)
  in your dependencies if you want to use `.json` files.

  To use `.jsonc` files, you should have [jsonc](https://hex.pm/packages/jsonc). You can also use this package for `.json` files.
  To use `.toml` files, you should have either [toml](https://hex.pm/packages/toml) or [tomerl](https://hex.pm/packages/tomerl) or [tomlex](https://hex.pm/packages/tomlex).

  You can load multiple files of different kinds:
  ```
  import Config

  env1 = Enux.load("config/one.env", url_encoded: true)
  config :otp_app, env1

  env2 = Enux.load("config/two.json")
  config :otp_app, :two, env2
  ```

  ### automatic loading

  Another way of using Enux is using the `Enux.autoload` function which will load all `.env`, `.json`, `.jsonc` and `.toml` files in your `config` directory.
  it makes more sense to call this function in your `config/runtime.exs` but you can call it anywhere in your code.

  If you have `config/pg.env` and `config/redis.json` in your project directory, after calling `Enux.autoload(:otp_app)`, you can access the variables
  using `Application.get_env(:otp_app, :pg)` and `Application.get_env(:otp_app, :redis)`. if a file is named `.env` or `.json` or `.jsonc` or `.toml`, you should use
  `Application.get_env(:otp_app, :env)` or `Application.get_env(:otp_app, :json)` or `Application.get_env(:otp_app, :jsonc)` or `Application.get_env(:otp_app, :toml)` respectively.
  ```
  Enux.autoload(:otp_app)
  ```

  ### multiple environments

  Using the `MIX_ENV` environmental variable you can adjust which files `Enux.autoload` loads into your app. If `MIX_ENV` is not specified, `dev` will be assumed.
  The only thing you need to do is specifying the environment in the name of each file like `db-staging.env`, `redis-prod.jsonc` or `rabbitmq-unit-tests.toml`.
  But after the file is loaded, you can access the variables using e.g. `Application.get_env(:otp_app, :db) or Application.get_env(:otp_app, :redis)` or
  `Application.get_env(:otp_app, :rabbitmq)`.
  If a file doesn't have `-` in its name, `Enux.autoload` will load it regardless of the value of `MIX_ENV`.

  ### environment validation

  You may also use `Enux.expect` to both validate and document your required environment. first you need to define a schema:
  ```
  schema = [
    id: [&is_integer/1, fn id -> id > 1000 end],
    username: [&is_binary/1, fn u -> String.length(u) > 8 end],
    metadata: [],
    profile: [
      full_name: [&is_binary/1],
      age: [&is_number/1]
    ]
  ]
  ```
  then the following line will check for compliance of your environment under `:otp_app` and `:key` with the schema defined above
  (an empty list implies only checking for existence):
  ```
  Enux.expect(:otp_app, :key, schema)
  ```
  """

  alias Enux.Env
  alias Enux.Json
  alias Enux.Jsonc
  alias Enux.Toml

  @doc """
  reads the variables in `config/.env` and returns a formatted keyword list.
  all values are loaded as they are.
  """
  def load() do
    File.stream!("config/.env", [], :line) |> Env.decode([])
  end

  @doc """
  reads the variables in `config/.env` and returns a formatted keyword list
  """
  def load(opts) when is_list(opts) do
    File.stream!("config/.env", [], :line) |> Env.decode(opts)
  end

  @doc """
  reads the variables in the given path(could be `.env`, `.json`, `.jsonc` or `.toml` file) and returns a formatted keyword list
  """
  def load(path, opts \\ []) when is_binary(path) and is_list(opts) do
    case String.split(path, ".") |> Enum.at(1) |> String.to_atom() do
      :env -> File.stream!(path, [], :line) |> Env.decode(opts)
      :json -> File.read!(path) |> Json.decode(opts)
      :jsonc -> File.read!(path) |> Jsonc.decode(opts)
      :toml -> File.read!(path) |> Toml.decode(opts)
      ext -> raise "unsupported file type: #{ext}"
    end
  end

  @doc """
  automatically loads all `.env`, `.json`, `.jsonc` and `.toml` files in your `config` directory.
  pass your project's name as an atom. you can also still pass `url_encoded: true` to it.
  """
  def autoload(app, opts \\ []) when is_atom(app) and is_list(opts) do
    files =
      File.ls!("config")
      |> Enum.map(fn f -> f |> String.split(".") end)
      |> Enum.filter(fn [_, ext] -> ext in ["env", "json", "jsonc", "toml"] end)
      |> Enum.filter(fn [filename, _] ->
        mix_env = System.get_env("MIX_ENV", "dev")

        case String.split(filename, "-") do
          [_] -> true
          [_, env] when env == mix_env -> true
          [_ | env_parts] -> Enum.join(env_parts, "-") == mix_env
        end
      end)
      |> Enum.map(fn f -> Enum.join(f, ".") end)

    cond do
      Enum.empty?(files) ->
        raise "There is no `.env`, `.json`, `.jsonc` or `.toml` file in your config directory"

      true ->
        files
        |> Enum.map(fn f -> [f, Enux.load("config/#{f}", opts)] end)
        |> Enum.map(fn [f, kwl] ->
          case f do
            ".env" ->
              ["env", kwl]

            ".json" ->
              ["json", kwl]

            ".jsonc" ->
              ["jsonc", kwl]

            ".toml" ->
              ["toml", kwl]

            _ ->
              [String.split(f, ".") |> Enum.at(0), kwl]
          end
        end)
        |> Enum.map(fn [key, kwl] ->
          case String.split(key, "-") do
            [key] -> [key, kwl]
            [key, _] -> [key, kwl]
            [key | _] -> [key, kwl]
          end
        end)
        |> Enum.each(fn [key, kwl] ->
          Application.put_env(app, key |> String.to_atom(), kwl)
        end)
    end
  end

  @doc """
  checks if the environment variables under `app` and `key` comply with the given `schema`. any non-compliance results in an error.
  you can use this function for both validating and documenting your required environment.
  """
  def expect(app, key, schema) when is_atom(app) and is_atom(key) and is_list(schema) do
    case Application.get_env(app, key) do
      nil ->
        raise "environment with key #{key} does not exist"

      env ->
        cond do
          Keyword.keyword?(env) ->
            if !Keyword.keyword?(schema) do
              raise "schema should be a keyword list"
            end

            check(env, schema)

          true ->
            check_item(env, schema, [])
        end
    end
  end

  defp check(env, schema, parents \\ [])
       when is_list(env) and is_list(schema) and is_list(parents) do
    schema
    |> Enum.each(fn {key, sub_schema} ->
      case env |> Keyword.get(key) do
        nil ->
          raise "your environment should contain #{parents |> Enum.reverse() |> Enum.join(".")}.#{key}"

        value ->
          cond do
            Keyword.keyword?(value) ->
              check(value, sub_schema, [key | parents])

            true ->
              check_item(value, sub_schema, [key | parents])
          end
      end
    end)
  end

  defp check_item(value, conditions, parents)
       when is_list(conditions) and is_list(parents) do
    conditions
    |> Enum.each(fn c ->
      case check_item(value, c) do
        false ->
          raise "condition #{inspect(c)} was not met for #{parents |> Enum.reverse() |> Enum.join(".")}"

        true ->
          nil
      end
    end)
  end

  defp check_item(value, condition) when is_function(condition) do
    case condition.(value) do
      result when is_boolean(result) ->
        result

      _ ->
        raise "function #{inspect(condition)} does not return a boolean"
    end
  end
end