lib/bylaw/ecto/query/check_options.ex

defmodule Bylaw.Ecto.Query.CheckOptions do
  @moduledoc false

  # `label` distinguishes top-level Bylaw option lists from nested check option
  # lists in exception messages.
  @spec keyword_list!(term(), String.t()) :: keyword()
  def keyword_list!(opts, label) when is_list(opts) do
    if Keyword.keyword?(opts) do
      opts
    else
      raise ArgumentError, "expected #{label} to be a keyword list, got: #{inspect(opts)}"
    end
  end

  def keyword_list!(opts, label) do
    raise ArgumentError, "expected #{label} to be a keyword list, got: #{inspect(opts)}"
  end

  # Most checks can reject unknown options centrally. Checks with more detailed
  # option validation pass `:any` and validate their own shape.
  @spec normalize!(term(), :any | list(atom())) :: keyword()
  def normalize!(opts, allowed_keys) when is_list(opts) do
    if Keyword.keyword?(opts) do
      validate_allowed_keys!(opts, allowed_keys)
      opts
    else
      raise ArgumentError, "expected opts to be a keyword list, got: #{inspect(opts)}"
    end
  end

  def normalize!(opts, _allowed_keys) do
    raise ArgumentError, "expected opts to be a keyword list, got: #{inspect(opts)}"
  end

  # Only explicit `validate: false` disables a check; missing or truthy values
  # keep validation enabled.
  @spec enabled?(keyword()) :: boolean()
  def enabled?(opts), do: Keyword.get(opts, :validate, true) != false

  # Configured checks use `:any` by default and may opt into `:all` when every
  # configured key must match. Any other value is treated as invalid config.
  @spec match!(keyword()) :: :any | :all
  def match!(opts) do
    case Keyword.get(opts, :match, :any) do
      match when match in [:any, :all] ->
        match

      match ->
        raise ArgumentError, "expected :match to be :any or :all, got: #{inspect(match)}"
    end
  end

  # Required field/key options must be non-empty so a configured check cannot
  # silently become a no-op through ambiguous empty input.
  @spec fetch_non_empty_atoms!(keyword(), atom()) :: list(atom())
  def fetch_non_empty_atoms!(opts, key) do
    case Keyword.fetch(opts, key) do
      {:ok, value} ->
        non_empty_atoms!(value, key)

      :error ->
        raise ArgumentError, "missing required #{inspect(key)} option"
    end
  end

  # Shared validator for `:keys` and `:fields` style options. Empty lists,
  # non-lists, and non-atom members are all configuration errors.
  @spec non_empty_atoms!(term(), atom()) :: list(atom())
  def non_empty_atoms!([], key) do
    raise ArgumentError,
          "expected #{inspect(key)} to be a non-empty list of atoms, got: []"
  end

  def non_empty_atoms!(values, key) when is_list(values), do: Enum.map(values, &atom!(&1, key))

  def non_empty_atoms!(values, key) do
    raise ArgumentError,
          "expected #{inspect(key)} to be a non-empty list of atoms, got: #{inspect(values)}"
  end

  # Pass `:any` when a check validates its own option keys; otherwise reject
  # unknown keys early so typos do not silently disable enforcement.
  @spec validate_allowed_keys!(keyword(), :any | list(atom())) :: :ok
  def validate_allowed_keys!(_opts, :any), do: :ok

  def validate_allowed_keys!(opts, allowed_keys) do
    Enum.each(opts, fn {key, _value} ->
      if key not in allowed_keys do
        raise ArgumentError, "unknown option: #{inspect(key)}"
      end
    end)
  end

  defp atom!(value, _key) when is_atom(value), do: value

  defp atom!(value, key) do
    raise ArgumentError,
          "expected #{inspect(key)} to contain only atoms, got: #{inspect(value)}"
  end
end