Skip to main content

lib/rulestead/config.ex

defmodule Rulestead.Config do
  @moduledoc """
  Validated host-app seam configuration for Rulestead.

  `Rulestead.Config` is stable in the 1.x contract (`api_stability.md` —
  "Stable rulestead Modules"). It owns the validated configuration schema for
  the Plug, LiveView, Oban, Runtime, and Tenancy integration points.

  ## Configuration

  Rulestead reads its configuration from `Application.get_env(:rulestead, :host)`.
  All keys are optional — the defaults below are used for any key not supplied.

  ```elixir
  # config/config.exs
  config :rulestead, :host,
    environment_key: "production",
    plug: [
      context_assign: :rulestead_context,
      targeting_key_sources: [
        {:session, "targeting_key"},
        {:cookie, "rulestead_targeting_key"},
        {:header, "x-rulestead-targeting-key"}
      ]
    ],
    runtime: [
      api: Rulestead.Runtime,
      pubsub: MyApp.PubSub
    ]
  ```

  ## Public API

  - `validate/1` — validate a keyword list, returning `{:ok, config}` or `{:error, reason}`
  - `validate!/1` — validate a keyword list, raising on invalid input
  - `load/1` — load from application env, merge overrides, and validate; raises on invalid config
  - `defaults/0` — return the compiled default configuration keyword list
  - `schema/0` — return the raw NimbleOptions schema keyword list

  ## Defaults

  | Key | Default |
  |-----|---------|
  | `environment_key` | `"dev"` |
  | `plug.context_assign` | `:rulestead_context` |
  | `live_view.assign_flags_mode` | `:enabled` |
  | `oban.enabled` | `true` |
  | `runtime.notifier` | `Rulestead.Runtime.Notifier.PhoenixPubSub` |
  | `tenancy.module` | `Rulestead.Tenancy.SingleTenant` |

  The full default set is returned by `defaults/0`.
  """

  # Validated Phase 5 host-app seam configuration.
  #
  # This schema owns the explicit defaults for the Plug, LiveView, and Oban
  # integration points added in Phase 5, along with the runtime facade module the
  # generated host code is expected to target.

  @defaults [
    environment_key: "dev",
    plug: [
      context_assign: :rulestead_context,
      targeting_key_sources: [
        {:session, "targeting_key"},
        {:cookie, "rulestead_targeting_key"},
        {:header, "x-rulestead-targeting-key"}
      ]
    ],
    live_view: [
      context_assign: :rulestead_context,
      targeting_key_sources: [
        {:session, "targeting_key"},
        {:assign, :targeting_key}
      ],
      assign_flags_mode: :enabled
    ],
    oban: [
      enabled: true,
      context_key: "rulestead_context",
      middlewares: [{Rulestead.Oban.Middleware, []}]
    ],
    runtime: [
      api: Rulestead.Runtime,
      notifier: Rulestead.Runtime.Notifier.PhoenixPubSub,
      health_peer_provider: nil,
      pubsub: nil,
      pubsub_topic: "rulestead:runtime_snapshot"
    ],
    tenancy: [
      module: Rulestead.Tenancy.SingleTenant
    ]
  ]

  @raw_schema [
    environment_key: [
      type: :string,
      required: true
    ],
    plug: [
      type: :keyword_list,
      required: true,
      keys: [
        context_assign: [type: :atom, required: true],
        targeting_key_sources: [type: {:list, :any}, required: true]
      ]
    ],
    live_view: [
      type: :keyword_list,
      required: true,
      keys: [
        context_assign: [type: :atom, required: true],
        targeting_key_sources: [type: {:list, :any}, required: true],
        assign_flags_mode: [type: {:in, [:enabled, :variant, :value, :evaluate]}, required: true]
      ]
    ],
    oban: [
      type: :keyword_list,
      required: true,
      keys: [
        enabled: [type: :boolean, required: true],
        context_key: [type: :string, required: true],
        middlewares: [type: {:list, :any}, required: true]
      ]
    ],
    runtime: [
      type: :keyword_list,
      required: true,
      keys: [
        api: [type: :atom, required: true],
        notifier: [type: :atom, required: true],
        health_peer_provider: [
          type: {:custom, __MODULE__, :validate_optional_module, []},
          required: false
        ],
        pubsub: [type: {:custom, __MODULE__, :validate_pubsub, []}, required: false],
        pubsub_topic: [type: :string, required: true]
      ]
    ],
    tenancy: [
      type: :keyword_list,
      required: true,
      keys: [
        module: [type: :atom, required: true]
      ]
    ]
  ]

  @compiled_schema NimbleOptions.new!(@raw_schema)

  @type t :: keyword() | map()

  @spec schema() :: keyword()
  def schema, do: @raw_schema

  @spec defaults() :: keyword()
  def defaults, do: @defaults

  @spec validate(keyword()) :: {:ok, t()} | {:error, NimbleOptions.ValidationError.t()}
  def validate(opts \\ []) when is_list(opts) do
    defaults()
    |> merge(opts)
    |> NimbleOptions.validate(@compiled_schema)
  end

  @spec validate!(keyword()) :: t()
  def validate!(opts \\ []) when is_list(opts) do
    defaults()
    |> merge(opts)
    |> NimbleOptions.validate!(@compiled_schema)
  end

  @spec load(keyword()) :: t()
  def load(overrides \\ []) when is_list(overrides) do
    app_config =
      Application.get_env(:rulestead, :host, [])
      |> normalize_keyword()

    defaults()
    |> merge(app_config)
    |> merge(overrides)
    |> NimbleOptions.validate!(@compiled_schema)
  end

  defp merge(base, overrides) do
    Keyword.merge(base, normalize_keyword(overrides), fn _key, left, right ->
      if Keyword.keyword?(left) and Keyword.keyword?(right) do
        merge(left, right)
      else
        right
      end
    end)
  end

  def validate_pubsub(nil), do: {:ok, nil}
  def validate_pubsub(pubsub) when is_atom(pubsub), do: {:ok, pubsub}
  def validate_pubsub(_pubsub), do: {:error, "expected nil or a module atom"}

  def validate_optional_module(nil), do: {:ok, nil}
  def validate_optional_module(module) when is_atom(module), do: {:ok, module}
  def validate_optional_module(_module), do: {:error, "expected nil or a module atom"}

  defp normalize_keyword(value) when is_list(value), do: value
  defp normalize_keyword(_value), do: []
end