Skip to main content

hh_cli/tool/
edit.rs

1use crate::tool::diff::build_unified_line_diff;
2use crate::tool::fs::resolve_workspace_target;
3use crate::tool::{Tool, ToolResult, ToolSchema, parse_tool_args};
4use async_trait::async_trait;
5use serde::{Deserialize, Serialize};
6use serde_json::{Value, json};
7use std::path::PathBuf;
8
9pub struct EditTool {
10    workspace_root: PathBuf,
11}
12
13impl EditTool {
14    pub fn new(workspace_root: PathBuf) -> Self {
15        Self { workspace_root }
16    }
17}
18
19#[derive(Debug, Deserialize)]
20struct EditArgs {
21    path: String,
22    old_string: String,
23    new_string: String,
24    #[serde(default)]
25    replace_all: bool,
26}
27
28#[derive(Debug, Serialize)]
29struct EditSummary {
30    added_lines: usize,
31    removed_lines: usize,
32}
33
34#[derive(Debug, Serialize)]
35struct EditOutput {
36    path: String,
37    applied: bool,
38    summary: EditSummary,
39    diff: String,
40}
41
42#[async_trait]
43impl Tool for EditTool {
44    fn schema(&self) -> ToolSchema {
45        ToolSchema {
46            name: "edit".to_string(),
47            description: "Edit a file by replacing an exact string".to_string(),
48            capability: Some("edit".to_string()),
49            mutating: Some(true),
50            parameters: json!({
51                "type": "object",
52                "properties": {
53                    "path": {"type": "string"},
54                    "old_string": {"type": "string"},
55                    "new_string": {"type": "string"},
56                    "replace_all": {"type": "boolean"}
57                },
58                "required": ["path", "old_string", "new_string"]
59            }),
60        }
61    }
62
63    async fn execute(&self, args: Value) -> ToolResult {
64        let parsed: EditArgs = match parse_tool_args(args, "edit") {
65            Ok(value) => value,
66            Err(err) => return err,
67        };
68
69        if parsed.old_string.is_empty() {
70            return ToolResult::error("old_string must not be empty");
71        }
72
73        let input_path = PathBuf::from(&parsed.path);
74        let target = match resolve_workspace_target(&self.workspace_root, &input_path) {
75            Ok(path) => path,
76            Err(err) => return ToolResult::error(err),
77        };
78
79        let before = match std::fs::read_to_string(&target) {
80            Ok(content) => content,
81            Err(err) => return ToolResult::error(format!("failed to read file: {err}")),
82        };
83
84        let matches = before.matches(&parsed.old_string).count();
85        if matches == 0 {
86            return ToolResult::error("old_string not found in file");
87        }
88        if !parsed.replace_all && matches > 1 {
89            return ToolResult::error(
90                "old_string is not unique; set replace_all=true to replace all matches",
91            );
92        }
93
94        let after = if parsed.replace_all {
95            before.replace(&parsed.old_string, &parsed.new_string)
96        } else {
97            before.replacen(&parsed.old_string, &parsed.new_string, 1)
98        };
99
100        let applied = before != after;
101
102        if applied && let Err(err) = std::fs::write(&target, &after) {
103            return ToolResult::error(format!("failed to write file: {err}"));
104        }
105
106        let diff = build_unified_line_diff(&before, &after, &parsed.path);
107
108        let output = EditOutput {
109            path: parsed.path,
110            applied,
111            summary: EditSummary {
112                added_lines: diff.added_lines,
113                removed_lines: diff.removed_lines,
114            },
115            diff: diff.unified,
116        };
117
118        ToolResult::ok_json_serializable("ok", &output)
119    }
120}