Cooper.Dotenv (Cooper v0.5.2)

Copy Markdown View Source

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. .env
  2. .env.<env> -- <env> is the explicit :dotenv_env option, else COOPER_ENV (see below) as the real environment, :env and the base .env file set it -- so .env.dev, .env.staging, .env.test and .env.prod in every Cooper, matching env/${COOPER_ENV}.casc. Picking it by the host's own name, as this once did, read .env.development beside documents that read dev.
  3. .env.local
  4. System.get_env/0 -- the real environment outranks every file.
  5. the :env option, if the caller passed one -- always the final, highest-precedence override, on top of every layer above it.

The real environment wins over .env files. A deployment sets variables in the environment it controls; a file sitting in the working directory must not silently beat them. This also matches what dotenv implementations in other ecosystems do by default -- Ruby's and Node's both decline to overwrite an already-set variable -- and what twelve-factor configuration expects.

Pass dotenv_override: true for the opposite order, where the files outrank the real environment. That is occasionally what a developer wants locally, to shadow something exported in their shell, but it is a deliberate choice rather than the default.

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 :dotenv_dir, which defaults to the project root -- 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, or where the application was started from. The project root is the directory of the running Mix project's mix.exs; in a release, which has no Mix, RELEASE_ROOT; else the working directory. An absolute path in :dotenv_files is used as it is.

: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.

COOPER_ENV

The one name a document reads the current environment by, in every Cooper implementation (CASC.md §7.2): import "env/${COOPER_ENV}.casc". Every layer above is consulted first, so a real COOPER_ENV -- in the environment, a .env file, or :env -- always wins. Unset or empty, it falls back to this host's own name for the same thing: MIX_ENV from those same layers, else the live Mix.env/0 (Mix does not export MIX_ENV to the OS environment), else -- in a release, which has no Mix -- Application.compile_env(:cooper, :dotenv_env) if the host app set config :cooper, dotenv_env: config_env(), else "dev". A fallback value is mapped onto the names every Cooper uses -- development and local become dev, testing becomes test, production becomes prod, anything else is kept -- so a document selects env/prod.casc the same way whichever host's convention set the variable. A real COOPER_ENV is used exactly as written.

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. :dotenvy is an ordinary dependency of Cooper: it was an optional one, and the default then silently read no .env file in any application that had not added it itself.

Summary

Functions

Resolves the final env map for opts (the same options load_file/2/ load_string/2 take): the .env files layered per the moduledoc above unless disabled, then System.get_env/0, 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()],
  dotenv_dir: String.t(),
  dotenv_override: boolean()
]

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): the .env files layered per the moduledoc above unless disabled, then System.get_env/0, with :env (if given) applied last as the final override.

Pass dotenv_override: true to put the files above the real environment instead.