defmodule CMDC.Backend.Results do # 内部模块 — `CMDC.Backend` 8 个标准 Result struct + `FileData` typed map。 # struct 定义会被 `CMDC.Backend` 公开模块的回调签名引用,但本模块本身不暴露。 @moduledoc false defmodule FileData do @moduledoc """ 文件数据 typed map。 ## 格式版本 - **v1**(legacy):`content` 为 `[binary]`(按 `\\n` 分割的行列表),无 `encoding` 字段 - **v2**(current):`content` 为 `binary`,加 `encoding` 字段(`"utf-8"` 或 `"base64"`) Backend 接受两种格式作为输入,输出推荐 v2。 """ @type t :: %{ required(:content) => String.t() | [String.t()], optional(:encoding) => String.t(), optional(:created_at) => String.t(), optional(:modified_at) => String.t() } @doc "构建 v2 格式文件数据。" @spec new(String.t(), keyword()) :: t() def new(content, opts \\ []) when is_binary(content) do now = DateTime.utc_now() |> DateTime.to_iso8601() %{ content: content, encoding: Keyword.get(opts, :encoding, "utf-8"), created_at: Keyword.get(opts, :created_at, now), modified_at: Keyword.get(opts, :modified_at, now) } end @doc "返回 v1(legacy)格式(lines list)。" @spec to_legacy(t()) :: %{content: [String.t()]} def to_legacy(%{content: content} = file_data) when is_binary(content) do lines = String.split(content, "\n") file_data |> Map.drop([:encoding]) |> Map.put(:content, lines) end def to_legacy(%{content: lines} = file_data) when is_list(lines), do: file_data @doc "从 v1 (lines) 转 v2 (string);幂等。" @spec to_v2(t()) :: t() def to_v2(%{content: content} = file_data) when is_list(content) do file_data |> Map.put(:content, Enum.join(content, "\n")) |> Map.put_new(:encoding, "utf-8") end def to_v2(file_data), do: file_data @doc "字符串内容(处理 v1/v2 两种格式)。" @spec to_string(t()) :: String.t() def to_string(%{content: content}) when is_binary(content), do: content def to_string(%{content: lines}) when is_list(lines), do: Enum.join(lines, "\n") end defmodule FileInfo do @moduledoc "目录条目元信息。" @type t :: %{ required(:path) => String.t(), optional(:is_dir) => boolean(), optional(:size) => non_neg_integer(), optional(:modified_at) => String.t() } end defmodule GrepMatch do @moduledoc "grep 单条匹配。" @type t :: %{ required(:path) => String.t(), required(:line) => pos_integer(), required(:text) => String.t() } end defmodule ReadResult do @moduledoc "`backend.read/3` 返回。" @derive Jason.Encoder defstruct [:error, :file_data] @type t :: %__MODULE__{ error: nil | atom() | String.t(), file_data: FileData.t() | nil } @doc "成功构造器。" @spec ok(FileData.t()) :: t() def ok(file_data), do: %__MODULE__{file_data: file_data} @doc "失败构造器。" @spec error(atom() | String.t()) :: t() def error(reason), do: %__MODULE__{error: reason} end defmodule WriteResult do @moduledoc """ `backend.write/2` 返回。 `files_update` 字段用于 LangGraph state-style backend(如 `State`)返回 状态更新 map;FilesystemBackend / SandboxBackend 已直接写盘则为 `nil`。 """ @derive Jason.Encoder defstruct [:error, :path, :files_update] @type t :: %__MODULE__{ error: nil | atom() | String.t(), path: String.t() | nil, files_update: map() | nil } @spec ok(String.t(), map() | nil) :: t() def ok(path, files_update \\ nil) do %__MODULE__{path: path, files_update: files_update} end @spec error(atom() | String.t()) :: t() def error(reason), do: %__MODULE__{error: reason} end defmodule EditResult do @moduledoc "`backend.edit/4` 返回。" @derive Jason.Encoder defstruct [:error, :path, :files_update, :occurrences] @type t :: %__MODULE__{ error: nil | atom() | String.t(), path: String.t() | nil, files_update: map() | nil, occurrences: non_neg_integer() | nil } @spec ok(String.t(), non_neg_integer(), map() | nil) :: t() def ok(path, occurrences, files_update \\ nil) do %__MODULE__{path: path, occurrences: occurrences, files_update: files_update} end @spec error(atom() | String.t()) :: t() def error(reason), do: %__MODULE__{error: reason} end defmodule LsResult do @moduledoc "`backend.ls/1` 返回。" @derive Jason.Encoder defstruct [:error, :entries] @type t :: %__MODULE__{ error: nil | atom() | String.t(), entries: [FileInfo.t()] | nil } @spec ok([FileInfo.t()]) :: t() def ok(entries), do: %__MODULE__{entries: entries} @spec error(atom() | String.t()) :: t() def error(reason), do: %__MODULE__{error: reason} end defmodule GrepResult do @moduledoc "`backend.grep/3` 返回。" @derive Jason.Encoder defstruct [:error, :matches] @type t :: %__MODULE__{ error: nil | atom() | String.t(), matches: [GrepMatch.t()] | nil } @spec ok([GrepMatch.t()]) :: t() def ok(matches), do: %__MODULE__{matches: matches} @spec error(atom() | String.t()) :: t() def error(reason), do: %__MODULE__{error: reason} end defmodule GlobResult do @moduledoc "`backend.glob/2` 返回。" @derive Jason.Encoder defstruct [:error, :matches] @type t :: %__MODULE__{ error: nil | atom() | String.t(), matches: [FileInfo.t()] | nil } @spec ok([FileInfo.t()]) :: t() def ok(matches), do: %__MODULE__{matches: matches} @spec error(atom() | String.t()) :: t() def error(reason), do: %__MODULE__{error: reason} end defmodule FileUploadResponse do @moduledoc "`backend.upload_files/1` 单文件结果。" @derive Jason.Encoder defstruct [:path, :error] @type t :: %__MODULE__{ path: String.t(), error: nil | atom() | String.t() } @spec ok(String.t()) :: t() def ok(path), do: %__MODULE__{path: path} @spec error(String.t(), atom() | String.t()) :: t() def error(path, reason), do: %__MODULE__{path: path, error: reason} end defmodule FileDownloadResponse do @moduledoc "`backend.download_files/1` 单文件结果。" @derive Jason.Encoder defstruct [:path, :content, :error] @type t :: %__MODULE__{ path: String.t(), content: binary() | nil, error: nil | atom() | String.t() } @spec ok(String.t(), binary()) :: t() def ok(path, content), do: %__MODULE__{path: path, content: content} @spec error(String.t(), atom() | String.t()) :: t() def error(path, reason), do: %__MODULE__{path: path, error: reason} end # ========================================================================== # 错误码标准 # ========================================================================== @standard_errors [:file_not_found, :permission_denied, :is_directory, :invalid_path] @doc """ 4 个标准错误码 atom 白名单。 Backend 实现优先映射到这 4 个,无法对应时再用 string。 """ @spec standard_errors() :: [atom()] def standard_errors, do: @standard_errors @doc "判断给定 reason 是否为标准错误码。" @spec standard_error?(term()) :: boolean() def standard_error?(reason) when reason in @standard_errors, do: true def standard_error?(_), do: false end