lib/planck/headless/config/json_binding.ex

defmodule Planck.Headless.Config.JsonBinding do
  @moduledoc false

  # Skogsra binding that reads ~/.planck/config.json and .planck/config.json.
  # Later files win (project-local overrides user-global).
  # The merged map is cached in persistent_term after the first resolution.
  #
  # Set `config :planck_headless, :skip_json_config, true` in test envs to
  # skip this binding entirely: `init/1` returns `:error` so Skogsra bypasses
  # the binding without emitting `:not_found` warnings for every key.
  #
  # Call invalidate/0 before ResourceStore.reload/0 to pick up file changes.

  use Skogsra.Binding

  alias Planck.Headless.Config

  require Logger

  @cache_key {__MODULE__, :config}

  @impl true
  def init(_env) do
    if Application.get_env(:planck_headless, :skip_json_config, false),
      do: :error,
      else: {:ok, cached_config()}
  end

  @impl true
  def get_env(env, config) do
    key = env.keys |> List.last() |> to_string()

    case Map.fetch(config, key) do
      {:ok, value} -> {:ok, value}
      :error -> {:error, :not_found}
    end
  end

  @doc "Clear the JSON config cache so the next resolution reloads from disk."
  @spec invalidate() :: :ok
  def invalidate do
    :persistent_term.erase(@cache_key)
    :ok
  end

  # ---------------------------------------------------------------------------
  # Private
  # ---------------------------------------------------------------------------

  @spec cached_config() :: map()
  defp cached_config do
    case :persistent_term.get(@cache_key, :miss) do
      :miss ->
        config = load_files()
        :persistent_term.put(@cache_key, config)
        config

      config ->
        config
    end
  end

  @spec load_files() :: map()
  defp load_files do
    Enum.reduce(Config.config_files!(), %{}, 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) do
    with {:ok, content} <- read_file(Path.expand(path)) do
      decode_json(content, path)
    end
  end

  @spec read_file(Path.t()) :: {:ok, String.t()} | :skip
  defp read_file(path) do
    case File.read(path) do
      {:ok, content} ->
        {:ok, content}

      {:error, :enoent} ->
        :skip

      {:error, reason} ->
        Logger.warning(
          "[Planck.Headless.Config] cannot read #{path}: #{:file.format_error(reason)}"
        )

        :skip
    end
  end

  @spec decode_json(String.t(), Path.t()) :: {:ok, map()} | :skip
  defp decode_json(content, path) do
    case Jason.decode(content) do
      {:ok, map} when is_map(map) ->
        {:ok, map}

      {:ok, _other} ->
        Logger.warning("[Planck.Headless.Config] #{path} must be a JSON object, skipping")
        :skip

      {:error, err} ->
        Logger.warning(
          "[Planck.Headless.Config] invalid JSON in #{path}: #{Exception.message(err)}"
        )

        :skip
    end
  end
end