use codescout::agent::Agent;
use codescout::lsp::LspManager;
use codescout::tools::markdown::EditMarkdown;
use codescout::tools::output_buffer::OutputBuffer;
use codescout::tools::symbol::EditCode;
use codescout::tools::{Tool, ToolContext};
use serde_json::json;
use std::sync::Arc;
use tempfile::tempdir;
async fn project_with_files(files: &[(&str, &str)]) -> (tempfile::TempDir, ToolContext) {
let dir = tempdir().unwrap();
std::fs::create_dir_all(dir.path().join(".codescout")).unwrap();
for (name, content) in files {
let path = dir.path().join(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(path, content).unwrap();
}
let agent = Agent::new(Some(dir.path().to_path_buf())).await.unwrap();
let ctx = ToolContext {
agent,
lsp: LspManager::new_arc(),
output_buffer: Arc::new(OutputBuffer::new(20)),
progress: None,
peer: None,
section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
codescout::tools::section_coverage::SectionCoverage::new(),
)),
guide_hints_emitted: std::sync::Arc::new(parking_lot::Mutex::new(Default::default())),
workspace_override: None,
};
(dir, ctx)
}
fn lsp_available(cmd: &str) -> bool {
std::process::Command::new("which")
.arg(cmd)
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[tokio::test]
#[ignore] async fn bug031_replace_symbol_with_doc_comments_rust() {
if !lsp_available("rust-analyzer") {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let src = r#"[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
"#;
let code = r#"/// Adds two numbers.
/// Returns the sum.
pub fn add(a: i32, b: i32) -> i32 {
a + b
}
/// Subtracts b from a.
pub fn sub(a: i32, b: i32) -> i32 {
a - b
}
"#;
let (dir, ctx) = project_with_files(&[("Cargo.toml", src), ("src/lib.rs", code)]).await;
let new_body = r#"/// Adds two numbers together.
/// Returns their sum.
pub fn add(a: i32, b: i32) -> i32 {
let result = a + b;
result
}"#;
EditCode
.call(
json!({
"path": "src/lib.rs",
"symbol": "add",
"action": "replace",
"body": new_body
}),
&ctx,
)
.await
.unwrap();
let result = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
assert_eq!(
result.matches("/// Adds two numbers").count(),
1,
"doc comment must appear exactly once (no duplication); got:\n{result}"
);
assert!(
result.contains("let result = a + b"),
"new body must be present; got:\n{result}"
);
assert!(
!result.contains("a + b\n}"),
"old body must be gone; got:\n{result}"
);
assert!(
result.contains("pub fn sub"),
"adjacent function must survive; got:\n{result}"
);
}
#[tokio::test]
#[ignore] async fn bug031_replace_symbol_with_doc_comments_python() {
if !lsp_available("pyright-langserver") {
eprintln!("Skipping: pyright-langserver not installed");
return;
}
let code = r#"def add(a: int, b: int) -> int:
"""Add two numbers."""
return a + b
def sub(a: int, b: int) -> int:
"""Subtract b from a."""
return a - b
"#;
let (dir, ctx) = project_with_files(&[("lib.py", code)]).await;
let new_body = r#"def add(a: int, b: int) -> int:
"""Add two numbers together."""
result = a + b
return result"#;
EditCode
.call(
json!({
"path": "lib.py",
"symbol": "add",
"action": "replace",
"body": new_body
}),
&ctx,
)
.await
.unwrap();
let result = std::fs::read_to_string(dir.path().join("lib.py")).unwrap();
assert_eq!(
result.matches("def add").count(),
1,
"function signature must appear once; got:\n{result}"
);
assert!(
result.contains("result = a + b"),
"new body; got:\n{result}"
);
assert!(
result.contains("def sub"),
"adjacent function must survive; got:\n{result}"
);
}
#[tokio::test]
#[ignore] async fn bug031_replace_symbol_with_doc_comments_typescript() {
if !lsp_available("typescript-language-server") {
eprintln!("Skipping: typescript-language-server not installed");
return;
}
let code = r#"/**
* Add two numbers.
* @param a First number
* @param b Second number
*/
export function add(a: number, b: number): number {
return a + b;
}
export function sub(a: number, b: number): number {
return a - b;
}
"#;
let tsconfig = r#"{ "compilerOptions": { "strict": true } }"#;
let (dir, ctx) = project_with_files(&[("src/lib.ts", code), ("tsconfig.json", tsconfig)]).await;
let new_body = r#"/**
* Add two numbers together.
* @param a First number
* @param b Second number
*/
export function add(a: number, b: number): number {
const result = a + b;
return result;
}"#;
EditCode
.call(
json!({
"path": "src/lib.ts",
"symbol": "add",
"action": "replace",
"body": new_body
}),
&ctx,
)
.await
.unwrap();
let result = std::fs::read_to_string(dir.path().join("src/lib.ts")).unwrap();
assert_eq!(
result.matches("export function add").count(),
1,
"function must appear once; got:\n{result}"
);
assert_eq!(
result.matches("/**").count(),
1,
"JSDoc opener must appear once (no duplication); got:\n{result}"
);
assert!(
result.contains("const result = a + b"),
"new body; got:\n{result}"
);
assert!(
result.contains("export function sub"),
"adjacent function must survive; got:\n{result}"
);
}
#[tokio::test]
#[ignore] async fn bug031_replace_symbol_with_doc_comments_go() {
if !lsp_available("gopls") {
eprintln!("Skipping: gopls not installed");
return;
}
let code = r#"package math
// Add returns the sum of a and b.
func Add(a, b int) int {
return a + b
}
// Sub returns a minus b.
func Sub(a, b int) int {
return a - b
}
"#;
let go_mod = "module example.com/test\n\ngo 1.21\n";
let (dir, ctx) = project_with_files(&[("math.go", code), ("go.mod", go_mod)]).await;
let new_body = r#"// Add returns the sum of a and b.
func Add(a, b int) int {
result := a + b
return result
}"#;
EditCode
.call(
json!({
"path": "math.go",
"symbol": "Add",
"action": "replace",
"body": new_body
}),
&ctx,
)
.await
.unwrap();
let result = std::fs::read_to_string(dir.path().join("math.go")).unwrap();
assert_eq!(
result.matches("func Add").count(),
1,
"function must appear once; got:\n{result}"
);
assert_eq!(
result.matches("// Add returns").count(),
1,
"doc comment must appear once; got:\n{result}"
);
assert!(
result.contains("result := a + b"),
"new body; got:\n{result}"
);
assert!(
result.contains("func Sub"),
"adjacent function must survive; got:\n{result}"
);
}
#[tokio::test]
#[ignore] async fn bug029_insert_code_after_nested_fn_rust() {
if !lsp_available("rust-analyzer") {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let src = r#"[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
"#;
let code = r#"pub fn add(a: i32, b: i32) -> i32 {
a + b
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add_positive() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_add_negative() {
assert_eq!(add(-1, -2), -3);
}
}
"#;
let (dir, ctx) = project_with_files(&[("Cargo.toml", src), ("src/lib.rs", code)]).await;
let new_test = r#"
#[test]
fn test_add_zero() {
assert_eq!(add(0, 0), 0);
}"#;
EditCode
.call(
json!({
"path": "src/lib.rs",
"symbol": "tests/test_add_positive",
"action": "insert",
"body": new_test,
"position": "after"
}),
&ctx,
)
.await
.unwrap();
let result = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
assert!(
result.contains("fn test_add_zero"),
"new test must be present; got:\n{result}"
);
assert!(
result.contains("assert_eq!(add(2, 3), 5);"),
"test_add_positive body must be intact; got:\n{result}"
);
let opens = result.matches('{').count();
let closes = result.matches('}').count();
assert_eq!(
opens, closes,
"braces must be balanced (opens={opens}, closes={closes}); got:\n{result}"
);
}
#[tokio::test]
#[ignore] async fn bug029_insert_code_after_function_typescript() {
if !lsp_available("typescript-language-server") {
eprintln!("Skipping: typescript-language-server not installed");
return;
}
let code = r#"export function first(): number {
const x = 1;
const y = 2;
return x + y;
}
export function third(): number {
return 3;
}
"#;
let tsconfig = r#"{ "compilerOptions": { "strict": true } }"#;
let (dir, ctx) = project_with_files(&[("src/lib.ts", code), ("tsconfig.json", tsconfig)]).await;
let new_fn = r#"
export function second(): number {
return 2;
}"#;
EditCode
.call(
json!({
"path": "src/lib.ts",
"symbol": "first",
"action": "insert",
"body": new_fn,
"position": "after"
}),
&ctx,
)
.await
.unwrap();
let result = std::fs::read_to_string(dir.path().join("src/lib.ts")).unwrap();
assert!(
result.contains("function second"),
"new function; got:\n{result}"
);
assert!(
result.contains("return x + y;"),
"first() body intact; got:\n{result}"
);
assert!(
result.contains("function third"),
"third() must survive; got:\n{result}"
);
let opens = result.matches('{').count();
let closes = result.matches('}').count();
assert_eq!(opens, closes, "braces balanced; got:\n{result}");
}
#[tokio::test]
#[ignore] async fn bug032_sequential_remove_symbol_rust() {
if !lsp_available("rust-analyzer") {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let src = r#"[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
"#;
let code = r#"pub enum Filter {
All,
Active,
Inactive,
}
impl Filter {
pub fn is_all(&self) -> bool {
matches!(self, Filter::All)
}
}
pub fn process(items: &[i32]) -> Vec<i32> {
items.iter().copied().collect()
}
"#;
let (dir, ctx) = project_with_files(&[("Cargo.toml", src), ("src/lib.rs", code)]).await;
EditCode
.call(
json!({
"path": "src/lib.rs",
"symbol": "Filter",
"action": "remove"
}),
&ctx,
)
.await
.unwrap();
let after_first = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
assert!(
!after_first.contains("pub enum Filter"),
"enum must be removed; got:\n{after_first}"
);
assert!(
after_first.contains("pub fn process"),
"process() must survive first removal; got:\n{after_first}"
);
let result = EditCode
.call(
json!({
"path": "src/lib.rs",
"symbol": "impl Filter",
"action": "remove"
}),
&ctx,
)
.await;
match result {
Ok(_) => {
let after_second = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
assert!(
!after_second.contains("impl Filter"),
"impl block must be removed; got:\n{after_second}"
);
assert!(
after_second.contains("pub fn process"),
"process() must survive both removals; got:\n{after_second}"
);
let opens = after_second.matches('{').count();
let closes = after_second.matches('}').count();
assert_eq!(
opens, closes,
"braces must be balanced; got:\n{after_second}"
);
}
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("stale") || msg.contains("not found"),
"error should be about stale data or missing symbol, got: {msg}"
);
}
}
}
#[tokio::test]
#[ignore] async fn bug032_sequential_remove_symbol_python() {
if !lsp_available("pyright-langserver") {
eprintln!("Skipping: pyright-langserver not installed");
return;
}
let code = r#"class Filter:
ALL = "all"
ACTIVE = "active"
def process(items: list[int]) -> list[int]:
return list(items)
def helper() -> str:
return "ok"
"#;
let (dir, ctx) = project_with_files(&[("lib.py", code)]).await;
EditCode
.call(json!({ "path": "lib.py", "symbol": "Filter" }), &ctx)
.await
.unwrap();
let after_first = std::fs::read_to_string(dir.path().join("lib.py")).unwrap();
assert!(
!after_first.contains("class Filter"),
"class removed; got:\n{after_first}"
);
assert!(
after_first.contains("def process"),
"process survives; got:\n{after_first}"
);
let result = EditCode
.call(json!({ "path": "lib.py", "symbol": "process" }), &ctx)
.await;
match result {
Ok(_) => {
let after_second = std::fs::read_to_string(dir.path().join("lib.py")).unwrap();
assert!(
!after_second.contains("def process"),
"process removed; got:\n{after_second}"
);
assert!(
after_second.contains("def helper"),
"helper survives; got:\n{after_second}"
);
}
Err(e) => {
let msg = e.to_string();
assert!(
msg.contains("stale") || msg.contains("not found"),
"acceptable error about stale data; got: {msg}"
);
}
}
}
#[tokio::test]
async fn bug043_edit_markdown_replace_rejects_when_section_has_subsections() {
let plan = "\
# Plan
## File Map
short map body
### Task A
work
### Task B
more work
### Task C
even more
";
let (dir, ctx) = project_with_files(&[("plan.md", plan)]).await;
let err = EditMarkdown
.call(
json!({
"path": "plan.md",
"heading": "## File Map",
"action": "replace",
"content": "new short map body\n"
}),
&ctx,
)
.await
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("### Task A") && msg.contains("### Task B") && msg.contains("### Task C"),
"error must list the would-be-consumed subsections; got: {msg}"
);
assert!(
msg.contains("include_subsections"),
"error must point to the opt-in flag; got: {msg}"
);
let on_disk = std::fs::read_to_string(dir.path().join("plan.md")).unwrap();
assert_eq!(on_disk, plan, "file must be untouched when guard fires");
}
#[tokio::test]
async fn bug043_edit_markdown_replace_allows_subsection_consumption_on_opt_in() {
let plan = "\
# Plan
## File Map
old
### Task A
work
";
let (dir, ctx) = project_with_files(&[("plan.md", plan)]).await;
EditMarkdown
.call(
json!({
"path": "plan.md",
"heading": "## File Map",
"action": "replace",
"content": "new body\n",
"include_subsections": true
}),
&ctx,
)
.await
.expect("opt-in must succeed");
let on_disk = std::fs::read_to_string(dir.path().join("plan.md")).unwrap();
assert!(on_disk.contains("new body"));
assert!(
!on_disk.contains("### Task A"),
"opt-in truly consumes subsections: {on_disk}"
);
}
#[tokio::test]
#[ignore] async fn bug044_replace_symbol_preserves_sibling_method_rust() {
if !lsp_available("rust-analyzer") {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let manifest = r#"[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
"#;
let code = r#"pub struct Foo;
impl Foo {
pub fn alpha(&self) -> i32 {
1
}
pub fn beta(&self) -> i32 {
2
}
}
"#;
let (dir, ctx) = project_with_files(&[("Cargo.toml", manifest), ("src/lib.rs", code)]).await;
let new_body = r#" pub fn alpha(&self) -> i32 {
99
}"#;
EditCode
.call(
json!({
"path": "src/lib.rs",
"symbol": "impl Foo/alpha",
"action": "replace",
"body": new_body
}),
&ctx,
)
.await
.unwrap();
let result = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
assert!(
result.contains("99"),
"alpha must be replaced with new body; got:\n{result}"
);
assert!(
result.contains("pub fn beta"),
"sibling beta must survive the replacement; got:\n{result}"
);
assert!(
result.contains('2'),
"beta's body must survive; got:\n{result}"
);
}
#[tokio::test]
#[ignore] async fn bug044_replace_symbol_preserves_sibling_method_python() {
if !lsp_available("pyright-langserver") {
eprintln!("Skipping: pyright-langserver not installed");
return;
}
let code = "\
class Foo:
def alpha(self) -> int:
return 1
def beta(self) -> int:
return 2
";
let (dir, ctx) = project_with_files(&[("main.py", code)]).await;
let new_body = " def alpha(self) -> int:\n return 99";
EditCode
.call(
json!({
"path": "main.py",
"symbol": "Foo/alpha",
"action": "replace",
"body": new_body
}),
&ctx,
)
.await
.unwrap();
let result = std::fs::read_to_string(dir.path().join("main.py")).unwrap();
assert!(
result.contains("return 99"),
"alpha must be replaced; got:\n{result}"
);
assert!(
result.contains("def beta"),
"sibling beta must survive; got:\n{result}"
);
assert!(
result.contains("return 2"),
"beta's body must survive; got:\n{result}"
);
}
#[tokio::test]
#[ignore] async fn u19_replace_with_empty_attributes_drops_outer_attrs() {
if !lsp_available("rust-analyzer") {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let manifest = r#"[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
"#;
let code = r#"#[allow(dead_code)]
#[inline]
pub fn target() -> i32 {
1
}
"#;
let (dir, ctx) = project_with_files(&[("Cargo.toml", manifest), ("src/lib.rs", code)]).await;
let new_body = "pub fn target() -> i32 {\n 99\n}";
EditCode
.call(
json!({
"path": "src/lib.rs",
"symbol": "target",
"action": "replace",
"body": new_body,
"attributes": [], }),
&ctx,
)
.await
.unwrap();
let result = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
assert!(
result.contains("99"),
"body must be replaced; got:\n{result}"
);
assert!(
!result.contains("#[allow(dead_code)]"),
"#[allow] attribute must be dropped; got:\n{result}"
);
assert!(
!result.contains("#[inline]"),
"#[inline] attribute must be dropped; got:\n{result}"
);
}
#[tokio::test]
#[ignore] async fn u21_replace_with_explicit_attributes_overrides_existing() {
if !lsp_available("rust-analyzer") {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let manifest = r#"[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
"#;
let code = r#"#[allow(dead_code)]
#[deprecated]
pub fn target() -> i32 {
1
}
"#;
let (dir, ctx) = project_with_files(&[("Cargo.toml", manifest), ("src/lib.rs", code)]).await;
let new_body = "pub fn target() -> i32 {\n 99\n}";
EditCode
.call(
json!({
"path": "src/lib.rs",
"symbol": "target",
"action": "replace",
"body": new_body,
"attributes": ["#[inline]"], }),
&ctx,
)
.await
.unwrap();
let result = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
assert!(
result.contains("#[inline]"),
"new #[inline] attribute must be present; got:\n{result}"
);
assert!(
!result.contains("#[allow(dead_code)]"),
"old #[allow] must be replaced; got:\n{result}"
);
assert!(
!result.contains("#[deprecated]"),
"old #[deprecated] must be replaced; got:\n{result}"
);
assert!(
result.contains("99"),
"body must be replaced; got:\n{result}"
);
}
#[tokio::test]
#[ignore] async fn u19_u21_replace_without_attributes_preserves_existing_default() {
if !lsp_available("rust-analyzer") {
eprintln!("Skipping: rust-analyzer not installed");
return;
}
let manifest = r#"[package]
name = "test-project"
version = "0.1.0"
edition = "2021"
"#;
let code = r#"#[allow(dead_code)]
pub fn target() -> i32 {
1
}
"#;
let (dir, ctx) = project_with_files(&[("Cargo.toml", manifest), ("src/lib.rs", code)]).await;
let new_body = "pub fn target() -> i32 {\n 99\n}";
EditCode
.call(
json!({
"path": "src/lib.rs",
"symbol": "target",
"action": "replace",
"body": new_body,
}),
&ctx,
)
.await
.unwrap();
let result = std::fs::read_to_string(dir.path().join("src/lib.rs")).unwrap();
assert!(
result.contains("#[allow(dead_code)]"),
"without `attributes` arg, existing attrs preserved; got:\n{result}"
);
assert!(result.contains("99"), "body still replaced; got:\n{result}");
}