lib/bb/mcp/json_schema.ex

# SPDX-FileCopyrightText: 2026 James Harton
#
# SPDX-License-Identifier: Apache-2.0

defmodule BB.MCP.JsonSchema do
  @moduledoc """
  Converts BB command argument definitions into JSON Schema fragments.

  Used to advertise command argument types to MCP clients via the
  `list_commands` tool, so agents can know what to pass to `invoke_command`.

  Maps Spark `:type` values to JSON Schema types:

      :integer        -> %{"type" => "integer"}
      :float, :number -> %{"type" => "number"}
      :boolean        -> %{"type" => "boolean"}
      :string         -> %{"type" => "string"}
      :atom           -> %{"type" => "string", "description" => "atom"}
      :map, {:map, fields} -> %{"type" => "object"}
      :keyword_list   -> %{"type" => "object"}
      :any            -> %{}
      module          -> %{"description" => "complex type: \#{inspect(module)}"}
  """

  alias BB.Dsl.Command
  alias BB.Dsl.Command.Argument

  @doc """
  Build a JSON Schema object describing a command's arguments.
  """
  @spec for_command(Command.t()) :: map()
  def for_command(%Command{arguments: arguments}) do
    properties =
      arguments
      |> Enum.flat_map(&for_argument_properties/1)
      |> Map.new()

    required =
      arguments
      |> Enum.flat_map(&required_properties/1)

    base = %{"type" => "object", "properties" => properties}
    if required == [], do: base, else: Map.put(base, "required", required)
  end

  @doc """
  Build a JSON Schema fragment for a single command argument, including
  description and default value where present.
  """
  @spec for_argument(Argument.t()) :: map()
  def for_argument(%Argument{type: type, doc: doc, default: default}) do
    for_field(type, doc, default)
  end

  defp for_field(type, doc, default) do
    type
    |> base_schema()
    |> maybe_put("description", doc)
    |> maybe_put("default", default)
  end

  defp base_schema(:integer), do: %{"type" => "integer"}
  defp base_schema(:non_neg_integer), do: %{"type" => "integer", "minimum" => 0}
  defp base_schema(:pos_integer), do: %{"type" => "integer", "minimum" => 1}
  defp base_schema(:float), do: %{"type" => "number"}
  defp base_schema(:number), do: %{"type" => "number"}
  defp base_schema(:boolean), do: %{"type" => "boolean"}
  defp base_schema(:string), do: %{"type" => "string"}
  defp base_schema(:atom), do: %{"type" => "string"}
  defp base_schema(:map), do: %{"type" => "object"}
  defp base_schema({:map, _fields}), do: %{"type" => "object"}
  defp base_schema(:keyword_list), do: %{"type" => "object"}
  defp base_schema({:list, inner}), do: %{"type" => "array", "items" => base_schema(inner)}
  defp base_schema(:any), do: %{}

  defp base_schema(module) when is_atom(module),
    do: %{"description" => "complex type: #{inspect(module)}"}

  defp base_schema(_other), do: %{}

  defp for_argument_properties(%Argument{name: name, type: {:map, fields}, required: required}) do
    flatten_map_fields([Atom.to_string(name)], fields, required)
  end

  defp for_argument_properties(%Argument{name: name, type: :map} = arg) do
    json_name = "#{name}_json"
    json_doc = "JSON object for `#{name}`"
    [{json_name, for_argument(%{arg | type: :string, doc: json_doc})}]
  end

  defp for_argument_properties(%Argument{name: name} = arg) do
    [{Atom.to_string(name), for_argument(arg)}]
  end

  defp required_properties(%Argument{name: name, type: {:map, fields}, required: true}) do
    flatten_required_map_fields([Atom.to_string(name)], fields, true)
  end

  defp required_properties(%Argument{name: name, type: :map, required: true}),
    do: ["#{name}_json"]

  defp required_properties(%Argument{name: name, required: true}), do: [Atom.to_string(name)]
  defp required_properties(_arg), do: []

  defp flatten_map_fields(path, fields, parent_required) when is_list(fields) do
    Enum.flat_map(fields, fn {name, spec} ->
      flatten_map_field(path, name, spec, parent_required)
    end)
  end

  defp flatten_map_field(path, name, spec, parent_required) do
    type = field_type(spec)
    required = field_required?(spec, parent_required)

    case type do
      {:map, fields} ->
        flatten_map_fields(path ++ [Atom.to_string(name)], fields, required)

      type ->
        field_name = Enum.join(path ++ [Atom.to_string(name)], ".")
        [{field_name, field_schema(type, spec)}]
    end
  end

  defp field_schema(type, spec) when is_list(spec) do
    for_field(type, Keyword.get(spec, :doc), Keyword.get(spec, :default))
  end

  defp field_schema(type, _spec), do: for_field(type, nil, nil)

  defp field_type(spec) when is_list(spec), do: Keyword.fetch!(spec, :type)
  defp field_type(type), do: type

  defp field_required?(spec, parent_required) when is_list(spec),
    do: parent_required and Keyword.get(spec, :required, false)

  defp field_required?(_spec, parent_required), do: parent_required

  defp flatten_required_map_fields(path, fields, parent_required) when is_list(fields) do
    Enum.flat_map(fields, fn {name, spec} ->
      flatten_required_map_field(path, name, spec, parent_required)
    end)
  end

  defp flatten_required_map_field(path, name, spec, parent_required) do
    type = field_type(spec)
    required = field_required?(spec, parent_required)

    case type do
      {:map, fields} ->
        flatten_required_map_fields(path ++ [Atom.to_string(name)], fields, required)

      _type when required ->
        [Enum.join(path ++ [Atom.to_string(name)], ".")]

      _type ->
        []
    end
  end

  defp maybe_put(map, _key, nil), do: map
  defp maybe_put(map, key, value), do: Map.put(map, key, value)
end