Skip to main content

sparrow/tools/
fs.rs

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}