Skip to main content

pawan/tools/
file.rs

1//! File read/write tools
2
3use super::Tool;
4use async_trait::async_trait;
5use serde_json::{json, Value};
6use std::path::PathBuf;
7
8/// Normalize a path relative to the workspace root.
9///
10/// Handles the double-prefix bug where the model passes an absolute path
11/// like "/opt/pawan/grind/opt/pawan/grind/foo.rs" — it joined the workspace
12/// root with an absolute path instead of a relative one. We detect the
13/// workspace root appearing twice and collapse to the second occurrence.
14///
15/// # Parameters
16/// - `workspace_root`: The root directory of the workspace
17/// - `path`: The path to normalize (can be relative or absolute)
18///
19/// # Returns
20/// The normalized path as a PathBuf
21pub 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 path starts with workspace_root, check if ws appears again in the remainder
27        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
44/// Tool for reading file contents
45pub 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                // Truncate very long lines
122                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
154/// Tool for writing file contents
155pub 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        // Create parent directories if needed
208        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        // Write the file
215        tokio::fs::write(&full_path, content)
216            .await
217            .map_err(crate::PawanError::Io)?;
218
219        // Verify written size matches expected
220        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
238/// Tool for listing directory contents
239pub 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        // Model passes absolute path with workspace root repeated
415        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}