Skip to main content

hh_cli/tool/
fs.rs

1use crate::tool::diff::build_unified_line_diff;
2use crate::tool::{Tool, ToolResult, ToolSchema};
3use async_trait::async_trait;
4use serde::Serialize;
5use serde_json::{Value, json};
6use std::path::{Component, Path, PathBuf};
7use tokio::process::Command;
8
9pub struct FsRead;
10pub struct FsWrite {
11    workspace_root: PathBuf,
12}
13pub struct FsList;
14pub struct FsGlob;
15pub struct FsGrep;
16
17#[derive(Debug, Serialize)]
18struct FileWriteSummary {
19    added_lines: usize,
20    removed_lines: usize,
21}
22
23#[derive(Debug, Serialize)]
24struct FileWriteOutput {
25    path: String,
26    applied: bool,
27    summary: FileWriteSummary,
28    diff: String,
29}
30
31#[derive(Debug, Serialize)]
32struct FileReadOutput {
33    path: String,
34    bytes: usize,
35    lines: usize,
36    start: usize,
37    end: usize,
38    total_lines: usize,
39    content: String,
40}
41
42#[derive(Debug, Serialize)]
43struct ListOutput {
44    path: String,
45    count: usize,
46    entries: Vec<String>,
47}
48
49#[derive(Debug, Serialize)]
50struct GlobOutput {
51    pattern: String,
52    count: usize,
53    matches: Vec<String>,
54}
55
56#[derive(Debug, Serialize)]
57struct GrepOutput {
58    path: String,
59    pattern: String,
60    include: Option<String>,
61    count: usize,
62    shown_count: usize,
63    truncated: bool,
64    has_errors: bool,
65    matches: Vec<GrepMatch>,
66}
67
68#[derive(Debug, Serialize)]
69struct GrepMatch {
70    path: String,
71    line_number: usize,
72    line: String,
73}
74
75#[async_trait]
76impl Tool for FsRead {
77    fn schema(&self) -> ToolSchema {
78        ToolSchema {
79            name: "read".to_string(),
80            description: "Read a UTF-8 text file".to_string(),
81            capability: Some("read".to_string()),
82            mutating: Some(false),
83            parameters: json!({
84                "type": "object",
85                "properties": {
86                    "path": {"type": "string"},
87                    "start": {"type": "integer", "minimum": 0, "default": 0},
88                    "end": {"type": "integer", "minimum": -1, "default": -1}
89                },
90                "required": ["path"]
91            }),
92        }
93    }
94
95    async fn execute(&self, args: Value) -> ToolResult {
96        let path = args
97            .get("path")
98            .and_then(|v| v.as_str())
99            .unwrap_or_default();
100        let start = args.get("start").and_then(|v| v.as_i64()).unwrap_or(0);
101        let end = args.get("end").and_then(|v| v.as_i64()).unwrap_or(-1);
102
103        if start < 0 {
104            return ToolResult::error("start must be >= 0".to_string());
105        }
106        if end < -1 {
107            return ToolResult::error("end must be >= -1".to_string());
108        }
109
110        let content = match std::fs::read_to_string(path) {
111            Ok(text) => text,
112            Err(err) => return ToolResult::error(err.to_string()),
113        };
114
115        let line_chunks: Vec<&str> = content.split_inclusive('\n').collect();
116        let total_lines = line_chunks.len();
117
118        let start = usize::try_from(start).unwrap_or(0).min(total_lines);
119        let end = if end == -1 {
120            total_lines
121        } else {
122            usize::try_from(end).unwrap_or(total_lines).min(total_lines)
123        };
124
125        if start > end {
126            return ToolResult::error("start must be less than or equal to end".to_string());
127        }
128
129        let content = line_chunks[start..end].join("");
130        let output = FileReadOutput {
131            path: path.to_string(),
132            bytes: content.len(),
133            lines: end.saturating_sub(start),
134            start,
135            end,
136            total_lines,
137            content,
138        };
139        ToolResult::ok_json_serializable("ok", &output)
140    }
141}
142
143impl FsWrite {
144    pub fn new(workspace_root: PathBuf) -> Self {
145        Self { workspace_root }
146    }
147}
148
149#[async_trait]
150impl Tool for FsWrite {
151    fn schema(&self) -> ToolSchema {
152        ToolSchema {
153            name: "write".to_string(),
154            description: "Write UTF-8 text to file".to_string(),
155            capability: Some("write".to_string()),
156            mutating: Some(true),
157            parameters: json!({
158                "type": "object",
159                "properties": {
160                    "path": {"type": "string"},
161                    "content": {"type": "string"}
162                },
163                "required": ["path", "content"]
164            }),
165        }
166    }
167
168    async fn execute(&self, args: Value) -> ToolResult {
169        let raw_path = args
170            .get("path")
171            .and_then(|v| v.as_str())
172            .unwrap_or_default()
173            .to_string();
174        let path = PathBuf::from(&raw_path);
175        let content = args
176            .get("content")
177            .and_then(|v| v.as_str())
178            .unwrap_or_default();
179
180        let target = match resolve_workspace_target(&self.workspace_root, &path) {
181            Ok(path) => path,
182            Err(err) => return ToolResult::error(err),
183        };
184
185        if let Some(parent) = target.parent()
186            && let Err(err) = std::fs::create_dir_all(parent)
187        {
188            return ToolResult::error(err.to_string());
189        }
190
191        let before = if target.exists() {
192            match std::fs::read_to_string(&target) {
193                Ok(text) => text,
194                Err(err) => {
195                    return ToolResult::error(format!(
196                        "failed to read existing file before write: {err}"
197                    ));
198                }
199            }
200        } else {
201            String::new()
202        };
203
204        match std::fs::write(target, content) {
205            Ok(_) => {
206                let diff = build_unified_line_diff(before.as_str(), content, &raw_path);
207                let output = FileWriteOutput {
208                    path: raw_path,
209                    applied: before != content,
210                    summary: FileWriteSummary {
211                        added_lines: diff.added_lines,
212                        removed_lines: diff.removed_lines,
213                    },
214                    diff: diff.unified,
215                };
216
217                ToolResult::ok_json_serializable("ok", &output)
218            }
219            Err(err) => ToolResult::error(err.to_string()),
220        }
221    }
222}
223
224#[async_trait]
225impl Tool for FsList {
226    fn schema(&self) -> ToolSchema {
227        ToolSchema {
228            name: "list".to_string(),
229            description: "List directory entries".to_string(),
230            capability: Some("list".to_string()),
231            mutating: Some(false),
232            parameters: json!({
233                "type": "object",
234                "properties": {"path": {"type": "string"}},
235                "required": ["path"]
236            }),
237        }
238    }
239
240    async fn execute(&self, args: Value) -> ToolResult {
241        let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");
242        match std::fs::read_dir(path) {
243            Ok(entries) => {
244                let mut entries_list = Vec::new();
245                for entry in entries.flatten() {
246                    entries_list.push(entry.path().display().to_string());
247                }
248                let output = ListOutput {
249                    path: path.to_string(),
250                    count: entries_list.len(),
251                    entries: entries_list,
252                };
253                ToolResult::ok_json_serializable("ok", &output)
254            }
255            Err(err) => ToolResult::error(err.to_string()),
256        }
257    }
258}
259
260#[async_trait]
261impl Tool for FsGlob {
262    fn schema(&self) -> ToolSchema {
263        ToolSchema {
264            name: "glob".to_string(),
265            description: "Glob files".to_string(),
266            capability: Some("glob".to_string()),
267            mutating: Some(false),
268            parameters: json!({
269                "type": "object",
270                "properties": {"pattern": {"type": "string"}},
271                "required": ["pattern"]
272            }),
273        }
274    }
275
276    async fn execute(&self, args: Value) -> ToolResult {
277        let pattern = args
278            .get("pattern")
279            .and_then(|v| v.as_str())
280            .unwrap_or_default();
281        let mut matches = Vec::new();
282        match glob::glob(pattern) {
283            Ok(paths) => {
284                for p in paths.flatten() {
285                    matches.push(p.display().to_string());
286                }
287                let output = GlobOutput {
288                    pattern: pattern.to_string(),
289                    count: matches.len(),
290                    matches,
291                };
292                ToolResult::ok_json_serializable("ok", &output)
293            }
294            Err(err) => ToolResult::error(err.to_string()),
295        }
296    }
297}
298
299#[async_trait]
300impl Tool for FsGrep {
301    fn schema(&self) -> ToolSchema {
302        ToolSchema {
303            name: "grep".to_string(),
304            description: "Search regex in files recursively".to_string(),
305            capability: Some("grep".to_string()),
306            mutating: Some(false),
307            parameters: json!({
308                "type": "object",
309                "properties": {
310                    "path": {"type": "string"},
311                    "pattern": {"type": "string"},
312                    "include": {"type": "string"}
313                },
314                "required": ["pattern"]
315            }),
316        }
317    }
318
319    async fn execute(&self, args: Value) -> ToolResult {
320        let root = PathBuf::from(args.get("path").and_then(|v| v.as_str()).unwrap_or("."));
321        let pattern = args
322            .get("pattern")
323            .and_then(|v| v.as_str())
324            .unwrap_or_default();
325        let include = args
326            .get("include")
327            .and_then(|v| v.as_str())
328            .map(ToOwned::to_owned);
329
330        let mut command = Command::new("rg");
331        command
332            .arg("--json")
333            .arg("--hidden")
334            .arg("--no-messages")
335            .arg("--color")
336            .arg("never")
337            .arg("--regexp")
338            .arg(pattern);
339        if let Some(include) = include.as_deref() {
340            command.arg("--glob").arg(include);
341        }
342        command.arg(&root);
343
344        let output = match command.output().await {
345            Ok(output) => output,
346            Err(err) => {
347                return ToolResult::error(format!("failed to run rg: {err}"));
348            }
349        };
350
351        let all_results = parse_rg_json_matches(&output.stdout);
352
353        let exit_code = output.status.code().unwrap_or_default();
354        // rg: 0 = matches found, 1 = no matches (not an error), 2 = error
355        let has_errors = exit_code == 2;
356
357        // Treat code 1 (no matches) as success; only report actual errors
358        if exit_code != 0 && exit_code != 1 {
359            let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
360            return if stderr.is_empty() {
361                ToolResult::error(format!("rg exited with code {exit_code}"))
362            } else {
363                ToolResult::error(stderr)
364            };
365        }
366
367        let total_count = all_results.len();
368        let limit = 100;
369        let truncated = total_count > limit;
370        let results = if truncated {
371            all_results.into_iter().take(limit).collect()
372        } else {
373            all_results
374        };
375        let shown_count = results.len();
376
377        let output = GrepOutput {
378            path: root.display().to_string(),
379            pattern: pattern.to_string(),
380            include,
381            count: total_count,
382            shown_count,
383            truncated,
384            has_errors,
385            matches: results,
386        };
387
388        ToolResult::ok_json_serializable("ok", &output)
389    }
390}
391
392fn parse_rg_json_matches(stdout: &[u8]) -> Vec<GrepMatch> {
393    if stdout.is_empty() {
394        return Vec::new();
395    }
396
397    String::from_utf8_lossy(stdout)
398        .lines()
399        .filter_map(parse_rg_match_line)
400        .collect()
401}
402
403fn parse_rg_match_line(line: &str) -> Option<GrepMatch> {
404    let event: Value = serde_json::from_str(line).ok()?;
405    let event_type = event.get("type")?.as_str()?;
406
407    if event_type != "match" {
408        return None;
409    }
410
411    let data = event.get("data")?;
412    let path = extract_rg_field(data, "path", "text")?;
413    let line_number = data
414        .get("line_number")
415        .and_then(|v| v.as_u64())
416        .and_then(|v| usize::try_from(v).ok())?;
417    let line = extract_rg_field(data, "lines", "text")
418        .unwrap_or_default()
419        .trim_end_matches('\n')
420        .to_string();
421
422    Some(GrepMatch {
423        path,
424        line_number,
425        line,
426    })
427}
428
429fn extract_rg_field(data: &Value, outer: &str, inner: &str) -> Option<String> {
430    data.get(outer)
431        .and_then(|v| v.get(inner))
432        .and_then(|v| v.as_str())
433        .map(|s| s.to_string())
434}
435
436pub(crate) fn to_workspace_target(workspace_root: &Path, path: &Path) -> PathBuf {
437    if path.is_absolute() {
438        path.to_path_buf()
439    } else {
440        workspace_root.join(path)
441    }
442}
443
444pub(crate) fn resolve_workspace_target(
445    workspace_root: &Path,
446    path: &Path,
447) -> Result<PathBuf, String> {
448    if path
449        .components()
450        .any(|component| matches!(component, Component::ParentDir))
451    {
452        return Err("path must not contain parent directory traversal".to_string());
453    }
454
455    let workspace_root = std::fs::canonicalize(workspace_root)
456        .map_err(|err| format!("failed to resolve workspace root: {err}"))?;
457    let target = to_workspace_target(&workspace_root, path);
458
459    let checked_target = if target.exists() {
460        std::fs::canonicalize(&target)
461            .map_err(|err| format!("failed to resolve target path: {err}"))?
462    } else {
463        let parent = target
464            .parent()
465            .ok_or_else(|| "target path has no parent directory".to_string())?;
466        let canonical_parent = std::fs::canonicalize(parent)
467            .map_err(|err| format!("failed to resolve target parent: {err}"))?;
468        let file_name = target
469            .file_name()
470            .ok_or_else(|| "target path has no file name".to_string())?;
471        canonical_parent.join(file_name)
472    };
473
474    if !checked_target.starts_with(&workspace_root) {
475        return Err("path is outside workspace".to_string());
476    }
477
478    Ok(target)
479}