1use async_trait::async_trait;
2use serde_json::json;
3use std::fs;
4use std::path::PathBuf;
5
6use super::{Tool, ToolCtx, ToolResult, resolve_workspace_path};
7use crate::event::RiskLevel;
8
9pub struct FsRead;
10
11#[async_trait]
12impl Tool for FsRead {
13 fn name(&self) -> &str {
14 "fs_read"
15 }
16 fn description(&self) -> &str {
17 "Read a file from the workspace"
18 }
19 fn schema(&self) -> serde_json::Value {
20 json!({
21 "type": "object",
22 "properties": {
23 "path": { "type": "string", "description": "Relative path to the file" },
24 "offset": { "type": "integer", "description": "Line number to start from (1-indexed)" },
25 "limit": { "type": "integer", "description": "Max lines to read" }
26 },
27 "required": ["path"]
28 })
29 }
30 fn risk(&self) -> RiskLevel {
31 RiskLevel::ReadOnly
32 }
33 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
34 let path = args["path"].as_str().unwrap_or("");
35 let full_path = resolve_workspace_path(&ctx.workspace_root, path)?;
36
37 let content = fs::read_to_string(&full_path)?;
38 let lines: Vec<&str> = content.lines().collect();
39
40 let offset = args["offset"].as_u64().unwrap_or(1).max(1) as usize - 1;
41 let limit = args["limit"].as_u64().unwrap_or(lines.len() as u64) as usize;
42
43 let start = offset.min(lines.len());
44 let end = (start + limit).min(lines.len());
45
46 let result: Vec<String> = lines[start..end]
47 .iter()
48 .enumerate()
49 .map(|(i, l)| format!("{:>6}: {}", start + i + 1, l))
50 .collect();
51
52 Ok(ToolResult::text(result.join("\n")))
53 }
54}
55
56pub struct FsList;
57
58#[async_trait]
59impl Tool for FsList {
60 fn name(&self) -> &str {
61 "fs_list"
62 }
63 fn description(&self) -> &str {
64 "List files and directories in the workspace"
65 }
66 fn schema(&self) -> serde_json::Value {
67 json!({
68 "type": "object",
69 "properties": {
70 "path": { "type": "string", "description": "Relative directory to list" },
71 "depth": { "type": "integer", "description": "Recursion depth (default 1)" }
72 },
73 "required": []
74 })
75 }
76 fn risk(&self) -> RiskLevel {
77 RiskLevel::ReadOnly
78 }
79 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
80 let path = args["path"].as_str().unwrap_or(".");
81 let depth = args["depth"].as_u64().unwrap_or(1) as usize;
82 let full_path = resolve_workspace_path(&ctx.workspace_root, path)?;
83
84 let entries = list_dir(&full_path, depth, 0)?;
85 Ok(ToolResult::text(entries.join("\n")))
86 }
87}
88
89fn list_dir(path: &PathBuf, max_depth: usize, current_depth: usize) -> anyhow::Result<Vec<String>> {
90 let mut result = Vec::new();
91 if current_depth > max_depth {
92 return Ok(result);
93 }
94 let prefix = " ".repeat(current_depth);
95
96 let mut entries: Vec<_> = fs::read_dir(path)?.filter_map(|e| e.ok()).collect();
97 entries.sort_by_key(|e| e.file_name());
98
99 for entry in &entries {
100 let name = entry.file_name().to_string_lossy().to_string();
101 let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
102 if is_dir {
103 result.push(format!("{}{}/", prefix, name));
104 result.extend(list_dir(&entry.path(), max_depth, current_depth + 1)?);
105 } else {
106 result.push(format!("{}{}", prefix, name));
107 }
108 }
109 Ok(result)
110}
111
112pub struct FsWrite;
113
114#[async_trait]
115impl Tool for FsWrite {
116 fn name(&self) -> &str {
117 "fs_write"
118 }
119 fn description(&self) -> &str {
120 "Write or overwrite a file in the workspace"
121 }
122 fn schema(&self) -> serde_json::Value {
123 json!({
124 "type": "object",
125 "properties": {
126 "path": { "type": "string", "description": "Relative path to write" },
127 "content": { "type": "string", "description": "File content" }
128 },
129 "required": ["path", "content"]
130 })
131 }
132 fn risk(&self) -> RiskLevel {
133 RiskLevel::Mutating
134 }
135 async fn call(&self, args: serde_json::Value, ctx: &ToolCtx) -> anyhow::Result<ToolResult> {
136 let path = args["path"].as_str().unwrap_or("");
137 let content = args["content"].as_str().unwrap_or("");
138 let full_path = resolve_workspace_path(&ctx.workspace_root, path)?;
139
140 if let Some(parent) = full_path.parent() {
141 fs::create_dir_all(parent)?;
142 }
143 fs::write(&full_path, content)?;
144
145 Ok(ToolResult::text(format!("Written: {}", path)))
146 }
147}