codescout 0.12.1

High-performance coding agent toolkit MCP server
Documentation
use crate::e2e::expectations::{load_expectations, LangExpectation};
use codescout::agent::Agent;
use codescout::lsp::manager::LspManager;
use codescout::tools::ast::ListFunctions;
use codescout::tools::grep::Grep;
use codescout::tools::symbol::{References, Symbols};
use codescout::tools::{Tool, ToolContext};
use serde_json::{json, Value};
use std::path::{Path, PathBuf};
use std::sync::Arc;

/// Root of the test fixtures directory.
fn fixtures_root() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("fixtures")
}

/// Get the fixture project directory for a language.
fn fixture_dir(language: &str) -> PathBuf {
    fixtures_root().join(format!("{language}-library"))
}

/// Create a fresh ToolContext for the given language.
///
/// Each `#[tokio::test]` gets its own Tokio runtime, so we must NOT cache
/// contexts in a static — LSP clients spawned on one runtime are dead once
/// that runtime shuts down. Agent::new + LspManager::new are cheap (just
/// struct construction); LSP servers start lazily on first tool call.
async fn fixture_context(language: &str) -> Arc<ToolContext> {
    let dir = fixture_dir(language);
    assert!(
        dir.exists(),
        "Fixture directory not found: {}",
        dir.display()
    );

    let agent = Agent::new(Some(dir.clone()))
        .await
        .unwrap_or_else(|e| panic!("Failed to create Agent for {language}: {e}"));

    let lsp = LspManager::new_arc();
    Arc::new(ToolContext {
        agent,
        lsp,
        output_buffer: Arc::new(codescout::tools::output_buffer::OutputBuffer::new(20)),
        progress: None,
        peer: None,
        section_coverage: std::sync::Arc::new(std::sync::Mutex::new(
            codescout::tools::section_coverage::SectionCoverage::new(),
        )),
    })
}
/// File extensions to prime per language.
fn language_extensions(language: &str) -> &'static [&'static str] {
    match language {
        "rust" => &["rs"],
        "kotlin" => &["kt", "kts"],
        "java" => &["java"],
        "python" => &["py"],
        "typescript" => &["ts", "tsx", "js", "jsx"],
        _ => &[],
    }
}

/// Pre-warm the LSP by issuing one `symbols` call per source file in the
/// fixture. Forces the LSP to open + index each file eagerly so that
/// textDocument/references has the workspace populated when refs tests run.
async fn prime_lsp(ctx: &ToolContext, language: &str) {
    let root = fixture_dir(language);
    let exts = language_extensions(language);
    if exts.is_empty() {
        return;
    }

    fn walk(dir: &std::path::Path, exts: &[&str], out: &mut Vec<std::path::PathBuf>) {
        let Ok(entries) = std::fs::read_dir(dir) else {
            return;
        };
        for entry in entries.flatten() {
            let path = entry.path();
            if path.is_dir() {
                let name = path.file_name().and_then(|n| n.to_str()).unwrap_or("");
                if name == "target" || name == "build" || name == "node_modules" || name == ".git" {
                    continue;
                }
                walk(&path, exts, out);
            } else if let Some(ext) = path.extension().and_then(|e| e.to_str()) {
                if exts.contains(&ext) {
                    out.push(path);
                }
            }
        }
    }

    let mut files = Vec::new();
    walk(&root, exts, &mut files);

    for abs in files {
        let rel = match abs.strip_prefix(&root) {
            Ok(r) => r.to_string_lossy().into_owned(),
            Err(_) => continue,
        };
        let _ = Symbols.call(json!({ "path": rel }), ctx).await;
    }
}

/// Run expectations from multiple TOML files for a language, sharing one LSP context.
///
/// This is the primary entry point: each language runs as a single `#[tokio::test]`
/// so the LSP server only starts once (important for kotlin-lsp / jdtls which
/// take 30-60s to initialize).
pub async fn run_all_expectations(language: &str, toml_filenames: &[&str]) {
    let ctx = fixture_context(language).await;

    // Pre-warm the LSP by issuing a `symbols` call on every source file in
    // the fixture. textDocument/references needs workspace-wide analysis;
    // touching each file forces the LSP to open + index it eagerly instead
    // of lazily on first use.
    prime_lsp(&ctx, language).await;

    let mut total_pass = 0;
    let mut total_failures = Vec::new();

    for toml_filename in toml_filenames {
        let toml_path = fixtures_root().join(toml_filename);
        let expectations = load_expectations(&toml_path, language);

        if expectations.is_empty() {
            total_failures.push((
                format!("{toml_filename}::<no tests>"),
                format!(
                    "No expectations found for language '{language}' in {toml_filename}. \
                     Check TOML for [{language}] sub-tables or top-level path/file."
                ),
            ));
            continue;
        }

        eprintln!("\n--- {toml_filename} ---");
        let (pass, failures) = run_expectations_inner(&ctx, &expectations).await;
        total_pass += pass;
        for (name, err) in failures {
            total_failures.push((format!("{toml_filename}::{name}"), err));
        }
    }

    let total = total_pass + total_failures.len();
    eprintln!("\n{total_pass}/{total} passed for {language}");

    if !total_failures.is_empty() {
        panic!(
            "{} of {total} expectations failed for {language}:\n{}",
            total_failures.len(),
            total_failures
                .iter()
                .map(|(n, e)| format!("  - {n}: {e}"))
                .collect::<Vec<_>>()
                .join("\n")
        );
    }
}

