Cooper.Dotenv (Cooper v0.2.0)

Copy Markdown

Builds the map Cooper's ${...} resolution (CASC.md §7.2) reads from. Called from Cooper.load_file/2/load_string/2 on every single call, hit or miss, cached or not -- and again by Cooper.Cache's own watch_env poll tick, so a .env file edit is detected the same way a real System.put_env/2 is. Not a separate pipeline stage callers normally reach for directly, but its own module (rather than inlined) because the layering rules below are non-trivial enough to want one place to read, and one place to test, them.

Layering

Five layers, later winning, Dotenvy.source/2's own convention:

  1. System.get_env/0 -- always the floor, whether or not :env is passed.
  2. .env
  3. .env.<env> -- <env>, in order: the explicit :dotenv_env option; else live Mix.env/0 when Mix is loaded (true for mix run/mix test/iex -S mix, false for a compiled OTP release); else Application.compile_env(:cooper, :dotenv_env) -- baked in at the host application's own compile time, the only way left to auto-detect an environment in a release. That last one only fires if the host app opts in with config :cooper, dotenv_env: config_env() in its own config/config.exs -- deliberately not something Cooper can default on its own (see compiled_env/0's own comment for why naively reading Mix.env/0 from inside Cooper's own source could never work here). No match at all leaves <env> (and this whole layer) absent.
  4. .env.local
  5. the :env option, if the caller passed one -- always the final, highest-precedence override, on top of every layer above it.

All of 2-4 are optional -- a missing file is silently skipped, never a load-time error. .env.<env> in particular is expected to be absent for every environment but the current one.

All paths are resolved relative to the current working directory, deliberately not :root (load_file/2's config-file directory) -- .env files live at the project root regardless of where the CASC file being loaded happens to sit.

:env is an override layer, not a replacement. Passing env: %{"FOO" => "bar"} does not isolate resolution from the real environment or .env files -- it guarantees FOO resolves to "bar" specifically, while every other ${...} reference still falls through to .env/.env.local/the real OS environment. There is currently no option that fully replaces every layer below it; a test that needs a ${...} reference to resolve to a specific, guaranteed-deterministic value should give that name an explicit entry in :env rather than relying on it being otherwise unset.

Enabling

.env file loading (layers 2-4) is on by default; dotenv: false disables just those three layers -- System.get_env/0 and an explicit :env still apply either way. If :dotenvy isn't installed: the default-enabled case silently no-ops (same as if no .env files existed), but an explicit dotenv: true with the dependency missing is a load-time error naming it -- asking for it by name and not getting it is a real misconfiguration, not something to paper over.

Summary

Functions

Resolves the final env map for opts (the same options load_file/2/ load_string/2 take): System.get_env/0, .env-layered per the moduledoc above unless disabled, with :env (if given) applied last as the final override.

Types

opts()

@type opts() :: [
  env: %{optional(String.t()) => String.t()},
  dotenv: boolean(),
  dotenv_env: atom() | nil,
  dotenv_files: [String.t()]
]

Functions

env(opts)

@spec env(opts()) ::
  {:ok, %{required(String.t()) => String.t()}} | {:error, Ichor.Error.t()}

Resolves the final env map for opts (the same options load_file/2/ load_string/2 take): System.get_env/0, .env-layered per the moduledoc above unless disabled, with :env (if given) applied last as the final override.