Current section

Files

Jump to
nous lib nous tools file_edit.ex
Raw

lib/nous/tools/file_edit.ex

defmodule Nous.Tools.FileEdit do
@moduledoc """
String replacement editing tool.
Edits files by finding and replacing exact string matches.
By default requires `old_string` to be unique in the file
to prevent unintended changes.
"""
@behaviour Nous.Tool.Behaviour
@impl true
def metadata do
%{
name: "file_edit",
description:
"Edit a file by replacing exact string matches. The old_string must be unique in the file unless replace_all is true.",
category: :write,
requires_approval: true,
parameters: %{
"type" => "object",
"properties" => %{
"file_path" => %{
"type" => "string",
"description" => "Path to the file to edit"
},
"old_string" => %{
"type" => "string",
"description" => "The exact text to find and replace"
},
"new_string" => %{
"type" => "string",
"description" => "The replacement text"
},
"replace_all" => %{
"type" => "boolean",
"description" =>
"Replace all occurrences instead of requiring uniqueness. Defaults to false."
}
},
"required" => ["file_path", "old_string", "new_string"]
}
}
end
@impl true
def execute(
ctx,
%{"file_path" => file_path, "old_string" => old_string, "new_string" => new_string} =
args
) do
replace_all = Map.get(args, "replace_all", false)
with {:ok, safe_path} <- Nous.Tools.PathGuard.validate(file_path, ctx),
{:ok, content} <- File.read(safe_path) do
occurrences = count_occurrences(content, old_string)
cond do
occurrences == 0 ->
{:error, "old_string not found in #{safe_path}"}
occurrences > 1 and not replace_all ->
{:error,
"old_string found #{occurrences} times in #{safe_path}. " <>
"Use replace_all: true to replace all occurrences."}
true ->
new_content =
if replace_all do
String.replace(content, old_string, new_string)
else
replace_first(content, old_string, new_string)
end
case File.write(safe_path, new_content) do
:ok ->
replaced = if replace_all, do: occurrences, else: 1
{:ok,
"Edited #{safe_path} (#{replaced} replacement#{if replaced > 1, do: "s", else: ""})"}
{:error, reason} ->
{:error, "Failed to write #{safe_path}: #{inspect(reason)}"}
end
end
else
{:error, reason} when is_binary(reason) -> {:error, reason}
{:error, reason} -> {:error, "Failed to access #{file_path}: #{inspect(reason)}"}
end
end
defp count_occurrences(string, substring) do
parts = String.split(string, substring)
length(parts) - 1
end
defp replace_first(string, old, new) do
case String.split(string, old, parts: 2) do
[before, rest] -> before <> new <> rest
[_no_match] -> string
end
end
end