/// Run all expectations with a shared context, returning (pass_count, failures).
///
/// Expectations are sorted so that `find_referencing_symbols` tests run last.
/// `textDocument/references` requires the LSP to complete workspace-wide analysis,
/// which happens in the background while earlier tool calls (documentSymbol, etc.)
/// warm up the server. Running symbol/overview tests first gives the LSP time
/// to finish indexing before reference lookups execute.
async fn run_expectations_inner(
    ctx: &ToolContext,
    expectations: &[(String, LangExpectation, String)],
) -> (usize, Vec<(String, String)>) {
    // Sort: non-reference tests first, reference tests last.
    let mut sorted: Vec<_> = expectations.iter().collect();
    sorted.sort_by_key(|(_, _, tool)| {
        if tool == "find_referencing_symbols" {
            1
        } else {
            0
        }
    });

    let mut pass = 0;
    let mut failures = Vec::new();

    for (name, expectation, tool) in sorted {
        match run_single(ctx, expectation, tool).await {
            Ok(()) => {
                pass += 1;
                eprintln!("  PASS  {name}");
            }
            Err(e) => {
                eprintln!("  FAIL  {name}: {e}");
                failures.push((name.clone(), e));
            }
        }
    }

    (pass, failures)
}

/// Run a single expectation and return Ok(()) or an error message.
async fn run_single(ctx: &ToolContext, exp: &LangExpectation, tool: &str) -> Result<(), String> {
    match tool {
        "get_symbols_overview" => run_symbols_overview(ctx, exp).await,
        "symbols" => run_symbols(ctx, exp).await,
        "find_referencing_symbols" => run_find_references(ctx, exp).await,
        "list_functions" => run_list_functions(ctx, exp).await,
        "search_for_pattern" => run_search_pattern(ctx, exp).await,
        other => Err(format!("Unknown tool: {other}")),
    }
}

async fn run_symbols_overview(ctx: &ToolContext, exp: &LangExpectation) -> Result<(), String> {
    let path = exp.path.as_deref().ok_or("Missing 'path'")?;
    let result = Symbols
        .call(json!({ "path": path }), ctx)
        .await
        .map_err(|e| format!("Tool error: {e}"))?;

    if let Some(expected) = &exp.contains_symbols {
        assert_contains_symbols(&result, expected)?;
    }
    Ok(())
}

async fn run_symbols(ctx: &ToolContext, exp: &LangExpectation) -> Result<(), String> {
    let symbol = exp.symbol.as_deref().ok_or("Missing 'symbol'")?;
    let mut params = json!({ "query": symbol });

    if let Some(path) = &exp.path {
        params["path"] = json!(path);
    }

    if exp.body_contains.is_some() {
        params["include_body"] = json!(true);
    }

    // Kotlin/Java LSPs index incrementally — `documentSymbol` may return a
    // partial child list on the first call. Retry until expected symbols
    // appear or the budget is exhausted.
    let expected_children = exp.contains_symbols.as_deref().unwrap_or(&[]);
    let expected_body = exp.body_contains.as_deref().unwrap_or(&[]);
    let mut last_result = serde_json::Value::Null;
    let mut last_err: Option<String> = None;

    for attempt in 0..8 {
        if attempt > 0 {
            tokio::time::sleep(std::time::Duration::from_millis(500 * attempt as u64)).await;
        }
        match Symbols.call(params.clone(), ctx).await {
            Ok(result) => {
                last_err = None;
                last_result = result;
                let result_str = serde_json::to_string(&last_result).unwrap_or_default();
                let children_ok = expected_children.is_empty()
                    || assert_contains_symbols(&last_result, expected_children).is_ok();
                let body_ok = expected_body.iter().all(|n| result_str.contains(n));
                if children_ok && body_ok {
                    return Ok(());
                }
            }
            Err(e) => {
                last_err = Some(format!("Tool error: {e}"));
            }
        }
    }

    if let Some(err) = last_err {
        return Err(err);
    }

    if !expected_children.is_empty() {
        assert_contains_symbols(&last_result, expected_children)?;
    }
    let result_str = serde_json::to_string(&last_result).unwrap_or_default();
    for needle in expected_body {
        if !result_str.contains(needle) {
            return Err(format!("symbols(\"{symbol}\") body missing \"{needle}\""));
        }
    }

    Ok(())
}

