Current section
Files
Jump to
Current section
Files
lib/cmdc/backend/state.ex
defmodule CMDC.Backend.State do
@moduledoc """
内存 backend — 文件存于 ETS。
**适合**:开发 / 单测 / 短会话 / 不需要 BEAM 重启后保留。
**不适合**:跨进程共享、生产持久化。
## 表结构
ETS `:set` 表,key 为绝对路径(必须以 `/` 开头),value 为 `FileData` map。
路径以 `/` 开头是为了 ls 目录视图能正确分层显示。
## 使用
backend = CMDC.Backend.State.new(:my_files)
%WriteResult{path: "/a.txt"} = CMDC.Backend.write(backend, "/a.txt", "hi")
%ReadResult{file_data: %{content: "hi"}} = CMDC.Backend.read(backend, "/a.txt")
"""
@behaviour CMDC.Backend
alias CMDC.Backend.Results
alias CMDC.Backend.Results.{
EditResult,
FileData,
GlobResult,
GrepResult,
LsResult,
ReadResult,
WriteResult
}
defstruct [:table]
@type t :: %__MODULE__{table: atom() | :ets.tid()}
# ==========================================================================
# 工厂
# ==========================================================================
@doc """
构建 State backend,使用一张命名 ETS 表。
- `table_name`:原子表名(命名 ETS 表,public + named_table),若同名已存在则复用
"""
@spec new(atom()) :: t()
def new(table_name) when is_atom(table_name) do
case :ets.info(table_name) do
:undefined ->
:ets.new(table_name, [:set, :public, :named_table])
_ ->
:ok
end
%__MODULE__{table: table_name}
end
@doc "清空表(仅测试用)。"
@spec reset(t()) :: :ok
def reset(%__MODULE__{table: table}) do
if :ets.info(table) != :undefined, do: :ets.delete_all_objects(table)
:ok
end
# ==========================================================================
# CMDC.Backend callbacks
# ==========================================================================
@impl true
def ls(%__MODULE__{table: table}, path) do
case validate_path(path) do
:ok -> do_ls(table, path)
{:error, reason} -> LsResult.error(reason)
end
end
defp do_ls(table, path) do
normalized = if String.ends_with?(path, "/"), do: path, else: path <> "/"
{file_entries, dir_set} =
table
|> :ets.tab2list()
|> Enum.reduce({[], MapSet.new()}, &accumulate_ls_entry(&1, &2, normalized))
dir_entries =
dir_set
|> Enum.map(&%{path: &1, is_dir: true, size: 0, modified_at: ""})
|> Enum.sort_by(& &1.path)
entries = (file_entries ++ dir_entries) |> Enum.sort_by(& &1.path)
LsResult.ok(entries)
end
defp accumulate_ls_entry({k, fd}, {files, dirs}, normalized) do
if String.starts_with?(k, normalized) do
relative = String.replace_prefix(k, normalized, "")
classify_ls_entry(k, fd, relative, normalized, files, dirs)
else
{files, dirs}
end
end
defp classify_ls_entry(k, fd, relative, normalized, files, dirs) do
case String.split(relative, "/", parts: 2) do
[_file] ->
size = fd |> FileData.to_string() |> byte_size()
info = %{path: k, is_dir: false, size: size, modified_at: fd[:modified_at] || ""}
{[info | files], dirs}
[subdir, _rest] ->
dir_path = normalized <> subdir <> "/"
{files, MapSet.put(dirs, dir_path)}
end
end
@impl true
def read(%__MODULE__{table: table}, path, opts) do
case validate_path(path) do
:ok -> do_read(table, path, opts)
{:error, reason} -> ReadResult.error(reason)
end
end
defp do_read(table, path, opts) do
case :ets.lookup(table, path) do
[] ->
ReadResult.error(:file_not_found)
[{_, fd}] ->
content = FileData.to_string(fd)
offset = Keyword.get(opts, :offset, 0)
limit = Keyword.get(opts, :limit, 2000)
slice_lines(content, offset, limit, fd)
end
end
defp slice_lines(content, offset, limit, fd) do
lines = String.split(content, "\n")
total = length(lines)
if offset >= total do
ReadResult.error("Line offset #{offset} exceeds file length (#{total} lines)")
else
build_sliced_read(lines, offset, limit, total, fd)
end
end
defp build_sliced_read(lines, offset, limit, total, fd) do
end_idx = min(offset + limit, total)
sliced = lines |> Enum.slice(offset, end_idx - offset) |> Enum.join("\n")
sliced_fd =
fd
|> Map.put(:content, sliced)
|> Map.put_new(:encoding, "utf-8")
ReadResult.ok(sliced_fd)
end
@impl true
def write(%__MODULE__{table: table}, path, content) do
case validate_path(path) do
:ok -> do_write(table, path, content)
{:error, reason} -> WriteResult.error(reason)
end
end
defp do_write(table, path, content) do
case :ets.lookup(table, path) do
[_] ->
WriteResult.error(
"Cannot write to #{path} because it already exists. Read and then make an edit, or write to a new path."
)
[] ->
fd = FileData.new(content)
:ets.insert(table, {path, fd})
WriteResult.ok(path, %{path => fd})
end
end
@impl true
def edit(%__MODULE__{table: table}, path, old_string, new_string, opts) do
case validate_path(path) do
:ok -> do_edit(table, path, old_string, new_string, opts)
{:error, reason} -> EditResult.error(reason)
end
end
defp do_edit(table, path, old_string, new_string, opts) do
case :ets.lookup(table, path) do
[] ->
EditResult.error(:file_not_found)
[{_, fd}] ->
content = FileData.to_string(fd)
replace_all = Keyword.get(opts, :replace_all, false)
apply_replacement(table, path, content, old_string, new_string, replace_all, fd)
end
end
defp apply_replacement(table, path, content, old, new, replace_all, fd) do
cond do
not String.contains?(content, old) ->
EditResult.error("Error: String not found in file: '#{old}'")
not replace_all and occurrences(content, old) > 1 ->
EditResult.error(
"Error: String '#{old}' appears multiple times. Use replace_all=true to replace all occurrences."
)
true ->
count = occurrences(content, old)
new_content =
if replace_all do
String.replace(content, old, new)
else
# 只替换第一个
String.replace(content, old, new, global: false)
end
new_fd =
fd
|> Map.put(:content, new_content)
|> Map.put(:modified_at, DateTime.utc_now() |> DateTime.to_iso8601())
:ets.insert(table, {path, new_fd})
actual_count = if replace_all, do: count, else: 1
EditResult.ok(path, actual_count, %{path => new_fd})
end
end
defp occurrences(content, needle) do
content
|> String.split(needle)
|> length()
|> Kernel.-(1)
end
@impl true
def grep(%__MODULE__{table: table}, pattern, opts) do
base_path = Keyword.get(opts, :path) || "/"
glob_pattern = Keyword.get(opts, :glob)
matches =
table
|> :ets.tab2list()
|> Enum.filter(fn {path, _} ->
String.starts_with?(path, base_path) and matches_glob?(path, glob_pattern)
end)
|> Enum.flat_map(fn {path, fd} ->
fd
|> FileData.to_string()
|> String.split("\n")
|> Enum.with_index(1)
|> Enum.filter(fn {line, _} -> String.contains?(line, pattern) end)
|> Enum.map(fn {line, n} -> %{path: path, line: n, text: line} end)
end)
GrepResult.ok(matches)
end
defp matches_glob?(_path, nil), do: true
defp matches_glob?(path, pattern) do
# 简化版 glob:转 regex
regex_source =
pattern
|> String.replace("**", "🌟🌟")
|> String.replace("*", "[^/]*")
|> String.replace("🌟🌟", ".*")
|> String.replace("?", ".")
|> String.replace(".", "\\.")
|> String.replace("\\.[^/]*", "\\.[^/]*")
# 修复 . 双重 escape
regex_source = String.replace(regex_source, "\\\\.", "\\.")
case Regex.compile("(^|/)" <> regex_source <> "$") do
{:ok, regex} -> Regex.match?(regex, path)
_ -> false
end
end
@impl true
def glob(%__MODULE__{table: table}, pattern, opts) do
base_path = Keyword.get(opts, :path, "/")
matches =
table
|> :ets.tab2list()
|> Enum.filter(fn {path, _} ->
String.starts_with?(path, base_path) and matches_glob?(path, pattern)
end)
|> Enum.map(fn {path, fd} ->
size = fd |> FileData.to_string() |> byte_size()
%{path: path, is_dir: false, size: size, modified_at: fd[:modified_at] || ""}
end)
|> Enum.sort_by(& &1.path)
GlobResult.ok(matches)
end
@impl true
def upload_files(%__MODULE__{table: table}, files) do
Enum.map(files, fn {path, content} ->
case validate_path(path) do
:ok ->
fd = FileData.new(content)
:ets.insert(table, {path, fd})
Results.FileUploadResponse.ok(path)
{:error, reason} ->
Results.FileUploadResponse.error(path, reason)
end
end)
end
@impl true
def download_files(%__MODULE__{table: table}, paths) do
Enum.map(paths, fn path ->
case :ets.lookup(table, path) do
[{_, fd}] ->
content = FileData.to_string(fd)
Results.FileDownloadResponse.ok(path, content)
[] ->
Results.FileDownloadResponse.error(path, :file_not_found)
end
end)
end
# ==========================================================================
# 私有
# ==========================================================================
defp validate_path(path) when is_binary(path) do
if String.starts_with?(path, "/") do
:ok
else
{:error, :invalid_path}
end
end
defp validate_path(_), do: {:error, :invalid_path}
end