1use super::Tool;
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::path::PathBuf;
7
8pub fn normalize_path(workspace_root: &PathBuf, path: &str) -> PathBuf {
22 let p = PathBuf::from(path);
23 if p.is_absolute() {
24 let ws = workspace_root.to_string_lossy();
25 let ps = p.to_string_lossy();
26 if ps.starts_with(&*ws) {
28 let tail = &ps[ws.len()..];
29 if let Some(idx) = tail.find(&*ws) {
30 let corrected = &tail[idx..];
31 tracing::warn!(
32 original = %ps, corrected = %corrected,
33 "Path normalization: double workspace prefix detected"
34 );
35 return PathBuf::from(corrected.to_string());
36 }
37 }
38 p
39 } else {
40 workspace_root.join(p)
41 }
42}
43
44pub struct ReadFileTool {
46 workspace_root: PathBuf,
47}
48
49impl ReadFileTool {
50 pub fn new(workspace_root: PathBuf) -> Self {
51 Self { workspace_root }
52 }
53
54 fn resolve_path(&self, path: &str) -> PathBuf {
55 normalize_path(&self.workspace_root, path)
56 }
57}
58
59#[async_trait]
60impl Tool for ReadFileTool {
61 fn name(&self) -> &str {
62 "read_file"
63 }
64
65 fn description(&self) -> &str {
66 "Read the contents of a file. Returns the file content with line numbers."
67 }
68
69 fn parameters_schema(&self) -> Value {
70 json!({
71 "type": "object",
72 "properties": {
73 "path": {
74 "type": "string",
75 "description": "Path to the file to read (relative to workspace root or absolute)"
76 },
77 "offset": {
78 "type": "integer",
79 "description": "Line number to start reading from (0-based, optional)"
80 },
81 "limit": {
82 "type": "integer",
83 "description": "Maximum number of lines to read (optional, defaults to 2000)"
84 }
85 },
86 "required": ["path"]
87 })
88 }
89
90 async fn execute(&self, args: Value) -> crate::Result<Value> {
91 let path = args["path"]
92 .as_str()
93 .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
94
95 let offset = args["offset"].as_u64().unwrap_or(0) as usize;
96 let limit = args["limit"].as_u64().unwrap_or(200) as usize;
97
98 let full_path = self.resolve_path(path);
99
100 if !full_path.exists() {
101 return Err(crate::PawanError::NotFound(format!(
102 "File not found: {}",
103 full_path.display()
104 )));
105 }
106
107 let content = tokio::fs::read_to_string(&full_path)
108 .await
109 .map_err(crate::PawanError::Io)?;
110
111 let lines: Vec<&str> = content.lines().collect();
112 let total_lines = lines.len();
113
114 let selected_lines: Vec<String> = lines
115 .into_iter()
116 .skip(offset)
117 .take(limit)
118 .enumerate()
119 .map(|(i, line)| {
120 let line_num = offset + i + 1;
121 let display_line = if line.len() > 2000 {
123 format!("{}...[truncated]", &line[..2000])
124 } else {
125 line.to_string()
126 };
127 format!("{:>6}\t{}", line_num, display_line)
128 })
129 .collect();
130
131 let output = selected_lines.join("\n");
132
133 let warning = if total_lines > 300 && selected_lines.len() == total_lines {
134 Some(format!(
135 "Large file ({} lines). Consider using offset/limit to read specific sections, \
136 or use anchor_text in edit_file_lines to avoid line-number math.",
137 total_lines
138 ))
139 } else {
140 None
141 };
142
143 Ok(json!({
144 "content": output,
145 "path": full_path.display().to_string(),
146 "total_lines": total_lines,
147 "lines_shown": selected_lines.len(),
148 "offset": offset,
149 "warning": warning
150 }))
151 }
152}
153
154pub struct WriteFileTool {
156 workspace_root: PathBuf,
157}
158
159impl WriteFileTool {
160 pub fn new(workspace_root: PathBuf) -> Self {
161 Self { workspace_root }
162 }
163
164 fn resolve_path(&self, path: &str) -> PathBuf {
165 normalize_path(&self.workspace_root, path)
166 }
167}
168
169#[async_trait]
170impl Tool for WriteFileTool {
171 fn name(&self) -> &str {
172 "write_file"
173 }
174
175 fn description(&self) -> &str {
176 "Write content to a file. Creates parent directories if needed. Overwrites existing content."
177 }
178
179 fn parameters_schema(&self) -> Value {
180 json!({
181 "type": "object",
182 "properties": {
183 "path": {
184 "type": "string",
185 "description": "Path to the file to write (relative to workspace root or absolute)"
186 },
187 "content": {
188 "type": "string",
189 "description": "Content to write to the file"
190 }
191 },
192 "required": ["path", "content"]
193 })
194 }
195
196 async fn execute(&self, args: Value) -> crate::Result<Value> {
197 let path = args["path"]
198 .as_str()
199 .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
200
201 let content = args["content"]
202 .as_str()
203 .ok_or_else(|| crate::PawanError::Tool("content is required".into()))?;
204
205 let full_path = self.resolve_path(path);
206
207 if let Some(parent) = full_path.parent() {
209 tokio::fs::create_dir_all(parent)
210 .await
211 .map_err(crate::PawanError::Io)?;
212 }
213
214 tokio::fs::write(&full_path, content)
216 .await
217 .map_err(crate::PawanError::Io)?;
218
219 let written_size = tokio::fs::metadata(&full_path)
221 .await
222 .map(|m| m.len() as usize)
223 .unwrap_or(0);
224 let line_count = content.lines().count();
225 let size_mismatch = written_size != content.len();
226
227 Ok(json!({
228 "success": true,
229 "path": full_path.display().to_string(),
230 "bytes_written": content.len(),
231 "bytes_on_disk": written_size,
232 "size_verified": !size_mismatch,
233 "lines": line_count
234 }))
235 }
236}
237
238pub struct ListDirectoryTool {
240 workspace_root: PathBuf,
241}
242
243impl ListDirectoryTool {
244 pub fn new(workspace_root: PathBuf) -> Self {
245 Self { workspace_root }
246 }
247
248 fn resolve_path(&self, path: &str) -> PathBuf {
249 normalize_path(&self.workspace_root, path)
250 }
251}
252
253#[async_trait]
254impl Tool for ListDirectoryTool {
255 fn name(&self) -> &str {
256 "list_directory"
257 }
258
259 fn description(&self) -> &str {
260 "List the contents of a directory."
261 }
262
263 fn parameters_schema(&self) -> Value {
264 json!({
265 "type": "object",
266 "properties": {
267 "path": {
268 "type": "string",
269 "description": "Path to the directory to list (relative to workspace root or absolute)"
270 },
271 "recursive": {
272 "type": "boolean",
273 "description": "Whether to list recursively (default: false)"
274 },
275 "max_depth": {
276 "type": "integer",
277 "description": "Maximum depth for recursive listing (default: 3)"
278 }
279 },
280 "required": ["path"]
281 })
282 }
283
284 async fn execute(&self, args: Value) -> crate::Result<Value> {
285 let path = args["path"]
286 .as_str()
287 .ok_or_else(|| crate::PawanError::Tool("path is required".into()))?;
288
289 let recursive = args["recursive"].as_bool().unwrap_or(false);
290 let max_depth = args["max_depth"].as_u64().unwrap_or(3) as usize;
291
292 let full_path = self.resolve_path(path);
293
294 if !full_path.exists() {
295 return Err(crate::PawanError::NotFound(format!(
296 "Directory not found: {}",
297 full_path.display()
298 )));
299 }
300
301 if !full_path.is_dir() {
302 return Err(crate::PawanError::Tool(format!(
303 "Not a directory: {}",
304 full_path.display()
305 )));
306 }
307
308 let mut entries = Vec::new();
309
310 if recursive {
311 for entry in walkdir::WalkDir::new(&full_path)
312 .max_depth(max_depth)
313 .into_iter()
314 .filter_map(|e| e.ok())
315 {
316 let path = entry.path();
317 let relative = path.strip_prefix(&full_path).unwrap_or(path);
318 let is_dir = entry.file_type().is_dir();
319 let size = if is_dir {
320 0
321 } else {
322 entry.metadata().map(|m| m.len()).unwrap_or(0)
323 };
324
325 entries.push(json!({
326 "path": relative.display().to_string(),
327 "is_dir": is_dir,
328 "size": size
329 }));
330 }
331 } else {
332 let mut read_dir = tokio::fs::read_dir(&full_path)
333 .await
334 .map_err(crate::PawanError::Io)?;
335
336 while let Some(entry) = read_dir.next_entry().await.map_err(crate::PawanError::Io)? {
337 let path = entry.path();
338 let name = entry.file_name().to_string_lossy().to_string();
339 let metadata = entry.metadata().await.ok();
340 let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
341 let size = metadata.map(|m| m.len()).unwrap_or(0);
342
343 entries.push(json!({
344 "name": name,
345 "path": path.display().to_string(),
346 "is_dir": is_dir,
347 "size": size
348 }));
349 }
350 }
351
352 Ok(json!({
353 "path": full_path.display().to_string(),
354 "entries": entries,
355 "count": entries.len()
356 }))
357 }
358}
359
360#[cfg(test)]
361mod tests {
362 use super::*;
363 use tempfile::TempDir;
364
365 #[tokio::test]
366 async fn test_read_file() {
367 let temp_dir = TempDir::new().unwrap();
368 let file_path = temp_dir.path().join("test.txt");
369 std::fs::write(&file_path, "line 1\nline 2\nline 3").unwrap();
370
371 let tool = ReadFileTool::new(temp_dir.path().to_path_buf());
372 let result = tool.execute(json!({"path": "test.txt"})).await.unwrap();
373
374 assert_eq!(result["total_lines"], 3);
375 assert!(result["content"].as_str().unwrap().contains("line 1"));
376 }
377
378 #[tokio::test]
379 async fn test_write_file() {
380 let temp_dir = TempDir::new().unwrap();
381
382 let tool = WriteFileTool::new(temp_dir.path().to_path_buf());
383 let result = tool
384 .execute(json!({
385 "path": "new_file.txt",
386 "content": "hello\nworld"
387 }))
388 .await
389 .unwrap();
390
391 assert!(result["success"].as_bool().unwrap());
392 assert_eq!(result["lines"], 2);
393
394 let content = std::fs::read_to_string(temp_dir.path().join("new_file.txt")).unwrap();
395 assert_eq!(content, "hello\nworld");
396 }
397
398 #[tokio::test]
399 async fn test_list_directory() {
400 let temp_dir = TempDir::new().unwrap();
401 std::fs::write(temp_dir.path().join("file1.txt"), "content").unwrap();
402 std::fs::write(temp_dir.path().join("file2.txt"), "content").unwrap();
403 std::fs::create_dir(temp_dir.path().join("subdir")).unwrap();
404
405 let tool = ListDirectoryTool::new(temp_dir.path().to_path_buf());
406 let result = tool.execute(json!({"path": "."})).await.unwrap();
407
408 assert_eq!(result["count"], 3);
409 }
410
411 #[test]
412 fn test_normalize_path_double_prefix() {
413 let ws = PathBuf::from("/opt/pawan/grind");
414 let bad = "/opt/pawan/grind/opt/pawan/grind/leftist_heap/src/lib.rs";
416 let result = normalize_path(&ws, bad);
417 assert_eq!(result, PathBuf::from("/opt/pawan/grind/leftist_heap/src/lib.rs"));
418 }
419
420 #[test]
421 fn test_normalize_path_normal_absolute() {
422 let ws = PathBuf::from("/opt/pawan/grind");
423 let normal = "/opt/pawan/grind/trie/src/lib.rs";
424 let result = normalize_path(&ws, normal);
425 assert_eq!(result, PathBuf::from("/opt/pawan/grind/trie/src/lib.rs"));
426 }
427
428 #[test]
429 fn test_normalize_path_relative() {
430 let ws = PathBuf::from("/opt/pawan/grind");
431 let rel = "trie/src/lib.rs";
432 let result = normalize_path(&ws, rel);
433 assert_eq!(result, PathBuf::from("/opt/pawan/grind/trie/src/lib.rs"));
434 }
435
436 #[test]
437 fn test_normalize_path_unrelated_absolute() {
438 let ws = PathBuf::from("/opt/pawan/grind");
439 let other = "/tmp/foo/bar.rs";
440 let result = normalize_path(&ws, other);
441 assert_eq!(result, PathBuf::from("/tmp/foo/bar.rs"));
442 }
443}