defmodule Planck.Headless.Config.EnvBinding do @moduledoc false # Skogsra binding that reads ~/.planck/.env and ./.planck/.env. # The project-local file takes precedence over the user-global file. # The merged map is cached in persistent_term after the first resolution. # # Set `config :planck_headless, :skip_env_config, true` in test envs to # skip this binding entirely. # # Call invalidate/0 to bust the cache when files change on disk. use Skogsra.Binding alias Planck.Headless.Config require Logger @cache_key {__MODULE__, :env} @impl true def init(_env) do if Application.get_env(:planck_headless, :skip_env_config, false), do: :error, else: {:ok, cached_env()} end @impl true def get_env(env, dotenv_map) do key = Keyword.get(env.options, :os_env) || env.keys |> List.last() |> to_string() |> String.upcase() case Map.fetch(dotenv_map, key) do {:ok, value} -> {:ok, value} :error -> {:error, :not_found} end end @doc "Clear the .env cache so the next resolution reloads from disk." @spec invalidate() :: :ok def invalidate do :persistent_term.erase(@cache_key) :ok end # --------------------------------------------------------------------------- # Private # --------------------------------------------------------------------------- @spec cached_env() :: map() defp cached_env do case :persistent_term.get(@cache_key, :miss) do :miss -> env = load_files() :persistent_term.put(@cache_key, env) env env -> env end end @spec load_files() :: map() defp load_files do Config.env_files!() |> Enum.reduce(%{}, fn path, acc -> case load_file(path) do {:ok, map} -> Map.merge(acc, map) :skip -> acc end end) end @spec load_file(Path.t()) :: {:ok, map()} | :skip defp load_file(path) defp load_file(path) when is_binary(path) do path |> Path.expand() |> File.read() |> case do {:ok, content} -> {:ok, parse(content)} {:error, :enoent} -> :skip {:error, reason} -> Logger.warning( "[Planck.Headless.Config] cannot read #{path}: #{:file.format_error(reason)}" ) :skip end end @spec parse(String.t()) :: map() defp parse(content) defp parse(content) when is_binary(content) do content |> String.split("\n") |> Stream.flat_map(&parse_line/1) |> Map.new() end @spec parse_line(String.t()) :: [{String.t(), String.t()}] defp parse_line(line) defp parse_line(line) when is_binary(line) do line = String.trim(line) with false <- line == "" or String.starts_with?(line, "#"), [key, value] <- String.split(line, "=", parts: 2) do key = String.trim(key) value = value |> String.trim() |> strip_quotes() [{key, value}] else _ -> [] end end @spec strip_quotes(String.t()) :: String.t() defp strip_quotes(value) defp strip_quotes(<<"\"", rest::binary>>), do: String.trim_trailing(rest, "\"") defp strip_quotes(<<"'", rest::binary>>), do: String.trim_trailing(rest, "'") defp strip_quotes(value), do: value end