Give the Rust fs tool Python's contract
Hanzo CI/CD / test (linux-amd64) (push) Skipped
Hanzo CI/CD / test (linux-arm64) (push) Skipped
Hanzo CI/CD / test-advisory (push) Skipped
Hanzo CI/CD / test-amd64 (push) Successful in 1m46s
publish / publish (push) Failing after 1m10s
Hanzo CI/CD / cicd (push) Successful in 1m53s
CI/CD / cicd (push) Successful in 3m43s

uri (path accepted), the unified envelope, sha256 hashes, write that only
creates, apply_patch guarded by the hash a read returned, list with depth,
pattern and cursor, search_text, and patch taking input. The search alias
tool goes: fs action=search_text is the one search.
This commit is contained in:
2026-09-30 15:03:49 -07:00
parent 6dd2293590
commit 03b7fd3edf
4 changed files with 603 additions and 1579 deletions
+4 -3
View File
@@ -262,7 +262,7 @@ params, the extension's `decodeCmd` body) answered by one RESPONSE (JSON, or
Python `hanzo-mcp` (`python-sdk/pkg/hanzo-tools-*`) is the reference. Counted
from code, one row per registered Python tool: **72 tools**. TypeScript: 13 yes,
23 partial, 36 missing. Rust: 24 yes, 20 partial, 28 missing. † not a
23 partial, 36 missing. Rust: 25 yes, 19 partial, 28 missing. † not a
`hanzo-mcp` dependency (extras); ‡ shipped but disabled when `hanzo` is on.
| Python tool (pkg) | TS | Rust |
@@ -287,7 +287,7 @@ from code, one row per registered Python tool: **72 tools**. TypeScript: 13 yes,
| sql_query, sql_search, sql_stats, graph_add/remove/query/search/stats (database) † | missing | missing |
| devserver (devserver) | missing | missing |
| neovim_edit, neovim_command, neovim_session (editor) † | missing | missing |
| fs (fs) | partial `src/tools/unified/fs.ts` (no patch) | partial `rust/src/tools/fs_tool.rs` (`path` not `uri`; no list, search_text) |
| fs (fs) | partial `src/tools/unified/fs.ts` (no patch; apply_patch takes `patch`, hash cut to 16 hex) | yes `rust/src/tools/fs_tool.rs` |
| gimp (gimp) † | yes `src/tools/gimp.ts` | missing |
| ide (ide) † | missing | missing |
| jupyter (jupyter) † | missing | missing |
@@ -316,7 +316,8 @@ from code, one row per registered Python tool: **72 tools**. TypeScript: 13 yes,
A grouped row counts once per Python tool it names. Not in Python: TS
`code_*`, `tracker_*`, `kai_*`; Rust `code_*`, `web_search`, `web_read`,
`research`, `system`. Recount after closing a gap and update both numbers.
`research`, `system`. Rust's `search` alias of fs is gone: `fs action=search_text`
is the one search. Recount after closing a gap and update both numbers.
## Canonical role
Part of the AI/agents SDK line. This TS package (`@hanzo/mcp`) is canonical; the
+3 -16
View File
@@ -140,13 +140,13 @@ impl ToolRegistry {
pub fn list(&self) -> Vec<String> {
let mut names: Vec<String> = self.tools.keys().cloned().collect();
// Add built-in tools (all 13 HIP-0300 canonical + search alias + browser extension)
// Add built-in tools (all 13 HIP-0300 canonical + browser)
names.extend(vec![
"exec".into(), "fs".into(), "code".into(), "git".into(),
"fetch".into(), "workspace".into(), "computer".into(),
"think".into(), "memory".into(), "hanzo".into(),
"plan".into(), "tasks".into(), "mode".into(),
"search".into(), "browser".into(),
"browser".into(),
]);
names.sort();
names.dedup();
@@ -166,14 +166,6 @@ impl ToolRegistry {
let result = self.fs.read().await.execute(args).await?;
Ok(ToolResult::ok(serde_json::from_str(&result)?))
}
"search" => {
let mut args: tools::FsToolArgs = serde_json::from_value(params)?;
if args.action.is_empty() {
args.action = "search".to_string();
}
let result = self.fs.read().await.execute(args).await?;
Ok(ToolResult::ok(serde_json::from_str(&result)?))
}
"plan" => {
let args: tools::PlanToolArgs = serde_json::from_value(params)?;
let result = self.plan.read().await.execute(args).await?;
@@ -259,11 +251,6 @@ impl ToolRegistry {
"description": tools::FsToolDefinition::new().description,
"inputSchema": tools::FsToolDefinition::new().input_schema
}),
json!({
"name": "search",
"description": "Search file contents (alias of fs with action=search)",
"inputSchema": tools::FsToolDefinition::new().input_schema
}),
json!({
"name": "plan",
"description": tools::PlanToolDefinition::new().description,
@@ -382,7 +369,7 @@ mod tests {
let tools = registry.list();
assert!(tools.contains(&"exec".to_string()));
assert!(tools.contains(&"fs".to_string()));
assert!(tools.contains(&"search".to_string()));
assert!(!tools.contains(&"search".to_string()), "fs action=search_text is the one search");
assert!(tools.contains(&"plan".to_string()));
assert!(tools.contains(&"think".to_string()));
assert!(tools.contains(&"memory".to_string()));
+500 -857
View File
File diff suppressed because it is too large Load Diff
+96 -703
View File
@@ -1,741 +1,134 @@
//! Filesystem Tools Tests - 1:1 parity with TypeScript file-ops.test.ts
//!
//! Tests for fs tool covering:
//! - Tool properties and metadata
//! - Read operations (with offset/limit)
//! - Write operations
//! - Edit operations (single/multiple replacements)
//! - Patch operations (add/update/delete)
//! - Tree operations
//! - Find operations (glob patterns)
//! - Search operations (regex/case-insensitive)
//! - Info operations
//! fs against python-sdk's contract: `uri` (or `path`), the unified
//! `{ok, data, error, meta}` envelope, hashes as `sha256:<hex>`, `write` that
//! only creates, and `apply_patch` guarded by the hash a read returned.
use hanzo_mcp::tools::{FsTool, FsToolArgs};
use std::fs;
use serde_json::{json, Value};
use tempfile::TempDir;
/// Test fs tool properties
#[test]
fn test_fs_tool_properties() {
let tool = FsTool::new();
// Tool is fs following HIP-0300
assert!(true); // FsTool exists
async fn fs(args: Value) -> Value {
let args: FsToolArgs = serde_json::from_value(args).unwrap();
serde_json::from_str(&FsTool::new().execute(args).await.unwrap()).unwrap()
}
/// Test fs help action
#[tokio::test]
async fn test_fs_help_action() {
let tool = FsTool::new();
let args = FsToolArgs {
action: "help".to_string(),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
// Help should list available actions
assert!(json.get("actions").is_some());
assert!(json.get("name").is_some());
assert_eq!(json["name"], "fs");
fn at(dir: &TempDir, name: &str) -> String {
dir.path().join(name).display().to_string()
}
/// Test read file basic
#[tokio::test]
async fn test_fs_read_basic() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "line 1\nline 2\nline 3\n").unwrap();
async fn write_creates_and_read_numbers_lines_with_the_hash() {
let d = TempDir::new().unwrap();
let f = at(&d, "sub/a.txt");
let w = fs(json!({"action": "write", "uri": f, "content": "one\ntwo\nthree\n"})).await;
assert_eq!(w["ok"], true, "{w}");
assert_eq!(w["meta"]["action"], "write");
let hash = w["data"]["hash"].as_str().unwrap().to_string();
assert!(hash.starts_with("sha256:") && hash.len() == 71);
let tool = FsTool::new();
let args = FsToolArgs {
action: "read".to_string(),
path: Some(file_path.to_string_lossy().to_string()),
..Default::default()
};
let r = fs(json!({"action": "read", "path": f, "offset": 1, "limit": 1})).await;
assert_eq!(r["data"]["text"], " 2│two");
assert_eq!(r["data"]["total_lines"], 3);
assert_eq!(r["data"]["hash"], hash);
assert!(r["data"]["uri"].as_str().unwrap().starts_with("file:///"));
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(json.get("content").is_some());
assert!(json["content"].as_str().unwrap().contains("line 1"));
assert!(json["content"].as_str().unwrap().contains("line 2"));
assert_eq!(json["total_lines"].as_i64().unwrap(), 3); // Rust's .lines() returns 3 for trailing newline
let again = fs(json!({"action": "write", "uri": f, "content": "x"})).await;
assert_eq!((again["ok"].clone(), again["error"]["code"].clone()), (json!(false), json!("CONFLICT")));
}
/// Test read file with offset and limit
#[tokio::test]
async fn test_fs_read_with_offset_limit() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
let content: String = (1..=100).map(|i| format!("line {}\n", i)).collect();
fs::write(&file_path, &content).unwrap();
async fn apply_patch_needs_the_current_hash_and_a_unique_match() {
let d = TempDir::new().unwrap();
let f = at(&d, "b.txt");
std::fs::write(&f, "alpha beta beta\n").unwrap();
let hash = fs(json!({"action": "stat", "uri": f})).await["data"]["hash"].as_str().unwrap().to_string();
let tool = FsTool::new();
let args = FsToolArgs {
action: "read".to_string(),
path: Some(file_path.to_string_lossy().to_string()),
offset: Some(10),
limit: Some(5),
..Default::default()
};
let stale = fs(json!({"action": "apply_patch", "uri": f, "old_text": "alpha", "new_text": "A", "base_hash": "sha256:0"})).await;
assert_eq!(stale["error"]["code"], "CONFLICT");
let twice = fs(json!({"action": "apply_patch", "uri": f, "old_text": "beta", "new_text": "B", "base_hash": hash})).await;
assert_eq!(twice["error"]["code"], "INVALID_PARAMS");
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(json["lines"].as_i64().unwrap(), 5);
assert_eq!(json["offset"].as_i64().unwrap(), 10);
assert!(json["truncated"].as_bool().unwrap());
let ok = fs(json!({"action": "apply_patch", "uri": f, "old_text": "alpha", "new_text": "A", "base_hash": hash})).await;
assert_eq!(ok["data"]["previous_hash"], hash);
assert_eq!(std::fs::read_to_string(&f).unwrap(), "A beta beta\n");
}
/// Test read file with file_path alias
#[tokio::test]
async fn test_fs_read_file_path_alias() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "hello world").unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "read".to_string(),
file_path: Some(file_path.to_string_lossy().to_string()),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains("hello world"));
}
/// Test write file basic
#[tokio::test]
async fn test_fs_write_basic() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("new.txt");
let tool = FsTool::new();
let args = FsToolArgs {
action: "write".to_string(),
path: Some(file_path.to_string_lossy().to_string()),
content: Some("test content\nline 2".to_string()),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(json["success"].as_bool().unwrap());
assert!(json["bytes"].as_i64().unwrap() > 0);
assert_eq!(json["lines"].as_i64().unwrap(), 2);
// Verify file contents
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "test content\nline 2");
}
/// Test write creates parent directories
#[tokio::test]
async fn test_fs_write_creates_parents() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("a/b/c/deep.txt");
let tool = FsTool::new();
let args = FsToolArgs {
action: "write".to_string(),
path: Some(file_path.to_string_lossy().to_string()),
content: Some("deep content".to_string()),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
// Verify file exists
assert!(file_path.exists());
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "deep content");
}
/// Test edit file single replacement
#[tokio::test]
async fn test_fs_edit_single() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("edit.txt");
fs::write(&file_path, "hello world").unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "edit".to_string(),
path: Some(file_path.to_string_lossy().to_string()),
old_string: Some("world".to_string()),
new_string: Some("rust".to_string()),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(json["success"].as_bool().unwrap());
assert_eq!(json["replacements"].as_i64().unwrap(), 1);
// Verify file contents
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "hello rust");
}
/// Test edit with old_text/new_text aliases
#[tokio::test]
async fn test_fs_edit_aliases() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("edit.txt");
fs::write(&file_path, "hello world").unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "edit".to_string(),
path: Some(file_path.to_string_lossy().to_string()),
old_text: Some("world".to_string()),
new_text: Some("universe".to_string()),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "hello universe");
}
/// Test edit fails on multiple matches without replace_all
#[tokio::test]
async fn test_fs_edit_multiple_without_replace_all() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("edit.txt");
fs::write(&file_path, "hello hello hello").unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "edit".to_string(),
path: Some(file_path.to_string_lossy().to_string()),
old_string: Some("hello".to_string()),
new_string: Some("hi".to_string()),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_err());
let error = result.unwrap_err();
assert!(error.to_string().contains("3") || error.to_string().contains("locations"));
}
/// Test edit with replace_all
#[tokio::test]
async fn test_fs_edit_replace_all() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("edit.txt");
fs::write(&file_path, "hello hello hello").unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "edit".to_string(),
path: Some(file_path.to_string_lossy().to_string()),
old_string: Some("hello".to_string()),
new_string: Some("hi".to_string()),
replace_all: true,
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(json["replacements"].as_i64().unwrap(), 3);
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "hi hi hi");
}
/// Test edit create new file (empty old_string)
#[tokio::test]
async fn test_fs_edit_create_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("new.txt");
let tool = FsTool::new();
let args = FsToolArgs {
action: "edit".to_string(),
path: Some(file_path.to_string_lossy().to_string()),
old_string: Some("".to_string()),
new_string: Some("new file content".to_string()),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(json["created"].as_bool().unwrap());
let content = fs::read_to_string(&file_path).unwrap();
assert_eq!(content, "new file content");
}
/// Test tree operation
#[tokio::test]
async fn test_fs_tree_basic() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir(temp_dir.path().join("subdir")).unwrap();
fs::write(temp_dir.path().join("file1.txt"), "").unwrap();
fs::write(temp_dir.path().join("subdir/file2.txt"), "").unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "tree".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
include_hidden: true, // Temp dirs start with '.' so we need to include hidden
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(json.get("tree").is_some());
assert!(json.get("directories").is_some());
assert!(json.get("files").is_some());
assert!(json["directories"].as_i64().unwrap() >= 1);
assert!(json["files"].as_i64().unwrap() >= 2);
}
/// Test tree with depth limit
#[tokio::test]
async fn test_fs_tree_with_depth() {
let temp_dir = TempDir::new().unwrap();
fs::create_dir_all(temp_dir.path().join("a/b/c/d")).unwrap();
fs::write(temp_dir.path().join("a/b/c/d/deep.txt"), "").unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "tree".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
depth: Some(2),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
// Should not include deeply nested files when depth is limited
assert!(!output.contains("deep.txt"));
}
/// Test find operation
#[tokio::test]
async fn test_fs_find_basic() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("test.rs"), "").unwrap();
fs::write(temp_dir.path().join("test.py"), "").unwrap();
fs::write(temp_dir.path().join("other.txt"), "").unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "find".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
pattern: Some("*.rs".to_string()),
include_hidden: true, // Temp dirs start with '.' so we need to include hidden
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(json.get("matches").is_some());
let matches = json["matches"].as_array().unwrap();
assert_eq!(matches.len(), 1);
assert!(matches[0].as_str().unwrap().contains("test.rs"));
}
/// Test find with limit
#[tokio::test]
async fn test_fs_find_with_limit() {
let temp_dir = TempDir::new().unwrap();
for i in 0..10 {
fs::write(temp_dir.path().join(format!("file{}.txt", i)), "").unwrap();
async fn list_walks_to_depth_filters_and_pages() {
let d = TempDir::new().unwrap();
for f in ["a.rs", "b.txt", "src/c.rs", "src/deep/d.rs"] {
let p = d.path().join(f);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(p, "x").unwrap();
}
let root = d.path().display().to_string();
let one = fs(json!({"action": "list", "uri": root})).await;
let names: Vec<&str> = one["data"]["entries"].as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap()).collect();
assert_eq!(names, ["a.rs", "b.txt", "src"]);
assert_eq!(one["data"]["entries"][2]["is_dir"], true);
let tool = FsTool::new();
let args = FsToolArgs {
action: "find".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
pattern: Some("*.txt".to_string()),
limit: Some(5),
include_hidden: true, // Temp dirs start with '.' so we need to include hidden
..Default::default()
};
let deep = fs(json!({"action": "list", "uri": root, "depth": 3, "pattern": "*.rs"})).await;
let names: Vec<&str> = deep["data"]["entries"].as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap()).collect();
assert_eq!(names, ["a.rs"], "a directory that fails the pattern is not entered: {deep}");
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(json["truncated"].as_bool().unwrap());
assert!(json["count"].as_i64().unwrap() <= 5);
let page = fs(json!({"action": "list", "uri": root, "limit": 2})).await;
assert_eq!(page["data"]["paging"], json!({"cursor": "2", "more": true, "total": 3}));
let rest = fs(json!({"action": "list", "uri": root, "limit": 2, "cursor": "2"})).await;
assert_eq!(rest["data"]["entries"][0]["name"], "src");
}
/// Test search operation
#[tokio::test]
async fn test_fs_search_basic() {
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join("test.rs"),
"fn main() {\n println!(\"Hello\");\n}\n",
)
.unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "search".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
pattern: Some("println".to_string()),
include_hidden: true, // Temp dirs start with '.' so we need to include hidden
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(json.get("results").is_some());
let results = json["results"].as_array().unwrap();
assert!(!results.is_empty());
assert!(results[0]["match"].as_str().unwrap().contains("println"));
async fn search_text_finds_lines_under_a_glob() {
let d = TempDir::new().unwrap();
std::fs::write(d.path().join("a.rs"), "fn main() {}\nlet needle = 1;\n").unwrap();
std::fs::write(d.path().join("b.txt"), "needle\n").unwrap();
let s = fs(json!({"action": "search_text", "pattern": "needle", "uri": d.path().display().to_string(), "glob": "*.rs"})).await;
let m = s["data"]["matches"].as_array().unwrap();
assert_eq!(m.len(), 1, "{s}");
assert_eq!((m[0]["line"].clone(), m[0]["text"].clone()), (json!(2), json!("let needle = 1;")));
}
/// Test search case insensitive
#[tokio::test]
async fn test_fs_search_case_insensitive() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("test.txt"), "Hello World\nHELLO WORLD\n").unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "search".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
pattern: Some("hello".to_string()),
ignore_case: true,
include_hidden: true, // Temp dirs start with '.' so we need to include hidden
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
let results = json["results"].as_array().unwrap();
assert_eq!(results.len(), 2); // Both lines should match
async fn patch_applies_the_rust_grammar() {
let d = TempDir::new().unwrap();
std::fs::write(d.path().join("u.txt"), "keep\nold\n").unwrap();
std::fs::write(d.path().join("gone.txt"), "x").unwrap();
std::env::set_current_dir(d.path()).unwrap();
let p = fs(json!({"action": "patch", "input": "*** Begin Patch\n*** Add File: new.txt\n+hello\n*** Update File: u.txt\n@@\n keep\n-old\n+new\n*** Delete File: gone.txt\n*** End Patch"})).await;
assert_eq!(p["data"]["success"], true, "{p}");
assert_eq!(std::fs::read_to_string(d.path().join("new.txt")).unwrap(), "hello\n");
assert_eq!(std::fs::read_to_string(d.path().join("u.txt")).unwrap(), "keep\nnew\n");
assert!(!d.path().join("gone.txt").exists());
}
/// Test search with context lines
#[tokio::test]
async fn test_fs_search_with_context() {
let temp_dir = TempDir::new().unwrap();
fs::write(
temp_dir.path().join("test.txt"),
"line 1\nline 2\nTARGET\nline 4\nline 5\n",
)
.unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "search".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
pattern: Some("TARGET".to_string()),
context: Some(1),
include_hidden: true, // Temp dirs start with '.' so we need to include hidden
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
let results = json["results"].as_array().unwrap();
assert!(!results.is_empty());
let context = results[0]["context"].as_str().unwrap();
assert!(context.contains("line 2"));
assert!(context.contains("TARGET"));
assert!(context.contains("line 4"));
async fn mv_mkdir_and_a_guarded_rm() {
let d = TempDir::new().unwrap();
let dir = at(&d, "x/y");
assert_eq!(fs(json!({"action": "mkdir", "uri": dir})).await["data"]["created"], true);
assert_eq!(fs(json!({"action": "mkdir", "uri": dir})).await["data"]["created"], false);
std::fs::write(at(&d, "f"), "1").unwrap();
let m = fs(json!({"action": "mv", "uri": at(&d, "f"), "destination": at(&d, "x/y/g")})).await;
assert_eq!(m["data"]["moved"], true);
assert_eq!(fs(json!({"action": "rm", "uri": at(&d, "x")})).await["error"]["code"], "INVALID_PARAMS");
assert_eq!(fs(json!({"action": "rm", "uri": at(&d, "x"), "confirm": true})).await["data"]["removed"], true);
assert!(!d.path().join("x").exists());
}
/// Test info operation
#[tokio::test]
async fn test_fs_info_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
fs::write(&file_path, "hello world").unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "info".to_string(),
path: Some(file_path.to_string_lossy().to_string()),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(json["type"], "file");
assert_eq!(json["size"].as_i64().unwrap(), 11); // "hello world" is 11 bytes
assert!(json.get("modified").is_some());
async fn paths_are_absolute_and_actions_are_named() {
assert_eq!(fs(json!({"action": "read", "uri": "relative.txt"})).await["error"]["message"], "Path must be absolute");
let bad = fs(json!({"action": "edit", "uri": "/tmp/x"})).await;
assert!(bad["error"]["message"].as_str().unwrap().starts_with("Unknown action 'edit'. Available: read, write, stat, list"));
let help = fs(json!({})).await;
assert_eq!(help["data"]["actions"].as_array().unwrap().len(), 10);
}
/// Test info operation on directory
#[tokio::test]
async fn test_fs_info_directory() {
let temp_dir = TempDir::new().unwrap();
let tool = FsTool::new();
let args = FsToolArgs {
action: "info".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(json["type"], "directory");
}
/// Test patch operation - add file
#[tokio::test]
async fn test_fs_patch_add_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("new.txt");
let patch = format!(
r#"*** Begin Patch
*** Add File: {}
+line 1
+line 2
*** End Patch"#,
file_path.to_string_lossy()
);
let tool = FsTool::new();
let args = FsToolArgs {
action: "patch".to_string(),
patch: Some(patch),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
let json: serde_json::Value = serde_json::from_str(&output).unwrap();
assert!(json["applied"].as_i64().unwrap() >= 1);
// Verify file was created
assert!(file_path.exists());
}
/// Test patch operation - update file
#[tokio::test]
async fn test_fs_patch_update_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("existing.txt");
fs::write(&file_path, "hello world\nfoo bar\n").unwrap();
let patch = format!(
r#"*** Begin Patch
*** Update File: {}
@@ context
-hello world
+hello rust
*** End Patch"#,
file_path.to_string_lossy()
);
let tool = FsTool::new();
let args = FsToolArgs {
action: "patch".to_string(),
patch: Some(patch),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let content = fs::read_to_string(&file_path).unwrap();
assert!(content.contains("hello rust"));
assert!(!content.contains("hello world"));
}
/// Test patch operation - delete file
#[tokio::test]
async fn test_fs_patch_delete_file() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("to_delete.txt");
fs::write(&file_path, "will be deleted").unwrap();
let patch = format!(
r#"*** Begin Patch
*** Delete File: {}
*** End Patch"#,
file_path.to_string_lossy()
);
let tool = FsTool::new();
let args = FsToolArgs {
action: "patch".to_string(),
patch: Some(patch),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
// Verify file was deleted
assert!(!file_path.exists());
}
/// Test action aliases
#[tokio::test]
async fn test_fs_action_aliases() {
let temp_dir = TempDir::new().unwrap();
fs::write(temp_dir.path().join("test.txt"), "content").unwrap();
let tool = FsTool::new();
// Test "ls" alias for "tree"
let args = FsToolArgs {
action: "ls".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
// Test "glob" alias for "find"
let args = FsToolArgs {
action: "glob".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
pattern: Some("*.txt".to_string()),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
// Test "grep" alias for "search"
let args = FsToolArgs {
action: "grep".to_string(),
path: Some(temp_dir.path().to_string_lossy().to_string()),
pattern: Some("content".to_string()),
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
}
/// Test tilde expansion
#[tokio::test]
async fn test_fs_tilde_expansion() {
// Skip if we can't get home directory
if dirs::home_dir().is_none() {
return;
}
let tool = FsTool::new();
let args = FsToolArgs {
action: "tree".to_string(),
path: Some("~".to_string()),
depth: Some(1),
..Default::default()
};
let result = tool.execute(args).await;
// Should work without error (home directory exists)
assert!(result.is_ok());
}
/// Test hidden files handling
#[tokio::test]
async fn test_fs_hidden_files() {
let temp_dir = TempDir::new().unwrap();
// Create a visible subdir to test hidden file filtering inside it
let subdir = temp_dir.path().join("testdir");
fs::create_dir(&subdir).unwrap();
fs::write(subdir.join(".hidden"), "").unwrap();
fs::write(subdir.join("visible"), "").unwrap();
let tool = FsTool::new();
// Without include_hidden - search inside visible subdir
let args = FsToolArgs {
action: "tree".to_string(),
path: Some(subdir.to_string_lossy().to_string()),
include_hidden: false,
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(!output.contains(".hidden"));
assert!(output.contains("visible"));
// With include_hidden
let args = FsToolArgs {
action: "tree".to_string(),
path: Some(subdir.to_string_lossy().to_string()),
include_hidden: true,
..Default::default()
};
let result = tool.execute(args).await;
assert!(result.is_ok());
let output = result.unwrap();
assert!(output.contains(".hidden"));
async fn an_image_reads_as_pixels() {
let d = TempDir::new().unwrap();
let png = [0x89, b'P', b'N', b'G', 13, 10, 26, 10];
std::fs::write(d.path().join("i.png"), png).unwrap();
let r = fs(json!({"action": "read", "uri": at(&d, "i.png")})).await;
assert_eq!(r["data"]["mime"], "image/png");
assert_eq!(r["data"]["image"]["data"], "iVBORw0KGgo=");
}