use super::display::{
format_goto_definition, format_hover, format_overview_symbols, format_search_symbols,
};
use super::list_overview::{
ast_class_names_for_dir, count_files_by_subdir, find_split_point, flat_symbol_count,
LIST_SYMBOLS_SINGLE_FILE_FLAT_CAP,
};
use super::symbols::{build_by_file, make_search_symbols_hint};
use super::*;
use crate::agent::Agent;
use crate::fs::{
classify_reference_path, format_library_path, resolve_library_roots, tag_external_path,
uri_to_path,
};
use crate::lsp::SymbolInfo;
use crate::symbol::edit::{
apply_text_edits, clamp_range_to_parent, editing_end_line, editing_end_line_strict,
editing_start_line, find_insert_before_line, text_sweep, write_lines,
};
use crate::symbol::query::{
collect_matching, filter_variable_symbols, find_matching_symbol, find_symbol_by_name_path,
find_unique_symbol_by_name_path, is_lead_in_line, matches_kind_filter, symbol_name_matches,
symbol_to_json, validate_symbol_position, validate_symbol_range,
};
use crate::tools::{Tool, ToolContext};
use serde_json::{json, Value};
use std::path::PathBuf;
use std::sync::Arc;
use tempfile::tempdir;
fn lsp() -> Arc<dyn crate::lsp::LspProvider> {
crate::lsp::LspManager::new_arc()
}
fn buf() -> Arc<crate::tools::output_buffer::OutputBuffer> {
Arc::new(crate::tools::output_buffer::OutputBuffer::new(20))
}
fn substr_pred(pat: &'static str) -> impl Fn(&SymbolInfo) -> bool {
move |sym: &SymbolInfo| {
sym.name.to_lowercase().contains(pat) || sym.name_path.to_lowercase().contains(pat)
}
}
async fn rust_project_ctx() -> Option<(tempfile::TempDir, ToolContext)> {
if !std::process::Command::new("rust-analyzer")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
return None;
}
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
r#"[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
"#,
)
.unwrap();
std::fs::create_dir_all(dir.path().join("src")).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-project\"\n\n[lsp.rust]\nmux = false\n",
)
.unwrap();
std::fs::write(
dir.path().join("src/main.rs"),
r#"fn main() {
println!("hello");
}
fn add(a: i32, b: i32) -> i32 {
a + b
}
struct Point {
x: f64,
y: f64,
}
impl Point {
fn new(x: f64, y: f64) -> Self {
Self { x, y }
}
}
"#,
)
.unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
Some((
dir,
ToolContext {
agent,
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
},
))
}
#[tokio::test]
async fn get_symbols_overview_returns_symbols() {
let Some((_dir, ctx)) = rust_project_ctx().await else {
eprintln!("Skipping: rust-analyzer not installed");
return;
};
let result = Symbols
.call(
json!({
"path": "src/main.rs",
"depth": 1
}),
&ctx,
)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
assert!(!symbols.is_empty(), "should find at least one symbol");
let names: Vec<&str> = symbols
.iter()
.map(|s| s["name"].as_str().unwrap())
.collect();
assert!(
names.contains(&"main"),
"should find main function, got: {:?}",
names
);
assert!(
names.contains(&"add"),
"should find add function, got: {:?}",
names
);
ctx.lsp.shutdown_all().await;
}
#[tokio::test]
async fn symbols_project_wide_uses_workspace_symbol() {
let Some((_dir, ctx)) = rust_project_ctx().await else {
eprintln!("Skipping: rust-analyzer not installed");
return;
};
let _ = Symbols
.call(json!({ "query": "main", "path": "src/main.rs" }), &ctx)
.await;
let mut found = false;
for _ in 0..10 {
let result = Symbols
.call(json!({ "query": "Point" }), &ctx)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
if symbols.iter().any(|s| s["name"].as_str() == Some("Point")) {
found = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
}
assert!(
found,
"should find 'Point' project-wide via workspace/symbol within 5s"
);
ctx.lsp.shutdown_all().await;
}
#[test]
fn validate_symbol_range_rejects_degenerate_range() {
use crate::lsp::SymbolKind;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("lib.rs");
std::fs::write(&file, "fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n").unwrap();
let sym = SymbolInfo {
name: "add".to_string(),
name_path: "add".to_string(),
kind: SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 0, start_col: 3,
children: vec![],
range_start_line: None,
detail: None,
};
let result = validate_symbol_range(&sym);
assert!(result.is_err(), "degenerate range should be rejected");
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("suspicious range"),
"error should mention suspicious range; got: {msg}"
);
}
#[test]
fn validate_symbol_range_accepts_good_range() {
use crate::lsp::SymbolKind;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("lib.rs");
std::fs::write(&file, "fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n").unwrap();
let sym = SymbolInfo {
name: "add".to_string(),
name_path: "add".to_string(),
kind: SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 5, start_col: 3,
children: vec![],
range_start_line: None,
detail: None,
};
let result = validate_symbol_range(&sym);
assert!(result.is_ok(), "good range should be accepted");
}
#[test]
fn validate_symbol_range_rejects_truncated_end_line() {
use crate::lsp::SymbolKind;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("lib.rs");
std::fs::write(&file, "fn target() {\n old_body();\n}\n").unwrap();
let sym = SymbolInfo {
name: "target".to_string(),
name_path: "target".to_string(),
kind: SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 1, start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
};
let result = validate_symbol_range(&sym);
assert!(
result.is_err(),
"truncated end_line should be rejected; got Ok"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("suspicious range"),
"error should mention suspicious range; got: {msg}"
);
}
#[test]
fn validate_symbol_range_rejects_degenerate_python() {
use crate::lsp::SymbolKind;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("lib.py");
std::fs::write(
&file,
"def add(a, b):\n result = a + b\n return result\n",
)
.unwrap();
let sym = SymbolInfo {
name: "add".to_string(),
name_path: "add".to_string(),
kind: SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 0, start_col: 4,
children: vec![],
range_start_line: None,
detail: None,
};
let result = validate_symbol_range(&sym);
assert!(
result.is_err(),
"Python degenerate range should be rejected"
);
let msg = result.unwrap_err().to_string();
assert!(msg.contains("suspicious range"), "got: {msg}");
}
#[test]
fn validate_symbol_range_rejects_degenerate_typescript() {
use crate::lsp::SymbolKind;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("lib.ts");
std::fs::write(
&file,
"function add(a: number, b: number): number {\n const result = a + b;\n return result;\n}\n",
)
.unwrap();
let sym = SymbolInfo {
name: "add".to_string(),
name_path: "add".to_string(),
kind: SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 0, start_col: 9,
children: vec![],
range_start_line: None,
detail: None,
};
let result = validate_symbol_range(&sym);
assert!(
result.is_err(),
"TypeScript degenerate range should be rejected"
);
let msg = result.unwrap_err().to_string();
assert!(msg.contains("suspicious range"), "got: {msg}");
}
#[test]
fn validate_symbol_range_rejects_degenerate_go() {
use crate::lsp::SymbolKind;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("lib.go");
std::fs::write(
&file,
"package main\n\nfunc Add(a int, b int) int {\n\tresult := a + b\n\treturn result\n}\n",
)
.unwrap();
let sym = SymbolInfo {
name: "Add".to_string(),
name_path: "Add".to_string(),
kind: SymbolKind::Function,
file: file.clone(),
start_line: 2, end_line: 2, start_col: 5,
children: vec![],
range_start_line: None,
detail: None,
};
let result = validate_symbol_range(&sym);
assert!(result.is_err(), "Go degenerate range should be rejected");
let msg = result.unwrap_err().to_string();
assert!(msg.contains("suspicious range"), "got: {msg}");
}
#[test]
fn validate_symbol_range_rejects_degenerate_rust_with_doc() {
use crate::lsp::SymbolKind;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("lib.rs");
std::fs::write(
&file,
"/// Adds two numbers.\nfn add(a: i32, b: i32) -> i32 {\n let r = a + b;\n r\n}\n",
)
.unwrap();
let sym = SymbolInfo {
name: "add".to_string(),
name_path: "add".to_string(),
kind: SymbolKind::Function,
file: file.clone(),
start_line: 1, end_line: 1, start_col: 3,
children: vec![],
range_start_line: None,
detail: None,
};
let result = validate_symbol_range(&sym);
assert!(
result.is_err(),
"Rust+doc comment degenerate range should be rejected"
);
let msg = result.unwrap_err().to_string();
assert!(msg.contains("suspicious range"), "got: {msg}");
}
#[test]
fn validate_symbol_range_picks_correct_function() {
use crate::lsp::SymbolKind;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("lib.rs");
std::fs::write(
&file,
"fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n\nfn multiply(a: i32, b: i32) -> i32 {\n a * b\n}\n",
)
.unwrap();
let sym = SymbolInfo {
name: "multiply".to_string(),
name_path: "multiply".to_string(),
kind: SymbolKind::Function,
file: file.clone(),
start_line: 4,
end_line: 4, start_col: 3,
children: vec![],
range_start_line: None,
detail: None,
};
let result = validate_symbol_range(&sym);
assert!(
result.is_err(),
"degenerate multiply range should be rejected"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("multiply"),
"error should name the symbol; got: {msg}"
);
assert!(msg.contains("suspicious range"), "got: {msg}");
}
#[test]
fn validate_symbol_range_accepts_when_ast_unavailable() {
use crate::lsp::SymbolKind;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("lib.rs");
std::fs::write(&file, "fn add(a: i32, b: i32) -> i32 {\n a + b\n}\n").unwrap();
let sym = SymbolInfo {
name: "nonexistent_fn".to_string(),
name_path: "nonexistent_fn".to_string(),
kind: SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 0, start_col: 3,
children: vec![],
range_start_line: None,
detail: None,
};
let result = validate_symbol_range(&sym);
assert!(
result.is_ok(),
"unknown name: range should be accepted (no AST confirmation to the contrary)"
);
}
#[test]
fn validate_symbol_range_recurses_into_children() {
use crate::lsp::SymbolKind;
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("lib.rs");
std::fs::write(
&file,
"struct Point { x: f64, y: f64 }\nimpl Point {\n fn distance(&self) -> f64 {\n (self.x * self.x + self.y * self.y).sqrt()\n }\n}\n",
)
.unwrap();
let sym = SymbolInfo {
name: "distance".to_string(),
name_path: "Point/distance".to_string(),
kind: SymbolKind::Method,
file: file.clone(),
start_line: 2, end_line: 2, start_col: 7,
children: vec![],
range_start_line: None,
detail: None,
};
let result = validate_symbol_range(&sym);
assert!(
result.is_err(),
"method in impl with degenerate range should be rejected"
);
let msg = result.unwrap_err().to_string();
assert!(msg.contains("suspicious range"), "got: {msg}");
}
#[tokio::test]
async fn symbols_by_name() {
let Some((_dir, ctx)) = rust_project_ctx().await else {
eprintln!("Skipping: rust-analyzer not installed");
return;
};
let result = Symbols
.call(
json!({
"query": "add",
"path": "src/main.rs"
}),
&ctx,
)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
assert!(!symbols.is_empty(), "should find 'add' symbol");
assert!(symbols.iter().any(|s| s["name"].as_str() == Some("add")));
ctx.lsp.shutdown_all().await;
}
#[tokio::test]
async fn get_symbols_overview_accepts_detail_level() {
let ctx = ToolContext {
agent: Agent::new(None).await.unwrap(),
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let err = Symbols
.call(json!({ "path": "x", "detail_level": "full" }), &ctx)
.await
.unwrap_err();
assert!(
err.to_string().contains("project"),
"should fail on project, not param: {}",
err
);
}
#[tokio::test]
async fn path_not_found_is_recoverable_error() {
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();
let ctx = ToolContext {
agent,
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let err = Symbols
.call(json!({ "path": "nonexistent/file.py" }), &ctx)
.await
.unwrap_err();
assert!(
err.downcast_ref::<crate::tools::RecoverableError>()
.is_some(),
"path not found must be RecoverableError, got: {}",
err
);
}
#[tokio::test]
async fn path_not_found_hint_mentions_list_dir() {
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();
let ctx = ToolContext {
agent,
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let err = Symbols
.call(json!({ "path": "missing.rs" }), &ctx)
.await
.unwrap_err();
let rec = err
.downcast_ref::<crate::tools::RecoverableError>()
.expect("should be RecoverableError");
assert!(
rec.hint().unwrap_or("").contains("tree"),
"hint should mention tree, got: {:?}",
rec.hint()
);
}
#[tokio::test]
async fn glob_no_match_is_recoverable_error() {
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();
let ctx = ToolContext {
agent,
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let err = Symbols
.call(json!({ "path": "src/**/*.nonexistent" }), &ctx)
.await
.unwrap_err();
assert!(
err.downcast_ref::<crate::tools::RecoverableError>()
.is_some(),
"empty glob must be RecoverableError, got: {}",
err
);
}
#[tokio::test]
async fn tools_error_without_project() {
let ctx = ToolContext {
agent: Agent::new(None).await.unwrap(),
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
assert!(Symbols.call(json!({"path": "x"}), &ctx).await.is_err());
assert!(Symbols.call(json!({"query": "x"}), &ctx).await.is_err());
assert!(References
.call(json!({"symbol": "x", "path": "y"}), &ctx)
.await
.is_err());
}
#[test]
fn apply_text_edits_simple_replacement() {
let content = "hello world\nfoo bar\nbaz\n";
let edits = vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 6,
},
end: lsp_types::Position {
line: 0,
character: 11,
},
},
new_text: "rust".to_string(),
}];
let result = apply_text_edits(content, &edits);
assert!(result.starts_with("hello rust"), "got: {}", result);
}
#[cfg(unix)]
#[test]
fn uri_to_path_parses_unix_uri() {
let p = uri_to_path("file:///home/user/code.rs").unwrap();
assert_eq!(p, PathBuf::from("/home/user/code.rs"));
}
#[tokio::test]
async fn symbols_project_wide_treesitter_fallback() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
std::fs::write(
dir.path().join("src/lib.rs"),
"pub fn unique_benchmark_fn() -> i32 { 42 }\n\npub struct UniqueTestStruct { x: i32 }\n",
)
.unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let result = Symbols
.call(json!({ "query": "unique_benchmark_fn" }), &ctx)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
assert!(
!symbols.is_empty(),
"should find symbol via tree-sitter fallback: {:?}",
result
);
assert!(symbols
.iter()
.any(|s| s["name"].as_str().unwrap() == "unique_benchmark_fn"));
let result2 = Symbols
.call(json!({ "query": "UniqueTestStruct" }), &ctx)
.await
.unwrap();
let symbols2 = result2["symbols"].as_array().unwrap();
assert!(
symbols2
.iter()
.any(|s| s["name"].as_str().unwrap() == "UniqueTestStruct"),
"should find struct via tree-sitter fallback: {:?}",
result2
);
}
#[tokio::test]
async fn get_symbols_overview_finds_nested_files() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
std::fs::write(
dir.path().join("src/lib.rs"),
"pub fn nested_function() -> i32 { 42 }\n",
)
.unwrap();
std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let result = Symbols.call(json!({}), &ctx).await.unwrap();
let files = result["files"].as_array().unwrap();
let file_names: Vec<&str> = files.iter().map(|f| f["file"].as_str().unwrap()).collect();
assert!(
files.len() >= 2,
"should find files in subdirectories, got: {:?}",
file_names
);
assert!(
file_names.iter().any(|f| f.contains("src/lib.rs")),
"should find nested src/lib.rs, got: {:?}",
file_names
);
assert!(
file_names.iter().any(|f| f.contains("main.rs")),
"should find root main.rs, got: {:?}",
file_names
);
}
#[tokio::test]
async fn symbols_overview_small_tree_recurses_fully() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src/deep/nested")).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
std::fs::write(dir.path().join("src/top.rs"), "pub fn top_level() {}\n").unwrap();
std::fs::write(
dir.path().join("src/deep/nested/hidden.rs"),
"pub fn deeply_nested() {}\n",
)
.unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let result = Symbols.call(json!({ "path": "src" }), &ctx).await.unwrap();
let files = result["files"].as_array().unwrap();
let file_names: Vec<&str> = files.iter().map(|f| f["file"].as_str().unwrap()).collect();
assert!(
file_names.iter().any(|f| f.contains("top.rs")),
"should find src/top.rs, got: {:?}",
file_names
);
assert!(
file_names.iter().any(|f| f.contains("hidden.rs")),
"small tree should recurse fully and find nested file, got: {:?}",
file_names
);
}
#[test]
fn symbols_in_tree() {
let symbols = vec![SymbolInfo {
name: "Foo".into(),
name_path: "Foo".into(),
kind: crate::lsp::SymbolKind::Struct,
file: PathBuf::from("test.rs"),
start_line: 0,
end_line: 5,
start_col: 0,
children: vec![SymbolInfo {
name: "bar".into(),
name_path: "Foo/bar".into(),
kind: crate::lsp::SymbolKind::Method,
file: PathBuf::from("test.rs"),
start_line: 2,
end_line: 4,
start_col: 4,
children: vec![],
range_start_line: None,
detail: None,
}],
range_start_line: None,
detail: None,
}];
assert!(find_symbol_by_name_path(&symbols, "Foo").is_some());
assert!(find_symbol_by_name_path(&symbols, "Foo/bar").is_some());
assert!(find_symbol_by_name_path(&symbols, "nonexistent").is_none());
}
#[test]
fn find_symbol_by_name_path_exact_match() {
let test_file = std::env::temp_dir().join("test.rs");
let symbols = vec![SymbolInfo {
name: "MyStruct".to_string(),
name_path: "MyStruct".to_string(),
kind: crate::lsp::SymbolKind::Struct,
file: test_file.clone(),
start_line: 0,
end_line: 10,
start_col: 0,
children: vec![SymbolInfo {
name: "my_method".to_string(),
name_path: "MyStruct/my_method".to_string(),
kind: crate::lsp::SymbolKind::Method,
file: test_file,
start_line: 2,
end_line: 5,
start_col: 4,
children: vec![],
range_start_line: None,
detail: None,
}],
range_start_line: None,
detail: None,
}];
let found = find_symbol_by_name_path(&symbols, "MyStruct/my_method");
assert!(found.is_some());
assert_eq!(found.unwrap().name, "my_method");
let found = find_symbol_by_name_path(&symbols, "MyStruct");
assert!(found.is_some());
assert_eq!(found.unwrap().name, "MyStruct");
let found = find_symbol_by_name_path(&symbols, "my_method");
assert!(found.is_some());
assert_eq!(found.unwrap().name, "my_method");
let found = find_symbol_by_name_path(&symbols, "nonexistent");
assert!(found.is_none());
}
#[test]
fn symbol_name_matches_generic_types() {
let make_sym = |name: &str, name_path: &str| SymbolInfo {
name: name.to_string(),
name_path: name_path.to_string(),
kind: crate::lsp::SymbolKind::Struct,
file: std::env::temp_dir().join("test.ts"),
start_line: 0,
end_line: 10,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
};
let sym = make_sym("IRepository<T, ID>", "IRepository<T, ID>");
assert!(symbol_name_matches(&sym, "IRepository<T, ID>"));
assert!(symbol_name_matches(&sym, "IRepository"));
assert!(!symbol_name_matches(&sym, "IRepo"));
let sym2 = make_sym("createStore()", "createStore()");
assert!(symbol_name_matches(&sym2, "createStore"));
assert!(!symbol_name_matches(&sym2, "create"));
let sym3 = make_sym("impl Tool for MyStruct<T>", "impl Tool for MyStruct<T>");
assert!(symbol_name_matches(&sym3, "impl Tool for MyStruct<T>"));
let sym4 = make_sym("PlainStruct", "PlainStruct");
assert!(symbol_name_matches(&sym4, "PlainStruct"));
assert!(!symbol_name_matches(&sym4, "Plain"));
}
#[test]
fn symbol_name_matches_suffix_at_word_boundary() {
let make_sym = |name: &str, name_path: &str| SymbolInfo {
name: name.to_string(),
name_path: name_path.to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::env::temp_dir().join("test.rs"),
start_line: 0,
end_line: 10,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
};
let s = make_sym("call", "impl Tool for SemanticSearch/call");
assert!(symbol_name_matches(&s, "SemanticSearch/call"));
assert!(symbol_name_matches(&s, "call"));
let s = make_sym(
"search_text",
"impl Searchable for crate::models::book::Book/search_text",
);
assert!(symbol_name_matches(&s, "Book/search_text"));
let s = make_sym("method", "OuterClass/InnerClass/method");
assert!(symbol_name_matches(&s, "InnerClass/method"));
let s = make_sym("call", "FooSemanticSearch/call");
assert!(!symbol_name_matches(&s, "SemanticSearch/call"));
let s = make_sym("call", "impl Tool for SemanticSearch/call");
assert!(!symbol_name_matches(&s, "ch/call"));
let s = make_sym("add", "impl Catalog<T>/add");
assert!(!symbol_name_matches(&s, "Catalog/add")); assert!(symbol_name_matches(&s, "add"));
let s = make_sym("add", "Catalog<T extends Searchable>/add");
assert!(!symbol_name_matches(&s, "Catalog/add"));
assert!(symbol_name_matches(&s, "add")); }
#[test]
fn find_symbol_by_name_path_generic_types() {
let test_file = std::env::temp_dir().join("test.ts");
let symbols = vec![
SymbolInfo {
name: "IRepository<T, ID>".to_string(),
name_path: "IRepository<T, ID>".to_string(),
kind: crate::lsp::SymbolKind::Interface,
file: test_file.clone(),
start_line: 0,
end_line: 20,
start_col: 0,
children: vec![SymbolInfo {
name: "findById".to_string(),
name_path: "IRepository<T, ID>/findById".to_string(),
kind: crate::lsp::SymbolKind::Method,
file: test_file.clone(),
start_line: 2,
end_line: 3,
start_col: 4,
children: vec![],
range_start_line: None,
detail: None,
}],
range_start_line: None,
detail: None,
},
SymbolInfo {
name: "IRepositoryExtended".to_string(),
name_path: "IRepositoryExtended".to_string(),
kind: crate::lsp::SymbolKind::Interface,
file: test_file,
start_line: 22,
end_line: 30,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
},
];
let found = find_symbol_by_name_path(&symbols, "IRepository");
assert!(found.is_some());
assert_eq!(found.unwrap().name, "IRepository<T, ID>");
let found_ext = find_symbol_by_name_path(&symbols, "IRepositoryExtended");
assert!(found_ext.is_some());
assert_eq!(found_ext.unwrap().name, "IRepositoryExtended");
let found_method = find_symbol_by_name_path(&symbols, "findById");
assert!(found_method.is_some());
assert_eq!(found_method.unwrap().name, "findById");
}
#[test]
fn find_unique_symbol_by_name_path_errors_on_ambiguous_name() {
let test_file = std::env::temp_dir().join("test.rs");
let make_method = |parent: &str, name: &str| SymbolInfo {
name: name.to_string(),
name_path: format!("{}/{}", parent, name),
kind: crate::lsp::SymbolKind::Function,
file: test_file.clone(),
start_line: 0,
end_line: 5,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
};
let symbols = vec![
SymbolInfo {
name: "ToolA".to_string(),
name_path: "ToolA".to_string(),
kind: crate::lsp::SymbolKind::Struct,
file: test_file.clone(),
start_line: 0,
end_line: 20,
start_col: 0,
children: vec![make_method("ToolA", "call")],
range_start_line: None,
detail: None,
},
SymbolInfo {
name: "ToolB".to_string(),
name_path: "ToolB".to_string(),
kind: crate::lsp::SymbolKind::Struct,
file: test_file.clone(),
start_line: 25,
end_line: 45,
start_col: 0,
children: vec![make_method("ToolB", "call")],
range_start_line: None,
detail: None,
},
];
let old_result = find_symbol_by_name_path(&symbols, "call");
assert!(
old_result.is_some(),
"old function returns Some for ambiguous name — no error, caller is unaware"
);
assert_eq!(
old_result.unwrap().name_path,
"ToolA/call",
"old function returns first depth-first match, silently ignoring ToolB/call"
);
let result = find_unique_symbol_by_name_path(&symbols, "call");
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("ToolA/call"),
"expected ToolA/call in error, got: {err_str}"
);
assert!(
err_str.contains("ToolB/call"),
"expected ToolB/call in error, got: {err_str}"
);
let result = find_unique_symbol_by_name_path(&symbols, "ToolA/call");
assert!(result.is_ok());
assert_eq!(result.unwrap().name_path, "ToolA/call");
let result = find_unique_symbol_by_name_path(&symbols, "nonexistent");
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("nonexistent"),
"expected symbol name in error, got: {err_str}"
);
}
#[test]
fn find_unique_symbol_by_name_path_suggests_leaf_matches() {
let test_file = std::env::temp_dir().join("test.rs");
let make_child = |parent: &str, name: &str| SymbolInfo {
name: name.to_string(),
name_path: format!("{}/{}", parent, name),
kind: crate::lsp::SymbolKind::Function,
file: test_file.clone(),
start_line: 1,
end_line: 5,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
};
let symbols = vec![SymbolInfo {
name: "impl Catalog<T>".to_string(),
name_path: "impl Catalog<T>".to_string(),
kind: crate::lsp::SymbolKind::Object,
file: test_file.clone(),
start_line: 0,
end_line: 20,
start_col: 0,
children: vec![make_child("impl Catalog<T>", "add")],
range_start_line: None,
detail: None,
}];
let result = find_unique_symbol_by_name_path(&symbols, "Catalog/add");
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("impl Catalog<T>/add"),
"hint should suggest the real name_path, got: {err_str}"
);
assert!(
err_str.contains("did you mean"),
"message should use 'did you mean' phrasing, got: {err_str}"
);
let result = find_unique_symbol_by_name_path(&symbols, "nonexistent");
assert!(result.is_err());
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("nonexistent"),
"bare-name miss should name the query, got: {err_str}"
);
assert!(
!err_str.contains("did you mean"),
"bare-name miss should not suggest, got: {err_str}"
);
}
#[test]
fn replace_symbol_with_ambiguous_name_path_returns_error() {
let test_file = std::env::temp_dir().join("ambig_test.rs");
let make_method = |parent: &str, name: &str| SymbolInfo {
name: name.to_string(),
name_path: format!("{}/{}", parent, name),
kind: crate::lsp::SymbolKind::Function,
file: test_file.clone(),
start_line: 0,
end_line: 5,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
};
let symbols = vec![
SymbolInfo {
name: "ToolA".to_string(),
name_path: "ToolA".to_string(),
kind: crate::lsp::SymbolKind::Struct,
file: test_file.clone(),
start_line: 0,
end_line: 20,
start_col: 0,
children: vec![make_method("ToolA", "call")],
range_start_line: None,
detail: None,
},
SymbolInfo {
name: "ToolB".to_string(),
name_path: "ToolB".to_string(),
kind: crate::lsp::SymbolKind::Struct,
file: test_file.clone(),
start_line: 25,
end_line: 45,
start_col: 0,
children: vec![make_method("ToolB", "call")],
range_start_line: None,
detail: None,
},
];
let result = find_unique_symbol_by_name_path(&symbols, "call");
assert!(result.is_err(), "expected error for ambiguous name_path");
let err_str = result.unwrap_err().to_string();
assert!(
err_str.contains("ambiguous"),
"expected 'ambiguous' in error, got: {err_str}"
);
assert!(
err_str.contains("ToolA/call"),
"expected ToolA/call in error, got: {err_str}"
);
assert!(
err_str.contains("ToolB/call"),
"expected ToolB/call in error, got: {err_str}"
);
}
#[tokio::test]
async fn find_referencing_symbols_returns_references() {
if !std::process::Command::new("rust-analyzer")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
r#"[package]
name = "test-refs"
version = "0.1.0"
edition = "2021"
"#,
)
.unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
std::fs::write(
dir.path().join("src/main.rs"),
r#"fn add(a: i32, b: i32) -> i32 {
a + b
}
fn main() {
let x = add(1, 2);
let y = add(3, 4);
println!("{} {}", x, y);
}
"#,
)
.unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let mut result_value: Option<Value> = None;
for attempt in 0..10 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(500 * attempt)).await;
}
let result = References
.call(
json!({
"symbol": "add",
"path": "src/main.rs"
}),
&ctx,
)
.await;
let value = match result {
Ok(v) => v,
Err(e) => {
eprintln!("Skipping: LSP error: {}", e);
return;
}
};
let total = value["total"].as_u64().unwrap_or(0);
if total >= 3 {
result_value = Some(value);
break;
}
eprintln!(
"Attempt {}: got {} references, retrying...",
attempt + 1,
total
);
}
let result = match result_value {
Some(v) => v,
None => {
eprintln!("Skipping: rust-analyzer did not index in time");
return;
}
};
let file_groups = result["file_groups"].as_array().unwrap();
let total = result["total"].as_u64().unwrap();
assert!(
total >= 3,
"Expected >= 3 references (def + 2 calls), got {}. groups: {:?}",
total,
file_groups
);
for group in file_groups {
let file = group["file"].as_str().unwrap();
assert!(
file.contains("main.rs"),
"Reference group in unexpected file: {}",
file
);
for r in group["items"].as_array().unwrap() {
let ctx_line = r["context"].as_str().unwrap();
assert!(!ctx_line.is_empty(), "Context line should not be empty");
}
}
}
#[tokio::test]
async fn symbols_schema_includes_scope() {
let tool = Symbols;
let schema = tool.input_schema();
assert!(schema["properties"]["scope"].is_object());
}
#[tokio::test]
async fn get_symbols_overview_schema_includes_scope() {
let tool = Symbols;
let schema = tool.input_schema();
assert!(schema["properties"]["scope"].is_object());
}
#[tokio::test]
async fn find_referencing_symbols_schema_includes_scope() {
let tool = References;
let schema = tool.input_schema();
assert!(schema["properties"]["scope"].is_object());
}
#[tokio::test]
async fn tag_external_path_returns_project_for_internal() {
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();
let root = agent.require_project_root().await.unwrap();
let internal = root.join("src/main.rs");
let tag = tag_external_path(&internal, &root, &agent).await;
assert_eq!(tag, "project");
}
#[tokio::test]
async fn tag_external_path_discovers_and_registers() {
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();
let root = agent.require_project_root().await.unwrap();
let lib_dir = tempfile::tempdir().unwrap();
std::fs::write(
lib_dir.path().join("Cargo.toml"),
"[package]\nname = \"fake_lib\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let lib_src = lib_dir.path().join("src");
std::fs::create_dir_all(&lib_src).unwrap();
let lib_file = lib_src.join("lib.rs");
std::fs::write(&lib_file, "pub fn hello() {}").unwrap();
let tag = tag_external_path(&lib_file, &root, &agent).await;
assert_eq!(tag, "lib:fake_lib");
let registry = agent.library_registry().await.unwrap();
assert!(registry.lookup("fake_lib").is_some());
}
#[tokio::test]
async fn symbols_directory_relative_path() {
let Some((_dir, ctx)) = rust_project_ctx().await else {
return; };
let result = Symbols
.call(json!({ "query": "add", "path": "src" }), &ctx)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
assert!(
!symbols.is_empty(),
"symbols with directory relative_path should find symbols"
);
assert!(symbols.iter().any(|s| s["name"] == "add"));
}
#[test]
fn collect_matching_matches_name_path() {
let symbols = vec![SymbolInfo {
name: "MyStruct".into(),
name_path: "MyStruct".into(),
kind: crate::lsp::SymbolKind::Struct,
file: PathBuf::from("test.rs"),
start_line: 0,
end_line: 10,
start_col: 0,
children: vec![SymbolInfo {
name: "my_method".into(),
name_path: "MyStruct/my_method".into(),
kind: crate::lsp::SymbolKind::Method,
file: PathBuf::from("test.rs"),
start_line: 2,
end_line: 5,
start_col: 4,
children: vec![],
range_start_line: None,
detail: None,
}],
range_start_line: None,
detail: None,
}];
let mut results = vec![];
collect_matching(
&symbols,
&substr_pred("mystruct/my_method"),
false,
None,
0,
true,
&mut results,
None,
);
assert!(
!results.is_empty(),
"pattern with '/' should match against name_path"
);
assert_eq!(results[0]["name"], "my_method");
let mut results2 = vec![];
collect_matching(
&symbols,
&substr_pred("my_method"),
false,
None,
0,
true,
&mut results2,
None,
);
assert!(
!results2.is_empty(),
"pattern without '/' should still match via name"
);
}
async fn rich_project_ctx() -> (tempfile::TempDir, ToolContext) {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src/utils")).unwrap();
std::fs::create_dir_all(dir.path().join("src/empty")).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-project\"\n\n[lsp.rust]\nmux = false\n",
)
.unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"test-project\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::write(
dir.path().join("src/main.rs"),
"fn main() {}\n\nfn add(a: i32, b: i32) -> i32 {\n a + b\n}\n",
)
.unwrap();
std::fs::write(
dir.path().join("src/lib.rs"),
"pub fn helper() -> bool { true }\n\npub struct Calculator;\n\nimpl Calculator {\n pub fn compute() -> i32 { 42 }\n}\n",
)
.unwrap();
std::fs::write(
dir.path().join("src/utils/math.rs"),
"pub fn multiply(a: i32, b: i32) -> i32 { a * b }\n",
)
.unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
(
dir,
ToolContext {
agent,
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
},
)
}
#[tokio::test]
async fn symbols_path_type_file() {
let (_dir, ctx) = rich_project_ctx().await;
let result = Symbols
.call(json!({ "query": "add", "path": "src/main.rs" }), &ctx)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
assert!(
!symbols.is_empty(),
"symbols with file relative_path should find symbols"
);
assert!(symbols.iter().any(|s| s["name"] == "add"));
}
#[tokio::test]
async fn symbols_path_type_directory() {
let (_dir, ctx) = rich_project_ctx().await;
let result = Symbols
.call(json!({ "query": "helper", "path": "src" }), &ctx)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
assert!(
!symbols.is_empty(),
"symbols with directory relative_path should find symbols: {:?}",
result
);
assert!(symbols.iter().any(|s| s["name"] == "helper"));
}
#[tokio::test]
async fn symbols_path_type_nested_directory() {
let (_dir, ctx) = rich_project_ctx().await;
let result = Symbols
.call(json!({ "query": "multiply", "path": "src/utils" }), &ctx)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
assert!(
!symbols.is_empty(),
"symbols with nested directory relative_path should find symbols: {:?}",
result
);
assert!(symbols.iter().any(|s| s["name"] == "multiply"));
}
#[tokio::test]
async fn symbols_path_type_glob() {
let (_dir, ctx) = rich_project_ctx().await;
let result = Symbols
.call(json!({ "query": "add", "path": "src/**/*.rs" }), &ctx)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
assert!(
!symbols.is_empty(),
"symbols with glob relative_path should find symbols: {:?}",
result
);
}
#[tokio::test]
async fn symbols_empty_directory_returns_empty() {
let (_dir, ctx) = rich_project_ctx().await;
let result = Symbols
.call(json!({ "query": "anything", "path": "src/empty" }), &ctx)
.await
.unwrap();
let total = result["total"].as_u64().unwrap();
assert_eq!(total, 0, "empty directory should return 0 results");
}
#[tokio::test]
async fn symbols_name_path_pattern_in_directory() {
let (_dir, ctx) = rich_project_ctx().await;
let result = Symbols
.call(
json!({ "query": "impl Calculator/compute", "path": "src" }),
&ctx,
)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
assert!(
!symbols.is_empty(),
"symbols with name_path pattern in directory should find symbols: {:?}",
result
);
assert!(symbols.iter().any(|s| s["name"] == "compute"));
}
#[tokio::test]
async fn symbols_name_path_pattern_project_wide() {
let (_dir, ctx) = rich_project_ctx().await;
let result = Symbols
.call(json!({ "query": "Calculator/compute" }), &ctx)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
assert!(
!symbols.is_empty(),
"symbols with name_path pattern project-wide should find symbols via tree-sitter: {:?}",
result
);
assert!(symbols.iter().any(|s| s["name"] == "compute"));
}
#[test]
fn collect_matching_slash_pattern_precision() {
let symbols = vec![SymbolInfo {
name: "MyStruct".into(),
name_path: "MyStruct".into(),
kind: crate::lsp::SymbolKind::Struct,
file: PathBuf::from("test.rs"),
start_line: 0,
end_line: 10,
start_col: 0,
children: vec![SymbolInfo {
name: "my_method".into(),
name_path: "MyStruct/my_method".into(),
kind: crate::lsp::SymbolKind::Method,
file: PathBuf::from("test.rs"),
start_line: 2,
end_line: 5,
start_col: 4,
children: vec![],
range_start_line: None,
detail: None,
}],
range_start_line: None,
detail: None,
}];
let mut results = vec![];
collect_matching(
&symbols,
&substr_pred("mystruct/my_method"),
false,
None,
0,
true,
&mut results,
None,
);
assert_eq!(
results.len(),
1,
"slash pattern should match exactly 1 result (the method), not the parent struct"
);
assert_eq!(results[0]["name"], "my_method");
}
#[test]
fn matches_kind_filter_function_group() {
use crate::lsp::SymbolKind;
assert!(matches_kind_filter(&SymbolKind::Function, "function"));
assert!(matches_kind_filter(&SymbolKind::Method, "function"));
assert!(matches_kind_filter(&SymbolKind::Constructor, "function"));
assert!(!matches_kind_filter(&SymbolKind::Variable, "function"));
assert!(!matches_kind_filter(&SymbolKind::Class, "function"));
}
#[test]
fn filter_variable_symbols_removes_variables_at_all_levels() {
let input = json!([
{ "name": "PASS", "kind": "Variable", "start_line": 1, "end_line": 1 },
{
"name": "call",
"kind": "Function",
"start_line": 5,
"end_line": 10,
"children": [
{ "name": "tool", "kind": "Variable", "start_line": 6, "end_line": 6 },
{ "name": "params", "kind": "Variable", "start_line": 6, "end_line": 6 }
]
},
{ "name": "assert_contains", "kind": "Function", "start_line": 12, "end_line": 14 }
]);
let result = filter_variable_symbols(input.as_array().unwrap().to_vec());
assert_eq!(result.len(), 2, "top-level Variable removed");
assert_eq!(result[0]["name"], "call");
assert!(
!result[0].as_object().unwrap().contains_key("children"),
"empty children stripped"
);
assert_eq!(result[1]["name"], "assert_contains");
}
#[test]
fn filter_variable_symbols_preserves_non_variable_children() {
let input = json!([
{
"name": "outer",
"kind": "Function",
"start_line": 1,
"end_line": 10,
"children": [
{ "name": "inner", "kind": "Function", "start_line": 3, "end_line": 5 },
{ "name": "local_var", "kind": "Variable", "start_line": 6, "end_line": 6 }
]
}
]);
let result = filter_variable_symbols(input.as_array().unwrap().to_vec());
assert_eq!(result.len(), 1);
let children = result[0]["children"].as_array().unwrap();
assert_eq!(children.len(), 1);
assert_eq!(children[0]["name"], "inner");
}
#[test]
fn matches_kind_filter_struct_vs_class() {
use crate::lsp::SymbolKind;
assert!(matches_kind_filter(&SymbolKind::Class, "class"));
assert!(!matches_kind_filter(&SymbolKind::Struct, "class"));
assert!(matches_kind_filter(&SymbolKind::Struct, "struct"));
assert!(!matches_kind_filter(&SymbolKind::Class, "struct"));
}
#[test]
fn matches_kind_filter_module_group() {
use crate::lsp::SymbolKind;
assert!(matches_kind_filter(&SymbolKind::Module, "module"));
assert!(matches_kind_filter(&SymbolKind::Namespace, "module"));
assert!(matches_kind_filter(&SymbolKind::Package, "module"));
assert!(!matches_kind_filter(&SymbolKind::Function, "module"));
}
#[test]
fn collect_matching_with_kind_filter_class_only() {
use crate::lsp::SymbolKind;
let symbols = vec![
SymbolInfo {
name: "WeeklyGrid".into(),
name_path: "WeeklyGrid".into(),
kind: SymbolKind::Class,
file: PathBuf::from("test.ts"),
start_line: 0,
end_line: 10,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
},
SymbolInfo {
name: "weeklyGrid".into(),
name_path: "weeklyGrid".into(),
kind: SymbolKind::Variable,
file: PathBuf::from("test.ts"),
start_line: 12,
end_line: 12,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
},
SymbolInfo {
name: "renderWeeklyGrid".into(),
name_path: "renderWeeklyGrid".into(),
kind: SymbolKind::Function,
file: PathBuf::from("test.ts"),
start_line: 14,
end_line: 20,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
},
];
let mut out = vec![];
collect_matching(
&symbols,
&substr_pred("weeklygrid"),
false,
None,
0,
true,
&mut out,
Some("class"),
);
assert_eq!(out.len(), 1);
assert_eq!(out[0]["name"], "WeeklyGrid");
}
#[test]
fn collect_matching_kind_filter_none_returns_all_matching() {
use crate::lsp::SymbolKind;
let symbols = vec![
SymbolInfo {
name: "foo".into(),
name_path: "foo".into(),
kind: SymbolKind::Function,
file: PathBuf::from("test.rs"),
start_line: 0,
end_line: 5,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
},
SymbolInfo {
name: "FOO".into(),
name_path: "FOO".into(),
kind: SymbolKind::Constant,
file: PathBuf::from("test.rs"),
start_line: 7,
end_line: 7,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
},
];
let mut out = vec![];
collect_matching(
&symbols,
&substr_pred("foo"),
false,
None,
0,
true,
&mut out,
None,
);
assert_eq!(
out.len(),
2,
"no filter → all name-matching symbols returned"
);
}
#[test]
fn build_by_file_sorts_desc_and_caps_at_15() {
let mut matches: Vec<Value> = vec![];
for i in 0usize..20 {
for _ in 0..(20 - i) {
matches.push(json!({ "file": format!("src/file{i}.rs") }));
}
}
let (by_file, overflow) = build_by_file(&matches);
assert_eq!(by_file.len(), 15, "cap at 15");
assert_eq!(overflow, 5, "20 files - 15 = 5 overflow");
assert_eq!(by_file[0].0, "src/file0.rs");
assert_eq!(by_file[0].1, 20);
for w in by_file.windows(2) {
assert!(w[0].1 >= w[1].1);
}
}
#[test]
fn build_by_file_no_overflow_under_cap() {
let matches: Vec<Value> = (0..3)
.flat_map(|i| vec![json!({ "file": format!("src/f{i}.rs") }); 5])
.collect();
let (by_file, overflow) = build_by_file(&matches);
assert_eq!(by_file.len(), 3);
assert_eq!(overflow, 0);
}
#[test]
fn make_search_symbols_hint_contains_top_file_and_kind_and_offset() {
let by_file = vec![
("src/components/WeeklyGrid.tsx".to_string(), 12usize),
("src/screens/Home.tsx".to_string(), 3),
];
let hint = make_search_symbols_hint(50, &by_file);
assert!(
hint.contains("src/components/WeeklyGrid.tsx"),
"should show top file path"
);
assert!(hint.contains("kind="), "should mention kind filter");
assert!(
hint.contains("offset=50"),
"should show next pagination offset"
);
}
#[test]
fn kind_filter_skipped_when_using_name_path() {
let input = json!({ "symbol": "Foo", "kind": "function" });
let is_name_path = input["symbol"].is_string();
let kind_filter: Option<&str> = if is_name_path {
None
} else {
input["kind"].as_str()
};
assert!(kind_filter.is_none());
}
fn make_test_sym(name: &str, detail: Option<&str>) -> crate::lsp::SymbolInfo {
crate::lsp::SymbolInfo {
name: name.to_string(),
name_path: name.to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("src/foo.rs"),
start_line: 0,
end_line: 5,
start_col: 0,
children: vec![],
range_start_line: None,
detail: detail.map(|s| s.to_string()),
}
}
#[test]
fn symbol_to_json_omits_file_when_show_file_false() {
let sym = make_test_sym("foo", None);
let result = symbol_to_json(&sym, false, None, 0, false);
assert!(
result.get("file").is_none(),
"file must be absent when show_file=false, got: {result}"
);
assert_eq!(result["name"], "foo");
}
#[test]
fn symbol_to_json_field_order_name_kind_before_line_numbers() {
let sym = make_test_sym("my_fn", Some("fn my_fn() -> u32"));
let result = symbol_to_json(&sym, false, None, 0, false);
let keys: Vec<&str> = result
.as_object()
.unwrap()
.keys()
.map(|s| s.as_str())
.collect();
let name_pos = keys.iter().position(|k| *k == "name").unwrap();
let start_pos = keys.iter().position(|k| *k == "start_line").unwrap();
let end_pos = keys.iter().position(|k| *k == "end_line").unwrap();
assert!(
name_pos < start_pos,
"name must appear before start_line, got key order: {keys:?}"
);
assert_eq!(
start_pos + 1,
end_pos,
"start_line must immediately precede end_line, got key order: {keys:?}"
);
assert_eq!(
end_pos,
keys.len() - 1,
"end_line must be the last field, got key order: {keys:?}"
);
}
#[test]
fn symbol_to_json_includes_file_when_show_file_true() {
let sym = make_test_sym("foo", None);
let result = symbol_to_json(&sym, false, None, 0, true);
assert_eq!(result["file"], "src/foo.rs");
}
#[test]
fn symbol_to_json_includes_signature_when_detail_present() {
let sym = make_test_sym("foo", Some("(x: i32) -> bool"));
let result = symbol_to_json(&sym, false, None, 0, false);
assert_eq!(result["signature"], "(x: i32) -> bool");
}
#[test]
fn symbol_to_json_omits_signature_when_detail_absent() {
let sym = make_test_sym("foo", None);
let result = symbol_to_json(&sym, false, None, 0, false);
assert!(
result.get("signature").is_none(),
"signature must be absent when detail=None"
);
}
#[test]
fn symbol_to_json_never_includes_source_field() {
let sym = make_test_sym("foo", None);
for show_file in [false, true] {
let result = symbol_to_json(&sym, false, None, 0, show_file);
assert!(
result.get("source").is_none(),
"source field must never appear (show_file={show_file})"
);
}
}
#[test]
fn symbols_overview_flat_cap_triggers_on_symbol_with_many_children() {
let symbols: Vec<Value> = (0..20)
.map(|i| {
let children: Vec<Value> = (0..10)
.map(|j| json!({ "name": format!("child_{i}_{j}") }))
.collect();
json!({ "name": format!("sym{i}"), "children": children })
})
.collect();
let flat = flat_symbol_count(&symbols);
assert_eq!(flat, 220);
let budget = LIST_SYMBOLS_SINGLE_FILE_FLAT_CAP;
let mut remaining = budget;
let mut capped: Vec<Value> = Vec::new();
for sym in symbols {
let cost = 1 + sym["children"].as_array().map(|c| c.len()).unwrap_or(0);
if cost <= remaining {
remaining -= cost;
capped.push(sym);
} else {
break;
}
}
assert_eq!(capped.len(), 13);
}
#[test]
fn symbols_overview_flat_cap_not_triggered_for_leaf_heavy_symbols() {
let symbols: Vec<Value> = (0..50)
.map(|i| json!({ "name": format!("fn{i}") }))
.collect();
let flat = flat_symbol_count(&symbols);
assert_eq!(flat, 50);
assert!(flat <= LIST_SYMBOLS_SINGLE_FILE_FLAT_CAP);
}
#[test]
fn symbols_overview_single_file_cap_unit() {
use crate::tools::output::OutputGuard;
let symbols: Vec<Value> = (0..150)
.map(|i| json!({ "name": format!("sym{i}"), "start_line": i + 1 }))
.collect();
const SINGLE_FILE_CAP: usize = 100;
let total = symbols.len();
let hint = format!(
"File has {total} symbols. Use depth=1 for top-level overview, \
or symbols(name_path='ClassName/methodName', include_body=true) for a specific symbol."
);
let g = OutputGuard {
max_results: SINGLE_FILE_CAP,
..OutputGuard::default()
};
let (kept, overflow) = g.cap_items(symbols, &hint);
assert_eq!(kept.len(), 100);
let ov = overflow.expect("overflow must be present");
assert_eq!(ov.total, 150);
assert_eq!(ov.shown, 100);
assert!(ov.hint.contains("symbols"));
assert!(ov.hint.contains("symbol"));
assert!(
ov.by_file.is_none(),
"single-file overflow must not include by_file"
);
}
#[test]
fn symbols_overview_single_file_no_overflow_under_cap_unit() {
use crate::tools::output::OutputGuard;
let symbols: Vec<Value> = (0..40)
.map(|i| json!({ "name": format!("sym{i}") }))
.collect();
let g = OutputGuard {
max_results: 100,
..OutputGuard::default()
};
let (kept, overflow) = g.cap_items(symbols, "hint");
assert_eq!(kept.len(), 40);
assert!(
overflow.is_none(),
"no overflow for 40 symbols under cap of 100"
);
}
#[test]
fn text_sweep_finds_matches_in_comments_and_docs() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("main.rs"),
"fn bar() {}\n// FooHandler manages connections\n",
)
.unwrap();
std::fs::write(
dir.path().join("README.md"),
"# Project\nThe FooHandler struct is the entry point.\nSee FooHandler::new() for details.\n",
)
.unwrap();
std::fs::write(
dir.path().join("config.toml"),
"[server]\nhandler = \"FooHandler\"\n",
)
.unwrap();
let lsp_files = std::collections::HashSet::new();
let matches = text_sweep(dir.path(), "FooHandler", &lsp_files, 20, 2).unwrap();
assert_eq!(matches.len(), 3);
assert_eq!(matches[0].kind, "documentation");
assert_eq!(matches[1].kind, "config");
assert_eq!(matches[2].kind, "source");
assert_eq!(matches[0].occurrence_count, 2);
assert_eq!(matches[0].previews.len(), 2);
assert_eq!(matches[1].occurrence_count, 1);
assert_eq!(matches[2].occurrence_count, 1);
}
#[test]
fn text_sweep_skips_lsp_modified_files() {
let dir = tempfile::tempdir().unwrap();
let modified_file = dir.path().join("already.rs");
std::fs::write(&modified_file, "// FooHandler was here\n").unwrap();
std::fs::write(dir.path().join("untouched.md"), "FooHandler docs\n").unwrap();
let mut lsp_files = std::collections::HashSet::new();
lsp_files.insert(modified_file);
let matches = text_sweep(dir.path(), "FooHandler", &lsp_files, 20, 2).unwrap();
assert_eq!(matches.len(), 1);
assert!(matches[0].file.contains("untouched.md"));
}
#[test]
fn text_sweep_respects_max_matches_cap() {
let dir = tempfile::tempdir().unwrap();
for i in 0..30 {
std::fs::write(
dir.path().join(format!("doc{i:02}.md")),
format!("FooHandler reference in doc {i}\n"),
)
.unwrap();
}
let lsp_files = std::collections::HashSet::new();
let matches = text_sweep(dir.path(), "FooHandler", &lsp_files, 20, 2).unwrap();
assert_eq!(matches.len(), 20);
}
#[test]
fn text_sweep_limits_previews_per_file() {
let dir = tempfile::tempdir().unwrap();
let content = (0..10)
.map(|i| format!("line {i}: FooHandler usage"))
.collect::<Vec<_>>()
.join("\n");
std::fs::write(dir.path().join("many.rs"), &content).unwrap();
let lsp_files = std::collections::HashSet::new();
let matches = text_sweep(dir.path(), "FooHandler", &lsp_files, 20, 2).unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].occurrence_count, 10);
assert_eq!(matches[0].previews.len(), 2); assert_eq!(matches[0].lines.len(), 10); }
#[test]
fn text_sweep_uses_word_boundary() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(
dir.path().join("test.rs"),
"let foo_handler = 1;\n// FooHandler docs\nlet FooHandlerConfig = 2;\n",
)
.unwrap();
let lsp_files = std::collections::HashSet::new();
let matches = text_sweep(dir.path(), "FooHandler", &lsp_files, 20, 2).unwrap();
assert_eq!(matches.len(), 1);
assert_eq!(matches[0].occurrence_count, 1);
assert!(matches[0].previews[0].contains("// FooHandler docs"));
}
#[test]
fn write_lines_no_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.txt");
let lines: Vec<&str> = vec!["line1", "line2", "line3"];
write_lines(&file, &lines, false).unwrap();
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"line1\nline2\nline3"
);
}
#[test]
fn write_lines_with_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.txt");
let lines: Vec<&str> = vec!["line1", "line2", "line3"];
write_lines(&file, &lines, true).unwrap();
assert_eq!(
std::fs::read_to_string(&file).unwrap(),
"line1\nline2\nline3\n"
);
}
#[test]
fn write_lines_empty_with_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.txt");
let lines: Vec<&str> = vec![];
write_lines(&file, &lines, true).unwrap();
assert_eq!(std::fs::read_to_string(&file).unwrap(), "");
}
#[test]
fn splice_multiline_body_no_trailing_newline() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.rs");
let original = "// before\nfn foo() {\n old();\n}\n// after\n";
std::fs::write(&file, original).unwrap();
let content = std::fs::read_to_string(&file).unwrap();
let lines: Vec<&str> = content.lines().collect();
let new_body = "fn foo() {\n new();\n}";
let start = 1usize;
let end = 4usize;
let mut new_lines = Vec::new();
new_lines.extend_from_slice(&lines[..start]);
new_lines.extend(new_body.lines());
new_lines.extend_from_slice(&lines[end..]);
write_lines(&file, &new_lines, content.ends_with('\n')).unwrap();
let result = std::fs::read_to_string(&file).unwrap();
assert_eq!(result, "// before\nfn foo() {\n new();\n}\n// after\n");
}
#[test]
fn splice_multiline_body_with_trailing_newline_no_blank_line() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.rs");
let original = "// before\nfn foo() {\n old();\n}\n// after\n";
std::fs::write(&file, original).unwrap();
let content = std::fs::read_to_string(&file).unwrap();
let lines: Vec<&str> = content.lines().collect();
let new_body = "fn foo() {\n new();\n}\n";
let start = 1usize;
let end = 4usize;
let mut new_lines = Vec::new();
new_lines.extend_from_slice(&lines[..start]);
new_lines.extend(new_body.lines()); new_lines.extend_from_slice(&lines[end..]);
write_lines(&file, &new_lines, content.ends_with('\n')).unwrap();
let result = std::fs::read_to_string(&file).unwrap();
assert_eq!(result, "// before\nfn foo() {\n new();\n}\n// after\n");
}
#[test]
fn splice_push_single_element_creates_blank_line_bug() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.rs");
let original = "// before\nfn foo() {\n old();\n}\n// after\n";
std::fs::write(&file, original).unwrap();
let content = std::fs::read_to_string(&file).unwrap();
let lines: Vec<&str> = content.lines().collect();
let new_body = "fn foo() {\n new();\n}\n"; let start = 1usize;
let end = 4usize;
let mut new_lines = Vec::new();
new_lines.extend_from_slice(&lines[..start]);
new_lines.push(new_body); new_lines.extend_from_slice(&lines[end..]);
write_lines(&file, &new_lines, content.ends_with('\n')).unwrap();
let result = std::fs::read_to_string(&file).unwrap();
assert!(
result.contains("}\n\n// after"),
"Expected blank line bug, got: {:?}",
result
);
}
#[test]
fn apply_text_edits_preserves_trailing_newline() {
let content = "hello world\nfoo bar\nbaz\n";
let edits = vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 1,
character: 0,
},
end: lsp_types::Position {
line: 1,
character: 7,
},
},
new_text: "replaced".to_string(),
}];
let result = apply_text_edits(content, &edits);
assert_eq!(result, "hello world\nreplaced\nbaz\n");
}
#[test]
fn apply_text_edits_multiline_replacement() {
let content = "aaa\nbbb\nccc\n";
let edits = vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 1,
character: 0,
},
end: lsp_types::Position {
line: 1,
character: 3,
},
},
new_text: "xxx\nyyy".to_string(),
}];
let result = apply_text_edits(content, &edits);
assert_eq!(result, "aaa\nxxx\nyyy\nccc\n");
}
#[test]
fn apply_text_edits_utf16_offset() {
let content = "// \u{03B1}: foo\n";
let edits = vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 6,
},
end: lsp_types::Position {
line: 0,
character: 9,
},
},
new_text: "bar".to_string(),
}];
let result = apply_text_edits(content, &edits);
assert_eq!(result, "// \u{03B1}: bar\n");
}
#[test]
fn apply_text_edits_utf16_surrogate_pair() {
let content = "\u{1F600} foo\n";
let edits = vec![lsp_types::TextEdit {
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 3,
},
end: lsp_types::Position {
line: 0,
character: 6,
},
},
new_text: "bar".to_string(),
}];
let result = apply_text_edits(content, &edits);
assert_eq!(result, "\u{1F600} bar\n");
}
#[test]
fn find_insert_before_line_walks_past_doc_comments() {
let lines = vec![
"other code",
"",
"/// Doc line 1",
"/// Doc line 2",
"fn foo() {}",
];
assert_eq!(find_insert_before_line(&lines, 4), 2);
}
#[test]
fn find_insert_before_line_walks_past_attributes() {
let lines = vec!["other code", "#[test]", "#[ignore]", "fn foo() {}"];
assert_eq!(find_insert_before_line(&lines, 3), 1);
}
#[test]
fn find_insert_before_line_stops_at_code() {
let lines = vec!["let x = 1;", "fn foo() {}"];
assert_eq!(find_insert_before_line(&lines, 1), 1);
}
#[test]
fn find_insert_before_line_walks_past_kdoc_bare_asterisk_line() {
let lines = vec![
"other code", "", " /**", " * Description.", " *", " * @param x ...", " */", " fun foo(x: Int) {}", ];
assert_eq!(find_insert_before_line(&lines, 7), 2);
}
#[test]
fn find_insert_before_line_at_start_of_file() {
let lines = vec!["/// Doc", "fn foo() {}"];
assert_eq!(find_insert_before_line(&lines, 1), 0);
}
#[test]
fn editing_start_line_uses_range_start_line_when_present() {
let sym = crate::lsp::SymbolInfo {
name: "foo".to_string(),
name_path: "foo".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.rs"),
start_line: 8,
end_line: 12,
start_col: 0,
children: vec![],
range_start_line: Some(5),
detail: None,
};
let lines = vec![
"other code",
"",
"/// doc1",
"/// doc2",
"#[test]",
"#[ignore]", "// between",
"// gap",
"fn foo() {", " body",
"}",
];
assert_eq!(editing_start_line(&sym, &lines), 5);
}
#[test]
fn editing_start_line_falls_back_to_heuristic_when_none() {
let sym = crate::lsp::SymbolInfo {
name: "foo".to_string(),
name_path: "foo".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.rs"),
start_line: 3,
end_line: 5,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
};
let lines = vec![
"other code",
"#[test]",
"#[ignore]",
"fn foo() {", " body",
"}",
];
assert_eq!(editing_start_line(&sym, &lines), 1);
}
#[test]
fn editing_start_line_walks_back_to_block_comment_opener_when_lsp_range_is_mid_comment() {
let sym = crate::lsp::SymbolInfo {
name: "createSolver".to_string(),
name_path: "Stage1SolverConfigFactory/createSolver".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("Stage1SolverConfigFactory.kt"),
start_line: 6, end_line: 9,
start_col: 4,
children: vec![],
range_start_line: Some(3), detail: None,
};
let lines = vec![
" /**", " * Create a configured solver.", " *", " * @param lessonCount Number of ...", " * @param moveThreadCount Threads", " */", " fun createSolver(", " lessonCount: Int,", " moveThreadCount: Int = 4,", " ): Solver<Stage1Solution> { }", ];
assert_eq!(editing_start_line(&sym, &lines), 0);
}
#[test]
fn editing_start_line_does_not_walk_back_from_attribute_even_if_lsp_range_set() {
let sym = crate::lsp::SymbolInfo {
name: "foo".to_string(),
name_path: "foo".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.rs"),
start_line: 8,
end_line: 12,
start_col: 0,
children: vec![],
range_start_line: Some(5), detail: None,
};
let lines = vec![
"other code", "", "/// doc1", "/// doc2", "#[test]", "#[ignore]", "// between", "// gap", "fn foo() {", " body", "}", ];
assert_eq!(editing_start_line(&sym, &lines), 5);
}
#[test]
fn editing_start_line_walks_back_past_doc_comments_when_range_misses_them() {
let sym = crate::lsp::SymbolInfo {
name: "is_source_path".to_string(),
name_path: "is_source_path".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.rs"),
start_line: 5, end_line: 9,
start_col: 0,
children: vec![],
range_start_line: Some(5), detail: None,
};
let lines = vec![
"use regex::Regex;", "", "/// Returns true if the path refers to a source code file.", "/// Used to gate `edit_file` multi-line source edits.", "#[inline]", "pub fn is_source_path(path: &str) -> bool {", " Regex::new(SOURCE_EXTENSIONS)", " .map(|re| re.is_match(path))", " .unwrap_or(false)", "}", ];
assert_eq!(editing_start_line(&sym, &lines), 2);
}
#[test]
fn editing_start_line_trusts_range_when_it_already_covers_docs() {
let sym = crate::lsp::SymbolInfo {
name: "foo".to_string(),
name_path: "foo".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.rs"),
start_line: 5,
end_line: 7,
start_col: 0,
children: vec![],
range_start_line: Some(3), detail: None,
};
let lines = vec![
"fn unrelated() {}", "// random comment", "", "/// Doc for foo", "#[test]", "fn foo() {", " body", "}", ];
assert_eq!(editing_start_line(&sym, &lines), 3);
}
#[test]
fn editing_start_line_does_not_walk_back_to_outer_attribute_on_impl_block() {
let sym = crate::lsp::SymbolInfo {
name: "impl SomeTrait for SomeType".to_string(),
name_path: "impl SomeTrait for SomeType".to_string(),
kind: crate::lsp::SymbolKind::Object,
file: std::path::PathBuf::from("test.rs"),
start_line: 2,
end_line: 6,
start_col: 0,
children: vec![],
range_start_line: Some(2), detail: None,
};
let lines = vec![
"}", "#[async_trait::async_trait]", "impl SomeTrait for SomeType {", " async fn foo(&self) {", " }", "}", ];
assert_eq!(editing_start_line(&sym, &lines), 2);
}
#[test]
fn editing_start_line_walks_back_when_docs_exist_above_attribute_on_impl() {
let sym = crate::lsp::SymbolInfo {
name: "impl SomeTrait for SomeType".to_string(),
name_path: "impl SomeTrait for SomeType".to_string(),
kind: crate::lsp::SymbolKind::Object,
file: std::path::PathBuf::from("test.rs"),
start_line: 3,
end_line: 6,
start_col: 0,
children: vec![],
range_start_line: Some(3), detail: None,
};
let lines = vec![
"}", "/// Implements SomeTrait.", "#[async_trait::async_trait]", "impl SomeTrait for SomeType {", " async fn foo(&self) {}", "}", ];
assert_eq!(editing_start_line(&sym, &lines), 1);
}
#[test]
fn editing_end_line_nested_fn_returns_closing_brace_line() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.rs");
let source = "\
use serde_json::json;
pub async fn write_message(writer: &mut Vec<u8>, msg: &str) -> Result<(), std::io::Error> {
writer.extend_from_slice(msg.as_bytes());
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn write_produces_valid_framing() {
let msg = json!({\"test\": true});
let mut buf = Vec::new();
write_message(&mut buf, &msg.to_string()).await.unwrap();
assert!(!buf.is_empty());
}
#[tokio::test]
async fn another_test() {
let x = 42;
assert_eq!(x, 42);
}
}
";
std::fs::write(&file, source).unwrap();
let sym = crate::lsp::SymbolInfo {
name: "write_produces_valid_framing".to_string(),
name_path: "tests/write_produces_valid_framing".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: file.clone(),
start_line: 12, end_line: 17, start_col: 4,
children: vec![],
range_start_line: Some(11), detail: None,
};
let end = editing_end_line(&sym);
assert_eq!(
end, 17,
"editing_end_line should return closing brace line (17), got {end}"
);
let lines: Vec<&str> = source.lines().collect();
let insert_at = (end as usize + 1).min(lines.len());
assert!(
insert_at <= lines.len(),
"insert point should be within file bounds"
);
if insert_at < lines.len() {
let next_line = lines[insert_at].trim();
assert!(
next_line.is_empty()
|| next_line.starts_with('#')
|| next_line.starts_with("async")
|| next_line.starts_with("fn"),
"line after insert should be blank or next function start, got: '{next_line}'"
);
}
}
#[test]
fn editing_end_line_corrects_lsp_short_end_line_via_ast() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.rs");
let source = "\
fn foo() {
let x = 1;
let y = 2;
println!(\"{}\", x + y);
}
";
std::fs::write(&file, source).unwrap();
let sym = crate::lsp::SymbolInfo {
name: "foo".to_string(),
name_path: "foo".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 3, start_col: 0,
children: vec![],
range_start_line: Some(0),
detail: None,
};
let end = editing_end_line(&sym);
assert_eq!(
end, 4,
"editing_end_line should correct short LSP end to AST end (4), got {end}"
);
}
#[test]
fn editing_end_line_with_syntax_errors_uses_ast_not_lsp_fallback() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.rs");
let source = "\
fn target() {\n\
let x = 1;\n\
let y = 2;\n\
println!(\"{}\", x + y);\n\
let z = x + y;\n\
}\n\
\n\
fn broken() {\n\
vec![1, 2,\n\
";
std::fs::write(&file, source).unwrap();
let sym = crate::lsp::SymbolInfo {
name: "target".to_string(),
name_path: "target".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 3, start_col: 0,
children: vec![],
range_start_line: Some(0),
detail: None,
};
let end = editing_end_line(&sym);
assert_eq!(
end, 5,
"editing_end_line must return AST's closing brace line (5), not LSP short-report (3), got {end}"
);
}
#[test]
fn editing_end_line_syntax_errors_do_not_regress_lsp_overextend() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.rs");
let source = "\
fn target() {\n\
let x = 1;\n\
}\n\
fn broken() {\n\
vec![1, 2,\n\
";
std::fs::write(&file, source).unwrap();
let sym = crate::lsp::SymbolInfo {
name: "target".to_string(),
name_path: "target".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 4, start_col: 0,
children: vec![],
range_start_line: Some(0),
detail: None,
};
let end = editing_end_line(&sym);
assert_eq!(
end, 2,
"editing_end_line must trust AST (2) not max with over-extended LSP (4), got {end}"
);
}
#[test]
fn editing_end_line_strict_returns_none_when_ast_cannot_find_symbol() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.rs");
std::fs::write(&file, "fn other_fn() {\n do_thing();\n}\n").unwrap();
let sym = crate::lsp::SymbolInfo {
name: "ghost_fn".to_string(),
name_path: "ghost_fn".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 1,
start_col: 0,
children: vec![],
range_start_line: Some(0),
detail: None,
};
let result = editing_end_line_strict(&sym);
assert!(
result.is_none(),
"strict variant must refuse when AST cannot find the symbol, got {result:?}"
);
}
#[test]
fn editing_end_line_strict_returns_some_when_ast_finds_symbol() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("test.rs");
std::fs::write(&file, "fn target() {\n let x = 1;\n let y = 2;\n}\n").unwrap();
let sym = crate::lsp::SymbolInfo {
name: "target".to_string(),
name_path: "target".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 3,
start_col: 0,
children: vec![],
range_start_line: Some(0),
detail: None,
};
assert_eq!(
editing_end_line_strict(&sym),
Some(3),
"strict variant must return AST-confirmed end line on a clean file"
);
}
#[test]
fn clamp_range_to_parent_caps_end_at_parent_closer() {
let (s, e) = clamp_range_to_parent(5, 26, 1, 20);
assert_eq!((s, e), (5, 20), "end must be capped at parent closer line");
}
#[test]
fn clamp_range_to_parent_lifts_start_to_parent_body_start() {
let (s, e) = clamp_range_to_parent(0, 10, 1, 20);
assert_eq!((s, e), (1, 10), "start must be lifted to parent body start");
}
#[test]
fn clamp_range_to_parent_passthrough_when_within_bounds() {
let (s, e) = clamp_range_to_parent(5, 10, 1, 20);
assert_eq!((s, e), (5, 10), "well-formed ranges must pass through");
}
#[test]
fn clamp_range_to_parent_preserves_start_le_end_invariant_on_collapse() {
let (s, e) = clamp_range_to_parent(25, 30, 1, 20);
assert!(s <= e, "start must remain <= end after clamp, got {s}..{e}");
}
#[test]
fn clamp_range_to_parent_exact_fit_is_identity() {
let (s, e) = clamp_range_to_parent(1, 20, 1, 20);
assert_eq!((s, e), (1, 20));
}
#[test]
fn clamp_range_to_parent_simulates_bug_044_impl_method_overshoot() {
let parent_body_start = 1;
let parent_body_end_exclusive = 19; let (s, e) = clamp_range_to_parent(1, 22, parent_body_start, parent_body_end_exclusive);
assert_eq!(
e, 19,
"must not extend past parent closer even under extreme overshoot"
);
assert_eq!(s, 1);
}
#[test]
fn editing_start_line_mod_tests_does_not_eat_preceding_function() {
let sym = crate::lsp::SymbolInfo {
name: "tests".to_string(),
name_path: "tests".to_string(),
kind: crate::lsp::SymbolKind::Module,
file: std::path::PathBuf::from("test.rs"),
start_line: 7, end_line: 15,
start_col: 0,
children: vec![],
range_start_line: Some(6), detail: None,
};
let lines = vec![
"pub async fn write_message() -> Result<()> {", " let body = serde_json::to_string(msg)?;", " let header = format!(\"Content-Length: {}\\r\\n\\r\\n\", body.len());", " writer.write_all(header.as_bytes()).await?;", "}", "", "#[cfg(test)]", "mod tests {", " use super::*;", " #[test]", " fn test_foo() {}", "}", ];
let result = editing_start_line(&sym, &lines);
assert_eq!(
result, 6,
"editing_start_line should stop at #[cfg(test)] (6), got {result}"
);
}
#[test]
fn editing_start_line_mod_tests_no_range_stops_at_blank_line() {
let sym = crate::lsp::SymbolInfo {
name: "tests".to_string(),
name_path: "tests".to_string(),
kind: crate::lsp::SymbolKind::Module,
file: std::path::PathBuf::from("test.rs"),
start_line: 5, end_line: 8,
start_col: 0,
children: vec![],
range_start_line: None, detail: None,
};
let lines = vec![
"pub async fn write_message() -> Result<()> {", " let body = \"hello\";", "}", "", "#[cfg(test)]", "mod tests {", " #[test]", " fn test_foo() {}", "}", ];
let result = editing_start_line(&sym, &lines);
assert_eq!(result, 4, "should stop at #[cfg(test)] (4), got {result}");
}
#[test]
fn editing_start_line_mod_tests_no_blank_line_between_functions() {
let sym = crate::lsp::SymbolInfo {
name: "tests".to_string(),
name_path: "tests".to_string(),
kind: crate::lsp::SymbolKind::Module,
file: std::path::PathBuf::from("test.rs"),
start_line: 4, end_line: 8,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
};
let lines = vec![
"pub fn write_message() -> Result<()> {", " let body = \"hello\";", "}", "#[cfg(test)]", "mod tests {", " #[test]", " fn test_foo() {}", "}", ];
let result = editing_start_line(&sym, &lines);
assert_eq!(
result, 3,
"should stop at #[cfg(test)] (3), not eat into write_message; got {result}"
);
}
#[test]
fn validate_symbol_position_detects_stale_positions() {
let original_lines = vec![
"pub enum SourceFilter {", " All,", " SourceOnly,", " NonSourceOnly,", "}", "", "impl SourceFilter {", " pub fn as_sql_filter(&self) -> Option<&'static str> {", " None", " }", "}", "", "pub fn project_db_path() {}", ];
let sym_impl = crate::lsp::SymbolInfo {
name: "SourceFilter".to_string(),
name_path: "impl SourceFilter".to_string(),
kind: crate::lsp::SymbolKind::Struct,
file: std::path::PathBuf::from("test.rs"),
start_line: 6, end_line: 10,
start_col: 0,
children: vec![],
range_start_line: Some(6),
detail: None,
};
assert!(
validate_symbol_position(&sym_impl, &original_lines).is_ok(),
"should be valid against original file"
);
let after_removal = vec![
"impl SourceFilter {", " pub fn as_sql_filter(&self) -> Option<&'static str> {", " None", " }", "}", "", "pub fn project_db_path() {}", ];
let result = validate_symbol_position(&sym_impl, &after_removal);
assert!(
result.is_err(),
"should detect stale position: 'SourceFilter' not at line 6 in modified file"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("stale"),
"error should mention stale; got: {msg}"
);
}
#[test]
fn validate_symbol_position_catches_start_line_inside_preceding_function() {
let lines = vec![
"pub fn read(&self) -> Result<Summary> {", " let data = self.load()?;", " Ok(Summary { data })", "}", "", "#[cfg(test)]", "mod tests {", " use super::*;", " #[test]", " fn test_read() {}", "}", ];
let sym_correct = crate::lsp::SymbolInfo {
name: "tests".to_string(),
name_path: "tests".to_string(),
kind: crate::lsp::SymbolKind::Module,
file: std::path::PathBuf::from("test.rs"),
start_line: 6,
end_line: 10,
start_col: 0,
children: vec![],
range_start_line: Some(5),
detail: None,
};
assert!(
validate_symbol_position(&sym_correct, &lines).is_ok(),
"correct position should validate"
);
let sym_stale = crate::lsp::SymbolInfo {
name: "tests".to_string(),
name_path: "tests".to_string(),
kind: crate::lsp::SymbolKind::Module,
file: std::path::PathBuf::from("test.rs"),
start_line: 2, end_line: 10,
start_col: 0,
children: vec![],
range_start_line: Some(2),
detail: None,
};
let result = validate_symbol_position(&sym_stale, &lines);
assert!(
result.is_err(),
"stale start_line inside preceding function should be detected"
);
let msg = result.unwrap_err().to_string();
assert!(
msg.contains("stale"),
"error should mention stale; got: {msg}"
);
}
#[test]
fn validate_symbol_position_accepts_lead_in_paren_close() {
let lines = vec![
" })", " }", "", " fn target() {", " old_body();", " }", ];
let sym = crate::lsp::SymbolInfo {
name: "target".to_string(),
name_path: "target".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.rs"),
start_line: 0,
end_line: 5,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
};
assert!(
validate_symbol_position(&sym, &lines).is_ok(),
"lead-in `}})` at start_line should be accepted"
);
}
#[test]
fn validate_symbol_position_accepts_lead_in_blank_line() {
let lines = vec![
"", "fn target() {", " body();", "}", ];
let sym = crate::lsp::SymbolInfo {
name: "target".to_string(),
name_path: "target".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.rs"),
start_line: 0,
end_line: 3,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
};
assert!(validate_symbol_position(&sym, &lines).is_ok());
}
#[test]
fn validate_symbol_position_accepts_lead_in_rust_attribute() {
let lines = vec![
"#[cfg(test)]", "mod tests {", "}", ];
let sym = crate::lsp::SymbolInfo {
name: "tests".to_string(),
name_path: "tests".to_string(),
kind: crate::lsp::SymbolKind::Module,
file: std::path::PathBuf::from("test.rs"),
start_line: 0,
end_line: 2,
start_col: 0,
children: vec![],
range_start_line: Some(0),
detail: None,
};
assert!(validate_symbol_position(&sym, &lines).is_ok());
}
#[test]
fn validate_symbol_position_accepts_lead_in_python_decorator() {
let lines = vec![
"@decorator", "def my_func():", " pass", ];
let sym = crate::lsp::SymbolInfo {
name: "my_func".to_string(),
name_path: "my_func".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.py"),
start_line: 0,
end_line: 2,
start_col: 0,
children: vec![],
range_start_line: Some(0),
detail: None,
};
assert!(validate_symbol_position(&sym, &lines).is_ok());
}
#[test]
fn validate_symbol_position_accepts_lead_in_kdoc_continuation() {
let lines = vec![
"/**", " * @param x the param", " */", "fun target() {}", ];
let sym = crate::lsp::SymbolInfo {
name: "target".to_string(),
name_path: "target".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.kt"),
start_line: 1,
end_line: 3,
start_col: 0,
children: vec![],
range_start_line: Some(1),
detail: None,
};
assert!(validate_symbol_position(&sym, &lines).is_ok());
}
#[test]
fn validate_symbol_position_catches_lead_in_with_distant_name() {
let lines = vec![
"", "fn unrelated_one() {", " do_thing();", "}", "", "fn unrelated_two() {", " do_other();", "}", "", "fn target() {}", ];
let sym = crate::lsp::SymbolInfo {
name: "target".to_string(),
name_path: "target".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.rs"),
start_line: 0,
end_line: 9,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
};
let result = validate_symbol_position(&sym, &lines);
assert!(
result.is_err(),
"name 9 lines below lead-in should be detected as stale"
);
assert!(result.unwrap_err().to_string().contains("stale"));
}
#[test]
fn validate_symbol_position_accepts_multiline_signature() {
let lines = vec![
"pub fn long_name(", " arg1: T,", " arg2: U,", ") -> R {", " body()", "}", ];
let sym = crate::lsp::SymbolInfo {
name: "long_name".to_string(),
name_path: "long_name".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.rs"),
start_line: 0,
end_line: 5,
start_col: 0,
children: vec![],
range_start_line: Some(0),
detail: None,
};
assert!(validate_symbol_position(&sym, &lines).is_ok());
}
#[test]
fn is_lead_in_line_classification() {
assert!(is_lead_in_line(""));
assert!(is_lead_in_line(" "));
assert!(is_lead_in_line("}"));
assert!(is_lead_in_line(" }"));
assert!(is_lead_in_line("})"));
assert!(is_lead_in_line(" })"));
assert!(is_lead_in_line("});"));
assert!(is_lead_in_line("})?;"));
assert!(is_lead_in_line("},"));
assert!(is_lead_in_line("// a comment"));
assert!(is_lead_in_line("/// doc comment"));
assert!(is_lead_in_line("/* block */"));
assert!(is_lead_in_line(" * KDoc continuation"));
assert!(is_lead_in_line("*/"));
assert!(is_lead_in_line("@decorator"));
assert!(is_lead_in_line("@Override"));
assert!(is_lead_in_line("#[cfg(test)]"));
assert!(is_lead_in_line("#![allow(unused)]"));
assert!(!is_lead_in_line("fn foo() {"));
assert!(!is_lead_in_line(" let x = 1;"));
assert!(!is_lead_in_line("class Foo {"));
assert!(!is_lead_in_line("def bar():"));
assert!(!is_lead_in_line(" return value"));
assert!(!is_lead_in_line("pub mod tests;"));
}
#[test]
fn validate_symbol_position_accepts_valid_position() {
let lines = vec!["/// doc comment", "pub fn my_function() {", " body", "}"];
let sym = crate::lsp::SymbolInfo {
name: "my_function".to_string(),
name_path: "my_function".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.rs"),
start_line: 1,
end_line: 3,
start_col: 0,
children: vec![],
range_start_line: Some(0),
detail: None,
};
assert!(validate_symbol_position(&sym, &lines).is_ok());
}
#[test]
fn editing_start_line_discards_walkback_when_no_block_comment_opener() {
let sym = crate::lsp::SymbolInfo {
name: "foo".to_string(),
name_path: "foo".to_string(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("test.rs"),
start_line: 3,
end_line: 5,
start_col: 0,
children: vec![],
range_start_line: Some(2), detail: None,
};
let lines = vec![
"use std::ptr;", "", "*mut u8", "fn foo() {", " body", "}", ];
assert_eq!(editing_start_line(&sym, &lines), 2);
}
#[test]
fn symbol_to_json_body_includes_attributes_when_range_start_line_set() {
let source = "#[test]\n/// A doc comment\nfn foo() {\n body();\n}\n";
let sym = crate::lsp::SymbolInfo {
name: "foo".into(),
name_path: "foo".into(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("src/lib.rs"),
start_line: 2, end_line: 4, start_col: 0,
children: vec![],
range_start_line: Some(0), detail: None,
};
let json = symbol_to_json(&sym, true, Some(source), 0, false);
let body = json["body"].as_str().unwrap();
assert!(
body.contains("#[test]"),
"body should include #[test] attribute; got:\n{body}"
);
assert!(
body.contains("/// A doc comment"),
"body should include doc comment; got:\n{body}"
);
assert!(
body.contains("fn foo()"),
"body should include fn declaration; got:\n{body}"
);
}
#[test]
fn symbol_to_json_includes_body_start_line() {
let source = "#[test]\nfn foo() {}\n";
let sym = crate::lsp::SymbolInfo {
name: "foo".into(),
name_path: "foo".into(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("src/lib.rs"),
start_line: 1,
end_line: 1,
start_col: 0,
children: vec![],
range_start_line: Some(0),
detail: None,
};
let json = symbol_to_json(&sym, true, Some(source), 0, false);
assert_eq!(
json["body_start_line"].as_u64(),
Some(1),
"body_start_line should be 1-indexed line where body begins (the attribute line)"
);
}
#[test]
fn symbol_to_json_body_uses_heuristic_when_range_start_line_none() {
let source = "#[test]\nfn foo() {\n body();\n}\n";
let sym = crate::lsp::SymbolInfo {
name: "foo".into(),
name_path: "foo".into(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("src/lib.rs"),
start_line: 1, end_line: 3,
start_col: 0,
children: vec![],
range_start_line: None, detail: None,
};
let json = symbol_to_json(&sym, true, Some(source), 0, false);
let body = json["body"].as_str().unwrap();
assert!(
body.contains("#[test]"),
"body should include #[test] via heuristic fallback; got:\n{body}"
);
}
#[test]
fn symbol_to_json_body_start_line_equals_start_line_when_no_attributes() {
let source = "fn foo() {\n body();\n}\n";
let sym = crate::lsp::SymbolInfo {
name: "foo".into(),
name_path: "foo".into(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("src/lib.rs"),
start_line: 0,
end_line: 2,
start_col: 0,
children: vec![],
range_start_line: Some(0), detail: None,
};
let json = symbol_to_json(&sym, true, Some(source), 0, false);
assert_eq!(
json["body_start_line"].as_u64(),
Some(1),
"body_start_line should equal start_line when no attributes"
);
assert_eq!(
json["start_line"].as_u64(),
Some(1),
"start_line should be 1 (1-indexed)"
);
}
#[test]
fn symbol_to_json_no_body_start_line_when_include_body_false() {
let source = "#[test]\nfn foo() {}\n";
let sym = crate::lsp::SymbolInfo {
name: "foo".into(),
name_path: "foo".into(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("src/lib.rs"),
start_line: 1,
end_line: 1,
start_col: 0,
children: vec![],
range_start_line: Some(0),
detail: None,
};
let json = symbol_to_json(&sym, false, Some(source), 0, false);
assert!(
json.get("body").is_none(),
"body should not be present when include_body=false"
);
assert!(
json.get("body_start_line").is_none(),
"body_start_line should not be present when include_body=false"
);
}
#[test]
fn symbol_to_json_body_includes_only_doc_comments() {
let source = "/// Doc line 1\n/// Doc line 2\nfn foo() {}\n";
let sym = crate::lsp::SymbolInfo {
name: "foo".into(),
name_path: "foo".into(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("src/lib.rs"),
start_line: 2, end_line: 2,
start_col: 0,
children: vec![],
range_start_line: Some(0), detail: None,
};
let json = symbol_to_json(&sym, true, Some(source), 0, false);
let body = json["body"].as_str().unwrap();
assert!(
body.contains("/// Doc line 1"),
"body should include first doc line; got:\n{body}"
);
assert!(
body.contains("/// Doc line 2"),
"body should include second doc line; got:\n{body}"
);
assert!(
body.contains("fn foo()"),
"body should include fn declaration; got:\n{body}"
);
assert_eq!(json["body_start_line"].as_u64(), Some(1));
assert_eq!(json["start_line"].as_u64(), Some(3)); }
#[test]
fn symbol_to_json_body_includes_multiline_attribute() {
let source = "#[cfg(\n target_os = \"linux\"\n)]\nfn foo() {}\n";
let sym = crate::lsp::SymbolInfo {
name: "foo".into(),
name_path: "foo".into(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("src/lib.rs"),
start_line: 3, end_line: 3,
start_col: 0,
children: vec![],
range_start_line: Some(0), detail: None,
};
let json = symbol_to_json(&sym, true, Some(source), 0, false);
let body = json["body"].as_str().unwrap();
assert!(
body.contains("#[cfg("),
"body should include multiline attribute opener; got:\n{body}"
);
assert!(
body.contains("target_os"),
"body should include attribute content; got:\n{body}"
);
assert!(
body.contains(")]"),
"body should include attribute closer; got:\n{body}"
);
assert_eq!(json["body_start_line"].as_u64(), Some(1));
}
#[test]
fn symbol_to_json_child_body_also_uses_full_range() {
let source = "impl Foo {\n #[test]\n fn bar() {}\n}\n";
let child = crate::lsp::SymbolInfo {
name: "bar".into(),
name_path: "Foo/bar".into(),
kind: crate::lsp::SymbolKind::Function,
file: std::path::PathBuf::from("src/lib.rs"),
start_line: 2, end_line: 2,
start_col: 0,
children: vec![],
range_start_line: Some(1), detail: None,
};
let parent = crate::lsp::SymbolInfo {
name: "Foo".into(),
name_path: "Foo".into(),
kind: crate::lsp::SymbolKind::Struct,
file: std::path::PathBuf::from("src/lib.rs"),
start_line: 0,
end_line: 3,
start_col: 0,
children: vec![child],
range_start_line: Some(0),
detail: None,
};
let json = symbol_to_json(&parent, true, Some(source), 1, false);
let child_body = json["children"][0]["body"].as_str().unwrap();
assert!(
child_body.contains("#[test]"),
"child body should include its attribute; got:\n{child_body}"
);
assert!(
child_body.contains("fn bar()"),
"child body should include fn declaration; got:\n{child_body}"
);
}
#[test]
fn find_insert_before_line_walks_past_multiline_attribute() {
let lines = vec![
"other code",
"#[cfg(",
" target_os = \"linux\"",
")]",
"fn foo() {}",
];
assert_eq!(find_insert_before_line(&lines, 4), 1);
}
#[test]
fn find_insert_before_line_walks_past_nested_multiline_attributes() {
let lines = vec![
"other code",
"#[cfg(all(",
" target_os = \"linux\",",
" feature = \"nightly\"",
"))]",
"#[inline]",
"fn foo() {}",
];
assert_eq!(find_insert_before_line(&lines, 6), 1);
}
#[test]
fn find_insert_before_line_walks_past_python_multiline_decorator() {
let lines = vec![
"other code",
"@app.route(",
" \"/api/v1/users\",",
" methods=[\"GET\"]",
")",
"def get_users():",
];
assert_eq!(find_insert_before_line(&lines, 5), 1);
}
#[test]
fn find_references_format_compact_shows_count() {
use serde_json::json;
let tool = References;
let result = json!({
"file_groups": [{"file": "a.rs", "count": 1, "items": [{"line": 10, "column": 0, "context": "foo()"}]}],
"total": 1,
"files": 1
});
let text = tool.format_compact(&result).unwrap();
assert!(text.contains("a.rs"), "got: {text}");
assert!(text.contains("10"), "got: {text}");
}
#[tokio::test]
async fn references_returns_grouped_shape() {
if !std::process::Command::new("rust-analyzer")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let dir = tempdir().unwrap();
let lib_rs = dir.path().join("src").join("lib.rs");
std::fs::create_dir_all(lib_rs.parent().unwrap()).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
std::fs::write(
&lib_rs,
"pub fn greet() {}\nfn main() { greet(); greet(); }\n",
)
.unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"t\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let tool = crate::tools::symbol::references::References;
let mut result_value: Option<Value> = None;
for attempt in 0..10 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(500 * attempt)).await;
}
let value = match tool
.call(json!({ "symbol": "greet", "path": "src/lib.rs" }), &ctx)
.await
{
Ok(v) => v,
Err(e) => {
eprintln!("Skipping: LSP error: {}", e);
return;
}
};
if value["total"].as_u64().unwrap_or(0) >= 1 {
result_value = Some(value);
break;
}
}
let result = match result_value {
Some(v) => v,
None => {
eprintln!("Skipping: rust-analyzer did not index in time");
return;
}
};
let groups = result["file_groups"].as_array().unwrap();
assert!(!groups.is_empty());
for group in groups {
assert!(group["file"].is_string());
assert!(group["count"].is_u64());
let items = group["items"].as_array().unwrap();
for item in items {
assert!(
item.get("file").is_none(),
"per-item file should be stripped: {item}"
);
assert!(item.get("line").is_some());
}
}
assert!(result["total"].is_u64());
assert!(result["files"].is_u64());
}
#[test]
fn rename_symbol_format_compact_shows_sites() {
use serde_json::json;
let tool = EditCode;
let result = json!({ "total_edits": 5, "textual_match_count": 1, "files_changed": 2, "new_name": "bar" });
let text = tool.format_compact(&result).unwrap();
assert!(text.contains("bar"), "got: {text}");
}
#[test]
fn insert_code_format_compact_shows_location() {
use serde_json::json;
let tool = EditCode;
let result = json!({ "status": "ok", "inserted_at_line": 42, "position": "after" });
let text = tool.format_compact(&result).unwrap();
assert!(text.contains("42"), "got: {text}");
}
#[test]
fn replace_symbol_format_compact_shows_range() {
let tool = EditCode;
let r = json!({ "status": "ok", "replaced_lines": "124-145" });
let t = tool.format_compact(&r).unwrap();
assert!(t.contains("L124"), "got: {t}");
}
#[test]
fn remove_symbol_format_compact_shows_range() {
let tool = EditCode;
let r = json!({ "status": "ok", "removed_lines": "201-215", "line_count": 14 });
let t = tool.format_compact(&r).unwrap();
assert!(t.contains("201"), "got: {t}");
assert!(t.contains("14"), "got: {t}");
}
#[test]
fn symbol_at_requires_lsp() {
let off = crate::tools::ToolCapabilities {
has_lsp: false,
has_embeddings: false,
has_git_remote: false,
has_libraries: false,
};
let on = crate::tools::ToolCapabilities {
has_lsp: true,
..off
};
let t = SymbolAt;
assert!(!t.availability(&off).is_available(&off));
assert!(t.availability(&on).is_available(&on));
}
#[test]
fn goto_single_project_definition() {
let val = serde_json::json!({
"definitions": [{
"file": "src/tools/output.rs",
"line": 35,
"end_line": 41,
"context": "pub struct OutputGuard {",
"source": "project"
}],
"from": "symbol.rs:120"
});
let result = format_goto_definition(&val);
assert_eq!(
result,
"src/tools/output.rs:35\n\n pub struct OutputGuard {"
);
}
#[test]
fn goto_single_external_definition() {
let val = serde_json::json!({
"definitions": [{
"file": "/home/user/.rustup/toolchains/stable/lib.rs",
"line": 100,
"end_line": 110,
"context": "pub enum Option<T> {",
"source": "external"
}],
"from": "main.rs:5"
});
let result = format_goto_definition(&val);
assert!(result.contains("(external)"));
assert!(result.contains(":100"));
assert!(result.contains("pub enum Option<T> {"));
}
#[test]
fn goto_multiple_definitions() {
let val = serde_json::json!({
"definitions": [
{ "file": "src/a.rs", "line": 10, "end_line": 15, "context": "fn foo()", "source": "project" },
{ "file": "src/b.rs", "line": 20, "end_line": 25, "context": "fn foo()", "source": "project" }
],
"from": "main.rs:1"
});
let result = format_goto_definition(&val);
assert!(result.starts_with("2 definitions"));
assert!(result.contains("src/a.rs:10"));
assert!(result.contains("src/b.rs:20"));
}
#[test]
fn goto_empty_definitions() {
let val = serde_json::json!({ "definitions": [] });
assert_eq!(format_goto_definition(&val), "");
}
#[test]
fn goto_empty_definitions_with_hint() {
let val = serde_json::json!({
"definitions": [],
"from": "main.rs:42",
"hint": "no definition resolvable at this position",
});
let out = format_goto_definition(&val);
assert_eq!(out, "no definition resolvable at this position");
}
#[test]
fn goto_no_context() {
let val = serde_json::json!({
"definitions": [{
"file": "src/lib.rs",
"line": 1,
"end_line": 1,
"context": "",
"source": "project"
}],
"from": "main.rs:1"
});
let result = format_goto_definition(&val);
assert_eq!(result, "src/lib.rs:1");
}
#[test]
fn goto_multiple_with_external() {
let val = serde_json::json!({
"definitions": [
{ "file": "src/a.rs", "line": 10, "end_line": 10, "context": "fn foo()", "source": "project" },
{ "file": "/ext/lib.rs", "line": 20, "end_line": 20, "context": "fn foo()", "source": "lib:serde" }
],
"from": "main.rs:1"
});
let result = format_goto_definition(&val);
assert!(result.contains("2 definitions"));
assert!(result.contains("src/a.rs:10"));
assert!(result.contains("(lib:serde)"));
}
#[test]
fn hover_with_code_fence() {
let val = serde_json::json!({
"content": "```rust\npub struct OutputGuard {\n mode: OutputMode,\n}\n```\n\nProgressive disclosure guard.",
"location": "output.rs:35"
});
let result = format_hover(&val);
assert!(result.starts_with("output.rs:35"));
assert!(result.contains(" pub struct OutputGuard {"));
assert!(result.contains(" Progressive disclosure guard."));
assert!(!result.contains("```"));
}
#[test]
fn hover_plain_text_no_fences() {
let val = serde_json::json!({
"content": "Some plain documentation.",
"location": "lib.rs:10"
});
let result = format_hover(&val);
assert_eq!(result, "lib.rs:10\n\n Some plain documentation.");
}
#[test]
fn hover_no_location() {
let val = serde_json::json!({
"content": "```rust\nfn main() {}\n```"
});
let result = format_hover(&val);
assert!(!result.contains("```"));
assert!(result.contains(" fn main() {}"));
}
#[test]
fn hover_empty_content() {
let val = serde_json::json!({});
assert_eq!(format_hover(&val), "");
}
#[test]
fn hover_null_content_with_hint_surfaces_hint() {
let val = serde_json::json!({
"content": null,
"location": "lib.rs:10",
"hint": "no hover info at this position",
});
let out = format_hover(&val);
assert!(out.starts_with("lib.rs:10"));
assert!(out.contains("no hover info at this position"));
}
#[test]
fn hover_null_content_hint_only() {
let val = serde_json::json!({
"content": null,
"hint": "lone hint",
});
assert_eq!(format_hover(&val), "lone hint");
}
#[test]
fn hover_multiline_doc() {
let val = serde_json::json!({
"content": "```rust\nfn add(a: i32, b: i32) -> i32\n```\n\nAdds two numbers.\n\nReturns the sum.",
"location": "math.rs:5"
});
let result = format_hover(&val);
assert!(result.contains(" fn add(a: i32, b: i32) -> i32"));
assert!(result.contains(" Adds two numbers."));
assert!(result.contains(" Returns the sum."));
assert!(!result.contains("```"));
}
#[test]
fn symbols_no_body() {
let val = serde_json::json!({
"symbols": [
{
"name": "OutputGuard", "symbol": "OutputGuard",
"kind": "Struct", "file": "src/tools/output.rs",
"start_line": 35, "end_line": 50
},
{
"name": "cap_items", "symbol": "OutputGuard/cap_items",
"kind": "Function", "file": "src/tools/output.rs",
"start_line": 55, "end_line": 80
}
],
"total": 2
});
let result = format_search_symbols(&val);
assert!(result.starts_with("src/tools/output.rs (2)\n"));
assert!(result.contains("Struct"));
assert!(result.contains("Function"));
assert!(result.contains("OutputGuard"));
assert!(result.contains("OutputGuard/cap_items"));
assert!(result.contains("35-50"));
assert!(result.contains("55-80"));
assert!(
!result.contains("src/tools/output.rs:35-50"),
"file path must not repeat in rows"
);
}
#[test]
fn symbols_with_body() {
let val = serde_json::json!({
"symbols": [
{
"name": "cap_items", "symbol": "OutputGuard/cap_items",
"kind": "Function", "file": "src/tools/output.rs",
"start_line": 55, "end_line": 80,
"body": "pub fn cap_items(&self) -> Option<OverflowInfo> {\n // impl\n}"
}
],
"total": 1
});
let result = format_search_symbols(&val);
assert!(result.starts_with("src/tools/output.rs (1)\n"));
assert!(result.contains("Function"));
assert!(result.contains("OutputGuard/cap_items"));
assert!(result.contains(" pub fn cap_items(&self) -> Option<OverflowInfo> {"));
assert!(result.contains(" // impl"));
assert!(result.contains(" }"));
}
#[test]
fn symbols_with_long_body_shows_hint_not_truncated_body() {
let long_body = "fun convert() {\n".to_string() + &" val x = 1\n".repeat(50) + "}";
assert!(
long_body.len() > 500,
"test body should exceed INLINE_BODY_LIMIT"
);
let val = serde_json::json!({
"symbols": [
{
"name": "convert", "symbol": "Stage1ToStage2Converter/convert",
"kind": "Method", "file": "src/Converter.kt",
"start_line": 160, "end_line": 490,
"body": long_body
}
],
"total": 1
});
let result = format_search_symbols(&val);
assert!(
result.contains("52-line body"),
"expected line count in hint, got: {result}"
);
assert!(
result.contains("$.symbols[0].body"),
"expected json_path hint, got: {result}"
);
assert!(
!result.contains("val x = 1"),
"body content must not appear inline"
);
}
#[test]
fn symbols_with_overflow() {
let val = serde_json::json!({
"symbols": [
{
"name": "foo", "symbol": "foo",
"kind": "Function", "file": "src/a.rs",
"start_line": 10, "end_line": 10
}
],
"total": 100,
"overflow": {
"shown": 20, "total": 100,
"hint": "narrow with path=",
"by_file": [["src/a.rs", 50], ["src/b.rs", 30]]
}
});
let result = format_search_symbols(&val);
assert!(result.contains("20 of 100"), "got:\n{result}");
assert!(result.contains("narrow with path="), "got:\n{result}");
assert!(result.contains("foo"), "got:\n{result}");
}
#[test]
fn symbols_empty() {
let val = serde_json::json!({
"symbols": [],
"total": 0
});
assert_eq!(format_search_symbols(&val), "0 matches");
}
#[test]
fn symbols_missing_symbols_key() {
let val = serde_json::json!({});
assert_eq!(format_search_symbols(&val), "");
}
#[test]
fn symbols_alignment() {
let val = serde_json::json!({
"symbols": [
{
"name": "Foo", "symbol": "Foo",
"kind": "Struct", "file": "src/a.rs",
"start_line": 1, "end_line": 5
},
{
"name": "bar_baz", "symbol": "bar_baz",
"kind": "Function", "file": "src/very/long/path.rs",
"start_line": 100, "end_line": 200
}
],
"total": 2
});
let result = format_search_symbols(&val);
assert!(result.contains("src/a.rs (1)"), "got:\n{result}");
assert!(
result.contains("src/very/long/path.rs (1)"),
"got:\n{result}"
);
assert!(result.contains("Struct"));
assert!(result.contains("Function"));
assert!(result.contains("1-5"));
assert!(result.contains("100-200"));
assert!(
!result.contains("src/a.rs:1-5"),
"file path must not be in rows: {result}"
);
assert!(
!result.contains("src/very/long/path.rs:100-200"),
"file path must not be in rows: {result}"
);
}
#[test]
fn symbols_single_line_location() {
let val = serde_json::json!({
"symbols": [
{
"name": "X", "symbol": "X",
"kind": "Constant", "file": "src/lib.rs",
"start_line": 42, "end_line": 42
}
],
"total": 1
});
let result = format_search_symbols(&val);
assert!(result.contains("src/lib.rs (1)"), "got:\n{result}");
assert!(result.contains("42"));
assert!(!result.contains("42-42"));
}
#[test]
fn symbols_overview_file_mode() {
let val = serde_json::json!({
"file": "src/tools/output.rs",
"symbols": [
{
"name": "OutputMode", "symbol": "OutputMode",
"kind": "Enum", "start_line": 10, "end_line": 15,
"children": [
{ "name": "Exploring", "kind": "EnumMember", "start_line": 11, "end_line": 11 },
{ "name": "Focused", "kind": "EnumMember", "start_line": 12, "end_line": 12 }
]
},
{
"name": "OutputGuard", "symbol": "OutputGuard",
"kind": "Struct", "start_line": 35, "end_line": 50
}
]
});
let result = format_overview_symbols(&val);
assert!(result.starts_with("src/tools/output.rs — 2 symbols\n"));
assert!(result.contains("Enum"));
assert!(result.contains("OutputMode"));
assert!(result.contains("L10-15"));
assert!(result.contains("Exploring"));
assert!(result.contains("L11"));
assert!(result.contains("Focused"));
assert!(result.contains("L12"));
assert!(result.contains("Struct"));
assert!(result.contains("OutputGuard"));
assert!(result.contains("L35-50"));
assert!(!result.contains("EnumMember"));
}
#[test]
fn symbols_overview_directory_mode() {
let val = serde_json::json!({
"directory": "src/tools",
"files": [
{
"file": "src/tools/ast.rs",
"symbols": [
{ "name": "ListFunctions", "symbol": "ListFunctions", "kind": "Struct", "start_line": 10, "end_line": 20 }
]
},
{
"file": "src/tools/config.rs",
"symbols": [
{ "name": "GetConfig", "symbol": "GetConfig", "kind": "Struct", "start_line": 5, "end_line": 15 },
{ "name": "ActivateProject", "symbol": "ActivateProject", "kind": "Struct", "start_line": 20, "end_line": 30 }
]
}
]
});
let result = format_overview_symbols(&val);
assert!(result.starts_with("src/tools\n"));
assert!(result.contains("src/tools/ast.rs — 1 symbol\n"));
assert!(result.contains("src/tools/config.rs — 2 symbols\n"));
assert!(result.contains("ListFunctions"));
assert!(result.contains("GetConfig"));
assert!(result.contains("ActivateProject"));
}
#[test]
fn symbols_overview_pattern_mode() {
let val = serde_json::json!({
"pattern": "src/**/*.rs",
"files": [
{
"file": "src/main.rs",
"symbols": [
{ "name": "main", "symbol": "main", "kind": "Function", "start_line": 1, "end_line": 10 }
]
}
]
});
let result = format_overview_symbols(&val);
assert!(result.starts_with("src/**/*.rs\n"));
assert!(result.contains("src/main.rs — 1 symbol\n"));
assert!(result.contains("main"));
}
#[test]
fn symbols_overview_empty_file() {
let val = serde_json::json!({
"file": "src/empty.rs",
"symbols": []
});
let result = format_overview_symbols(&val);
assert!(result.contains("0 symbols"));
}
#[test]
fn symbols_overview_empty_directory() {
let val = serde_json::json!({
"directory": "src/empty",
"files": []
});
let result = format_overview_symbols(&val);
assert_eq!(result, "src/empty — 0 symbols");
}
#[tokio::test]
async fn symbols_overview_falls_back_to_treesitter_when_lsp_returns_empty() {
use crate::lsp::{mock::MockLspClient, mock::MockLspProvider};
use crate::tools::symbol::Symbols;
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let file = src_dir.join("modlike.rs");
std::fs::write(&file, "pub mod alpha;\npub mod beta;\nfn helper() {}\n").unwrap();
let mock = MockLspClient::new();
let lsp = MockLspProvider::with_client(mock);
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp,
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let result = Symbols
.call(json!({"path": "src/modlike.rs"}), &ctx)
.await
.expect("call must succeed");
let syms = result["symbols"].as_array().expect("symbols array");
assert!(
!syms.is_empty(),
"expected tree-sitter fallback to populate symbols when LSP returns empty for non-empty file, got: {result}"
);
let names: Vec<&str> = syms.iter().filter_map(|s| s["name"].as_str()).collect();
assert!(
names.contains(&"alpha") || names.contains(&"helper"),
"expected at least one of the source symbols, got names: {names:?}"
);
}
#[tokio::test]
async fn symbols_overview_returns_empty_for_empty_file_via_treesitter() {
use crate::lsp::{mock::MockLspClient, mock::MockLspProvider};
use crate::tools::symbol::Symbols;
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let file = src_dir.join("blank.rs");
std::fs::write(&file, "// just a comment\n").unwrap();
let mock = MockLspClient::new();
let lsp = MockLspProvider::with_client(mock);
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp,
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let result = Symbols
.call(json!({"path": "src/blank.rs"}), &ctx)
.await
.expect("call must succeed");
let syms = result["symbols"].as_array().expect("symbols array");
assert!(
syms.is_empty(),
"comment-only file should return empty symbols (no fallback needed), got: {result}"
);
}
#[test]
fn symbols_overview_with_overflow() {
let val = serde_json::json!({
"directory": "src",
"files": [
{
"file": "src/a.rs",
"symbols": [
{ "name": "Foo", "symbol": "Foo", "kind": "Struct", "start_line": 1, "end_line": 5 }
]
}
],
"overflow": { "shown": 10, "total": 50, "hint": "Narrow with a more specific glob or file path" }
});
let result = format_overview_symbols(&val);
assert!(result.contains("10 of 50"));
assert!(result.contains("Narrow with a more specific glob"));
}
#[test]
fn symbols_overview_children_with_fields() {
let val = serde_json::json!({
"file": "src/model.rs",
"symbols": [
{
"name": "Config", "symbol": "Config",
"kind": "Struct", "start_line": 1, "end_line": 10,
"children": [
{ "name": "port", "kind": "Field", "start_line": 2, "end_line": 2 },
{ "name": "host", "kind": "Field", "start_line": 3, "end_line": 3 }
]
}
]
});
let result = format_overview_symbols(&val);
assert!(!result.contains("Field"));
assert!(result.contains("port"));
assert!(result.contains("host"));
assert!(result.contains("L2"));
assert!(result.contains("L3"));
}
#[test]
fn symbols_overview_children_with_methods() {
let val = serde_json::json!({
"file": "src/service.rs",
"symbols": [
{
"name": "Server", "symbol": "Server",
"kind": "Struct", "start_line": 1, "end_line": 50,
"children": [
{ "name": "new", "kind": "Function", "start_line": 5, "end_line": 10 },
{ "name": "run", "kind": "Function", "start_line": 12, "end_line": 40 }
]
}
]
});
let result = format_overview_symbols(&val);
assert!(result.contains("Function new"));
assert!(result.contains("Function run"));
}
#[test]
fn symbols_overview_missing_symbols_key() {
let val = serde_json::json!({});
assert_eq!(format_overview_symbols(&val), "");
}
#[test]
fn symbols_overview_singular_symbol_word() {
let val = serde_json::json!({
"file": "src/single.rs",
"symbols": [
{ "name": "main", "symbol": "main", "kind": "Function", "start_line": 1, "end_line": 5 }
]
});
let result = format_overview_symbols(&val);
assert!(result.contains("1 symbol\n"));
assert!(!result.contains("1 symbols"));
}
#[test]
fn find_references_basic() {
use crate::tools::symbol::references::References;
use crate::tools::Tool;
let tool = References;
let result = serde_json::json!({
"file_groups": [
{"file": "src/foo.rs", "count": 2, "items": [
{"line": 10, "column": 0, "context": ""},
{"line": 30, "column": 0, "context": ""}
]},
{"file": "src/bar.rs", "count": 1, "items": [
{"line": 20, "column": 0, "context": ""}
]}
],
"total": 3,
"files": 2
});
let text = tool.format_compact(&result).unwrap();
assert!(text.contains("3"), "should mention count");
assert!(
text.contains("references") || text.contains("refs"),
"should say refs or reference(s)"
);
}
#[test]
fn find_references_empty() {
use crate::tools::symbol::references::References;
use crate::tools::Tool;
let tool = References;
let result = serde_json::json!({ "file_groups": [], "total": 0, "files": 0 });
let text = tool.format_compact(&result).unwrap();
assert!(text.contains("0"), "should mention zero, got: {}", text);
}
#[test]
fn format_find_references_shows_locations() {
use crate::tools::symbol::references::References;
use crate::tools::Tool;
let tool = References;
let result = serde_json::json!({
"total": 8,
"files": 5,
"file_groups": [
{"file": "src/tools/symbol.rs", "count": 2, "items": [
{"line": 142, "column": 0, "context": ""},
{"line": 198, "column": 0, "context": ""}
]},
{"file": "src/server.rs", "count": 1, "items": [
{"line": 87, "column": 0, "context": ""}
]},
{"file": "src/agent.rs", "count": 1, "items": [
{"line": 210, "column": 0, "context": ""}
]},
{"file": "src/main.rs", "count": 1, "items": [
{"line": 45, "column": 0, "context": ""}
]},
{"file": "src/config.rs", "count": 1, "items": [
{"line": 12, "column": 0, "context": ""}
]}
]
});
let out = tool.format_compact(&result).unwrap();
assert!(out.contains("8"), "should show total");
assert!(out.contains("references"), "should use references noun");
assert!(out.contains("src/tools/symbol.rs"), "should show file");
assert!(out.contains("142"), "should show line numbers");
assert!(out.contains("src/server.rs"), "should show other files");
assert!(
out.contains("src/config.rs"),
"format_compact shows all refs, no cap"
);
}
#[test]
fn format_find_references_five_or_fewer_no_trailer() {
use crate::tools::symbol::references::References;
use crate::tools::Tool;
let tool = References;
let result = serde_json::json!({
"total": 3,
"files": 3,
"file_groups": [
{"file": "src/a.rs", "count": 1, "items": [{"line": 1, "column": 0, "context": ""}]},
{"file": "src/b.rs", "count": 1, "items": [{"line": 2, "column": 0, "context": ""}]},
{"file": "src/c.rs", "count": 1, "items": [{"line": 3, "column": 0, "context": ""}]}
]
});
let out = tool.format_compact(&result).unwrap();
assert!(out.contains("src/a.rs"), "should show first file");
assert!(out.contains("src/b.rs"), "should show second file");
assert!(out.contains("src/c.rs"), "should show third file");
assert!(!out.contains("more"), "no trailer when all refs fit");
}
#[tokio::test]
async fn symbols_falls_back_to_document_symbols_on_bad_workspace_range() {
use crate::lsp::{mock::MockLspClient, mock::MockLspProvider, SymbolInfo, SymbolKind};
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let file = src_dir.join("lib.rs");
std::fs::write(
&file,
"fn helper(x: i32) -> i32 {\n let y = x + 1;\n y * 2\n}\n",
)
.unwrap();
let degenerate = SymbolInfo {
name: "helper".to_string(),
name_path: "helper".to_string(),
kind: SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 0,
start_col: 3,
children: vec![],
range_start_line: None,
detail: None,
};
let correct = SymbolInfo {
name: "helper".to_string(),
name_path: "helper".to_string(),
kind: SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 3,
start_col: 3,
children: vec![],
range_start_line: None,
detail: None,
};
let mock = MockLspClient::new()
.with_workspace_symbols(vec![degenerate])
.with_symbols(&file, vec![correct]);
let lsp = MockLspProvider::with_client(mock);
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp,
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let result = Symbols
.call(
json!({
"query": "helper",
"include_body": true,
}),
&ctx,
)
.await;
let val = result.expect("symbols should recover via document_symbols fallback");
let symbols = val["symbols"].as_array().expect("symbols array");
assert_eq!(symbols.len(), 1, "should find exactly one symbol");
let body = symbols[0]["body"].as_str().expect("body should be present");
assert!(
body.contains("let y = x + 1"),
"body should contain function contents; got: {body}"
);
}
#[tokio::test]
async fn symbol_at_def_uses_col_param_over_identifier() {
use crate::lsp::{mock::MockLspClient, mock::MockLspProvider};
use crate::tools::symbol::SymbolAt;
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let file = src_dir.join("lib.rs");
std::fs::write(&file, "fn helper(x: i32) {}\n").unwrap();
let target = lsp_types::Location {
uri: url::Url::from_file_path(&file)
.unwrap()
.as_str()
.parse()
.unwrap(),
range: lsp_types::Range {
start: lsp_types::Position {
line: 5,
character: 0,
},
end: lsp_types::Position {
line: 5,
character: 6,
},
},
};
let mock = MockLspClient::new().with_definitions(0, 3, vec![target]);
let lsp = MockLspProvider::with_client(mock);
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp,
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let result = SymbolAt
.call(
json!({
"path": "src/lib.rs",
"line": 1,
"col": 4,
"identifier": "DOES_NOT_EXIST_ON_LINE",
"fields": ["def"],
}),
&ctx,
)
.await
.expect("col should win over identifier and resolve");
let defs = result["def"]["definitions"].as_array().unwrap();
assert_eq!(defs.len(), 1, "exactly one definition expected");
assert_eq!(defs[0]["line"].as_u64(), Some(6)); }
#[tokio::test]
async fn symbol_at_hover_returns_ok_with_null_content_when_lsp_empty() {
use crate::lsp::{mock::MockLspClient, mock::MockLspProvider};
use crate::tools::symbol::SymbolAt;
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let file = src_dir.join("lib.rs");
std::fs::write(&file, "fn x() {}\n").unwrap();
let mock = MockLspClient::new();
let lsp = MockLspProvider::with_client(mock);
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp,
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let result = SymbolAt
.call(
json!({
"path": "src/lib.rs",
"line": 1,
"col": 4,
"fields": ["hover"],
}),
&ctx,
)
.await
.expect("empty hover must be Ok, not Err (no misclassification)");
assert!(
result["hover"]["content"].is_null(),
"content should be null on empty"
);
assert!(
result["hover"]["hint"].as_str().is_some(),
"hint should be present to guide caller"
);
}
#[tokio::test]
async fn symbol_at_hover_col_zero_rejected() {
use crate::lsp::{mock::MockLspClient, mock::MockLspProvider};
use crate::tools::symbol::SymbolAt;
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let file = src_dir.join("lib.rs");
std::fs::write(&file, "fn x() {}\n").unwrap();
let lsp = MockLspProvider::with_client(MockLspClient::new());
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp,
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let err = SymbolAt
.call(
json!({"path": "src/lib.rs", "line": 1, "col": 0, "fields": ["hover"]}),
&ctx,
)
.await
.unwrap_err();
assert!(err.to_string().contains("'col' must be >= 1"), "got: {err}");
}
#[tokio::test]
async fn symbol_at_hover_retries_once_on_mux_disconnect() {
use crate::lsp::{mock::MockLspClient, mock::MockLspProvider};
use crate::tools::symbol::SymbolAt;
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let file = src_dir.join("lib.rs");
std::fs::write(&file, "fn x() {}\n").unwrap();
let mock = MockLspClient::new().with_hover_responses(vec![
Err(anyhow::anyhow!("Mux connection lost")),
Ok(Some("```rust\nfn x()\n```".to_string())),
]);
let lsp = MockLspProvider::with_client(mock);
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp,
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let result = SymbolAt
.call(
json!({"path": "src/lib.rs", "line": 1, "col": 4, "fields": ["hover"]}),
&ctx,
)
.await
.expect("transient mux disconnect should be retried, not surfaced");
assert_eq!(
result["hover"]["content"].as_str(),
Some("```rust\nfn x()\n```"),
"second-attempt content should be returned"
);
}
#[tokio::test]
async fn symbol_at_hover_does_not_retry_non_disconnect_errors() {
use crate::lsp::{mock::MockLspClient, mock::MockLspProvider};
use crate::tools::symbol::SymbolAt;
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let file = src_dir.join("lib.rs");
std::fs::write(&file, "fn x() {}\n").unwrap();
let mock = MockLspClient::new()
.with_hover_responses(vec![Err(anyhow::anyhow!("some unrelated LSP error"))]);
let lsp = MockLspProvider::with_client(mock);
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp,
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let err = SymbolAt
.call(
json!({"path": "src/lib.rs", "line": 1, "col": 4, "fields": ["hover"]}),
&ctx,
)
.await
.unwrap_err();
assert!(
err.to_string().contains("some unrelated LSP error"),
"non-disconnect errors must surface immediately, got: {err}"
);
}
#[tokio::test]
async fn symbol_at_returns_both_fields_by_default() {
use crate::lsp::{mock::MockLspClient, mock::MockLspProvider};
use crate::tools::symbol::SymbolAt;
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let file = src_dir.join("lib.rs");
std::fs::write(&file, "fn helper(x: i32) {}\n").unwrap();
let target = lsp_types::Location {
uri: url::Url::from_file_path(&file)
.unwrap()
.as_str()
.parse()
.unwrap(),
range: lsp_types::Range {
start: lsp_types::Position {
line: 0,
character: 3,
},
end: lsp_types::Position {
line: 0,
character: 9,
},
},
};
let mock = MockLspClient::new()
.with_definitions(0, 3, vec![target])
.with_hover_responses(vec![Ok(Some(
"```rust\nfn helper(x: i32)\n```".to_string(),
))]);
let lsp = MockLspProvider::with_client(mock);
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp,
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let result = SymbolAt
.call(json!({"path": "src/lib.rs", "line": 1, "col": 4}), &ctx)
.await
.expect("symbol_at with default fields should succeed");
assert!(
result.get("def").is_some(),
"default fields should include def; got: {result:?}"
);
assert!(
result.get("hover").is_some(),
"default fields should include hover; got: {result:?}"
);
}
#[test]
fn find_matching_symbol_finds_top_level() {
use crate::lsp::SymbolKind;
let symbols = vec![SymbolInfo {
name: "foo".to_string(),
name_path: "foo".to_string(),
kind: SymbolKind::Function,
file: PathBuf::from("lib.rs"),
start_line: 10,
end_line: 20,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
}];
let result = find_matching_symbol(&symbols, "foo", 10);
assert!(result.is_some());
assert_eq!(result.unwrap().end_line, 20);
}
#[test]
fn find_matching_symbol_finds_nested_child() {
use crate::lsp::SymbolKind;
let child = SymbolInfo {
name: "bar".to_string(),
name_path: "Foo/bar".to_string(),
kind: SymbolKind::Function,
file: PathBuf::from("lib.rs"),
start_line: 15,
end_line: 18,
start_col: 4,
children: vec![],
range_start_line: None,
detail: None,
};
let parent = SymbolInfo {
name: "Foo".to_string(),
name_path: "Foo".to_string(),
kind: SymbolKind::Struct,
file: PathBuf::from("lib.rs"),
start_line: 10,
end_line: 20,
start_col: 0,
children: vec![child],
range_start_line: None,
detail: None,
};
let result = find_matching_symbol(&[parent], "bar", 15);
assert!(result.is_some());
assert_eq!(result.unwrap().end_line, 18);
}
#[test]
fn find_matching_symbol_returns_none_on_name_mismatch() {
use crate::lsp::SymbolKind;
let symbols = vec![SymbolInfo {
name: "foo".to_string(),
name_path: "foo".to_string(),
kind: SymbolKind::Function,
file: PathBuf::from("lib.rs"),
start_line: 10,
end_line: 20,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
}];
let result = find_matching_symbol(&symbols, "bar", 10);
assert!(result.is_none());
}
#[test]
fn find_matching_symbol_returns_none_when_line_too_far() {
use crate::lsp::SymbolKind;
let symbols = vec![SymbolInfo {
name: "foo".to_string(),
name_path: "foo".to_string(),
kind: SymbolKind::Function,
file: PathBuf::from("lib.rs"),
start_line: 10,
end_line: 20,
start_col: 0,
children: vec![],
range_start_line: None,
detail: None,
}];
let result = find_matching_symbol(&symbols, "foo", 13);
assert!(result.is_none());
}
#[tokio::test]
async fn symbols_propagates_error_when_fallback_also_fails() {
use crate::lsp::{mock::MockLspClient, mock::MockLspProvider, SymbolInfo, SymbolKind};
let dir = tempfile::tempdir().unwrap();
let src_dir = dir.path().join("src");
std::fs::create_dir_all(&src_dir).unwrap();
let file = src_dir.join("lib.rs");
std::fs::write(
&file,
"fn helper(x: i32) -> i32 {\n let y = x + 1;\n y * 2\n}\n",
)
.unwrap();
let degenerate = SymbolInfo {
name: "helper".to_string(),
name_path: "helper".to_string(),
kind: SymbolKind::Function,
file: file.clone(),
start_line: 0,
end_line: 0,
start_col: 3,
children: vec![],
range_start_line: None,
detail: None,
};
let mock = MockLspClient::new().with_workspace_symbols(vec![degenerate]);
let lsp = MockLspProvider::with_client(mock);
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp,
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let result = Symbols
.call(
json!({
"query": "helper",
"include_body": true,
}),
&ctx,
)
.await;
let err = result.expect_err("should propagate error when fallback fails");
let msg = err.to_string();
assert!(
msg.contains("suspicious range"),
"error should mention suspicious range; got: {msg}"
);
}
#[tokio::test]
async fn resolve_library_roots_empty_when_no_libraries() {
let dir = tempdir().unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let roots = resolve_library_roots(&crate::library::scope::Scope::Libraries, &agent)
.await
.unwrap();
assert!(roots.is_empty());
}
#[tokio::test]
async fn resolve_library_roots_returns_registered_libraries() {
let dir = tempdir().unwrap();
let lib_dir = tempdir().unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
{
let mut inner = agent.inner.write().await;
let project = inner.active_project_mut().unwrap();
project.library_registry.register(
"mylib".to_string(),
lib_dir.path().to_path_buf(),
"rust".to_string(),
crate::library::registry::DiscoveryMethod::Manual,
true,
);
}
let roots = resolve_library_roots(&crate::library::scope::Scope::Libraries, &agent)
.await
.unwrap();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].0, "mylib");
assert_eq!(roots[0].1, lib_dir.path().to_path_buf());
}
#[tokio::test]
async fn resolve_library_roots_filters_by_name() {
let dir = tempdir().unwrap();
let lib1 = tempdir().unwrap();
let lib2 = tempdir().unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
{
let mut inner = agent.inner.write().await;
let project = inner.active_project_mut().unwrap();
project.library_registry.register(
"alpha".to_string(),
lib1.path().to_path_buf(),
"rust".to_string(),
crate::library::registry::DiscoveryMethod::Manual,
true,
);
project.library_registry.register(
"beta".to_string(),
lib2.path().to_path_buf(),
"rust".to_string(),
crate::library::registry::DiscoveryMethod::Manual,
true,
);
}
let roots = resolve_library_roots(
&crate::library::scope::Scope::Library("alpha".to_string()),
&agent,
)
.await
.unwrap();
assert_eq!(roots.len(), 1);
assert_eq!(roots[0].0, "alpha");
}
#[tokio::test]
async fn resolve_library_roots_project_scope_returns_empty() {
let dir = tempdir().unwrap();
let lib_dir = tempdir().unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
{
let mut inner = agent.inner.write().await;
let project = inner.active_project_mut().unwrap();
project.library_registry.register(
"mylib".to_string(),
lib_dir.path().to_path_buf(),
"rust".to_string(),
crate::library::registry::DiscoveryMethod::Manual,
true,
);
}
let roots = resolve_library_roots(&crate::library::scope::Scope::Project, &agent)
.await
.unwrap();
assert!(roots.is_empty());
}
#[tokio::test]
async fn resolve_library_roots_all_scope_returns_all() {
let dir = tempdir().unwrap();
let lib1 = tempdir().unwrap();
let lib2 = tempdir().unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
{
let mut inner = agent.inner.write().await;
let project = inner.active_project_mut().unwrap();
project.library_registry.register(
"alpha".to_string(),
lib1.path().to_path_buf(),
"rust".to_string(),
crate::library::registry::DiscoveryMethod::Manual,
true,
);
project.library_registry.register(
"beta".to_string(),
lib2.path().to_path_buf(),
"python".to_string(),
crate::library::registry::DiscoveryMethod::Manual,
true,
);
}
let roots = resolve_library_roots(&crate::library::scope::Scope::All, &agent)
.await
.unwrap();
assert_eq!(roots.len(), 2);
}
#[tokio::test]
async fn resolve_library_roots_excludes_source_unavailable() {
let dir = tempdir().unwrap();
let lib_dir = tempdir().unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
{
let mut inner = agent.inner.write().await;
let project = inner.active_project_mut().unwrap();
project.library_registry.register(
"available".to_string(),
lib_dir.path().to_path_buf(),
"rust".to_string(),
crate::library::registry::DiscoveryMethod::Manual,
true,
);
project.library_registry.register(
"unavailable".to_string(),
PathBuf::new(),
"java".to_string(),
crate::library::registry::DiscoveryMethod::ManifestScan,
false,
);
}
let result = resolve_library_roots(
&crate::library::scope::Scope::Library("unavailable".to_string()),
&agent,
)
.await;
assert!(
result.is_err(),
"should return error for source-unavailable library"
);
let err = result.unwrap_err().to_string();
assert!(err.contains("source code is not available"), "error: {err}");
let roots = resolve_library_roots(&crate::library::scope::Scope::All, &agent)
.await
.unwrap();
assert_eq!(
roots.len(),
1,
"All scope should only return available libs"
);
assert_eq!(roots[0].0, "available");
}
#[test]
fn format_library_path_strips_root() {
let lib_root = PathBuf::from("/home/user/.cargo/registry/src/serde-1.0");
let file = PathBuf::from("/home/user/.cargo/registry/src/serde-1.0/src/lib.rs");
let result = format_library_path("serde", &lib_root, &file);
assert_eq!(result, "lib:serde/src/lib.rs");
}
#[test]
fn format_library_path_fallback_for_outside_root() {
let lib_root = PathBuf::from("/home/user/.cargo/registry/src/serde-1.0");
let file = PathBuf::from("/somewhere/else/lib.rs");
let result = format_library_path("serde", &lib_root, &file);
assert_eq!(result, "/somewhere/else/lib.rs");
}
#[test]
fn classify_reference_path_project() {
let root = PathBuf::from("/project");
let libs = vec![("mylib".to_string(), PathBuf::from("/libs/mylib"))];
let path = PathBuf::from("/project/src/main.rs");
let (classification, display) = classify_reference_path(&path, &root, &libs);
assert_eq!(classification, "project");
assert_eq!(display, "src/main.rs");
}
#[test]
fn classify_reference_path_library() {
let root = PathBuf::from("/project");
let libs = vec![("mylib".to_string(), PathBuf::from("/libs/mylib"))];
let path = PathBuf::from("/libs/mylib/src/lib.rs");
let (classification, display) = classify_reference_path(&path, &root, &libs);
assert_eq!(classification, "lib:mylib");
assert_eq!(display, "lib:mylib/src/lib.rs");
}
#[test]
fn classify_reference_path_external() {
let root = PathBuf::from("/project");
let libs = vec![("mylib".to_string(), PathBuf::from("/libs/mylib"))];
let path = PathBuf::from("/somewhere/else.rs");
let (classification, display) = classify_reference_path(&path, &root, &libs);
assert_eq!(classification, "external");
assert_eq!(display, "/somewhere/else.rs");
}
fn test_ctx_with_agent(agent: Agent) -> ToolContext {
ToolContext {
agent,
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
}
}
#[tokio::test]
async fn symbols_overview_scope_libraries_includes_library_files() {
let project_dir = tempdir().unwrap();
std::fs::create_dir_all(project_dir.path().join(".codescout")).unwrap();
let lib_dir = tempdir().unwrap();
let lib_src = lib_dir.path().join("src");
std::fs::create_dir_all(&lib_src).unwrap();
std::fs::write(lib_src.join("lib.rs"), "pub fn hello() {}\n").unwrap();
let agent = Agent::new(Some(project_dir.path().to_path_buf()))
.await
.unwrap();
{
let mut inner = agent.inner.write().await;
let project = inner.active_project_mut().unwrap();
project.library_registry.register(
"testlib".to_string(),
lib_dir.path().to_path_buf(),
"rust".to_string(),
crate::library::registry::DiscoveryMethod::Manual,
true,
);
}
let ctx = test_ctx_with_agent(agent);
let tool = Symbols;
let result = tool
.call(json!({"scope": "libraries"}), &ctx)
.await
.unwrap();
let files = result["files"].as_array().unwrap();
assert!(!files.is_empty(), "should find library files");
let first_file = files[0]["file"].as_str().unwrap();
assert!(
first_file.starts_with("lib:testlib/"),
"library file should have lib: prefix, got: {}",
first_file
);
}
#[tokio::test]
async fn symbols_overview_scope_project_excludes_libraries() {
let project_dir = tempdir().unwrap();
std::fs::create_dir_all(project_dir.path().join(".codescout")).unwrap();
let lib_dir = tempdir().unwrap();
std::fs::create_dir_all(lib_dir.path().join("src")).unwrap();
std::fs::write(lib_dir.path().join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(project_dir.path().join("main.rs"), "fn main() {}\n").unwrap();
let agent = Agent::new(Some(project_dir.path().to_path_buf()))
.await
.unwrap();
{
let mut inner = agent.inner.write().await;
let project = inner.active_project_mut().unwrap();
project.library_registry.register(
"testlib".to_string(),
lib_dir.path().to_path_buf(),
"rust".to_string(),
crate::library::registry::DiscoveryMethod::Manual,
true,
);
}
let ctx = test_ctx_with_agent(agent);
let tool = Symbols;
let result = tool.call(json!({"scope": "project"}), &ctx).await.unwrap();
let empty = vec![];
let files = result["files"].as_array().unwrap_or(&empty);
for f in files {
let path = f["file"].as_str().unwrap();
assert!(
!path.starts_with("lib:"),
"project scope should not include library files: {}",
path
);
}
}
#[tokio::test]
async fn symbols_scope_libraries_searches_library_dirs() {
let project_dir = tempdir().unwrap();
std::fs::create_dir_all(project_dir.path().join(".codescout")).unwrap();
let lib_dir = tempdir().unwrap();
std::fs::create_dir_all(lib_dir.path().join("src")).unwrap();
std::fs::write(
lib_dir.path().join("src/lib.rs"),
"pub fn library_unique_symbol_xyz() {}\n",
)
.unwrap();
let agent = Agent::new(Some(project_dir.path().to_path_buf()))
.await
.unwrap();
{
let mut inner = agent.inner.write().await;
let project = inner.active_project_mut().unwrap();
project.library_registry.register(
"testlib".to_string(),
lib_dir.path().to_path_buf(),
"rust".to_string(),
crate::library::registry::DiscoveryMethod::Manual,
true,
);
}
let ctx = test_ctx_with_agent(agent);
let tool = Symbols;
let result = tool
.call(
json!({
"query": "library_unique_symbol_xyz",
"scope": "libraries"
}),
&ctx,
)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
assert!(!symbols.is_empty(), "should find symbol in library");
let file = symbols[0]["file"]
.as_str()
.or_else(|| result["file"].as_str())
.unwrap();
assert!(
file.starts_with("lib:testlib/"),
"file path should have lib: prefix: {}",
file
);
}
#[tokio::test]
async fn symbols_scope_all_searches_both() {
let project_dir = tempdir().unwrap();
std::fs::create_dir_all(project_dir.path().join(".codescout")).unwrap();
let lib_dir = tempdir().unwrap();
std::fs::write(project_dir.path().join("main.rs"), "fn project_func() {}\n").unwrap();
std::fs::create_dir_all(lib_dir.path().join("src")).unwrap();
std::fs::write(lib_dir.path().join("src/lib.rs"), "pub fn lib_func() {}\n").unwrap();
let agent = Agent::new(Some(project_dir.path().to_path_buf()))
.await
.unwrap();
{
let mut inner = agent.inner.write().await;
let project = inner.active_project_mut().unwrap();
project.library_registry.register(
"testlib".to_string(),
lib_dir.path().to_path_buf(),
"rust".to_string(),
crate::library::registry::DiscoveryMethod::Manual,
true,
);
}
let ctx = test_ctx_with_agent(agent);
let tool = Symbols;
let result = tool
.call(
json!({
"query": "func",
"scope": "all"
}),
&ctx,
)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
let files: Vec<&str> = symbols.iter().filter_map(|s| s["file"].as_str()).collect();
assert!(
files.iter().any(|f| f.starts_with("lib:testlib/")),
"should include library symbol"
);
assert!(
files.iter().any(|f| !f.starts_with("lib:")),
"should include project symbol"
);
}
#[tokio::test]
async fn symbols_scope_project_default_excludes_libraries() {
let project_dir = tempdir().unwrap();
std::fs::create_dir_all(project_dir.path().join(".codescout")).unwrap();
let lib_dir = tempdir().unwrap();
std::fs::write(project_dir.path().join("main.rs"), "fn my_func() {}\n").unwrap();
std::fs::create_dir_all(lib_dir.path().join("src")).unwrap();
std::fs::write(lib_dir.path().join("src/lib.rs"), "pub fn my_func() {}\n").unwrap();
let agent = Agent::new(Some(project_dir.path().to_path_buf()))
.await
.unwrap();
{
let mut inner = agent.inner.write().await;
let project = inner.active_project_mut().unwrap();
project.library_registry.register(
"testlib".to_string(),
lib_dir.path().to_path_buf(),
"rust".to_string(),
crate::library::registry::DiscoveryMethod::Manual,
true,
);
}
let ctx = test_ctx_with_agent(agent);
let tool = Symbols;
let result = tool
.call(
json!({
"query": "my_func",
"scope": "project"
}),
&ctx,
)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
let top_file = result["file"].as_str();
for s in symbols {
let file = s["file"].as_str().or(top_file).unwrap();
assert!(
!file.starts_with("lib:"),
"project scope should not include library: {}",
file
);
}
}
#[tokio::test]
async fn symbols_with_multiple_matches_returns_all() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src/a")).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
std::fs::write(
dir.path().join("src/a/alpha.rs"),
"pub fn process_alpha() -> i32 { 1 }\n",
)
.unwrap();
std::fs::write(
dir.path().join("src/a/beta.rs"),
"pub fn process_beta() -> i32 { 2 }\n",
)
.unwrap();
std::fs::write(
dir.path().join("src/a/gamma.rs"),
"pub fn process_gamma() -> i32 { 3 }\n",
)
.unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp: lsp(),
output_buffer: buf(),
progress: None,
peer: None, section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
crate::tools::section_coverage::SectionCoverage::new(),
)),
};
let result = Symbols
.call(json!({ "query": "process" }), &ctx)
.await
.unwrap();
let symbols = result["symbols"].as_array().unwrap();
assert!(
symbols.len() >= 3,
"should return all matches when peer=None, got {} symbols: {:?}",
symbols.len(),
result
);
let total = result["total"].as_u64().unwrap_or(0);
assert!(total >= 3, "total should be >= 3 with no peer, got {total}");
}
#[tokio::test]
async fn symbols_rejects_regex_alternation() {
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();
let ctx = test_ctx_with_agent(agent);
let err = Symbols
.call(json!({"query": "foo|bar"}), &ctx)
.await
.unwrap_err();
let rec = err
.downcast_ref::<crate::tools::RecoverableError>()
.expect("should be RecoverableError");
assert!(
rec.message.contains("regex"),
"message should mention regex, got: {}",
rec.message
);
assert!(
rec.hint().unwrap_or("").contains("grep"),
"hint should mention grep, got: {:?}",
rec.hint()
);
}
#[tokio::test]
async fn symbols_rejects_regex_wildcard() {
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();
let ctx = test_ctx_with_agent(agent);
let err = Symbols
.call(json!({"query": "foo.*bar"}), &ctx)
.await
.unwrap_err();
assert!(
err.downcast_ref::<crate::tools::RecoverableError>()
.is_some(),
"should be RecoverableError, got: {}",
err
);
}
#[tokio::test]
async fn symbols_allows_plain_pattern() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
std::fs::write(dir.path().join("test.rs"), "fn my_function() {}\n").unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = test_ctx_with_agent(agent);
let result = Symbols.call(json!({"query": "my_function"}), &ctx).await;
assert!(result.is_ok(), "plain pattern should not be rejected");
}
#[tokio::test]
async fn symbols_allows_name_path_with_regex_chars() {
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();
let ctx = test_ctx_with_agent(agent);
let result = Symbols.call(json!({"symbol": "foo|bar"}), &ctx).await;
assert!(
result.is_ok(),
"name_path should skip regex check, got err: {:?}",
result.err()
);
}
#[test]
fn find_split_point_collapses_single_child_chain() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("a/b/c")).unwrap();
for i in 0..3 {
std::fs::write(root.join(format!("a/b/c/file{i}.rs")), "").unwrap();
}
let split = find_split_point(root);
assert_eq!(split, root.join("a/b/c"));
}
#[test]
fn find_split_point_stops_at_branch() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("a/b")).unwrap();
std::fs::create_dir_all(root.join("a/c")).unwrap();
std::fs::write(root.join("a/b/file.rs"), "").unwrap();
std::fs::write(root.join("a/c/file.rs"), "").unwrap();
let split = find_split_point(root);
assert_eq!(split, root.join("a"), "should stop at branching dir");
}
#[test]
fn find_split_point_stops_when_dir_has_direct_files() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("a/b")).unwrap();
std::fs::write(root.join("a/root.rs"), "").unwrap();
std::fs::write(root.join("a/b/file.rs"), "").unwrap();
let split = find_split_point(root);
assert_eq!(split, root.join("a"), "mixed dir stops descent");
}
#[test]
fn count_files_by_subdir_groups_and_sorts() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("sub_a")).unwrap();
for i in 0..3 {
std::fs::write(root.join(format!("sub_a/file{i}.rs")), "").unwrap();
}
std::fs::create_dir_all(root.join("sub_b")).unwrap();
for i in 0..5 {
std::fs::write(root.join(format!("sub_b/file{i}.rs")), "").unwrap();
}
std::fs::write(root.join("root.rs"), "").unwrap();
let (total, subdirs) = count_files_by_subdir(root, root);
assert_eq!(total, 9);
assert_eq!(subdirs.len(), 2);
assert!(subdirs[0].0.contains("sub_b"), "largest subdir first");
assert_eq!(subdirs[0].1, 5);
assert!(subdirs[1].0.contains("sub_a"));
assert_eq!(subdirs[1].1, 3);
}
#[test]
fn count_files_by_subdir_collapses_passthrough() {
let dir = tempdir().unwrap();
let root = dir.path();
for (sub, n) in &[("api", 3usize), ("domain", 2)] {
std::fs::create_dir_all(root.join(format!("kotlin/edu/planner/{sub}"))).unwrap();
for i in 0..*n {
std::fs::write(root.join(format!("kotlin/edu/planner/{sub}/f{i}.rs")), "").unwrap();
}
}
let (total, subdirs) = count_files_by_subdir(root, &root.join("kotlin"));
assert_eq!(total, 5);
assert_eq!(subdirs.len(), 2, "collapsed to planner/ children, not edu/");
assert!(subdirs[0].0.contains("api"), "api (3) before domain (2)");
assert_eq!(subdirs[0].1, 3);
}
#[test]
fn count_files_by_subdir_flat_dir_returns_empty_subdirs() {
let dir = tempdir().unwrap();
let root = dir.path();
for i in 0..4 {
std::fs::write(root.join(format!("file{i}.rs")), "").unwrap();
}
let (total, subdirs) = count_files_by_subdir(root, root);
assert_eq!(total, 4);
assert!(subdirs.is_empty());
}
#[test]
fn count_files_by_subdir_ignores_non_source_files() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("sub")).unwrap();
std::fs::write(root.join("sub/README.md"), "").unwrap(); std::fs::write(root.join("sub/build.rs"), "").unwrap(); let (total, _subdirs) = count_files_by_subdir(root, root);
assert_eq!(total, 1, "markdown should not be counted as source");
}
#[test]
fn ast_class_names_for_dir_extracts_class_like_symbols() {
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("types.rs"),
r#"
struct Foo { x: i32 }
struct Bar;
enum Baz { A, B }
fn not_a_class() {}
const SKIP: i32 = 1;
"#,
)
.unwrap();
std::fs::write(dir.path().join("README.md"), "# hi").unwrap();
let names = ast_class_names_for_dir(dir.path());
assert!(names.contains(&"Foo".to_string()));
assert!(names.contains(&"Bar".to_string()));
assert!(names.contains(&"Baz".to_string()));
assert!(!names.contains(&"not_a_class".to_string()));
assert!(!names.contains(&"SKIP".to_string()));
assert_eq!(names, {
let mut v = names.clone();
v.sort();
v
});
}
#[test]
fn ast_class_names_for_dir_does_not_recurse_into_subdirs() {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("sub/deep.rs"), "struct DeepClass;").unwrap();
std::fs::write(dir.path().join("top.rs"), "struct TopClass;").unwrap();
let names = ast_class_names_for_dir(dir.path());
assert!(names.contains(&"TopClass".to_string()));
assert!(!names.contains(&"DeepClass".to_string()));
}
#[tokio::test]
async fn symbols_overview_nested_dir_returns_overview_mode() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".codescout")).unwrap();
for sub in &["sub_a", "sub_b"] {
std::fs::create_dir_all(root.join(sub)).unwrap();
for i in 0..20 {
std::fs::write(root.join(format!("{sub}/f{i}.rs")), "pub struct S;").unwrap();
}
}
let agent = Agent::new(Some(root.to_path_buf())).await.unwrap();
let ctx = test_ctx_with_agent(agent);
let result = Symbols.call(json!({ "path": "." }), &ctx).await.unwrap();
assert_eq!(result["mode"].as_str(), Some("class_overview"));
let subdirs = result["subdirectories"].as_array().unwrap();
assert_eq!(subdirs.len(), 2);
assert_eq!(result["total_files"].as_u64(), Some(40));
let sub_a = subdirs
.iter()
.find(|s| s["path"].as_str().unwrap_or("").contains("sub_a"))
.unwrap();
assert!(
sub_a["classes"]
.as_array()
.unwrap()
.iter()
.any(|c| c.as_str() == Some("S")),
"AST class names extracted"
);
}
#[tokio::test]
async fn symbols_overview_force_mode_symbols_bypasses_threshold() {
let dir = tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join(".codescout")).unwrap();
for sub in &["sub_a", "sub_b"] {
std::fs::create_dir_all(root.join(sub)).unwrap();
for i in 0..20 {
std::fs::write(root.join(format!("{sub}/f{i}.rs")), "pub struct S;").unwrap();
}
}
let agent = Agent::new(Some(root.to_path_buf())).await.unwrap();
let ctx = test_ctx_with_agent(agent);
let result = Symbols
.call(json!({ "path": ".", "force_mode": "symbols" }), &ctx)
.await
.unwrap();
assert!(result["mode"].is_null(), "no mode field in symbols output");
assert!(result["files"].is_array(), "files array present");
}
#[tokio::test]
async fn edit_code_replace_appends_caller_hint() {
if !std::process::Command::new("rust-analyzer")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
{
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let dir = tempdir().unwrap();
std::fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"test-hint\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
std::fs::create_dir_all(dir.path().join("src")).unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
std::fs::write(
dir.path().join("src/lib.rs"),
"pub fn greet(name: &str) -> String {\n format!(\"Hello, {}!\", name)\n}\n",
)
.unwrap();
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = test_ctx_with_agent(agent);
let mut outcome: Option<Value> = None;
for attempt in 0..5 {
if attempt > 0 {
tokio::time::sleep(std::time::Duration::from_millis(600 * attempt)).await;
}
let result = EditCode
.call(
json!({
"symbol": "greet",
"path": "src/lib.rs",
"action": "replace",
"body": "pub fn greet(name: &str) -> String {\n format!(\"Hi, {}!\", name)\n}\n"
}),
&ctx,
)
.await;
match result {
Ok(v) => {
outcome = Some(v);
break;
}
Err(e) => {
eprintln!("Attempt {}: {}", attempt + 1, e);
}
}
}
let result = match outcome {
Some(v) => v,
None => {
eprintln!("Skipping: LSP did not respond in time");
return;
}
};
let hint = result["hint"]
.as_str()
.expect("replace result must contain 'hint' field");
assert!(
hint.contains("references("),
"hint should mention references: {hint}"
);
assert!(
hint.contains("greet"),
"hint should include symbol name: {hint}"
);
assert!(
hint.contains("src/lib.rs"),
"hint should include file path: {hint}"
);
}
#[test]
fn collect_matching_skips_function_children_when_pushed() {
use crate::lsp::SymbolKind;
let symbols = vec![SymbolInfo {
name: "complete_text".into(),
name_path: "complete_text".into(),
kind: SymbolKind::Function,
file: PathBuf::from("gemini.py"),
start_line: 130,
end_line: 169,
start_col: 0,
children: vec![
SymbolInfo {
name: "prompt".into(),
name_path: "complete_text/prompt".into(),
kind: SymbolKind::Variable,
file: PathBuf::from("gemini.py"),
start_line: 131,
end_line: 131,
start_col: 4,
children: vec![],
range_start_line: None,
detail: None,
},
SymbolInfo {
name: "model".into(),
name_path: "complete_text/model".into(),
kind: SymbolKind::Variable,
file: PathBuf::from("gemini.py"),
start_line: 132,
end_line: 132,
start_col: 4,
children: vec![],
range_start_line: None,
detail: None,
},
],
range_start_line: None,
detail: None,
}];
let mut out = vec![];
collect_matching(
&symbols,
&substr_pred("complete_text"),
true,
None,
0,
true,
&mut out,
None,
);
assert_eq!(
out.len(),
1,
"function parent should suppress its parameter children"
);
assert_eq!(out[0]["name"], "complete_text");
}
#[test]
fn collect_matching_keeps_class_method_descendants() {
use crate::lsp::SymbolKind;
let symbols = vec![SymbolInfo {
name: "Foo".into(),
name_path: "Foo".into(),
kind: SymbolKind::Class,
file: PathBuf::from("a.py"),
start_line: 0,
end_line: 20,
start_col: 0,
children: vec![SymbolInfo {
name: "Foo_helper".into(),
name_path: "Foo/Foo_helper".into(),
kind: SymbolKind::Method,
file: PathBuf::from("a.py"),
start_line: 2,
end_line: 5,
start_col: 4,
children: vec![],
range_start_line: None,
detail: None,
}],
range_start_line: None,
detail: None,
}];
let mut out = vec![];
collect_matching(
&symbols,
&substr_pred("foo"),
true,
None,
0,
true,
&mut out,
None,
);
assert_eq!(out.len(), 2, "class should not suppress method descendants");
}
#[test]
fn format_search_symbols_groups_by_file() {
use crate::tools::symbol::display::format_search_symbols;
let val = json!({
"symbols": [
{ "kind": "Function", "file": "a.rs", "start_line": 1, "end_line": 5, "name": "foo", "symbol": "foo" },
{ "kind": "Function", "file": "a.rs", "start_line": 10, "end_line": 15, "name": "bar", "symbol": "bar" },
{ "kind": "Function", "file": "b.rs", "start_line": 3, "end_line": 7, "name": "baz", "symbol": "baz" },
],
"total": 3,
});
let out = format_search_symbols(&val);
assert!(out.starts_with("3 matches in 2 files\n"), "got:\n{out}");
assert!(out.contains("a.rs (2)"));
assert!(out.contains("b.rs (1)"));
assert!(
!out.contains("a.rs:1-5"),
"row should not repeat file path: {out}"
);
assert!(out.contains("foo"));
assert!(out.contains("bar"));
assert!(out.contains("baz"));
}
#[test]
fn format_search_symbols_single_file_no_global_header() {
use crate::tools::symbol::display::format_search_symbols;
let val = json!({
"symbols": [
{ "kind": "Function", "file": "a.rs", "start_line": 1, "end_line": 5, "name": "foo", "symbol": "foo" },
],
"total": 1,
});
let out = format_search_symbols(&val);
assert!(
!out.contains(" in 1 files"),
"single-file output should omit global header: {out}"
);
assert!(out.starts_with("a.rs (1)\n"), "got:\n{out}");
}