lib/omni/tools/file_system.ex

defmodule Omni.Tools.FileSystem do
  @moduledoc """
  An `Omni.Tool` for file operations scoped to a base directory.

  Provides read, write, patch, list, and delete commands over a
  configurable directory. Configuration controls whether writes are
  allowed and whether subdirectories are supported.

      # Full read-write access with nested subdirectories
      tool = Omni.Tools.FileSystem.new(base_dir: "/data/workspace")

      # Read-only access, flat (no subdirectories)
      tool = Omni.Tools.FileSystem.new(base_dir: "/data/docs", read_only: true, nested: false)

  The tool delegates all operations to `Omni.Tools.FileSystem.FS`, which
  can also be used independently of the tool machinery.

  ## Options

  - `:base_dir` (required) — absolute path to an existing directory.
  - `:read_only` — restricts to `read` and `list` only. Default `false`.
  - `:nested` — allows subdirectory paths in ids. Default `true`.
  """

  use Omni.Tool, name: "file_system"

  alias Omni.Tools.FileSystem.FS

  @defaults [
    read_only: false,
    nested: true
  ]

  @impl Omni.Tool
  def init(opts) do
    @defaults
    |> Keyword.merge(Application.get_env(:omni_tools, __MODULE__, []))
    |> Keyword.merge(opts || [])
    |> FS.new()
  end

  @impl Omni.Tool
  def schema(%FS{read_only?: true}) do
    import Omni.Schema

    object(
      %{
        command: enum(["read", "list"], description: "The operation to perform"),
        id: string(description: "File path relative to the base directory")
      },
      required: [:command]
    )
  end

  def schema(%FS{}) do
    import Omni.Schema

    object(
      %{
        command:
          enum(
            ["read", "list", "write", "patch", "delete"],
            description: "The operation to perform"
          ),
        id: string(description: "File path relative to the base directory"),
        content: string(description: "File content (for write)"),
        search: string(description: "Exact string to find (must match exactly once)"),
        replace: string(description: "Replacement string (for patch)")
      },
      required: [:command]
    )
  end

  @impl Omni.Tool
  def description(%FS{} = fs) do
    intro =
      if fs.read_only? do
        """
        Browse and read files on demand from a directory of reference material.

        Read-only access. Available commands: read, list
        """
      else
        """
        Create and manage persistent files that you author directly: markdown notes, \
        HTML pages, data files, reports, code files, SVG graphics. The user can view or download them.

        Read-write access. Available commands: read, list, write, patch, delete
        """
      end

    scope =
      if fs.nested? do
        "All file paths (`id`) are relative to the base directory. Subdirectories are allowed."
      else
        "All file names (`id`) are bare filenames only (no subdirectories)."
      end

    commands = """
    - **read** — returns the file content. Requires `id`.
    - **list** — lists all files with media types and sizes. No arguments.
    """

    commands =
      if fs.read_only?,
        do: commands,
        else:
          commands <>
            """
            - **write** — creates or overwrites a file. Requires `id` and `content`.
            - **patch** — targeted find-and-replace. Requires `id`, `search`, and `replace`. \
            The `search` string must appear exactly once in the file — if it matches zero \
            or multiple times, the operation fails with a diagnostic message.
            - **delete** — removes a file. Requires `id`.

            ## Prefer patch over write
            When editing an existing file, always prefer patch for targeted changes. \
            Only use write to replace an entire file when most of the content is changing. \
            Ask yourself: can I describe the change as search → replace? If yes, use patch.
            """

    """
    #{intro}

    File operations are scoped to a base directory. \
    #{scope} Absolute paths, ".." sqeuences, and null bytes are rejected.

    ## Commands
    #{commands}
    """
  end

  @impl Omni.Tool
  def call(%{command: "read"} = input, %FS{} = fs) do
    id = fetch!(input, :id, "read")

    case FS.read(fs, id) do
      {:ok, content} -> content
      {:error, reason} -> raise format_error(reason, id)
    end
  end

  def call(%{command: "list"}, %FS{} = fs) do
    case FS.list(fs) do
      {:ok, []} ->
        "No files"

      {:ok, entries} ->
        Enum.map_join(entries, "\n", fn e ->
          "#{e.id} (#{e.media_type}, #{e.size} bytes)"
        end)
    end
  end

  def call(%{command: "write"} = input, %FS{} = fs) do
    id = fetch!(input, :id, "write")
    content = fetch!(input, :content, "write")

    case FS.write(fs, id, content) do
      {:ok, entry} -> "Wrote #{entry.id} (#{entry.size} bytes)"
      {:error, reason} -> raise format_error(reason, id)
    end
  end

  def call(%{command: "patch"} = input, %FS{} = fs) do
    id = fetch!(input, :id, "patch")
    search = fetch!(input, :search, "patch")
    replace = fetch!(input, :replace, "patch")

    case FS.patch(fs, id, search, replace) do
      {:ok, entry} -> "Patched #{entry.id} (#{entry.size} bytes)"
      {:error, reason} -> raise format_error(reason, id)
    end
  end

  def call(%{command: "delete"} = input, %FS{} = fs) do
    id = fetch!(input, :id, "delete")

    case FS.delete(fs, id) do
      :ok -> "Deleted #{id}"
      {:error, reason} -> raise format_error(reason, id)
    end
  end

  defp fetch!(input, key, command) do
    case Map.get(input, key) do
      nil -> raise "#{command} command requires #{inspect(key)} param"
      value -> value
    end
  end

  defp format_error(:read_only, _id), do: "file system is read-only"
  defp format_error(:not_found, id), do: "file not found: #{id}"
  defp format_error({:invalid_id, msg}, _id), do: msg

  defp format_error({:patch_no_match, search}, _id),
    do: "search string not found: #{inspect(search)}"

  defp format_error({:patch_multiple_matches, count}, _id),
    do: "search string matches #{count} times (must match exactly once)"

  defp format_error({:file_error, posix}, id), do: "file error on #{id}: #{posix}"
  defp format_error(other, id), do: "unexpected error on #{id}: #{inspect(other)}"
end