Skip to main content

lib/jido_memory/capability_set.ex

defmodule Jido.Memory.CapabilitySet do
  @moduledoc """
  Normalized capability descriptor for a memory provider.
  """

  alias Jido.Memory.{Capabilities, ProviderRegistry}

  @schema Zoi.struct(
            __MODULE__,
            %{
              provider: Zoi.atom(description: "Concrete provider module or alias") |> Zoi.optional(),
              key: Zoi.atom(description: "Canonical provider key") |> Zoi.optional(),
              capabilities:
                Zoi.list(Zoi.atom(), description: "Supported capability atoms")
                |> Zoi.default([]),
              descriptor:
                Zoi.map(description: "Structured capability descriptor")
                |> Zoi.default(Capabilities.default()),
              metadata: Zoi.map(description: "Additional capability metadata") |> Zoi.default(%{})
            },
            coerce: true
          )

  @type t :: unquote(Zoi.type_spec(@schema))

  @enforce_keys Zoi.Struct.enforce_keys(@schema)
  defstruct Zoi.Struct.struct_fields(@schema)

  @doc "Returns the capability set schema."
  @spec schema() :: Zoi.schema()
  def schema, do: @schema

  @doc "Builds and normalizes a capability set."
  @spec new(map() | keyword()) :: {:ok, t()} | {:error, term()}
  def new(attrs) when is_list(attrs), do: new(Map.new(attrs))

  def new(attrs) when is_map(attrs) do
    provider = get_attr(attrs, :provider)
    key = get_attr(attrs, :key, get_attr(attrs, :provider_key))
    raw_capabilities = get_attr(attrs, :capabilities, [])
    raw_descriptor = get_attr(attrs, :descriptor, get_attr(attrs, :capability_descriptor))

    {capability_input, descriptor_input} =
      case {raw_capabilities, raw_descriptor} do
        {%{} = descriptor, nil} -> {[], descriptor}
        _ -> {raw_capabilities, raw_descriptor}
      end

    with {:ok, provider} <- normalize_provider(provider),
         {:ok, key} <- normalize_key(key, provider),
         {:ok, capabilities} <- normalize_capabilities_input(capability_input),
         {:ok, descriptor} <- normalize_descriptor_input(descriptor_input, capabilities),
         {:ok, capabilities} <- merge_capabilities(capabilities, descriptor),
         {:ok, metadata} <- normalize_metadata(get_attr(attrs, :metadata, %{})) do
      {:ok,
       struct!(__MODULE__, %{
         provider: provider,
         key: key,
         capabilities: capabilities,
         descriptor: descriptor,
         metadata: metadata
       })}
    end
  end

  def new(_attrs), do: {:error, :invalid_capability_set}

  @doc "Builds and normalizes a capability set, raising on error."
  @spec new!(map() | keyword()) :: t()
  def new!(attrs) do
    case new(attrs) do
      {:ok, value} -> value
      {:error, reason} -> raise ArgumentError, "invalid capability set: #{inspect(reason)}"
    end
  end

  @doc "Returns true when the capability set includes the given capability."
  @spec supports?(t(), atom() | [atom()]) :: boolean()
  def supports?(%__MODULE__{capabilities: capabilities}, capability) when is_atom(capability) do
    capability in capabilities
  end

  def supports?(%__MODULE__{descriptor: descriptor}, capability_path) when is_list(capability_path) do
    Capabilities.supported?(descriptor, capability_path)
  end

  @doc "Returns a structured capability value by nested path."
  @spec get(t(), [atom()]) :: term()
  def get(%__MODULE__{descriptor: descriptor}, capability_path) when is_list(capability_path) do
    Capabilities.get(descriptor, capability_path)
  end

  defp normalize_provider(nil), do: {:ok, nil}
  defp normalize_provider(provider) when is_atom(provider), do: {:ok, provider}
  defp normalize_provider(other), do: {:error, {:invalid_provider, other}}

  defp normalize_key(nil, provider), do: {:ok, ProviderRegistry.key_for(provider)}
  defp normalize_key(key, _provider) when is_atom(key), do: {:ok, key}
  defp normalize_key(other, _provider), do: {:error, {:invalid_provider_key, other}}

  defp normalize_capabilities_input(values) when is_list(values) do
    values
    |> Enum.reduce_while([], fn
      value, acc when is_atom(value) ->
        if value in acc, do: {:cont, acc}, else: {:cont, [value | acc]}

      other, _acc ->
        {:halt, {:error, {:invalid_capability, other}}}
    end)
    |> case do
      {:error, _} = error -> error
      normalized -> {:ok, Enum.reverse(normalized)}
    end
  end

  defp normalize_capabilities_input(%{}), do: {:ok, []}
  defp normalize_capabilities_input(nil), do: {:ok, []}
  defp normalize_capabilities_input(other), do: {:error, {:invalid_capabilities, other}}

  defp normalize_descriptor_input(nil, capabilities) do
    {:ok, Capabilities.from_flat_list(capabilities)}
  end

  defp normalize_descriptor_input(%{} = descriptor, capabilities) do
    descriptor =
      case capabilities do
        [] -> descriptor
        _ -> Map.merge(Capabilities.from_flat_list(capabilities), descriptor)
      end

    {:ok, Capabilities.normalize(descriptor)}
  end

  defp normalize_descriptor_input(other, _capabilities), do: {:error, {:invalid_capability_descriptor, other}}

  defp merge_capabilities(capabilities, descriptor) when is_list(capabilities) and is_map(descriptor) do
    merged =
      case capabilities do
        [] -> Capabilities.flatten_supported(descriptor)
        values -> values
      end

    {:ok, Enum.uniq(merged)}
  end

  defp normalize_metadata(%{} = metadata), do: {:ok, metadata}
  defp normalize_metadata(nil), do: {:ok, %{}}
  defp normalize_metadata(other), do: {:error, {:invalid_capability_metadata, other}}

  defp get_attr(map, key, default \\ nil) do
    Map.get(map, key, Map.get(map, Atom.to_string(key), default))
  end
end