use super::super::create_file::CreateFile;
use super::super::grep::{format_grep, Grep};
use super::super::read_file::{format_read_file, ReadFile};
use super::super::tree::{common_path_prefix, format_list_dir, Tree};
use super::super::RecoverableError;
use super::*;
use crate::agent::Agent;
use crate::lsp::LspManager;
use serde_json::json;
use tempfile::tempdir;
fn flat_matches(result: &serde_json::Value) -> Vec<serde_json::Value> {
let mut out = vec![];
if let Some(groups) = result["file_groups"].as_array() {
for group in groups {
let file = group["file"].as_str().unwrap_or("?");
if let Some(items) = group["items"].as_array() {
for item in items {
let mut clone = item.clone();
if let Some(obj) = clone.as_object_mut() {
obj.insert(
"file".to_string(),
serde_json::Value::String(file.to_string()),
);
}
out.push(clone);
}
}
}
}
out
}
async fn test_ctx() -> ToolContext {
ToolContext {
agent: Agent::new(None).await.unwrap(),
lsp: LspManager::new_arc(),
output_buffer: std::sync::Arc::new(crate::tools::output_buffer::OutputBuffer::new(20)),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
guide_hints_emitted: std::sync::Arc::new(parking_lot::Mutex::new(Default::default())),
workspace_override: None,
}
}
#[tokio::test]
async fn create_file_honors_workspace_override_pin() {
let dir_a = tempdir().unwrap();
let dir_b = tempdir().unwrap();
std::fs::create_dir_all(dir_a.path().join(".codescout")).unwrap();
std::fs::create_dir_all(dir_b.path().join(".codescout")).unwrap();
let root_a = std::fs::canonicalize(dir_a.path()).unwrap();
let root_b = std::fs::canonicalize(dir_b.path()).unwrap();
let agent = Agent::new(Some(dir_b.path().to_path_buf())).await.unwrap();
let mut ctx = test_ctx().await;
ctx.agent = agent;
ctx.workspace_override = Some(root_a.clone());
crate::tools::create_file::CreateFile
.call(json!({ "path": "pinned.txt", "content": "ALPHA" }), &ctx)
.await
.unwrap();
assert!(
root_a.join("pinned.txt").exists(),
"pinned write must land in workspace A"
);
assert!(
!root_b.join("pinned.txt").exists(),
"pinned write must NOT land in the default workspace B (regime-3 bleed)"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn create_file_concurrent_pins_no_cross_workspace_bleed() {
const N: usize = 5;
let mut dirs = Vec::new();
let mut roots = Vec::new();
for _ in 0..N {
let d = tempdir().unwrap();
std::fs::create_dir_all(d.path().join(".codescout")).unwrap();
roots.push(std::fs::canonicalize(d.path()).unwrap());
dirs.push(d); }
let agent = Agent::new(Some(dirs[0].path().to_path_buf()))
.await
.unwrap();
let mut handles = Vec::new();
for (i, root_i) in roots.iter().cloned().enumerate() {
let agent = agent.clone();
handles.push(tokio::spawn(async move {
let mut ctx = test_ctx().await;
ctx.agent = agent;
ctx.workspace_override = Some(root_i);
crate::tools::create_file::CreateFile
.call(
json!({ "path": format!("created-{i}.txt"), "content": format!("BODY-{i}") }),
&ctx,
)
.await
.unwrap();
}));
}
for h in handles {
h.await.unwrap();
}
for (i, root_i) in roots.iter().enumerate() {
let own = root_i.join(format!("created-{i}.txt"));
assert!(
own.exists(),
"task {i}'s file must land in its own workspace"
);
let body = std::fs::read_to_string(&own).unwrap();
assert!(
body.contains(&format!("BODY-{i}")),
"task {i} body mismatch: {body:?}"
);
for (j, root_j) in roots.iter().enumerate() {
if j != i {
assert!(
!root_j.join(format!("created-{i}.txt")).exists(),
"task {i}'s file leaked into workspace {j} (regime-3 write bleed)"
);
}
}
}
}
async fn project_ctx() -> (tempfile::TempDir, ToolContext) {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
(
dir,
ToolContext {
agent,
lsp: LspManager::new_arc(),
output_buffer: std::sync::Arc::new(crate::tools::output_buffer::OutputBuffer::new(20)),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
guide_hints_emitted: std::sync::Arc::new(parking_lot::Mutex::new(Default::default())),
workspace_override: None,
},
)
}
#[tokio::test]
async fn read_file_returns_full_content() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("hello.txt");
std::fs::write(&file, "hello world").unwrap();
let result = ReadFile
.call(json!({ "path": file.to_str().unwrap() }), &ctx)
.await
.unwrap();
assert_eq!(result["content"], "hello world");
}
#[tokio::test]
async fn read_file_with_line_range() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("lines.txt");
std::fs::write(&file, "line1\nline2\nline3\nline4\nline5").unwrap();
let result = ReadFile
.call(
json!({
"path": file.to_str().unwrap(),
"start_line": 2,
"end_line": 4
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result["content"], "line2\nline3\nline4");
}
#[tokio::test]
async fn read_file_rejects_start_line_zero() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("lines.txt");
std::fs::write(&file, "line1\nline2\nline3").unwrap();
let path = file.to_str().unwrap();
let result = ReadFile
.call(
json!({ "path": path, "start_line": 1, "end_line": 2 }),
&ctx,
)
.await
.unwrap();
assert!(
!result["content"].as_str().unwrap().is_empty(),
"valid range must return content"
);
let result = ReadFile
.call(
json!({ "path": path, "start_line": 0, "end_line": 3 }),
&ctx,
)
.await;
assert!(
result.is_err(),
"start_line=0 must be rejected (lines are 1-indexed)"
);
let result = ReadFile
.call(
json!({ "path": path, "start_line": 2, "end_line": 3 }),
&ctx,
)
.await
.unwrap();
assert!(
!result["content"].as_str().unwrap().is_empty(),
"valid range after error cases must still return content"
);
}
#[tokio::test]
async fn read_file_rejects_end_before_start() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("lines.txt");
std::fs::write(&file, "line1\nline2\nline3\nline4\nline5").unwrap();
let path = file.to_str().unwrap();
let result = ReadFile
.call(
json!({ "path": path, "start_line": 2, "end_line": 4 }),
&ctx,
)
.await
.unwrap();
assert!(
!result["content"].as_str().unwrap().is_empty(),
"valid range must return content"
);
let result = ReadFile
.call(
json!({ "path": path, "start_line": 5, "end_line": 2 }),
&ctx,
)
.await;
assert!(result.is_err(), "end_line < start_line must be rejected");
let result = ReadFile
.call(
json!({ "path": path, "start_line": 3, "end_line": 3 }),
&ctx,
)
.await
.unwrap();
assert!(
!result["content"].as_str().unwrap().is_empty(),
"start_line == end_line (single line) must succeed"
);
}
#[tokio::test]
async fn read_file_missing_errors() {
let ctx = test_ctx().await;
let result = ReadFile
.call(json!({ "path": "/no/such/file.txt" }), &ctx)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn read_file_missing_path_param_errors() {
let ctx = test_ctx().await;
let result = ReadFile.call(json!({}), &ctx).await;
assert!(result.is_err());
}
#[tokio::test]
async fn read_file_caps_large_file_in_exploring_mode() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("big.txt");
let content: String = (1..=300)
.map(|i| format!("line {:04} {}\n", i, "x".repeat(30)))
.collect();
std::fs::write(&file, &content).unwrap();
let result = ReadFile
.call(json!({ "path": file.to_str().unwrap() }), &ctx)
.await
.unwrap();
assert!(
result.get("file_id").is_some(),
"300-line file should be buffered; got: {}",
result
);
let file_id = result["file_id"].as_str().unwrap();
assert!(file_id.starts_with("@file_"));
let entry = ctx.output_buffer.get(file_id).unwrap();
assert!(entry.stdout.contains("line 0150"));
}
#[tokio::test]
async fn read_file_small_file_no_overflow() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("small.txt");
std::fs::write(&file, "line 1\nline 2\nline 3\n").unwrap();
let result = ReadFile
.call(json!({ "path": file.to_str().unwrap() }), &ctx)
.await
.unwrap();
assert!(result.get("overflow").is_none());
assert_eq!(result["total_lines"], 3);
}
#[tokio::test]
async fn read_file_tool_ref_returns_full_content() {
let ctx = test_ctx().await;
let id = ctx
.output_buffer
.store_tool("onboarding", "{\"languages\":[\"rust\"]}".to_string());
let result = ReadFile.call(json!({ "path": id }), &ctx).await.unwrap();
assert_eq!(
result["content"],
"{\n \"languages\": [\n \"rust\"\n ]\n}"
);
}
#[tokio::test]
async fn read_file_cmd_ref_returns_full_content() {
let ctx = test_ctx().await;
let id = ctx.output_buffer.store(
"cargo test".to_string(),
"ok\n".to_string(),
String::new(),
0,
);
let result = ReadFile.call(json!({ "path": id }), &ctx).await.unwrap();
assert_eq!(result["content"], "ok\n");
assert_eq!(result["total_lines"], 1);
}
#[tokio::test]
async fn read_file_tool_ref_with_line_range() {
let ctx = test_ctx().await;
let content = "line1\nline2\nline3\nline4\nline5".to_string();
let id = ctx.output_buffer.store_tool("symbols", content);
let result = ReadFile
.call(json!({ "path": id, "start_line": 2, "end_line": 4 }), &ctx)
.await
.unwrap();
assert_eq!(result["content"], "line2\nline3\nline4");
assert_eq!(result["total_lines"], 5);
}
#[tokio::test]
async fn read_file_tool_ref_missing_returns_error() {
let ctx = test_ctx().await;
let result = ReadFile
.call(json!({ "path": "@tool_deadbeef" }), &ctx)
.await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("buffer reference not found"), "got: {}", msg);
}
#[tokio::test]
async fn read_file_tool_ref_invalid_line_range_errors() {
let ctx = test_ctx().await;
let id = ctx
.output_buffer
.store_tool("onboarding", "a\nb\nc".to_string());
let result = ReadFile
.call(json!({ "path": id, "start_line": 5, "end_line": 2 }), &ctx)
.await;
assert!(result.is_err());
let msg = result.unwrap_err().to_string();
assert!(msg.contains("invalid line range"), "got: {}", msg);
}
#[tokio::test]
async fn read_file_buffer_ref_large_range_buffers_as_file_ref() {
let (dir, ctx) = project_ctx().await;
let big: String = (1..=300)
.map(|i| format!("line {:04} padding_padding_padding_padd\n", i))
.collect();
assert!(
big.len() > crate::tools::TOOL_OUTPUT_BUFFER_THRESHOLD,
"test data must exceed threshold ({} bytes), got {}",
crate::tools::TOOL_OUTPUT_BUFFER_THRESHOLD,
big.len()
);
let path = dir.path().join("big.txt");
std::fs::write(&path, &big).unwrap();
let r1 = ReadFile
.call(json!({ "path": path.to_str().unwrap() }), &ctx)
.await
.unwrap();
let file_ref = r1["file_id"]
.as_str()
.expect("large file should produce @file_* ref on first read")
.to_string();
let result = ReadFile
.call(
json!({ "path": file_ref, "start_line": 1, "end_line": 300 }),
&ctx,
)
.await
.unwrap();
assert!(
result.get("file_id").is_some(),
"large buffer-ref range should produce a @file_* ref; got: {}",
result
);
assert_eq!(
result["total_lines"].as_u64().unwrap(),
300,
"total_lines must reflect content lines, not JSON structure lines"
);
assert!(
result["content"].as_str().is_some(),
"auto-chunked range must include first chunk of content; got: {}",
result
);
assert_eq!(
result["complete"], false,
"must signal incomplete for oversized range; got: {}",
result
);
assert!(
result["next"].as_str().unwrap_or("").contains("read_file"),
"must include a next continuation command; got: {}",
result
);
let file_ref2 = result["file_id"].as_str().unwrap().to_string();
let sub = ReadFile
.call(
json!({ "path": file_ref2, "start_line": 50, "end_line": 50 }),
&ctx,
)
.await
.unwrap();
assert!(
sub["content"].as_str().unwrap_or("").contains("line 0050"),
"sub-range on chained @file_* ref must return correct content; got: {}",
sub
);
}
#[tokio::test]
async fn read_file_buffer_ref_range_auto_chunks() {
let (dir, ctx) = project_ctx().await;
let content: String = (1..=300)
.map(|i| format!("line {:04} padding_padding_padding_padd\n", i))
.collect();
assert!(
content.len() > crate::tools::TOOL_OUTPUT_BUFFER_THRESHOLD,
"test data must exceed threshold"
);
let path = dir.path().join("big.txt");
std::fs::write(&path, &content).unwrap();
let r1 = ReadFile
.call(serde_json::json!({ "path": path.to_str().unwrap() }), &ctx)
.await
.unwrap();
let buf_id = r1["file_id"]
.as_str()
.expect("large file should produce @file_* ref on first read")
.to_string();
let result = ReadFile
.call(
serde_json::json!({ "path": buf_id, "start_line": 1, "end_line": 300 }),
&ctx,
)
.await
.unwrap();
assert!(
result.get("content").is_some(),
"should auto-chunk content; got: {result}"
);
assert_eq!(
result["complete"], false,
"should be incomplete; got: {result}"
);
let next = result["next"].as_str().expect("should have next command");
assert!(
next.contains("start_line="),
"next should include start_line; got: {next}"
);
let file_id = result["file_id"].as_str().expect("should have file_id");
assert!(
next.contains(file_id),
"next should reference file_id; got: {next}"
);
}
#[tokio::test]
async fn read_file_real_file_range_auto_chunks() {
let (dir, ctx) = project_ctx().await;
let content: String = (1..=300)
.map(|i| format!("line {:04} padding_padding_padding_padd\n", i))
.collect();
assert!(
content.len() > crate::tools::TOOL_OUTPUT_BUFFER_THRESHOLD,
"test data must exceed threshold"
);
std::fs::write(dir.path().join("big.txt"), &content).unwrap();
let result = ReadFile
.call(
serde_json::json!({ "path": "big.txt", "start_line": 1, "end_line": 300 }),
&ctx,
)
.await
.unwrap();
assert!(
result.get("content").is_some(),
"should auto-chunk; got: {result}"
);
assert_eq!(result["complete"], false);
let next = result["next"].as_str().expect("should have next");
assert!(result["file_id"].as_str().is_some());
assert!(
next.contains("start_line="),
"next should include continuation; got: {next}"
);
let shown = result["shown_lines"].as_array().unwrap();
assert_eq!(shown[0], 1);
}
#[tokio::test]
async fn read_file_real_file_range_shown_lines_mid_file() {
let (dir, ctx) = project_ctx().await;
let content: String = (1..=400)
.map(|i| format!("line {:04} padding_padding_padding_padd\n", i))
.collect();
std::fs::write(dir.path().join("big.txt"), &content).unwrap();
let result = ReadFile
.call(
serde_json::json!({ "path": "big.txt", "start_line": 50, "end_line": 400 }),
&ctx,
)
.await
.unwrap();
assert!(
result.get("content").is_some(),
"should auto-chunk; got: {result}"
);
let shown = result["shown_lines"].as_array().unwrap();
assert_eq!(
shown[0], 50,
"shown_lines[0] should equal the requested start_line (50), got: {}",
shown[0]
);
let end_val = shown[1].as_u64().unwrap();
assert!(
end_val > 50 && end_val < 300,
"shown_lines[1] should be within the file range; got {end_val}"
);
}
#[tokio::test]
async fn read_file_full_buffer_auto_chunks() {
let (dir, ctx) = project_ctx().await;
let content: String = (1..=300)
.map(|i| format!("line {:04} padding_padding_padding_padd\n", i))
.collect();
assert!(
content.len() > crate::tools::TOOL_OUTPUT_BUFFER_THRESHOLD,
"test data must exceed threshold ({} bytes), got {}",
crate::tools::TOOL_OUTPUT_BUFFER_THRESHOLD,
content.len()
);
let file_path = dir.path().join("test.txt");
std::fs::write(&file_path, &content).unwrap();
let buf_id = ctx
.output_buffer
.store_file(file_path.to_string_lossy().into_owned(), content);
let result = ReadFile
.call(serde_json::json!({ "path": &buf_id }), &ctx)
.await
.unwrap();
assert!(
result.get("content").is_some(),
"should auto-chunk; got: {result}"
);
assert_eq!(result["complete"], false);
let next = result["next"].as_str().unwrap();
assert!(
next.contains(&buf_id),
"next should reference original buffer; got: {next}"
);
}
#[tokio::test]
async fn read_file_source_range_blocked_when_symbol_overlaps() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("fixture.rs");
std::fs::write(
&file,
"use std::io;\n\npub fn greet(name: &str) -> String {\n format!(\"Hello, {}!\", name)\n}\n",
)
.unwrap();
let path = file.to_str().unwrap();
let result = ReadFile
.call(
json!({ "path": path, "start_line": 3, "end_line": 5 }),
&ctx,
)
.await;
assert!(
result.is_err(),
"range overlapping a symbol body must be blocked"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("greet"),
"error must name the overlapping symbol, got: {msg}"
);
}
#[tokio::test]
async fn read_file_source_range_force_bypasses_gate() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("fixture.rs");
std::fs::write(
&file,
"use std::io;\n\npub fn greet(name: &str) -> String {\n format!(\"Hello, {}!\", name)\n}\n",
)
.unwrap();
let path = file.to_str().unwrap();
let result = ReadFile
.call(
json!({ "path": path, "start_line": 3, "end_line": 5, "force": true }),
&ctx,
)
.await
.unwrap();
assert!(
result["content"].as_str().unwrap().contains("greet"),
"force=true must return the raw content"
);
}
#[tokio::test]
async fn read_file_source_range_not_blocked_for_imports() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("fixture.rs");
std::fs::write(
&file,
"use std::io;\n\npub fn greet(name: &str) -> String {\n format!(\"Hello, {}!\", name)\n}\n",
)
.unwrap();
let path = file.to_str().unwrap();
let result = ReadFile
.call(
json!({ "path": path, "start_line": 1, "end_line": 1 }),
&ctx,
)
.await
.unwrap();
assert!(
result["content"].as_str().unwrap().contains("use std::io"),
"import-only range must pass through"
);
}
#[tokio::test]
async fn read_file_source_range_non_source_not_blocked() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("config.toml");
std::fs::write(&file, "[package]\nname = \"foo\"\nversion = \"0.1.0\"\n").unwrap();
let path = file.to_str().unwrap();
let result = ReadFile
.call(
json!({ "path": path, "start_line": 1, "end_line": 2 }),
&ctx,
)
.await
.unwrap();
assert!(
result["content"].as_str().unwrap().contains("package"),
"non-source file line range must pass through"
);
}
#[tokio::test]
async fn tree_returns_shallow_entries() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "").unwrap();
std::fs::write(dir.path().join("b.rs"), "").unwrap();
let result = Tree
.call(json!({ "path": dir.path().to_str().unwrap() }), &ctx)
.await
.unwrap();
let entries: Vec<&str> = result["entries"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(entries.len(), 2);
assert!(entries.iter().any(|e| e.ends_with("a.rs")));
assert!(entries.iter().any(|e| e.ends_with("b.rs")));
}
#[tokio::test]
async fn tree_shallow_does_not_descend() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("deep.rs"), "").unwrap();
let result = Tree
.call(
json!({ "path": dir.path().to_str().unwrap(), "recursive": false }),
&ctx,
)
.await
.unwrap();
let entries: Vec<&str> = result["entries"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(!entries.iter().any(|e| e.ends_with("deep.rs")));
}
#[tokio::test]
async fn tree_recursive_descends() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let sub = dir.path().join("sub");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("deep.rs"), "").unwrap();
let result = Tree
.call(
json!({ "path": dir.path().to_str().unwrap(), "recursive": true }),
&ctx,
)
.await
.unwrap();
let entries: Vec<&str> = result["entries"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(entries.iter().any(|e| e.ends_with("deep.rs")));
}
#[tokio::test]
async fn tree_caps_output_in_exploring_mode() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
for i in 0..5 {
std::fs::write(dir.path().join(format!("file_{}.rs", i)), "").unwrap();
}
let result = Tree
.call(json!({ "path": dir.path().to_str().unwrap() }), &ctx)
.await
.unwrap();
let entries = result["entries"].as_array().unwrap();
assert_eq!(entries.len(), 5);
assert!(result.get("overflow").is_none());
}
#[tokio::test]
async fn tree_max_depth_limits_descent() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let deep = dir.path().join("depth1").join("depth2").join("depth3");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("deep.rs"), "").unwrap();
std::fs::write(dir.path().join("depth1").join("shallow.rs"), "").unwrap();
let result = Tree
.call(
json!({ "path": dir.path().to_str().unwrap(), "max_depth": 2 }),
&ctx,
)
.await
.unwrap();
let entries: Vec<&str> = result["entries"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(entries.iter().any(|e| e.ends_with("shallow.rs")));
assert!(!entries.iter().any(|e| e.ends_with("deep.rs")));
assert!(result.get("depth_capped").is_none());
}
#[tokio::test]
async fn tree_recursive_exploring_caps_at_depth_3() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let deep = dir.path().join("a").join("b").join("c").join("d");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("leaf.rs"), "").unwrap();
std::fs::write(dir.path().join("a").join("b").join("mid.rs"), "").unwrap();
let result = Tree
.call(
json!({ "path": dir.path().to_str().unwrap(), "recursive": true }),
&ctx,
)
.await
.unwrap();
let entries: Vec<&str> = result["entries"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(entries.iter().any(|e| e.ends_with("mid.rs")));
assert!(!entries.iter().any(|e| e.ends_with("leaf.rs")));
assert_eq!(result["depth_capped"], json!(3));
}
#[tokio::test]
async fn tree_recursive_focused_no_depth_cap() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let deep = dir.path().join("a").join("b").join("c").join("d");
std::fs::create_dir_all(&deep).unwrap();
std::fs::write(deep.join("leaf.rs"), "").unwrap();
let result = Tree
.call(
json!({
"path": dir.path().to_str().unwrap(),
"recursive": true,
"detail_level": "full"
}),
&ctx,
)
.await
.unwrap();
let entries: Vec<&str> = result["entries"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(entries.iter().any(|e| e.ends_with("leaf.rs")));
assert!(result.get("depth_capped").is_none());
}
#[tokio::test]
async fn search_finds_matching_line() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("code.rs"), "fn main() {}\nlet x = 42;\n").unwrap();
let result = Grep
.call(
json!({ "pattern": "fn main", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
let matches = flat_matches(&result);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0]["line"], 1);
assert!(matches[0]["content"].as_str().unwrap().contains("fn main"));
}
#[tokio::test]
async fn search_returns_no_matches_when_absent() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("code.rs"), "fn main() {}").unwrap();
let result = Grep
.call(
json!({ "pattern": "xyz_not_present", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
assert_eq!(flat_matches(&result).len(), 0);
}
#[tokio::test]
async fn search_respects_limit() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let content = (0..20)
.map(|i| format!("match_{}", i))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(dir.path().join("data.txt"), &content).unwrap();
let result = Grep
.call(
json!({
"pattern": "match_",
"path": dir.path().to_str().unwrap(),
"limit": 5
}),
&ctx,
)
.await
.unwrap();
assert_eq!(flat_matches(&result).len(), 5);
}
#[tokio::test]
async fn search_invalid_regex_errors() {
let (dir, ctx) = project_ctx().await;
let err = Grep
.call(
json!({ "pattern": "foo|[invalid", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap_err();
assert!(
err.downcast_ref::<RecoverableError>().is_some(),
"invalid regex should be RecoverableError, not a hard error"
);
}
#[tokio::test]
async fn search_missing_pattern_errors() {
let ctx = test_ctx().await;
let result = Grep.call(json!({}), &ctx).await;
assert!(result.is_err());
}
#[tokio::test]
async fn create_text_file_writes_content() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("new.txt");
let result = CreateFile
.call(
json!({
"path": file.to_str().unwrap(),
"content": "hello file"
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result, "ok");
assert_eq!(std::fs::read_to_string(&file).unwrap(), "hello file");
}
#[tokio::test]
async fn create_text_file_creates_parent_dirs() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("a").join("b").join("deep.txt");
CreateFile
.call(
json!({
"path": file.to_str().unwrap(),
"content": "nested"
}),
&ctx,
)
.await
.unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), "nested");
}
#[tokio::test]
async fn create_text_file_missing_params_errors() {
let ctx = test_ctx().await;
assert!(CreateFile.call(json!({}), &ctx).await.is_err());
let outside = std::env::temp_dir().join("nonexistent_xplat_test");
let outside_str = outside.to_str().unwrap();
assert!(CreateFile
.call(json!({ "path": outside_str }), &ctx)
.await
.is_err());
}
#[tokio::test]
async fn create_file_refuses_to_overwrite_by_default() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("existing.txt");
std::fs::write(&file, "original").unwrap();
let result = CreateFile
.call(
json!({
"path": file.to_str().unwrap(),
"content": "new content"
}),
&ctx,
)
.await;
assert!(result.is_err(), "must refuse to overwrite");
let contents = std::fs::read_to_string(&file).unwrap();
assert_eq!(contents, "original", "file must be untouched");
}
#[tokio::test]
async fn create_file_overwrites_with_explicit_flag() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("existing.txt");
std::fs::write(&file, "original").unwrap();
CreateFile
.call(
json!({
"path": file.to_str().unwrap(),
"content": "new content",
"overwrite": true
}),
&ctx,
)
.await
.unwrap();
let contents = std::fs::read_to_string(&file).unwrap();
assert_eq!(contents, "new content");
}
#[tokio::test]
async fn tree_glob_matches_pattern() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("foo.rs"), "").unwrap();
std::fs::write(dir.path().join("bar.rs"), "").unwrap();
std::fs::write(dir.path().join("baz.txt"), "").unwrap();
let result = Tree
.call(
json!({
"glob": "*.rs",
"path": dir.path().to_str().unwrap()
}),
&ctx,
)
.await
.unwrap();
let files = result["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
assert!(files.iter().all(|f| f.as_str().unwrap().ends_with(".rs")));
}
#[tokio::test]
async fn tree_glob_recursive() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let sub = dir.path().join("src");
std::fs::create_dir(&sub).unwrap();
std::fs::write(sub.join("lib.rs"), "").unwrap();
std::fs::write(dir.path().join("main.rs"), "").unwrap();
let result = Tree
.call(
json!({
"glob": "**/*.rs",
"path": dir.path().to_str().unwrap()
}),
&ctx,
)
.await
.unwrap();
let files = result["files"].as_array().unwrap();
assert_eq!(files.len(), 2);
}
#[tokio::test]
async fn tree_glob_respects_limit() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
for i in 0..10 {
std::fs::write(dir.path().join(format!("f{}.rs", i)), "").unwrap();
}
let result = Tree
.call(
json!({
"glob": "*.rs",
"path": dir.path().to_str().unwrap(),
"limit": 3
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result["files"].as_array().unwrap().len(), 3);
assert_eq!(result["total"], 3);
}
#[tokio::test]
async fn tree_glob_no_matches() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("readme.md"), "").unwrap();
let result = Tree
.call(
json!({
"glob": "*.rs",
"path": dir.path().to_str().unwrap()
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result["files"].as_array().unwrap().len(), 0);
}
#[tokio::test]
async fn tree_recursive_excludes_git() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let git_dir = dir.path().join(".git");
std::fs::create_dir(&git_dir).unwrap();
std::fs::create_dir(git_dir.join("objects")).unwrap();
std::fs::write(git_dir.join("objects").join("abc123"), "blob").unwrap();
std::fs::create_dir(git_dir.join("hooks")).unwrap();
std::fs::write(git_dir.join("hooks").join("pre-commit"), "#!/bin/sh").unwrap();
std::fs::write(git_dir.join("HEAD"), "ref: refs/heads/main").unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir(&src_dir).unwrap();
std::fs::write(src_dir.join("main.rs"), "fn main() {}").unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "[package]").unwrap();
let result = Tree
.call(
json!({ "path": dir.path().to_str().unwrap(), "recursive": true }),
&ctx,
)
.await
.unwrap();
let entries: Vec<&str> = result["entries"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert!(
entries.iter().any(|e| e.ends_with("main.rs")),
"should contain main.rs, got: {:?}",
entries
);
assert!(
entries.iter().any(|e| e.ends_with("Cargo.toml")),
"should contain Cargo.toml, got: {:?}",
entries
);
assert!(
!entries.iter().any(|e| e.contains(".git")),
".git/ entries should be excluded, got: {:?}",
entries
);
}
#[tokio::test]
async fn search_for_pattern_skips_hidden_dirs() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("main.rs"), "fn hello() {}").unwrap();
let wt_dir = dir.path().join(".worktrees").join("feature");
std::fs::create_dir_all(&wt_dir).unwrap();
std::fs::write(wt_dir.join("lib.rs"), "fn hello() {}").unwrap();
let result = Grep
.call(
json!({ "pattern": "fn hello", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
let matches = flat_matches(&result);
let files: Vec<&str> = matches
.iter()
.map(|v| v["file"].as_str().unwrap())
.collect();
assert!(
files.iter().any(|f| f.ends_with("main.rs")),
"should find match in main.rs"
);
assert!(
!files.iter().any(|f| f.contains(".worktrees")),
".worktrees/ should be excluded, got: {:?}",
files
);
}
#[tokio::test]
async fn tree_glob_skips_hidden_dirs() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("main.rs"), "").unwrap();
let wt_dir = dir.path().join(".claude").join("worktrees").join("branch");
std::fs::create_dir_all(&wt_dir).unwrap();
std::fs::write(wt_dir.join("main.rs"), "").unwrap();
let result = Tree
.call(
json!({ "glob": "**/*.rs", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
let files: Vec<&str> = result["files"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_str().unwrap())
.collect();
assert_eq!(
files.len(),
1,
"only main.rs should match, got: {:?}",
files
);
assert!(
files[0].ends_with("main.rs"),
"expected main.rs, got: {:?}",
files
);
}
#[tokio::test]
async fn read_file_missing_path_errors() {
let ctx = test_ctx().await;
let result = ReadFile.call(json!({}), &ctx).await;
assert!(result.is_err(), "read_file without path should error");
}
#[tokio::test]
async fn read_file_empty_path_errors() {
let ctx = test_ctx().await;
let result = ReadFile.call(json!({ "path": "" }), &ctx).await;
assert!(result.is_err(), "read_file with empty path should error");
}
#[test]
fn reindent_shifts_base_indent() {
let new = " let x = 1;\n let y = 2;";
let out = reindent_block(new, " ", " ");
assert_eq!(out, " let x = 1;\n let y = 2;");
}
#[test]
fn reindent_preserves_relative_deeper_indent() {
let new = " if c {\n body()\n }";
let out = reindent_block(new, " ", " ");
assert_eq!(out, " if c {\n body()\n }");
}
#[test]
fn reindent_blank_lines_stay_blank() {
let new = " a\n\n b";
let out = reindent_block(new, " ", " ");
assert_eq!(out, " a\n\n b");
}
#[test]
fn nearest_window_returns_best_ratio_region() {
let content = "alpha\nlet x = 1;\nlet y = 9;\nomega\n";
let old = "let x = 1;\nlet y = 2;"; let (s, e, text) = nearest_window_hint(content, old).unwrap();
assert_eq!((s, e), (2, 3));
assert_eq!(text, "let x = 1;\nlet y = 9;");
}
#[tokio::test]
async fn create_text_file_missing_params_detailed_errors() {
let ctx = test_ctx().await;
let result = CreateFile.call(json!({ "content": "hello" }), &ctx).await;
assert!(
result.is_err(),
"create_text_file without path should error"
);
let outside = std::env::temp_dir().join("nonexistent_xplat_test.txt");
let outside_str = outside.to_str().unwrap();
let result = CreateFile.call(json!({ "path": outside_str }), &ctx).await;
assert!(
result.is_err(),
"create_text_file without content should error"
);
}
#[tokio::test]
async fn search_for_pattern_missing_pattern_errors() {
let ctx = test_ctx().await;
let result = Grep.call(json!({}), &ctx).await;
assert!(
result.is_err(),
"search_for_pattern without pattern should error"
);
}
#[tokio::test]
async fn tree_without_glob_falls_back_to_list_dir() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("a.txt"), "").unwrap();
let result = Tree
.call(json!({ "path": dir.path().to_str().unwrap() }), &ctx)
.await
.unwrap();
assert!(
result.get("entries").is_some(),
"no glob -> list_dir shape with entries; got: {result}"
);
assert!(result.get("files").is_none());
}
#[tokio::test]
async fn search_for_pattern_invalid_regex_errors() {
let (dir, ctx) = project_ctx().await;
let err = Grep
.call(
json!({ "pattern": "bar|[invalid(", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap_err();
assert!(
err.downcast_ref::<RecoverableError>().is_some(),
"invalid regex should be RecoverableError, not a hard error"
);
}
#[tokio::test]
async fn read_file_nonexistent_errors_gracefully() {
let ctx = test_ctx().await;
let result = ReadFile
.call(json!({ "path": "/nonexistent/path/file.txt" }), &ctx)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn read_file_directory_path_returns_recoverable_error() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let result = ReadFile
.call(json!({ "path": dir.path().to_str().unwrap() }), &ctx)
.await;
let err = result.unwrap_err();
assert!(
err.downcast_ref::<RecoverableError>().is_some(),
"read_file on a directory must be RecoverableError (not a hard error); got: {err}"
);
let rec = err.downcast_ref::<RecoverableError>().unwrap();
assert!(
rec.message.contains("directory"),
"error message should mention 'directory'; got: {}",
rec.message
);
assert!(
rec.hint().unwrap_or("").contains("tree"),
"hint should suggest tree; got: {:?}",
rec.hint()
);
}
#[tokio::test]
async fn read_file_binary_content_does_not_panic() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("binary.bin");
std::fs::write(&file, b"\x00\x01\x02\xff\xfe").unwrap();
let result = ReadFile
.call(json!({ "path": file.to_str().unwrap() }), &ctx)
.await;
let err = result.unwrap_err();
assert!(
err.downcast_ref::<RecoverableError>().is_some(),
"binary file error should be RecoverableError, got: {err}"
);
assert!(
err.to_string().contains("non-UTF-8"),
"error should mention non-UTF-8, got: {err}"
);
}
#[cfg_attr(
target_os = "windows",
ignore = "test passes /nonexistent/directory; Tree validates against project root differently on Windows. See docs/issues/2026-05-24-ci-windows-test-portability-rot.md"
)]
#[tokio::test]
async fn tree_nonexistent_path_errors() {
let ctx = test_ctx().await;
let result = Tree
.call(json!({ "path": "/nonexistent/directory" }), &ctx)
.await
.unwrap();
let entries = result["entries"].as_array().unwrap();
assert!(entries.is_empty());
}
#[tokio::test]
async fn search_for_pattern_limit_respected() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let content = (0..100)
.map(|i| format!("match_{}", i))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(dir.path().join("many.txt"), &content).unwrap();
let result = Grep
.call(
json!({
"pattern": "match_",
"path": dir.path().to_str().unwrap(),
"limit": 5
}),
&ctx,
)
.await
.unwrap();
let matches = flat_matches(&result);
assert_eq!(matches.len(), 5, "limit should be respected");
}
#[tokio::test]
async fn search_for_pattern_single_file_path() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file_path = dir.path().join("spec.md");
std::fs::write(
&file_path,
"# Notifications\nPush notifications are sent via FCM.\nEmail alerts are also supported.\n",
)
.unwrap();
let result = Grep
.call(
json!({
"pattern": "notification|push",
"path": file_path.to_str().unwrap(),
"limit": 40
}),
&ctx,
)
.await
.unwrap();
let matches = flat_matches(&result);
assert!(
!matches.is_empty(),
"search_for_pattern should work with a single file path"
);
}
#[tokio::test]
async fn read_file_denies_ssh_key() {
let ctx = test_ctx().await;
if let Ok(home) = std::env::var("HOME") {
let ssh_path = format!("{}/.ssh/id_rsa", home);
let result = ReadFile.call(json!({ "path": &ssh_path }), &ctx).await;
assert!(result.is_err(), "read of ~/.ssh/id_rsa should be denied");
}
}
#[tokio::test]
async fn create_file_outside_project_rejected() {
let (_dir, ctx) = project_ctx().await;
let result = CreateFile
.call(
json!({
"path": "/var/outside_ce_test/evil.rs",
"content": "evil code"
}),
&ctx,
)
.await;
assert!(result.is_err(), "write outside project should be rejected");
}
#[tokio::test]
async fn create_file_within_project_works() {
let (dir, ctx) = project_ctx().await;
let result = CreateFile
.call(
json!({
"path": dir.path().join("new_file.txt").to_str().unwrap(),
"content": "hello"
}),
&ctx,
)
.await;
assert!(result.is_ok());
assert_eq!(
std::fs::read_to_string(dir.path().join("new_file.txt")).unwrap(),
"hello"
);
}
#[tokio::test]
async fn write_requires_active_project() {
let ctx = test_ctx().await;
let outside = std::env::temp_dir().join("nonexistent_xplat_test.txt");
let outside_str = outside.to_str().unwrap();
let result = CreateFile
.call(json!({ "path": outside_str, "content": "hi" }), &ctx)
.await;
assert!(result.is_err(), "write without active project should error");
}
#[tokio::test]
async fn search_for_pattern_huge_regex_rejected() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("file.txt"), "hello").unwrap();
let huge_pattern = format!("({})", "a?".repeat(100_000));
let err = Grep
.call(
json!({
"pattern": huge_pattern,
"path": dir.path().to_str().unwrap()
}),
&ctx,
)
.await
.unwrap_err();
assert!(
err.downcast_ref::<RecoverableError>().is_some(),
"size-limit rejection must be RecoverableError so parallel sibling calls are not aborted"
);
}
#[tokio::test]
async fn read_file_tags_project_source() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "hello world").unwrap();
let tool = ReadFile;
let result = tool
.call(json!({ "path": "test.txt" }), &ctx)
.await
.unwrap();
assert!(
result["source"].is_null(),
"source should be omitted for project files"
);
}
#[tokio::test]
async fn read_file_allows_source_code_files() {
let (dir, ctx) = project_ctx().await;
let rs_file = dir.path().join("main.rs");
std::fs::write(&rs_file, "fn main() {}\n").unwrap();
let result = ReadFile
.call(json!({ "path": rs_file.to_str().unwrap() }), &ctx)
.await;
assert!(
result.is_ok(),
"read_file should now allow .rs files: {:?}",
result.err()
);
assert!(
result.unwrap()["content"]
.as_str()
.unwrap()
.contains("fn main"),
"content should be returned"
);
}
#[tokio::test]
async fn read_file_allows_non_source_files() {
let (dir, ctx) = project_ctx().await;
let toml_file = dir.path().join("config.toml");
std::fs::write(&toml_file, "key = \"value\"\n").unwrap();
let result = ReadFile
.call(json!({ "path": toml_file.to_str().unwrap() }), &ctx)
.await;
assert!(result.is_ok(), "read_file should allow .toml files");
}
#[tokio::test]
async fn read_file_gates_markdown_files() {
let (dir, ctx) = project_ctx().await;
let md_file = dir.path().join("README.md");
std::fs::write(&md_file, "# Hello\n").unwrap();
let result = ReadFile
.call(json!({ "path": md_file.to_str().unwrap() }), &ctx)
.await;
assert!(
result.is_err(),
"read_file should gate .md files to read_markdown"
);
}
#[tokio::test]
async fn edit_file_replace_all_on_markdown_passes_through() {
let (dir, ctx) = project_ctx().await;
let md_file = dir.path().join("doc.md");
std::fs::write(&md_file, "# Doc\n\nold-id\n\n## Section\n\nold-id again\n").unwrap();
let result = EditFile
.call(
json!({
"path": md_file.to_str().unwrap(),
"old_string": "old-id",
"new_string": "new-id",
"replace_all": true,
}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"edit_file(replace_all=true) on .md must pass through: {:?}",
result
);
let body = std::fs::read_to_string(&md_file).unwrap();
assert!(!body.contains("old-id"), "old-id should be gone: {body}");
assert_eq!(body.matches("new-id").count(), 2);
}
#[tokio::test]
async fn edit_file_single_replace_on_markdown_still_gated() {
let (dir, ctx) = project_ctx().await;
let md_file = dir.path().join("doc.md");
std::fs::write(&md_file, "# Doc\n\nold-id\n").unwrap();
let result = EditFile
.call(
json!({
"path": md_file.to_str().unwrap(),
"old_string": "old-id",
"new_string": "new-id",
}),
&ctx,
)
.await;
assert!(
result.is_err(),
"edit_file without replace_all on .md should be gated: {:?}",
result
);
}
#[tokio::test]
async fn edit_file_batch_all_replace_all_on_markdown_passes_through() {
let (dir, ctx) = project_ctx().await;
let md_file = dir.path().join("doc.md");
std::fs::write(&md_file, "alpha alpha bravo bravo\n").unwrap();
let result = EditFile
.call(
json!({
"path": md_file.to_str().unwrap(),
"edits": [
{ "old_string": "alpha", "new_string": "A", "replace_all": true },
{ "old_string": "bravo", "new_string": "B", "replace_all": true },
]
}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"edit_file batch with every replace_all=true on .md must pass through: {:?}",
result
);
let body = std::fs::read_to_string(&md_file).unwrap();
assert_eq!(body, "A A B B\n");
}
#[tokio::test]
async fn edit_file_batch_mixed_replace_all_on_markdown_still_gated() {
let (dir, ctx) = project_ctx().await;
let md_file = dir.path().join("doc.md");
std::fs::write(&md_file, "alpha bravo\n").unwrap();
let result = EditFile
.call(
json!({
"path": md_file.to_str().unwrap(),
"edits": [
{ "old_string": "alpha", "new_string": "A", "replace_all": true },
{ "old_string": "bravo", "new_string": "B" },
]
}),
&ctx,
)
.await;
assert!(
result.is_err(),
"edit_file mixed batch (some replace_all=false) on .md should be gated: {:?}",
result
);
}
#[tokio::test]
async fn read_file_allows_unknown_extensions() {
let (dir, ctx) = project_ctx().await;
let csv_file = dir.path().join("data.csv");
std::fs::write(&csv_file, "a,b,c\n1,2,3\n").unwrap();
let result = ReadFile
.call(json!({ "path": csv_file.to_str().unwrap() }), &ctx)
.await;
assert!(result.is_ok(), "read_file should allow unknown extensions");
}
#[tokio::test]
async fn read_file_small_file_returns_content_directly() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("small.txt");
std::fs::write(&path, "# Hello\nWorld\n").unwrap();
let result = ReadFile
.call(json!({"file_path": path.to_str().unwrap()}), &ctx)
.await
.unwrap();
assert!(
result.get("file_id").is_none(),
"small file should not buffer"
);
assert!(result["content"].as_str().unwrap().contains("Hello"));
}
#[tokio::test]
async fn read_file_small_source_file_has_hint() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("small.rs");
std::fs::write(&path, "fn main() {}\n").unwrap();
let result = ReadFile
.call(json!({ "path": path.to_str().unwrap() }), &ctx)
.await
.unwrap();
let hint = result["hint"]
.as_str()
.expect("hint field missing for source file");
assert!(hint.contains("symbols(path)"), "got: {hint}");
assert!(hint.contains("include_body=true"), "got: {hint}");
}
#[tokio::test]
async fn read_file_small_non_source_file_no_hint() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("config.json");
std::fs::write(&path, "{\"key\": \"value\"}\n").unwrap();
let result = ReadFile
.call(json!({ "path": path.to_str().unwrap() }), &ctx)
.await
.unwrap();
assert!(
result.get("hint").is_none(),
"non-source file should not have hint"
);
}
#[tokio::test]
async fn read_file_large_file_returns_buffer_ref() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("big.txt");
let content: String = (1..=210)
.map(|i| format!("line {:04} {}\n", i, "x".repeat(45)))
.collect();
std::fs::write(&path, &content).unwrap();
let result = ReadFile
.call(json!({"file_path": path.to_str().unwrap()}), &ctx)
.await
.unwrap();
let file_id = result["file_id"]
.as_str()
.expect("large file should have file_id");
assert!(
file_id.starts_with("@file_"),
"file_id should start with @file_, got: {file_id}"
);
assert!(result["hint"].is_null(), "hint field should be absent");
let entry = ctx.output_buffer.get(file_id).unwrap();
assert!(entry.stdout.contains("line 0100"));
}
#[tokio::test]
async fn read_file_explicit_range_always_returns_directly() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("big.rs");
let content: String = (1..=300).map(|i| format!("// line {}\n", i)).collect();
std::fs::write(&path, &content).unwrap();
let result = ReadFile
.call(
json!({"file_path": path.to_str().unwrap(), "start_line": 1, "end_line": 5}),
&ctx,
)
.await
.unwrap();
assert!(
result.get("file_id").is_none(),
"explicit range should never buffer"
);
assert!(result["content"].as_str().unwrap().contains("line 1"));
}
#[tokio::test]
async fn read_file_large_explicit_range_buffers_as_file_ref() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("big.txt");
let content: String = (1..=300)
.map(|i| format!("line {:04} padding_padding_padding_padd\n", i))
.collect();
assert!(
content.len() > crate::tools::TOOL_OUTPUT_BUFFER_THRESHOLD,
"test data must exceed threshold ({} bytes), got {}",
crate::tools::TOOL_OUTPUT_BUFFER_THRESHOLD,
content.len()
);
std::fs::write(&path, &content).unwrap();
let result = ReadFile
.call(
json!({"file_path": path.to_str().unwrap(), "start_line": 1, "end_line": 300}),
&ctx,
)
.await
.unwrap();
assert!(
result.get("content").is_some(),
"large explicit range should auto-chunk content inline; got: {}",
result
);
assert!(
result.get("file_id").is_some(),
"large explicit range should buffer as @file_* for navigation; got: {}",
result
);
assert!(
!result["complete"].as_bool().unwrap_or(true),
"large explicit range should be incomplete (more chunks follow); got: {}",
result
);
assert!(
result["next"]
.as_str()
.unwrap_or("")
.contains("start_line="),
"auto-chunked range must include a next continuation command; got: {}",
result
);
let file_id = result["file_id"].as_str().unwrap().to_string();
let sub = ReadFile
.call(
json!({"path": file_id, "start_line": 10, "end_line": 10}),
&ctx,
)
.await
.unwrap();
assert!(
sub["content"].as_str().unwrap_or("").contains("line 0010"),
"sub-range on @file_* ref should return line 10; got: {}",
sub
);
}
#[tokio::test]
async fn read_file_small_source_file_no_longer_errors() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("lib.rs");
let content: String = (0..105)
.map(|i| format!("fn fn_{}() {{}}\n\n", i))
.collect();
std::fs::write(&path, &content).unwrap();
let result = ReadFile
.call(json!({"file_path": path.to_str().unwrap()}), &ctx)
.await
.unwrap();
assert!(
result.get("file_id").is_some() || result.get("content").is_some(),
"should buffer or return content, not error; got: {}",
result
);
}
#[tokio::test]
async fn search_pattern_regex_character_class_matches() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("code.js"),
"const foo = async () => {};\nconst bar = async () => {};\nfunction baz() {}\n",
)
.unwrap();
let result = Grep
.call(
json!({ "pattern": "const [a-zA-Z]+ = async", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
let matches = flat_matches(&result);
assert_eq!(
matches.len(),
2,
"should match both const async arrow functions"
);
assert!(matches[0]["content"]
.as_str()
.unwrap()
.contains("const foo"));
assert!(matches[1]["content"]
.as_str()
.unwrap()
.contains("const bar"));
assert_eq!(result["total"], 2);
}
#[tokio::test]
async fn search_pattern_escaped_paren_matches_literal_paren() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("api.js"),
"function check() {\n if (name === 'admin') { return true; }\n}\n",
)
.unwrap();
let result = Grep
.call(
json!({ "pattern": r"if \(name === '", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
let matches = flat_matches(&result);
assert_eq!(matches.len(), 1, "escaped paren should match literal (");
assert!(matches[0]["content"].as_str().unwrap().contains("if (name"));
}
#[tokio::test]
async fn search_pattern_unescaped_paren_literal_fallback() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("api.js"),
"function check() {\n if (name === 'admin') { return true; }\n}\n",
)
.unwrap();
let result = Grep
.call(
json!({ "pattern": "if (name === '", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
assert_eq!(
result["mode"].as_str().unwrap(),
"literal_fallback",
"non-regex-looking invalid regex should use literal fallback"
);
assert!(
!flat_matches(&result).is_empty(),
"literal fallback should find the text"
);
}
#[tokio::test]
async fn search_pattern_dot_matches_any_char_not_literal_dot() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("code.rs"),
"fn main() {}\nfn_main_alt() {}\n",
)
.unwrap();
let result = Grep
.call(
json!({ "pattern": "fn.main", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
let matches = flat_matches(&result);
assert_eq!(
matches.len(),
2,
"dot should match any char including space and underscore"
);
}
#[tokio::test]
async fn search_pattern_literal_fallback_on_plain_text() {
let (dir, ctx) = project_ctx().await;
std::fs::write(
dir.path().join("test.rs"),
"fn check(x: i32) -> bool { if (x > 0) { true } else { false } }\n",
)
.unwrap();
let result = Grep
.call(json!({"pattern": "if (x > 0"}), &ctx)
.await
.unwrap();
assert_eq!(result["mode"].as_str().unwrap(), "literal_fallback");
assert!(result["reason"].as_str().unwrap().contains("literal"));
assert!(!flat_matches(&result).is_empty());
}
#[tokio::test]
async fn search_pattern_literal_fallback_zero_matches() {
let (dir, ctx) = project_ctx().await;
std::fs::write(dir.path().join("test.rs"), "fn main() {}\n").unwrap();
let result = Grep
.call(json!({"pattern": "if (x > 0"}), &ctx)
.await
.unwrap();
assert_eq!(result["mode"].as_str().unwrap(), "literal_fallback");
assert!(flat_matches(&result).is_empty());
assert_eq!(result["total"].as_u64().unwrap(), 0);
}
#[tokio::test]
async fn search_pattern_keeps_error_for_broken_regex_intent() {
let (dir, ctx) = project_ctx().await;
std::fs::write(dir.path().join("test.rs"), "fn main() {}\n").unwrap();
let err = Grep
.call(json!({"pattern": "(foo|bar"}), &ctx)
.await
.unwrap_err();
assert!(
err.downcast_ref::<RecoverableError>().is_some(),
"broken regex with regex intent should be RecoverableError, got: {}",
err
);
}
#[tokio::test]
async fn search_pattern_valid_regex_has_no_mode() {
let (dir, ctx) = project_ctx().await;
std::fs::write(dir.path().join("test.rs"), "fn foo() {}\nfn bar() {}\n").unwrap();
let result = Grep
.call(json!({"pattern": r"fn \w+"}), &ctx)
.await
.unwrap();
assert!(result.get("mode").is_none());
}
#[tokio::test]
async fn search_pattern_multi_file_returns_all_matches() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("a.rs"), "pub fn handler() {}\n").unwrap();
std::fs::write(dir.path().join("b.rs"), "pub fn handler() {}\n").unwrap();
std::fs::write(dir.path().join("c.rs"), "fn unrelated() {}\n").unwrap();
let result = Grep
.call(
json!({ "pattern": "pub fn handler", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
let matches = flat_matches(&result);
assert_eq!(
matches.len(),
2,
"should find matches across multiple files"
);
let files: Vec<&str> = matches
.iter()
.map(|m| m["file"].as_str().unwrap())
.collect();
assert!(files.iter().any(|f| f.ends_with("a.rs")));
assert!(files.iter().any(|f| f.ends_with("b.rs")));
}
#[tokio::test]
async fn search_pattern_case_sensitive_by_default() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("code.rs"),
"async fn handler() {}\nAsync fn Handler() {}\n",
)
.unwrap();
let result = Grep
.call(
json!({ "pattern": "async fn handler", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
let matches = flat_matches(&result);
assert_eq!(
matches.len(),
1,
"search should be case-sensitive by default"
);
assert!(matches[0]["content"].as_str().unwrap().starts_with("async"));
}
#[tokio::test]
async fn read_file_source_with_range_allowed() {
let (dir, ctx) = project_ctx().await;
let rs_file = dir.path().join("lib.rs");
std::fs::write(&rs_file, "line1\nline2\nline3\nline4\nline5\n").unwrap();
let result = ReadFile
.call(
json!({
"path": rs_file.to_str().unwrap(),
"start_line": 2,
"end_line": 4
}),
&ctx,
)
.await
.unwrap();
let content = result["content"].as_str().unwrap();
assert!(content.contains("line2"), "should include line2: {content}");
assert!(content.contains("line4"), "should include line4: {content}");
assert!(
!content.contains("line5"),
"should not include line5: {content}"
);
}
#[tokio::test]
async fn read_file_source_without_range_now_works() {
let (dir, ctx) = project_ctx().await;
let rs_file = dir.path().join("lib.rs");
std::fs::write(&rs_file, "fn main() {}\n").unwrap();
let result = ReadFile
.call(json!({ "path": rs_file.to_str().unwrap() }), &ctx)
.await;
assert!(
result.is_ok(),
"source files should now be readable: {:?}",
result.err()
);
assert!(
result.unwrap()["content"]
.as_str()
.unwrap()
.contains("fn main"),
"content should contain source"
);
}
#[tokio::test]
async fn search_pattern_context_lines_zero_backward_compat() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("code.rs"), "fn main() {}\nlet x = 42;\n").unwrap();
let result = Grep
.call(
json!({ "pattern": "fn main", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
let matches = flat_matches(&result);
assert_eq!(matches.len(), 1);
assert_eq!(matches[0]["line"], 1);
assert!(matches[0]["content"].as_str().unwrap().contains("fn main"));
}
#[tokio::test]
async fn search_pattern_context_lines_single_match() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("code.rs"),
"line1\nline2\nTARGET\nline4\nline5\n",
)
.unwrap();
let result = Grep
.call(
json!({
"pattern": "TARGET",
"path": dir.path().to_str().unwrap(),
"context_lines": 2
}),
&ctx,
)
.await
.unwrap();
let matches = result["matches"].as_array().unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(
matches[0]["match_lines"][0], 3,
"match_lines[0] should be 1-indexed line of TARGET"
);
assert_eq!(
matches[0]["start_line"], 1,
"start_line = match(3) - context(2) = 1"
);
let content = matches[0]["content"].as_str().unwrap();
assert!(
content.contains("line1"),
"context_before should include line1"
);
assert!(content.contains("TARGET"), "content should include match");
assert!(
content.contains("line5"),
"context_after should include line5"
);
}
#[tokio::test]
async fn search_pattern_context_lines_adjacent_matches_merge() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("code.rs"),
"line1\nline2\nMATCH_A\nline4\nMATCH_B\nline6\nline7\n",
)
.unwrap();
let result = Grep
.call(
json!({
"pattern": "MATCH_",
"path": dir.path().to_str().unwrap(),
"context_lines": 2
}),
&ctx,
)
.await
.unwrap();
let matches = result["matches"].as_array().unwrap();
assert_eq!(
matches.len(),
1,
"overlapping context windows should merge into one block"
);
let content = matches[0]["content"].as_str().unwrap();
assert!(
content.contains("MATCH_A"),
"merged block should contain first match"
);
assert!(
content.contains("MATCH_B"),
"merged block should contain second match"
);
assert!(
content.contains("line7"),
"block should extend to MATCH_B's context_after"
);
}
#[tokio::test]
async fn search_pattern_context_mode_total_equals_block_count_not_line_count() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("code.rs"),
"line1\nline2\nMATCH_A\nline4\nMATCH_B\nline6\nline7\n",
)
.unwrap();
let path = dir.path().to_str().unwrap();
let no_ctx = Grep
.call(json!({ "pattern": "MATCH_", "path": path }), &ctx)
.await
.unwrap();
assert_eq!(
no_ctx["total"].as_u64().unwrap(),
2,
"without context, total must equal the number of matching lines"
);
assert_eq!(flat_matches(&no_ctx).len(), 2);
let with_ctx = Grep
.call(
json!({ "pattern": "MATCH_", "path": path, "context_lines": 2 }),
&ctx,
)
.await
.unwrap();
let matches = with_ctx["matches"].as_array().unwrap();
let total = with_ctx["total"].as_u64().unwrap();
assert_eq!(
matches.len(),
1,
"adjacent matches with context_lines=2 must merge into one block"
);
assert_eq!(
total, 1,
"total must be block count (1), not line count (2)"
);
assert_eq!(
total,
matches.len() as u64,
"total must always equal matches.len()"
);
}
#[tokio::test]
async fn search_pattern_context_lines_non_adjacent_matches_separate() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file_content = (1..=20)
.map(|i| {
if i == 2 || i == 18 {
format!("MATCH line{i}")
} else {
format!("other line{i}")
}
})
.collect::<Vec<_>>()
.join("\n")
+ "\n";
std::fs::write(dir.path().join("code.rs"), file_content).unwrap();
let result = Grep
.call(
json!({
"pattern": "MATCH",
"path": dir.path().to_str().unwrap(),
"context_lines": 2
}),
&ctx,
)
.await
.unwrap();
let matches = result["matches"].as_array().unwrap();
assert_eq!(
matches.len(),
2,
"non-overlapping windows should produce two separate blocks"
);
assert_eq!(matches[0]["match_lines"][0], 2);
assert_eq!(matches[1]["match_lines"][0], 18);
}
#[tokio::test]
async fn search_pattern_context_lines_limit_is_global_not_per_file() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let content = "MATCH\nother\nother\nother\nother\nMATCH\n";
std::fs::write(dir.path().join("a.txt"), content).unwrap();
std::fs::write(dir.path().join("b.txt"), content).unwrap();
let result = Grep
.call(
json!({
"pattern": "MATCH",
"path": dir.path().to_str().unwrap(),
"context_lines": 1,
"limit": 3
}),
&ctx,
)
.await
.unwrap();
let matches = result["matches"].as_array().unwrap();
let total_blocks = matches.len();
assert_eq!(
total_blocks, 3,
"limit=3 should produce exactly 3 blocks globally, got {total_blocks}"
);
assert_eq!(result["total"], 3);
assert!(
result.get("overflow").is_some(),
"overflow should be present when cap is hit"
);
}
#[tokio::test]
async fn search_pattern_accepts_library_path() {
use crate::library::registry::{DiscoveryMethod, LibraryRegistry};
let proj_dir = tempdir().unwrap();
let lib_dir = tempdir().unwrap();
std::fs::write(lib_dir.path().join("lib.rs"), "pub fn hello_world() {}").unwrap();
std::fs::create_dir_all(proj_dir.path().join(".codescout")).unwrap();
let mut registry = LibraryRegistry::new();
registry.register(
"fake-lib".to_string(),
lib_dir.path().to_path_buf(),
"rust".to_string(),
DiscoveryMethod::Manual,
true,
);
let registry_path = proj_dir.path().join(".codescout/libraries.json");
registry.save(®istry_path).unwrap();
let agent = Agent::new(Some(proj_dir.path().to_path_buf()))
.await
.unwrap();
let ctx = ToolContext {
agent,
lsp: crate::lsp::LspManager::new_arc(),
output_buffer: std::sync::Arc::new(crate::tools::output_buffer::OutputBuffer::new(20)),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
guide_hints_emitted: std::sync::Arc::new(parking_lot::Mutex::new(Default::default())),
workspace_override: None,
};
let result = Grep
.call(
json!({
"pattern": "hello_world",
"path": lib_dir.path().to_str().unwrap()
}),
&ctx,
)
.await
.unwrap();
let matches = flat_matches(&result);
assert_eq!(
matches.len(),
1,
"expected 1 match in library, got {matches:?}"
);
assert!(
matches[0]["content"]
.as_str()
.unwrap()
.contains("hello_world"),
"match content should include the searched symbol"
);
}
#[tokio::test]
async fn edit_file_replaces_unique_match() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "hello world\n").unwrap();
let result = EditFile
.call(
json!({
"path": file.to_str().unwrap(),
"old_string": "hello",
"new_string": "goodbye"
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result, json!("ok"));
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "goodbye world\n");
}
#[tokio::test]
async fn edit_file_empty_new_string_deletes() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "aaa bbb ccc\n").unwrap();
let result = EditFile
.call(
json!({
"path": file.to_str().unwrap(),
"old_string": " bbb",
"new_string": ""
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result, json!("ok"));
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "aaa ccc\n");
}
#[tokio::test]
async fn edit_file_not_found_errors() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "hello world\n").unwrap();
let err = EditFile
.call(
json!({
"path": file.to_str().unwrap(),
"old_string": "does not exist",
"new_string": "replacement"
}),
&ctx,
)
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("not found"),
"error should mention 'not found', got: {msg}"
);
}
#[tokio::test]
async fn edit_file_multiple_matches_without_replace_all_errors() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "foo bar foo baz foo\n").unwrap();
let err = EditFile
.call(
json!({
"path": file.to_str().unwrap(),
"old_string": "foo",
"new_string": "qux"
}),
&ctx,
)
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("3 times"),
"error should mention '3 times', got: {msg}"
);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "foo bar foo baz foo\n");
}
#[tokio::test]
async fn edit_file_replace_all_replaces_all_occurrences() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "foo bar foo baz foo\n").unwrap();
let result = EditFile
.call(
json!({
"path": file.to_str().unwrap(),
"old_string": "foo",
"new_string": "qux",
"replace_all": true
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result, json!("ok"));
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "qux bar qux baz qux\n");
}
#[tokio::test]
async fn edit_file_empty_old_string_errors() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "some content\n").unwrap();
let err = EditFile
.call(
json!({
"path": file.to_str().unwrap(),
"old_string": "",
"new_string": "replacement"
}),
&ctx,
)
.await
.unwrap_err();
let recoverable = err
.downcast_ref::<RecoverableError>()
.expect("expected a RecoverableError");
let hint = recoverable.hint().unwrap_or("");
assert!(
hint.contains("create_file"),
"expected error hint to mention create_file, got: {hint}"
);
}
#[tokio::test]
async fn edit_file_multiline_replace() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "fn old() {\n todo!()\n}\n").unwrap();
let result = EditFile
.call(
json!({
"path": file.to_str().unwrap(),
"old_string": "fn old() {\n todo!()\n}",
"new_string": "fn new_func() {\n 42\n}"
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result, json!("ok"));
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "fn new_func() {\n 42\n}\n");
}
#[tokio::test]
async fn edit_file_whitespace_sensitive() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, " indented\n").unwrap();
let result = EditFile
.call(
json!({
"path": file.to_str().unwrap(),
"old_string": "indented",
"new_string": "replaced"
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result, json!("ok"));
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, " replaced\n");
}
#[tokio::test]
async fn edit_file_returns_ok_string() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "hello world\n").unwrap();
let result = EditFile
.call(
json!({
"path": file.to_str().unwrap(),
"old_string": "hello",
"new_string": "goodbye"
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result, json!("ok"));
assert!(
result.is_string(),
"response must be a plain string, not an object"
);
}
#[test]
fn tree_glob_format_compact_shows_count() {
use serde_json::json;
let tool = Tree;
let result = json!({ "files": ["src/a.rs", "src/b.rs"], "total": 2 });
let text = tool.format_compact(&result).unwrap();
assert!(text.contains("2 files"), "got: {text}");
}
#[tokio::test]
async fn normalized_apply_lands_and_reindents_with_note() {
let (dir, ctx) = project_ctx().await;
let f = dir.path().join("a.rs");
std::fs::write(&f, "fn a() {\n\t\tlet x = 1;\n}\n").unwrap();
let result = EditFile
.call(json!({ "path": f.to_str().unwrap(), "old_string": " let x = 1;", "new_string": " let x = 42;" }), &ctx)
.await
.unwrap();
assert_eq!(result["applied_via"], "whitespace-normalized match");
assert_eq!(
std::fs::read_to_string(&f).unwrap(),
"fn a() {\n\t\tlet x = 42;\n}\n"
);
}
#[tokio::test]
async fn normalized_apply_handles_trailing_newline_in_strings() {
let (dir, ctx) = project_ctx().await;
let f = dir.path().join("a.rs");
std::fs::write(&f, "fn a() {\n\t\tlet x = 1;\n}\n").unwrap();
let result = EditFile
.call(
json!({
"path": f.to_str().unwrap(),
"old_string": " let x = 1;\n",
"new_string": " let x = 42;\n"
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result["applied_via"], "whitespace-normalized match");
assert_eq!(
std::fs::read_to_string(&f).unwrap(),
"fn a() {\n\t\tlet x = 42;\n}\n"
);
}
#[tokio::test]
async fn exact_match_preferred_no_note() {
let (dir, ctx) = project_ctx().await;
let f = dir.path().join("a.txt");
std::fs::write(&f, "hello world\n").unwrap();
let result = EditFile
.call(
json!({ "path": f.to_str().unwrap(), "old_string": "world", "new_string": "there" }),
&ctx,
)
.await
.unwrap();
assert!(result.get("applied_via").is_none());
assert_eq!(std::fs::read_to_string(&f).unwrap(), "hello there\n");
}
#[tokio::test]
async fn normalized_ambiguous_errors_without_writing() {
let (dir, ctx) = project_ctx().await;
let f = dir.path().join("a.txt");
let original = "\tlog()\nmid\n\t\tlog()\n";
std::fs::write(&f, original).unwrap();
let result = EditFile
.call(
json!({ "path": f.to_str().unwrap(), "old_string": " log()", "new_string": " trace()" }),
&ctx,
)
.await;
assert!(result.is_err(), "ambiguous normalized match must error");
assert_eq!(
std::fs::read_to_string(&f).unwrap(),
original,
"file must be untouched"
);
}
#[tokio::test]
async fn content_diff_falls_through_to_nearest_text_error() {
let (dir, ctx) = project_ctx().await;
let f = dir.path().join("a.txt");
let original = " let x = 1;\n";
std::fs::write(&f, original).unwrap();
let result = EditFile
.call(json!({ "path": f.to_str().unwrap(), "old_string": " let x = 2;", "new_string": "let x = 3;" }), &ctx)
.await;
assert!(result.is_err());
assert_eq!(std::fs::read_to_string(&f).unwrap(), original);
}
#[tokio::test]
async fn normalized_fallback_disabled_for_python() {
let (dir, ctx) = project_ctx().await;
let f = dir.path().join("g.py");
let original = "def f():\n\t\tx = 1\n";
std::fs::write(&f, original).unwrap();
let result = EditFile
.call(
json!({ "path": f.to_str().unwrap(), "old_string": " x = 1", "new_string": " x = 42" }),
&ctx,
)
.await;
assert!(
result.is_err(),
"normalized fallback must be disabled for Python"
);
assert!(
result
.unwrap_err()
.to_string()
.contains("indentation-significant"),
"error must explain the fallback is disabled for indentation-significant languages"
);
assert_eq!(
std::fs::read_to_string(&f).unwrap(),
original,
"file must be untouched"
);
}
#[tokio::test]
async fn normalized_fallback_disabled_for_yaml() {
let (dir, ctx) = project_ctx().await;
let f = dir.path().join("c.yaml");
let original = "root:\n\t\tkey: 1\n";
std::fs::write(&f, original).unwrap();
let result = EditFile
.call(
json!({ "path": f.to_str().unwrap(), "old_string": " key: 1", "new_string": " key: 42" }),
&ctx,
)
.await;
assert!(
result.is_err(),
"normalized fallback must be disabled for YAML"
);
assert_eq!(
std::fs::read_to_string(&f).unwrap(),
original,
"file must be untouched"
);
}
#[tokio::test]
async fn mid_line_miss_stays_exact_only() {
let (dir, ctx) = project_ctx().await;
let f = dir.path().join("a.txt");
let original = "let total = a + b + c;\n";
std::fs::write(&f, original).unwrap();
let result = EditFile
.call(json!({ "path": f.to_str().unwrap(), "old_string": "a + X + c", "new_string": "a + b + d" }), &ctx)
.await;
assert!(result.is_err());
assert_eq!(std::fs::read_to_string(&f).unwrap(), original);
}
#[test]
fn infer_edit_hint_remove_when_new_string_empty() {
let hint = infer_edit_hint("fn foo() {\n bar();\n}", "");
assert!(hint.contains("edit_code"), "got: {hint}");
}
#[test]
fn infer_edit_hint_replace_symbol_for_rust_fn() {
let hint = infer_edit_hint("fn foo() {\n old();\n}", "fn foo() {\n new();\n}");
assert!(hint.contains("edit_code"), "got: {hint}");
}
#[test]
fn infer_edit_hint_replace_symbol_for_python_def() {
let hint = infer_edit_hint(
"def process(x):\n return x",
"def process(x):\n return y",
);
assert!(hint.contains("edit_code"), "got: {hint}");
}
#[test]
fn infer_edit_hint_replace_symbol_for_class() {
let hint = infer_edit_hint("class Foo {\n x: i32\n}", "class Foo {\n y: i32\n}");
assert!(hint.contains("edit_code"), "got: {hint}");
}
#[test]
fn find_def_keyword_ignores_class_in_comment() {
use super::{find_def_keyword, guard_structural_rewrite};
let s = "// handles the slot-intersection class cases\nfoo = bar;\n";
assert!(
find_def_keyword(s, "java").is_none(),
"comment-only 'class' must not be a def keyword"
);
assert!(
find_def_keyword(s, "kotlin").is_none(),
"comment-only 'class' must not be a def keyword (kotlin)"
);
let real = "class Foo {\n x: i32\n}";
assert!(find_def_keyword(real, "java").is_some());
assert!(find_def_keyword(real, "kotlin").is_some());
let result = guard_structural_rewrite(
"Foo.java",
"// slot-intersection class cases\nfoo = bar;\n",
"// slot-intersection class cases\nbaz = bar;\n",
);
assert!(
result.is_ok(),
"guard must not fire for comment-only keyword: {result:?}"
);
}
#[test]
fn guard_allows_blank_line_before_unchanged_fn() {
let old = " let flat = HashMap::new();\n fn covered() {}";
let new = " let flat = HashMap::new();\n\n fn covered() {}";
assert!(guard_structural_rewrite("x.rs", old, new).is_ok());
}
#[test]
fn guard_allows_kotlin_blank_line_before_unchanged_fun() {
let old = " val m = HashMap<String, Int>()\n fun covered(): Int = 0";
let new = " val m = HashMap<String, Int>()\n\n fun covered(): Int = 0";
assert!(guard_structural_rewrite("x.kt", old, new).is_ok());
}
#[test]
fn guard_allows_comment_added_before_unchanged_fn() {
let old = "let x = 1;\nfn foo() {}";
let new = "let x = 1;\n// helper\nfn foo() {}";
assert!(guard_structural_rewrite("x.rs", old, new).is_ok());
}
#[test]
fn guard_blocks_fn_rename() {
let old = "fn foo() {\n body();\n}";
let new = "fn bar() {\n body();\n}";
let err = guard_structural_rewrite("x.rs", old, new).unwrap_err();
assert!(err.message.contains("fn "), "got: {}", err.message);
}
#[test]
fn guard_blocks_new_fn_introduced_in_new_string() {
let old = "let a = 1;\nlet b = 2;";
let new = "let a = 1;\nfn helper() {}\nlet b = 2;";
assert!(guard_structural_rewrite("x.rs", old, new).is_err());
}
#[test]
fn guard_blocks_changed_keyword_line_despite_unchanged_keyword_context() {
let old = "fn keep() {}\nfn foo() {}";
let new = "fn keep() {}\nfn bar() {}";
let err = guard_structural_rewrite("x.rs", old, new).unwrap_err();
assert!(err.message.contains("fn "), "got: {}", err.message);
}
#[test]
fn infer_edit_hint_insert_code_when_new_is_longer() {
let hint = infer_edit_hint("placeholder", "fn extra() {\n todo!();\n}\nplaceholder");
assert!(hint.contains("edit_code"), "got: {hint}");
}
#[tokio::test]
async fn edit_file_allows_singleline_on_rust_source() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("src/lib.rs");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "let x = 1;\n").unwrap();
let result = EditFile
.call(
json!({"path": "src/lib.rs", "old_string": "x = 1", "new_string": "x = 2"}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"single-line edits on source should pass: {:?}",
result.err()
);
}
#[tokio::test]
async fn edit_file_allows_multiline_on_non_source() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("README.txt");
std::fs::write(&path, "line one\nline two\n").unwrap();
let result = EditFile
.call(
json!({"path": "README.txt", "old_string": "line one\nline two", "new_string": "updated one\nupdated two"}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"multi-line edits on non-source should pass: {:?}",
result.err()
);
}
#[tokio::test]
async fn edit_file_warns_multiline_python() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("app.py");
std::fs::write(&path, "def greet():\n print('hello')\n").unwrap();
let result = EditFile
.call(
json!({"path": "app.py", "old_string": "def greet():\n print('hello')", "new_string": "def hello():\n print('hello')"}),
&ctx,
)
.await;
assert!(
result.is_err(),
"should hard-block structural rename on Python"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("symbol definition"),
"error should mention symbol definition, got: {err}"
);
}
#[tokio::test]
async fn edit_file_warns_hint_suggests_remove_when_new_empty() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("src/lib.rs");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "fn foo() {\n bar();\n}\n").unwrap();
let result = EditFile
.call(
json!({"path": "src/lib.rs", "old_string": "fn foo() {\n bar();\n}", "new_string": ""}),
&ctx,
)
.await;
assert!(
result.is_err(),
"should hard-block structural delete on LSP language"
);
let err = result.unwrap_err();
let recoverable = err
.downcast_ref::<RecoverableError>()
.expect("should be RecoverableError");
assert!(
err.to_string().contains("symbol definition"),
"error should mention symbol definition, got: {err}"
);
let hint = recoverable.hint().unwrap_or("");
assert!(
hint.contains("edit_code"),
"hint should mention edit_code, got: {hint}"
);
}
#[tokio::test]
async fn edit_file_blocked_on_source_file_when_debug_enforce_symbol_tools() {
let dir = tempdir().unwrap();
let codescout_dir = dir.path().join(".codescout");
std::fs::create_dir_all(&codescout_dir).unwrap();
std::fs::write(
codescout_dir.join("project.toml"),
"[project]\nname = \"test\"\n\n[security]\ndebug_enforce_symbol_tools = true\n",
)
.unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp: LspManager::new_arc(),
output_buffer: std::sync::Arc::new(crate::tools::output_buffer::OutputBuffer::new(20)),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
guide_hints_emitted: std::sync::Arc::new(parking_lot::Mutex::new(Default::default())),
workspace_override: None,
};
let src_file = dir.path().join("src/lib.rs");
std::fs::create_dir_all(src_file.parent().unwrap()).unwrap();
std::fs::write(&src_file, "fn hello() {\n println!(\"hi\");\n}\n").unwrap();
let result = EditFile
.call(
json!({"path": "src/lib.rs", "old_string": "fn hello() {\n println!(\"hi\");\n}", "new_string": "fn hello_world() {\n println!(\"hi\");\n}"}),
&ctx,
)
.await;
assert!(
result.is_err(),
"should block structural edits when debug_enforce_symbol_tools=true"
);
let err = result.unwrap_err();
let recoverable = err
.downcast_ref::<RecoverableError>()
.expect("should be RecoverableError");
assert!(
err.to_string().contains("debug_enforce_symbol_tools"),
"error should mention the flag, got: {err}"
);
assert!(
recoverable.hint().unwrap_or("").contains("edit_code"),
"hint should point to edit_code, got: {:?}",
recoverable.hint()
);
}
#[tokio::test]
async fn edit_file_allows_literal_substitution_when_debug_enforce_symbol_tools() {
let dir = tempdir().unwrap();
let codescout_dir = dir.path().join(".codescout");
std::fs::create_dir_all(&codescout_dir).unwrap();
std::fs::write(
codescout_dir.join("project.toml"),
"[project]\nname = \"test\"\n\n[security]\ndebug_enforce_symbol_tools = true\n",
)
.unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp: LspManager::new_arc(),
output_buffer: std::sync::Arc::new(crate::tools::output_buffer::OutputBuffer::new(20)),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
guide_hints_emitted: std::sync::Arc::new(parking_lot::Mutex::new(Default::default())),
workspace_override: None,
};
let src_file = dir.path().join("src/lib.rs");
std::fs::create_dir_all(src_file.parent().unwrap()).unwrap();
std::fs::write(&src_file, "fn hello() { let x = \"old_value\"; }\n").unwrap();
let result = EditFile
.call(
json!({"path": "src/lib.rs", "old_string": "\"old_value\"", "new_string": "\"new_value\""}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"literal string substitution should be allowed through debug_enforce_symbol_tools, got: {:?}",
result.err()
);
let content = std::fs::read_to_string(&src_file).unwrap();
assert!(content.contains("\"new_value\""), "file should be updated");
}
async fn repair_ctx(dir: &std::path::Path) -> ToolContext {
std::fs::create_dir_all(dir.join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.to_path_buf())).await.unwrap();
ToolContext {
agent,
lsp: LspManager::new_arc(),
output_buffer: std::sync::Arc::new(crate::tools::output_buffer::OutputBuffer::new(20)),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
guide_hints_emitted: std::sync::Arc::new(parking_lot::Mutex::new(Default::default())),
workspace_override: None,
}
}
#[tokio::test]
async fn edit_file_auto_repairs_literal_newline_in_new_string() {
let dir = tempdir().unwrap();
let ctx = repair_ctx(dir.path()).await;
let src_file = dir.path().join("src/lib.rs");
std::fs::create_dir_all(src_file.parent().unwrap()).unwrap();
std::fs::write(&src_file, "fn a() {}\n").unwrap();
let result = EditFile
.call(
json!({
"path": "src/lib.rs",
"old_string": "fn a() {}",
"new_string": "fn a() {\\n let x = 1;\\n let _ = x;\\n}"
}),
&ctx,
)
.await
.unwrap();
let content = std::fs::read_to_string(&src_file).unwrap();
assert!(content.contains("let x = 1;"), "body inserted: {content}");
assert!(
!content.contains("\\n"),
"literal backslash-n must be decoded to real newlines: {content}"
);
assert!(
result.get("note").is_some(),
"a repair should surface a note: {result:?}"
);
}
#[tokio::test]
async fn edit_file_matches_old_string_with_literal_newline_escapes() {
let dir = tempdir().unwrap();
let ctx = repair_ctx(dir.path()).await;
let src_file = dir.path().join("src/lib.rs");
std::fs::create_dir_all(src_file.parent().unwrap()).unwrap();
std::fs::write(&src_file, "fn a() {\n let x = 1;\n}\n").unwrap();
let result = EditFile
.call(
json!({
"path": "src/lib.rs",
"old_string": " let x = 1;\\n}",
"new_string": " let x = 2;\\n}"
}),
&ctx,
)
.await
.unwrap();
let content = std::fs::read_to_string(&src_file).unwrap();
assert!(
content.contains("let x = 2;"),
"decode-fallback match should apply the edit: {content}"
);
assert_eq!(
result.get("applied_via").and_then(|v| v.as_str()),
Some("escape-decoded match"),
"should report the decode-fallback path: {result:?}"
);
}
#[tokio::test]
async fn edit_file_recovers_over_escaped_quotes_single_line() {
let dir = tempdir().unwrap();
let ctx = repair_ctx(dir.path()).await;
let src_file = dir.path().join("src/lib.rs");
std::fs::create_dir_all(src_file.parent().unwrap()).unwrap();
std::fs::write(&src_file, "fn a() {\n assert!(x, \"msg\");\n}\n").unwrap();
let result = EditFile
.call(
json!({
"path": "src/lib.rs",
"old_string": "assert!(x, \\\"msg\\\");",
"new_string": "assert!(y, \\\"msg\\\");"
}),
&ctx,
)
.await
.unwrap();
let content = std::fs::read_to_string(&src_file).unwrap();
assert!(
content.contains("assert!(y, \"msg\");"),
"quote-decode recovery should apply the edit with plain quotes: {content}"
);
assert_eq!(
result.get("applied_via").and_then(|v| v.as_str()),
Some("escape-decoded match (quotes)"),
"should report the quote-decode recovery path: {result:?}"
);
}
#[tokio::test]
async fn edit_file_recovers_over_escaped_quotes_with_newline() {
let dir = tempdir().unwrap();
let ctx = repair_ctx(dir.path()).await;
let src_file = dir.path().join("src/lib.rs");
std::fs::create_dir_all(src_file.parent().unwrap()).unwrap();
std::fs::write(&src_file, "fn a() {\n assert!(x, \"msg\");\n}\n").unwrap();
let result = EditFile
.call(
json!({
"path": "src/lib.rs",
"old_string": " assert!(x, \\\"msg\\\");\\n}",
"new_string": " assert!(y, \\\"msg\\\");\\n}"
}),
&ctx,
)
.await
.unwrap();
let content = std::fs::read_to_string(&src_file).unwrap();
assert!(
content.contains("assert!(y, \"msg\");"),
"newline+quote recovery should apply the edit: {content}"
);
assert_eq!(
result.get("applied_via").and_then(|v| v.as_str()),
Some("escape-decoded match (quotes)"),
"should report the quote-decode recovery path: {result:?}"
);
}
#[tokio::test]
async fn edit_file_leaves_genuine_escaped_quote_in_file_to_exact_path() {
let dir = tempdir().unwrap();
let ctx = repair_ctx(dir.path()).await;
let src_file = dir.path().join("src/lib.rs");
std::fs::create_dir_all(src_file.parent().unwrap()).unwrap();
std::fs::write(&src_file, "fn a() {\n let s = \"a\\\"b\";\n}\n").unwrap();
let result = EditFile
.call(
json!({
"path": "src/lib.rs",
"old_string": " let s = \"a\\\"b\";",
"new_string": " let s = \"a\\\"b\"; // edited"
}),
&ctx,
)
.await
.unwrap();
let content = std::fs::read_to_string(&src_file).unwrap();
assert!(
content.contains("// edited"),
"exact match should apply the edit: {content}"
);
assert!(
result.get("applied_via").is_none(),
"exact path must not route through any recovery tier: {result:?}"
);
}
#[tokio::test]
async fn edit_file_ambiguous_quote_decode_does_not_apply() {
let dir = tempdir().unwrap();
let ctx = repair_ctx(dir.path()).await;
let src_file = dir.path().join("src/lib.rs");
std::fs::create_dir_all(src_file.parent().unwrap()).unwrap();
std::fs::write(
&src_file,
"fn a() {\n let s = \"x\";\n let t = \"x\";\n}\n",
)
.unwrap();
let result = EditFile
.call(
json!({
"path": "src/lib.rs",
"old_string": "\\\"x\\\"",
"new_string": "\\\"REPLACED\\\""
}),
&ctx,
)
.await;
assert!(
result.is_err(),
"ambiguous quote-decode must not silently apply: {result:?}"
);
let content = std::fs::read_to_string(&src_file).unwrap();
assert!(
!content.contains("REPLACED"),
"no write should occur on an ambiguous decode: {content}"
);
assert_eq!(
content.matches("\"x\"").count(),
2,
"both original occurrences must be untouched: {content}"
);
}
#[tokio::test]
async fn edit_file_preserves_legitimate_backslash_n_in_valid_edit() {
let dir = tempdir().unwrap();
let ctx = repair_ctx(dir.path()).await;
let src_file = dir.path().join("src/lib.rs");
std::fs::create_dir_all(src_file.parent().unwrap()).unwrap();
std::fs::write(&src_file, "fn a() { let s = \"x\"; }\n").unwrap();
EditFile
.call(
json!({
"path": "src/lib.rs",
"old_string": "let s = \"x\";",
"new_string": "let s = \"line1\\nline2\";"
}),
&ctx,
)
.await
.unwrap();
let content = std::fs::read_to_string(&src_file).unwrap();
assert!(
content.contains("line1\\nline2"),
"a legitimate \\n inside a string literal must be preserved verbatim: {content}"
);
}
#[tokio::test]
async fn edit_file_prepend_adds_text_at_start() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "line two\n").unwrap();
let result = EditFile
.call(
json!({"path": "test.txt", "insert": "prepend", "new_string": "line one\n"}),
&ctx,
)
.await
.unwrap();
assert_eq!(result, json!("ok"));
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "line one\nline two\n");
}
#[tokio::test]
async fn edit_file_append_adds_text_at_end() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "line one\n").unwrap();
let result = EditFile
.call(
json!({"path": "test.txt", "insert": "append", "new_string": "line two\n"}),
&ctx,
)
.await
.unwrap();
assert_eq!(result, json!("ok"));
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "line one\nline two\n");
}
#[tokio::test]
async fn edit_file_insert_without_old_string_ok() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "existing\n").unwrap();
let result = EditFile
.call(
json!({"path": "test.txt", "insert": "prepend", "new_string": "header\n"}),
&ctx,
)
.await;
assert!(result.is_ok(), "should succeed without old_string");
}
#[tokio::test]
async fn edit_file_blocks_def_keyword_on_lsp_language() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("src/lib.rs");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "fn foo() {\n old();\n}\n").unwrap();
let result = EditFile
.call(
json!({
"path": "src/lib.rs",
"old_string": "fn foo() {\n old();\n}",
"new_string": "fn bar() {\n old();\n}"
}),
&ctx,
)
.await;
assert!(
result.is_err(),
"should hard-block structural rename on LSP language"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("symbol definition"),
"error should mention symbol definition, got: {err}"
);
}
#[tokio::test]
async fn edit_file_allows_body_edit_on_lsp_language() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("src/lib.rs");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "fn foo() {\n old();\n}\n").unwrap();
let result = EditFile
.call(
json!({
"path": "src/lib.rs",
"old_string": "fn foo() {\n old();\n}",
"new_string": "fn foo() {\n new();\n}"
}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"body edit with unchanged signature should apply: {result:?}"
);
let after = std::fs::read_to_string(&path).unwrap();
assert!(
after.contains("new()"),
"edit should have applied, got: {after}"
);
}
#[tokio::test]
async fn edit_file_passes_non_lsp_language() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("script.lua");
std::fs::write(&path, "function greet()\n print('hi')\nend\n").unwrap();
let result = EditFile
.call(
json!({
"path": "script.lua",
"old_string": "function greet()\n print('hi')\nend",
"new_string": "function greet()\n print('hello')\nend"
}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"should allow structural edit on non-LSP language: {:?}",
result.err()
);
}
#[tokio::test]
async fn edit_file_passes_no_def_keyword() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("src/lib.rs");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "use crate::{\n Foo,\n Bar,\n};\n").unwrap();
let result = EditFile
.call(
json!({
"path": "src/lib.rs",
"old_string": "use crate::{\n Foo,\n Bar,\n}",
"new_string": "use crate::{\n Foo,\n Bar,\n Baz,\n}"
}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"should allow import list edit (no def keyword): {:?}",
result.err()
);
}
#[tokio::test]
async fn edit_file_passes_multiline_non_source() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("README.txt");
std::fs::write(&path, "line one\nline two\n").unwrap();
let result = EditFile
.call(
json!({
"path": "README.txt",
"old_string": "line one\nline two",
"new_string": "updated one\nupdated two"
}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"should allow multi-line edit on non-source file: {:?}",
result.err()
);
}
#[tokio::test]
async fn edit_file_md_gate_blocks_non_insert() {
let (dir, ctx) = project_ctx().await;
let md = dir.path().join("test.md");
std::fs::write(&md, "# Title\ncontent\n").unwrap();
let result = EditFile
.call(
json!({
"path": md.to_str().unwrap(),
"old_string": "content",
"new_string": "new content"
}),
&ctx,
)
.await;
assert!(result.is_err(), "edit_file should gate .md files");
let err = result.unwrap_err();
assert!(
err.to_string().contains("edit_markdown"),
"error should mention edit_markdown"
);
}
#[tokio::test]
async fn edit_file_md_gate_allows_prepend() {
let (dir, ctx) = project_ctx().await;
let md = dir.path().join("test.md");
std::fs::write(&md, "# Title\ncontent\n").unwrap();
let result = EditFile
.call(
json!({
"path": md.to_str().unwrap(),
"insert": "prepend",
"new_string": "---\nfrontmatter\n---\n"
}),
&ctx,
)
.await;
assert!(result.is_ok(), "edit_file prepend should be allowed on .md");
}
#[tokio::test]
async fn edit_file_md_gate_allows_append() {
let (dir, ctx) = project_ctx().await;
let md = dir.path().join("test.md");
std::fs::write(&md, "# Title\ncontent\n").unwrap();
let result = EditFile
.call(
json!({
"path": md.to_str().unwrap(),
"insert": "append",
"new_string": "\n## Footer\n"
}),
&ctx,
)
.await;
assert!(result.is_ok(), "edit_file append should be allowed on .md");
}
#[tokio::test]
async fn edit_file_null_new_string_errors() {
let (dir, ctx) = project_ctx().await;
let file = dir.path().join("test.txt");
std::fs::write(&file, "hello world\n").unwrap();
let err = EditFile
.call(
json!({
"path": file.to_str().unwrap(),
"old_string": "hello",
"new_string": null
}),
&ctx,
)
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("required"),
"should error that new_string is required, got: {msg}"
);
let content = std::fs::read_to_string(&file).unwrap();
assert_eq!(content, "hello world\n");
}
#[tokio::test]
async fn edit_file_warns_on_syntax_error_after_edit() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("app.py");
std::fs::write(&path, "x = 1\ny = 2\n").unwrap();
let result = EditFile
.call(
json!({
"path": path.to_str().unwrap(),
"old_string": "x = 1",
"new_string": "x = (" }),
&ctx,
)
.await
.unwrap();
assert_eq!(
result["status"], "ok",
"syntax error is non-fatal; should not return Err"
);
let warning = result["warning"].as_str().unwrap_or("");
assert!(
warning.contains("syntax"),
"warning should mention 'syntax', got: {warning:?}"
);
}
#[tokio::test]
async fn batch_edit_applies_all() {
let (dir, ctx) = project_ctx().await;
std::fs::write(dir.path().join("test.txt"), "# Title\nfoo\nbar\nbaz\n").unwrap();
let _ = EditFile
.call(
json!({
"path": dir.path().join("test.txt").to_str().unwrap(),
"edits": [
{"old_string": "foo", "new_string": "FOO"},
{"old_string": "bar", "new_string": "BAR"},
{"old_string": "baz", "new_string": "BAZ"}
]
}),
&ctx,
)
.await
.unwrap();
let content = std::fs::read_to_string(dir.path().join("test.txt")).unwrap();
assert!(content.contains("FOO"));
assert!(content.contains("BAR"));
assert!(content.contains("BAZ"));
}
#[tokio::test]
async fn batch_edit_string_coerced() {
let (dir, ctx) = project_ctx().await;
std::fs::write(dir.path().join("test.txt"), "# Title\nfoo\nbar\n").unwrap();
let _ = EditFile
.call(
json!({
"path": dir.path().join("test.txt").to_str().unwrap(),
"edits": "[{\"old_string\":\"foo\",\"new_string\":\"FOO\"},{\"old_string\":\"bar\",\"new_string\":\"BAR\"}]"
}),
&ctx,
)
.await
.unwrap();
let content = std::fs::read_to_string(dir.path().join("test.txt")).unwrap();
assert!(content.contains("FOO"), "first edit should apply");
assert!(content.contains("BAR"), "second edit should apply");
}
#[tokio::test]
async fn batch_edit_atomic_rollback() {
let (dir, ctx) = project_ctx().await;
std::fs::write(dir.path().join("test.txt"), "# Title\nfoo\nbar\n").unwrap();
let result = EditFile
.call(
json!({
"path": dir.path().join("test.txt").to_str().unwrap(),
"edits": [
{"old_string": "foo", "new_string": "FOO"},
{"old_string": "nonexistent", "new_string": "X"}
]
}),
&ctx,
)
.await;
assert!(result.is_err());
let content = std::fs::read_to_string(dir.path().join("test.txt")).unwrap();
assert!(
content.contains("foo"),
"first edit should have been rolled back"
);
}
#[tokio::test]
async fn batch_edit_and_old_string_mutual_exclusion() {
let (dir, ctx) = project_ctx().await;
std::fs::write(dir.path().join("test.txt"), "# Title\n").unwrap();
let result = EditFile
.call(
json!({
"path": dir.path().join("test.txt").to_str().unwrap(),
"old_string": "foo",
"new_string": "bar",
"edits": [{"old_string": "x", "new_string": "y"}]
}),
&ctx,
)
.await;
assert!(result.is_err());
}
#[tokio::test]
async fn batch_edit_line_shift() {
let (dir, ctx) = project_ctx().await;
std::fs::write(
dir.path().join("test.txt"),
"# Title\nline one\nline two\nline three\n",
)
.unwrap();
let _ = EditFile
.call(
json!({
"path": dir.path().join("test.txt").to_str().unwrap(),
"edits": [
{"old_string": "line one", "new_string": "line one\nextra line a\nextra line b"},
{"old_string": "line three", "new_string": "line three updated"}
]
}),
&ctx,
)
.await
.unwrap();
let content = std::fs::read_to_string(dir.path().join("test.txt")).unwrap();
assert!(content.contains("extra line a"));
assert!(content.contains("extra line b"));
assert!(content.contains("line three updated"));
}
#[tokio::test]
async fn batch_edit_blocks_structural_rewrite() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("src").join("lib.rs");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&path,
"pub fn foo() {\n println!(\"old\");\n}\n\npub fn bar() {}\n",
)
.unwrap();
let result = EditFile
.call(
json!({
"path": path.to_str().unwrap(),
"edits": [
{
"old_string": "pub fn foo() {\n println!(\"old\");\n}",
"new_string": "pub fn renamed() {\n println!(\"old\");\n}"
}
]
}),
&ctx,
)
.await;
assert!(result.is_err(), "batch must reject structural rename");
let err = result.unwrap_err();
let recoverable = err
.downcast_ref::<RecoverableError>()
.expect("should be RecoverableError");
assert!(
err.to_string().contains("symbol definition"),
"error should mention symbol definition, got: {err}"
);
let hint = recoverable.hint().unwrap_or("");
assert!(
hint.contains("edit_code"),
"hint should mention edit_code, got: {hint}"
);
let after = std::fs::read_to_string(&path).unwrap();
assert!(after.contains("println!(\"old\")"));
}
#[tokio::test]
async fn batch_edit_blocks_new_symbol_introduction_via_new_string() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("src").join("lib.rs");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "pub fn anchor() {}\n").unwrap();
let result = EditFile
.call(
json!({
"path": path.to_str().unwrap(),
"edits": [
{
"old_string": "pub fn anchor() {}",
"new_string": "pub fn anchor() {}\n\nfn helper() {\n todo!();\n}"
}
]
}),
&ctx,
)
.await;
assert!(
result.is_err(),
"batch must reject new-symbol introduction via new_string"
);
let err = result.unwrap_err();
assert!(
err.to_string().contains("symbol definition"),
"error should mention symbol definition, got: {err}"
);
let recoverable = err
.downcast_ref::<RecoverableError>()
.expect("should be RecoverableError");
let hint = recoverable.hint().unwrap_or("");
assert!(
hint.contains("edit_code"),
"hint should mention edit_code, got: {hint}"
);
let after = std::fs::read_to_string(&path).unwrap();
assert!(
!after.contains("fn helper"),
"file must not be mutated when guard rejects edit, got: {after:?}"
);
}
#[tokio::test]
async fn single_edit_blocks_new_symbol_introduction_via_new_string() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("src/lib.rs");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "pub const VERSION: &str = \"1\";\n").unwrap();
let result = EditFile
.call(
json!({
"path": "src/lib.rs",
"old_string": "pub const VERSION: &str = \"1\";",
"new_string": "pub const VERSION: &str = \"1\";\n\nfn helper() {\n todo!();\n}"
}),
&ctx,
)
.await;
assert!(
result.is_err(),
"single-edit must reject new-symbol introduction via new_string"
);
let err = result.unwrap_err().to_string();
assert!(
err.contains("symbol definition"),
"error should mention symbol definition, got: {err}"
);
}
#[tokio::test]
async fn singleline_new_string_with_fn_token_still_allowed() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("src/lib.rs");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(&path, "// renamed fn old to new\n").unwrap();
let result = EditFile
.call(
json!({
"path": "src/lib.rs",
"old_string": "// renamed fn old to new",
"new_string": "// renamed fn old to new (legacy)"
}),
&ctx,
)
.await;
assert!(
result.is_ok(),
"single-line new_string with fn token should pass: {:?}",
result.err()
);
}
#[test]
fn list_dir_basic() {
let val = serde_json::json!({
"entries": [
"src/tools/ast.rs",
"src/tools/config.rs",
"src/tools/file.rs",
"src/tools/git.rs",
"src/tools/library.rs",
"src/tools/memory.rs",
"src/tools/mod.rs",
"src/tools/output.rs",
"src/tools/semantic.rs",
"src/tools/symbol.rs",
"src/tools/workflow.rs",
"src/tools/user_format.rs"
]
});
let result = format_list_dir(&val);
assert!(result.starts_with("src/tools — 12 entries"));
assert!(result.contains("ast.rs"));
assert!(result.contains("mod.rs"));
assert!(result.contains("user_format.rs"));
assert!(!result.contains("src/tools/ast.rs"));
}
#[test]
fn list_dir_with_overflow() {
let val = serde_json::json!({
"entries": ["src/a.rs", "src/b.rs"],
"overflow": { "shown": 200, "total": 350, "hint": "Use a more specific path" }
});
let result = format_list_dir(&val);
assert!(result.contains("2 entries"));
assert!(result.contains("200 of 350"));
assert!(result.contains("Use a more specific path"));
}
#[test]
fn list_dir_empty() {
let val = serde_json::json!({ "entries": [] });
assert_eq!(format_list_dir(&val), "(empty directory)");
}
#[test]
fn list_dir_no_common_prefix() {
let val = serde_json::json!({
"entries": ["Cargo.toml", "README.md", "src/"]
});
let result = format_list_dir(&val);
assert!(result.starts_with(". — 3 entries"));
assert!(result.contains("Cargo.toml"));
assert!(result.contains("README.md"));
assert!(result.contains("src/"));
}
#[test]
fn normalized_window_unique_on_indentation_diff() {
let content = "fn a() {\n let x = 1;\n let y = 2;\n}\n";
let old = " let x = 1;\n let y = 2;";
let w = find_normalized_windows(content, old);
assert_eq!(w.len(), 1);
assert_eq!((w[0].start_line, w[0].end_line), (2, 3));
}
#[test]
fn normalized_window_zero_on_content_diff() {
let content = " let x = 1;\n";
let old = " let x = 2;";
assert_eq!(find_normalized_windows(content, old).len(), 0);
}
#[test]
fn normalized_window_ambiguous_returns_all() {
let content = " log(x)\nmid\n log(x)\n";
let old = "log(x)";
assert_eq!(find_normalized_windows(content, old).len(), 2);
}
#[test]
fn normalized_window_exact_aligned_multiline() {
let content = "a\nb\nc\nd\n";
let old = "b\nc";
let w = find_normalized_windows(content, old);
assert_eq!(w.len(), 1);
assert_eq!((w[0].start_line, w[0].end_line), (2, 3));
}
#[test]
fn list_dir_recursive_deep_paths() {
let val = serde_json::json!({
"entries": [
"src/lsp/client.rs",
"src/lsp/ops.rs",
"src/lsp/config.rs",
"src/lsp/types.rs"
]
});
let result = format_list_dir(&val);
assert!(result.starts_with("src/lsp — 4 entries"));
assert!(result.contains("client.rs"));
assert!(result.contains("ops.rs"));
}
#[test]
fn list_dir_single_entry() {
let val = serde_json::json!({
"entries": ["src/main.rs"]
});
let result = format_list_dir(&val);
assert!(result.contains("1 entries"));
assert!(result.contains("main.rs"));
}
#[test]
fn list_dir_directories_with_slash() {
let val = serde_json::json!({
"entries": ["src/tools/", "src/lsp/", "src/embed/"]
});
let result = format_list_dir(&val);
assert!(result.contains("tools/"));
assert!(result.contains("lsp/"));
assert!(result.contains("embed/"));
}
#[test]
fn list_dir_missing_entries() {
let val = serde_json::json!({});
assert_eq!(format_list_dir(&val), "");
}
#[test]
fn list_dir_tree_mode_renders_indented() {
let val = serde_json::json!({
"entries": [
"src/lsp/",
"src/lsp/client.rs",
"src/lsp/ops.rs",
"src/tools/",
"src/tools/file.rs",
"src/main.rs"
]
});
let result = format_list_dir(&val);
assert!(result.starts_with("src — 6 entries"));
assert!(result.contains(" lsp/\n"));
assert!(result.contains(" client.rs\n"));
assert!(result.contains(" ops.rs\n"));
assert!(result.contains(" tools/\n"));
assert!(result.contains(" file.rs\n"));
assert!(result.contains(" main.rs\n"));
assert!(!result.contains("src/lsp/client.rs"));
}
#[test]
fn list_dir_depth_capped_note() {
let val = serde_json::json!({
"entries": ["src/a.rs"],
"depth_capped": 3
});
let result = format_list_dir(&val);
assert!(result.contains("depth capped at 3"));
assert!(result.contains("max_depth"));
}
#[test]
fn list_dir_no_depth_capped_note_when_absent() {
let val = serde_json::json!({
"entries": ["src/a.rs", "src/b.rs"]
});
let result = format_list_dir(&val);
assert!(!result.contains("depth capped"));
}
#[test]
fn prefix_empty_input() {
assert_eq!(common_path_prefix(&[]), "");
}
#[test]
fn prefix_single_path() {
assert_eq!(common_path_prefix(&["src/tools/mod.rs"]), "src/tools/");
}
#[test]
fn prefix_common_dir() {
assert_eq!(
common_path_prefix(&["src/tools/a.rs", "src/tools/b.rs"]),
"src/tools/"
);
}
#[test]
fn prefix_no_common() {
assert_eq!(common_path_prefix(&["Cargo.toml", "README.md"]), "");
}
#[test]
fn prefix_partial_name_not_included() {
assert_eq!(
common_path_prefix(&["src/tools/a.rs", "src/tokens/b.rs"]),
"src/"
);
}
#[test]
fn search_simple_mode() {
let val = serde_json::json!({
"file_groups": [
{
"file": "src/tools/mod.rs",
"count": 2,
"items": [
{ "line": 54, "content": "pub struct RecoverableError {" },
{ "line": 60, "content": " RecoverableError { error, hint }" }
]
},
{
"file": "src/server.rs",
"count": 1,
"items": [
{ "line": 230, "content": "RecoverableError => {" }
]
}
],
"total": 3,
"files": 2
});
let result = format_grep(&val);
assert!(result.contains("3 matches"), "got: {result}");
assert!(result.contains("src/tools/mod.rs"), "got: {result}");
assert!(result.contains("src/server.rs"), "got: {result}");
assert!(
result.contains("pub struct RecoverableError {"),
"got: {result}"
);
}
#[test]
fn search_context_mode() {
let val = serde_json::json!({
"matches": [
{
"file": "src/tools/mod.rs",
"match_lines": [54],
"start_line": 52,
"content": "/// Soft error\npub struct RecoverableError {\n pub error: String,"
}
],
"total": 1
});
let result = format_grep(&val);
assert!(result.starts_with("1 match\n"));
assert!(result.contains("src/tools/mod.rs (1)"));
assert!(result.contains(" 52- /// Soft error"));
assert!(result.contains(" 53- pub struct RecoverableError {"));
assert!(result.contains(" 54: pub error: String,"));
}
#[test]
fn search_with_overflow() {
let val = serde_json::json!({
"matches": [
{ "file": "src/a.rs", "line": 1, "content": "match" }
],
"total": 1,
"overflow": { "shown": 50, "hint": "Showing first 50 matches (cap hit). Narrow with a more specific pattern." }
});
let result = format_grep(&val);
assert!(result.contains("first 50"));
assert!(result.contains("Narrow with a more specific pattern"));
}
#[test]
fn search_empty_matches() {
let val = serde_json::json!({
"matches": [],
"total": 0
});
assert_eq!(format_grep(&val), "0 matches");
}
#[test]
fn search_single_match_singular() {
let val = serde_json::json!({
"file_groups": [
{
"file": "src/main.rs",
"count": 1,
"items": [
{ "line": 1, "content": "fn main() {" }
]
}
],
"total": 1,
"files": 1
});
let result = format_grep(&val);
assert!(result.contains("src/main.rs"), "got: {result}");
assert!(result.contains("fn main() {"), "got: {result}");
assert!(!result.contains("matches"), "got: {result}");
}
#[test]
fn search_context_mode_multiple_files() {
let val = serde_json::json!({
"matches": [
{
"file": "src/a.rs",
"match_lines": [10],
"start_line": 9,
"content": "// context\nfn foo() {"
},
{
"file": "src/b.rs",
"match_lines": [5],
"start_line": 4,
"content": "// other\nfn bar() {"
}
],
"total": 2
});
let result = format_grep(&val);
assert!(result.contains("2 matches"));
assert!(result.contains("src/a.rs (1)"));
assert!(result.contains("src/b.rs (1)"));
assert!(result.contains(" 9- // context"));
assert!(result.contains(" 10: fn foo() {"));
assert!(result.contains(" 4- // other"));
assert!(result.contains(" 5: fn bar() {"));
}
#[test]
fn search_simple_alignment() {
let val = serde_json::json!({
"file_groups": [
{
"file": "a.rs",
"count": 1,
"items": [ { "line": 1, "content": "short" } ]
},
{
"file": "very/long/path.rs",
"count": 1,
"items": [ { "line": 100, "content": "long" } ]
}
],
"total": 2,
"files": 2
});
let result = format_grep(&val);
assert!(result.contains("a.rs"), "got: {result}");
assert!(result.contains("very/long/path.rs"), "got: {result}");
}
#[test]
fn search_missing_matches_key() {
let val = serde_json::json!({});
assert_eq!(format_grep(&val), "0 matches");
}
#[tokio::test]
async fn grep_identifier_pattern_adds_suggestion() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("code.rs"),
"pub struct WriteMemory;\nimpl WriteMemory {}\n",
)
.unwrap();
let result = Grep
.call(
json!({ "pattern": "WriteMemory", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
let suggestion = result["suggestion"]
.as_str()
.expect("suggestion field missing");
assert!(
suggestion.contains("symbols(name='WriteMemory')"),
"got: {suggestion}"
);
assert!(
suggestion.contains("references(symbol='WriteMemory')"),
"got: {suggestion}"
);
assert!(
suggestion.contains("call_graph(symbol='WriteMemory'"),
"got: {suggestion}"
);
}
#[tokio::test]
async fn grep_regex_pattern_no_suggestion() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(dir.path().join("code.rs"), "fn main() {}\n").unwrap();
let result = Grep
.call(
json!({ "pattern": "fn.*main", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
assert!(
result.get("suggestion").is_none(),
"regex pattern should not add suggestion"
);
}
#[tokio::test]
async fn grep_pipe_alternation_suggestion_uses_first_part() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("code.rs"),
"struct WriteMemory;\nstruct ReadMemory;\n",
)
.unwrap();
let result = Grep
.call(
json!({ "pattern": "WriteMemory|ReadMemory", "path": dir.path().to_str().unwrap() }),
&ctx,
)
.await
.unwrap();
let suggestion = result["suggestion"]
.as_str()
.expect("suggestion field missing");
assert!(
suggestion.contains("symbols(name='WriteMemory')"),
"got: {suggestion}"
);
}
#[test]
fn read_file_content_mode_basic() {
let val = serde_json::json!({
"content": "fn main() {\n println!(\"hello\");\n}\n",
"total_lines": 3,
"source": "project"
});
let result = format_read_file(&val);
assert!(result.starts_with("3 lines\n"));
assert!(result.contains("fn main() {\n println!(\"hello\");\n}"));
assert!(
!result.contains("1| "),
"line-number prefixes must be dropped; got: {result}"
);
}
#[test]
fn read_file_content_mode_single_line() {
let val = serde_json::json!({
"content": "hello world",
"total_lines": 1,
"source": "project"
});
let result = format_read_file(&val);
assert!(result.starts_with("1 line\n"));
assert!(!result.starts_with("1 lines"));
assert!(result.contains("hello world"));
assert!(
!result.contains("1| hello world"),
"no line-number prefix; got: {result}"
);
}
#[test]
fn read_file_content_with_overflow() {
let val = serde_json::json!({
"content": "line1\nline2\n",
"total_lines": 500,
"source": "project",
"overflow": { "shown": 200, "total": 500, "hint": "File has 500 lines. Use start_line/end_line to read specific ranges" }
});
let result = format_read_file(&val);
assert!(result.starts_with("500 lines\n"));
assert!(result.contains("200 of 500"));
assert!(result.contains("start_line/end_line"));
}
#[test]
fn read_file_empty_content() {
let val = serde_json::json!({
"content": "",
"total_lines": 0,
"source": "project"
});
let result = format_read_file(&val);
assert_eq!(result, "0 lines");
}
#[test]
fn read_file_missing_content() {
let val = serde_json::json!({});
assert_eq!(format_read_file(&val), "");
}
#[test]
fn read_file_buffered_range_shows_hint() {
let val = serde_json::json!({
"content": "line 0001 padding text\nline 0002 padding text\nline 0003 padding text",
"file_id": "@file_abc123",
"total_lines": 311,
"shown_lines": [1, 3],
"complete": false,
"next": "read_file(\"@file_abc123\", start_line=4, end_line=6)"
});
let result = format_read_file(&val);
assert!(
result.contains("line 0001"),
"should show content; got: {result}"
);
assert!(
result.contains("3 of 311"),
"should show progress; got: {result}"
);
assert!(
result.contains("start_line=4"),
"should show next command; got: {result}"
);
}
#[test]
fn format_read_file_auto_chunked() {
let val = serde_json::json!({
"content": "line 0001 text\nline 0002 text\nline 0003 text",
"total_lines": 300,
"shown_lines": [1, 3],
"complete": false,
"file_id": "@file_abc123",
"next": "read_file(\"@file_abc123\", start_line=4, end_line=6)"
});
let result = format_read_file(&val);
assert!(
result.contains("line 0001 text\nline 0002 text"),
"should show raw content; got: {result}"
);
assert!(
!result.contains("1| "),
"line-number prefixes must be dropped; got: {result}"
);
assert!(
result.contains("3 of 300"),
"should show progress; got: {result}"
);
assert!(
result.contains("start_line=4"),
"should show next; got: {result}"
);
assert!(
result.contains("@file_abc123"),
"should show buffer ref; got: {result}"
);
}
#[test]
fn format_read_file_auto_chunked_mid_file() {
let val = serde_json::json!({
"content": "middle content\nmore content",
"total_lines": 300,
"shown_lines": [50, 51],
"complete": false,
"next": "read_file(\"@file_abc\", start_line=52, end_line=53)"
});
let result = format_read_file(&val);
assert!(
result.contains("middle content\nmore content"),
"should show raw content; got: {result}"
);
assert!(
!result.contains("50|") && !result.contains("51|"),
"line-number prefixes must be dropped; got: {result}"
);
assert!(
result.contains("start_line=52"),
"should still show next hint; got: {result}"
);
}
#[test]
fn format_read_file_auto_chunked_complete() {
let val = serde_json::json!({
"content": "line 1\nline 2",
"total_lines": 2,
"shown_lines": [1, 2],
"complete": true,
});
let result = format_read_file(&val);
assert!(
result.contains("line 1"),
"should show content; got: {result}"
);
assert!(
!result.contains("Next:"),
"should not show next for complete reads; got: {result}"
);
}
#[test]
fn read_file_source_summary() {
let val = serde_json::json!({
"type": "source",
"line_count": 500,
"symbols": [
{ "name": "OutputGuard", "kind": "Struct", "line": 35 },
{ "name": "cap_items", "kind": "Function", "line": 55 }
],
"file_id": "@file_abc123",
"hint": "Full file stored as @file_abc123. Query with: run_command(\"grep/sed/awk @file_abc123\")"
});
let result = format_read_file(&val);
assert!(result.starts_with("500 lines\n"));
assert!(result.contains("Symbols:"));
assert!(result.contains("Struct"));
assert!(result.contains("OutputGuard"));
assert!(result.contains("L35"));
assert!(result.contains("Function"));
assert!(result.contains("cap_items"));
assert!(result.contains("L55"));
assert!(result.contains("Buffer: @file_abc123"));
assert!(result.contains("Full file stored as @file_abc123"));
}
#[test]
fn read_file_markdown_summary() {
let val = serde_json::json!({
"type": "markdown",
"line_count": 200,
"headings": [
{"heading": "# Title", "level": 1, "line": 1, "end_line": 200},
{"heading": "## Section 1", "level": 2, "line": 5, "end_line": 100},
{"heading": "## Section 2", "level": 2, "line": 101, "end_line": 200}
],
"file_id": "@file_xyz",
"hint": "Full file stored as @file_xyz."
});
let result = format_read_file(&val);
assert!(result.starts_with("200 lines (Markdown)\n"));
assert!(result.contains("Headings:"));
assert!(result.contains("# Title L1-200"));
assert!(result.contains("## Section 1 L5-100"));
assert!(result.contains("## Section 2 L101-200"));
assert!(result.contains("Buffer: @file_xyz"));
}
#[test]
fn read_file_config_summary() {
let val = serde_json::json!({
"type": "config",
"line_count": 50,
"preview": "[package]\nname = \"codescout\"\nversion = \"0.1.0\"",
"file_id": "@file_cfg",
"hint": "Full file stored as @file_cfg."
});
let result = format_read_file(&val);
assert!(result.starts_with("50 lines (Config)\n"));
assert!(result.contains("Preview:"));
assert!(result.contains("[package]"));
assert!(result.contains("name = \"codescout\""));
assert!(result.contains("Buffer: @file_cfg"));
}
#[test]
fn read_file_generic_summary() {
let val = serde_json::json!({
"type": "generic",
"line_count": 300,
"head": "first line\nsecond line",
"tail": "last line",
"file_id": "@file_gen",
"hint": "Full file stored as @file_gen."
});
let result = format_read_file(&val);
assert!(result.starts_with("300 lines\n"));
assert!(result.contains("Head:"));
assert!(result.contains("first line"));
assert!(result.contains("second line"));
assert!(result.contains("Tail:"));
assert!(result.contains("last line"));
assert!(result.contains("Buffer: @file_gen"));
}
#[test]
fn read_file_source_summary_empty_symbols() {
let val = serde_json::json!({
"type": "source",
"line_count": 100,
"symbols": [],
"file_id": "@file_empty",
"hint": "Full file stored as @file_empty."
});
let result = format_read_file(&val);
assert!(result.starts_with("100 lines\n"));
assert!(!result.contains("Symbols:"));
assert!(result.contains("Buffer: @file_empty"));
}
#[test]
fn read_file_lineno_alignment() {
let content = (1..=12)
.map(|i| format!("line {i}"))
.collect::<Vec<_>>()
.join("\n");
let val = serde_json::json!({
"content": content,
"total_lines": 12,
"source": "project"
});
let result = format_read_file(&val);
assert!(result.contains("line 1\nline 2"));
assert!(result.contains("line 12"));
assert!(
!result.contains(" 1| ") && !result.contains("10| "),
"line-number prefixes must be dropped; got: {result}"
);
}
#[test]
fn read_file_source_summary_symbol_alignment() {
let val = serde_json::json!({
"type": "source",
"line_count": 200,
"symbols": [
{ "name": "Foo", "kind": "Struct", "line": 10 },
{ "name": "bar_function", "kind": "Function", "line": 50 }
],
"file_id": "@file_align",
"hint": "test"
});
let result = format_read_file(&val);
assert!(result.contains("Struct "));
assert!(result.contains("Function"));
}
#[test]
fn read_file_markdown_empty_headings() {
let val = serde_json::json!({
"type": "markdown",
"line_count": 50,
"headings": [],
"file_id": "@file_md",
"hint": "test"
});
let result = format_read_file(&val);
assert!(result.starts_with("50 lines (Markdown)\n"));
assert!(!result.contains("Headings:"));
assert!(result.contains("Buffer: @file_md"));
}
#[tokio::test]
async fn read_file_string_start_end_line_parsed_as_numbers() {
let dir = tempdir().unwrap();
let file = dir.path().join("test.txt");
std::fs::write(
&file,
"line1
line2
line3
line4
line5",
)
.unwrap();
let ctx = test_ctx().await;
let result = ReadFile
.call(
json!({
"path": file.to_str().unwrap(),
"start_line": "2",
"end_line": "4"
}),
&ctx,
)
.await
.unwrap();
let content = result["content"].as_str().unwrap();
assert_eq!(
content,
"line2
line3
line4"
);
}
#[tokio::test]
async fn read_file_large_content_returns_file_id_not_inline() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = crate::agent::Agent::new(Some(dir.path().to_path_buf()))
.await
.unwrap();
let ctx = ToolContext {
agent,
lsp: std::sync::Arc::new(crate::lsp::LspManager::new()),
output_buffer: std::sync::Arc::new(crate::tools::output_buffer::OutputBuffer::new(20)),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
guide_hints_emitted: std::sync::Arc::new(parking_lot::Mutex::new(Default::default())),
workspace_override: None,
};
let file = dir.path().join("big.txt");
let line = "x".repeat(100);
let lines: Vec<&str> = std::iter::repeat_n(line.as_str(), 120).collect();
std::fs::write(
&file,
lines.join(
"
",
),
)
.unwrap();
let result = ReadFile
.call(json!({ "path": file.to_str().unwrap() }), &ctx)
.await
.unwrap();
assert!(
result["file_id"].as_str().is_some(),
"large file should return file_id, got: {}",
serde_json::to_string_pretty(&result).unwrap()
);
assert!(
result["content"].is_null(),
"should NOT have inline content"
);
}
#[tokio::test]
async fn read_file_small_fat_file_returns_content_inline() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("data.jsonl");
let line = format!(
"{{\"id\":1,\"data\":\"{}\"}}\n",
"x".repeat(550) );
let content: String = line.as_str().repeat(10);
assert!(
content.len() > 5_000,
"test file must exceed old 5KB threshold"
);
assert!(
content.len() / 4 <= crate::tools::MAX_INLINE_TOKENS,
"test file must be under new token threshold"
);
std::fs::write(&file, &content).unwrap();
let result = ReadFile
.call(json!({ "path": file.to_str().unwrap() }), &ctx)
.await
.unwrap();
assert!(
result.get("content").is_some(),
"small file should have inline content; got: {}",
serde_json::to_string_pretty(&result).unwrap()
);
assert!(
result.get("file_id").is_none(),
"small file should NOT be buffered; got: {}",
serde_json::to_string_pretty(&result).unwrap()
);
}
#[tokio::test]
async fn read_file_large_token_count_is_buffered() {
let ctx = test_ctx().await;
let dir = tempdir().unwrap();
let file = dir.path().join("big.py");
let line = format!("# {}\n", "x".repeat(95));
let content: String = line.as_str().repeat(150);
assert!(
content.len() / 4 > crate::tools::MAX_INLINE_TOKENS,
"test file must exceed token threshold"
);
std::fs::write(&file, &content).unwrap();
let result = ReadFile
.call(json!({ "path": file.to_str().unwrap() }), &ctx)
.await
.unwrap();
assert!(
result.get("file_id").is_some(),
"large file should be buffered; got: {}",
serde_json::to_string_pretty(&result).unwrap()
);
}
#[tokio::test]
async fn edit_file_batch_mixed_structural_lists_safe_indices_in_hint() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("app.py");
std::fs::write(
&path,
"MARKER = 'old'\n\ndef greet():\n print('hello')\n",
)
.unwrap();
let result = EditFile
.call(
json!({
"path": "app.py",
"edits": [
{ "old_string": "MARKER = 'old'", "new_string": "MARKER = 'new'" },
{
"old_string": "def greet():\n print('hello')",
"new_string": "def hello():\n print('hello')",
},
]
}),
&ctx,
)
.await;
assert!(result.is_err());
let err = result.unwrap_err();
let recoverable = err
.downcast_ref::<RecoverableError>()
.expect("should be RecoverableError");
let msg = err.to_string();
let hint = recoverable.hint().unwrap_or("");
assert!(msg.contains("edit[1]"), "got: {msg}");
assert!(msg.contains("symbol definition"), "got: {msg}");
assert!(hint.contains("[1]"), "got: {hint}");
assert!(hint.contains("[0]"), "got: {hint}");
assert!(hint.contains("split"), "got: {hint}");
let body = std::fs::read_to_string(&path).unwrap();
assert_eq!(body, "MARKER = 'old'\n\ndef greet():\n print('hello')\n");
}
#[tokio::test]
async fn edit_file_batch_all_safe_passes_through() {
let (dir, ctx) = project_ctx().await;
let path = dir.path().join("app.py");
std::fs::write(&path, "alpha bravo charlie\n").unwrap();
let result = EditFile
.call(
json!({
"path": "app.py",
"edits": [
{ "old_string": "alpha", "new_string": "ALPHA" },
{ "old_string": "bravo", "new_string": "BRAVO" },
]
}),
&ctx,
)
.await;
assert!(result.is_ok(), "all-safe batch must pass: {result:?}");
let body = std::fs::read_to_string(&path).unwrap();
assert_eq!(body, "ALPHA BRAVO charlie\n");
}
#[tokio::test]
async fn normalized_apply_aborts_on_introduced_syntax_error() {
let (dir, ctx) = project_ctx().await;
let f = dir.path().join("a.rs");
let original = "fn a() {\n\t\tlet x = 1;\n}\n"; std::fs::write(&f, original).unwrap();
let result = EditFile
.call(
json!({
"path": f.to_str().unwrap(),
"old_string": " let x = 1;",
"new_string": " let x = 1; {{{"
}),
&ctx,
)
.await;
assert!(
result.is_err(),
"relaxed edit that introduces syntax errors must be rejected"
);
assert_eq!(
std::fs::read_to_string(&f).unwrap(),
original,
"file must be unchanged"
);
}
#[tokio::test]
async fn normalized_apply_allowed_when_file_already_broken() {
let (dir, ctx) = project_ctx().await;
let f = dir.path().join("a.rs");
std::fs::write(&f, "fn a() {\n\t\tlet x = 1;\n").unwrap();
let result = EditFile
.call(
json!({
"path": f.to_str().unwrap(),
"old_string": " let x = 1;",
"new_string": " let x = 2;"
}),
&ctx,
)
.await
.unwrap();
assert_eq!(result["applied_via"], "whitespace-normalized match");
assert!(std::fs::read_to_string(&f).unwrap().contains("let x = 2;"));
}