lib/mix/tasks/llm_core.config.set.ex

defmodule Mix.Tasks.LlmCore.Config.Set do
  @moduledoc """
  Mutates `llm_core.toml` entries and reloads the runtime configuration.

  ## Examples

      mix llm_core.config.set --path memory.hindsight.default_bank_id --value dev-bank
      mix llm_core.config.set --path routing.tasks.coding.alias --value openai
      mix llm_core.config.set --path providers.auto.enabled --value false --type boolean

  Options:

    * `--path` (required) - dot-separated key path (e.g. `routing.tasks.coding.alias`)
    * `--value` - string value (interpreted via `--type`)
    * `--json` - raw JSON payload for the value (overrides `--value`)
    * `--type` - `string` (default), `integer`, `float`, `boolean`
    * `--file` - alternate path to `llm_core.toml`
    * `--reload` - reload config after writing (default true)
  """

  use Mix.Task

  alias LlmCore.Config.{Editor, Loader}

  @shortdoc "Updates llm_core configuration"

  @impl true
  @spec run([String.t()]) :: :ok
  def run(args) do
    Mix.Task.run("app.start")

    {opts, _, _} =
      OptionParser.parse(args,
        switches: [
          path: :string,
          value: :string,
          type: :string,
          json: :string,
          file: :string,
          reload: :boolean
        ],
        aliases: [p: :path]
      )

    path = opts[:path] || Mix.raise("--path is required")
    value = decode_value(opts)
    file = opts[:file] || Editor.default_path()

    {:ok, new_config} =
      Editor.update(
        fn config ->
          segments = path |> String.split(".", trim: true)
          Editor.put_path(config, segments, value)
        end,
        file
      )

    Mix.shell().info("Updated #{path} in #{file}")

    if Map.get(opts, :reload, true) do
      Loader.reload_providers(path: file)
    end

    Mix.shell().info("New value: #{inspect(get_in(new_config, path_segments(path)))}")
  end

  defp decode_value(opts) do
    cond do
      opts[:json] -> Jason.decode!(opts[:json])
      true -> coerce_value(opts[:value], opts[:type])
    end
  end

  defp coerce_value(nil, _type), do: Mix.raise("--value or --json is required")

  defp coerce_value(value, nil), do: value

  defp coerce_value(value, type) do
    case String.downcase(type) do
      "string" -> value
      "integer" -> String.to_integer(value)
      "int" -> String.to_integer(value)
      "float" -> String.to_float(value)
      "boolean" -> parse_boolean(value)
      "bool" -> parse_boolean(value)
      other -> Mix.raise("Unsupported --type #{other}")
    end
  end

  defp parse_boolean(value) do
    case String.downcase(value) do
      "true" -> true
      "false" -> false
      _ -> Mix.raise("Boolean value must be true/false")
    end
  end

  defp path_segments(path) do
    path |> String.split(".", trim: true)
  end
end