mirror of
https://github.com/hanzoai/mcp.git
synced 2026-10-15 12:30:12 +00:00
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
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:
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
+96
-703
@@ -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=");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user