async fn run_find_references(ctx: &ToolContext, exp: &LangExpectation) -> Result<(), String> {
    let symbol = exp.symbol.as_deref().ok_or("Missing 'symbol'")?;
    let file = exp.path.as_deref().ok_or("Missing 'path'/'file'")?;

    // textDocument/references requires the LSP to finish workspace-wide analysis.
    // LSP servers index in the background — early calls may return only partial
    // results (e.g., just the definition site) before cross-file analysis completes.
    // Retry until ALL expected references are found or we exhaust attempts.
    // Budget: up to 16 attempts × 750ms backoff base ≈ 90s of wait time, which
    // is long enough for rust-analyzer to finish workspace analysis on a small
    // fixture project even on a cold CI runner.
    let expected = exp.expected_refs_contain.as_deref().unwrap_or(&[]);
    let mut last_result_str = String::new();
    let mut last_err: Option<String> = None;

    for attempt in 0..16 {
        if attempt > 0 {
            tokio::time::sleep(std::time::Duration::from_millis(750 * attempt as u64)).await;
        }
        match References
            .call(json!({ "symbol": symbol, "path": file }), ctx)
            .await
        {
            Ok(result) => {
                last_err = None;
                last_result_str = serde_json::to_string(&result).unwrap_or_default();
                // Check if ALL expected needles are present.
                let all_found = expected
                    .iter()
                    .all(|n| last_result_str.contains(n.as_str()));
                if all_found {
                    return Ok(());
                }
                // Partial results — LSP still indexing cross-file refs. Retry.
            }
            Err(e) => {
                last_err = Some(format!("Tool error: {e}"));
                // Retry on tool errors (symbol might not be found until indexed).
            }
        }
    }

    if let Some(err) = last_err {
        return Err(err);
    }

    // Report which specific needles are missing.
    for needle in expected {
        if !last_result_str.contains(needle.as_str()) {
            return Err(format!(
                "find_referencing_symbols(\"{symbol}\") missing reference to \"{needle}\""
            ));
        }
    }

    Ok(())
}

async fn run_list_functions(ctx: &ToolContext, exp: &LangExpectation) -> Result<(), String> {
    let path = exp.path.as_deref().ok_or("Missing 'path'")?;
    let result = ListFunctions
        .call(json!({ "path": path }), ctx)
        .await
        .map_err(|e| format!("Tool error: {e}"))?;

    if let Some(expected) = &exp.contains_functions {
        let result_str = serde_json::to_string(&result).unwrap_or_default();
        for needle in expected {
            if !result_str.contains(needle) {
                return Err(format!("list_functions(\"{path}\") missing \"{needle}\""));
            }
        }
    }

    Ok(())
}

async fn run_search_pattern(ctx: &ToolContext, exp: &LangExpectation) -> Result<(), String> {
    let pattern = exp.pattern.as_deref().ok_or("Missing 'pattern'")?;
    let result = Grep
        .call(json!({ "pattern": pattern }), ctx)
        .await
        .map_err(|e| format!("Tool error: {e}"))?;

    if let Some(expected_files) = &exp.expected_files {
        let result_str = serde_json::to_string(&result).unwrap_or_default();
        for needle in expected_files {
            if !result_str.contains(needle) {
                return Err(format!(
                    "search_for_pattern(\"{pattern}\") missing file \"{needle}\""
                ));
            }
        }
    }

    Ok(())
}

/// Check that expected symbol names appear somewhere in the JSON result.
fn assert_contains_symbols(result: &Value, expected: &[String]) -> Result<(), String> {
    let result_str = serde_json::to_string(result).unwrap_or_default();
    let mut missing = Vec::new();
    for name in expected {
        if !result_str.contains(name.as_str()) {
            missing.push(name.as_str());
        }
    }
    if missing.is_empty() {
        Ok(())
    } else {
        Err(format!("Missing symbols: {:?}", missing))
    }
}