lib/bb/mcp/robots.ex

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

defmodule BB.MCP.Robots do
  @moduledoc """
  Resolves between agent-facing robot name strings and robot modules.

  Robot names are derived from the last segment of the module name, lowercased.

      MyApp.WX200    -> "wx200"
      MyApp.SO101    -> "so101"
      MyApp.Robot    -> "robot"
      MyApp.Two.Word -> "two_word" (only when the segment is itself camel-cased,
                                    e.g. `MyApp.TwoWord` -> "two_word")
  """

  @type config :: %{required(String.t()) => module()}

  @doc """
  Build a name → module map from a list of robot modules, raising on collisions.
  """
  @spec build!([module()]) :: config()
  def build!(modules) when is_list(modules) do
    Enum.reduce(modules, %{}, fn module, acc ->
      name = name_for(module)

      case Map.fetch(acc, name) do
        {:ok, other} when other != module ->
          raise ArgumentError,
                "bb_mcp: robot name collision on #{inspect(name)} between " <>
                  "#{inspect(other)} and #{inspect(module)}"

        _ ->
          Map.put(acc, name, module)
      end
    end)
  end

  @doc """
  The agent-facing name for a robot module.
  """
  @spec name_for(module()) :: String.t()
  def name_for(module) when is_atom(module) do
    module
    |> Module.split()
    |> List.last()
    |> Macro.underscore()
  end

  @doc """
  Look up a robot module by name. Returns `{:ok, module}` or `{:error, :unknown_robot}`.
  """
  @spec fetch(config(), String.t()) :: {:ok, module()} | {:error, :unknown_robot}
  def fetch(config, name) when is_binary(name) do
    case Map.fetch(config, name) do
      {:ok, module} -> {:ok, module}
      :error -> {:error, :unknown_robot}
    end
  end

  @doc """
  List all configured robots as `{name, module}` tuples, sorted by name.
  """
  @spec to_list(config()) :: [{String.t(), module()}]
  def to_list(config), do: config |> Map.to_list() |> Enum.sort()
end