use parking_lot::RwLock;
/// MCP tool handler methods and their input parameter structs.
///
/// Each handler follows the pattern:
/// 1. Acquire read lock (or write lock for `index_folder`)
/// 2. Check loading guard (except `health` which always responds)
/// 3. Extract needed data into owned values
/// 4. Drop lock
/// 5. Call `format::` function
/// 6. Return `String`
///
/// Anti-patterns avoided (per RESEARCH.md):
/// - Never return JSON — always plain text String (AD-6)
/// - Never use MCP error codes for not-found — return helpful text via format functions
/// - Never hold RwLockReadGuard across await points — extract into owned values first
use std::collections::{HashMap, HashSet};
use std::io::Read;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::sync::atomic::Ordering;
use std::time::{Duration, Instant};

use axum::http::StatusCode;
use rmcp::handler::server::wrapper::Parameters;
use rmcp::{tool, tool_router};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::capability::{
    CapabilityCost, CapabilityEvidence, CapabilityFreshness, CapabilityName, CapabilitySafety,
    CapabilityStatus, CouplingPreparePolicy, FrecencyCollectionPolicy, RankingDiagnosticsPolicy,
    WorktreeRoutingPolicy,
};
use crate::protocol::result_status::{OutcomeClass, ResultStatus};

#[cfg(test)]
use super::edit_tools::{EditError, prepare_batch_paths_for_edit, prepare_exact_path_for_edit};
#[cfg(test)]
pub(crate) use super::read_tools::lenient_option_vec;
pub use super::read_tools::{
    FindDependentsInput, GetFileContentInput, GetFileContextInput, GetRepoMapInput,
    GetSymbolContextInput, GetSymbolInput, InspectMatchInput, SymbolTarget, TraceSymbolInput,
};
pub(crate) use super::read_tools::{
    ValidateFileSyntaxInput, encode_include_tests_marker, file_content_options_from_input,
    include_tests_from_sections, lenient_bool, lenient_i64, lenient_u32, lenient_u64,
    lenient_vec_required, normalize_file_content_aliases, visible_sections,
};
#[cfg(test)]
pub(crate) use super::search_tools::normalize_search_text_glob;
pub use super::search_tools::{
    FindReferencesInput, SearchFilesInput, SearchSymbolsInput, SearchTextInput,
};
pub(crate) use super::search_tools::{
    fix_common_double_escapes, parse_language_filter, search_symbols_options_from_input,
    search_text_options_from_input,
};

const INDEX_FOLDER_RESET_ENV: &str = "SYMFORGE_INDEX_FOLDER_RESET";

pub(crate) fn index_folder_reset_requested() -> bool {
    std::env::var(INDEX_FOLDER_RESET_ENV).as_deref() == Ok("1")
}

fn statused_tool_result(
    text: String,
    outcome_class: OutcomeClass,
) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
    Ok(ResultStatus::new(outcome_class).into_call_tool_result(text))
}

pub(super) fn is_index_unavailable_output(text: &str) -> bool {
    text.starts_with("Index not loaded.")
        || text.starts_with("Index is loading")
        || text.starts_with("Index degraded:")
}

fn classify_get_symbol_output(text: &str) -> OutcomeClass {
    if is_index_unavailable_output(text) {
        OutcomeClass::InternalFailure
    } else if text.starts_with("Error:") {
        OutcomeClass::InvalidRequest
    } else if text.starts_with("Ambiguous:") {
        OutcomeClass::Ambiguous
    } else if text.starts_with("File not found:") || text.starts_with("No symbol ") {
        OutcomeClass::NotFound
    } else {
        OutcomeClass::Found
    }
}

fn classify_get_symbol_context_output(text: &str) -> OutcomeClass {
    if is_index_unavailable_output(text) {
        OutcomeClass::InternalFailure
    } else if text.starts_with("Error:") {
        OutcomeClass::InvalidRequest
    } else if text.starts_with("Ambiguous symbol selector") || text.starts_with("Ambiguous:") {
        OutcomeClass::Ambiguous
    } else if text.starts_with("Symbol \"") || text.starts_with("Symbol '") {
        OutcomeClass::NotFound
    } else {
        OutcomeClass::Found
    }
}

/// Strip an optional leading `── mode: <name> (explicit) ──\n` annotation so
/// output classification can match the renderer's message at the start of the
/// remaining body. Returns `text` unchanged when no annotation is present.
fn strip_mode_annotation(text: &str) -> &str {
    text.strip_prefix("── mode: ")
        .and_then(|rest| rest.split_once(" ──\n"))
        .map(|(_, body)| body)
        .unwrap_or(text)
}
fn classify_get_file_content_output(text: &str) -> OutcomeClass {
    // Strip an optional `── mode: <name> (explicit) ──` prefix so the renderer's
    // status message is matched at the start of `body` even when an explicit-mode
    // annotation precedes it. Anchoring on `body` (not a bare `contains`) keeps a
    // successful read whose CONTENT merely mentions these phrases classified Found.
    let body = strip_mode_annotation(text);
    if is_index_unavailable_output(text) {
        OutcomeClass::InternalFailure
    } else if text.starts_with("Invalid get_file_content request:")
        || text.starts_with("mode=")
        || text.contains("[error:")
        || (body.starts_with("Chunk ") && body.contains(" out of range for "))
    {
        // A request for a non-existent chunk index is an invalid request, not a
        // successful read: the path exists but the requested page does not.
        OutcomeClass::InvalidRequest
    } else if text.starts_with("Ambiguous symbol selector") {
        OutcomeClass::Ambiguous
    } else if text.starts_with("File not found:")
        || text.starts_with("No symbol ")
        || text.starts_with("Symbol not found in ")
        || body.starts_with("No matches for '")
        || body.starts_with("Match occurrence ")
    {
        // around_match / match-occurrence misses: the needle was not found in the
        // file. These must report NotFound, not a successful read.
        OutcomeClass::NotFound
    } else {
        OutcomeClass::Found
    }
}

fn classify_search_symbols_output(text: &str) -> OutcomeClass {
    if is_index_unavailable_output(text) {
        OutcomeClass::InternalFailure
    } else if text.starts_with("search_symbols requires") {
        OutcomeClass::InvalidRequest
    } else if text.starts_with("No symbols matching") {
        OutcomeClass::EmptyResult
    } else {
        OutcomeClass::Found
    }
}

fn classify_search_text_output(text: &str) -> OutcomeClass {
    if is_index_unavailable_output(text) {
        OutcomeClass::InternalFailure
    } else if text.starts_with("Error:")
        || text.starts_with("Regex search requires")
        || text.starts_with("Search requires")
        || text.starts_with("Invalid regex")
        || text.starts_with("Invalid glob")
        || text.starts_with("whole_word is not supported")
    {
        OutcomeClass::InvalidRequest
    } else if text.starts_with("No matches") || text.starts_with("No AST matches") {
        OutcomeClass::EmptyResult
    } else {
        OutcomeClass::Found
    }
}

fn classify_search_files_output(text: &str) -> OutcomeClass {
    if is_index_unavailable_output(text) {
        OutcomeClass::InternalFailure
    } else if text.starts_with("Path search requires")
        || text.starts_with("Path hint must not be empty")
        || text.starts_with("search_files")
    {
        OutcomeClass::InvalidRequest
    } else if text.contains("Ambiguous path hint") {
        OutcomeClass::Ambiguous
    } else if text.starts_with("No indexed source files matching")
        || text.starts_with("No indexed source path matched")
        || text.starts_with("No git history found")
        || text.starts_with("'") && text.contains("not found in git history")
    {
        OutcomeClass::NotFound
    } else if text.starts_with("No high-confidence co-change data") {
        OutcomeClass::EmptyResult
    } else {
        OutcomeClass::Found
    }
}

fn classify_find_references_output(text: &str) -> OutcomeClass {
    if is_index_unavailable_output(text) {
        OutcomeClass::InternalFailure
    } else if text.starts_with("Ambiguous symbol selector") {
        OutcomeClass::Ambiguous
    } else if text.starts_with("File not found:")
        || text.starts_with("Symbol not found")
        || text.contains("this symbol is not defined in the indexed project")
    {
        OutcomeClass::NotFound
    } else if text.starts_with("No references found")
        || text.starts_with("No implementations found")
    {
        OutcomeClass::EmptyResult
    } else {
        OutcomeClass::Found
    }
}

/// Deserialize a required `u32` from either a JSON number or a stringified number.
use crate::domain::index::{AdmissionTier, BINARY_SNIFF_BYTES, SkipReason};
use crate::domain::{FileClassification, LanguageId, ReferenceKind};
use crate::live_index::qualified_usages::{self, QualifiedUsage};
use crate::live_index::{
    FindReferencesView, IndexedFile, ReferenceContextLineView, ReferenceFileView, ReferenceHitView,
    SearchFilesCouplingEvidence, SearchFilesCouplingNeighbors, SearchFilesHit,
    SearchFilesResolveView, SearchFilesTier, SearchFilesView, search,
    store::{IndexState, LiveIndex},
};
use crate::protocol::edit;
use crate::protocol::format;
use crate::protocol::search_format;
use crate::sidecar::handlers::{
    ImpactParams, OutlineParams, SymbolContextParams, impact_tool_text, outline_tool_text,
    repo_map_text, symbol_context_tool_text,
};
use crate::sidecar::{SidecarState, TokenStats};
use crate::watcher;

use super::SymForgeServer;

// ─── Input parameter structs ────────────────────────────────────────────────

/// Input for `index_folder`.
#[derive(Deserialize, Serialize, JsonSchema)]
pub struct IndexFolderInput {
    /// Absolute or relative path to the directory to index.
    pub path: String,
    /// Optional key used to replay an identical `index_folder` request safely.
    #[serde(default)]
    pub idempotency_key: Option<String>,
}

/// Input for `checkpoint_now`.
#[derive(Default, Deserialize, Serialize, JsonSchema)]
pub struct CheckpointNowInput {
    /// When true, deserialize the snapshot after writing and fail the command if it cannot be read back.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub verify_after_write: Option<bool>,
}

/// Input for `what_changed`.
#[derive(Deserialize, Serialize, JsonSchema)]
pub struct WhatChangedInput {
    /// Optional Unix timestamp (seconds since epoch). Files newer than this are returned.
    #[serde(default, deserialize_with = "lenient_i64")]
    pub since: Option<i64>,
    /// Optional git ref to diff against, for example `HEAD~5` or `branch:main`.
    pub git_ref: Option<String>,
    /// When true, report uncommitted git changes. Defaults to true when no other mode is specified and a repo root exists.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub uncommitted: Option<bool>,
    /// Optional relative path prefix scope, for example `src/` or `src/protocol`.
    pub path_prefix: Option<String>,
    /// Optional canonical language name such as `Rust`, `TypeScript`, `C#`, or `C++`.
    pub language: Option<String>,
    /// When true, exclude non-source files (docs, configs, images, lock files).
    /// Only files with a recognized programming language extension are included.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub code_only: Option<bool>,
    /// When true, also include a symbol-level diff alongside the file list.
    /// Only applies in git_ref and uncommitted modes (not timestamp mode).
    /// In git_ref mode, diffs the given ref against HEAD.
    /// In uncommitted mode, diffs HEAD against the working tree.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub include_symbol_diff: Option<bool>,
    /// When true, return an approximate token cost estimate instead of actual content.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub estimate: Option<bool>,
    /// Optional maximum token budget for the response.
    #[serde(default, deserialize_with = "lenient_u64")]
    pub max_tokens: Option<u64>,
}

/// Input for `analyze_file_impact`.
#[derive(Deserialize, Serialize, JsonSchema)]
pub struct AnalyzeFileImpactInput {
    /// Relative path to the file to re-read from disk.
    pub path: String,
    /// When true, treat the file as newly created and index it.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub new_file: Option<bool>,
    /// When true, also include git temporal co-change data (files that historically change together with this file).
    #[serde(default, deserialize_with = "lenient_bool")]
    pub include_co_changes: Option<bool>,
    /// Maximum co-changing files to return (default 10). Only used when include_co_changes=true.
    #[serde(default, deserialize_with = "lenient_u32")]
    pub co_changes_limit: Option<u32>,
    /// When true, return an approximate token cost estimate instead of actual content.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub estimate: Option<bool>,
}

/// Input for `explore`.
#[derive(Deserialize, Serialize, JsonSchema, Default)]
pub struct ExploreInput {
    /// Natural-language concept or topic to explore (e.g., "error handling", "concurrency", "database").
    pub query: String,
    /// Maximum number of results per category (default 10).
    #[serde(default, deserialize_with = "lenient_u32")]
    pub limit: Option<u32>,
    /// Exploration depth: 1 (default) = symbol names + text patterns + files.
    /// 2 = adds signatures and one hop of dependents for top symbols.
    /// 3 = adds call chains and type dependency edges for top symbols.
    #[serde(default, deserialize_with = "lenient_u32")]
    pub depth: Option<u32>,
    /// When true, include results from vendor, generated, and gitignored files.
    /// By default these are hidden to reduce noise (default false).
    #[serde(default, deserialize_with = "lenient_bool")]
    pub include_noise: Option<bool>,
    /// When true, surface vendored/third-party paths (additive with `include_noise`).
    /// Default false.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub include_vendor: Option<bool>,
    /// When true, surface personal tooling paths (.claude/gsd-*; additive with `include_noise`).
    /// Default false.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub include_personal_tooling: Option<bool>,
    /// Optional canonical language name filter (e.g., "Rust", "TypeScript", "C#").
    pub language: Option<String>,
    /// Optional relative path prefix scope (e.g., "src/", "backend/").
    pub path_prefix: Option<String>,
    /// When true, return an approximate token cost estimate instead of actual content.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub estimate: Option<bool>,
    /// Optional maximum token budget for the response.
    #[serde(default, deserialize_with = "lenient_u64")]
    pub max_tokens: Option<u64>,
}

#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SmartQueryInput {
    /// Natural language question about the codebase. Examples:
    /// "who calls optimize_deterministic", "where is LiveIndex defined",
    /// "how does the parser work", "what changed", "find file tools.rs"
    pub query: String,
    /// Optional maximum token budget for the response.
    #[serde(default, deserialize_with = "lenient_u64")]
    pub max_tokens: Option<u64>,
}

#[derive(Debug, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
pub struct EditPlanInput {
    /// The symbol name or file path you want to edit.
    pub target: String,
}

#[derive(Debug, serde::Deserialize, serde::Serialize, schemars::JsonSchema)]
pub struct InvestigationInput {
    /// Optional focus area to filter suggestions.
    pub focus: Option<String>,
}

/// Input for `diff_symbols`.
#[derive(Deserialize, Serialize, JsonSchema)]
pub struct DiffSymbolsInput {
    /// Base git ref to compare from (default: "main").
    pub base: Option<String>,
    /// Target git ref to compare to (default: "HEAD").
    pub target: Option<String>,
    /// Optional path filter — only show diffs for files matching this prefix.
    pub path_prefix: Option<String>,
    /// Optional canonical language name such as `Rust`, `TypeScript`, `C#`, or `C++`.
    pub language: Option<String>,
    /// When true, exclude non-source files (docs, configs, images, lock files).
    /// Only files with a recognized programming language extension are included.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub code_only: Option<bool>,
    /// When true, show only per-file summary counts (+N added, -N removed, ~N modified)
    /// without listing individual symbols. Like `git diff --stat` vs `git diff`.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub compact: Option<bool>,
    /// When true, emit only the aggregate summary line (total added/removed/modified
    /// counts) without any per-file detail. Useful for quick change-scope assessment.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub summary_only: Option<bool>,
    /// When true, return an approximate token cost estimate instead of actual content.
    #[serde(default, deserialize_with = "lenient_bool")]
    pub estimate: Option<bool>,
    /// Optional maximum token budget for the response.
    #[serde(default, deserialize_with = "lenient_u64")]
    pub max_tokens: Option<u64>,
}

enum WhatChangedMode {
    Timestamp(i64),
    GitRef(String),
    Uncommitted,
}

#[derive(Default)]
struct ExploreMatchScore {
    raw_count: usize,
    matched_terms: HashSet<String>,
}

#[derive(Default)]
struct ExploreFileSignal {
    raw_score: u64,
    matched_terms: HashSet<String>,
}

struct DerivedExploreCluster {
    seed_terms: Vec<String>,
    promoted_symbols: Vec<String>,
    seed_files: Vec<String>,
}

fn determine_what_changed_mode(
    input: &WhatChangedInput,
    has_repo_root: bool,
) -> Result<WhatChangedMode, String> {
    if let Some(git_ref) = input
        .git_ref
        .as_deref()
        .map(str::trim)
        .filter(|git_ref| !git_ref.is_empty())
    {
        return if has_repo_root {
            Ok(WhatChangedMode::GitRef(
                git_ref
                    .strip_prefix("branch:")
                    .unwrap_or(git_ref)
                    .to_string(),
            ))
        } else {
            Err("Git change detection unavailable; pass `since` for timestamp mode.".to_string())
        };
    }

    if input.uncommitted.unwrap_or(false) || (input.since.is_none() && has_repo_root) {
        return if has_repo_root {
            Ok(WhatChangedMode::Uncommitted)
        } else {
            Err("Git change detection unavailable; pass `since` for timestamp mode.".to_string())
        };
    }

    if let Some(since) = input.since {
        Ok(WhatChangedMode::Timestamp(since))
    } else {
        Err(
            "what_changed requires either `since`, `git_ref`, or an available repo root."
                .to_string(),
        )
    }
}

fn filter_paths_by_prefix_and_language(
    paths: Vec<String>,
    path_prefix: Option<&str>,
    language: Option<&str>,
    code_only: bool,
) -> Result<Vec<String>, String> {
    let lang_filter = parse_language_filter(language)?;
    let prefix = path_prefix
        .map(str::trim)
        .filter(|p| !p.is_empty())
        .map(|p| {
            p.replace('\\', "/")
                .trim_start_matches("./")
                .trim_start_matches('/')
                .trim_end_matches('/')
                .to_string()
        });

    Ok(paths
        .into_iter()
        .filter(|path| {
            if let Some(ref pfx) = prefix
                && !path.starts_with(pfx.as_str())
            {
                return false;
            }
            if let Some(ref lang) = lang_filter {
                let ext = path.rsplit('.').next().unwrap_or("");
                if crate::domain::index::LanguageId::from_extension(ext).as_ref() != Some(lang) {
                    return false;
                }
            }
            if code_only && lang_filter.is_none() {
                let ext = path.rsplit('.').next().unwrap_or("");
                match crate::domain::index::LanguageId::from_extension(ext) {
                    None => return false,
                    Some(lang) => {
                        if crate::parsing::config_extractors::is_config_language(&lang) {
                            return false;
                        }
                    }
                }
            }
            true
        })
        .collect())
}

fn normalize_exact_path(input: &str) -> String {
    let normalized = input
        .trim()
        .replace('\\', "/")
        .trim_start_matches("./")
        .trim_start_matches('/')
        .trim_end_matches('/')
        .to_string();

    if normalized.is_empty() {
        input.trim().to_string()
    } else {
        normalized
    }
}

/// Find up to 5 similar file paths for "file not found" suggestions.
/// Extracts the basename from the failed path and searches the index.
fn suggest_similar_files(index: &crate::live_index::LiveIndex, path: &str) -> Vec<String> {
    let basename = std::path::Path::new(path)
        .file_name()
        .and_then(|f| f.to_str())
        .unwrap_or(path);
    // Try basename match first
    let mut suggestions: Vec<String> = index
        .find_files_by_basename(basename)
        .into_iter()
        .take(5)
        .map(|s| s.to_string())
        .collect();
    // If no basename match, try the stem (without extension)
    if suggestions.is_empty() {
        let stem = std::path::Path::new(path)
            .file_stem()
            .and_then(|f| f.to_str())
            .unwrap_or(basename);
        if stem.len() >= 3 {
            let stem_lower = stem.to_ascii_lowercase();
            suggestions = index
                .all_files()
                .map(|(p, _)| p.as_str())
                .filter(|p| {
                    let file_stem = std::path::Path::new(p)
                        .file_stem()
                        .and_then(|s| s.to_str())
                        .unwrap_or("");
                    file_stem.to_ascii_lowercase().contains(&stem_lower)
                })
                .take(5)
                .map(|s| s.to_string())
                .collect();
        }
    }
    suggestions
}

fn enrich_with_callers(
    index: &crate::live_index::LiveIndex,
    result: &mut search::TextSearchResult,
    file_limit: usize,
) {
    use std::collections::HashSet;

    for file_matches in result.files.iter_mut().take(file_limit) {
        // Collect unique enclosing symbol names from this file's matches
        let mut symbol_names: HashSet<String> = HashSet::new();
        for m in &file_matches.matches {
            if let Some(ref enc) = m.enclosing_symbol {
                symbol_names.insert(enc.name.clone());
            }
        }

        if symbol_names.is_empty() {
            continue;
        }

        let mut callers: Vec<search::CallerEntry> = Vec::new();
        let mut seen: HashSet<(String, String)> = HashSet::new(); // (file, symbol) dedup

        for sym_name in &symbol_names {
            let refs = index.find_references_for_name(sym_name, None, false);
            for (ref_file, ref_record) in refs {
                // Get enclosing symbol of the reference
                let enclosing_name = ref_record
                    .enclosing_symbol_index
                    .and_then(|idx| {
                        index
                            .get_file(ref_file)
                            .and_then(|f| f.symbols.get(idx as usize))
                            .map(|s| s.name.clone())
                    })
                    .unwrap_or_else(|| "(top-level)".to_string());

                // Skip self-references only when the caller IS one of the matched
                // symbols (same-file callers from different symbols are useful context)
                if ref_file == file_matches.path && symbol_names.contains(&enclosing_name) {
                    continue;
                }

                let key = (ref_file.to_string(), enclosing_name.clone());
                if seen.insert(key) {
                    callers.push(search::CallerEntry {
                        file: ref_file.to_string(),
                        symbol: enclosing_name,
                        line: ref_record.line_range.0 + 1, // 0-based to 1-based
                    });
                }
            }
        }

        // Cap at 10 callers to avoid noise
        callers.truncate(10);

        // Always set callers when follow_refs was requested — distinguishes
        // "not requested" (None) from "ran but found nothing" (Some([]))
        file_matches.callers = Some(callers);
    }
}

fn freshen_exact_path_for_targeted_retrieval(
    server: &SymForgeServer,
    path_scope: &search::PathScope,
) -> bool {
    let search::PathScope::Exact(relative_path) = path_scope else {
        return false;
    };
    let expected_gen = server.index.current_project_generation();
    let Some(repo_root) = server.capture_repo_root() else {
        return false;
    };
    let Ok(abs_path) = safe_repo_path_for_freshen(&repo_root, relative_path) else {
        return false;
    };
    match watcher::freshen_file_if_stale(relative_path, &abs_path, &server.index, expected_gen) {
        watcher::FreshenResult::Fresh => false,
        watcher::FreshenResult::StaleReindexed => true,
        watcher::FreshenResult::StaleRemoved => true,
        watcher::FreshenResult::GenerationMismatch => false,
    }
}

pub(super) fn safe_repo_path_for_freshen(
    repo_root: &std::path::Path,
    relative_path: &str,
) -> Result<PathBuf, String> {
    let relative = std::path::Path::new(relative_path);
    if relative.is_absolute()
        || relative
            .components()
            .any(|component| matches!(component, std::path::Component::ParentDir))
    {
        return Err(format!("path '{relative_path}' is outside the repository"));
    }

    match edit::safe_repo_path(repo_root, relative_path) {
        Ok(path) => Ok(path),
        Err(_) => {
            let canon_root = repo_root
                .canonicalize()
                .map_err(|e| format!("cannot resolve repo root: {e}"))?;
            Ok(canon_root.join(relative))
        }
    }
}

fn search_scope_summary(
    path_scope: &search::PathScope,
    language_filter: Option<&LanguageId>,
    noise_policy: &search::NoisePolicy,
    include_personal_tooling: bool,
    glob: Option<&str>,
    exclude_glob: Option<&str>,
    ranked: bool,
) -> String {
    let mut parts = Vec::new();
    match path_scope {
        search::PathScope::Any => parts.push("repo-wide".to_string()),
        search::PathScope::Exact(path) => parts.push(format!("path `{path}`")),
        search::PathScope::Prefix(prefix) => parts.push(format!("path prefix `{prefix}`")),
    }
    if let Some(language) = language_filter {
        parts.push(format!("language `{language}`"));
    }
    parts.push(if noise_policy.include_tests {
        "tests included".to_string()
    } else {
        "tests filtered".to_string()
    });
    parts.push(if noise_policy.include_generated {
        "generated included".to_string()
    } else {
        "generated filtered".to_string()
    });
    parts.push(if noise_policy.include_vendor {
        "vendor included".to_string()
    } else {
        "vendor filtered".to_string()
    });
    parts.push(if include_personal_tooling {
        "personal tooling included".to_string()
    } else {
        "personal tooling filtered".to_string()
    });
    if let Some(glob) = glob {
        parts.push(format!("glob `{glob}`"));
    }
    if let Some(exclude_glob) = exclude_glob {
        parts.push(format!("exclude `{exclude_glob}`"));
    }
    if ranked {
        parts.push("ranked ordering enabled".to_string());
    }
    parts.join("; ")
}

fn search_parse_state_for_paths<'a, I>(index: &LiveIndex, paths: I) -> &'static str
where
    I: IntoIterator<Item = &'a str>,
{
    if paths
        .into_iter()
        .filter_map(|path| index.get_file(path))
        .any(|file| file.parse_diagnostic.is_some())
    {
        "partial"
    } else {
        "parsed"
    }
}

fn parse_state_for_file(file: &IndexedFile) -> &'static str {
    match &file.parse_status {
        crate::live_index::store::ParseStatus::Parsed => "parsed",
        crate::live_index::store::ParseStatus::PartialParse { .. } => {
            // SF-003: a partial parse caused only by the tree-sitter-typescript
            // 0.23.2 import-type-array grammar limitation is valid TypeScript;
            // report it as parsed rather than partial.
            if crate::parsing::is_expected_typescript_import_type_array_limitation(
                &file.language,
                &file.content,
            ) {
                "parsed"
            } else {
                "partial"
            }
        }
        crate::live_index::store::ParseStatus::Failed { .. } => "degraded",
    }
}

fn context_source_authority_label(refreshed: bool) -> &'static str {
    if refreshed {
        "disk-refreshed"
    } else {
        "current index"
    }
}

fn context_bundle_completeness_label(
    view: &crate::live_index::ContextBundleFoundView,
    rendered: &str,
) -> String {
    let section_overflow =
        view.callers.overflow_count + view.callees.overflow_count + view.type_usages.overflow_count;
    let mut parts = Vec::new();
    if rendered.contains("Truncated at ~") {
        parts.push("budget-limited".to_string());
    }
    if section_overflow > 0 {
        parts.push(format!(
            "section-capped ({} additional reference entries not shown)",
            section_overflow
        ));
    }
    if parts.is_empty() {
        "full".to_string()
    } else {
        parts.join("; ")
    }
}

fn search_completeness_label(overflow_count: usize, suppressed_by_noise: usize) -> String {
    let mut parts = vec![if overflow_count > 0 {
        format!("truncated by result cap ({overflow_count} more omitted)")
    } else {
        "full for current scope".to_string()
    }];
    if suppressed_by_noise > 0 {
        if suppressed_by_noise > search::SUPPRESSED_TEXT_MATCH_DISPLAY_CAP {
            parts.push(format!(
                "{}+ noise-filtered match(es) suppressed",
                search::SUPPRESSED_TEXT_MATCH_DISPLAY_CAP
            ));
        } else {
            parts.push(format!(
                "{suppressed_by_noise} noise-filtered match(es) suppressed"
            ));
        }
    }
    parts.join("; ")
}

fn search_text_match_type_label(
    structural: bool,
    is_regex: bool,
    terms: Option<&[String]>,
    auto_detected_regex: bool,
    auto_corrected_regex: bool,
    ranked: bool,
) -> String {
    if structural {
        "structural (ast-grep)".to_string()
    } else if auto_corrected_regex {
        "heuristic (auto-corrected regex)".to_string()
    } else if is_regex && auto_detected_regex {
        "heuristic (auto-detected regex)".to_string()
    } else if is_regex {
        "heuristic (regex)".to_string()
    } else if ranked {
        match terms {
            Some(terms) if !terms.is_empty() => "heuristic (ranked OR-literal terms)".to_string(),
            _ => "heuristic (ranked literal)".to_string(),
        }
    } else if matches!(terms, Some(terms) if !terms.is_empty()) {
        "constrained (OR-literal terms)".to_string()
    } else {
        "constrained (literal)".to_string()
    }
}

fn search_symbols_match_type_label(
    result: &search::SymbolSearchResult,
    is_browse: bool,
) -> &'static str {
    if is_browse {
        "constrained (scoped browse)"
    } else {
        match result.hits.first().map(|hit| hit.tier) {
            Some(search::SymbolMatchTier::Exact) => "exact",
            Some(search::SymbolMatchTier::Prefix) => "constrained (prefix tier)",
            Some(search::SymbolMatchTier::Substring) => "heuristic (substring tier)",
            None => "constrained",
        }
    }
}

fn search_files_match_type_label(view: &SearchFilesView) -> &'static str {
    match view {
        SearchFilesView::Found { hits, .. } => match hits.first().map(|hit| hit.tier) {
            Some(SearchFilesTier::CoChange) => "heuristic (git-temporal coupling)",
            Some(SearchFilesTier::StrongPath) | Some(SearchFilesTier::Basename) => {
                "constrained (tiered path relevance)"
            }
            Some(SearchFilesTier::LoosePath) => "heuristic (loose path relevance)",
            None => "constrained",
        },
        _ => "constrained",
    }
}

const MAX_CO_CHANGE_PARTNERS_PER_ANCHOR: u32 = 20;

struct SearchFilesCoChangeResolution {
    neighbors: Option<SearchFilesCouplingNeighbors>,
    evidence: CapabilityEvidence,
}

fn cochange_ranking_evidence(
    status: CapabilityStatus,
    freshness: CapabilityFreshness,
    cost: CapabilityCost,
    detail: impl Into<String>,
) -> CapabilityEvidence {
    CapabilityEvidence::new(CapabilityName::CoChangeRanking, status)
        .with_freshness(freshness)
        .with_cost(cost)
        .with_safety(CapabilitySafety::ReadOnly)
        .with_detail(detail)
}

/// Build the precise `FallbackUsed` co-change detail for the case where the
/// coupling store loaded usable partner rows for the anchor, yet no returned
/// candidate landed in the co-change tier. The four distinguishable reasons:
///
/// 1. chore-anchor excluded (`is_chore_anchor_path` fires before the gate),
/// 2. anchor reached only prefix-tier path confidence (below the basename
///    floor), with a `query="<basename>"` hint to clear it,
/// 3. neighbors exist but none of them appear among the returned candidates
///    (the genuine path-mismatch case),
/// 4. neighbors appear among candidates but were filtered out by a later gate.
///
/// The anchor-confidence reason (1 and 2) is computed once at anchor level via
/// [`classify_anchor_cochange_rejection`], not per candidate.
fn cochange_fallback_detail(
    query: &str,
    anchor_path: &str,
    neighbors: &SearchFilesCouplingNeighbors,
    candidate_paths: &[&str],
) -> String {
    let partner_count = neighbors.len();
    let anchor_score = crate::live_index::query::anchor_path_match_score(query, anchor_path);
    match crate::live_index::rank_signals::classify_anchor_cochange_rejection(
        anchor_path,
        anchor_score,
    ) {
        Some(crate::live_index::rank_signals::AnchorCoChangeRejection::ChoreAnchor) => {
            format!(
                "anchor_path={anchor_path} loaded {partner_count} usable coupling partner(s), but it is a chore anchor (lockfile/changelog/workflow) excluded from co-change promotion; path ranking returned"
            )
        }
        Some(crate::live_index::rank_signals::AnchorCoChangeRejection::BelowConfidenceFloor) => {
            let anchor_basename = std::path::Path::new(anchor_path)
                .file_name()
                .and_then(|name| name.to_str())
                .unwrap_or(anchor_path);
            format!(
                "anchor_path={anchor_path} loaded {partner_count} usable coupling partner(s), but the query reached only prefix-tier path confidence for the anchor (below the basename-tier floor); pass query=\"{anchor_basename}\" to clear it; path ranking returned"
            )
        }
        None => {
            let any_partner_in_candidates = candidate_paths
                .iter()
                .any(|path| neighbors.contains_key(*path));
            if any_partner_in_candidates {
                format!(
                    "anchor_path={anchor_path} loaded {partner_count} usable coupling partner(s) present among returned candidates, but a later rank gate filtered them; path ranking returned"
                )
            } else {
                format!(
                    "anchor_path={anchor_path} loaded {partner_count} usable coupling partner(s), but none appear among the returned candidates; path ranking returned"
                )
            }
        }
    }
}

fn cochange_lazy_prepare_evidence(
    root: &Path,
    reason: &str,
    fallback_detail: &str,
) -> CapabilityEvidence {
    match crate::live_index::coupling::start_lazy_prepare(root) {
        Ok(crate::live_index::coupling::LazyPrepareOutcome::Started) => cochange_ranking_evidence(
            CapabilityStatus::Preparing,
            CapabilityFreshness::Unknown,
            CapabilityCost::Bounded,
            format!("{reason}; bounded background preparation started; {fallback_detail}"),
        ),
        Ok(crate::live_index::coupling::LazyPrepareOutcome::AlreadyRunning) => {
            cochange_ranking_evidence(
                CapabilityStatus::Preparing,
                CapabilityFreshness::Unknown,
                CapabilityCost::Bounded,
                format!(
                    "{reason}; bounded background preparation already in progress; {fallback_detail}"
                ),
            )
        }
        Err(error) => cochange_ranking_evidence(
            CapabilityStatus::Unavailable,
            CapabilityFreshness::Unknown,
            CapabilityCost::Bounded,
            format!("{reason}; unable to start bounded preparation: {error}; {fallback_detail}"),
        ),
    }
}

fn cochange_stale_prepare_evidence(root: &Path) -> CapabilityEvidence {
    match crate::live_index::coupling::start_lazy_prepare(root) {
        Ok(crate::live_index::coupling::LazyPrepareOutcome::Started) => cochange_ranking_evidence(
            CapabilityStatus::Stale,
            CapabilityFreshness::Stale,
            CapabilityCost::Bounded,
            "coupling store is stale for the current HEAD; bounded background refresh started; path ranking returned",
        ),
        Ok(crate::live_index::coupling::LazyPrepareOutcome::AlreadyRunning) => {
            cochange_ranking_evidence(
                CapabilityStatus::Stale,
                CapabilityFreshness::Stale,
                CapabilityCost::Bounded,
                "coupling store is stale for the current HEAD; bounded background refresh already in progress; path ranking returned",
            )
        }
        Err(error) => cochange_ranking_evidence(
            CapabilityStatus::Unavailable,
            CapabilityFreshness::Stale,
            CapabilityCost::Bounded,
            format!(
                "coupling store is stale for the current HEAD; unable to start bounded refresh: {error}; path ranking returned"
            ),
        ),
    }
}

fn search_files_coupling_neighbors(
    index: &LiveIndex,
    repo_root: Option<&Path>,
    anchor_path: &str,
) -> SearchFilesCoChangeResolution {
    let anchor_path = normalize_exact_path(anchor_path);
    if !index.files.contains_key(&anchor_path) {
        return SearchFilesCoChangeResolution {
            neighbors: None,
            evidence: cochange_ranking_evidence(
                CapabilityStatus::FallbackUsed,
                CapabilityFreshness::Unknown,
                CapabilityCost::Free,
                format!("anchor_path={anchor_path} is not indexed; path ranking returned"),
            ),
        };
    }

    match crate::live_index::coupling::coupling_prepare_policy_from_env() {
        CouplingPreparePolicy::Disabled => {
            return SearchFilesCoChangeResolution {
                neighbors: None,
                evidence: cochange_ranking_evidence(
                    CapabilityStatus::DisabledByPolicy,
                    CapabilityFreshness::Unknown,
                    CapabilityCost::Free,
                    "operator policy disabled co-change preparation and ranking; path ranking returned",
                ),
            };
        }
        CouplingPreparePolicy::LazyOnRequest | CouplingPreparePolicy::WarmOnStart => {}
    }

    let store = if let Some(store) = index.coupling_store() {
        Some(store.clone())
    } else if let Some(root) = repo_root {
        match crate::live_index::coupling::open_existing_coupling_store(root) {
            Ok(Some(store)) => Some((*store).clone()),
            Ok(None) => {
                return SearchFilesCoChangeResolution {
                    neighbors: None,
                    evidence: cochange_lazy_prepare_evidence(
                        root,
                        "no coupling store exists for this workspace",
                        "path ranking returned",
                    ),
                };
            }
            Err(error) => {
                return SearchFilesCoChangeResolution {
                    neighbors: None,
                    evidence: cochange_ranking_evidence(
                        CapabilityStatus::Unavailable,
                        CapabilityFreshness::Unknown,
                        CapabilityCost::Low,
                        format!("unable to open coupling store: {error}; path ranking returned"),
                    ),
                };
            }
        }
    } else {
        return SearchFilesCoChangeResolution {
            neighbors: None,
            evidence: cochange_ranking_evidence(
                CapabilityStatus::Unavailable,
                CapabilityFreshness::Unknown,
                CapabilityCost::Free,
                "no repository root is bound; path ranking returned",
            ),
        };
    };
    let Some(store) = store else {
        return SearchFilesCoChangeResolution {
            neighbors: None,
            evidence: cochange_ranking_evidence(
                CapabilityStatus::Unavailable,
                CapabilityFreshness::Unknown,
                CapabilityCost::Free,
                "no coupling store is available; path ranking returned",
            ),
        };
    };

    if let Some(root) = repo_root {
        match store.cold_built_at() {
            Ok(Some(_)) => {}
            Ok(None) => {
                return SearchFilesCoChangeResolution {
                    neighbors: None,
                    evidence: cochange_lazy_prepare_evidence(
                        root,
                        "coupling store exists but has not completed a cold build",
                        "path ranking returned",
                    ),
                };
            }
            Err(error) => {
                return SearchFilesCoChangeResolution {
                    neighbors: None,
                    evidence: cochange_ranking_evidence(
                        CapabilityStatus::Unavailable,
                        CapabilityFreshness::Unknown,
                        CapabilityCost::Low,
                        format!(
                            "unable to inspect coupling store build state: {error}; path ranking returned"
                        ),
                    ),
                };
            }
        }

        let stored_head = match store.last_head() {
            Ok(head) => head,
            Err(error) => {
                return SearchFilesCoChangeResolution {
                    neighbors: None,
                    evidence: cochange_ranking_evidence(
                        CapabilityStatus::Unavailable,
                        CapabilityFreshness::Unknown,
                        CapabilityCost::Low,
                        format!(
                            "unable to inspect coupling store HEAD state: {error}; path ranking returned"
                        ),
                    ),
                };
            }
        };
        let current_head = crate::git::head_sha(root).ok();
        if stored_head != current_head {
            return SearchFilesCoChangeResolution {
                neighbors: None,
                evidence: cochange_stale_prepare_evidence(root),
            };
        }
    }

    let rows = store
        .query_with_floor(
            &crate::live_index::coupling::AnchorKey::file(&anchor_path),
            MAX_CO_CHANGE_PARTNERS_PER_ANCHOR,
            crate::live_index::rank_signals::FILE_LEVEL_CO_CHANGE_FLOOR,
        )
        .map_err(|error| error.to_string());
    let rows = match rows {
        Ok(rows) => rows,
        Err(error) => {
            return SearchFilesCoChangeResolution {
                neighbors: None,
                evidence: cochange_ranking_evidence(
                    CapabilityStatus::Unavailable,
                    CapabilityFreshness::Unknown,
                    CapabilityCost::Low,
                    format!("unable to query coupling store: {error}; path ranking returned"),
                ),
            };
        }
    };
    let mut neighbors = HashMap::new();
    for row in rows {
        let Some(partner_path) = row.partner.as_str().strip_prefix("file:") else {
            continue;
        };
        let weighted_score = row.weighted_score as f32;
        if !weighted_score.is_finite() || weighted_score <= 0.0 {
            continue;
        }
        neighbors.insert(
            partner_path.to_string(),
            SearchFilesCouplingEvidence {
                shared_commits: row.shared_commits,
                weighted_score,
            },
        );
    }
    if neighbors.is_empty() {
        SearchFilesCoChangeResolution {
            neighbors: None,
            evidence: cochange_ranking_evidence(
                CapabilityStatus::FallbackUsed,
                CapabilityFreshness::Empty,
                CapabilityCost::Low,
                format!(
                    "ready coupling store has no usable partner rows for anchor_path={anchor_path}; path ranking returned"
                ),
            ),
        }
    } else {
        SearchFilesCoChangeResolution {
            neighbors: Some(neighbors),
            evidence: cochange_ranking_evidence(
                CapabilityStatus::Ready,
                CapabilityFreshness::Current,
                CapabilityCost::Low,
                format!(
                    "ready coupling store loaded usable partner rows for anchor_path={anchor_path}"
                ),
            ),
        }
    }
}

fn frecency_ranking_evidence(
    status: CapabilityStatus,
    detail: impl Into<String>,
) -> CapabilityEvidence {
    CapabilityEvidence::new(CapabilityName::FrecencyRanking, status)
        .with_freshness(CapabilityFreshness::Current)
        .with_safety(CapabilitySafety::ReadOnly)
        .with_detail(detail)
}

const RANKING_DIAGNOSTICS_ENV: &str = "SYMFORGE_DEBUG_RANKING";

fn ranking_diagnostics_policy_from_env() -> RankingDiagnosticsPolicy {
    match std::env::var(RANKING_DIAGNOSTICS_ENV) {
        Ok(value) => match value.trim().to_ascii_lowercase().as_str() {
            "" => RankingDiagnosticsPolicy::CallTimeExplain,
            "1" | "true" | "on" | "yes" | "default-on" => RankingDiagnosticsPolicy::DefaultOn,
            "0" | "false" | "no" | "off" | "disabled" | "disable" => {
                RankingDiagnosticsPolicy::Disabled
            }
            _ => RankingDiagnosticsPolicy::Disabled,
        },
        Err(std::env::VarError::NotPresent) => RankingDiagnosticsPolicy::CallTimeExplain,
        Err(std::env::VarError::NotUnicode(_)) => RankingDiagnosticsPolicy::Disabled,
    }
}

struct SearchFilesRankingDiagnosticsDecision {
    explain: bool,
    evidence: Option<CapabilityEvidence>,
}

fn ranking_diagnostics_evidence(
    status: CapabilityStatus,
    detail: impl Into<String>,
) -> CapabilityEvidence {
    CapabilityEvidence::new(CapabilityName::RankingDiagnostics, status)
        .with_freshness(CapabilityFreshness::Current)
        .with_safety(CapabilitySafety::ReadOnly)
        .with_detail(detail)
}

fn search_files_debug_ranking_requested(
    input: &SearchFilesInput,
) -> SearchFilesRankingDiagnosticsDecision {
    match ranking_diagnostics_policy_from_env() {
        RankingDiagnosticsPolicy::CallTimeExplain => SearchFilesRankingDiagnosticsDecision {
            explain: input.debug_ranking.unwrap_or(false),
            evidence: None,
        },
        RankingDiagnosticsPolicy::DefaultOn => SearchFilesRankingDiagnosticsDecision {
            explain: true,
            evidence: None,
        },
        RankingDiagnosticsPolicy::Disabled if input.debug_ranking.unwrap_or(false) => {
            SearchFilesRankingDiagnosticsDecision {
                explain: false,
                evidence: Some(ranking_diagnostics_evidence(
                    CapabilityStatus::DisabledByPolicy,
                    "operator policy disabled ranking diagnostics; ranking explanation omitted",
                )),
            }
        }
        RankingDiagnosticsPolicy::Disabled => SearchFilesRankingDiagnosticsDecision {
            explain: false,
            evidence: None,
        },
    }
}

fn search_files_rank_mode_label(rank_by: Option<&str>) -> &'static str {
    match rank_by {
        Some("frecency") => "frecency",
        Some("path+cochange") => "path+cochange",
        _ => "default path",
    }
}

fn search_files_tier_summary(view: &SearchFilesView) -> String {
    let SearchFilesView::Found { hits, .. } = view else {
        return "no returned files".to_string();
    };
    let mut cochange = 0usize;
    let mut strong = 0usize;
    let mut basename = 0usize;
    let mut loose = 0usize;
    for hit in hits {
        match hit.tier {
            SearchFilesTier::CoChange => cochange += 1,
            SearchFilesTier::StrongPath => strong += 1,
            SearchFilesTier::Basename => basename += 1,
            SearchFilesTier::LoosePath => loose += 1,
        }
    }
    let mut parts = Vec::new();
    if cochange > 0 {
        parts.push(format!("co-change={cochange}"));
    }
    if strong > 0 {
        parts.push(format!("strong path={strong}"));
    }
    if basename > 0 {
        parts.push(format!("basename={basename}"));
    }
    if loose > 0 {
        parts.push(format!("loose path={loose}"));
    }
    if parts.is_empty() {
        "no returned files".to_string()
    } else {
        parts.join(", ")
    }
}

fn search_files_signal_explanation(
    signal: &str,
    evidence: Option<&CapabilityEvidence>,
    not_requested: bool,
) -> String {
    if not_requested {
        return format!("{signal} signal: not requested");
    }
    match evidence {
        Some(evidence) => {
            let mut line = format!("{signal} signal: {}", evidence.status);
            if let Some(detail) = evidence.detail.as_deref().map(str::trim)
                && !detail.is_empty()
            {
                line.push_str(" - ");
                line.push_str(detail.trim_end_matches('.'));
            }
            line
        }
        None => format!("{signal} signal: unavailable - no capability evidence was recorded"),
    }
}

fn search_files_final_ordering_note(
    rank_by: Option<&str>,
    cochange_evidence: Option<&CapabilityEvidence>,
    frecency_evidence: Option<&CapabilityEvidence>,
) -> String {
    match rank_by {
        Some("frecency") => match frecency_evidence.map(|evidence| evidence.status) {
            Some(CapabilityStatus::Applied) => {
                "frecency fusion applied after path ranking; ties remain deterministic by path"
                    .to_string()
            }
            Some(status) => format!(
                "frecency requested but {status}; path-ranked ordering was returned"
            ),
            None => "frecency requested but no evidence was available; path-ranked ordering was returned"
                .to_string(),
        },
        Some("path+cochange") => match cochange_evidence.map(|evidence| evidence.status) {
            Some(CapabilityStatus::Applied) | Some(CapabilityStatus::Ready) => {
                "co-change partners that passed gates were fused with path ranking; remaining files keep path tie-breakers"
                    .to_string()
            }
            Some(status) => format!(
                "path+cochange requested but {status}; path-ranked ordering was returned"
            ),
            None => "path+cochange requested but no evidence was available; path-ranked ordering was returned"
                .to_string(),
        },
        _ => "tiered path relevance ordered the results with deterministic path tie-breakers"
            .to_string(),
    }
}

fn search_files_ranking_explanation(
    view: &SearchFilesView,
    rank_by: Option<&str>,
    cochange_evidence: Option<&CapabilityEvidence>,
    frecency_evidence: Option<&CapabilityEvidence>,
) -> String {
    let rank_mode = search_files_rank_mode_label(rank_by);
    let frecency_not_requested = rank_by != Some("frecency");
    let cochange_not_requested = rank_by != Some("path+cochange");
    [
        "Ranking explanation:".to_string(),
        format!("requested rank mode: {rank_mode}"),
        format!(
            "path signal: applied - returned tier family: {}",
            search_files_tier_summary(view)
        ),
        search_files_signal_explanation("frecency", frecency_evidence, frecency_not_requested),
        search_files_signal_explanation("co-change", cochange_evidence, cochange_not_requested),
        format!(
            "final ordering: {}",
            search_files_final_ordering_note(rank_by, cochange_evidence, frecency_evidence)
        ),
    ]
    .join("\n")
}

#[derive(Debug, Clone, PartialEq, Eq)]
struct CapabilityStatusReport {
    frecency: String,
    co_change: String,
    worktree_routing: String,
    ranking_diagnostics: String,
}

impl CapabilityStatusReport {
    fn full_text(&self) -> String {
        format!(
            "Capabilities:\n  frecency: {}\n  co-change: {}\n  worktree routing: {}\n  ranking diagnostics: {}",
            self.frecency, self.co_change, self.worktree_routing, self.ranking_diagnostics
        )
    }

    fn compact_text(&self) -> String {
        format!(
            "Capabilities: frecency={}; co-change={}; worktree={}; ranking={}",
            self.frecency, self.co_change, self.worktree_routing, self.ranking_diagnostics
        )
    }
}

fn frecency_health_status(repo_root: Option<&Path>) -> String {
    let Some(repo_root) = repo_root else {
        return "unavailable/no-repository-root".to_string();
    };
    let has_persistent_history = repo_root
        .join(crate::paths::SYMFORGE_FRECENCY_DB_PATH)
        .is_file();
    match crate::live_index::frecency::collection_policy_from_env() {
        FrecencyCollectionPolicy::Disabled => "disabled by policy".to_string(),
        FrecencyCollectionPolicy::Session => {
            if has_persistent_history {
                "ready/session+persistent".to_string()
            } else {
                "ready/session/no-history fallback-used-on-empty".to_string()
            }
        }
        FrecencyCollectionPolicy::Persistent => {
            if has_persistent_history {
                "ready/persistent".to_string()
            } else {
                "ready/persistent/no-history fallback-used-on-empty".to_string()
            }
        }
    }
}

fn cochange_store_health_status(
    store: &crate::live_index::coupling::CouplingStore,
    repo_root: &Path,
) -> String {
    match store.cold_built_at() {
        Ok(Some(_)) => {}
        Ok(None) => return "preparing/cold-build-pending fallback-used-on-request".to_string(),
        Err(error) => return format!("unavailable/store-build-state-error ({error})"),
    }

    let stored_head = match store.last_head() {
        Ok(Some(head)) => head,
        Ok(None) => return "preparing/no-head-recorded fallback-used-on-request".to_string(),
        Err(error) => return format!("unavailable/store-head-state-error ({error})"),
    };
    let current_head = match crate::git::head_sha(repo_root) {
        Ok(head) => head,
        Err(error) => return format!("unavailable/head-read-failed ({error})"),
    };
    if stored_head != current_head {
        "stale/head-mismatch fallback-used-on-request".to_string()
    } else {
        "ready/current".to_string()
    }
}

fn cochange_health_status(index: &LiveIndex, repo_root: Option<&Path>) -> String {
    let policy = crate::live_index::coupling::coupling_prepare_policy_from_env();
    if matches!(policy, CouplingPreparePolicy::Disabled) {
        return "disabled by policy".to_string();
    }

    let Some(repo_root) = repo_root else {
        return "unavailable/no-repository-root".to_string();
    };
    if git2::Repository::discover(repo_root).is_err() {
        return "unavailable/not-a-git-repo".to_string();
    }

    if let Some(store) = index.coupling_store() {
        return cochange_store_health_status(store, repo_root);
    }

    match crate::live_index::coupling::open_existing_coupling_store(repo_root) {
        Ok(Some(store)) => cochange_store_health_status(store.as_ref(), repo_root),
        Ok(None) => match policy {
            CouplingPreparePolicy::LazyOnRequest => {
                "preparing/lazy-on-request fallback-used-on-request".to_string()
            }
            CouplingPreparePolicy::WarmOnStart => {
                "preparing/warm-on-start fallback-used-on-request".to_string()
            }
            CouplingPreparePolicy::Disabled => "disabled by policy".to_string(),
        },
        Err(error) => format!("unavailable/store-open-failed ({error})"),
    }
}

fn worktree_routing_health_status() -> String {
    match crate::worktree::routing_policy_from_env() {
        WorktreeRoutingPolicy::ExplicitCallTime => "explicit-call enabled".to_string(),
        WorktreeRoutingPolicy::Disabled => "disabled by policy".to_string(),
    }
}

fn ranking_diagnostics_health_status() -> String {
    match ranking_diagnostics_policy_from_env() {
        RankingDiagnosticsPolicy::CallTimeExplain => {
            "call-time explain available/default-off".to_string()
        }
        RankingDiagnosticsPolicy::DefaultOn => "call-time explain available/default-on".to_string(),
        RankingDiagnosticsPolicy::Disabled => "disabled by policy".to_string(),
    }
}

fn capability_status_report(index: &LiveIndex, repo_root: Option<&Path>) -> CapabilityStatusReport {
    CapabilityStatusReport {
        frecency: frecency_health_status(repo_root),
        co_change: cochange_health_status(index, repo_root),
        worktree_routing: worktree_routing_health_status(),
        ranking_diagnostics: ranking_diagnostics_health_status(),
    }
}

const CHANGED_WITH_DEPRECATION_WARNING: &str = "Deprecation warning: `search_files changed_with=` is deprecated since v7.x and scheduled for removal in v8.x; prefer `rank_by=\"path+cochange\"` with `anchor_path=<path>`.";

fn append_changed_with_deprecation_warning(mut result: String) -> String {
    result.push_str("\n\n");
    result.push_str(CHANGED_WITH_DEPRECATION_WARNING);
    result
}

fn search_files_total_matches(view: &SearchFilesView) -> usize {
    match view {
        SearchFilesView::Found { total_matches, .. } => *total_matches,
        _ => 0,
    }
}

fn search_symbols_total_matches(result: &search::SymbolSearchResult) -> usize {
    result.hits.len().saturating_add(result.overflow_count)
}

fn hidden_search_symbols_noise_count(
    index: &LiveIndex,
    query: &str,
    kind: Option<&str>,
    options: &search::SymbolSearchOptions,
    visible: &search::SymbolSearchResult,
) -> usize {
    if options.noise_policy.include_vendor && options.include_personal_tooling {
        return 0;
    }

    let mut unfiltered_options = options.clone();
    unfiltered_options.noise_policy.include_vendor = true;
    unfiltered_options.include_personal_tooling = true;
    let unfiltered = search::search_symbols_with_options(index, query, kind, &unfiltered_options);
    search_symbols_total_matches(&unfiltered).saturating_sub(search_symbols_total_matches(visible))
}

fn search_files_resolve_candidate_count(view: &SearchFilesResolveView) -> usize {
    match view {
        SearchFilesResolveView::Resolved { .. } => 1,
        SearchFilesResolveView::Ambiguous {
            matches,
            overflow_count,
            ..
        } => matches.len().saturating_add(*overflow_count),
        _ => 0,
    }
}

fn search_files_hidden_noise_note(
    hidden_count: usize,
    include_vendor: bool,
    include_personal_tooling: bool,
) -> Option<String> {
    if hidden_count == 0 || (include_vendor && include_personal_tooling) {
        return None;
    }

    let mut flags = Vec::new();
    if !include_vendor {
        flags.push("include_vendor=true");
    }
    if !include_personal_tooling {
        flags.push("include_personal_tooling=true");
    }

    let noun = if hidden_count == 1 {
        "path candidate"
    } else {
        "path candidates"
    };
    Some(format!(
        "{hidden_count} vendor/personal-tooling {noun} hidden by default; pass {} to include suppressed noise.",
        flags.join(" or ")
    ))
}

fn search_files_filter_summary(include_vendor: bool, include_personal_tooling: bool) -> String {
    let vendor = if include_vendor {
        "vendor included"
    } else {
        "vendor filtered"
    };
    let personal = if include_personal_tooling {
        "personal tooling included"
    } else {
        "personal tooling filtered"
    };
    format!("filters: {vendor}; {personal}")
}

fn search_files_scope_summary(
    base_scope: impl Into<String>,
    include_vendor: bool,
    include_personal_tooling: bool,
) -> String {
    format!(
        "{}; {}",
        base_scope.into(),
        search_files_filter_summary(include_vendor, include_personal_tooling)
    )
}

fn append_search_files_filter_summary(
    result: &mut String,
    include_vendor: bool,
    include_personal_tooling: bool,
) {
    result.push_str("\n\n");
    result.push_str(&search_files_filter_summary(
        include_vendor,
        include_personal_tooling,
    ));
}

fn search_files_resolve_match_type_label(view: &SearchFilesResolveView) -> &'static str {
    match view {
        SearchFilesResolveView::Resolved { .. } => "exact (resolve)",
        SearchFilesResolveView::Ambiguous { .. } => "constrained (resolve candidates)",
        _ => "constrained",
    }
}

fn anchored_search_evidence(anchors: Vec<String>, noun: &str) -> String {
    if anchors.is_empty() {
        format!("no {noun} available")
    } else {
        let rendered = anchors
            .into_iter()
            .map(|anchor| format!("`{anchor}`"))
            .collect::<Vec<_>>()
            .join(", ");
        format!("{noun} {rendered}")
    }
}

fn search_text_evidence(result: &search::TextSearchResult) -> String {
    let anchors = result
        .files
        .iter()
        .flat_map(|file| {
            file.matches
                .iter()
                .take(2)
                .map(move |line_match| format!("{}:{}", file.path, line_match.line_number))
        })
        .take(3)
        .collect();
    anchored_search_evidence(anchors, "line anchors")
}

fn search_symbols_evidence(result: &search::SymbolSearchResult) -> String {
    let anchors = result
        .hits
        .iter()
        .take(3)
        .map(|hit| format!("{}:{}", hit.path, hit.line))
        .collect();
    anchored_search_evidence(anchors, "symbol anchors")
}

fn search_paths_evidence<'a, I>(paths: I) -> String
where
    I: IntoIterator<Item = &'a str>,
{
    let anchors = paths
        .into_iter()
        .take(3)
        .map(std::borrow::ToOwned::to_owned)
        .collect();
    anchored_search_evidence(anchors, "paths")
}

fn normalize_untracked_search_path(raw: &str) -> String {
    let mut normalized = raw.trim().replace('\\', "/");
    while normalized.starts_with("./") {
        normalized = normalized[2..].to_string();
    }
    normalized.trim_matches('/').to_string()
}

fn untracked_path_has_component(path: &str, component: &str) -> bool {
    path.split('/')
        .any(|part| part.eq_ignore_ascii_case(component))
}

fn language_for_path(path: &str) -> Option<LanguageId> {
    Path::new(path)
        .extension()
        .and_then(|extension| extension.to_str())
        .map(str::to_ascii_lowercase)
        .and_then(|extension| LanguageId::from_extension(&extension))
}

fn untracked_common_path_filters_allow(
    path: &str,
    include_vendor: bool,
    include_personal_tooling: bool,
) -> bool {
    (include_vendor || !crate::live_index::query::is_vendor_path(path))
        && (include_personal_tooling || !crate::live_index::query::is_personal_tooling_path(path))
}

fn untracked_paths_not_in_index(server: &SymForgeServer) -> Vec<String> {
    let Some(repo_root) = server.effective_repo_root_for_git_tools() else {
        return Vec::new();
    };
    let Ok(repo) = crate::git::GitRepo::open(&repo_root) else {
        return Vec::new();
    };
    let Ok(mut paths) = repo.untracked_paths() else {
        return Vec::new();
    };

    {
        let guard = server.index.read();
        paths.retain(|path| guard.get_file(path).is_none());
    }
    paths.sort();
    paths.dedup();
    paths
}

fn untracked_file_diagnostic(paths: &[String]) -> Option<String> {
    let first = paths.first()?;
    Some(format!(
        "untracked file may match: {} untracked path(s) are not indexed. To index the first match, call analyze_file_impact(\"{}\", new_file=true).",
        paths.len(),
        first
    ))
}

fn append_untracked_file_diagnostic(output: &mut String, paths: &[String]) {
    if let Some(diagnostic) = untracked_file_diagnostic(paths) {
        output.push_str("\n\n");
        output.push_str(&diagnostic);
    }
}

fn untracked_path_matches_search_files_query(path: &str, query: &str) -> bool {
    let normalized_query = normalize_untracked_search_path(query);
    if normalized_query.is_empty() {
        return false;
    }

    let is_glob = normalized_query.contains('*')
        || normalized_query.contains('?')
        || normalized_query.contains('[');
    if is_glob
        && let Ok(glob) = globset::GlobBuilder::new(&normalized_query)
            .literal_separator(false)
            .build()
    {
        return glob.compile_matcher().is_match(path);
    }

    let path_lower = path.to_ascii_lowercase();
    let normalized_query_lower = normalized_query.to_ascii_lowercase();
    let has_path_context = normalized_query.contains('/');
    if path_lower == normalized_query_lower
        || (has_path_context && path_lower.ends_with(&normalized_query_lower))
    {
        return true;
    }

    let tokens: Vec<String> = normalized_query
        .split(|ch: char| ch == '/' || ch.is_whitespace())
        .filter(|part| !part.is_empty())
        .map(str::to_ascii_lowercase)
        .collect();
    let Some(basename_token) = tokens.last() else {
        return false;
    };
    let component_tokens = if tokens.len() > 1 {
        &tokens[..tokens.len() - 1]
    } else {
        &[][..]
    };
    let file_name = Path::new(path)
        .file_name()
        .and_then(|name| name.to_str())
        .unwrap_or("");
    if file_name.eq_ignore_ascii_case(basename_token)
        && component_tokens
            .iter()
            .all(|component| untracked_path_has_component(path, component))
    {
        return true;
    }

    if basename_token.len() >= 3 {
        let file_stem = Path::new(path)
            .file_stem()
            .and_then(|stem| stem.to_str())
            .unwrap_or("");
        if file_stem.to_ascii_lowercase().starts_with(basename_token) {
            return true;
        }
    }

    tokens.iter().all(|token| path_lower.contains(token))
}

fn matching_untracked_paths_for_search_files(
    server: &SymForgeServer,
    query: &str,
    include_vendor: bool,
    include_personal_tooling: bool,
) -> Vec<String> {
    untracked_paths_not_in_index(server)
        .into_iter()
        .filter(|path| {
            untracked_common_path_filters_allow(path, include_vendor, include_personal_tooling)
                && untracked_path_matches_search_files_query(path, query)
        })
        .collect()
}

fn untracked_text_path_allowed(path: &str, options: &search::TextSearchOptions) -> bool {
    let classification = FileClassification::for_code_path(path);
    options.path_scope.matches(path)
        && options.search_scope.allows(&classification)
        && options.noise_policy.allows(&classification)
        && (options.noise_policy.include_vendor || !crate::live_index::query::is_vendor_path(path))
        && (options.include_personal_tooling
            || !crate::live_index::query::is_personal_tooling_path(path))
        && options
            .language_filter
            .as_ref()
            .is_none_or(|language| language_for_path(path).as_ref() == Some(language))
        && untracked_text_globs_allow(path, options)
}

fn untracked_text_globs_allow(path: &str, options: &search::TextSearchOptions) -> bool {
    let include_matches = match options.glob.as_deref() {
        Some(pattern) => globset::GlobBuilder::new(pattern)
            .literal_separator(false)
            .build()
            .map(|glob| glob.compile_matcher().is_match(path))
            .unwrap_or(false),
        None => true,
    };
    let exclude_matches = match options.exclude_glob.as_deref() {
        Some(pattern) => globset::GlobBuilder::new(pattern)
            .literal_separator(false)
            .build()
            .map(|glob| glob.compile_matcher().is_match(path))
            .unwrap_or(false),
        None => false,
    };
    include_matches && !exclude_matches
}

fn whole_word_contains(haystack: &str, needle: &str) -> bool {
    haystack.match_indices(needle).any(|(idx, matched)| {
        let before_is_word = haystack[..idx]
            .chars()
            .next_back()
            .is_some_and(|ch| ch == '_' || ch.is_alphanumeric());
        let after_idx = idx + matched.len();
        let after_is_word = haystack[after_idx..]
            .chars()
            .next()
            .is_some_and(|ch| ch == '_' || ch.is_alphanumeric());
        !before_is_word && !after_is_word
    })
}

fn untracked_text_matches(
    content: &str,
    query: Option<&str>,
    terms: Option<&[String]>,
    is_regex: bool,
    options: &search::TextSearchOptions,
) -> bool {
    let case_sensitive = options.case_sensitive.unwrap_or(is_regex);
    if is_regex {
        let Some(pattern) = query.map(str::trim).filter(|pattern| !pattern.is_empty()) else {
            return false;
        };
        return regex::RegexBuilder::new(pattern)
            .case_insensitive(!case_sensitive)
            .build()
            .map(|regex| regex.is_match(content))
            .unwrap_or(false);
    }

    let normalized_terms: Vec<&str> = match terms {
        Some(raw_terms) if !raw_terms.is_empty() => raw_terms
            .iter()
            .map(|term| term.trim())
            .filter(|term| !term.is_empty())
            .collect(),
        _ => query
            .map(str::trim)
            .filter(|text| !text.is_empty())
            .map(|text| vec![text])
            .unwrap_or_default(),
    };
    if normalized_terms.is_empty() {
        return false;
    }

    if case_sensitive {
        if options.whole_word {
            normalized_terms
                .iter()
                .any(|term| whole_word_contains(content, term))
        } else {
            normalized_terms.iter().any(|term| content.contains(term))
        }
    } else {
        let lowered = content.to_lowercase();
        normalized_terms.iter().any(|term| {
            let lowered_term = term.to_lowercase();
            if options.whole_word {
                whole_word_contains(&lowered, &lowered_term)
            } else {
                lowered.contains(&lowered_term)
            }
        })
    }
}

fn matching_untracked_paths_for_search_text(
    server: &SymForgeServer,
    query: Option<&str>,
    terms: Option<&[String]>,
    structural: bool,
    is_regex: bool,
    options: &search::TextSearchOptions,
) -> Vec<String> {
    if structural {
        return Vec::new();
    }
    let Some(repo_root) = server.effective_repo_root_for_git_tools() else {
        return Vec::new();
    };
    let Ok(repo) = crate::git::GitRepo::open(&repo_root) else {
        return Vec::new();
    };

    untracked_paths_not_in_index(server)
        .into_iter()
        .filter(|path| untracked_text_path_allowed(path, options))
        .filter(|path| {
            repo.file_from_workdir(path)
                .ok()
                .flatten()
                .is_some_and(|content| {
                    untracked_text_matches(&content, query, terms, is_regex, options)
                })
        })
        .collect()
}

struct AdmissionDegradationView {
    tier: AdmissionTier,
    path: String,
    size: Option<u64>,
    extension: Option<String>,
    language: Option<LanguageId>,
    reason: Option<SkipReason>,
}

fn normalize_admission_degradation_path(raw: &str) -> String {
    let mut normalized = raw.trim().replace('\\', "/");
    while normalized.starts_with("./") {
        normalized = normalized[2..].to_string();
    }
    normalized.trim_matches('/').to_string()
}

fn admission_degradation_view_from_lookup(
    view: crate::live_index::query::AdmissionTierLookupView,
) -> AdmissionDegradationView {
    AdmissionDegradationView {
        tier: view.tier,
        path: view.path,
        size: view.size,
        extension: view.extension,
        language: view.language,
        reason: view.reason,
    }
}

fn admission_degradation_view_from_disk(
    repo_root: &Path,
    path: &str,
) -> Option<AdmissionDegradationView> {
    let canonical_root = std::fs::canonicalize(repo_root).ok()?;
    let raw_path = Path::new(path);
    let candidate = if raw_path.is_absolute() {
        raw_path.to_path_buf()
    } else {
        canonical_root.join(raw_path)
    };
    let canonical_candidate = std::fs::canonicalize(candidate).ok()?;
    if !canonical_candidate.starts_with(&canonical_root) {
        return None;
    }

    let metadata = std::fs::metadata(&canonical_candidate).ok()?;
    if !metadata.is_file() {
        return None;
    }

    let size = metadata.len();
    let initial_decision = crate::discovery::classify_admission(&canonical_candidate, size, None);
    let decision = if initial_decision.tier == AdmissionTier::Normal {
        let mut sample = Vec::new();
        let mut file = std::fs::File::open(&canonical_candidate).ok()?;
        file.by_ref()
            .take(BINARY_SNIFF_BYTES as u64)
            .read_to_end(&mut sample)
            .ok()?;
        crate::discovery::classify_admission(&canonical_candidate, size, Some(&sample))
    } else {
        initial_decision
    };

    if decision.tier == AdmissionTier::Normal {
        return None;
    }

    let relative_path = canonical_candidate
        .strip_prefix(&canonical_root)
        .ok()
        .map(|relative| relative.to_string_lossy().replace('\\', "/"))
        .filter(|relative| !relative.is_empty())
        .unwrap_or_else(|| normalize_admission_degradation_path(path));
    let extension = canonical_candidate
        .extension()
        .and_then(|extension| extension.to_str())
        .map(str::to_string);
    let language = extension.as_deref().and_then(LanguageId::from_extension);

    Some(AdmissionDegradationView {
        tier: decision.tier,
        path: relative_path,
        size: Some(size),
        extension,
        language,
        reason: decision.reason,
    })
}

fn admission_tier_degradation_for_path(
    index: &LiveIndex,
    repo_root: Option<&Path>,
    tool_name: &str,
    path: &str,
    symbol_name: &str,
) -> Option<String> {
    let view = index
        .capture_admission_tier_lookup_view(path)
        .map(admission_degradation_view_from_lookup)
        .or_else(|| repo_root.and_then(|root| admission_degradation_view_from_disk(root, path)))?;
    match view.tier {
        AdmissionTier::Normal => None,
        AdmissionTier::MetadataOnly => Some(format!(
            "degraded result (Tier 2 metadata-only): `{tool_name}` cannot return symbol or \
             reference data for `{}` because SymForge admits this path as metadata-only instead \
             of parsing it.\n\n\
             Warning: metadata-only files are not parsed, so no symbol or reference data exists \
             for this file.\n\
             Path: {}\n\
             Size: {}\n\
             Extension: {}\n\
             Language: {}\n\
             Reason: {}\n\
             Requested symbol: `{symbol_name}`\n\n\
             No symbol or reference data is available for this file.",
            view.path,
            view.path,
            view.size
                .map(|size| format!("{size} bytes"))
                .unwrap_or_else(|| "unknown".to_string()),
            view.extension.as_deref().unwrap_or("unavailable"),
            view.language
                .as_ref()
                .map(std::string::ToString::to_string)
                .unwrap_or_else(|| "unavailable".to_string()),
            view.reason
                .map(|reason| reason.to_string())
                .unwrap_or_else(|| "unknown".to_string()),
        )),
        AdmissionTier::HardSkip => Some(format!(
            "Not available (Tier 3 hard-skipped): `{tool_name}` cannot inspect `{}` because \
             the file was hard-skipped during admission.\n\n\
             Path: {}\n\
             Size: {}\n\
             Extension: {}\n\
             Reason: {}\n\
             Requested symbol: `{symbol_name}`\n\n\
             No symbol or reference data is available for this file.",
            view.path,
            view.path,
            view.size
                .map(|size| format!("{size} bytes"))
                .unwrap_or_else(|| "unknown".to_string()),
            view.extension.as_deref().unwrap_or("unavailable"),
            view.reason
                .map(|reason| reason.to_string())
                .unwrap_or_else(|| "unknown".to_string()),
        )),
    }
}

fn find_references_match_type_label(input: &FindReferencesInput, mode: &str) -> &'static str {
    if mode == "implementations" {
        return "constrained (implementations mode)";
    }
    if input.path.is_some() && input.symbol_line.is_some() {
        "exact"
    } else if input.path.is_some() {
        "constrained (path-scoped symbol)"
    } else if input.symbol_kind.is_some() || input.kind.as_deref().is_some_and(|kind| kind != "all")
    {
        "constrained (repo-wide filtered symbol)"
    } else {
        "constrained (repo-wide name match)"
    }
}

fn find_references_scope_summary(input: &FindReferencesInput, mode: &str) -> String {
    let mut parts = Vec::new();
    if mode == "implementations" {
        parts.push(format!(
            "repo-wide implementations for symbol token `{}`",
            input.name
        ));
        parts.push(format!(
            "direction `{}`",
            input.direction.as_deref().unwrap_or("auto")
        ));
        return parts.join("; ");
    }

    match input.path.as_deref() {
        Some(path) => parts.push(format!("path `{path}`")),
        None => parts.push(format!("repo-wide symbol token `{}`", input.name)),
    }
    if let Some(line) = input.symbol_line {
        parts.push(format!("exact selector line {line}"));
    }
    if let Some(symbol_kind) = input.symbol_kind.as_deref() {
        parts.push(format!("symbol kind `{symbol_kind}`"));
    }
    if let Some(reference_kind) = input.kind.as_deref().filter(|kind| *kind != "all") {
        parts.push(format!("reference kind `{reference_kind}`"));
    }
    parts.join("; ")
}

fn find_references_completeness_label(
    view: &crate::live_index::FindReferencesView,
    limits: &format::OutputLimits,
) -> String {
    let shown_files = view.files.len().min(limits.max_files);
    let mut shown_refs = 0usize;
    for file in view.files.iter().take(limits.max_files) {
        if shown_refs >= limits.total_hits {
            break;
        }
        let remaining_budget = limits.total_hits.saturating_sub(shown_refs);
        shown_refs += file
            .hits
            .len()
            .min(limits.max_per_file)
            .min(remaining_budget);
    }
    let omitted_refs = view.total_refs.saturating_sub(shown_refs);
    let omitted_files = view.total_files.saturating_sub(shown_files);
    if omitted_refs == 0 && omitted_files == 0 {
        return "full for current scope".to_string();
    }
    let mut parts = vec!["truncated by result cap".to_string()];
    if omitted_refs > 0 {
        parts.push(format!("{omitted_refs} reference(s) omitted"));
    }
    if omitted_files > 0 {
        parts.push(format!("{omitted_files} file(s) omitted"));
    }
    parts.join("; ")
}

fn find_references_evidence(view: &crate::live_index::FindReferencesView) -> String {
    let anchors = view
        .files
        .iter()
        .flat_map(|file| {
            let file_path = file.file_path.clone();
            file.hits.iter().flat_map(move |hit| {
                hit.context_lines
                    .iter()
                    .filter(|line| line.is_reference_line)
                    .map({
                        let file_path = file_path.clone();
                        move |line| format!("{file_path}:{}", line.line_number)
                    })
            })
        })
        .take(3)
        .collect();
    anchored_search_evidence(anchors, "reference anchors")
}

fn find_references_kind_filter(kind_filter: Option<&str>) -> Option<ReferenceKind> {
    match kind_filter {
        Some("call") => Some(ReferenceKind::Call),
        Some("import") => Some(ReferenceKind::Import),
        Some("type_usage") => Some(ReferenceKind::TypeUsage),
        Some("macro_use") => Some(ReferenceKind::MacroUse),
        Some("value_use") => Some(ReferenceKind::ValueUse),
        Some("all") | None => None,
        _ => None,
    }
}

fn should_collect_qualified_usages(input: &FindReferencesInput) -> bool {
    input.path.is_none()
        && !input.name.is_empty()
        && matches!(
            input.kind.as_deref(),
            None | Some("all") | Some("type_usage")
        )
}

fn qualified_usage_hit_view(usage: &QualifiedUsage) -> ReferenceHitView {
    let confidence = if usage.confident {
        "confident"
    } else {
        "uncertain"
    };
    let annotation = format!("[qualified-path scan: {confidence}]");

    ReferenceHitView {
        context_lines: vec![ReferenceContextLineView {
            line_number: usage.line,
            text: usage.line_text.clone(),
            is_reference_line: true,
            enclosing_annotation: Some(annotation),
        }],
    }
}

fn merge_qualified_usages_into_view(
    view: &mut FindReferencesView,
    mut usages: Vec<QualifiedUsage>,
    mut seen_ranges: HashSet<(String, (u32, u32))>,
) {
    let mut known_files: HashSet<String> = seen_ranges
        .iter()
        .map(|(file_path, _)| file_path.clone())
        .collect();
    known_files.extend(view.files.iter().map(|file| file.file_path.clone()));

    usages.sort_by(|a, b| {
        a.file_path
            .cmp(&b.file_path)
            .then(a.line.cmp(&b.line))
            .then(a.byte_range.0.cmp(&b.byte_range.0))
    });

    let mut added = 0usize;
    for usage in usages {
        let range_key = (usage.file_path.clone(), usage.byte_range);
        if !seen_ranges.insert(range_key) {
            continue;
        }
        let hit = qualified_usage_hit_view(&usage);
        if let Some(file_view) = view
            .files
            .iter_mut()
            .find(|file| file.file_path == usage.file_path)
        {
            file_view.hits.push(hit);
        } else {
            view.files.push(ReferenceFileView {
                file_path: usage.file_path.clone(),
                hits: vec![hit],
            });
        }
        known_files.insert(usage.file_path);
        added += 1;
    }

    if added > 0 {
        view.total_refs += added;
        view.total_files = known_files.len();
        view.files.sort_by(|a, b| a.file_path.cmp(&b.file_path));
    }
}

fn implementations_parse_state_for_paths(
    index: &LiveIndex,
    view: &crate::live_index::ImplementationsView,
) -> &'static str {
    search_parse_state_for_paths(
        index,
        view.entries.iter().map(|entry| entry.file_path.as_str()),
    )
}

fn implementations_completeness_label(
    view: &crate::live_index::ImplementationsView,
    limits: &format::OutputLimits,
) -> String {
    let shown = view
        .entries
        .len()
        .min(limits.max_files * limits.max_per_file);
    let omitted = view.entries.len().saturating_sub(shown);
    if omitted == 0 {
        "full for current scope".to_string()
    } else {
        format!("truncated by result cap ({omitted} implementation entry(s) omitted)")
    }
}

fn implementations_evidence(view: &crate::live_index::ImplementationsView) -> String {
    let anchors = view
        .entries
        .iter()
        .take(3)
        .map(|entry| format!("{}:{}", entry.file_path, entry.line + 1))
        .collect();
    anchored_search_evidence(anchors, "implementation anchors")
}

fn explore_is_test_like_path(
    path: &str,
    classification: Option<&crate::domain::index::FileClassification>,
) -> bool {
    let path_lower = path.replace('\\', "/").to_ascii_lowercase();
    classification.is_some_and(|c| c.is_test)
        || path_lower.contains("/tests/")
        || path_lower.contains("/test/")
        || path_lower.contains("/__tests__/")
        || path_lower.ends_with("/tests.rs")
        || path_lower.ends_with("/test.rs")
        || path_lower.ends_with("_test.rs")
        || path_lower.ends_with("_spec.rs")
}

fn explore_should_skip_path_boost(
    path: &str,
    classification: &crate::domain::index::FileClassification,
    include_noise: bool,
    include_vendor: bool,
    include_personal_tooling: bool,
) -> bool {
    let suppress_vendor = !(include_noise || include_vendor);
    let suppress_personal = !(include_noise || include_personal_tooling);
    let suppress_other = !include_noise;
    if !suppress_vendor && !suppress_personal && !suppress_other {
        return false;
    }
    if suppress_other && explore_is_test_like_path(path, Some(classification)) {
        return true;
    }
    if suppress_personal && crate::live_index::query::is_personal_tooling_path(path) {
        return true;
    }
    if suppress_vendor && classification.is_vendor {
        return true;
    }
    if suppress_other && (classification.is_generated || classification.is_config) {
        return true;
    }
    false
}

fn explore_path_penalty(
    path: &str,
    classification: Option<&crate::domain::index::FileClassification>,
) -> u64 {
    let path_lower = path.replace('\\', "/").to_ascii_lowercase();
    if explore_is_test_like_path(path, classification) {
        return 2;
    }
    if classification.is_some_and(|c| c.is_config)
        || path_lower.ends_with(".md")
        || path_lower.contains("/docs/")
        || path_lower.contains("/plans/")
        || path_lower.contains("/manual/")
        || path_lower.contains("changelog")
        || path_lower.contains(".planning/")
        || path_lower.contains(".auto-claude")
    {
        return 2;
    }
    if path_lower.contains("/examples/")
        || path_lower.contains("/fixtures/")
        || path_lower.contains("/bench/")
        || path_lower.contains("/benches/")
        || path_lower.contains("/sample/")
        || path_lower.contains("/samples/")
    {
        return 3;
    }
    8
}

fn explore_fallback_alignment_multiplier(
    query_term_count: usize,
    matched_term_count: usize,
) -> u64 {
    if query_term_count <= 1 {
        return 8;
    }
    match (query_term_count, matched_term_count) {
        (_, 0) => 1,
        (2, 1) => 3,
        (2, _) => 8,
        (3, 1) => 2,
        (3, 2) => 6,
        (3, _) => 8,
        (_, 1) => 1,
        (_, 2) => 4,
        _ => 8,
    }
}

fn changed_paths_completeness_label(before_filter: usize, after_filter: usize) -> String {
    if before_filter == after_filter {
        "full for current scope".to_string()
    } else {
        format!("full for filtered scope ({after_filter} of {before_filter} path(s) shown)")
    }
}

fn what_changed_scope_summary(input: &WhatChangedInput, mode: &WhatChangedMode) -> String {
    let mut parts = Vec::new();
    match mode {
        WhatChangedMode::Timestamp(since_ts) => parts.push(format!("timestamp since `{since_ts}`")),
        WhatChangedMode::Uncommitted => parts.push("uncommitted working tree".to_string()),
        WhatChangedMode::GitRef(git_ref) => {
            parts.push(format!("git diff from `{git_ref}` to `HEAD`"))
        }
    }
    if let Some(path_prefix) = input
        .path_prefix
        .as_deref()
        .filter(|prefix| !prefix.trim().is_empty())
    {
        parts.push(format!(
            "path prefix `{}`",
            normalize_exact_path(path_prefix)
        ));
    }
    if let Some(language) = input.language.as_deref() {
        parts.push(format!("language `{language}`"));
    }
    if input.code_only.unwrap_or(false) {
        parts.push("code-only filter".to_string());
    }
    if input.include_symbol_diff.unwrap_or(false) {
        parts.push("symbol diff appended".to_string());
    }
    parts.join("; ")
}

fn what_changed_source_authority_label(mode: &WhatChangedMode) -> &'static str {
    match mode {
        WhatChangedMode::Timestamp(_) => "current index",
        WhatChangedMode::Uncommitted => "git working tree",
        WhatChangedMode::GitRef(_) => "git ref diff",
    }
}

fn what_changed_parse_state_label(
    mode: &WhatChangedMode,
    include_symbol_diff: bool,
) -> &'static str {
    match mode {
        WhatChangedMode::Timestamp(_) => "parsed",
        _ if include_symbol_diff => {
            "degraded (git path diff + lexical symbol diff — regex extraction may miss nested symbols)"
        }
        _ => "not-applicable (git path diff)",
    }
}

#[allow(clippy::too_many_arguments)]
fn render_diff_symbols_output(
    base: &str,
    target: &str,
    all_changed_files: usize,
    changed_files: &[&str],
    repo: &crate::git::GitRepo,
    compact: bool,
    summary_only: bool,
    path_prefix: Option<&str>,
    language: Option<&str>,
    code_only: bool,
) -> String {
    let mut scope_parts = vec![format!("git diff `{base}`...`{target}`")];
    if let Some(path_prefix) = path_prefix.filter(|prefix| !prefix.trim().is_empty()) {
        scope_parts.push(format!(
            "path prefix `{}`",
            normalize_exact_path(path_prefix)
        ));
    }
    if let Some(language) = language {
        scope_parts.push(format!("language `{language}`"));
    }
    if code_only {
        scope_parts.push("code-only filter".to_string());
    }
    if compact {
        scope_parts.push("compact output".to_string());
    }
    if summary_only {
        scope_parts.push("summary-only output".to_string());
    }
    let completeness = if changed_files.len() == all_changed_files {
        "full for filtered git delta".to_string()
    } else {
        format!(
            "full for filtered git delta ({} of {} changed file(s) shown)",
            changed_files.len(),
            all_changed_files
        )
    };
    let envelope = search_format::format_search_envelope(
        "exact (git ref diff)",
        "git ref diff",
        "high (tree-sitter AST extraction for supported languages, regex fallback for others)",
        &completeness,
        &scope_parts.join("; "),
        &search_paths_evidence(changed_files.iter().copied()),
    );
    let output =
        format::diff_symbols_result_view(base, target, changed_files, repo, compact, summary_only);
    format!("{envelope}\n\n{output}")
}

/// Strip vendor / personal-tooling paths from a `TextSearchResult` per the
/// caller's `include_vendor` / `include_personal_tooling` flags. Suppressed
/// match counts feed `suppressed_by_noise`, which renders into the existing
/// "N noise-filtered match(es) suppressed" envelope footer.
fn apply_path_predicate_filter(
    result: &mut Result<search::TextSearchResult, search::TextSearchError>,
    include_vendor: bool,
    include_personal_tooling: bool,
) {
    if include_vendor && include_personal_tooling {
        return;
    }
    if let Ok(r) = result {
        let mut filtered: usize = 0;
        r.files.retain(|file| {
            if !include_vendor && crate::live_index::query::is_vendor_path(&file.path) {
                filtered += file.matches.len();
                return false;
            }
            if !include_personal_tooling
                && crate::live_index::query::is_personal_tooling_path(&file.path)
            {
                filtered += file.matches.len();
                return false;
            }
            true
        });
        r.suppressed_by_noise = r.suppressed_by_noise.saturating_add(filtered);
    }
}

#[allow(clippy::too_many_arguments)]
fn render_search_text_output(
    server: &SymForgeServer,
    result: Result<search::TextSearchResult, search::TextSearchError>,
    query: Option<&str>,
    structural: bool,
    group_by: Option<&str>,
    terms: Option<&[String]>,
    options: &search::TextSearchOptions,
    is_regex: bool,
    auto_detected_regex: bool,
    auto_corrected_regex: bool,
) -> String {
    let envelope = match &result {
        Ok(result) if !result.files.is_empty() => {
            let guard = server.index.read();
            Some(search_format::format_search_envelope(
                &search_text_match_type_label(
                    structural,
                    is_regex,
                    terms,
                    auto_detected_regex,
                    auto_corrected_regex,
                    options.ranked,
                ),
                "current index",
                search_parse_state_for_paths(
                    &guard,
                    result.files.iter().map(|file| file.path.as_str()),
                ),
                &search_completeness_label(result.overflow_count, result.suppressed_by_noise),
                &search_scope_summary(
                    &options.path_scope,
                    options.language_filter.as_ref(),
                    &options.noise_policy,
                    options.include_personal_tooling,
                    options.glob.as_deref(),
                    options.exclude_glob.as_deref(),
                    options.ranked,
                ),
                &search_text_evidence(result),
            ))
        }
        _ => None,
    };
    let matching_untracked_paths = match &result {
        Ok(result) if result.files.is_empty() && result.suppressed_by_noise == 0 => {
            matching_untracked_paths_for_search_text(
                server, query, terms, structural, is_regex, options,
            )
        }
        _ => Vec::new(),
    };
    let confidence = if auto_corrected_regex {
        0.75f32
    } else if is_regex && auto_detected_regex {
        0.80
    } else if is_regex {
        0.85
    } else if options.ranked {
        0.80
    } else {
        0.95
    };
    let output = format::search_text_result_view(result, group_by, terms, Some(confidence));
    let mut rendered = match envelope {
        Some(envelope) => format!("{envelope}\n\n{output}"),
        None => output,
    };
    append_untracked_file_diagnostic(&mut rendered, &matching_untracked_paths);
    rendered
}

fn resolve_text_search_enclosing_symbols(
    index: &LiveIndex,
    result: &mut Result<search::TextSearchResult, search::TextSearchError>,
) {
    let Ok(result) = result else {
        return;
    };

    for file_matches in &mut result.files {
        let Some(file) = index.get_file(&file_matches.path) else {
            continue;
        };

        for line_match in &mut file_matches.matches {
            let zero_based_line = line_match.line_number.saturating_sub(1) as u32;
            line_match.enclosing_symbol =
                crate::domain::find_enclosing_symbol(&file.symbols, zero_based_line)
                    .and_then(|idx| file.symbols.get(idx as usize))
                    .map(|symbol| search::EnclosingMatchSymbol {
                        name: symbol.name.clone(),
                        kind: symbol.kind.to_string(),
                        line_range: symbol.line_range,
                    });
        }
    }
}

fn sidecar_state_for_server(server: &SymForgeServer) -> SidecarState {
    SidecarState {
        index: Arc::clone(&server.index),
        token_stats: server.token_stats.clone().unwrap_or_else(TokenStats::new),
        repo_root: server.capture_repo_root(),
        symbol_cache: Arc::new(RwLock::new(HashMap::new())),
    }
}

fn sidecar_status_for_server(server: &SymForgeServer) -> crate::sidecar::port_file::SidecarStatus {
    let bind_host =
        std::env::var("SYMFORGE_SIDECAR_BIND").unwrap_or_else(|_| "127.0.0.1".to_string());
    if let Some(repo_root) = server.capture_repo_root() {
        let symforge_dir = repo_root.join(crate::sidecar::port_file::DIR_NAME);
        return crate::sidecar::port_file::read_sidecar_status_at(&symforge_dir, &bind_host);
    }
    crate::sidecar::port_file::read_sidecar_status(&bind_host)
}

/// Best-effort PATH-shadow report for the binary serving this runtime.
///
/// Uses `std::env::current_exe()` as "our binary" and asks `path_shadow` whether
/// a bare `symforge` on `$PATH` would run a different install. Returns `None`
/// when we win, when there is no shadow, or when `current_exe()` is unresolvable.
fn current_exe_shadow_report() -> Option<crate::path_shadow::ShadowReport> {
    let our_binary = std::env::current_exe().ok()?;
    crate::path_shadow::detect_shadow(&our_binary)
}

/// Extract the `version=<token>` value emitted on the runtime-status line of a
/// `health` / `health_compact` report (see `format::format_runtime_status`).
///
/// The runtime line is ` | `-delimited, e.g.
/// `Runtime: mode=... | version=7.18.1 | project_root=...`, and round-2 guarantees
/// a `version=` token is present in EVERY mode (regression:
/// `test_runtime_status_includes_binary_version_in_every_mode`). Returns the raw
/// token (e.g. `"7.18.1"`) or `None` when no such token is present.
fn extract_runtime_version(health_output: &str) -> Option<&str> {
    let after = health_output.split("version=").nth(1)?;
    // The version token runs until the next field separator, whitespace, or EOL.
    let token = after
        .split(['|', ' ', '\n', '\r', '\t'])
        .next()
        .map(str::trim)
        .filter(|s| !s.is_empty())?;
    Some(token)
}

/// LOUD operations guard (SF-001 / SF-009 root-cause class): when the MCP
/// front-end is proxying to a daemon, the daemon's reported binary version can be
/// OLDER than the front-end's own build — a stale/shadowed daemon serving
/// pre-fix behavior, which is exactly what masked the SF-001 fix during the audit.
///
/// Compares the daemon version parsed out of the proxied health report against the
/// front-end's `frontend_version` (its `env!("CARGO_PKG_VERSION")`). Returns a
/// warning string ONLY when the daemon version is strictly OLDER than the
/// front-end. Fail-safe by construction: a missing/unparseable token or an
/// equal/newer daemon yields `None` (no warning).
///
/// Product decision: WARN, do NOT auto-restart. Auto-restarting risks killing a
/// daemon mid-session for other clients; a loud, idempotent health warning is the
/// safe, sufficient remedy and tells the operator to reconnect/restart.
fn daemon_staleness_warning(proxied_health: &str, frontend_version: &str) -> Option<String> {
    let daemon_version = extract_runtime_version(proxied_health)?;

    // `is_newer_version(latest, current)` is true iff `latest` > `current`. The
    // daemon is stale exactly when the front-end is newer than the daemon.
    if !crate::cli::version::is_newer_version(frontend_version, daemon_version) {
        return None;
    }

    Some(format!(
        "\n── \u{26a0} Daemon serving stale code ──\n\
         The MCP daemon answering this session reports version {daemon_version}, but this\n\
         front-end is version {frontend_version} (newer). The daemon is serving an OLDER\n\
         binary, so tool behavior may be stale (this is the operational root cause behind\n\
         masked fixes such as SF-001). Reconnect your MCP client (e.g. /mcp) or restart the\n\
         daemon so it picks up the newer binary; this is a WARNING only — no automatic\n\
         restart is performed."
    ))
}

/// Append `daemon_staleness_warning` to a proxied health report when the daemon is
/// older than the front-end; otherwise return the report unchanged.
fn append_daemon_staleness_warning(mut proxied_health: String, frontend_version: &str) -> String {
    if let Some(warning) = daemon_staleness_warning(&proxied_health, frontend_version) {
        proxied_health.push_str(&warning);
    }
    proxied_health
}

fn symbol_candidate_paths(index: &crate::live_index::store::LiveIndex, name: &str) -> Vec<String> {
    let mut candidates: Vec<String> = index
        .all_files()
        .filter_map(|(path, file)| {
            if file.symbols.iter().any(|s| s.name == name) {
                Some(path.to_string())
            } else {
                None
            }
        })
        .collect();
    candidates.sort();
    candidates.dedup();
    candidates
}

fn format_ambiguous_symbol_context(name: &str, candidates: &[String]) -> String {
    let mut output = format!(
        "Ambiguous symbol selector: {} symbols named \"{}\" found.\nPass `path` or `file` to disambiguate.\nCandidate paths:",
        candidates.len(),
        name
    );
    for path in candidates {
        output.push_str(&format!("\n- {path}"));
    }
    output
}

fn render_symbol_context_header(
    file: &IndexedFile,
    name: &str,
    symbol_kind: Option<&str>,
    symbol_line: Option<u32>,
    verbosity: &str,
    max_tokens: Option<u64>,
) -> Option<String> {
    use crate::live_index::query::{SymbolSelectorMatch, resolve_symbol_selector};

    match resolve_symbol_selector(file, name, symbol_kind, symbol_line) {
        SymbolSelectorMatch::Selected(_, sym) => {
            let body = std::str::from_utf8(
                &file.content[sym.byte_range.0 as usize..sym.byte_range.1 as usize],
            )
            .ok()?;
            let (rendered, actual_level) = format::resolve_verbosity(
                body,
                Some(verbosity),
                max_tokens,
                0.7, // standalone symbol — allocate 70% of budget to body
            );
            let mut output = format!(
                "{}\n[{}, {}:{}-{}]",
                rendered,
                sym.kind,
                file.relative_path,
                sym.line_range.0 + 1,
                sym.line_range.1 + 1
            );
            if actual_level != "full" && actual_level != verbosity {
                output.push_str(&format!(
                    "\n[adaptive verbosity: {} — fits within {} token budget]",
                    actual_level,
                    max_tokens.unwrap_or(0)
                ));
            }
            Some(output)
        }
        SymbolSelectorMatch::NotFound | SymbolSelectorMatch::Ambiguous(_) => None,
    }
}

enum CapturedGetSymbolsEntry {
    SymbolLookup {
        file: Arc<IndexedFile>,
        name: String,
        kind: Option<String>,
        symbol_line: Option<u32>,
    },
    CodeSlice {
        file: Arc<IndexedFile>,
        start_byte: usize,
        end_byte: Option<usize>,
    },
    FileNotFound {
        path: String,
    },
}

// ─── Tool handlers ───────────────────────────────────────────────────────────

/// Loading guard helper — returns `Some(message)` when index is NOT ready.
///
/// Call at the top of every handler except `health`. If `Some` is returned,
/// return that string immediately. Otherwise continue with the handler body.
macro_rules! loading_guard {
    ($guard:expr) => {
        match $guard.index_state() {
            IndexState::Ready => {}
            IndexState::Empty => return format::empty_guard_message(),
            IndexState::Loading => return format::loading_guard_message(),
            IndexState::CircuitBreakerTripped { summary } => {
                return format!("Index degraded: {summary}");
            }
        }
    };
}

fn loading_guard_message_from_published(
    published: &crate::live_index::PublishedIndexState,
) -> Option<String> {
    match published.status {
        crate::live_index::PublishedIndexStatus::Ready => None,
        crate::live_index::PublishedIndexStatus::Empty => Some(format::empty_guard_message()),
        crate::live_index::PublishedIndexStatus::Loading => Some(format::loading_guard_message()),
        crate::live_index::PublishedIndexStatus::Degraded => Some(format!(
            "Index degraded: {}",
            published
                .degraded_summary
                .as_deref()
                .unwrap_or("circuit breaker tripped")
        )),
    }
}

#[tool_router(router = core_tool_router, vis = "pub(crate)")]
impl SymForgeServer {
    /// Look up symbol(s) by file path and name. Single mode: provide path + name for one symbol.
    /// Batch mode: provide targets[] array for multiple symbols or code slices in one call.
    /// When multiple symbols share the same name (e.g. `handle` in different impl blocks),
    /// pass symbol_line (1-based) to disambiguate; omitting it auto-selects by kind tier or
    /// returns an Ambiguous error listing candidate lines.
    /// NOT for finding symbols by name (use search_symbols first).
    /// NOT for understanding who calls it (use find_references or get_symbol_context).
    /// NOT for edit preparation (use get_symbol_context with bundle=true).
    #[tool(
        name = "get_symbol",
        description = "Prefer this over reading an entire file when you already know the symbol or have narrowed to one file. Retrieves the complete source code of a specific symbol with doc comments. Single mode: provide path + name. Batch mode: provide targets[] array for 2+ symbols or code slices in one call (each target is file path + symbol name or byte range). When multiple symbols share the same name, pass symbol_line (1-based) to disambiguate; auto-selects by kind tier when possible. Use search_symbols first if you only know part of the name. NOT for understanding callers (use find_references or get_symbol_context). NOT for edit preparation (use get_symbol_context with bundle=true).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn get_symbol_tool(
        &self,
        params: Parameters<GetSymbolInput>,
    ) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
        let started = Instant::now();
        let output = self.get_symbol(params).await;
        let outcome_class = classify_get_symbol_output(&output);
        self.record_tool_completion("get_symbol", &output, started.elapsed(), outcome_class);
        statused_tool_result(output, outcome_class)
    }

    pub(crate) async fn get_symbol(&self, params: Parameters<GetSymbolInput>) -> String {
        if let Some(result) = self.proxy_tool_call("get_symbol", &params.0).await {
            return result;
        }

        // Batch mode: targets[] provided
        if let Some(ref targets) = params.0.targets {
            if targets.is_empty() {
                return "Error: targets array is empty.".to_string();
            }
            let captured = {
                let guard = self.index.read();
                loading_guard!(guard);

                targets
                    .iter()
                    .map(|target| match target.name.as_deref() {
                        Some(name) => match guard.capture_shared_file(&target.path) {
                            Some(file) => CapturedGetSymbolsEntry::SymbolLookup {
                                file,
                                name: name.to_string(),
                                kind: target.kind.clone(),
                                symbol_line: target.symbol_line,
                            },
                            None => CapturedGetSymbolsEntry::FileNotFound {
                                path: target.path.clone(),
                            },
                        },
                        None => match guard.capture_shared_file(&target.path) {
                            None => CapturedGetSymbolsEntry::FileNotFound {
                                path: target.path.clone(),
                            },
                            Some(file) => CapturedGetSymbolsEntry::CodeSlice {
                                file,
                                start_byte: target.start_byte.unwrap_or(0) as usize,
                                end_byte: target.end_byte.map(|e| e as usize),
                            },
                        },
                    })
                    .collect::<Vec<_>>()
            };

            // Frecency bump — batch commitment path. Collect the file path
            // for every resolved entry (skip FileNotFound since we did not
            // actually touch them), dedup, and emit one bump call at the end
            // per Implementation Notes §"Bump dedup per tool invocation".
            // Collection policy is resolved inside bump_frecency.
            let bump_paths: Vec<PathBuf> = captured
                .iter()
                .filter_map(|entry| match entry {
                    CapturedGetSymbolsEntry::SymbolLookup { file, .. }
                    | CapturedGetSymbolsEntry::CodeSlice { file, .. } => {
                        Some(PathBuf::from(&file.relative_path))
                    }
                    CapturedGetSymbolsEntry::FileNotFound { .. } => None,
                })
                .collect::<std::collections::HashSet<_>>()
                .into_iter()
                .collect();
            let has_symbol_lookup = captured
                .iter()
                .any(|entry| matches!(entry, CapturedGetSymbolsEntry::SymbolLookup { .. }));
            let mut output = captured
                .into_iter()
                .map(|entry| match entry {
                    CapturedGetSymbolsEntry::SymbolLookup {
                        file,
                        name,
                        kind,
                        symbol_line,
                    } => format::symbol_detail_from_indexed_file(
                        file.as_ref(),
                        &name,
                        kind.as_deref(),
                        symbol_line,
                    ),
                    CapturedGetSymbolsEntry::CodeSlice {
                        file,
                        start_byte,
                        end_byte,
                    } => format::code_slice_from_indexed_file(file.as_ref(), start_byte, end_byte),
                    CapturedGetSymbolsEntry::FileNotFound { path } => format::not_found_file(&path),
                })
                .collect::<Vec<_>>()
                .join("\n---\n");
            // Append the next-step hint ONCE for the whole batch — an agent
            // learns it after the first occurrence, so repeating it per entry is
            // pure token overhead.
            if has_symbol_lookup {
                output.push_str(&format::compact_next_step_hint(&[
                    "get_symbol_context (callers/callees/types)",
                    "find_references (usages)",
                    "edit_within_symbol / replace_symbol_body (edits)",
                ]));
            }
            self.record_tool_savings_named(
                "get_symbol",
                (output.len() * 5 / 4) as u64,
                (output.len() / 4) as u64,
            );
            self.bump_frecency(&bump_paths);
            return output;
        }

        // Single mode: path + name
        let file = {
            let guard = self.index.read();
            loading_guard!(guard);
            guard.capture_shared_file(&params.0.path)
        };

        // Estimate mode: return token cost without full content
        if params.0.estimate == Some(true) {
            if let Some(ref file) = file {
                let file_tokens = file.content.len() / 4;
                let sym_tokens = file
                    .symbols
                    .iter()
                    .find(|s| s.name == params.0.name)
                    .map(|s| (s.byte_range.1 - s.byte_range.0) as usize / 4)
                    .unwrap_or(0);
                return format!(
                    "Estimate for get_symbol(path=\"{}\", name=\"{}\"):\n  Symbol body: ~{} tokens\n  Raw file: ~{} tokens",
                    params.0.path, params.0.name, sym_tokens, file_tokens
                );
            } else {
                return format::not_found_file(&params.0.path);
            }
        }

        match file {
            Some(file) => {
                let body = format::symbol_detail_from_indexed_file(
                    file.as_ref(),
                    &params.0.name,
                    params.0.kind.as_deref(),
                    params.0.symbol_line,
                );
                let output = format!(
                    "{body}{}",
                    format::compact_next_step_hint(&[
                        "get_symbol_context (callers/callees/types)",
                        "find_references (usages)",
                        "edit_within_symbol / replace_symbol_body (edits)",
                    ])
                );
                self.record_tool_savings_named(
                    "get_symbol",
                    (output.len() * 5 / 4) as u64,
                    (output.len() / 4) as u64,
                );
                self.session_context.record_symbol(
                    &params.0.path,
                    &params.0.name,
                    (output.len() / 4) as u32,
                );
                // Frecency bump — commitment tool, single-symbol happy path.
                // Collection policy is resolved inside bump_frecency. See wiki
                // `[[SymForge Frecency-Weighted File Ranking]]` §"Bump hooks".
                self.bump_frecency(&[PathBuf::from(&params.0.path)]);
                output
            }
            None => {
                let suggestions = {
                    let guard = self.index.read();
                    suggest_similar_files(&guard, &params.0.path)
                };
                format::not_found_file_with_suggestions(&params.0.path, &suggestions)
            }
        }
    }

    /// Start here. Project overview with adjustable detail level. Modes: (1) default/compact: ~500 token
    /// overview with file count, languages, and directory tree. (2) detail='full': complete symbol outline
    /// of every file — warning: large output. (3) detail='tree': browsable file tree with per-file symbol
    /// counts and language tags — supports path and depth params for subtree browsing.
    /// NOT for file details (use get_file_context) or finding symbols (use search_symbols).
    #[tool(
        description = "Start here for project orientation and the first code-reading pass before any broad raw file read. Returns a structural overview of the repository. Modes: (1) default/compact: ~500 token overview with file count, languages, and directory tree. (2) detail='full': complete symbol outline of every file — warning: large output. (3) detail='tree': browsable file tree with per-file symbol counts and language tags — supports path and depth params for subtree browsing. NOT for file details (use get_file_context) or finding symbols (use search_symbols).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn get_repo_map(&self, params: Parameters<GetRepoMapInput>) -> String {
        if let Some(result) = self.proxy_tool_call("get_repo_map", &params.0).await {
            return result;
        }
        // Single loading guard covering EVERY local path (estimate, compact, and
        // the `full`/`tree` outline branches), consistent with how
        // `search_symbols`/`search_text` guard at the top. Without this, an
        // outline-only call on an empty/unloaded index could fall through to a
        // bare empty map instead of the "index still loading / run index_folder"
        // guidance. The per-branch guards below remain as harmless redundancy.
        {
            let guard = self.index.read();
            loading_guard!(guard);
        }
        if params.0.estimate == Some(true) {
            let guard = self.index.read();
            loading_guard!(guard);
            let file_count = guard.file_count();
            drop(guard);
            let detail = params.0.detail.as_deref().unwrap_or("compact");
            let est = match detail {
                "full" => {
                    let max = params.0.max_files.unwrap_or(200) as usize;
                    max.min(file_count) * 30
                }
                "tree" => file_count * 10,
                _ => 500,
            };
            return format!(
                "Estimate for get_repo_map: ~{} tokens (detail={}, files={})",
                est, detail, file_count
            );
        }
        let detail = if let Some(ref d) = params.0.detail {
            d.as_str()
        } else if let Some(max_tokens) = params.0.max_tokens {
            // Adaptive detail: auto-select "full" when the estimated output fits
            // within the token budget, otherwise fall back to "compact".
            let guard = self.index.read();
            let file_count = guard.file_count();
            drop(guard);
            let max_files = params.0.max_files.unwrap_or(200) as usize;
            let full_estimate = max_files.min(file_count) * 30;
            if (full_estimate as u64) <= max_tokens {
                "full"
            } else {
                "compact"
            }
        } else {
            "compact"
        };
        let output = match detail {
            "full" => {
                let published = self.index.published_state();
                if let Some(message) = loading_guard_message_from_published(&published) {
                    return message;
                }
                let view = self.index.published_repo_outline();
                if let Some(ref path) = params.0.path {
                    // Filter outline to files under the given path prefix
                    let filtered_files: Vec<_> = view
                        .files
                        .iter()
                        .filter(|fo| fo.relative_path.starts_with(path.as_str()))
                        .cloned()
                        .collect();
                    let filtered_symbols: usize =
                        filtered_files.iter().map(|f| f.symbol_count).sum();
                    let filtered_view = crate::live_index::query::RepoOutlineView {
                        total_files: filtered_files.len(),
                        total_symbols: filtered_symbols,
                        files: filtered_files,
                    };
                    let max_files = params.0.max_files.unwrap_or(200) as usize;
                    if filtered_view.files.len() > max_files {
                        let remaining = filtered_view.files.len() - max_files;
                        let truncated_files: Vec<_> = filtered_view
                            .files
                            .iter()
                            .take(max_files)
                            .cloned()
                            .collect();
                        let truncated_view = crate::live_index::query::RepoOutlineView {
                            total_files: filtered_view.total_files,
                            total_symbols: filtered_view.total_symbols,
                            files: truncated_files,
                        };
                        let mut output =
                            format::repo_outline_view(&truncated_view, &self.project_name);
                        output.push_str(&format!(
                            "\n\n... and {} more files (increase max_files= to see more)",
                            remaining
                        ));
                        output
                    } else {
                        format::repo_outline_view(&filtered_view, &self.project_name)
                    }
                } else {
                    let max_files = params.0.max_files.unwrap_or(200) as usize;
                    if view.files.len() > max_files {
                        let truncated_files: Vec<_> =
                            view.files.iter().take(max_files).cloned().collect();
                        let remaining = view.files.len() - max_files;
                        let truncated_view = crate::live_index::query::RepoOutlineView {
                            total_files: view.total_files,
                            total_symbols: view.total_symbols,
                            files: truncated_files,
                        };
                        let mut output =
                            format::repo_outline_view(&truncated_view, &self.project_name);
                        output.push_str(&format!(
                            "\n\n... and {} more files (use path= to scope or increase max_files=)",
                            remaining
                        ));
                        output
                    } else {
                        format::repo_outline_view(&view, &self.project_name)
                    }
                }
            }
            "tree" => {
                let published = self.index.published_state();
                if let Some(message) = loading_guard_message_from_published(&published) {
                    return message;
                }
                let path = params.0.path.as_deref().unwrap_or("");
                let depth = params.0.depth.unwrap_or(2).min(5);
                let view = self.index.published_repo_outline();
                let guard = self.index.read();
                let skipped = guard.skipped_files().to_vec();
                drop(guard);
                format::file_tree_view_with_skipped(&view.files, &skipped, path, depth)
            }
            _ => {
                let guard = self.index.read();
                loading_guard!(guard);
                drop(guard);

                let state = sidecar_state_for_server(self);
                match repo_map_text(&state) {
                    Ok(result) => {
                        let hint = format::compact_next_step_hint(&[
                            "get_file_context (open one file)",
                            "search_symbols (find by name)",
                            "search_text (find text/patterns)",
                            "diff_symbols (review changes)",
                        ]);
                        format!("{result}{hint}")
                    }
                    Err(StatusCode::NOT_FOUND) => "Repository map unavailable.".to_string(),
                    Err(StatusCode::INTERNAL_SERVER_ERROR) => {
                        "Repository map failed: internal error.".to_string()
                    }
                    Err(other) => format!("Repository map failed: HTTP {}", other.as_u16()),
                }
            }
        };
        self.session_context.record_summary_output(
            "get_repo_map",
            (output.len() / 4).min(u32::MAX as usize) as u32,
        );
        format::enforce_token_budget(output, params.0.max_tokens)
    }

    /// Rich file summary: symbol outline, imports, consumers, references, and git activity.
    /// Use sections=['outline'] for a compact symbol outline only.
    /// Use sections=['outline','imports'] to limit output. Best tool for understanding a file before editing.
    /// Much smaller than reading the raw file.
    /// NOT for reading actual source code (use get_file_content or get_symbol).
    #[tool(
        description = "Prefer this over raw file reads for code understanding — it usually saves 70-95% of tokens by returning the file's symbol outline and structure first. Rich file summary: symbol outline, imports, consumers, references, and git activity. Use sections=['outline'] for a fast first pass, or sections=['outline','imports'] to stay compact. Best tool for understanding a file before editing or before deciding whether you need exact raw text. NOT for reading actual source code (use get_file_content or get_symbol).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn get_file_context(&self, params: Parameters<GetFileContextInput>) -> String {
        if let Some(result) = self.proxy_tool_call("get_file_context", &params.0).await {
            return result;
        }
        if params.0.estimate == Some(true) {
            let guard = self.index.read();
            loading_guard!(guard);
            if let Some(file) = guard.capture_shared_file(&params.0.path) {
                let file_tokens = file.content.len() / 4;
                let outline_tokens = file.content.len() / 50; // ~5-10% of raw
                return format!(
                    "Estimate for get_file_context(path=\"{}\"):\n  Outline: ~{} tokens\n  Raw file: ~{} tokens\n  Savings: ~{}%",
                    params.0.path,
                    outline_tokens,
                    file_tokens,
                    (outline_tokens * 100)
                        .checked_div(file_tokens)
                        .map(|pct| 100usize.saturating_sub(pct))
                        .unwrap_or(0)
                );
            } else {
                return format::not_found_file(&params.0.path);
            }
        }
        const LARGE_FILE_CONTEXT_DEFAULT_TOKENS: u64 = 2000;
        let (raw_chars, symbol_count, reference_count) = {
            let guard = self.index.read();
            loading_guard!(guard);
            let stats = guard
                .capture_shared_file(&params.0.path)
                .map(|f| (f.content.len(), f.symbols.len(), f.references.len()))
                .unwrap_or((0, 0, 0));
            drop(guard);
            stats
        };
        let large_default_summary = params.0.sections.is_none()
            && (raw_chars > 200_000 || symbol_count > 250 || reference_count > 800);

        let state = sidecar_state_for_server(self);
        let include_tests = include_tests_from_sections(params.0.sections.as_ref());
        let sections = if large_default_summary {
            Some(vec!["outline".to_string(), "imports".to_string()])
        } else {
            visible_sections(&params.0.sections)
        };
        let context_max_tokens = params
            .0
            .max_tokens
            .or_else(|| large_default_summary.then_some(LARGE_FILE_CONTEXT_DEFAULT_TOKENS));
        let outline = OutlineParams {
            path: params.0.path.clone(),
            max_tokens: context_max_tokens,
            sections: sections.clone(),
        };
        match outline_tool_text(&state, &outline) {
            Ok(result) => {
                let mut result =
                    format::collapse_large_test_modules(result, include_tests, sections.as_deref());
                if large_default_summary {
                    let note = format!(
                        "Large file summary: outline+imports only ({} symbols, {} references); omitted consumers/references/git; request sections=[\"consumers\"], sections=[\"references\"], sections=[\"git\"] or higher max_tokens.",
                        symbol_count, reference_count
                    );
                    result = if let Some((envelope, rest)) = result.split_once("\n\n") {
                        format!("{envelope}\n\n{note}\n\n{rest}")
                    } else {
                        format!("{note}\n\n{result}")
                    };
                }
                let hint = format::compact_next_step_hint(&[
                    "get_symbol (body)",
                    "find_references (callers/imports)",
                    "search_text (string/pattern usage)",
                    "get_file_content (exact raw text)",
                ]);
                let body = format!("{result}{hint}");
                let saved = raw_chars.saturating_sub(body.len());
                let footer = format::compact_savings_footer(body.len(), raw_chars);
                self.record_read_savings((saved / 4) as u64);
                let output =
                    format::enforce_token_budget(format!("{body}{footer}"), context_max_tokens);
                self.session_context
                    .record_listed_file(&params.0.path, (output.len() / 4) as u32);
                // Frecency bump — commitment tool. Reached only on the happy
                // path after a successful outline fetch; collection policy is
                // resolved inside bump_frecency. See wiki `[[SymForge Frecency-Weighted
                // File Ranking]]` §"Bump hooks".
                self.bump_frecency(&[PathBuf::from(&params.0.path)]);
                output
            }
            Err(StatusCode::NOT_FOUND) => format::not_found_file(&params.0.path),
            Err(StatusCode::INTERNAL_SERVER_ERROR) => {
                "File context failed: internal error.".to_string()
            }
            Err(other) => format!("File context failed: HTTP {}", other.as_u16()),
        }
    }

    /// Symbol usage analysis with three modes. (1) Default: definition + callers grouped by file + callees + type usages.
    /// (2) bundle=true: symbol body + full definitions of all referenced custom types, resolved recursively — best
    /// for edit preparation (requires path). (3) sections=[...]: comprehensive trace analysis — definition, callers,
    /// callees, implementations, type dependencies, git activity. Valid sections: 'dependents', 'siblings',
    /// 'implementations', 'git' (empty array = all). Set verbosity='signature' for ~80% smaller output.
    /// NOT for just the symbol body (use get_symbol).
    #[tool(
        name = "get_symbol_context",
        description = "Symbol usage analysis with three modes. (1) Default: definition + callers grouped by file + callees + type usages. (2) bundle=true: symbol body + full definitions of all referenced custom types, resolved recursively — best for edit preparation (requires path). (3) sections=[...]: comprehensive trace analysis — definition, callers, callees, implementations, type dependencies, git activity. Valid sections: 'dependents', 'siblings', 'implementations', 'git' (empty array = all). Set verbosity='signature' for ~80% smaller output. NOT for just the symbol body (use get_symbol).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn get_symbol_context_tool(
        &self,
        params: Parameters<GetSymbolContextInput>,
    ) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
        let started = Instant::now();
        let output = self.get_symbol_context(params).await;
        let outcome_class = classify_get_symbol_context_output(&output);
        self.record_tool_completion(
            "get_symbol_context",
            &output,
            started.elapsed(),
            outcome_class,
        );
        statused_tool_result(output, outcome_class)
    }

    pub(crate) async fn get_symbol_context(
        &self,
        params: Parameters<GetSymbolContextInput>,
    ) -> String {
        if let Some(result) = self.proxy_tool_call("get_symbol_context", &params.0).await {
            return result;
        }
        if let Some(path) = params.0.path.as_deref().or(params.0.file.as_deref()) {
            let repo_root = self.capture_repo_root();
            let degraded = {
                let guard = self.index.read();
                loading_guard!(guard);
                admission_tier_degradation_for_path(
                    &guard,
                    repo_root.as_deref(),
                    "get_symbol_context",
                    path,
                    &params.0.name,
                )
            };
            if let Some(result) = degraded {
                return result;
            }
        }
        if params.0.estimate == Some(true) {
            let path = params.0.path.as_deref().unwrap_or("");
            let guard = self.index.read();
            loading_guard!(guard);
            if let Some(file) = guard.capture_shared_file(path) {
                let sym_tokens = file
                    .symbols
                    .iter()
                    .find(|s| s.name == params.0.name)
                    .map(|s| (s.byte_range.1 - s.byte_range.0) as usize / 4)
                    .unwrap_or(0);
                let ref_count = file
                    .references
                    .iter()
                    .filter(|r| r.name == params.0.name)
                    .count();
                let bundle_est = sym_tokens * 3; // rough: body + deps
                return format!(
                    "Estimate for get_symbol_context(name=\"{}\"):\n  Symbol body: ~{} tokens\n  Callers: ~{} tokens\n  Bundle: ~{} tokens",
                    params.0.name,
                    sym_tokens,
                    ref_count * 15 + 50,
                    bundle_est
                );
            } else {
                return format!("Symbol '{}' not found in '{}'", params.0.name, path);
            }
        }
        if params.0.bundle.unwrap_or(false) {
            let path = match params.0.path.as_deref() {
                Some(p) => p.to_string(),
                None => return "Error: bundle=true requires the 'path' parameter.".to_string(),
            };
            let refreshed =
                freshen_exact_path_for_targeted_retrieval(self, &search::PathScope::exact(&path));
            let (view, raw_chars, parse_state) = {
                let guard = self.index.read();
                loading_guard!(guard);
                let raw = guard
                    .capture_shared_file(&path)
                    .map(|f| f.content.len())
                    .unwrap_or(0);
                let parse_state = guard
                    .capture_shared_file(&path)
                    .map(|f| parse_state_for_file(f.as_ref()))
                    .unwrap_or("parsed");
                let v = guard.capture_context_bundle_view(
                    &path,
                    &params.0.name,
                    params.0.symbol_kind.as_deref(),
                    params.0.symbol_line,
                );
                (v, raw, parse_state)
            };
            let verbosity = params.0.verbosity.as_deref().unwrap_or("full");
            let result = format::context_bundle_result_view_with_max_tokens(
                &view,
                verbosity,
                params.0.max_tokens,
            );
            let result = if let crate::live_index::ContextBundleView::Found(found) = &view {
                let scope = match params.0.max_tokens {
                    Some(max_tokens) => format!(
                        "path `{}`; bundle mode; max_tokens={max_tokens}",
                        found.file_path
                    ),
                    None => format!("path `{}`; bundle mode", found.file_path),
                };
                let envelope = search_format::format_search_envelope(
                    if params.0.symbol_line.is_some() {
                        "exact"
                    } else {
                        "constrained"
                    },
                    context_source_authority_label(refreshed),
                    parse_state,
                    &context_bundle_completeness_label(found.as_ref(), &result),
                    &scope,
                    &format!(
                        "symbol anchor `{}:{}`",
                        found.file_path,
                        found.line_range.0.saturating_add(1)
                    ),
                );
                format!("{envelope}\n\n{result}")
            } else {
                result
            };
            let saved = raw_chars.saturating_sub(result.len());
            let footer = format::compact_savings_footer(result.len(), raw_chars);
            self.record_read_savings((saved / 4) as u64);
            let output = format!("{result}{footer}");
            self.session_context
                .record_symbol(&path, &params.0.name, (output.len() / 4) as u32);
            // Frecency bump — commitment tool, bundle-mode happy path.
            // Collection policy is resolved inside bump_frecency.
            self.bump_frecency(&[PathBuf::from(&path)]);
            return output;
        }

        // Trace mode: comprehensive symbol analysis (absorbed from trace_symbol)
        if params.0.sections.is_some() {
            let path = match params.0.path.as_deref() {
                Some(p) => p.to_string(),
                None => return "Error: sections requires the 'path' parameter.".to_string(),
            };

            // Convert sections: Some(empty vec) = all sections (like trace_symbol's None)
            let include_tests = include_tests_from_sections(params.0.sections.as_ref());
            let sections_param = visible_sections(&params.0.sections)
                .and_then(|s| if s.is_empty() { None } else { Some(s) });
            let sections_param = encode_include_tests_marker(sections_param, include_tests);

            let trace_input = TraceSymbolInput {
                path: path.clone(),
                name: params.0.name.clone(),
                kind: params.0.symbol_kind.clone(),
                symbol_line: params.0.symbol_line,
                sections: sections_param,
                verbosity: params.0.verbosity.clone(),
                max_tokens: params.0.max_tokens,
                estimate: None,
            };
            let trace_result = self.trace_symbol(Parameters(trace_input)).await;
            self.session_context.record_symbol(
                &path,
                &params.0.name,
                (trace_result.len() / 4) as u32,
            );
            // Frecency bump — commitment tool, trace-mode happy path.
            // Collection policy is resolved inside bump_frecency.
            self.bump_frecency(&[PathBuf::from(&path)]);
            return format::enforce_token_budget(trace_result, params.0.max_tokens);
        }

        // Default: symbol context mode
        if let Some(result) = self.proxy_tool_call("get_symbol_context", &params.0).await {
            return result;
        }
        let file_path_hint = params.0.path.as_deref().or(params.0.file.as_deref());
        // Auto-resolve path from index when not provided
        let resolved_path: Option<String>;
        let file_path_hint = if file_path_hint.is_some() {
            resolved_path = None;
            file_path_hint
        } else {
            let guard = self.index.read();
            let candidates = symbol_candidate_paths(&guard, &params.0.name);
            drop(guard);
            if candidates.len() == 1 {
                resolved_path = Some(candidates.into_iter().next().unwrap());
                resolved_path.as_deref()
            } else if candidates.len() > 1 {
                return format_ambiguous_symbol_context(&params.0.name, &candidates);
            } else {
                resolved_path = None;
                None
            }
        };
        let verbosity = params.0.verbosity.as_deref().unwrap_or("full");
        let max_tokens = params.0.max_tokens;

        // Capture the symbol definition from the index so we can prepend it
        // (the sidecar only returns reference locations, not the definition itself).
        let (symbol_header, impl_block_tip, callees_text, raw_chars) = {
            let guard = self.index.read();
            loading_guard!(guard);

            let file = file_path_hint.and_then(|p| guard.capture_shared_file(p));
            let raw = file.as_ref().map(|f| f.content.len()).unwrap_or(0);

            let header = file.and_then(|f| {
                render_symbol_context_header(
                    f.as_ref(),
                    &params.0.name,
                    params.0.symbol_kind.as_deref(),
                    params.0.symbol_line,
                    verbosity,
                    max_tokens,
                )
            });

            let (impl_block_tip, callees_text) = file_path_hint
                .map(|path| {
                    let view = guard.capture_context_bundle_view(
                        path,
                        &params.0.name,
                        params.0.symbol_kind.as_deref(),
                        params.0.symbol_line,
                    );
                    let tip = format::context_bundle_impl_suggestion_tip(&view);
                    let callees = format::context_bundle_callees_text(&view);
                    (tip, callees)
                })
                .unwrap_or_default();

            (header, impl_block_tip, callees_text, raw)
        };

        let state = sidecar_state_for_server(self);
        let symbol_context = SymbolContextParams {
            name: params.0.name.clone(),
            file: params.0.file.clone(),
            path: params.0.path.clone(),
            symbol_kind: params.0.symbol_kind.clone(),
            symbol_line: params.0.symbol_line,
        };
        match symbol_context_tool_text(&state, &symbol_context) {
            Ok(refs_text) => {
                let mut output = String::new();
                if let Some(header) = &symbol_header {
                    output.push_str(header);
                    output.push_str("\n\n");
                }
                output.push_str(&refs_text);
                if !callees_text.is_empty() {
                    output.push('\n');
                    output.push_str(&callees_text);
                }
                if !impl_block_tip.is_empty() {
                    output.push_str(impl_block_tip.trim_start_matches('\n'));
                }
                output.push_str(&format::compact_next_step_hint(&[
                    "get_symbol_context (callers/callees/types)",
                    "find_references (usages)",
                    "edit_within_symbol / replace_symbol_body (edits)",
                ]));
                let saved = raw_chars.saturating_sub(output.len());
                let footer = format::compact_savings_footer(output.len(), raw_chars);
                self.record_read_savings((saved / 4) as u64);
                // Frecency bump — commitment tool, default-mode happy path.
                // Resolve the bump path the same way the output did:
                // explicit params first, auto-resolved path as fallback.
                // Collection policy is resolved inside bump_frecency.
                if let Some(bump_path) = params
                    .0
                    .path
                    .as_deref()
                    .or(params.0.file.as_deref())
                    .map(PathBuf::from)
                    .or_else(|| resolved_path.as_deref().map(PathBuf::from))
                {
                    self.bump_frecency(&[bump_path]);
                }
                format::enforce_token_budget(format!("{output}{footer}"), max_tokens)
            }
            Err(_) => {
                // Sidecar unavailable — fall back to the index definition so callers
                // always get at least the symbol body instead of an opaque error.
                if let Some(header) = symbol_header {
                    let mut body = format!(
                        "{header}\n\n(Reference data unavailable — showing definition only)"
                    );
                    if !impl_block_tip.is_empty() {
                        body.push('\n');
                        body.push_str(impl_block_tip.trim_start_matches('\n'));
                    }
                    body.push_str(&format::compact_next_step_hint(&[
                        "get_symbol_context (callers/callees/types)",
                        "find_references (usages)",
                    ]));
                    let footer = format::compact_savings_footer(body.len(), raw_chars);
                    let saved = raw_chars.saturating_sub(body.len());
                    self.record_read_savings((saved / 4) as u64);
                    let output = format!("{body}{footer}");
                    if let Some(ref p) = resolved_path {
                        self.session_context.record_symbol(
                            p,
                            &params.0.name,
                            (output.len() / 4) as u32,
                        );
                    } else if let Some(ref p) = params.0.path {
                        self.session_context.record_symbol(
                            p,
                            &params.0.name,
                            (output.len() / 4) as u32,
                        );
                    }
                    // Frecency bump — commitment tool, fallback-definition
                    // branch (sidecar unavailable but we still returned a
                    // useful symbol body to the caller). Collection policy is
                    // resolved inside bump_frecency.
                    if let Some(bump_path) = params
                        .0
                        .path
                        .as_deref()
                        .or(params.0.file.as_deref())
                        .map(PathBuf::from)
                        .or_else(|| resolved_path.as_deref().map(PathBuf::from))
                    {
                        self.bump_frecency(&[bump_path]);
                    }
                    format::enforce_token_budget(output, max_tokens)
                } else {
                    format!("Symbol \"{}\" not found in index.", params.0.name)
                }
            }
        }
    }

    /// Call AFTER editing a file. Re-reads from disk, updates the index, reports added/removed/modified
    /// symbols and affected dependents. Set include_co_changes=true to also see git temporal coupling data
    /// (files that historically change together with this file). Always call this after making edits.
    #[tool(
        description = "Call AFTER editing a file. Re-reads from disk, updates the index, reports added/removed/modified symbols and affected dependents. Set include_co_changes=true to also see git temporal coupling data (files that historically change together). Always call this after making edits to keep the index current. NOT for listing changed files across the repo (use what_changed). NOT for a symbol-level diff between git refs (use diff_symbols).",
        annotations(
            read_only_hint = false,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    pub(crate) async fn analyze_file_impact(
        &self,
        params: Parameters<AnalyzeFileImpactInput>,
    ) -> String {
        if let Some(result) = self.proxy_tool_call("analyze_file_impact", &params.0).await {
            return result;
        }
        if params.0.estimate == Some(true) {
            let with_co = params.0.include_co_changes.unwrap_or(false);
            let co_limit = params.0.co_changes_limit.unwrap_or(10) as usize;
            let est = 200 + if with_co { co_limit * 13 } else { 0 };
            return format!(
                "Estimate for analyze_file_impact: ~{} tokens (include_co_changes={})",
                est, with_co
            );
        }
        {
            let guard = self.index.read();
            loading_guard!(guard);
        }

        let state = sidecar_state_for_server(self);
        let impact = ImpactParams {
            path: params.0.path.clone(),
            new_file: params.0.new_file,
        };
        let mut result = match impact_tool_text(state, &impact).await {
            Ok(result) => result,
            Err(StatusCode::NOT_FOUND) => {
                return format!("File not found on disk: {}", params.0.path);
            }
            Err(StatusCode::INTERNAL_SERVER_ERROR) => {
                return "Impact analysis failed: internal error.".to_string();
            }
            Err(other) => return format!("Impact analysis failed: HTTP {}", other.as_u16()),
        };

        // Append co-changes if requested
        if params.0.include_co_changes.unwrap_or(false) {
            let temporal = self.index.git_temporal();
            match temporal.state {
                crate::live_index::git_temporal::GitTemporalState::Ready => {
                    let limit = params.0.co_changes_limit.unwrap_or(10) as usize;
                    let path = params.0.path.as_str();
                    match temporal.files.get(path) {
                        Some(history) => {
                            result.push_str("\n\n");
                            result.push_str(&format::co_changes_result_view(path, history, limit));
                        }
                        None => {
                            result.push_str("\n\nNo git co-change data found for this file.");
                        }
                    }
                }
                crate::live_index::git_temporal::GitTemporalState::Pending
                | crate::live_index::git_temporal::GitTemporalState::Computing => {
                    result.push_str(
                        "\n\nGit temporal data is still loading. Co-changes unavailable.",
                    );
                }
                crate::live_index::git_temporal::GitTemporalState::Unavailable(ref reason) => {
                    result.push_str(&format!("\n\nGit temporal data unavailable: {reason}"));
                }
            }
        }

        self.session_context.record_summary_output(
            "analyze_file_impact",
            (result.len() / 4).min(u32::MAX as usize) as u32,
        );
        result
    }

    /// Find symbols by name substring across the project — returns name, kind, file, line range.
    /// Use when you know part of a symbol name but not the file. Supports kind filter, language filter,
    /// and path prefix scope. Query is optional: omit it to browse by kind or path_prefix (at least
    /// one of query, kind, or path_prefix is required).
    /// NOT for text content search (use search_text). NOT for file path search (use search_files).
    #[tool(
        name = "search_symbols",
        description = "Prefer this before grep when you are looking for a function, class, type, or other symbol by name. Finds symbols across the repository in milliseconds and returns name, kind, file, and line range. Use when you know part of a symbol name but not the file. Supports kind filter, language filter, and path prefix scope. Query is optional — omit it to browse all symbols matching kind/path_prefix (browse mode defaults to limit=20, sorted by path+line). At least one of query, kind, or path_prefix is required. NOT for text content search (use search_text). NOT for file path search (use search_files).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn search_symbols_tool(
        &self,
        params: Parameters<SearchSymbolsInput>,
    ) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
        let started = Instant::now();
        let output = self.search_symbols(params).await;
        let outcome_class = classify_search_symbols_output(&output);
        self.record_tool_completion("search_symbols", &output, started.elapsed(), outcome_class);
        statused_tool_result(output, outcome_class)
    }

    pub(crate) async fn search_symbols(&self, params: Parameters<SearchSymbolsInput>) -> String {
        let query_str = params.0.query.as_deref().unwrap_or("").trim();
        let is_browse = query_str.is_empty();
        if is_browse && params.0.kind.is_none() && params.0.path_prefix.is_none() {
            return "search_symbols requires at least one of: query, kind, or path_prefix"
                .to_string();
        }
        if let Some(result) = self.proxy_tool_call("search_symbols", &params.0).await {
            return result;
        }
        let options = match search_symbols_options_from_input(&params.0) {
            Ok(options) => options,
            Err(message) => return message,
        };
        let mut result = {
            let guard = self.index.read();
            loading_guard!(guard);
            search::search_symbols_with_options(
                &guard,
                query_str,
                params.0.kind.as_deref(),
                &options,
            )
        };
        let hidden_noise_count = {
            let guard = self.index.read();
            hidden_search_symbols_noise_count(
                &guard,
                query_str,
                params.0.kind.as_deref(),
                &options,
                &result,
            )
        };
        // In browse mode, sort by relevance: public symbols first, then kind priority,
        // then penalize very short/common names, then alphabetical tiebreaker.
        if is_browse {
            let scores: Vec<(bool, f32, bool)> = {
                let guard = self.index.read();
                result
                    .hits
                    .iter()
                    .map(|hit| {
                        let is_pub = guard
                            .get_file(&hit.path)
                            .map(|f| LiveIndex::has_pub_symbol(f, &hit.name))
                            .unwrap_or(false);
                        let kind_score = search::symbol_kind_priority(&hit.kind);
                        let is_short_common = hit.name.len() <= 3;
                        (is_pub, kind_score, is_short_common)
                    })
                    .collect()
            };
            let mut indices: Vec<usize> = (0..result.hits.len()).collect();
            indices.sort_by(|&i, &j| {
                scores[j]
                    .0
                    .cmp(&scores[i].0) // public first
                    .then(
                        scores[j]
                            .1
                            .partial_cmp(&scores[i].1) // higher kind priority first
                            .unwrap_or(std::cmp::Ordering::Equal),
                    )
                    .then(scores[i].2.cmp(&scores[j].2)) // non-short names first
                    .then(result.hits[i].name.cmp(&result.hits[j].name)) // alphabetical
            });
            result.hits = indices
                .into_iter()
                .map(|i| result.hits[i].clone())
                .collect();
        }
        let envelope = if result.hits.is_empty() {
            None
        } else {
            let guard = self.index.read();
            Some(search_format::format_search_envelope(
                search_symbols_match_type_label(&result, is_browse),
                "current index",
                search_parse_state_for_paths(
                    &guard,
                    result.hits.iter().map(|hit| hit.path.as_str()),
                ),
                &search_completeness_label(result.overflow_count, hidden_noise_count),
                &search_scope_summary(
                    &options.path_scope,
                    options.language_filter.as_ref(),
                    &options.noise_policy,
                    options.include_personal_tooling,
                    None,
                    None,
                    false,
                ),
                &search_symbols_evidence(&result),
            ))
        };
        let output = format::search_symbols_result_view(&result, query_str);
        let hint = format::compact_next_step_hint(&[
            "get_symbol (body)",
            "get_symbol_context (callers/callees)",
            "get_file_context (file overview)",
        ]);
        let output = match envelope {
            Some(envelope) => format!("{envelope}\n\n{output}{hint}"),
            None => format!("{output}{hint}"),
        };
        self.record_tool_savings_named(
            "search_symbols",
            (output.len() * 10 / 4) as u64,
            (output.len() / 4) as u64,
        );
        self.session_context.record_summary_output(
            "search_symbols",
            (output.len() / 4).min(u32::MAX as usize) as u32,
        );
        // Session tracking: record each symbol hit as list-only until its body is fetched.
        for hit in &result.hits {
            self.session_context
                .record_listed_symbol(&hit.path, &hit.name);
        }
        // Intentionally NO frecency bump here — search_symbols is a discovery
        // tool. See wiki `[[SymForge Frecency-Weighted File Ranking]]`
        // §"Search tools deliberately do NOT bump" for the positive-feedback-
        // loop rationale.
        format::enforce_token_budget(output, params.0.max_tokens)
    }

    /// Full-text search across file contents — literal, OR-terms, regex, or structural AST patterns.
    /// Shows matches with enclosing symbol context. Use group_by='symbol' to deduplicate,
    /// follow_refs=true to inline callers. Set structural=true to match AST patterns using
    /// ast-grep syntax ($VAR for metavariables, $$$ for multi-node wildcards).
    /// NOT for symbol name search (use search_symbols). NOT for file path search (use search_files).
    #[tool(
        name = "search_text",
        description = "Prefer this over grep/ripgrep for code search — it returns matches with enclosing symbol context instead of raw lines alone. Full-text search across file contents: literal, OR-terms, regex, or structural AST patterns. Use group_by='symbol' to deduplicate and follow_refs=true to inline callers. Set structural=true with query as an ast-grep pattern to match code by AST structure (e.g., 'fn $NAME($$$) { $$$ }'). NOT for symbol name search (use search_symbols). NOT for file path search (use search_files).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn search_text_tool(
        &self,
        params: Parameters<SearchTextInput>,
    ) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
        let started = Instant::now();
        let output = self.search_text(params).await;
        let outcome_class = classify_search_text_output(&output);
        self.record_tool_completion("search_text", &output, started.elapsed(), outcome_class);
        statused_tool_result(output, outcome_class)
    }

    pub(crate) async fn search_text(&self, params: Parameters<SearchTextInput>) -> String {
        if let Some(result) = self.proxy_tool_call("search_text", &params.0).await {
            return result;
        }
        if params.0.estimate == Some(true) {
            let limit = params.0.limit.unwrap_or(50) as usize;
            let per_file = params.0.max_per_file.unwrap_or(5) as usize;
            let est = limit * 20 + 50;
            return format!(
                "Estimate for search_text: ~{} tokens (limit={}, max_per_file={})",
                est, limit, per_file
            );
        }
        // Structural (AST-pattern) search mode.
        if params.0.structural.unwrap_or(false) {
            let pattern = match params.0.query.as_deref() {
                Some(p) if !p.trim().is_empty() => p.trim(),
                _ => return "Error: `query` is required for structural search.".to_string(),
            };
            let options = match search_text_options_from_input(&params.0) {
                Ok(o) => o,
                Err(message) => return message,
            };
            let mut result = {
                let guard = self.index.read();
                loading_guard!(guard);
                let mut result = search::search_structural(&guard, pattern, &options);
                resolve_text_search_enclosing_symbols(&guard, &mut result);
                result
            };
            apply_path_predicate_filter(
                &mut result,
                params.0.include_vendor.unwrap_or(false),
                params.0.include_personal_tooling.unwrap_or(false),
            );
            let output = render_search_text_output(
                self,
                result,
                Some(pattern),
                true,
                params.0.group_by.as_deref(),
                params.0.terms.as_deref(),
                &options,
                false,
                false,
                false,
            );
            let hint = format::compact_next_step_hint(&[
                "inspect_match (deep-dive one hit)",
                "get_file_context (file overview)",
                "search_symbols (name-based lookup)",
            ]);
            let result = format!("{output}{hint}");
            self.session_context.record_summary_output(
                "search_text",
                (result.len() / 4).min(u32::MAX as usize) as u32,
            );
            return format::enforce_token_budget(result, params.0.max_tokens);
        }

        let mut options = match search_text_options_from_input(&params.0) {
            Ok(options) => options,
            Err(message) => return message,
        };
        let mut is_regex = params.0.regex.unwrap_or(false);
        let mut auto_detected_regex = false;
        let original_query = params.0.query.clone();

        // Auto-detect regex patterns: if regex is not explicitly set and the
        // query contains unambiguous regex sequences (\w, \d, \s, \b, .+, .*),
        // enable regex mode automatically. These sequences never appear
        // literally in code, so treating them as literals always gives 0 results.
        if !is_regex && let Some(ref q) = params.0.query {
            let has_regex_escape = q.contains("\\w")
                || q.contains("\\d")
                || q.contains("\\s")
                || q.contains("\\b")
                || q.contains("\\W")
                || q.contains("\\D")
                || q.contains("\\S");
            if has_regex_escape {
                is_regex = true;
                auto_detected_regex = true;
                // Relax noise policy for auto-detected regex — the user
                // is doing a targeted pattern search and expects grep-like
                // completeness, so include test files by default.
                if params.0.include_tests.is_none() {
                    options.noise_policy.include_tests = true;
                }
            }
        }

        // Extract churn scores from GitTemporalIndex BEFORE acquiring the
        // LiveIndex read lock to avoid lock ordering issues.
        if options.ranked {
            let git_temporal = self.index.git_temporal();
            if matches!(
                git_temporal.state,
                crate::live_index::git_temporal::GitTemporalState::Ready
            ) {
                let churn_map: std::collections::HashMap<String, f32> = git_temporal
                    .files
                    .iter()
                    .map(|(path, history)| (path.clone(), history.churn_score))
                    .collect();
                if !churn_map.is_empty() {
                    options.churn_scores = Some(churn_map);
                }
            }
        }

        let mut result = {
            let guard = self.index.read();
            loading_guard!(guard);
            let mut r = search::search_text_with_options(
                &guard,
                params.0.query.as_deref(),
                params.0.terms.as_deref(),
                is_regex,
                &options,
            );
            resolve_text_search_enclosing_symbols(&guard, &mut r);
            // Enrich with callers if follow_refs is set
            if params.0.follow_refs.unwrap_or(false)
                && let Ok(ref mut text_result) = r
            {
                let limit = params.0.follow_refs_limit.unwrap_or(3) as usize;
                enrich_with_callers(&guard, text_result, limit);
            }
            r
        };
        apply_path_predicate_filter(
            &mut result,
            params.0.include_vendor.unwrap_or(false),
            params.0.include_personal_tooling.unwrap_or(false),
        );

        // Auto-correct double-escaped regex patterns: if regex=true and the
        // result is InvalidRegex or Ok-but-empty, try fixing common
        // double-escaped character classes (\\s → \s, \\d → \d, etc.).
        if is_regex {
            let should_retry = match &result {
                Err(search::TextSearchError::InvalidRegex { .. }) => true,
                Ok(r) if r.files.is_empty() => true,
                _ => false,
            };
            if should_retry
                && let Some(ref query) = original_query
                && let Some(fixed) = fix_common_double_escapes(query)
            {
                let mut retry_result = {
                    let guard = self.index.read();
                    loading_guard!(guard);
                    let mut r = search::search_text_with_options(
                        &guard,
                        Some(fixed.as_str()),
                        params.0.terms.as_deref(),
                        true,
                        &options,
                    );
                    resolve_text_search_enclosing_symbols(&guard, &mut r);
                    if params.0.follow_refs.unwrap_or(false)
                        && let Ok(ref mut text_result) = r
                    {
                        let limit = params.0.follow_refs_limit.unwrap_or(3) as usize;
                        enrich_with_callers(&guard, text_result, limit);
                    }
                    r
                };
                apply_path_predicate_filter(
                    &mut retry_result,
                    params.0.include_vendor.unwrap_or(false),
                    params.0.include_personal_tooling.unwrap_or(false),
                );
                // Use the retry result if it actually produced matches
                if let Ok(ref retry_ok) = retry_result
                    && !retry_ok.files.is_empty()
                {
                    let mut output = render_search_text_output(
                        self,
                        retry_result,
                        Some(fixed.as_str()),
                        false,
                        params.0.group_by.as_deref(),
                        params.0.terms.as_deref(),
                        &options,
                        true,
                        auto_detected_regex,
                        true,
                    );
                    output.push_str(&format!(
                        "\n(auto-corrected double-escaped regex: `{}` → `{}`)",
                        query, fixed
                    ));
                    output.push_str(&format::compact_next_step_hint(&[
                        "inspect_match (deep-dive one hit)",
                        "get_file_context (file overview)",
                        "search_symbols (name-based lookup)",
                    ]));
                    self.session_context.record_summary_output(
                        "search_text",
                        (output.len() / 4).min(u32::MAX as usize) as u32,
                    );
                    return output;
                }
            }
        }

        let output = render_search_text_output(
            self,
            result,
            params.0.query.as_deref(),
            false,
            params.0.group_by.as_deref(),
            params.0.terms.as_deref(),
            &options,
            is_regex,
            auto_detected_regex,
            false,
        );
        let hint = format::compact_next_step_hint(&[
            "inspect_match (deep-dive one hit)",
            "get_file_context (file overview)",
            "search_symbols (name-based lookup)",
        ]);
        let result = format!("{output}{hint}");
        self.session_context.record_summary_output(
            "search_text",
            (result.len() / 4).min(u32::MAX as usize) as u32,
        );
        // Intentionally NO frecency bump here — search_text is a discovery
        // tool. Bumping on discovery would create a positive feedback loop
        // (hot files get searched more, which would bump them more, which
        // would rank them higher still). See wiki `[[SymForge Frecency-
        // Weighted File Ranking]]` §"Search tools deliberately do NOT bump".
        format::enforce_token_budget(result, params.0.max_tokens)
    }

    /// Internal: trace_symbol logic, called by get_symbol_context when sections are provided.
    /// The daemon compatibility alias routes through get_symbol_context so this
    /// canonical path owns trace-mode behavior.
    pub(crate) async fn trace_symbol(&self, params: Parameters<TraceSymbolInput>) -> String {
        if let Some(result) = self.proxy_tool_call("trace_symbol", &params.0).await {
            return result;
        }
        let sections = visible_sections(&params.0.sections);
        if params.0.estimate == Some(true) {
            let section_count = sections
                .as_ref()
                .map(|s| if s.is_empty() { 4 } else { s.len() })
                .unwrap_or(4);
            let est = 200 + section_count * 200;
            return format!(
                "Estimate for trace_symbol: ~{} tokens ({} sections)",
                est, section_count
            );
        }

        let mut trace_view = {
            let guard = self.index.read();
            loading_guard!(guard);
            guard.capture_trace_symbol_view(
                &params.0.path,
                &params.0.name,
                params.0.kind.as_deref(),
                params.0.symbol_line,
                sections.as_deref(),
                include_tests_from_sections(params.0.sections.as_ref()),
            )
        };

        // Fill in git activity if it was requested (or if all sections requested)
        if let crate::live_index::TraceSymbolView::Found(ref mut found) = trace_view {
            let wants_git = sections
                .as_ref()
                .map(|s| s.iter().any(|v| v.eq_ignore_ascii_case("git")))
                .unwrap_or(true);

            if wants_git {
                let temporal = self.index.git_temporal();
                if temporal.state == crate::live_index::git_temporal::GitTemporalState::Ready
                    && let Some(history) = temporal.files.get(&params.0.path)
                {
                    use crate::live_index::git_temporal::{churn_bar, churn_label, relative_time};

                    found.git_activity = Some(crate::live_index::GitActivityView {
                        churn_score: history.churn_score,
                        churn_bar: churn_bar(history.churn_score),
                        churn_label: churn_label(history.churn_score).to_string(),
                        commit_count: history.commit_count,
                        last_relative: relative_time(history.last_commit.days_ago),
                        last_hash: history.last_commit.hash.clone(),
                        last_message: history.last_commit.message_head.clone(),
                        last_author: history.last_commit.author.clone(),
                        last_timestamp: history.last_commit.timestamp.clone(),
                        owners: history
                            .contributors
                            .iter()
                            .map(|c| format!("{} {:.0}%", c.author, c.percentage))
                            .collect(),
                        co_changes: history
                            .co_changes
                            .iter()
                            .map(|e| (e.path.clone(), e.coupling_score, e.shared_commits))
                            .collect(),
                    });
                }
            }
        }

        let verbosity = params.0.verbosity.as_deref().unwrap_or("full");
        let output = format::trace_symbol_result_view(
            &trace_view,
            &params.0.name,
            verbosity,
            params.0.max_tokens,
        );
        self.session_context.record_symbol(
            &params.0.path,
            &params.0.name,
            (output.len() / 4) as u32,
        );
        output
    }

    /// Inspect a specific line in full symbol context: shows the enclosing symbol, parent chain,
    /// and siblings. Works standalone with just path + line, or after search_text to deep-dive a match.
    #[tool(
        description = "Inspect a specific line in full symbol context: enclosing symbol, parent chain (e.g. module → class → method), and sibling symbols. Works standalone with just path + line number, or after search_text to deep-dive a specific hit. NOT for finding all occurrences of a pattern (use search_text). NOT for understanding a symbol's callers and callees (use get_symbol_context).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn inspect_match(&self, params: Parameters<InspectMatchInput>) -> String {
        if let Some(result) = self.proxy_tool_call("inspect_match", &params.0).await {
            return result;
        }
        if params.0.estimate == Some(true) {
            let context = params.0.context.unwrap_or(3) as usize;
            let siblings = params.0.sibling_limit.unwrap_or(10) as usize;
            let est = (context * 2 + 1) * 20 + siblings * 8 + 80;
            return format!(
                "Estimate for inspect_match: ~{} tokens (context={}, siblings={})",
                est, context, siblings
            );
        }

        let view = {
            let guard = self.index.read();
            loading_guard!(guard);
            guard.capture_inspect_match_view(
                &params.0.path,
                params.0.line,
                params.0.context,
                params.0.sibling_limit,
            )
        };

        let output = format::inspect_match_result_view(&view);
        self.session_context.record_summary_output(
            "inspect_match",
            (output.len() / 4).min(u32::MAX as usize) as u32,
        );
        format::enforce_token_budget(output, params.0.max_tokens)
    }

    /// Find files by path, filename, or folder — ranked by relevance. With changed_with=path,
    /// finds co-changing files via git temporal coupling. Set rank_by="path+cochange" with
    /// anchor_path to fuse path matches with the coupling store. Set resolve=true for exact path resolution.
    /// NOT for file content search (use search_text). NOT for symbol names (use search_symbols).
    #[tool(
        name = "search_files",
        description = "Prefer this over a shell find/glob for locating files: it ranks by relevance (frecency + path match) and resolves an ambiguous or partial path to one exact project path. Find files by path, filename, or folder — ranked by relevance. Modes: (1) default: fuzzy search ranked by relevance, (2) changed_with=path: co-changing files via git temporal coupling, (3) rank_by=\"path+cochange\" with anchor_path: fuse path matches with coupling-store evidence, (4) resolve=true: resolve an ambiguous filename or partial path to one exact project path. NOT for file content search (use search_text). NOT for symbol names (use search_symbols).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn search_files_tool(
        &self,
        params: Parameters<SearchFilesInput>,
    ) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
        let started = Instant::now();
        let output = self.search_files(params).await;
        let outcome_class = classify_search_files_output(&output);
        self.record_tool_completion("search_files", &output, started.elapsed(), outcome_class);
        statused_tool_result(output, outcome_class)
    }

    pub(crate) async fn search_files(&self, params: Parameters<SearchFilesInput>) -> String {
        if let Some(result) = self.proxy_tool_call("search_files", &params.0).await {
            return result;
        }
        let include_vendor = params.0.include_vendor.unwrap_or(false);
        let include_personal_tooling = params.0.include_personal_tooling.unwrap_or(false);
        if params.0.estimate == Some(true) {
            let limit = params.0.limit.unwrap_or(20) as usize;
            let est = limit * 10 + 30;
            return format!(
                "Estimate for search_files: ~{} tokens (limit={})",
                est, limit
            );
        }

        // Resolve mode: exact path resolution
        if params.0.resolve.unwrap_or(false) {
            if params.0.query.is_empty() {
                return "search_files with resolve=true requires a non-empty `query`.".to_string();
            }
            let view = {
                let guard = self.index.read();
                loading_guard!(guard);
                guard.capture_search_files_resolve_view_with_noise(
                    &params.0.query,
                    include_vendor,
                    include_personal_tooling,
                )
            };
            let hidden_noise_count = if include_vendor && include_personal_tooling {
                0
            } else {
                let guard = self.index.read();
                let unfiltered = guard.capture_search_files_resolve_view(&params.0.query);
                search_files_resolve_candidate_count(&unfiltered)
                    .saturating_sub(search_files_resolve_candidate_count(&view))
            };
            let envelope = match &view {
                SearchFilesResolveView::Resolved { path } => {
                    let guard = self.index.read();
                    Some(search_format::format_search_envelope(
                        search_files_resolve_match_type_label(&view),
                        "current index",
                        search_parse_state_for_paths(&guard, std::iter::once(path.as_str())),
                        &search_completeness_label(0, hidden_noise_count),
                        &search_files_scope_summary(
                            "resolve=true against indexed file paths",
                            include_vendor,
                            include_personal_tooling,
                        ),
                        &search_paths_evidence(std::iter::once(path.as_str())),
                    ))
                }
                SearchFilesResolveView::Ambiguous {
                    matches,
                    overflow_count,
                    ..
                } => {
                    let guard = self.index.read();
                    Some(search_format::format_search_envelope(
                        search_files_resolve_match_type_label(&view),
                        "current index",
                        search_parse_state_for_paths(
                            &guard,
                            matches.iter().map(std::string::String::as_str),
                        ),
                        &search_completeness_label(*overflow_count, hidden_noise_count),
                        &search_files_scope_summary(
                            "resolve=true against indexed file paths",
                            include_vendor,
                            include_personal_tooling,
                        ),
                        &search_paths_evidence(matches.iter().map(std::string::String::as_str)),
                    ))
                }
                _ => None,
            };
            let output = format::search_files_resolve_result_view(&view);
            let had_envelope = envelope.is_some();
            let result = match envelope {
                Some(envelope) => format!("{envelope}\n\n{output}"),
                None => output,
            };
            let mut result = result;
            if !had_envelope {
                append_search_files_filter_summary(
                    &mut result,
                    include_vendor,
                    include_personal_tooling,
                );
            }
            if let Some(note) = search_files_hidden_noise_note(
                hidden_noise_count,
                include_vendor,
                include_personal_tooling,
            ) {
                result.push_str("\n\n");
                result.push_str(&note);
            }
            if matches!(view, SearchFilesResolveView::NotFound { .. }) {
                let matching_untracked_paths = matching_untracked_paths_for_search_files(
                    self,
                    &params.0.query,
                    include_vendor,
                    include_personal_tooling,
                );
                append_untracked_file_diagnostic(&mut result, &matching_untracked_paths);
            }
            let result = format::enforce_token_budget(result, params.0.max_tokens);
            self.session_context.record_summary_output(
                "search_files",
                (result.len() / 4).min(u32::MAX as usize) as u32,
            );
            return result;
        }

        // Deprecated since v7.x: prefer `rank_by="path+cochange"` with `anchor_path`.
        // Keep this compatibility path behavior unchanged for one release cycle.
        if let Some(ref target_path) = params.0.changed_with {
            let temporal = self.index.git_temporal();
            match temporal.state {
                crate::live_index::git_temporal::GitTemporalState::Ready => {}
                crate::live_index::git_temporal::GitTemporalState::Unavailable(ref reason) => {
                    return append_changed_with_deprecation_warning(format!(
                        "Git temporal data unavailable: {reason}"
                    ));
                }
                _ => {
                    return append_changed_with_deprecation_warning(
                        "Git temporal data is still loading. Try again in a few seconds."
                            .to_string(),
                    );
                }
            }
            let commit_count = temporal.stats.total_commits_analyzed;
            if let Some(history) = temporal.files.get(target_path.as_str()) {
                let hits: Vec<SearchFilesHit> = history
                    .co_changes
                    .iter()
                    .map(|entry| SearchFilesHit {
                        tier: SearchFilesTier::CoChange,
                        path: entry.path.clone(),
                        coupling_score: Some(entry.coupling_score),
                        shared_commits: Some(entry.shared_commits),
                    })
                    .collect();
                let weak_hits: Vec<SearchFilesHit> = history
                    .weak_co_changes
                    .iter()
                    .map(|entry| SearchFilesHit {
                        tier: SearchFilesTier::CoChange,
                        path: entry.path.clone(),
                        coupling_score: Some(entry.coupling_score),
                        shared_commits: Some(entry.shared_commits),
                    })
                    .collect();
                if hits.is_empty() {
                    if !weak_hits.is_empty() {
                        let total = weak_hits.len();
                        let mut result =
                            format::search_files_result_view(&SearchFilesView::Found {
                                query: format!("weak co-changes with {target_path}"),
                                total_matches: total,
                                overflow_count: 0,
                                hits: weak_hits,
                            });
                        let envelope = {
                            let guard = self.index.read();
                            search_format::format_search_envelope(
                                "weak heuristic (git-temporal coupling)",
                                "current index + git temporal",
                                search_parse_state_for_paths(
                                    &guard,
                                    history
                                        .weak_co_changes
                                        .iter()
                                        .map(|entry| entry.path.as_str()),
                                ),
                                "full for current scope",
                                &format!(
                                    "weak git-temporal co-change candidates for `{target_path}`"
                                ),
                                &search_paths_evidence(
                                    history
                                        .weak_co_changes
                                        .iter()
                                        .map(|entry| entry.path.as_str()),
                                ),
                            )
                        };
                        result = format!("{envelope}\n\n{result}");
                        result.push_str(
                            "\n\nLow confidence: these candidates missed the strong threshold (2 shared commits and Jaccard >= 0.15). Use them as hints, not proof.",
                        );
                        if commit_count < 50 {
                            result.push_str(&format!(
                                "\nOnly {commit_count} commit(s) were analyzed, so coupling may strengthen as history grows."
                            ));
                        }
                        return append_changed_with_deprecation_warning(result);
                    }
                    let result = if commit_count < 50 {
                        format!(
                            "No high-confidence co-change data for '{target_path}'. Only {commit_count} commit(s) analyzed — strong co-change needs at least 2 shared commits and Jaccard >= 0.15. Results improve with more history."
                        )
                    } else {
                        format!(
                            "No high-confidence co-change data for '{target_path}' (analyzed {commit_count} commits). This file may change independently of others or only have weak coupling signals."
                        )
                    };
                    return append_changed_with_deprecation_warning(result);
                }
                let total = hits.len();
                let mut result = format::search_files_result_view(&SearchFilesView::Found {
                    query: format!("co-changes with {target_path}"),
                    total_matches: total,
                    overflow_count: 0,
                    hits,
                });
                let envelope = {
                    let guard = self.index.read();
                    search_format::format_search_envelope(
                        "heuristic (git-temporal coupling)",
                        "current index + git temporal",
                        search_parse_state_for_paths(
                            &guard,
                            history.co_changes.iter().map(|entry| entry.path.as_str()),
                        ),
                        "full for current scope",
                        &format!("git-temporal co-changes for `{target_path}`"),
                        &search_paths_evidence(
                            history.co_changes.iter().map(|entry| entry.path.as_str()),
                        ),
                    )
                };
                result = format!("{envelope}\n\n{result}");
                if commit_count < 50 {
                    result.push_str(&format!(
                        "\n\n⚠ Low confidence: only {commit_count} commit(s) analyzed. Coupling scores may shift as history grows."
                    ));
                }
                return append_changed_with_deprecation_warning(result);
            }
            if commit_count < 20 {
                return append_changed_with_deprecation_warning(format!(
                    "'{target_path}' not found in git history ({commit_count} commit(s) analyzed). Co-change analysis needs more history to produce useful results."
                ));
            }
            return append_changed_with_deprecation_warning(format!(
                "No git history found for '{target_path}'. Check the file path is correct and that it has been committed."
            ));
        }

        if params.0.query.is_empty() {
            return "search_files requires a non-empty `query` (or use `changed_with` to find co-changing files).".to_string();
        }

        let rank_by_path_cochange = params.0.rank_by.as_deref() == Some("path+cochange");
        let rank_by_frecency = params.0.rank_by.as_deref() == Some("frecency");
        let ranking_diagnostics = search_files_debug_ranking_requested(&params.0);
        let debug_ranking = ranking_diagnostics.explain;
        let mut cochange_evidence: Option<CapabilityEvidence> = None;
        let mut frecency_evidence: Option<CapabilityEvidence> = None;
        let mut hidden_noise_count = 0usize;
        let cochange_repo_root = if rank_by_path_cochange {
            self.capture_repo_root()
        } else {
            None
        };
        let mut view = {
            let guard = self.index.read();
            loading_guard!(guard);
            let cochange_resolution = if rank_by_path_cochange {
                match params.0.anchor_path.as_deref() {
                    Some(anchor_path) => Some(search_files_coupling_neighbors(
                        &guard,
                        cochange_repo_root.as_deref(),
                        anchor_path,
                    )),
                    None => Some(SearchFilesCoChangeResolution {
                        neighbors: None,
                        evidence: cochange_ranking_evidence(
                            CapabilityStatus::FallbackUsed,
                            CapabilityFreshness::Unknown,
                            CapabilityCost::Free,
                            "`rank_by=\"path+cochange\"` requires `anchor_path=<path>`; path ranking returned",
                        ),
                    }),
                }
            } else {
                None
            };
            if let Some(resolution) = &cochange_resolution {
                cochange_evidence = Some(resolution.evidence.clone());
            }
            let coupling_neighbors = cochange_resolution
                .as_ref()
                .and_then(|resolution| resolution.neighbors.as_ref());
            let coupling_context = params.0.anchor_path.as_deref().zip(coupling_neighbors);
            let view = guard.capture_search_files_view_with_noise(
                &params.0.query,
                params.0.limit.unwrap_or(20) as usize,
                params.0.current_file.as_deref(),
                coupling_context,
                include_vendor,
                include_personal_tooling,
            );
            if !(include_vendor && include_personal_tooling) {
                let unfiltered = guard.capture_search_files_view(
                    &params.0.query,
                    params.0.limit.unwrap_or(20) as usize,
                    params.0.current_file.as_deref(),
                    coupling_context,
                );
                hidden_noise_count = search_files_total_matches(&unfiltered)
                    .saturating_sub(search_files_total_matches(&view));
            }
            if rank_by_path_cochange && let Some((anchor_path, neighbors)) = coupling_context {
                let applied_hits = match &view {
                    SearchFilesView::Found { hits, .. } => hits
                        .iter()
                        .filter(|hit| hit.tier == SearchFilesTier::CoChange)
                        .count(),
                    _ => 0,
                };
                cochange_evidence = if applied_hits > 0 {
                    Some(cochange_ranking_evidence(
                        CapabilityStatus::Applied,
                        CapabilityFreshness::Current,
                        CapabilityCost::Low,
                        format!(
                            "anchor_path={anchor_path} loaded {} usable coupling partner(s); rows with shared-commit counts show applied evidence",
                            neighbors.len()
                        ),
                    ))
                } else {
                    let candidate_paths: Vec<&str> = match &view {
                        SearchFilesView::Found { hits, .. } => {
                            hits.iter().map(|hit| hit.path.as_str()).collect()
                        }
                        _ => Vec::new(),
                    };
                    Some(cochange_ranking_evidence(
                        CapabilityStatus::FallbackUsed,
                        CapabilityFreshness::Current,
                        CapabilityCost::Low,
                        cochange_fallback_detail(
                            &params.0.query,
                            anchor_path,
                            neighbors,
                            &candidate_paths,
                        ),
                    ))
                };
            }
            view
        };
        // Optional frecency-fusion rerank. Activated when the caller requests
        // `rank_by="frecency"`, independent of the old persistent-collection
        // feature flag. Discovery still never opens a writeable frecency store:
        // this branch only reads existing persistent rows and current-process
        // session rows, then reports explicit evidence for every fallback.
        if rank_by_frecency {
            match crate::live_index::frecency::collection_policy_from_env() {
                crate::capability::FrecencyCollectionPolicy::Disabled => {
                    frecency_evidence = Some(frecency_ranking_evidence(
                        CapabilityStatus::DisabledByPolicy,
                        "operator policy disabled frecency collection and ranking; path ranking returned",
                    ));
                }
                _ => match &mut view {
                    SearchFilesView::Found { hits, .. } if hits.is_empty() => {
                        frecency_evidence = Some(frecency_ranking_evidence(
                            CapabilityStatus::FallbackUsed,
                            "no returned candidates to score; path ranking returned",
                        ));
                    }
                    SearchFilesView::Found { hits, .. } => {
                        if let Some(repo_root) = self.capture_repo_root() {
                            let candidate_count = hits.len();
                            let hit_paths: Vec<std::path::PathBuf> = hits
                                .iter()
                                .map(|hit| std::path::PathBuf::from(&hit.path))
                                .collect();
                            let path_refs: Vec<&std::path::Path> =
                                hit_paths.iter().map(|path| path.as_path()).collect();
                            let now_ts = std::time::SystemTime::now()
                                .duration_since(std::time::UNIX_EPOCH)
                                .map(|duration| duration.as_secs() as i64)
                                .unwrap_or(0);
                            match crate::live_index::frecency::ranking_scores_for_paths(
                                &repo_root, &path_refs, now_ts,
                            ) {
                                Ok(Some(snapshot)) if !snapshot.scores.is_empty() => {
                                    let scored_count = snapshot.scores.len();
                                    let breakdowns =
                                        crate::live_index::search::score_hits_by_frecency_fusion(
                                            hits,
                                            &snapshot.scores,
                                        );
                                    let taken = std::mem::take(hits);
                                    *hits =
                                        crate::live_index::search::reorder_hits_by_frecency_fusion(
                                            taken,
                                            &breakdowns,
                                        );
                                    frecency_evidence = Some(frecency_ranking_evidence(
                                        CapabilityStatus::Applied,
                                        format!(
                                            "{scored_count}/{candidate_count} returned candidates had frecency scores from {}; requested frecency ranking applied",
                                            snapshot.source
                                        ),
                                    ));
                                }
                                Ok(Some(snapshot)) => {
                                    frecency_evidence = Some(frecency_ranking_evidence(
                                        CapabilityStatus::FallbackUsed,
                                        format!(
                                            "{} is empty or has no scores for returned candidates; path ranking returned",
                                            snapshot.source
                                        ),
                                    ));
                                }
                                Ok(None) => {
                                    frecency_evidence = Some(frecency_ranking_evidence(
                                        CapabilityStatus::FallbackUsed,
                                        "no frecency history found; path ranking returned",
                                    ));
                                }
                                Err(reason) => {
                                    frecency_evidence = Some(frecency_ranking_evidence(
                                        CapabilityStatus::Unavailable,
                                        format!(
                                            "unable to read frecency history: {reason}; path ranking returned"
                                        ),
                                    ));
                                }
                            }
                        } else {
                            frecency_evidence = Some(frecency_ranking_evidence(
                                CapabilityStatus::Unavailable,
                                "no repository root is bound; path ranking returned",
                            ));
                        }
                    }
                    _ => {
                        frecency_evidence = Some(frecency_ranking_evidence(
                            CapabilityStatus::FallbackUsed,
                            "no returned candidates to score; path ranking returned",
                        ));
                    }
                },
            }
        }
        let envelope = match &view {
            SearchFilesView::Found {
                hits,
                overflow_count,
                ..
            } => {
                let guard = self.index.read();
                let scope = match params.0.current_file.as_deref() {
                    Some(current_file) => {
                        format!("ranked indexed file paths; current file boost `{current_file}`")
                    }
                    None => "ranked indexed file paths".to_string(),
                };
                Some(search_format::format_search_envelope(
                    search_files_match_type_label(&view),
                    if rank_by_path_cochange {
                        "current index + optional coupling store"
                    } else if rank_by_frecency {
                        "current index + optional frecency history"
                    } else {
                        "current index"
                    },
                    search_parse_state_for_paths(&guard, hits.iter().map(|hit| hit.path.as_str())),
                    &search_completeness_label(*overflow_count, hidden_noise_count),
                    &search_files_scope_summary(scope, include_vendor, include_personal_tooling),
                    &search_paths_evidence(hits.iter().map(|hit| hit.path.as_str())),
                ))
            }
            _ => None,
        };
        let output = format::search_files_result_view(&view);
        let had_envelope = envelope.is_some();
        let mut result = match envelope {
            Some(envelope) => format!("{envelope}\n\n{output}"),
            None => output,
        };
        if !had_envelope {
            append_search_files_filter_summary(
                &mut result,
                include_vendor,
                include_personal_tooling,
            );
        }
        if let Some(evidence) = cochange_evidence.as_ref() {
            result.push_str("\n\n");
            result.push_str(&format::capability_evidence_line(evidence));
        }
        if let Some(evidence) = frecency_evidence.as_ref() {
            result.push_str("\n\n");
            result.push_str(&format::capability_evidence_line(evidence));
        }
        if let Some(evidence) = ranking_diagnostics.evidence.as_ref() {
            result.push_str("\n\n");
            result.push_str(&format::capability_evidence_line(evidence));
        }
        if debug_ranking {
            result.push_str("\n\n");
            result.push_str(&search_files_ranking_explanation(
                &view,
                params.0.rank_by.as_deref(),
                cochange_evidence.as_ref(),
                frecency_evidence.as_ref(),
            ));
        }
        if let Some(note) = search_files_hidden_noise_note(
            hidden_noise_count,
            include_vendor,
            include_personal_tooling,
        ) {
            result.push_str("\n\n");
            result.push_str(&note);
        }
        if matches!(view, SearchFilesView::NotFound { .. }) {
            let matching_untracked_paths = matching_untracked_paths_for_search_files(
                self,
                &params.0.query,
                include_vendor,
                include_personal_tooling,
            );
            append_untracked_file_diagnostic(&mut result, &matching_untracked_paths);
        }
        self.session_context.record_summary_output(
            "search_files",
            (result.len() / 4).min(u32::MAX as usize) as u32,
        );
        // Intentionally NO frecency bump here — search_files is a discovery
        // tool, and bumping on discovery creates a positive feedback loop
        // where files that rank high are searched more, which makes them
        // rank even higher. Bumps fire only on commitment (get_file_context,
        // get_file_content, get_symbol, get_symbol_context, and the 7 edit
        // tools via EditHook). See wiki `[[SymForge Frecency-Weighted File
        // Ranking]]` §"Search tools deliberately do NOT bump".
        result
    }

    fn project_id_for_root(root: &Path) -> String {
        let normalized = root.display().to_string().replace('\\', "/");
        let stable_path = if cfg!(windows) {
            normalized.to_lowercase()
        } else {
            normalized
        };
        format!(
            "project-{}",
            crate::hash::digest_hex(stable_path.as_bytes())
        )
    }

    fn runtime_status_for(
        &self,
        published: &crate::live_index::PublishedIndexState,
        mode: format::RuntimeMode,
        project_id: Option<String>,
        session_id: Option<String>,
        project_root: Option<PathBuf>,
    ) -> format::RuntimeStatus {
        let project_root = project_root.or_else(|| self.capture_repo_root());
        let project_id = project_id.unwrap_or_else(|| {
            project_root
                .as_deref()
                .map(Self::project_id_for_root)
                .unwrap_or_else(|| "project-unknown".to_string())
        });
        let session_id =
            session_id.unwrap_or_else(|| format!("local-process-{}", std::process::id()));

        format::RuntimeStatus {
            mode,
            project_root: project_root.map(|root| root.to_string_lossy().replace('\\', "/")),
            project_id,
            session_id,
            index_generation: published.generation,
            project_generation: self.index.current_project_generation(),
            reset_project_generation: self.index.current_reset_project_generation(),
            load_source: published.load_source,
        }
    }

    fn local_runtime_mode(&self) -> format::RuntimeMode {
        if self.daemon_client.is_some() && self.daemon_degraded.load(Ordering::Relaxed) {
            format::RuntimeMode::DaemonDegradedLocalFallback
        } else {
            format::RuntimeMode::LocalProcess
        }
    }

    fn health_for_runtime(
        &self,
        mode: format::RuntimeMode,
        project_id: Option<String>,
        session_id: Option<String>,
        project_root: Option<PathBuf>,
    ) -> String {
        let published = self.index.published_state();
        let runtime_status =
            self.runtime_status_for(&published, mode, project_id, session_id, project_root);
        let watcher_guard = self.watcher_info.lock();
        let rejected_stale_mutations = self.index.current_rejected_stale_mutations();
        let mut result = format::health_report_from_published_state(
            &published,
            &watcher_guard,
            rejected_stale_mutations,
        );
        result.push('\n');
        result.push_str(&format::format_runtime_status(&runtime_status));
        let sidecar_status = sidecar_status_for_server(self);
        result.push('\n');
        result.push_str(&format::format_sidecar_status(&sidecar_status));

        // Append token savings section if the sidecar's TokenStats are available.
        if let Some(ref stats) = self.token_stats {
            let snap = stats.summary();
            let savings = format::format_token_savings(&snap);
            if !savings.is_empty() {
                result.push('\n');
                result.push_str(&savings);
            }

            // Append per-tool call counts.
            let counts = stats.tool_call_counts();
            let counts_section = format::format_tool_call_counts(&counts);
            if !counts_section.is_empty() {
                result.push('\n');
                result.push_str(&counts_section);
            }

            // Append per-tool token efficiency breakdown.
            let token_details = stats.tool_token_details();
            let breakdown = format::format_tool_token_breakdown(&token_details);
            if !breakdown.is_empty() {
                result.push('\n');
                result.push_str(&breakdown);
            }
        }

        let adoption =
            crate::cli::hook::load_hook_adoption_snapshot(self.capture_repo_root().as_deref());
        let adoption_section = format::format_hook_adoption(&adoption);
        if !adoption_section.is_empty() {
            result.push('\n');
            result.push_str(&adoption_section);
        }

        // Append git temporal summary.
        result.push('\n');
        result.push_str(&format::git_temporal_health_line(
            &self.index.git_temporal(),
        ));

        let capabilities = {
            let repo_root = self.capture_repo_root();
            let guard = self.index.read();
            capability_status_report(&guard, repo_root.as_deref())
        };
        result.push('\n');
        result.push_str(&capabilities.full_text());

        // Append worktree-awareness misuse counter (rolling last-hour window).
        result.push('\n');
        result.push_str(&format!(
            "── Worktree-awareness misuse ──\nedit tool calls without working_directory (last hour): {}",
            self.worktree_misuse.current_window_count(),
        ));

        // Append frecency diagnostics when SYMFORGE_FRECENCY=1. The feature-flag
        // guard mirrors the one in `frecency::bump`; when the flag is unset,
        // the health output remains compact unless the feature is explicitly enabled.
        if std::env::var(crate::live_index::frecency::FRECENCY_FLAG_ENV).as_deref() == Ok("1")
            && let Some(repo_root) = self.capture_repo_root()
            && let Ok(store) = crate::live_index::frecency::FrecencyStore::open(
                &repo_root.join(crate::paths::SYMFORGE_FRECENCY_DB_PATH),
            )
        {
            let now_ts = std::time::SystemTime::now()
                .duration_since(std::time::UNIX_EPOCH)
                .map(|d| d.as_secs() as i64)
                .unwrap_or(0);
            if let Ok(top) = store.top_frecent(10, now_ts) {
                result.push('\n');
                result.push_str(&format::format_frecency_top(&top));
            }
            // "Last 10 frecency bumps" is additionally gated on
            // the ranking diagnostics default-on policy; it is a debug-only
            // surface for ranker tuning, not the default health view.
            if ranking_diagnostics_policy_from_env() == RankingDiagnosticsPolicy::DefaultOn
                && let Ok(last) = store.last_10_bumps()
            {
                result.push('\n');
                result.push_str(&format::format_frecency_last_bumps(&last));
            }
        }

        // Surface a version-drift warning when this daemon's binary is older
        // than another installed copy (e.g. an npm-updated package). Read-only;
        // never copies or replaces the binary (see version_registry).
        if let Some(drift) = crate::version_registry::drift_banner_default() {
            result.push('\n');
            result.push_str(&drift);
        }

        // Surface a PATH-shadow warning when a bare `symforge` would run a
        // DIFFERENT install than the binary serving this runtime. Read-only;
        // emits the exact remediation commands, never executes them.
        if let Some(report) = current_exe_shadow_report() {
            result.push('\n');
            result.push_str(&crate::path_shadow::format_shadow_warning(&report));
        }

        result
    }

    fn health_compact_for_runtime(
        &self,
        mode: format::RuntimeMode,
        project_id: Option<String>,
        session_id: Option<String>,
        project_root: Option<PathBuf>,
    ) -> String {
        let published = self.index.published_state();
        let runtime_status =
            self.runtime_status_for(&published, mode, project_id, session_id, project_root);
        let watcher_guard = self.watcher_info.lock();
        let rejected_stale_mutations = self.index.current_rejected_stale_mutations();
        let mut result = format::health_report_compact_from_published_state(
            &published,
            &watcher_guard,
            rejected_stale_mutations,
        );
        result.push('\n');
        result.push_str(&format::format_runtime_status_compact(&runtime_status));
        let sidecar_status = sidecar_status_for_server(self);
        result.push('\n');
        result.push_str(&format::format_sidecar_status_compact(&sidecar_status));

        if let Some(ref stats) = self.token_stats {
            let snap = stats.summary();
            let total_saved =
                snap.read_saved_tokens + snap.edit_saved_tokens + snap.grep_saved_tokens;
            let total_fires =
                snap.read_fires + snap.edit_fires + snap.write_fires + snap.grep_fires;
            if total_fires > 0 {
                result.push_str(&format!(
                    "\nToken savings: ~{} tokens saved across {} hook fires",
                    total_saved, total_fires
                ));
            }

            let counts = stats.tool_call_counts();
            let total_tool_calls: usize = counts.iter().map(|(_, count)| *count).sum();
            if total_tool_calls > 0 {
                result.push_str(&format!(
                    "\nTool calls: {} recorded across {} tools",
                    total_tool_calls,
                    counts.len()
                ));
            }
        }

        let git_temporal = format::git_temporal_health_line(&self.index.git_temporal());
        let git_temporal_summary = git_temporal
            .lines()
            .next()
            .unwrap_or("Git temporal: unknown");
        result.push_str(&format!(
            "\n{} | Worktree misuse/hour: {}",
            git_temporal_summary,
            self.worktree_misuse.current_window_count()
        ));

        let capabilities = {
            let repo_root = self.capture_repo_root();
            let guard = self.index.read();
            capability_status_report(&guard, repo_root.as_deref())
        };
        result.push('\n');
        result.push_str(&capabilities.compact_text());

        if let Some(drift) = crate::version_registry::drift_banner_default() {
            result.push('\n');
            result.push_str(&drift);
        }

        // Compact PATH-shadow banner (one line, points at full health for the
        // exact fix). See `health_for_runtime` for the full block.
        if let Some(report) = current_exe_shadow_report() {
            result.push('\n');
            result.push_str(&crate::path_shadow::format_shadow_warning_compact(&report));
        }

        result
    }

    pub(crate) fn health_for_daemon_session(
        &self,
        project_id: String,
        session_id: String,
        project_root: PathBuf,
    ) -> String {
        self.health_for_runtime(
            format::RuntimeMode::DaemonReusedSession,
            Some(project_id),
            Some(session_id),
            Some(project_root),
        )
    }

    pub(crate) fn health_compact_for_daemon_session(
        &self,
        project_id: String,
        session_id: String,
        project_root: PathBuf,
    ) -> String {
        self.health_compact_for_runtime(
            format::RuntimeMode::DaemonReusedSession,
            Some(project_id),
            Some(session_id),
            Some(project_root),
        )
    }

    /// Diagnostic: index status, file/symbol counts, load time, watcher state, token savings,
    /// project/session identity, hook adoption metrics, git temporal status. Always responds
    /// even during loading. Use to verify SymForge is working.
    #[tool(
        description = "Diagnostic: index status, file/symbol counts, project/session identity, load time, watcher state, token savings, hook adoption metrics, git temporal status. Always responds even during loading. Use to verify SymForge is working. NOT for diagnosing a specific file or symbol (use get_file_context or get_symbol).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn health(&self) -> String {
        if let Some(result) = self.proxy_tool_call_without_params("health").await {
            // Ops guard (SF-001/SF-009 root-cause class): warn loudly if the daemon
            // we proxied to is serving an OLDER binary than this front-end.
            return append_daemon_staleness_warning(result, env!("CARGO_PKG_VERSION"));
        }
        let result = self.health_for_runtime(self.local_runtime_mode(), None, None, None);
        self.session_context
            .record_summary_output("health", (result.len() / 4).min(u32::MAX as usize) as u32);
        result
    }

    /// Compact diagnostic: essential health fields only. Use when token budget matters.
    #[tool(
        description = "Compact diagnostic: essential index, project/session identity, watcher, admission, and token-savings health fields only. Prefer this when token budget matters; use health for full diagnostic lists. NOT for diagnosing a specific file or symbol (use get_file_context or get_symbol).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn health_compact(&self) -> String {
        if let Some(result) = self.proxy_tool_call_without_params("health_compact").await {
            // Ops guard (SF-001/SF-009 root-cause class): warn loudly if the daemon
            // we proxied to is serving an OLDER binary than this front-end.
            return append_daemon_staleness_warning(result, env!("CARGO_PKG_VERSION"));
        }

        let result = self.health_compact_for_runtime(self.local_runtime_mode(), None, None, None);
        self.session_context.record_summary_output(
            "health_compact",
            (result.len() / 4).min(u32::MAX as usize) as u32,
        );
        result
    }

    /// Serialize the current in-memory index to `.symforge/index.bin` immediately.
    #[tool(
        name = "checkpoint_now",
        description = "Serialize the current in-memory index to `.symforge/index.bin` immediately. Uses the same atomic snapshot path as shutdown persistence and reports failures explicitly.",
        annotations(
            read_only_hint = false,
            destructive_hint = false,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    pub(crate) async fn checkpoint_now(&self, params: Parameters<CheckpointNowInput>) -> String {
        // Proxy-first: in daemon-proxy mode the daemon owns the authoritative
        // live index, while this process holds only `LiveIndex::empty()`.
        // Forward to the daemon (where `daemon_client` is None and `repo_root`
        // is real) so the checkpoint runs against the populated index. On
        // local-fallback the proxy returns None and the in-process logic below
        // checkpoints the locally reloaded index.
        let daemon_configured = self.daemon_client.is_some();
        if let Some(result) = self.proxy_tool_call("checkpoint_now", &params.0).await {
            return result;
        }
        let repo_root = match self.capture_repo_root() {
            Some(root) => root,
            None => return "Checkpoint failed: no repository root configured.".to_string(),
        };
        let index = Arc::clone(&self.index);
        let checkpoint_root = repo_root.clone();
        let verify_after_write = params.0.verify_after_write.unwrap_or(false);
        match tokio::task::spawn_blocking(move || {
            let report =
                crate::live_index::persist::checkpoint_shared_index(&index, &checkpoint_root)?;
            if verify_after_write
                && crate::live_index::persist::load_snapshot(&checkpoint_root).is_none()
            {
                anyhow::bail!(
                    "checkpoint verification failed: snapshot did not deserialize after write"
                );
            }
            anyhow::Ok(report)
        })
        .await
        {
            Ok(Ok(report)) => {
                let mut message = format!(
                    "Checkpoint complete: wrote {} bytes for {} file(s) to {}",
                    report.bytes,
                    report.files,
                    report.path.display()
                );
                // Honest receipt (review finding 2, post-v7.19.0): reaching this
                // point with a daemon CONFIGURED means the proxy failed and we
                // fell back to checkpointing the locally reloaded index, which
                // may lag the daemon's authoritative live index. Say so, instead
                // of an output indistinguishable from the daemon path — the
                // snapshot written here can be staler than one the daemon would
                // have written.
                if daemon_configured {
                    message.push_str(
                        "\nnote: daemon unreachable; this checkpoint wrote the locally reloaded \
                         index, which may lag the daemon's live index. Re-run checkpoint_now once \
                         the daemon is reachable to persist its authoritative state.",
                    );
                }
                message
            }
            Ok(Err(error)) => format!("Checkpoint failed: {error}"),
            Err(join_error) => format!("Checkpoint failed: checkpoint task panicked: {join_error}"),
        }
    }

    /// Reindex a directory from scratch — replaces the current index, restarts watcher, triggers
    /// git temporal analysis. Use when switching projects. Destructive to current index.
    #[tool(
        description = "Reindex a directory from scratch — replaces the current index, restarts watcher, triggers git temporal analysis. Use when switching projects. Destructive to current index. NOT for re-reading a single changed file (use analyze_file_impact). NOT for reading content from an existing index (use get_file_content).",
        annotations(
            // Destructive: replaces the active index (discards current contents),
            // matching the description. Also idempotent: re-running with the same
            // path converges to the same index. Destructive + idempotent are
            // independent MCP hints, so both are honestly true here.
            read_only_hint = false,
            destructive_hint = true,
            idempotent_hint = true,
            open_world_hint = false
        )
    )]
    pub async fn index_folder(&self, params: Parameters<IndexFolderInput>) -> String {
        if let Some(result) = self.proxy_tool_call("index_folder", &params.0).await {
            // The daemon has rebound the session to the new project. Update our
            // local repo_root so that local-fallback tools (what_changed,
            // analyze_file_impact) and ensure_local_index use the correct root
            // if the daemon connection degrades later.
            if result.starts_with("Indexed ") {
                let new_root = PathBuf::from(&params.0.path);
                self.set_repo_root(Some(new_root));
                // Trust-critical: invalidate any stale in-process index left
                // over from a previous local-fallback load. Without this, a
                // server that already populated `self.index` for the OLD
                // project (via `ensure_local_index`) would keep serving the old
                // project from every tool that falls back to local execution
                // (search_symbols, search_text, get_file_context, conventions,
                // explore, ...), because `ensure_local_index` only reloads when
                // the index is empty. Resetting to empty forces the next local
                // fallback to reload from the NEW root, so no tool can silently
                // mix projects after a daemon-proxied `index_folder` switch.
                self.index.reset_to_empty();
            }
            return result;
        }
        let input = params.0;
        let root = PathBuf::from(&input.path);
        if !root.exists() {
            return format!("Path does not exist: {}", input.path);
        }
        if !root.is_dir() {
            return format!("Path is not a directory: {}", input.path);
        }
        // Trust boundary: canonicalize and reject sensitive system paths.
        let root = match root.canonicalize() {
            Ok(p) => p,
            Err(e) => return format!("Cannot resolve path: {e}"),
        };
        if crate::paths::is_sensitive_path(&root) {
            return format!(
                "Refused to index sensitive system path: {}.                  Use a project directory instead.",
                root.display()
            );
        }
        // Bounded discovery: a huge NON-sensitive tree (a deep monorepo, a
        // generated-file bomb, a user-pointed scratch dir) can no longer OOM or
        // panic the reload. `discovery::discover_all_files`/`discover_files`
        // enforce a file-count and cumulative-byte ceiling
        // (`SYMFORGE_MAX_INDEX_FILES` / `SYMFORGE_MAX_INDEX_BYTES`, generous
        // defaults) DURING the walk and return a graceful "tree too large to
        // index" error before the in-memory index build commits. The reload
        // below propagates that error to the caller instead of crashing.
        let reset_requested = index_folder_reset_requested();
        let current_root = self.capture_repo_root();
        let idempotency = match input.idempotency_key.as_deref() {
            Some(raw_key) => match crate::idempotency::begin_index_folder_replay(
                &root,
                current_root.as_deref(),
                &root,
                raw_key,
                reset_requested,
            ) {
                Ok(crate::idempotency::ReplayStart::FirstExecution(active)) => Some(active),
                Ok(crate::idempotency::ReplayStart::Replay(response)) => return response,
                Err(error) => return crate::idempotency::format_tool_error(&error),
            },
            None => None,
        };
        let reset_report = if reset_requested {
            match crate::live_index::persist::reset_snapshot_state(&root) {
                Ok(report) => Some(report),
                Err(error) => {
                    let output = format!("Reset failed: {error}");
                    if let Some(idempotency) = &idempotency {
                        let _ = idempotency.fail(output.clone());
                    }
                    return output;
                }
            }
        } else {
            None
        };
        let previous_watcher = self.watcher_handle.lock().take();
        if let Some(previous_watcher) = previous_watcher {
            previous_watcher.stop_token.store(true, Ordering::Release);
            let mut previous_task = previous_watcher.task;
            if tokio::time::timeout(Duration::from_secs(2), &mut previous_task)
                .await
                .is_err()
            {
                tracing::warn!(
                    "watcher: previous watcher did not stop within 2s before index reload"
                );
                previous_task.abort();
            }
        }
        let index = Arc::clone(&self.index);
        let reload_root = root.clone();
        match tokio::task::spawn_blocking(move || index.reload(&reload_root)).await {
            Ok(Ok(())) => {
                if reset_report.is_some() {
                    self.index.mark_index_folder_reset();
                }
                let published = self.index.published_state();
                let file_count = published.file_count;
                let symbol_count = published.symbol_count;

                self.set_repo_root(Some(root.clone()));

                // Restart the file watcher at the new root so freshness continues.
                let watcher_handle = crate::watcher::restart_watcher(
                    root.clone(),
                    Arc::clone(&self.index),
                    Arc::clone(&self.watcher_info),
                    None,
                );
                self.watcher_handle.lock().replace(watcher_handle);
                tracing::info!(root = %root.display(), "file watcher restarted after index_folder");

                // Refresh git temporal data for the new root.
                let expected_gen = self.index.current_project_generation();
                crate::live_index::git_temporal::spawn_git_temporal_computation(
                    Arc::clone(&self.index),
                    root.clone(),
                    expected_gen,
                );

                let mut output = format!("Indexed {} files, {} symbols.", file_count, symbol_count);
                if let Some(reset_report) = reset_report {
                    output.push_str(&format!(
                        "\nReset: scope={} | removed={} | missing={}",
                        crate::live_index::persist::SNAPSHOT_RESET_SCOPE_LABEL,
                        reset_report.removed_count(),
                        reset_report.missing_count()
                    ));
                    let runtime_status = self.runtime_status_for(
                        &published,
                        self.local_runtime_mode(),
                        None,
                        None,
                        Some(root.clone()),
                    );
                    output.push('\n');
                    output.push_str(&format::format_runtime_status(&runtime_status));
                }
                if let Some(idempotency) = &idempotency
                    && let Err(error) = idempotency.complete(output.clone())
                {
                    output.push_str(&format!(
                        "\nIdempotency warning: failed to store replay result: {error}"
                    ));
                }
                self.session_context.record_summary_output(
                    "index_folder",
                    (output.len() / 4).min(u32::MAX as usize) as u32,
                );
                output
            }
            Ok(Err(e)) => {
                let output = format!("Index failed: {e}");
                if let Some(idempotency) = &idempotency {
                    let _ = idempotency.fail(output.clone());
                }
                output
            }
            Err(join_err) => {
                let output = format!("Index failed: reload task panicked: {join_err}");
                if let Some(idempotency) = &idempotency {
                    let _ = idempotency.fail(output.clone());
                }
                output
            }
        }
    }

    /// List changed files: uncommitted=true for working tree, git_ref for ref comparison, since for
    /// timestamp filter. Use to see what files changed.
    /// Set code_only=true to exclude non-source files (docs, configs, lock files).
    /// Set include_symbol_diff=true to also get a symbol-level diff in the same response (git modes only).
    #[tool(
        description = "List changed files: uncommitted=true for working tree, git_ref for ref comparison, since for timestamp filter. Filter with path_prefix and/or language. Set code_only=true to exclude non-source files (docs, configs, lock files). Set include_symbol_diff=true to also include a symbol-level diff in the same response (git_ref and uncommitted modes only), saving a round-trip vs calling diff_symbols separately. NOT for symbol-level change detail alone (use diff_symbols). NOT for finding files by name or content (use search_files or search_text).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn what_changed(&self, params: Parameters<WhatChangedInput>) -> String {
        if let Some(result) = self.proxy_tool_call("what_changed", &params.0).await {
            return result;
        }
        if params.0.estimate == Some(true) {
            let with_diff = params.0.include_symbol_diff.unwrap_or(false);
            let est = if with_diff { 500 } else { 200 };
            return format!(
                "Estimate for what_changed: ~{} tokens (include_symbol_diff={})",
                est, with_diff
            );
        }
        let effective_repo_root = self.effective_repo_root_for_git_tools();
        let requested_git_mode = params.0.uncommitted.unwrap_or(false)
            || params
                .0
                .git_ref
                .as_deref()
                .map(str::trim)
                .is_some_and(|git_ref| !git_ref.is_empty());
        if params.0.since.is_none() && !requested_git_mode && effective_repo_root.is_none() {
            return "No repo root attached; call index_folder(path=...) or pass since=..."
                .to_string();
        }
        let mode = match determine_what_changed_mode(&params.0, effective_repo_root.is_some()) {
            Ok(mode) => mode,
            Err(message) => return message,
        };

        match &mode {
            WhatChangedMode::Timestamp(since_ts) => {
                let view = {
                    let guard = self.index.read();
                    loading_guard!(guard);
                    guard.capture_what_changed_timestamp_view()
                };
                if *since_ts < view.loaded_secs {
                    match filter_paths_by_prefix_and_language(
                        view.paths.clone(),
                        params.0.path_prefix.as_deref(),
                        params.0.language.as_deref(),
                        params.0.code_only.unwrap_or(false),
                    ) {
                        Ok(filtered) => {
                            if filtered.is_empty() {
                                return if view.paths.is_empty() {
                                    "Index is empty — no files tracked.".to_string()
                                } else {
                                    "No indexed files matched the requested filters since the last index load.".to_string()
                                };
                            }
                            let envelope = {
                                let guard = self.index.read();
                                search_format::format_search_envelope(
                                    "exact (timestamp compare)",
                                    what_changed_source_authority_label(&mode),
                                    search_parse_state_for_paths(
                                        &guard,
                                        filtered.iter().map(|path| path.as_str()),
                                    ),
                                    &changed_paths_completeness_label(
                                        view.paths.len(),
                                        filtered.len(),
                                    ),
                                    &what_changed_scope_summary(&params.0, &mode),
                                    &search_paths_evidence(
                                        filtered.iter().map(|path| path.as_str()),
                                    ),
                                )
                            };
                            let output = format::what_changed_paths_result(
                                &filtered,
                                "No indexed files matched the requested filters since the last index load.",
                            );
                            let result = format!("{envelope}\n\n{output}");
                            self.session_context.record_summary_output(
                                "what_changed",
                                (result.len() / 4).min(u32::MAX as usize) as u32,
                            );
                            result
                        }
                        Err(error) => {
                            self.session_context.record_summary_output(
                                "what_changed",
                                (error.len() / 4).min(u32::MAX as usize) as u32,
                            );
                            error
                        }
                    }
                } else {
                    let result = format::what_changed_timestamp_view(&view, *since_ts);
                    self.session_context.record_summary_output(
                        "what_changed",
                        (result.len() / 4).min(u32::MAX as usize) as u32,
                    );
                    result
                }
            }
            WhatChangedMode::Uncommitted => {
                let guard = self.index.read();
                loading_guard!(guard);
                drop(guard);

                let Some(repo_root) = effective_repo_root.as_deref() else {
                    return "Git change detection unavailable; pass `since` for timestamp mode."
                        .to_string();
                };
                let repo = match crate::git::GitRepo::open(repo_root) {
                    Ok(r) => r,
                    Err(e) => return format!("Git change detection failed: {e}"),
                };
                match repo.uncommitted_paths() {
                    Ok(paths) => {
                        let total_paths = paths.len();
                        match filter_paths_by_prefix_and_language(
                            paths,
                            params.0.path_prefix.as_deref(),
                            params.0.language.as_deref(),
                            params.0.code_only.unwrap_or(false),
                        ) {
                            Ok(filtered) => {
                                if filtered.is_empty() {
                                    return if total_paths == 0 {
                                        "No uncommitted changes detected.".to_string()
                                    } else {
                                        "No uncommitted changes matched the requested filters."
                                            .to_string()
                                    };
                                }
                                let include_symbol_diff =
                                    params.0.include_symbol_diff.unwrap_or(false);
                                let envelope = search_format::format_search_envelope(
                                    "exact (uncommitted working tree)",
                                    what_changed_source_authority_label(&mode),
                                    what_changed_parse_state_label(&mode, include_symbol_diff),
                                    &changed_paths_completeness_label(total_paths, filtered.len()),
                                    &what_changed_scope_summary(&params.0, &mode),
                                    &search_paths_evidence(
                                        filtered.iter().map(|path| path.as_str()),
                                    ),
                                );
                                let mut output = format::what_changed_paths_result(
                                    &filtered,
                                    "No uncommitted changes detected.",
                                );
                                if include_symbol_diff {
                                    let changed_refs: Vec<&str> =
                                        filtered.iter().map(|s| s.as_str()).collect();
                                    let sym_diff = format::diff_symbols_result_view(
                                        "HEAD",
                                        "",
                                        &changed_refs,
                                        &repo,
                                        true,
                                        false,
                                    );
                                    output.push_str("\n\n");
                                    output.push_str(&sym_diff);
                                }
                                let result = format!("{envelope}\n\n{output}");
                                self.session_context.record_summary_output(
                                    "what_changed",
                                    (result.len() / 4).min(u32::MAX as usize) as u32,
                                );
                                format::enforce_token_budget(result, params.0.max_tokens)
                            }
                            Err(e) => e,
                        }
                    }
                    Err(e) => format!("Git change detection failed: {e}"),
                }
            }
            WhatChangedMode::GitRef(git_ref) => {
                let guard = self.index.read();
                loading_guard!(guard);
                drop(guard);

                let Some(repo_root) = effective_repo_root.as_deref() else {
                    return "Git change detection unavailable; pass `since` for timestamp mode."
                        .to_string();
                };
                let repo = match crate::git::GitRepo::open(repo_root) {
                    Ok(r) => r,
                    Err(e) => return format!("Git change detection failed: {e}"),
                };
                match repo.changed_paths_from_ref(git_ref) {
                    Ok(paths) => {
                        let total_paths = paths.len();
                        match filter_paths_by_prefix_and_language(
                            paths,
                            params.0.path_prefix.as_deref(),
                            params.0.language.as_deref(),
                            params.0.code_only.unwrap_or(false),
                        ) {
                            Ok(filtered) => {
                                if filtered.is_empty() {
                                    return if total_paths == 0 {
                                        format!(
                                            "No changes detected relative to git ref '{git_ref}'."
                                        )
                                    } else {
                                        format!(
                                            "No changes relative to git ref '{git_ref}' matched the requested filters."
                                        )
                                    };
                                }
                                let include_symbol_diff =
                                    params.0.include_symbol_diff.unwrap_or(false);
                                let envelope = search_format::format_search_envelope(
                                    "exact (git ref diff)",
                                    what_changed_source_authority_label(&mode),
                                    what_changed_parse_state_label(&mode, include_symbol_diff),
                                    &changed_paths_completeness_label(total_paths, filtered.len()),
                                    &what_changed_scope_summary(&params.0, &mode),
                                    &search_paths_evidence(
                                        filtered.iter().map(|path| path.as_str()),
                                    ),
                                );
                                let mut output = format::what_changed_paths_result(
                                    &filtered,
                                    &format!(
                                        "No changes detected relative to git ref '{git_ref}'."
                                    ),
                                );
                                if include_symbol_diff {
                                    let base =
                                        git_ref.strip_prefix("branch:").unwrap_or(git_ref.as_str());
                                    let changed_refs: Vec<&str> =
                                        filtered.iter().map(|s| s.as_str()).collect();
                                    let sym_diff = format::diff_symbols_result_view(
                                        base,
                                        "HEAD",
                                        &changed_refs,
                                        &repo,
                                        true,
                                        false,
                                    );
                                    output.push_str("\n\n");
                                    output.push_str(&sym_diff);
                                }
                                let result = format!("{envelope}\n\n{output}");
                                self.session_context.record_summary_output(
                                    "what_changed",
                                    (result.len() / 4).min(u32::MAX as usize) as u32,
                                );
                                result
                            }
                            Err(e) => e,
                        }
                    }
                    Err(e) => format!("Git change detection failed: {e}"),
                }
            }
        }
    }

    /// Read raw file content. Modes: full file, line range, around_line/around_match/around_symbol,
    /// or chunked paging. Use this for exact docs/config reads, whitespace-sensitive inspection,
    /// or when you need actual source text that other tools don't provide.
    /// For structured understanding use get_file_context. For a single function
    /// body use get_symbol.
    #[tool(
        name = "get_file_content",
        description = "Read exact raw file content. Modes: full file, line range, around_line/around_match/around_symbol, or chunked paging. Use this for exact docs/config reads, whitespace-sensitive inspection, or exact source excerpts after narrowing with get_file_context, search_text, or get_symbol. For structured code understanding use get_file_context first. For a single function body use get_symbol. Accepts offset/limit (Read-tool idiom) as aliases for start_line/end_line. Use max_tokens to request a smaller response budget. Unknown fields are rejected with an error naming the invalid param. Responses are capped at ~60 KB; if truncated, a footer suggests chunk_index+max_lines, around_line, or around_symbol.",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn get_file_content_tool(
        &self,
        params: Parameters<GetFileContentInput>,
    ) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
        let started = Instant::now();
        let output = self.get_file_content(params).await;
        let outcome_class = classify_get_file_content_output(&output);
        self.record_tool_completion(
            "get_file_content",
            &output,
            started.elapsed(),
            outcome_class,
        );
        statused_tool_result(output, outcome_class)
    }

    pub(crate) async fn get_file_content(&self, params: Parameters<GetFileContentInput>) -> String {
        let mut input = params.0;
        input.path = normalize_exact_path(&input.path);
        let max_tokens = input.max_tokens;

        if let Err(e) = normalize_file_content_aliases(&mut input) {
            return e;
        }

        if let Some(result) = self.proxy_tool_call("get_file_content", &input).await {
            return format::enforce_token_budget(
                format::cap_file_content_output(result),
                max_tokens,
            );
        }
        // Estimate mode: return token cost without reading content
        if input.estimate == Some(true) {
            let guard = self.index.read();
            loading_guard!(guard);
            if let Some(file) = guard.capture_shared_file(&input.path) {
                let file_tokens = file.content.len() / 4;
                let line_count = file.content.iter().filter(|&&b| b == b'\n').count();
                return format!(
                    "Estimate for get_file_content(path=\"{}\"):\n  ~{} tokens (~{} lines)",
                    input.path, file_tokens, line_count
                );
            } else {
                return format::not_found_file(&input.path);
            }
        }

        let options = match file_content_options_from_input(&input) {
            Ok(options) => options,
            Err(message) => return message,
        };
        freshen_exact_path_for_targeted_retrieval(self, &options.path_scope);
        let mode_annotation = match (
            &options.content_context.mode_name,
            options.content_context.mode_explicit,
        ) {
            (Some(mode), true) => format!("── mode: {} (explicit) ──\n", mode),
            _ => String::new(),
        };
        let file = {
            let guard = self.index.read();
            loading_guard!(guard);
            guard.capture_shared_file_for_scope(&options.path_scope)
        };
        match file {
            Some(file) => {
                let raw_chars = file.as_ref().content.len();
                let output = format::file_content_from_indexed_file_with_context(
                    file.as_ref(),
                    options.content_context,
                );
                self.record_tool_savings_named(
                    "get_file_content",
                    (raw_chars / 4) as u64,
                    (output.len() / 4) as u64,
                );
                self.session_context
                    .record_file(&input.path, (output.len() / 4) as u32);
                // Frecency bump — commitment tool. Indexed-file branch;
                // collection policy is resolved inside bump_frecency. See wiki
                // `[[SymForge Frecency-Weighted File Ranking]]` §"Bump hooks".
                self.bump_frecency(&[PathBuf::from(&input.path)]);
                format::enforce_token_budget(
                    format::cap_file_content_output(format!("{}{}", mode_annotation, output)),
                    max_tokens,
                )
            }
            None => {
                // Not in index — try raw disk read for non-source files
                // (Cargo.toml, package.json, workflow YAMLs, etc.)
                if let Some(root) = self.capture_repo_root() {
                    let canon_path = match edit::safe_repo_path(&root, &input.path) {
                        Ok(p) => p,
                        // Distinguish a containment violation (path escapes the
                        // repo root, e.g. `../../../etc/passwd`) from a genuine
                        // in-repo miss. `safe_repo_path` reports the former with
                        // an "outside the repository" message; surface that
                        // explicitly instead of a misleading "File not found".
                        Err(message) if message.contains("outside the repository") => {
                            return format::path_outside_repo(&input.path);
                        }
                        Err(_) => return format::not_found_file(&input.path),
                    };
                    if canon_path.is_file() {
                        match std::fs::read(&canon_path) {
                            Ok(content) => {
                                let body = format::render_file_content_bytes(
                                    &input.path,
                                    &content,
                                    options.content_context,
                                );
                                // Frecency bump — commitment tool. Raw-disk
                                // fallback branch (non-indexed source files);
                                // collection policy is resolved inside bump_frecency.
                                self.bump_frecency(&[PathBuf::from(&input.path)]);
                                return format::enforce_token_budget(
                                    format::cap_file_content_output(format!(
                                        "{}{}",
                                        mode_annotation, body
                                    )),
                                    max_tokens,
                                );
                            }
                            Err(e) => {
                                return format!("{} [error: could not read file: {e}]", input.path);
                            }
                        }
                    }
                }
                // Suggest similar files from the index
                let suggestions = {
                    let guard = self.index.read();
                    suggest_similar_files(&guard, &input.path)
                };
                format::not_found_file_with_suggestions(&input.path, &suggestions)
            }
        }
    }

    /// Find all references or implementations for a symbol. Modes: (1) default/references: call sites,
    /// Validate a file's syntax and surface parser diagnostics with exact locations when available.
    /// Best for malformed TOML/JSON/YAML and other config files where you need authoritative parse errors.
    #[tool(
        description = "Validate a file's syntax and surface parser diagnostics with exact locations when available. Best for malformed TOML/JSON/YAML and other config files where you need authoritative parse errors. Uses the indexed file when available and falls back to direct parsing from disk when needed. NOT for understanding file structure (use get_file_context). NOT for searching file content (use search_text).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn validate_file_syntax(
        &self,
        params: Parameters<ValidateFileSyntaxInput>,
    ) -> String {
        let mut input = params.0;
        input.path = normalize_exact_path(&input.path);

        if let Some(result) = self.proxy_tool_call("validate_file_syntax", &input).await {
            return result;
        }

        let path_scope = search::PathScope::exact(&input.path);
        freshen_exact_path_for_targeted_retrieval(self, &path_scope);

        let indexed_file = {
            let guard = self.index.read();
            // Skip loading_guard here — the disk-read fallback below does not
            // need the index, so we should not block when the index is still loading.
            guard.capture_shared_file(&input.path)
        };
        if let Some(file) = indexed_file {
            let output = format::validate_file_syntax_result(&input.path, file.as_ref());
            self.session_context.record_summary_output(
                "validate_file_syntax",
                (output.len() / 4).min(u32::MAX as usize) as u32,
            );
            return output;
        }

        let extension = std::path::Path::new(&input.path)
            .extension()
            .and_then(|ext| ext.to_str())
            .unwrap_or("");
        let Some(language) = LanguageId::from_extension(extension) else {
            return format!(
                "Syntax validation unavailable for {}: unsupported or unknown file extension.",
                input.path
            );
        };

        let Some(root) = self.capture_repo_root() else {
            return format::not_found_file(&input.path);
        };
        let canon_path = match edit::safe_repo_path(&root, &input.path) {
            Ok(path) => path,
            Err(_) => return format::not_found_file(&input.path),
        };
        if !canon_path.is_file() {
            return format::not_found_file(&input.path);
        }

        let bytes = match std::fs::read(&canon_path) {
            Ok(bytes) => bytes,
            Err(e) => return format!("{} [error: could not read file: {e}]", input.path),
        };
        let classification = crate::domain::FileClassification::for_code_path(&input.path);
        let result = crate::parsing::process_file_with_classification(
            &input.path,
            &bytes,
            language,
            classification,
        );
        let file = IndexedFile::from_parse_result(result, bytes);
        let output = format::validate_file_syntax_result(&input.path, &file);
        self.session_context.record_summary_output(
            "validate_file_syntax",
            (output.len() / 4).min(u32::MAX as usize) as u32,
        );
        output
    }

    /// Find all references or implementations for a symbol. Modes: (1) default/references: call sites,
    /// imports, type usages grouped by file - set compact=true for ~60-75% smaller output. (2) mode='implementations':
    /// find trait/interface implementors bidirectionally - set direction='trait'/'type'/'auto'.
    /// Use when you need 'who calls this?' or 'who implements this?'
    /// NOT for file-level dependencies (use find_dependents).
    /// NOT for full refactoring context (use get_symbol_context with sections=[...]).
    #[tool(
        name = "find_references",
        description = "Find all references or implementations for a symbol. Modes: (1) default/references: call sites, imports, type usages grouped by file - set compact=true for ~60-75% smaller output. (2) mode='implementations': find trait/interface implementors bidirectionally - set direction='trait'/'type'/'auto'. Use when you need 'who calls this?' or 'who implements this?' NOT for file-level dependencies (use find_dependents). NOT for full refactoring context (use get_symbol_context with sections=[...]).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn find_references_tool(
        &self,
        params: Parameters<FindReferencesInput>,
    ) -> Result<rmcp::model::CallToolResult, rmcp::ErrorData> {
        let started = Instant::now();
        let output = self.find_references(params).await;
        let outcome_class = classify_find_references_output(&output);
        self.record_tool_completion("find_references", &output, started.elapsed(), outcome_class);
        statused_tool_result(output, outcome_class)
    }

    pub(crate) async fn find_references(&self, params: Parameters<FindReferencesInput>) -> String {
        if let Some(result) = self.proxy_tool_call("find_references", &params.0).await {
            return result;
        }
        let input = &params.0;
        if let Some(path) = input.path.as_deref() {
            let repo_root = self.capture_repo_root();
            let degraded = {
                let guard = self.index.read();
                loading_guard!(guard);
                admission_tier_degradation_for_path(
                    &guard,
                    repo_root.as_deref(),
                    "find_references",
                    path,
                    &input.name,
                )
            };
            if let Some(result) = degraded {
                return result;
            }
        }
        let mode = input.mode.as_deref().unwrap_or("references");

        if mode == "implementations" {
            let view = {
                let guard = self.index.read();
                loading_guard!(guard);
                guard.capture_implementations_view(&input.name, input.direction.as_deref())
            };
            let cap = input.limit.unwrap_or(200).min(500);
            let limits = format::OutputLimits::new(cap, cap);
            let envelope = if !view.entries.is_empty() {
                let guard = self.index.read();
                Some(search_format::format_search_envelope(
                    find_references_match_type_label(input, mode),
                    "current index",
                    implementations_parse_state_for_paths(&guard, &view),
                    &implementations_completeness_label(&view, &limits),
                    &find_references_scope_summary(input, mode),
                    &implementations_evidence(&view),
                ))
            } else {
                None
            };
            let result = format::implementations_result_view(&view, &input.name, &limits);
            if view.entries.is_empty() {
                let guard = self.index.read();
                let is_concrete = guard.all_files().any(|(_, file)| {
                    file.symbols.iter().any(|s| {
                        s.name == input.name
                            && matches!(
                                s.kind,
                                crate::domain::SymbolKind::Class
                                    | crate::domain::SymbolKind::Struct
                                    | crate::domain::SymbolKind::Enum
                            )
                    })
                });
                if is_concrete {
                    drop(guard);
                    return format!(
                        "No implementations found for \"{}\" — it is a class/struct, not an \
                         interface/trait.\nUse find_references with mode=\"references\" to find \
                         callers and usages instead.",
                        input.name
                    );
                }
                // Check if the symbol exists at all in the indexed project
                let exists_in_project = guard
                    .all_files()
                    .any(|(_, file)| file.symbols.iter().any(|s| s.name == input.name));
                drop(guard);
                if !exists_in_project {
                    return format!(
                        "No implementations found for \"{}\" — this symbol is not defined \
                         in the indexed project (likely from an external dependency).\n\
                         Use search_text to find usages of this symbol in your code instead.",
                        input.name
                    );
                }
            }
            return match envelope {
                Some(envelope) => format!("{envelope}\n\n{result}"),
                None => result,
            };
        }

        let limits =
            format::OutputLimits::new(input.limit.unwrap_or(20), input.max_per_file.unwrap_or(10));
        let collect_qualified_usages = should_collect_qualified_usages(input);
        let (result, indexed_ranges, qualified_usages) = {
            let guard = self.index.read();
            loading_guard!(guard);
            if let Some(path) = input.path.as_deref() {
                (
                    guard.capture_find_references_view_for_symbol(
                        path,
                        &input.name,
                        input.symbol_kind.as_deref(),
                        input.symbol_line,
                        input.kind.as_deref(),
                        limits.total_hits,
                    ),
                    HashSet::new(),
                    Vec::new(),
                )
            } else {
                let kind_filter = find_references_kind_filter(input.kind.as_deref());
                let refs = guard.find_references_for_name(&input.name, kind_filter, false);
                let indexed_ranges = refs
                    .iter()
                    .map(|(file_path, reference)| ((*file_path).to_string(), reference.byte_range))
                    .collect();
                let qualified_usages = if collect_qualified_usages {
                    let files = guard.files.iter().filter_map(|(path, file)| {
                        (file.language == LanguageId::Rust).then_some(
                            qualified_usages::QualifiedFileContent {
                                file_path: path.as_str(),
                                content: file.content.as_slice(),
                            },
                        )
                    });
                    qualified_usages::collect_qualified_usages(&input.name, files)
                } else {
                    Vec::new()
                };
                (
                    Ok(guard.capture_find_references_view(
                        &input.name,
                        input.kind.as_deref(),
                        limits.total_hits,
                    )),
                    indexed_ranges,
                    qualified_usages,
                )
            }
        };
        match result {
            Ok(mut view) => {
                if collect_qualified_usages {
                    merge_qualified_usages_into_view(&mut view, qualified_usages, indexed_ranges);
                }
                let envelope = if !view.files.is_empty() {
                    let guard = self.index.read();
                    Some(search_format::format_search_envelope(
                        find_references_match_type_label(input, mode),
                        "current index",
                        search_parse_state_for_paths(
                            &guard,
                            view.files.iter().map(|file| file.file_path.as_str()),
                        ),
                        &find_references_completeness_label(&view, &limits),
                        &find_references_scope_summary(input, mode),
                        &find_references_evidence(&view),
                    ))
                } else {
                    None
                };
                let mut output = if input.compact.unwrap_or(false) {
                    format::find_references_compact_view(&view, &input.name, &limits)
                } else {
                    format::find_references_result_view(&view, &input.name, &limits)
                };

                // Supplemental: if index-based refs are empty, try text search to catch
                // qualified-path calls (e.g., module::func()) that the xref extractor misses.
                // This aligns find_references results with what search_text(follow_refs=true) finds.
                if view.files.is_empty() {
                    let text_options = search::TextSearchOptions::for_current_code_search();
                    let text_result = {
                        let guard = self.index.read();
                        search::search_text_with_options(
                            &guard,
                            Some(&input.name),
                            None,
                            false,
                            &text_options,
                        )
                    };
                    if let Ok(tr) = text_result
                        && !tr.files.is_empty()
                    {
                        output.push_str(&format!(
                            "\n\nNote: no indexed references found, but search_text found {} file(s) \
                             containing \"{}\". The index may miss qualified-path calls (e.g., \
                             module::{}()). Use search_text(query=\"{}\") for full coverage.",
                            tr.files.len(), input.name, input.name, input.name
                        ));
                    }
                }

                self.record_tool_savings_named(
                    "find_references",
                    (output.len() * 8 / 4) as u64,
                    (output.len() / 4) as u64,
                );
                self.session_context.record_summary_output(
                    "find_references",
                    (output.len() / 4).min(u32::MAX as usize) as u32,
                );
                self.session_context.record_listed_symbol("", &input.name);
                let result = match envelope {
                    Some(envelope) => format!("{envelope}\n\n{output}"),
                    None => output,
                };
                format::enforce_token_budget(result, params.0.max_tokens)
            }
            Err(error) => error,
        }
    }

    /// File-level dependency graph: which files import the given file. Set compact=true for ~60-75%
    /// smaller output. Supports Mermaid/Graphviz output. Use for "what breaks if I change this file?"
    /// NOT for symbol-level references (use find_references).
    /// NOT for git co-change patterns (use analyze_file_impact with include_co_changes=true).
    #[tool(
        description = "File-level dependency graph: which files import the given file. Set compact=true for ~60-75% smaller output. Supports Mermaid/Graphviz output. Use for 'what breaks if I change this file?' NOT for symbol-level references (use find_references). NOT for git co-change patterns (use analyze_file_impact with include_co_changes=true).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn find_dependents(&self, params: Parameters<FindDependentsInput>) -> String {
        // Symbol-shaped misuse guard: `find_dependents` answers a FILE-level
        // question ("what imports this file?"). A caller passing a `name`
        // alongside `path` wants symbol-level callers and would otherwise get a
        // file-level import graph that reads like symbol callers. Redirect
        // explicitly to `find_references` instead of silently returning the
        // wrong shape. Pure input validation — runs before proxy/index access so
        // it is identical in local and daemon-proxied modes.
        if let Some(name) = params
            .0
            .name
            .as_deref()
            .map(str::trim)
            .filter(|n| !n.is_empty())
        {
            return format!(
                "find_dependents is file-level only (it lists files that import \"{path}\"). \
                 You supplied name=\"{name}\", which is a symbol-level query. \
                 Use find_references(name=\"{name}\") for who calls/uses that symbol, \
                 or call find_dependents with only path=\"{path}\" for the file dependency graph.",
                path = params.0.path,
                name = name,
            );
        }
        if let Some(result) = self.proxy_tool_call("find_dependents", &params.0).await {
            return result;
        }
        let input = &params.0;
        let view = {
            let guard = self.index.read();
            loading_guard!(guard);
            guard.capture_find_dependents_view(&input.path)
        };
        let limits =
            format::OutputLimits::new(input.limit.unwrap_or(20), input.max_per_file.unwrap_or(10));
        let fmt = input.format.as_deref().unwrap_or("text");
        let output = match fmt {
            "mermaid" => format::find_dependents_mermaid(&view, &input.path, &limits),
            "dot" => format::find_dependents_dot(&view, &input.path, &limits),
            _ if input.compact.unwrap_or(false) => {
                format::find_dependents_compact_view(&view, &input.path, &limits)
            }
            _ => format::find_dependents_result_view(&view, &input.path, &limits),
        };
        self.session_context.record_summary_output(
            "find_dependents",
            (output.len() / 4).min(u32::MAX as usize) as u32,
        );
        format::enforce_token_budget(output, params.0.max_tokens)
    }

    /// Extract query terms that aren't part of the matched concept key,
    /// filtering out stopwords and short words.
    fn compute_remainder_terms(query: &str, concept_key: &str) -> Vec<String> {
        const STOPWORDS: &[&str] = &[
            "a", "an", "the", "in", "on", "of", "for", "to", "and", "or", "is", "it", "my", "at",
            "by", "do", "no", "so", "up", "if", "with", "from", "this", "that",
        ];
        let key_words: Vec<&str> = concept_key.split_whitespace().collect();
        query
            .split_whitespace()
            .filter(|w| {
                let lower = w.to_ascii_lowercase();
                !key_words.iter().any(|kw| kw.eq_ignore_ascii_case(w))
                    && !STOPWORDS.contains(&lower.as_str())
                    && lower.len() >= 3
            })
            .map(|w| w.to_ascii_lowercase())
            .collect()
    }

    fn explore_symbol_segments(name: &str) -> Vec<String> {
        let mut segments = Vec::new();
        let mut current = String::new();
        for ch in name.chars() {
            let is_separator =
                !ch.is_alphanumeric() || matches!(ch, '_' | ':' | '-' | '/' | '\\' | '.');
            if is_separator {
                if !current.is_empty() {
                    segments.push(current.to_ascii_lowercase());
                    current.clear();
                }
                continue;
            }

            let split_before = ch.is_uppercase()
                && !current.is_empty()
                && current
                    .chars()
                    .last()
                    .is_some_and(|prev| prev.is_lowercase() || prev.is_ascii_digit());
            if split_before {
                segments.push(current.to_ascii_lowercase());
                current.clear();
            }
            current.push(ch);
        }
        if !current.is_empty() {
            segments.push(current.to_ascii_lowercase());
        }

        segments
    }

    fn explore_fallback_symbol_match(name: &str, term: &str) -> bool {
        if name.eq_ignore_ascii_case(term) {
            return true;
        }

        let segments = Self::explore_symbol_segments(name);
        segments.iter().any(|segment| segment == term)
    }

    fn explore_terms_related(lhs: &str, rhs: &str) -> bool {
        if lhs.eq_ignore_ascii_case(rhs) {
            return true;
        }

        let lhs = lhs.to_ascii_lowercase();
        let rhs = rhs.to_ascii_lowercase();
        let shared_prefix = lhs
            .chars()
            .zip(rhs.chars())
            .take_while(|(a, b)| a == b)
            .count();
        shared_prefix >= 5
    }

    fn record_explore_file_signal(
        file_signals: &mut HashMap<String, ExploreFileSignal>,
        path: &str,
        term: &str,
        weight: u64,
    ) {
        let signal = file_signals.entry(path.to_string()).or_default();
        signal.raw_score += weight;
        signal.matched_terms.insert(term.to_string());
    }

    fn derive_explore_cluster(
        index: &crate::live_index::LiveIndex,
        query_terms: &[String],
        file_signals: &HashMap<String, ExploreFileSignal>,
        limit: usize,
    ) -> Option<DerivedExploreCluster> {
        const GENERIC_SYMBOLS: &[&str] = &[
            "build", "create", "error", "get", "handle", "init", "main", "new", "parse", "process",
            "result", "run", "set", "test", "update",
        ];

        if query_terms.len() < 2 {
            return None;
        }

        let mut ranked_files: Vec<(String, u64, usize)> = file_signals
            .iter()
            .filter_map(|(path, signal)| {
                let file = index.get_file(path)?;
                let coverage = signal.matched_terms.len();
                if coverage < 2 {
                    return None;
                }
                let path_penalty = explore_path_penalty(path, Some(&file.classification));
                let score = (signal.raw_score + ((coverage as u64) * (coverage as u64) * 10))
                    * path_penalty;
                Some((path.clone(), score, coverage))
            })
            .collect();
        ranked_files.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
        ranked_files.truncate(limit.clamp(1, 3));
        if ranked_files.is_empty() {
            return None;
        }

        let mut candidate_scores: HashMap<String, u64> = HashMap::new();
        for (path, file_score, coverage) in &ranked_files {
            let Some(file) = index.get_file(path) else {
                continue;
            };

            for symbol in &file.symbols {
                let lower = symbol.name.to_ascii_lowercase();
                if lower.len() < 4 || GENERIC_SYMBOLS.contains(&lower.as_str()) {
                    continue;
                }

                let segments = Self::explore_symbol_segments(&symbol.name);
                let overlap = query_terms
                    .iter()
                    .filter(|term| {
                        segments
                            .iter()
                            .any(|segment| Self::explore_terms_related(segment, term))
                    })
                    .count();
                if overlap == 0 {
                    continue;
                }

                let kind_bonus = match symbol.kind.to_string().as_str() {
                    "struct" | "class" | "trait" | "interface" | "enum" => 5,
                    "fn" | "method" => 4,
                    "impl" | "mod" | "module" => 3,
                    _ => 1,
                } as u64;
                let reverse_hits = index
                    .reverse_index
                    .get(&symbol.name)
                    .map(|hits| hits.len())
                    .unwrap_or(0);
                let rarity_bonus = match reverse_hits {
                    0 => 8,
                    1..=2 => 6,
                    3..=5 => 4,
                    6..=10 => 2,
                    _ => 1,
                } as u64;
                let length_bonus = (symbol.name.len().min(24) / 6) as u64;
                let score = *file_score
                    + ((*coverage as u64) * 5)
                    + ((overlap as u64) * 12)
                    + kind_bonus
                    + rarity_bonus
                    + length_bonus;

                let entry = candidate_scores.entry(symbol.name.clone()).or_insert(0);
                if score > *entry {
                    *entry = score;
                }
            }
        }

        let mut ranked_symbols: Vec<(String, u64)> = candidate_scores.into_iter().collect();
        ranked_symbols.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
        let promoted_symbols: Vec<String> = ranked_symbols
            .into_iter()
            .take(limit.clamp(1, 4))
            .map(|(name, _)| name)
            .collect();
        if promoted_symbols.is_empty() {
            return None;
        }

        Some(DerivedExploreCluster {
            seed_terms: query_terms.to_vec(),
            promoted_symbols,
            seed_files: ranked_files.into_iter().map(|(path, _, _)| path).collect(),
        })
    }

    fn ask_query_tokens(query: &str) -> Vec<String> {
        let mut tokens = Vec::new();
        for raw in query.split_whitespace() {
            let token = raw
                .trim_matches(|c: char| !c.is_alphanumeric() && c != '_' && c != ':')
                .to_string();
            if token.is_empty() {
                continue;
            }
            if token.len() < 2 {
                continue;
            }
            if tokens
                .iter()
                .any(|existing: &String| existing.eq_ignore_ascii_case(&token))
            {
                continue;
            }
            tokens.push(token);
        }
        tokens
    }

    fn ask_symbol_candidate_tokens(query: &str) -> Vec<String> {
        Self::ask_query_tokens(query)
            .into_iter()
            .filter(|token| {
                let is_symbol_like = token.contains('_')
                    || token.contains("::")
                    || token.chars().skip(1).any(|c| c.is_uppercase());
                is_symbol_like && token.len() >= 4
            })
            .collect()
    }

    fn extract_exact_symbol_understanding_candidate(
        index: &crate::live_index::LiveIndex,
        query: &str,
    ) -> Option<String> {
        const GENERIC_SYMBOLS: &[&str] = &[
            "build", "create", "get", "handle", "init", "main", "new", "parse", "process", "run",
            "set", "test", "update",
        ];

        /// Score a single (path, symbol) match for prominence. Higher = more canonical.
        fn score_match(path: &str, line_start: u32, line_end: u32) -> i32 {
            let mut score = 0i32;
            if path.starts_with("src/") || path.contains("/src/") {
                score += 10;
            }
            let lower = path.to_ascii_lowercase();
            if !lower.contains("test")
                && !lower.contains("vendor")
                && !lower.contains("example")
                && !lower.contains("bench")
            {
                score += 5;
            }
            let span = line_end.saturating_sub(line_start);
            score += ((span / 10) as i32).min(10);
            score
        }

        /// Search `index` for an exact (case-insensitive) match for `token`.
        /// Returns `Some((canonical_name, best_score))` when 1-5 matches are found.
        fn find_token(index: &crate::live_index::LiveIndex, token: &str) -> Option<(String, i32)> {
            let mut matches: Vec<(String, i32)> = Vec::new(); // (canonical_name, score)
            for (path, file) in index.all_files() {
                for symbol in &file.symbols {
                    if symbol.name.eq_ignore_ascii_case(token) {
                        let s =
                            score_match(path.as_str(), symbol.line_range.0, symbol.line_range.1);
                        matches.push((symbol.name.clone(), s));
                    }
                }
            }
            if matches.is_empty() || matches.len() > 5 {
                return None;
            }
            // Pick the match with the highest score; stable (first wins on tie).
            let best = matches
                .into_iter()
                .max_by_key(|(_, score)| *score)
                .expect("non-empty; guarded by is_empty check above");
            Some(best)
        }

        // Collect (canonical_name, score) for each qualifying token.
        let mut candidates: Vec<(String, i32)> = Vec::new();
        let tokens = Self::ask_symbol_candidate_tokens(query);
        for token in &tokens {
            if GENERIC_SYMBOLS.contains(&token.to_ascii_lowercase().as_str()) {
                continue;
            }
            if let Some(hit) = find_token(index, token) {
                candidates.push(hit);
            }
        }

        // Compound token joining: try "token_a_token_b" for adjacent pairs when
        // no single-token candidate was found yet.
        if candidates.is_empty() && tokens.len() >= 2 {
            for window in tokens.windows(2) {
                let joined = format!("{}_{}", window[0], window[1]);
                if GENERIC_SYMBOLS.contains(&joined.to_ascii_lowercase().as_str()) {
                    continue;
                }
                if let Some(hit) = find_token(index, &joined) {
                    candidates.push(hit);
                }
            }
        }

        if candidates.is_empty() {
            return None;
        }

        // Deduplicate by canonical name, keeping the entry with the highest score.
        candidates.sort_by(|a, b| a.0.cmp(&b.0).then(b.1.cmp(&a.1)));
        candidates.dedup_by(|later, first| {
            // `dedup_by` drops `later` when true is returned.  The sort above orders ascending
            // by name, then descending by score within each name group, so `first` always holds
            // the highest score for a given name — exactly the entry we want to keep.
            later.0.eq_ignore_ascii_case(&first.0)
        });

        // Pick the single candidate with the highest prominence score.
        candidates
            .into_iter()
            .max_by_key(|(_, score)| *score)
            .map(|(name, _)| name)
    }

    fn extract_exact_implementation_understanding_candidate(
        index: &crate::live_index::LiveIndex,
        query: &str,
    ) -> Option<String> {
        const IMPLEMENTATION_CUES: &[&str] = &[
            "type",
            "types",
            "implementation",
            "implementations",
            "implementor",
            "implementors",
            "implementer",
            "implementers",
            "implements",
        ];
        const GENERIC_QUERY_WORDS: &[&str] = &[
            "a",
            "all",
            "an",
            "and",
            "are",
            "describe",
            "does",
            "explain",
            "help",
            "how",
            "main",
            "me",
            "of",
            "the",
            "through",
            "tell",
            "type",
            "types",
            "understand",
            "walk",
            "what",
            "work",
        ];
        let tokens = Self::ask_query_tokens(query);
        if !tokens.iter().any(|token| {
            IMPLEMENTATION_CUES
                .iter()
                .any(|cue| token.eq_ignore_ascii_case(cue))
        }) {
            return None;
        }

        let mut candidates = Vec::new();
        for token in tokens {
            let lower = token.to_ascii_lowercase();
            if GENERIC_QUERY_WORDS.contains(&lower.as_str()) {
                continue;
            }

            if let Some(candidate) = Self::exact_trait_like_symbol_candidate(index, &token) {
                candidates.push(candidate);
                continue;
            }

            if lower.ends_with('s') && lower.len() > 4 {
                let singular = &token[..token.len() - 1];
                if let Some(candidate) = Self::exact_trait_like_symbol_candidate(index, singular) {
                    candidates.push(candidate);
                }
            }
        }

        candidates.sort();
        candidates.dedup();
        if candidates.len() == 1 {
            candidates.into_iter().next()
        } else {
            None
        }
    }

    fn exact_trait_like_symbol_candidate(
        index: &crate::live_index::LiveIndex,
        token: &str,
    ) -> Option<String> {
        let mut exact_match_count = 0usize;
        let mut canonical_name: Option<String> = None;
        for (_path, file) in index.all_files() {
            for symbol in &file.symbols {
                if !symbol.name.eq_ignore_ascii_case(token) {
                    continue;
                }
                if !matches!(
                    symbol.kind,
                    crate::domain::index::SymbolKind::Trait
                        | crate::domain::index::SymbolKind::Interface
                        | crate::domain::index::SymbolKind::Type
                ) {
                    continue;
                }
                exact_match_count += 1;
                if canonical_name.is_none() {
                    canonical_name = Some(symbol.name.clone());
                }
            }
        }

        if exact_match_count == 1 {
            canonical_name
        } else {
            None
        }
    }

    /// Start here when you don't know where to look. Accepts a natural-language concept
    /// and returns related symbols, patterns, and files. Set depth=2 for signatures and
    /// callers of top symbols (~1500 tokens). Set depth=3 for implementations and type
    /// dependency chains (~3000 tokens). NOT for finding a specific symbol by name
    /// (use search_symbols). NOT for text content search (use search_text).
    #[tool(
        description = "Use this when you have a concept or topic but not a specific file or symbol name. Accepts a natural-language concept and returns related symbols, patterns, and files. Set depth=2 for signatures and callers of top symbols (~1500 tokens). Set depth=3 for implementations and type dependency chains (~3000 tokens). NOT for finding a specific symbol by name (use search_symbols). NOT for text content search (use search_text).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn explore(&self, params: Parameters<ExploreInput>) -> String {
        if let Some(result) = self.proxy_tool_call("explore", &params.0).await {
            return result;
        }
        if params.0.estimate == Some(true) {
            let depth = params.0.depth.unwrap_or(1);
            let est = match depth {
                1 => 500,
                2 => 1500,
                _ => 3000,
            };
            return format!("Estimate for explore: ~{} tokens (depth={})", est, depth);
        }
        let lang_filter = match parse_language_filter(params.0.language.as_deref()) {
            Ok(f) => f,
            Err(e) => return e,
        };
        let limit = params.0.limit.unwrap_or(10) as usize;
        let include_noise = params.0.include_noise.unwrap_or(false);
        let include_vendor = params.0.include_vendor.unwrap_or(false);
        let include_personal_tooling = params.0.include_personal_tooling.unwrap_or(false);
        // B1 additive: vendor surfaces when include_noise OR include_vendor is true;
        // personal-tooling surfaces when include_noise OR include_personal_tooling.
        let suppress_vendor = !(include_noise || include_vendor);
        let suppress_personal = !(include_noise || include_personal_tooling);
        let suppress_other_noise = !include_noise;
        let any_suppression = suppress_vendor || suppress_personal || suppress_other_noise;
        let guard = self.index.read();
        loading_guard!(guard);

        let concept = super::explore::match_concept(&params.0.query);

        let mut enriched_imports: Vec<String> = Vec::new();
        let (label, symbol_queries, text_queries, remainder_terms) = if let Some((key, c)) = concept
        {
            let remainder = Self::compute_remainder_terms(&params.0.query, key);
            let mut sym_q: Vec<String> = c.symbol_queries.iter().map(|s| s.to_string()).collect();
            // Convention-aware enrichment: add project-specific imports related to the concept.
            let project_imports =
                crate::protocol::conventions::extract_top_import_roots(&guard, 100);
            let enrichment = super::explore::enrich_concept_with_imports(c, &project_imports);
            enriched_imports = enrichment;
            sym_q.extend(enriched_imports.iter().cloned());
            (
                c.label.to_string(),
                sym_q,
                c.text_queries
                    .iter()
                    .map(|s| s.to_string())
                    .collect::<Vec<_>>(),
                remainder,
            )
        } else {
            let terms = super::explore::fallback_terms(&params.0.query);
            if terms.is_empty() {
                return "Explore requires a non-empty query.".to_string();
            }
            (
                format!("'{}'", params.0.query),
                terms.clone(),
                terms,
                vec![],
            )
        };

        // Phase 1: Symbol search — over-fetch and track both match counts and
        // query-term coverage per symbol so multi-term hits outrank one-term noise.
        let mut match_scores: HashMap<(String, String, String), ExploreMatchScore> = HashMap::new();
        let mut file_signals: HashMap<String, ExploreFileSignal> = HashMap::new();

        // Phase 0: Module-path boosting — symbols from files whose path segment
        // matches a query term get a weight boost. +2 for exact segment match,
        // +1 for substring segment match. Per-directory cap of `limit` symbols.
        // For concept+remainder queries, boost on remainder terms only so path-scoping
        // is driven by the narrowing terms (e.g., "watcher" in "error handling in the watcher").
        let boost_terms = if remainder_terms.is_empty() {
            symbol_queries.clone()
        } else {
            remainder_terms.clone()
        };
        for term in &boost_terms {
            let term_lower = term.to_ascii_lowercase();
            for (file_path, file) in guard.all_files() {
                if explore_should_skip_path_boost(
                    file_path,
                    &file.classification,
                    include_noise,
                    include_vendor,
                    include_personal_tooling,
                ) {
                    continue;
                }
                let segments: Vec<&str> = file_path.split(&['/', '\\'][..]).collect();
                let best_match = segments
                    .iter()
                    .filter_map(|seg| {
                        let seg_lower = seg.to_ascii_lowercase();
                        let seg_stem = seg_lower
                            .strip_suffix(".rs")
                            .or_else(|| seg_lower.strip_suffix(".py"))
                            .or_else(|| seg_lower.strip_suffix(".ts"))
                            .or_else(|| seg_lower.strip_suffix(".js"))
                            .or_else(|| seg_lower.strip_suffix(".go"))
                            .unwrap_or(&seg_lower);
                        if seg_stem == term_lower {
                            Some(2usize)
                        } else if seg_stem.contains(&*term_lower) {
                            Some(1usize)
                        } else {
                            None
                        }
                    })
                    .max();
                if let Some(weight) = best_match {
                    Self::record_explore_file_signal(
                        &mut file_signals,
                        file_path,
                        &term_lower,
                        weight as u64,
                    );
                    for (injected, sym) in file.symbols.iter().enumerate() {
                        if injected >= limit {
                            break;
                        }
                        let entry = (sym.name.clone(), sym.kind.to_string(), file_path.clone());
                        let score = match_scores.entry(entry).or_default();
                        if score.raw_count == 0 {
                            // Cap path-boost: only seed symbols that have no prior
                            // matches. This prevents path-matching files from
                            // dominating over content-matching files.
                            score.raw_count = weight.min(1);
                        }
                        score.matched_terms.insert(term_lower.clone());
                    }
                }
            }
        }

        // Merge remainder terms into symbol/text queries for Phases 1-2 so that compound
        // queries like "error handling in the watcher" search concept queries AND "watcher".
        let mut all_symbol_queries = symbol_queries.clone();
        all_symbol_queries.extend(remainder_terms.iter().cloned());
        let mut all_text_queries = text_queries.clone();
        all_text_queries.extend(remainder_terms.iter().cloned());

        let fallback_mode = concept.is_none();
        for sq in &all_symbol_queries {
            let term_key = sq.to_ascii_lowercase();
            let result = search::search_symbols(&guard, sq, None, limit * 3);
            let mut seen_paths = HashSet::new();
            for hit in &result.hits {
                if fallback_mode && !Self::explore_fallback_symbol_match(&hit.name, &term_key) {
                    continue;
                }
                let entry = (hit.name.clone(), hit.kind.clone(), hit.path.clone());
                let score = match_scores.entry(entry).or_default();
                score.raw_count += 1;
                score.matched_terms.insert(term_key.clone());
                if seen_paths.insert(hit.path.clone()) {
                    Self::record_explore_file_signal(&mut file_signals, &hit.path, &term_key, 2);
                }
            }
        }

        // Filter Phase 1 results by language and path_prefix
        if lang_filter.is_some() || params.0.path_prefix.is_some() {
            match_scores.retain(|(_, _, path), _| {
                if let Some(ref prefix) = params.0.path_prefix
                    && !path.starts_with(prefix.as_str())
                {
                    return false;
                }
                if let Some(ref lang) = lang_filter {
                    let ext = path.rsplit('.').next().unwrap_or("");
                    if crate::domain::index::LanguageId::from_extension(ext).as_ref() != Some(lang)
                    {
                        return false;
                    }
                }
                true
            });
            file_signals.retain(|path, _| {
                if let Some(ref prefix) = params.0.path_prefix
                    && !path.starts_with(prefix.as_str())
                {
                    return false;
                }
                if let Some(ref lang) = lang_filter {
                    let ext = path.rsplit('.').next().unwrap_or("");
                    if crate::domain::index::LanguageId::from_extension(ext).as_ref() != Some(lang)
                    {
                        return false;
                    }
                }
                true
            });
        }

        if any_suppression {
            file_signals.retain(|path, _| {
                let Some(file) = guard.get_file(path) else {
                    return false;
                };
                if suppress_other_noise
                    && explore_is_test_like_path(path, Some(&file.classification))
                {
                    return false;
                }
                if suppress_personal && crate::live_index::query::is_personal_tooling_path(path) {
                    return false;
                }
                let class = search::NoisePolicy::classify_path(path, None);
                match class {
                    search::NoiseClass::Vendor => !suppress_vendor,
                    search::NoiseClass::Generated | search::NoiseClass::Ignored => {
                        !suppress_other_noise
                    }
                    search::NoiseClass::None => true,
                }
            });
        }

        // Phase 2: Text search — collect text hits and inject enclosing symbols into match_counts
        let mut text_hits: Vec<(String, String, usize)> = Vec::new(); // (path, line, line_number)
        for tq in &all_text_queries {
            let mut options = search::TextSearchOptions {
                total_limit: limit.min(50),
                max_per_file: limit, // need enough matches per file for enclosing symbol extraction
                ..search::TextSearchOptions::for_current_code_search()
            };
            if let Some(ref prefix) = params.0.path_prefix {
                options.path_scope = search::PathScope::Prefix(prefix.clone());
            }
            if let Some(ref lang) = lang_filter {
                options.language_filter = Some(lang.clone());
            }
            let result = search::search_text_with_options(&guard, Some(tq), None, false, &options);
            if let Ok(r) = result {
                let term_key = tq.to_ascii_lowercase();
                for file in &r.files {
                    if !file.matches.is_empty() {
                        Self::record_explore_file_signal(
                            &mut file_signals,
                            &file.path,
                            &term_key,
                            3,
                        );
                    }
                    for m in &file.matches {
                        if text_hits.len() < limit && !format::is_noise_line(&m.line) {
                            text_hits.push((file.path.clone(), m.line.clone(), m.line_number));
                        }
                        // Inject enclosing symbol into match_counts.
                        // Weight 2 so content matches outweigh path-only boosts.
                        if let Some(ref enc) = m.enclosing_symbol {
                            let entry = (enc.name.clone(), enc.kind.clone(), file.path.clone());
                            let score = match_scores.entry(entry).or_default();
                            score.raw_count += 2;
                            score.matched_terms.insert(term_key.clone());
                        }
                    }
                }
            }
        }

        // Filter text hits by path_prefix (language already handled via TextSearchOptions)
        if let Some(ref prefix) = params.0.path_prefix {
            text_hits.retain(|(path, _, _)| path.starts_with(prefix.as_str()));
        }

        let derived_cluster = if fallback_mode {
            Self::derive_explore_cluster(&guard, &symbol_queries, &file_signals, limit)
        } else {
            None
        };

        if let Some(cluster) = &derived_cluster {
            for derived_query in &cluster.promoted_symbols {
                let term_key = derived_query.to_ascii_lowercase();
                if !all_symbol_queries
                    .iter()
                    .any(|existing| existing.eq_ignore_ascii_case(derived_query))
                {
                    let result = search::search_symbols(&guard, derived_query, None, limit * 2);
                    let mut seen_paths = HashSet::new();
                    for hit in &result.hits {
                        let entry = (hit.name.clone(), hit.kind.clone(), hit.path.clone());
                        let score = match_scores.entry(entry).or_default();
                        score.raw_count += 1;
                        score.matched_terms.insert(term_key.clone());
                        if seen_paths.insert(hit.path.clone()) {
                            Self::record_explore_file_signal(
                                &mut file_signals,
                                &hit.path,
                                &term_key,
                                2,
                            );
                        }
                    }
                }

                let mut options = search::TextSearchOptions {
                    total_limit: limit.min(50),
                    max_per_file: limit,
                    ..search::TextSearchOptions::for_current_code_search()
                };
                if let Some(ref prefix) = params.0.path_prefix {
                    options.path_scope = search::PathScope::Prefix(prefix.clone());
                }
                if let Some(ref lang) = lang_filter {
                    options.language_filter = Some(lang.clone());
                }

                if let Ok(result) = search::search_text_with_options(
                    &guard,
                    Some(derived_query),
                    None,
                    false,
                    &options,
                ) {
                    for file in &result.files {
                        if !file.matches.is_empty() {
                            Self::record_explore_file_signal(
                                &mut file_signals,
                                &file.path,
                                &term_key,
                                2,
                            );
                        }
                        for m in &file.matches {
                            if text_hits.len() < limit && !format::is_noise_line(&m.line) {
                                text_hits.push((file.path.clone(), m.line.clone(), m.line_number));
                            }
                            if let Some(ref enc) = m.enclosing_symbol {
                                let entry = (enc.name.clone(), enc.kind.clone(), file.path.clone());
                                let score = match_scores.entry(entry).or_default();
                                score.raw_count += 1;
                                score.matched_terms.insert(term_key.clone());
                            }
                        }
                    }
                }
            }
        }

        // Phase 3: Filter noise, weight by kind and path, sort, truncate to limit.
        // Exclude explore.rs itself (CONCEPT_MAP contains concept keywords in its body).
        match_scores.retain(|(_, _, path), _| !path.ends_with("protocol/explore.rs"));

        // Score each symbol: match_count * kind_weight, penalized for doc/generated files.
        let scored: Vec<((String, String, String), u64)> = match_scores
            .into_iter()
            .map(|((name, kind, path), score_data)| {
                // Kind weight: definition-like symbols rank higher than incidental matches.
                let kind_weight: u64 = match kind.as_str() {
                    "fn" | "method" => 4,
                    "struct" | "class" | "trait" | "interface" | "enum" => 4,
                    "impl" | "mod" | "module" => 3,
                    "const" | "type" => 2,
                    "variable" | "let" => 1,
                    "key" | "section" => 1,
                    _ => 2, // "other" (selectors, etc.)
                };
                let classification = guard.get_file(&path).map(|file| &file.classification);
                let path_penalty = explore_path_penalty(&path, classification);
                let coverage_bonus: u64 = match score_data.matched_terms.len() as u64 {
                    0 | 1 => 1,
                    n => n * n,
                };
                let alignment_multiplier = if fallback_mode {
                    explore_fallback_alignment_multiplier(
                        symbol_queries.len(),
                        score_data.matched_terms.len(),
                    )
                } else {
                    8
                };
                let score = (score_data.raw_count as u64)
                    * kind_weight
                    * path_penalty
                    * coverage_bonus
                    * alignment_multiplier;
                ((name, kind, path), score)
            })
            .collect();

        let mut ranked = scored;
        ranked.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.0.cmp(&b.0.0)));
        // Filter out weak matches (score < 8 means single text-only hit in a doc file).
        ranked.retain(|(_, score)| *score >= 8);
        ranked.truncate(limit);
        let max_score = ranked.first().map(|(_, s)| *s as f32).unwrap_or(1.0);
        let symbol_scores: Vec<f32> = ranked
            .iter()
            .map(|(_, s)| {
                if max_score > 0.0 {
                    (*s as f32 / max_score).min(1.0)
                } else {
                    0.0
                }
            })
            .collect();
        let symbol_hits: Vec<(String, String, String)> =
            ranked.into_iter().map(|(k, _)| k).collect();

        // Noise filtering: hide vendor / generated / gitignored / personal-tooling
        // by default. include_noise is the umbrella; include_vendor and
        // include_personal_tooling are additive finer-grained overrides per B1.
        let mut noise_hidden: usize = 0;
        let should_hide_path = |path: &str| -> bool {
            let Some(file) = guard.get_file(path) else {
                return true;
            };
            if suppress_other_noise && explore_is_test_like_path(path, Some(&file.classification)) {
                return true;
            }
            if suppress_personal && crate::live_index::query::is_personal_tooling_path(path) {
                return true;
            }
            let class = search::NoisePolicy::classify_path(path, None);
            match class {
                search::NoiseClass::Vendor => suppress_vendor,
                search::NoiseClass::Generated | search::NoiseClass::Ignored => suppress_other_noise,
                search::NoiseClass::None => false,
            }
        };
        let (symbol_hits, text_hits) = if any_suppression {
            let filtered_symbols: Vec<(String, String, String)> = symbol_hits
                .into_iter()
                .filter(|(_, _, path)| {
                    let hide = should_hide_path(path);
                    if hide {
                        noise_hidden += 1;
                    }
                    !hide
                })
                .collect();
            let filtered_text: Vec<(String, String, usize)> = text_hits
                .into_iter()
                .filter(|(path, _, _)| {
                    let hide = should_hide_path(path);
                    if hide {
                        noise_hidden += 1;
                    }
                    !hide
                })
                .collect();
            (filtered_symbols, filtered_text)
        } else {
            (symbol_hits, text_hits)
        };

        // Count files by symbol/text presence
        let mut file_counts: std::collections::HashMap<String, usize> =
            std::collections::HashMap::new();
        for (_, _, path) in &symbol_hits {
            *file_counts.entry(path.clone()).or_default() += 1;
        }
        for (path, _, _) in &text_hits {
            *file_counts.entry(path.clone()).or_default() += 1;
        }
        let mut related_files: Vec<(String, usize)> = file_counts.into_iter().collect();
        related_files.sort_by_key(|(_, count)| std::cmp::Reverse(*count));
        related_files.truncate(limit);

        // Depth 2+: enrich top symbol hits with signatures and dependents
        let depth = params.0.depth.unwrap_or(1).clamp(1, 3);
        let mut enriched_symbols: Vec<format::ExploreEnrichedSymbol> = Vec::new();
        // (name, kind, path, signature, dependent_files)

        if depth >= 2 {
            let enrich_limit = 5.min(symbol_hits.len());
            for (name, kind, path) in &symbol_hits[..enrich_limit] {
                let signature = guard.get_file(path).and_then(|file| {
                    let sym = file.symbols.iter().find(|s| {
                        s.name == *name && s.kind.to_string().eq_ignore_ascii_case(kind)
                    })?;
                    let body = std::str::from_utf8(
                        &file.content[sym.byte_range.0 as usize..sym.byte_range.1 as usize],
                    )
                    .ok()?;
                    Some(format::apply_verbosity(body, "signature"))
                });

                let dependents = {
                    let ref_view = guard.capture_find_references_view(name, None, 3);
                    ref_view
                        .files
                        .iter()
                        .take(3)
                        .map(|f| f.file_path.clone())
                        .collect()
                };

                enriched_symbols.push((
                    name.clone(),
                    kind.clone(),
                    path.clone(),
                    signature,
                    dependents,
                ));
            }
        }

        // Depth 3: gather implementations AND type dependencies for top symbols
        let mut symbol_impls: Vec<(String, Vec<String>)> = Vec::new();
        let mut symbol_deps: Vec<(String, Vec<String>)> = Vec::new();
        if depth >= 3 {
            let impl_limit = 3.min(enriched_symbols.len());
            for (name, _kind, path, _, _) in &enriched_symbols[..impl_limit] {
                // Implementations (trait → implementors)
                let impl_view = guard.capture_implementations_view(name, None);
                let impl_names: Vec<String> = impl_view
                    .entries
                    .iter()
                    .take(5)
                    .map(|e| {
                        format!(
                            "{} impl {} ({}:{})",
                            e.implementor, e.trait_name, e.file_path, e.line
                        )
                    })
                    .collect();
                if !impl_names.is_empty() {
                    symbol_impls.push((name.clone(), impl_names));
                }

                // Type dependencies (what types does this symbol reference?)
                let bundle = guard.capture_context_bundle_view(path, name, None, None);
                if let crate::live_index::query::ContextBundleView::Found(found) = bundle {
                    let dep_names: Vec<String> = found
                        .dependencies
                        .iter()
                        .take(8)
                        .map(|d| format!("{} {} ({})", d.kind_label, d.name, d.file_path))
                        .collect();
                    if !dep_names.is_empty() {
                        symbol_deps.push((name.clone(), dep_names));
                    }
                }
            }
        }

        let mut output = format::explore_result_view(format::ExploreResultViewInput {
            label: &label,
            symbol_hits: &symbol_hits,
            text_hits: &text_hits,
            related_files: &related_files,
            enriched_symbols: &enriched_symbols,
            symbol_impls: &symbol_impls,
            symbol_deps: &symbol_deps,
            derived_seed_terms: derived_cluster
                .as_ref()
                .map(|cluster| cluster.seed_terms.as_slice())
                .unwrap_or(&[]),
            derived_symbols: derived_cluster
                .as_ref()
                .map(|cluster| cluster.promoted_symbols.as_slice())
                .unwrap_or(&[]),
            enriched_imports: &enriched_imports,
            symbol_scores: &symbol_scores,
            derived_seed_files: derived_cluster
                .as_ref()
                .map(|cluster| cluster.seed_files.as_slice())
                .unwrap_or(&[]),
            depth,
        });

        if noise_hidden > 0 {
            output.push_str(&format!(
                "\n\nNote: {noise_hidden} result(s) from vendor/generated files hidden. Use include_noise=true to include."
            ));
        }

        if !output.is_empty() {
            output.push_str(
                "\n\nranked by: concept match + symbol-token alignment + path proximity + caller density"
            );
        }

        self.record_tool_savings_named(
            "explore",
            (output.len() * 10 / 4) as u64,
            (output.len() / 4) as u64,
        );
        self.session_context
            .record_summary_output("explore", (output.len() / 4).min(u32::MAX as usize) as u32);
        format::enforce_token_budget(output, params.0.max_tokens)
    }

    #[tool(
        description = "Detect project coding conventions from the indexed codebase. Returns error handling style, naming patterns, test organization, common imports, and file structure. Use when you need to write code that fits the project's existing patterns.",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn conventions(&self) -> String {
        if let Some(result) = self.proxy_tool_call_without_params("conventions").await {
            return result;
        }
        let conv = {
            let guard = self.index.read();
            loading_guard!(guard);
            crate::protocol::conventions::detect_conventions(&guard)
        };
        let mut output = crate::protocol::conventions::format_conventions(&conv);
        output.push_str("\n\n── Worktree awareness ──\n");
        output.push_str(
            "When editing from inside a git worktree, pass `working_directory` \
             (absolute path of the worktree root) to every edit tool \
             (edit_within_symbol, replace_symbol_body, insert_symbol, \
             delete_symbol, batch_edit, batch_insert, batch_rename) so writes \
             land in your worktree instead of the indexed copy.\n\
             A supplied `working_directory` is explicit call-time routing \
             consent; unset/normal policy allows it, while false/off/disabled \
             `SYMFORGE_WORKTREE_AWARE` values block requested routing before \
             write. Omitting the parameter preserves today's indexed-path \
             behaviour. See README §Worktree awareness.",
        );
        output.push_str("\n\n── Frecency ranking ──\n");
        output.push_str(
            "Per-workspace file-ranking signal that decays on a 7-day half-life \
             and fuses with existing path-match and co-change signals. Call \
             `search_files` with `rank_by=\"frecency\"` to surface files you \
             recently touched. With `SYMFORGE_FRECENCY` unset, commitment tools \
             collect session-scoped history; `SYMFORGE_FRECENCY=1` uses the \
             persistent `.symforge/frecency.db`; false/off/disabled values block \
             frecency and requested ranking reports disabled-by-policy evidence.\n\
             Bump-on-commitment policy: SymForge bumps a path's frecency score \
             on every edit tool and on the read tools that imply commitment to \
             a known file (`get_file_context`, `get_file_content`, `get_symbol`, \
             `get_symbol_context`). Discovery tools (`search_files`, \
             `search_text`, `search_symbols`) deliberately never bump — \
             searching for a file is not the same as working on it, and \
             self-bumping searches corrupt rankings via a positive feedback \
             loop. Batch tools dedup bumps per invocation. Set \
             `search_files(debug_ranking=true)` for compact call-time ranking \
             diagnostics, or `SYMFORGE_DEBUG_RANKING=1` to default them on \
             and show the last-10 bumps list in `health`. See README \
             §Frecency ranking and ADR 0011 for the full policy.",
        );
        self.session_context.record_summary_output(
            "conventions",
            (output.len() / 4).min(u32::MAX as usize) as u32,
        );
        output
    }

    #[tool(
        description = "Plan an edit: analyzes a target symbol or file, counts references, and suggests the right sequence of SymForge edit tools. Accepts a bare symbol name, a file path, or an exact selector like `src/lib.rs::helper`. Use before making changes to understand impact.",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn edit_plan(&self, params: Parameters<EditPlanInput>) -> String {
        if let Some(result) = self.proxy_tool_call("edit_plan", &params.0).await {
            return result;
        }
        let guard = self.index.read();
        loading_guard!(guard);
        let output = crate::protocol::edit_plan::plan_edit(&guard, &params.0.target);
        self.session_context.record_summary_output(
            "edit_plan",
            (output.len() / 4).min(u32::MAX as usize) as u32,
        );
        output
    }

    #[tool(
        description = "Suggest what to investigate next based on what you've already loaded. Analyzes session context to find referenced-but-not-loaded symbols. Use during deep investigations to find gaps.",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn investigation_suggest(
        &self,
        params: Parameters<InvestigationInput>,
    ) -> String {
        if let Some(result) = self
            .proxy_tool_call("investigation_suggest", &params.0)
            .await
        {
            return result;
        }
        let guard = self.index.read();
        loading_guard!(guard);
        let output = crate::protocol::investigation::suggest_next_steps(
            &guard,
            &self.session_context,
            params.0.focus.as_deref(),
        );
        self.session_context.record_summary_output(
            "investigation_suggest",
            (output.len() / 4).min(u32::MAX as usize) as u32,
        );
        output
    }

    #[tool(
        description = "Show what symbols and files have been fetched this session. Returns a context inventory with token counts. Use to track your context budget and avoid re-fetching content you already have.",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn context_inventory(&self) -> String {
        if let Some(result) = self
            .proxy_tool_call_without_params("context_inventory")
            .await
        {
            return result;
        }
        let snap = self.session_context.snapshot();
        crate::protocol::session::format_context_inventory(&snap)
    }

    #[tool(
        description = "Natural language entry point — ask any question about the codebase and SymForge routes to the right tool internally. Use when unsure which specific tool to call. Examples: 'who calls X', 'where is X defined', 'how does X work', 'what changed', 'find file X'. Returns the result plus which tool was used, so you can call it directly next time.",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn ask(&self, params: Parameters<SmartQueryInput>) -> String {
        use crate::protocol::smart_query;

        let original_q = params.0.query.trim();
        let q = smart_query::strip_leading_articles(original_q);
        if q.is_empty() {
            return "query requires a non-empty question.".to_string();
        }

        let (mut intent, mut matched_prefix) = smart_query::classify_intent_with_match(q);
        if matches!(
            intent,
            smart_query::QueryIntent::Understand { .. } | smart_query::QueryIntent::Explore { .. }
        ) {
            let guard = self.index.read();
            if let Some(name) =
                Self::extract_exact_implementation_understanding_candidate(&guard, q)
            {
                intent = smart_query::QueryIntent::UnderstandImplementations { name };
                matched_prefix = false;
            } else if let Some(symbol) =
                Self::extract_exact_symbol_understanding_candidate(&guard, q)
            {
                intent = smart_query::QueryIntent::UnderstandSymbol { symbol };
                matched_prefix = false;
            }
        }
        let assessment = smart_query::assess_route(&intent, matched_prefix);
        let route_desc = smart_query::route_description(&intent);

        let result = match &intent {
            smart_query::QueryIntent::FindCallers { symbol, path } => {
                let input = FindReferencesInput {
                    name: symbol.clone(),
                    kind: None,
                    path: path.clone(),
                    symbol_kind: None,
                    symbol_line: None,
                    limit: None,
                    max_per_file: None,
                    compact: Some(true),
                    mode: None,
                    direction: None,
                    estimate: None,
                    max_tokens: None,
                };
                self.find_references(Parameters(input)).await
            }
            smart_query::QueryIntent::FindSymbol { name, kind } => {
                let input = SearchSymbolsInput {
                    query: Some(name.clone()),
                    kind: kind.clone(),
                    path_prefix: None,
                    language: None,
                    limit: None,
                    include_generated: None,
                    include_tests: None,
                    include_vendor: None,
                    include_personal_tooling: None,
                    estimate: None,
                    max_tokens: None,
                };
                self.search_symbols(Parameters(input)).await
            }
            smart_query::QueryIntent::FindFile { hint } => {
                let input = SearchFilesInput {
                    query: hint.clone(),
                    limit: None,
                    current_file: None,
                    changed_with: None,
                    resolve: None,
                    estimate: None,
                    max_tokens: None,
                    debug_ranking: None,
                    rank_by: None,
                    anchor_path: None,
                    include_vendor: None,
                    include_personal_tooling: None,
                };
                self.search_files(Parameters(input)).await
            }
            smart_query::QueryIntent::FindChanges => {
                let input = WhatChangedInput {
                    since: None,
                    git_ref: None,
                    uncommitted: Some(true),
                    path_prefix: None,
                    language: None,
                    code_only: Some(true),
                    include_symbol_diff: Some(true),
                    estimate: None,
                    max_tokens: None,
                };
                self.what_changed(Parameters(input)).await
            }
            smart_query::QueryIntent::Understand { concept } => {
                let input = ExploreInput {
                    query: concept.clone(),
                    limit: None,
                    depth: Some(2),
                    include_noise: None,
                    include_vendor: None,
                    include_personal_tooling: None,
                    language: None,
                    path_prefix: None,
                    estimate: None,
                    max_tokens: None,
                };
                self.explore(Parameters(input)).await
            }
            smart_query::QueryIntent::UnderstandSymbol { symbol } => {
                let input = GetSymbolContextInput {
                    name: symbol.clone(),
                    file: None,
                    path: None,
                    symbol_kind: None,
                    symbol_line: None,
                    verbosity: Some("compact".to_string()),
                    bundle: None,
                    sections: None,
                    max_tokens: None,
                    estimate: None,
                };
                self.get_symbol_context(Parameters(input)).await
            }
            smart_query::QueryIntent::UnderstandImplementations { name } => {
                let input = FindReferencesInput {
                    name: name.clone(),
                    kind: None,
                    path: None,
                    symbol_kind: None,
                    symbol_line: None,
                    limit: None,
                    max_per_file: None,
                    compact: None,
                    mode: Some("implementations".to_string()),
                    direction: None,
                    estimate: None,
                    max_tokens: None,
                };
                self.find_references(Parameters(input)).await
            }
            smart_query::QueryIntent::SearchCode { pattern } => {
                let input = SearchTextInput {
                    query: Some(pattern.clone()),
                    terms: None,
                    regex: None,
                    path_prefix: None,
                    language: None,
                    limit: None,
                    max_per_file: None,
                    glob: None,
                    exclude_glob: None,
                    context: None,
                    case_sensitive: None,
                    whole_word: None,
                    group_by: None,
                    follow_refs: None,
                    follow_refs_limit: None,
                    ranked: None,
                    include_generated: None,
                    include_tests: None,
                    include_vendor: None,
                    include_personal_tooling: None,
                    estimate: None,
                    max_tokens: None,
                    structural: None,
                };
                self.search_text(Parameters(input)).await
            }
            smart_query::QueryIntent::FindDependents { target } => {
                let input = FindDependentsInput {
                    path: target.clone(),
                    name: None,
                    limit: None,
                    max_per_file: None,
                    format: None,
                    compact: Some(true),
                    estimate: None,
                    max_tokens: None,
                };
                self.find_dependents(Parameters(input)).await
            }
            smart_query::QueryIntent::FindImplementations { name } => {
                let input = FindReferencesInput {
                    name: name.clone(),
                    kind: None,
                    path: None,
                    symbol_kind: None,
                    symbol_line: None,
                    limit: None,
                    max_per_file: None,
                    compact: None,
                    mode: Some("implementations".to_string()),
                    direction: None,
                    estimate: None,
                    max_tokens: None,
                };
                self.find_references(Parameters(input)).await
            }
            smart_query::QueryIntent::ToolHelp { topic } => {
                smart_query::render_tool_catalog_for_topic(topic.as_deref())
            }
            smart_query::QueryIntent::Explore { query } => {
                let input = ExploreInput {
                    query: query.clone(),
                    limit: None,
                    depth: Some(2),
                    include_noise: None,
                    include_vendor: None,
                    include_personal_tooling: None,
                    language: None,
                    path_prefix: None,
                    estimate: None,
                    max_tokens: None,
                };
                self.explore(Parameters(input)).await
            }
        };

        let mut envelope = format!(
            "Route confidence: {}\nChosen tool: {}\nInvocation: {}\nRationale: {}",
            smart_query::route_confidence_label(assessment.confidence),
            smart_query::route_tool_name(&intent),
            smart_query::route_invocation(&intent),
            assessment.rationale,
        );
        if original_q != q {
            envelope.push_str(&format!("\nOriginal query: {original_q}"));
        }
        if let Some(next_step) = assessment.suggested_next_step {
            envelope.push_str(&format!("\nSuggested next step: {next_step}"));
        }

        let output = format!("{envelope}\n{route_desc}\n\n{result}");
        self.session_context
            .record_summary_output("ask", (output.len() / 4).min(u32::MAX as usize) as u32);
        format::enforce_token_budget(output, params.0.max_tokens)
    }

    /// Symbol-level diff between two git refs. Shows +added, -removed, ~modified symbols per changed
    /// file. Filter with path_prefix and/or language. Set code_only=true to exclude non-source files.
    /// Use for code review to see which functions/classes changed.
    /// NOT for file-level change lists (use what_changed).
    #[tool(
        description = "Symbol-level diff between two git refs. Shows +added, -removed, ~modified symbols per changed file. Filter with path_prefix and/or language. Set code_only=true to exclude non-source files. Use for code review to see which functions/classes changed. NOT for file-level change lists (use what_changed).",
        annotations(read_only_hint = true, open_world_hint = false)
    )]
    pub(crate) async fn diff_symbols(&self, params: Parameters<DiffSymbolsInput>) -> String {
        if let Some(result) = self.proxy_tool_call("diff_symbols", &params.0).await {
            return result;
        }
        if params.0.estimate == Some(true) {
            let compact = params.0.compact.unwrap_or(false);
            let summary = params.0.summary_only.unwrap_or(false);
            let est = if summary {
                50
            } else if compact {
                200
            } else {
                500
            };
            return format!(
                "Estimate for diff_symbols: ~{} tokens (compact={}, summary_only={})",
                est, compact, summary
            );
        }
        let base = params.0.base.as_deref().unwrap_or("main");
        let target = params.0.target.as_deref().unwrap_or("HEAD");

        let repo_root = self.capture_repo_root();

        let Some(repo_root) = repo_root else {
            return "No repository root found.".to_string();
        };

        // Check index is not loading/empty
        {
            let guard = self.index.read();
            loading_guard!(guard);
        }

        // Get changed files
        let repo = match crate::git::GitRepo::open(&repo_root) {
            Ok(r) => r,
            Err(e) => return format!("Failed to open repository: {e}"),
        };
        let changed_files_owned = match repo.changed_paths_between_refs(base, target) {
            Ok(paths) => paths,
            Err(e) => return format!("Failed to run git diff: {e}"),
        };

        // Apply path_prefix + language filter
        let lang_filter = match parse_language_filter(params.0.language.as_deref()) {
            Ok(f) => f,
            Err(e) => return e,
        };
        let code_only = params.0.code_only.unwrap_or(false);
        let changed_files: Vec<&str> = changed_files_owned
            .iter()
            .map(|s| s.as_str())
            .filter(|p| {
                if let Some(ref prefix) = params.0.path_prefix
                    && !p.starts_with(prefix.as_str())
                {
                    return false;
                }
                if let Some(ref lang) = lang_filter {
                    let ext = p.rsplit('.').next().unwrap_or("");
                    if crate::domain::index::LanguageId::from_extension(ext).as_ref() != Some(lang)
                    {
                        return false;
                    }
                }
                if code_only && lang_filter.is_none() {
                    let ext = p.rsplit('.').next().unwrap_or("");
                    match crate::domain::index::LanguageId::from_extension(ext) {
                        None => return false,
                        Some(lang) => {
                            if crate::parsing::config_extractors::is_config_language(&lang) {
                                return false;
                            }
                        }
                    }
                }
                true
            })
            .collect();

        if changed_files.is_empty() {
            return format!("No file changes found between {base} and {target}.");
        }

        let output = render_diff_symbols_output(
            base,
            target,
            changed_files_owned.len(),
            &changed_files,
            &repo,
            params.0.compact.unwrap_or(false),
            params.0.summary_only.unwrap_or(false),
            params.0.path_prefix.as_deref(),
            params.0.language.as_deref(),
            code_only,
        );
        self.record_tool_savings((output.len() * 5 / 4) as u64, (output.len() / 4) as u64);
        self.session_context.record_summary_output(
            "diff_symbols",
            (output.len() / 4).min(u32::MAX as usize) as u32,
        );
        format::enforce_token_budget(output, params.0.max_tokens)
    }
}

// ─── Unit tests ──────────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    mod find_references;

    use std::collections::HashMap;
    use std::ffi::OsString;
    use std::fs;
    use std::path::{Path, PathBuf};
    use std::process::Command;
    use std::sync::Arc;
    use std::time::{Duration, Instant};

    use crate::domain::{LanguageId, ReferenceKind, ReferenceRecord, SymbolKind, SymbolRecord};
    use crate::live_index::store::{CircuitBreakerState, IndexedFile, LiveIndex, ParseStatus};
    use crate::protocol::SymForgeServer;
    use crate::protocol::result_status::{OutcomeClass, RESULT_STATUS_META_KEY};
    use rmcp::handler::server::tool::IntoCallToolResult;
    use rmcp::handler::server::wrapper::Parameters;
    use tempfile::TempDir;

    // ── Test helpers ─────────────────────────────────────────────────────────

    fn make_symbol(name: &str, kind: SymbolKind, line_start: u32, line_end: u32) -> SymbolRecord {
        let byte_range = (0, 10);
        SymbolRecord {
            name: name.to_string(),
            kind,
            depth: 0,
            sort_order: 0,
            byte_range,
            item_byte_range: Some(byte_range),
            line_range: (line_start, line_end),
            doc_byte_range: None,
        }
    }

    // ── SF-001 Part 2: daemon served-binary staleness guard ──────────────────

    /// Build a minimal proxied-health string carrying the daemon's runtime
    /// `version=` token, exactly as `format::format_runtime_status` emits it.
    fn proxied_health_with_version(daemon_version: &str) -> String {
        format!(
            "Runtime: mode=daemon_reused_session | runtime_state=daemon_reused_session | \
             version={daemon_version} | project_root=/repo | project_id=p | session_id=s | \
             index_id=i | index_generation=1 | project_generation=1 | load_source=fresh_load | \
             reset_state=none | index_state=fresh_process"
        )
    }

    #[test]
    fn daemon_staleness_warning_fires_when_daemon_older_than_front_end() {
        let proxied = proxied_health_with_version("7.17.2");
        let out = super::append_daemon_staleness_warning(proxied, "7.18.1");
        assert!(
            out.contains("Daemon serving stale code"),
            "older daemon must trigger the staleness warning; got: {out}"
        );
        assert!(
            out.contains("7.17.2"),
            "warning should name the daemon version"
        );
        assert!(
            out.contains("7.18.1"),
            "warning should name the front-end version"
        );
    }

    #[test]
    fn daemon_staleness_warning_silent_when_versions_equal() {
        let proxied = proxied_health_with_version("7.18.1");
        let out = super::append_daemon_staleness_warning(proxied.clone(), "7.18.1");
        assert_eq!(
            out, proxied,
            "equal versions must not append any staleness warning"
        );
        assert!(!out.contains("Daemon serving stale code"));
    }

    #[test]
    fn daemon_staleness_warning_silent_when_daemon_newer() {
        let proxied = proxied_health_with_version("7.19.0");
        let out = super::append_daemon_staleness_warning(proxied.clone(), "7.18.1");
        assert_eq!(out, proxied, "a newer daemon must not be flagged as stale");
    }

    #[test]
    fn daemon_staleness_warning_silent_when_version_token_absent() {
        // Fail-safe: a report with no `version=` token yields no warning.
        let proxied = "Runtime: mode=daemon_reused_session | project_root=/repo".to_string();
        let out = super::append_daemon_staleness_warning(proxied.clone(), "7.18.1");
        assert_eq!(out, proxied);
    }

    #[test]
    fn daemon_staleness_warning_silent_when_version_unparseable() {
        // Fail-safe: a non-numeric version token cannot be compared, so no warning.
        let proxied = proxied_health_with_version("unknown");
        let out = super::append_daemon_staleness_warning(proxied.clone(), "7.18.1");
        assert_eq!(out, proxied);
    }

    #[test]
    fn extract_runtime_version_pulls_token_from_runtime_line() {
        let proxied = proxied_health_with_version("7.17.2");
        assert_eq!(super::extract_runtime_version(&proxied), Some("7.17.2"));
        assert_eq!(super::extract_runtime_version("no version here"), None);
    }

    fn make_symbol_with_bytes(
        name: &str,
        kind: SymbolKind,
        line_start: u32,
        line_end: u32,
        byte_range: (u32, u32),
    ) -> SymbolRecord {
        SymbolRecord {
            name: name.to_string(),
            kind,
            depth: 0,
            sort_order: 0,
            byte_range,
            item_byte_range: Some(byte_range),
            line_range: (line_start, line_end),
            doc_byte_range: None,
        }
    }

    fn make_file(path: &str, content: &[u8], symbols: Vec<SymbolRecord>) -> (String, IndexedFile) {
        (
            path.to_string(),
            IndexedFile {
                relative_path: path.to_string(),
                language: LanguageId::Rust,
                classification: crate::domain::FileClassification::for_code_path(path),
                content: content.to_vec(),
                symbols,
                parse_status: ParseStatus::Parsed,
                parse_diagnostic: None,
                byte_len: content.len() as u64,
                content_hash: "test".to_string(),
                references: vec![],
                alias_map: std::collections::HashMap::new(),
                mtime_secs: 0,
            },
        )
    }

    fn make_file_with_refs(
        path: &str,
        content: &[u8],
        symbols: Vec<SymbolRecord>,
        references: Vec<ReferenceRecord>,
    ) -> (String, IndexedFile) {
        let (key, mut file) = make_file(path, content, symbols);
        file.references = references;
        (key, file)
    }

    fn make_ref(
        name: &str,
        qualified_name: Option<&str>,
        kind: ReferenceKind,
        line: u32,
        enclosing_symbol_index: Option<u32>,
    ) -> ReferenceRecord {
        ReferenceRecord {
            name: name.to_string(),
            qualified_name: qualified_name.map(str::to_string),
            kind,
            byte_range: (line * 10, line * 10 + 6),
            line_range: (line, line),
            enclosing_symbol_index,
        }
    }

    fn make_symbol_with_depth(
        name: &str,
        kind: SymbolKind,
        depth: u32,
        line_start: u32,
        line_end: u32,
        byte_range: (u32, u32),
    ) -> SymbolRecord {
        SymbolRecord {
            name: name.to_string(),
            kind,
            depth,
            sort_order: 0,
            byte_range,
            item_byte_range: Some(byte_range),
            line_range: (line_start, line_end),
            doc_byte_range: None,
        }
    }

    fn large_outline_fixture(
        path: &str,
        function_count: u32,
        line_count: u32,
    ) -> (String, IndexedFile) {
        let mut content = String::new();
        let mut symbols = Vec::new();

        let module_start = content.len() as u32;
        content.push_str("mod large {\n");
        for i in 0..function_count {
            let start = content.len() as u32;
            content.push_str(&format!("    fn function_{i:03}() {{}}\n"));
            let end = content.len() as u32;
            symbols.push(make_symbol_with_depth(
                &format!("function_{i:03}"),
                SymbolKind::Function,
                1,
                i + 1,
                i + 1,
                (start, end),
            ));
        }
        content.push_str("}\n");

        while content.lines().count() < line_count as usize {
            content.push_str("// padding\n");
        }

        let module_end = content.len() as u32;
        symbols.insert(
            0,
            make_symbol_with_depth(
                "large",
                SymbolKind::Module,
                0,
                0,
                line_count.saturating_sub(1),
                (module_start, module_end),
            ),
        );

        make_file(path, content.as_bytes(), symbols)
    }

    fn nested_test_module_fixture(path: &str, test_count: u32) -> (String, IndexedFile) {
        let mut content = String::from("#[cfg(test)]\nmod tests {\n");
        let mut symbols = vec![make_symbol_with_depth(
            "tests",
            SymbolKind::Module,
            0,
            1,
            test_count + 2,
            (0, 0),
        )];

        for i in 0..test_count {
            let start = content.len() as u32;
            content.push_str(&format!("    fn test_{i:03}() {{}}\n"));
            let end = content.len() as u32;
            symbols.push(make_symbol_with_depth(
                &format!("test_{i:03}"),
                SymbolKind::Function,
                1,
                i + 2,
                i + 2,
                (start, end),
            ));
        }
        content.push_str("}\n");

        make_file(path, content.as_bytes(), symbols)
    }

    fn make_live_index_ready(files: Vec<(String, IndexedFile)>) -> LiveIndex {
        use crate::live_index::trigram::TrigramIndex;
        let files_map = files
            .into_iter()
            .map(|(path, file)| (path, std::sync::Arc::new(file)))
            .collect::<HashMap<_, _>>();
        let trigram_index = TrigramIndex::build_from_files(&files_map);
        let mut index = LiveIndex {
            files: files_map,
            loaded_at: Instant::now(),
            loaded_at_system: std::time::SystemTime::now(),
            load_duration: Duration::from_millis(10),
            cb_state: CircuitBreakerState::new(0.20),
            is_empty: false,
            load_source: crate::live_index::store::IndexLoadSource::FreshLoad,
            snapshot_verify_state: crate::live_index::store::SnapshotVerifyState::NotNeeded,
            reverse_index: HashMap::new(),
            files_by_basename: HashMap::new(),
            files_by_dir_component: HashMap::new(),
            trigram_index,
            gitignore: None,
            skipped_files: Vec::new(),
            coupling_store: None,
            local_empty_reason: std::sync::Arc::new(parking_lot::RwLock::new(None)),
            indexed_root: None,
        };
        index.rebuild_reverse_index();
        index.rebuild_path_indices();
        index
    }

    fn make_live_index_ready_with_coupling_store(
        files: Vec<(String, IndexedFile)>,
        store: crate::live_index::coupling::CouplingStore,
    ) -> LiveIndex {
        let mut index = make_live_index_ready(files);
        index.coupling_store = Some(Arc::new(store));
        index
    }

    /// Build an index seeded with one src, one vendor, and one personal-tooling
    /// file -- each containing the literal `noisy_token`. Used by Task 2.2 tests
    /// to exercise the default-suppress / opt-in surfacing semantics in
    /// `search_text` and `explore`.
    fn make_live_index_ready_with_vendor_file() -> LiveIndex {
        let content = b"// noisy_token marker for path-filter tests\n";
        let sym = SymbolRecord {
            name: "anchor".to_string(),
            kind: SymbolKind::Function,
            depth: 0,
            sort_order: 0,
            byte_range: (0, content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let (src_key, src_file) = make_file("src/normal.rs", content, vec![sym.clone()]);
        let (vendor_key, vendor_file) = make_file("vendor/foo/bar.rs", content, vec![sym.clone()]);
        let (personal_key, personal_file) =
            make_file(".claude/gsd-local-patches/foo.rs", content, vec![sym]);
        make_live_index_ready(vec![
            (src_key, src_file),
            (vendor_key, vendor_file),
            (personal_key, personal_file),
        ])
    }

    fn make_live_index_empty() -> LiveIndex {
        use crate::live_index::trigram::TrigramIndex;
        LiveIndex {
            files: HashMap::new(),
            loaded_at: Instant::now(),
            loaded_at_system: std::time::SystemTime::now(),
            load_duration: Duration::ZERO,
            cb_state: CircuitBreakerState::new(0.20),
            is_empty: true,
            load_source: crate::live_index::store::IndexLoadSource::EmptyBootstrap,
            snapshot_verify_state: crate::live_index::store::SnapshotVerifyState::NotNeeded,
            reverse_index: HashMap::new(),
            files_by_basename: HashMap::new(),
            files_by_dir_component: HashMap::new(),
            trigram_index: TrigramIndex::new(),
            gitignore: None,
            skipped_files: Vec::new(),
            coupling_store: None,
            local_empty_reason: std::sync::Arc::new(parking_lot::RwLock::new(None)),
            indexed_root: None,
        }
    }

    fn make_live_index_tripped() -> LiveIndex {
        use crate::live_index::trigram::TrigramIndex;
        let cb = CircuitBreakerState::new(0.10);
        for _ in 0..8 {
            cb.record_success();
        }
        for i in 0..2 {
            cb.record_failure(&format!("f{i}.rs"), "err");
        }
        cb.should_abort(); // trips at 20% > 10%
        LiveIndex {
            files: HashMap::new(),
            loaded_at: Instant::now(),
            loaded_at_system: std::time::SystemTime::now(),
            load_duration: Duration::ZERO,
            cb_state: cb,
            is_empty: false,
            load_source: crate::live_index::store::IndexLoadSource::FreshLoad,
            snapshot_verify_state: crate::live_index::store::SnapshotVerifyState::NotNeeded,
            reverse_index: HashMap::new(),
            files_by_basename: HashMap::new(),
            files_by_dir_component: HashMap::new(),
            trigram_index: TrigramIndex::new(),
            gitignore: None,
            skipped_files: Vec::new(),
            coupling_store: None,
            local_empty_reason: std::sync::Arc::new(parking_lot::RwLock::new(None)),
            indexed_root: None,
        }
    }

    fn make_server_with_root(index: LiveIndex, repo_root: Option<PathBuf>) -> SymForgeServer {
        use crate::watcher::WatcherInfo;
        use parking_lot::Mutex;
        let shared = crate::live_index::SharedIndexHandle::shared(index);
        let watcher_info = Arc::new(Mutex::new(WatcherInfo::default()));
        SymForgeServer::new(
            shared,
            "test_project".to_string(),
            watcher_info,
            repo_root,
            None,
        )
    }

    fn make_server(index: LiveIndex) -> SymForgeServer {
        make_server_with_root(index, None)
    }

    fn serialized_tool_result<R: IntoCallToolResult>(result: R) -> serde_json::Value {
        let result = result.into_call_tool_result().unwrap();
        serde_json::to_value(&result).unwrap()
    }

    fn assert_tool_result_status(serialized: &serde_json::Value, expected: OutcomeClass) {
        assert_eq!(
            serialized["_meta"][RESULT_STATUS_META_KEY]["contract_version"],
            serde_json::json!(1),
            "missing or wrong result-status contract version: {serialized}"
        );
        assert_eq!(
            serialized["_meta"][RESULT_STATUS_META_KEY]["outcome_class"],
            serde_json::json!(expected.as_str()),
            "wrong result-status outcome: {serialized}"
        );
    }

    fn assert_tool_result_edit_status(
        serialized: &serde_json::Value,
        expected_status: &str,
        expected_outcome: OutcomeClass,
    ) {
        assert_tool_result_status(serialized, expected_outcome);
        assert_eq!(
            serialized["_meta"][RESULT_STATUS_META_KEY]["status"],
            serde_json::json!(expected_status),
            "wrong edit result status: {serialized}"
        );
    }

    fn tool_result_text(serialized: &serde_json::Value) -> &str {
        serialized["content"][0]["text"]
            .as_str()
            .expect("tool result should contain one text content item")
    }

    fn get_file_content_input(path: &str) -> super::GetFileContentInput {
        super::GetFileContentInput {
            path: path.to_string(),
            mode: None,
            start_line: None,
            end_line: None,
            chunk_index: None,
            max_lines: None,
            around_line: None,
            around_match: None,
            match_occurrence: None,
            around_symbol: None,
            symbol_line: None,
            context_lines: None,
            show_line_numbers: None,
            header: None,
            estimate: None,
            offset: None,
            limit: None,
            max_tokens: None,
        }
    }

    fn get_symbol_input(path: &str, name: &str) -> super::GetSymbolInput {
        super::GetSymbolInput {
            path: path.to_string(),
            name: name.to_string(),
            kind: None,
            symbol_line: None,
            targets: None,
            estimate: None,
        }
    }

    fn search_symbols_input(query: Option<&str>) -> super::SearchSymbolsInput {
        super::SearchSymbolsInput {
            query: query.map(str::to_string),
            kind: None,
            path_prefix: None,
            language: None,
            limit: None,
            include_generated: None,
            include_tests: None,
            include_vendor: None,
            include_personal_tooling: None,
            estimate: None,
            max_tokens: None,
        }
    }

    fn search_files_input(query: &str) -> super::SearchFilesInput {
        super::SearchFilesInput {
            query: query.to_string(),
            limit: None,
            current_file: None,
            changed_with: None,
            resolve: None,
            estimate: None,
            max_tokens: None,
            debug_ranking: None,
            rank_by: None,
            anchor_path: None,
            include_vendor: None,
            include_personal_tooling: None,
        }
    }

    fn find_references_input(name: &str) -> super::FindReferencesInput {
        super::FindReferencesInput {
            name: name.to_string(),
            kind: None,
            path: None,
            symbol_kind: None,
            symbol_line: None,
            limit: None,
            max_per_file: None,
            compact: None,
            mode: None,
            direction: None,
            estimate: None,
            max_tokens: None,
        }
    }

    fn write_sidecar_state(repo_root: &Path, port: Option<u16>, pid: Option<&str>) {
        let dir = crate::paths::ensure_symforge_dir(repo_root)
            .expect("test repo root should allow .symforge creation");
        if let Some(port) = port {
            fs::write(dir.join("sidecar.port"), port.to_string())
                .expect("sidecar port file should be writable");
        }
        if let Some(pid) = pid {
            fs::write(dir.join("sidecar.pid"), pid).expect("sidecar pid file should be writable");
        }
    }

    struct EnvVarGuard {
        key: &'static str,
        previous: Option<OsString>,
    }

    #[allow(unsafe_code)] // test-only env guard serializes process env mutation.
    impl EnvVarGuard {
        fn set(key: &'static str, value: &str) -> Self {
            let previous = std::env::var_os(key);
            // SAFETY: called only in single-threaded test context; no concurrent env readers.
            unsafe {
                std::env::set_var(key, value);
            }
            Self { key, previous }
        }

        fn remove(key: &'static str) -> Self {
            let previous = std::env::var_os(key);
            // SAFETY: called only in single-threaded test context; no concurrent env readers.
            unsafe {
                std::env::remove_var(key);
            }
            Self { key, previous }
        }

        fn set_path(key: &'static str, value: &Path) -> Self {
            let previous = std::env::var_os(key);
            // SAFETY: called only in single-threaded test context; no concurrent env readers.
            unsafe {
                std::env::set_var(key, value);
            }
            Self { key, previous }
        }
    }

    #[allow(unsafe_code)] // test-only env guard restores serialized process env mutation.
    impl Drop for EnvVarGuard {
        fn drop(&mut self) {
            match &self.previous {
                // SAFETY: called only in single-threaded test context; no concurrent env readers.
                Some(previous) => unsafe {
                    std::env::set_var(self.key, previous);
                },
                // SAFETY: called only in single-threaded test context; no concurrent env readers.
                None => unsafe {
                    std::env::remove_var(self.key);
                },
            }
        }
    }

    struct CwdGuard {
        previous: PathBuf,
    }

    impl CwdGuard {
        fn set(path: &Path) -> Self {
            let previous = std::env::current_dir().expect("current dir");
            std::env::set_current_dir(path).expect("set current dir");
            Self { previous }
        }
    }

    impl Drop for CwdGuard {
        fn drop(&mut self) {
            if std::env::set_current_dir(&self.previous).is_err() {
                std::env::set_current_dir(env!("CARGO_MANIFEST_DIR")).expect("restore current dir");
            }
        }
    }

    fn run_git(repo_root: &Path, args: &[&str]) {
        let output = Command::new("git")
            .arg("-C")
            .arg(repo_root)
            .args(args)
            .output()
            .expect("git command should start");
        assert!(
            output.status.success(),
            "git {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr)
        );
    }

    fn init_git_repo() -> TempDir {
        let dir = TempDir::new().expect("temp git repo");
        run_git(dir.path(), &["init", "-q"]);
        run_git(
            dir.path(),
            &["config", "user.email", "symforge-tests@example.com"],
        );
        run_git(dir.path(), &["config", "user.name", "SymForge Tests"]);
        dir
    }

    // ── Loading guard tests ───────────────────────────────────────────────────

    #[tokio::test]
    async fn test_loading_guard_empty_returns_empty_message() {
        let server = make_server(make_live_index_empty());
        // Any non-health tool should return the empty guard message
        let result = server
            .get_symbol(Parameters(super::GetSymbolInput {
                path: "anything.rs".to_string(),
                name: "anything".to_string(),
                kind: None,
                symbol_line: None,
                targets: None,
                estimate: None,
            }))
            .await;
        assert_eq!(
            result,
            crate::protocol::format::empty_guard_message(),
            "empty index should return empty guard message"
        );
    }

    #[tokio::test]
    async fn test_get_repo_map_outline_on_empty_index_returns_guard_message() {
        // The outline (`detail="full"`) path must surface the empty-index
        // guidance rather than a bare empty map. Regression for X4: previously
        // only some branches guarded, so an outline-only call on an empty/
        // unloaded index could fall through to an empty result.
        let server = make_server(make_live_index_empty());
        let result = server
            .get_repo_map(Parameters(super::GetRepoMapInput {
                detail: Some("full".to_string()),
                path: None,
                depth: None,
                max_files: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            crate::protocol::format::empty_guard_message(),
            "empty index get_repo_map(detail=full) must return the guard message, got: {result}"
        );

        // The `tree` outline branch must guard identically.
        let tree_result = server
            .get_repo_map(Parameters(super::GetRepoMapInput {
                detail: Some("tree".to_string()),
                path: None,
                depth: None,
                max_files: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            tree_result,
            crate::protocol::format::empty_guard_message(),
            "empty index get_repo_map(detail=tree) must return the guard message, got: {tree_result}"
        );

        // And the default compact branch.
        let compact_result = server
            .get_repo_map(Parameters(super::GetRepoMapInput {
                detail: None,
                path: None,
                depth: None,
                max_files: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            compact_result,
            crate::protocol::format::empty_guard_message(),
            "empty index get_repo_map(compact) must return the guard message, got: {compact_result}"
        );
    }

    #[tokio::test]
    async fn test_loading_guard_circuit_breaker_returns_degraded_message() {
        let server = make_server(make_live_index_tripped());
        let result = server
            .get_symbol(Parameters(super::GetSymbolInput {
                path: "anything.rs".to_string(),
                name: "anything".to_string(),
                kind: None,
                symbol_line: None,
                targets: None,
                estimate: None,
            }))
            .await;
        assert!(
            result.starts_with("Index degraded:"),
            "tripped CB should return degraded message, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_health_always_responds_on_empty_index() {
        let server = make_server(make_live_index_empty());
        let result = server.health().await;
        // Health should NOT return the guard message; it should return actual health info
        assert!(
            !result.starts_with("Index not loaded"),
            "health should always respond, got: {result}"
        );
        assert!(
            result.contains("Status: Empty"),
            "health of empty index should show Empty, got: {result}"
        );
    }

    // ── Tool handler tests ────────────────────────────────────────────────────

    #[tokio::test]
    async fn test_get_file_context_outline_only_contains_path_and_symbol() {
        let sym = make_symbol("main", SymbolKind::Function, 1, 5);
        let (key, file) = make_file("src/main.rs", b"fn main() {}", vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_context(Parameters(super::GetFileContextInput {
                path: "src/main.rs".to_string(),
                max_tokens: None,
                sections: Some(vec!["outline".to_string()]),
                estimate: None,
            }))
            .await;
        assert!(result.contains("src/main.rs"), "should contain path");
        assert!(result.contains("main"), "should contain symbol name");
        assert!(result.contains("Tip:"), "should include next-step hint");
    }

    #[tokio::test]
    async fn test_get_symbol_delegates_to_formatter() {
        let sym = make_symbol("foo", SymbolKind::Function, 1, 3);
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_symbol(Parameters(super::GetSymbolInput {
                path: "src/lib.rs".to_string(),
                name: "foo".to_string(),
                kind: None,
                symbol_line: None,
                targets: None,
                estimate: None,
            }))
            .await;
        // Should return source body or not-found message — not a guard message
        assert!(
            !result.starts_with("Index"),
            "should not return guard message, got: {result}"
        );
        assert!(
            result.contains("Tip:"),
            "should include next-step hint: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_repo_map_full_uses_project_name() {
        let (key, file) = make_file("src/main.rs", b"fn main() {}", vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_repo_map(Parameters(super::GetRepoMapInput {
                detail: Some("full".to_string()),
                path: None,
                depth: None,
                max_files: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("test_project"),
            "repo outline should use project_name, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_repo_map_full_loading_guard_empty() {
        let server = make_server(make_live_index_empty());
        let result = server
            .get_repo_map(Parameters(super::GetRepoMapInput {
                detail: Some("full".to_string()),
                path: None,
                depth: None,
                max_files: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, crate::protocol::format::empty_guard_message());
    }

    #[tokio::test]
    async fn test_get_repo_map_full_proxies_to_daemon_session() {
        let daemon_home = TempDir::new().expect("daemon home");
        let _env_guard = EnvVarGuard::set_path("SYMFORGE_HOME", daemon_home.path());
        // The daemon is fail-closed; pin a token so the direct open call and the
        // proxy client (which resolves the token via env) can authenticate.
        let auth_token = "repo-map-proxy-test-token";
        let _auth_guard = EnvVarGuard::set("SYMFORGE_DAEMON_AUTH_TOKEN", auth_token);
        let project = TempDir::new().expect("project dir");
        fs::create_dir_all(project.path().join("src")).expect("src dir");
        fs::write(project.path().join("src").join("main.rs"), "fn main() {}\n")
            .expect("write source");

        let handle = crate::daemon::spawn_daemon("127.0.0.1")
            .await
            .expect("spawn daemon");
        let base_url = format!("http://127.0.0.1:{}", handle.port);
        let opened = reqwest::Client::new()
            .post(format!("{base_url}/v1/sessions/open"))
            .bearer_auth(auth_token)
            .json(&crate::daemon::OpenProjectRequest {
                project_root: project.path().display().to_string(),
                client_name: "codex".to_string(),
                pid: Some(1234),
            })
            .send()
            .await
            .expect("open request")
            .error_for_status()
            .expect("open status")
            .json::<crate::daemon::OpenProjectResponse>()
            .await
            .expect("open body");

        let daemon_client = crate::daemon::DaemonSessionClient::new_for_test(
            base_url,
            opened.project_id,
            opened.session_id,
            opened.project_name,
        );
        let server = SymForgeServer::new_daemon_proxy(daemon_client);

        let result = server
            .get_repo_map(Parameters(super::GetRepoMapInput {
                detail: Some("full".to_string()),
                path: None,
                depth: None,
                max_files: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("main.rs"),
            "remote repo outline should come from daemon project instance, got: {result}"
        );

        let _ = handle.shutdown_tx.send(());
    }

    /// Review finding 2 (post-v7.19.0): when a daemon is CONFIGURED but
    /// unreachable, `checkpoint_now` falls back to checkpointing the locally
    /// reloaded index — and its receipt must say so, because the snapshot it
    /// writes can lag the daemon's authoritative live index. A client pointed
    /// at a dead port (with no stored project root, so `reconnect` cannot
    /// rediscover a real daemon) forces exactly that fallback path.
    #[tokio::test]
    async fn test_checkpoint_now_degraded_fallback_receipt_names_local_index() {
        let daemon_client = crate::daemon::DaemonSessionClient::new_for_test(
            // Port 1 is never serving; connection fails fast.
            "http://127.0.0.1:1".to_string(),
            "project-id".to_string(),
            "session-id".to_string(),
            "project-name".to_string(),
        );
        let server = SymForgeServer::new_daemon_proxy(daemon_client);
        let dir = tempfile::tempdir().expect("create temp repo root");
        server.set_repo_root(Some(dir.path().to_path_buf()));

        let output = server
            .checkpoint_now(Parameters(super::CheckpointNowInput {
                verify_after_write: Some(true),
            }))
            .await;

        assert!(
            output.contains("Checkpoint complete"),
            "local fallback should still checkpoint successfully, got: {output}"
        );
        assert!(
            output.contains("daemon unreachable"),
            "degraded-fallback receipt must disclose the daemon was unreachable, got: {output}"
        );
        assert!(
            output.contains("locally reloaded index"),
            "degraded-fallback receipt must name WHAT was checkpointed, got: {output}"
        );
    }

    #[tokio::test]
    async fn test_get_repo_map_returns_directory_breakdown() {
        let sym = make_symbol("main", SymbolKind::Function, 1, 3);
        let (key, file) = make_file("src/main.rs", b"fn main() {}", vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));

        let result = server
            .get_repo_map(Parameters(super::GetRepoMapInput {
                detail: None,
                path: None,
                depth: None,
                max_files: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("Index: 1 files, 1 symbols"),
            "repo map should include totals header; got: {result}"
        );
        assert!(
            result.contains("src"),
            "repo map should include directory breakdown; got: {result}"
        );
        assert!(
            result.contains("Tip:"),
            "repo map should include next-step hint"
        );
    }

    #[tokio::test]
    async fn test_get_file_context_returns_outline_and_key_references() {
        let callee = make_symbol("target", SymbolKind::Function, 1, 3);
        let caller = make_symbol("caller", SymbolKind::Function, 1, 3);
        let target_file = make_file("src/target.rs", b"pub fn target() {}", vec![callee]);
        let caller_file = make_file_with_refs(
            "src/caller.rs",
            b"use crate::target;\nfn caller() { target(); }",
            vec![caller],
            vec![
                ReferenceRecord {
                    name: "target".to_string(),
                    qualified_name: Some("crate::target".to_string()),
                    kind: ReferenceKind::Import,
                    byte_range: (4, 10),
                    line_range: (0, 0),
                    enclosing_symbol_index: None,
                },
                ReferenceRecord {
                    name: "target".to_string(),
                    qualified_name: None,
                    kind: ReferenceKind::Call,
                    byte_range: (30, 36),
                    line_range: (1, 1),
                    enclosing_symbol_index: Some(0),
                },
            ],
        );
        let server = make_server(make_live_index_ready(vec![target_file, caller_file]));

        let result = server
            .get_file_context(Parameters(super::GetFileContextInput {
                path: "src/target.rs".to_string(),
                max_tokens: None,
                sections: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("src/target.rs"),
            "file context should include file header; got: {result}"
        );
        assert!(
            result.contains("Key references"),
            "file context should include reference section; got: {result}"
        );
        assert!(
            result.contains("Scope: path `src/target.rs`; all sections"),
            "file context should surface scope; got: {result}"
        );
        assert!(
            result.contains("src/caller.rs"),
            "file context should include caller file; got: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_file_context_large_default_uses_bounded_summary() {
        let mut content = String::new();
        let mut symbols = Vec::new();
        for i in 0..320u32 {
            let name = format!("symbol_{i:03}");
            content.push_str(&format!("pub fn {name}() {{}}\n"));
            symbols.push(make_symbol(&name, SymbolKind::Function, i, i));
        }
        let target_file = make_file("src/target.rs", content.as_bytes(), symbols);
        let caller_file = make_file_with_refs(
            "src/caller.rs",
            b"use crate::target::symbol_000;\nfn caller() { symbol_000(); }",
            vec![make_symbol("caller", SymbolKind::Function, 1, 1)],
            vec![
                ReferenceRecord {
                    name: "symbol_000".to_string(),
                    qualified_name: Some("crate::target::symbol_000".to_string()),
                    kind: ReferenceKind::Import,
                    byte_range: (4, 14),
                    line_range: (0, 0),
                    enclosing_symbol_index: None,
                },
                ReferenceRecord {
                    name: "symbol_000".to_string(),
                    qualified_name: None,
                    kind: ReferenceKind::Call,
                    byte_range: (43, 53),
                    line_range: (1, 1),
                    enclosing_symbol_index: Some(0),
                },
            ],
        );
        let server = make_server(make_live_index_ready(vec![target_file, caller_file]));

        let result = server
            .get_file_context(Parameters(super::GetFileContextInput {
                path: "src/target.rs".to_string(),
                max_tokens: None,
                sections: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("Large file summary"),
            "large default should declare summary mode: {result}"
        );
        assert!(
            result.contains("sections outline, imports"),
            "large default should limit expensive sections: {result}"
        );
        assert!(
            result.contains("omitted") || result.contains("Omitted"),
            "large summary should explain omitted detail: {result}"
        );
        assert!(
            !result.contains("Used by") && !result.contains("Key references"),
            "large default should skip expensive dependent/reference sections: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_file_context_shows_parse_diagnostic_for_partial_file() {
        let (key, mut file) = make_file(
            "Cargo.toml",
            b"[package]\nname = \"symforge\"\ninvalid = \"unterminated\n",
            vec![make_symbol("package", SymbolKind::Key, 1, 1)],
        );
        let diagnostic = crate::domain::ParseDiagnostic {
            parser: "toml_edit".to_string(),
            message: "missing closing quote".to_string(),
            line: Some(3),
            column: Some(11),
            byte_span: Some((28, 41)),
            fallback_used: true,
        };
        file.language = LanguageId::Toml;
        file.parse_status = ParseStatus::PartialParse {
            warning: diagnostic.summary(),
        };
        file.parse_diagnostic = Some(diagnostic);
        let server = make_server(make_live_index_ready(vec![(key, file)]));

        let result = server
            .get_file_context(Parameters(super::GetFileContextInput {
                path: "Cargo.toml".to_string(),
                max_tokens: None,
                sections: Some(vec!["outline".to_string()]),
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("Parse status: partial"),
            "file context should surface parse status; got: {result}"
        );
        assert!(
            result.contains("Diagnostic: toml_edit: missing closing quote (line 3, column 11) [fallback symbol extraction used]"),
            "file context should include the structured diagnostic; got: {result}"
        );
        assert!(
            result.contains("Byte span: 28..41"),
            "file context should include the diagnostic byte span; got: {result}"
        );
    }

    #[tokio::test]
    async fn test_validate_file_syntax_reports_partial_toml_diagnostics() {
        let repo = TempDir::new().expect("temp repo");
        fs::write(
            repo.path().join("Cargo.toml"),
            "[package]\nname = \"symforge\"\nversion = \"0.1.0\"\ninvalid = \"unterminated\n",
        )
        .expect("write malformed toml");
        let server =
            make_server_with_root(make_live_index_empty(), Some(repo.path().to_path_buf()));

        let result = server
            .validate_file_syntax(Parameters(super::ValidateFileSyntaxInput {
                path: "Cargo.toml".to_string(),
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("Status: partial"),
            "validator should report a partial parse when fallback extraction succeeds; got: {result}"
        );
        assert!(
            result.contains("Diagnostic: toml_edit:"),
            "validator should show the parser source; got: {result}"
        );
        assert!(
            result.contains("fallback symbol extraction used"),
            "validator should surface fallback usage; got: {result}"
        );
        assert!(
            !result.contains("Symbols extracted: 0"),
            "validator should report recovered symbols for malformed TOML; got: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_file_content_outside_root_returns_explicit_error() {
        // L2: a path that escapes the repo root must yield an explicit
        // "outside the repository root" error, not a misleading generic
        // "File not found". A normal in-repo path is unaffected.
        let repo = TempDir::new().expect("temp repo");
        fs::write(repo.path().join("real.txt"), b"hello world\n").expect("write in-repo file");
        let server = make_server_with_root(
            make_live_index_ready(vec![]),
            Some(repo.path().to_path_buf()),
        );

        let escaped = server
            .get_file_content(Parameters(get_file_content_input("../../../etc/passwd")))
            .await;
        assert!(
            escaped.contains("outside the repository root"),
            "traversal path must report a containment violation; got: {escaped}"
        );
        assert!(
            !escaped.starts_with("File not found"),
            "traversal path must NOT be reported as a generic miss; got: {escaped}"
        );

        // A genuine in-repo file (not in the index) still reads from disk fine.
        let ok = server
            .get_file_content(Parameters(get_file_content_input("real.txt")))
            .await;
        assert!(
            ok.contains("hello world"),
            "normal in-repo path must still be read; got: {ok}"
        );

        // A genuine in-repo but missing path still reports a normal not-found,
        // NOT an outside-root error.
        let missing = server
            .get_file_content(Parameters(get_file_content_input("does_not_exist.txt")))
            .await;
        assert!(
            !missing.contains("outside the repository root"),
            "an in-repo missing file must not be misreported as outside-root; got: {missing}"
        );
    }

    #[tokio::test]
    async fn test_validate_file_syntax_reports_deepest_nested_source_diagnostic() {
        let repo = TempDir::new().expect("temp repo");
        fs::write(
            repo.path().join("broken.py"),
            "def compute():\n    value = outer(inner(1 + 2, tail)\n    other = {'a': {'b': 1}}\n",
        )
        .expect("write malformed python");
        let server =
            make_server_with_root(make_live_index_empty(), Some(repo.path().to_path_buf()));

        let result = server
            .validate_file_syntax(Parameters(super::ValidateFileSyntaxInput {
                path: "broken.py".to_string(),
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("Status: partial"),
            "validator should report malformed source as a partial parse; got: {result}"
        );
        assert!(
            result.contains(
                "Diagnostic: tree-sitter: syntax error near `inner(1 + 2, tail)` (line 2, column 19)"
            ),
            "validator should report the nested actionable syntax location; got: {result}"
        );
        assert!(
            result.contains("Byte span: 33..51"),
            "validator should report the nested diagnostic byte span; got: {result}"
        );
    }

    #[tokio::test]
    async fn test_validate_file_syntax_reports_deepest_missing_node_diagnostic() {
        let repo = TempDir::new().expect("temp repo");
        fs::write(
            repo.path().join("broken.rs"),
            "fn compute() {\n    let value = outer(inner(1 + ), tail);\n}\n",
        )
        .expect("write malformed rust");
        let server =
            make_server_with_root(make_live_index_empty(), Some(repo.path().to_path_buf()));

        let result = server
            .validate_file_syntax(Parameters(super::ValidateFileSyntaxInput {
                path: "broken.rs".to_string(),
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("Status: partial"),
            "validator should report malformed source as a partial parse; got: {result}"
        );
        assert!(
            result.contains("Diagnostic: tree-sitter: syntax missing identifier"),
            "validator should report the missing node kind; got: {result}"
        );
        assert!(
            result.contains("(line 2, column 32)"),
            "validator should report the missing node location; got: {result}"
        );
        assert!(
            result.contains("Byte span: 46..46"),
            "validator should report the zero-width missing-node byte span; got: {result}"
        );
        assert!(
            result.contains("), tail);"),
            "validator should include source context after the missing node; got: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_file_context_shows_imports_and_used_by_sections() {
        let callee = make_symbol("target", SymbolKind::Function, 1, 3);
        let caller = make_symbol("caller", SymbolKind::Function, 1, 3);
        // caller.rs imports from crate::target and calls target().
        let caller_file = make_file_with_refs(
            "src/caller.rs",
            b"use crate::target;\nfn caller() { target(); }",
            vec![caller],
            vec![
                ReferenceRecord {
                    name: "target".to_string(),
                    qualified_name: Some("crate::target".to_string()),
                    kind: ReferenceKind::Import,
                    byte_range: (4, 10),
                    line_range: (0, 0),
                    enclosing_symbol_index: None,
                },
                ReferenceRecord {
                    name: "target".to_string(),
                    qualified_name: None,
                    kind: ReferenceKind::Call,
                    byte_range: (30, 36),
                    line_range: (1, 1),
                    enclosing_symbol_index: Some(0),
                },
            ],
        );
        let target_file = make_file("src/target.rs", b"pub fn target() {}", vec![callee]);
        let server = make_server(make_live_index_ready(vec![target_file, caller_file]));

        // Check caller.rs — should have "Imports from" section.
        let caller_result = server
            .get_file_context(Parameters(super::GetFileContextInput {
                path: "src/caller.rs".to_string(),
                max_tokens: Some(2000),
                sections: None,
                estimate: None,
            }))
            .await;
        assert!(
            caller_result.contains("Imports from"),
            "caller should show imports section; got: {caller_result}"
        );
        assert!(
            caller_result.contains("crate::target"),
            "caller should list crate::target as import source; got: {caller_result}"
        );

        // Check target.rs — should have "Used by" section.
        let target_result = server
            .get_file_context(Parameters(super::GetFileContextInput {
                path: "src/target.rs".to_string(),
                max_tokens: Some(2000),
                sections: None,
                estimate: None,
            }))
            .await;
        assert!(
            target_result.contains("Used by"),
            "target should show used-by section; got: {target_result}"
        );
        assert!(
            target_result.contains("src/caller.rs"),
            "target should list caller.rs as consumer; got: {target_result}"
        );
    }

    #[tokio::test]
    async fn test_get_file_context_ignores_generic_name_noise_without_real_dependency() {
        let target = make_symbol("main", SymbolKind::Function, 1, 3);
        let helper = make_symbol("helper", SymbolKind::Function, 1, 4);
        let helper_main = make_symbol("main", SymbolKind::Function, 5, 7);
        let target_file = make_file("src/target.py", b"def main():\n    pass\n", vec![target]);
        let helper_file = make_file_with_refs(
            "scripts/helper.py",
            b"def helper():\n    main()\n\ndef main():\n    pass\n",
            vec![helper, helper_main],
            vec![ReferenceRecord {
                name: "main".to_string(),
                qualified_name: None,
                kind: ReferenceKind::Call,
                byte_range: (18, 22),
                line_range: (1, 1),
                enclosing_symbol_index: Some(0),
            }],
        );
        let server = make_server(make_live_index_ready(vec![target_file, helper_file]));

        let result = server
            .get_file_context(Parameters(super::GetFileContextInput {
                path: "src/target.py".to_string(),
                max_tokens: None,
                sections: None,
                estimate: None,
            }))
            .await;

        assert!(
            !result.contains("scripts/helper.py"),
            "generic-name local calls should not be attributed as key references: {result}"
        );
    }

    mod get_file_context {
        use super::*;

        #[test]
        fn include_tests_is_advertised_for_context_tools() {
            for tool_name in ["get_file_context", "get_symbol_context"] {
                let tools = SymForgeServer::tool_definitions();
                let tool = tools
                    .iter()
                    .find(|tool| tool.name.as_ref() == tool_name)
                    .unwrap_or_else(|| panic!("{tool_name} should be registered"));
                let properties = tool
                    .input_schema
                    .get("properties")
                    .and_then(serde_json::Value::as_object)
                    .unwrap_or_else(|| panic!("{tool_name} should expose schema properties"));
                assert!(
                    properties.contains_key("include_tests"),
                    "{tool_name} should advertise include_tests in its input schema"
                );
            }
        }

        #[tokio::test]
        async fn nested_test_module_collapsed_by_default() {
            let file = nested_test_module_fixture("src/lib.rs", 125);
            let server = make_server(make_live_index_ready(vec![file]));

            let result = server
                .get_file_context(Parameters(super::super::GetFileContextInput {
                    path: "src/lib.rs".to_string(),
                    max_tokens: Some(2000),
                    sections: None,
                    estimate: None,
                }))
                .await;

            assert!(
                result.contains("test module `tests` collapsed: 125 functions not shown"),
                "large test module should collapse by default; got:\n{result}"
            );
            assert!(
                !result.contains("test_124"),
                "default outline should not enumerate every nested test function; got:\n{result}"
            );
        }

        #[tokio::test]
        async fn include_tests_true_restores_full_outline() {
            let file = nested_test_module_fixture("src/lib.rs", 125);
            let server = make_server(make_live_index_ready(vec![file]));

            let input: super::super::GetFileContextInput =
                serde_json::from_value(serde_json::json!({
                    "path": "src/lib.rs",
                    "max_tokens": 4000,
                    "include_tests": true
                }))
                .expect("include_tests input should deserialize");
            let result = server.get_file_context(Parameters(input)).await;

            assert!(
                result.contains("test_124"),
                "include_tests=true should show nested test functions; got:\n{result}"
            );
            assert!(
                !result.contains("test module `tests` collapsed"),
                "include_tests=true should not render collapse summary; got:\n{result}"
            );
        }
    }

    mod get_symbol_context {
        use super::*;

        #[tokio::test]
        async fn large_file_respects_budget() {
            let max_tokens = 600;
            let file = large_outline_fixture("src/large.rs", 300, 16_000);
            let server = make_server(make_live_index_ready(vec![file]));

            let started = Instant::now();
            let result = server
                .get_symbol_context(Parameters(super::super::GetSymbolContextInput {
                    name: "function_000".to_string(),
                    file: None,
                    path: Some("src/large.rs".to_string()),
                    symbol_kind: Some("fn".to_string()),
                    symbol_line: Some(2),
                    verbosity: None,
                    bundle: None,
                    sections: Some(vec![]),
                    max_tokens: Some(max_tokens),
                    estimate: None,
                }))
                .await;
            let elapsed = started.elapsed();

            eprintln!(
                "large_file_respects_budget wall_ms={} bytes={} max_tokens={}",
                elapsed.as_millis(),
                result.len(),
                max_tokens
            );
            assert!(
                elapsed < Duration::from_secs(5),
                "large-file context should complete under 5s; elapsed={elapsed:?}"
            );
            assert!(
                result.len() <= (max_tokens as usize * 4 * 12) / 10,
                "response should stay within +20% of token budget; bytes={}, max_tokens={}, output:\n{}",
                result.len(),
                max_tokens,
                result
            );
        }
    }

    #[tokio::test]
    async fn test_get_symbol_context_returns_grouped_references() {
        let caller = make_symbol("caller", SymbolKind::Function, 1, 3);
        let caller_file = make_file_with_refs(
            "src/caller.rs",
            b"fn caller() { target(); }",
            vec![caller],
            vec![ReferenceRecord {
                name: "target".to_string(),
                qualified_name: None,
                kind: ReferenceKind::Call,
                byte_range: (12, 18),
                line_range: (1, 1),
                enclosing_symbol_index: Some(0),
            }],
        );
        let server = make_server(make_live_index_ready(vec![caller_file]));

        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "target".to_string(),
                file: None,
                path: None,
                symbol_kind: None,
                symbol_line: None,
                verbosity: None,
                bundle: None,
                sections: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("src/caller.rs"),
            "symbol context should group matches by file; got: {result}"
        );
        assert!(
            result.contains("in fn caller"),
            "symbol context should include enclosing symbol names; got: {result}"
        );
        assert!(
            result.contains("Scope: repo-wide symbol token `target`"),
            "default symbol context should surface repo-wide scope; got: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_symbol_context_surfaces_impl_guidance_for_zero_caller_struct() {
        let content = b"pub struct ProjectOrchestratorActor;\n\nimpl ProjectOrchestratorActor {\n    pub fn new() -> Self {\n        Self\n    }\n}\n\nimpl Actor for ProjectOrchestratorActor {\n    fn handle(&self) {}\n}\n";
        let symbols = vec![
            SymbolRecord {
                name: "ProjectOrchestratorActor".to_string(),
                kind: SymbolKind::Struct,
                depth: 0,
                sort_order: 0,
                byte_range: (0, 35),
                line_range: (0, 0),
                doc_byte_range: None,
                item_byte_range: None,
            },
            SymbolRecord {
                name: "impl ProjectOrchestratorActor".to_string(),
                kind: SymbolKind::Impl,
                depth: 0,
                sort_order: 1,
                byte_range: (37, 117),
                line_range: (2, 6),
                doc_byte_range: None,
                item_byte_range: None,
            },
            SymbolRecord {
                name: "impl Actor for ProjectOrchestratorActor".to_string(),
                kind: SymbolKind::Impl,
                depth: 0,
                sort_order: 2,
                byte_range: (119, content.len() as u32),
                line_range: (8, 10),
                doc_byte_range: None,
                item_byte_range: None,
            },
        ];
        let server = make_server(make_live_index_ready(vec![make_file(
            "src/actors.rs",
            content,
            symbols,
        )]));

        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "ProjectOrchestratorActor".to_string(),
                file: None,
                path: Some("src/actors.rs".to_string()),
                symbol_kind: Some("struct".to_string()),
                symbol_line: None,
                verbosity: None,
                bundle: None,
                sections: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("0 direct callers"),
            "default symbol context should surface impl guidance for zero-caller structs: {result}"
        );
        assert!(
            result.contains("impl ProjectOrchestratorActor (src/actors.rs:3)"),
            "inherent impl suggestion should be surfaced: {result}"
        );
        assert!(
            result.contains("impl Actor for ProjectOrchestratorActor (src/actors.rs:9)"),
            "trait impl suggestion should be surfaced: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_symbol_context_exact_selector_excludes_unrelated_same_name_hits() {
        let target = make_file(
            "src/db.rs",
            b"pub fn connect() {}\n",
            vec![make_symbol("connect", SymbolKind::Function, 1, 1)],
        );
        let dependent = make_file_with_refs(
            "src/service.rs",
            b"use crate::db::connect;\nfn run() { connect(); }\n",
            vec![make_symbol("run", SymbolKind::Function, 2, 2)],
            vec![
                make_ref("db", Some("crate::db"), ReferenceKind::Import, 0, None),
                make_ref(
                    "connect",
                    Some("crate::db::connect"),
                    ReferenceKind::Call,
                    1,
                    Some(0),
                ),
            ],
        );
        let unrelated = make_file_with_refs(
            "src/other.rs",
            b"fn run() { connect(); }\n",
            vec![make_symbol("run", SymbolKind::Function, 1, 1)],
            vec![make_ref("connect", None, ReferenceKind::Call, 0, Some(0))],
        );
        let server = make_server(make_live_index_ready(vec![target, dependent, unrelated]));

        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "connect".to_string(),
                file: None,
                path: Some("src/db.rs".to_string()),
                symbol_kind: Some("fn".to_string()),
                symbol_line: Some(2),
                verbosity: None,
                bundle: None,
                sections: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("src/service.rs"),
            "expected dependent hit: {result}"
        );
        assert!(
            result.contains("Scope: path `src/db.rs`; exact selector line 2"),
            "exact selector scope should be explicit; got: {result}"
        );
        assert!(
            !result.contains("src/other.rs"),
            "unrelated same-name file should be excluded: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_symbol_context_exact_selector_requires_line_for_ambiguous_symbol() {
        let target = make_file(
            "src/db.rs",
            b"fn connect() { first(); }\nfn connect() { second(); }\n",
            vec![
                make_symbol_with_bytes("connect", SymbolKind::Function, 1, 1, (0, 25)),
                make_symbol_with_bytes("connect", SymbolKind::Function, 2, 2, (26, 52)),
            ],
        );
        let server = make_server(make_live_index_ready(vec![target]));

        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "connect".to_string(),
                file: None,
                path: Some("src/db.rs".to_string()),
                symbol_kind: Some("fn".to_string()),
                symbol_line: None,
                verbosity: None,
                bundle: None,
                sections: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("Ambiguous symbol selector"),
            "got: {result}"
        );
        assert!(
            !result.contains("first()"),
            "ambiguous selector should not prepend an arbitrary definition: {result}"
        );
        assert!(result.contains("1"), "got: {result}");
        assert!(result.contains("2"), "got: {result}");
    }

    #[tokio::test]
    async fn test_get_symbol_context_without_path_returns_ambiguous_candidate_list() {
        let alpha = make_file(
            "src/alpha.rs",
            b"fn connect() { alpha(); }\n",
            vec![make_symbol_with_bytes(
                "connect",
                SymbolKind::Function,
                1,
                1,
                (0, 25),
            )],
        );
        let beta = make_file(
            "src/beta.rs",
            b"fn connect() { beta(); }\n",
            vec![make_symbol_with_bytes(
                "connect",
                SymbolKind::Function,
                1,
                1,
                (0, 24),
            )],
        );
        let server = make_server(make_live_index_ready(vec![alpha, beta]));

        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "connect".to_string(),
                file: None,
                path: None,
                symbol_kind: Some("fn".to_string()),
                symbol_line: None,
                verbosity: None,
                bundle: None,
                sections: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.starts_with("Ambiguous symbol selector"),
            "ambiguous selector must fail explicitly; got: {result}"
        );
        assert!(result.contains("src/alpha.rs"), "got: {result}");
        assert!(result.contains("src/beta.rs"), "got: {result}");
        assert!(
            result.contains("Pass `path` or `file`"),
            "ambiguous response should include recovery guidance; got: {result}"
        );
        assert!(
            !result.contains("alpha()") && !result.contains("beta()"),
            "ambiguous selector must not show an arbitrary first definition: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_symbol_context_with_path_succeeds_when_symbol_name_is_duplicated() {
        let alpha = make_file(
            "src/alpha.rs",
            b"fn connect() { alpha(); }\n",
            vec![make_symbol_with_bytes(
                "connect",
                SymbolKind::Function,
                1,
                1,
                (0, 25),
            )],
        );
        let beta = make_file(
            "src/beta.rs",
            b"fn connect() { beta(); }\n",
            vec![make_symbol_with_bytes(
                "connect",
                SymbolKind::Function,
                1,
                1,
                (0, 24),
            )],
        );
        let server = make_server(make_live_index_ready(vec![alpha, beta]));

        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "connect".to_string(),
                file: None,
                path: Some("src/beta.rs".to_string()),
                symbol_kind: Some("fn".to_string()),
                symbol_line: None,
                verbosity: None,
                bundle: None,
                sections: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(
            !result.starts_with("Ambiguous symbol selector"),
            "explicit path should disambiguate duplicate symbol names: {result}"
        );
        assert!(result.contains("src/beta.rs"), "got: {result}");
        assert!(result.contains("beta()"), "got: {result}");
        assert!(!result.contains("alpha()"), "got: {result}");
    }

    #[tokio::test]
    async fn test_get_symbol_context_without_path_ambiguity_does_not_bump_frecency() {
        let repo = TempDir::new().expect("temp repo");
        let _frecency = EnvVarGuard::set(crate::live_index::frecency::FRECENCY_FLAG_ENV, "1");
        let alpha = make_file(
            "src/alpha.rs",
            b"fn connect() { alpha(); }\n",
            vec![make_symbol("connect", SymbolKind::Function, 1, 1)],
        );
        let beta = make_file(
            "src/beta.rs",
            b"fn connect() { beta(); }\n",
            vec![make_symbol("connect", SymbolKind::Function, 1, 1)],
        );
        let server = make_server_with_root(
            make_live_index_ready(vec![alpha, beta]),
            Some(repo.path().to_path_buf()),
        );

        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "connect".to_string(),
                file: None,
                path: None,
                symbol_kind: Some("fn".to_string()),
                symbol_line: None,
                verbosity: None,
                bundle: None,
                sections: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.starts_with("Ambiguous symbol selector"),
            "got: {result}"
        );
        assert!(
            !repo
                .path()
                .join(crate::paths::SYMFORGE_FRECENCY_DB_PATH)
                .exists(),
            "ambiguous selector must not bump frecency for an arbitrary first candidate"
        );
    }

    #[test]
    fn test_symbol_candidate_paths_sorts_all_matches() {
        let index = make_live_index_ready(vec![
            make_file(
                "src/zeta.rs",
                b"fn connect() {}\n",
                vec![make_symbol("connect", SymbolKind::Function, 1, 1)],
            ),
            make_file(
                "src/echo.rs",
                b"fn connect() {}\n",
                vec![make_symbol("connect", SymbolKind::Function, 1, 1)],
            ),
            make_file(
                "src/bravo.rs",
                b"fn connect() {}\n",
                vec![make_symbol("connect", SymbolKind::Function, 1, 1)],
            ),
            make_file(
                "src/alpha.rs",
                b"fn connect() {}\n",
                vec![make_symbol("connect", SymbolKind::Function, 1, 1)],
            ),
            make_file(
                "src/delta.rs",
                b"fn connect() {}\n",
                vec![make_symbol("connect", SymbolKind::Function, 1, 1)],
            ),
            make_file(
                "src/charlie.rs",
                b"fn connect() {}\n",
                vec![make_symbol("connect", SymbolKind::Function, 1, 1)],
            ),
        ]);

        let candidates = super::symbol_candidate_paths(&index, "connect");
        assert_eq!(candidates.len(), 6);
        assert_eq!(candidates.first().map(String::as_str), Some("src/alpha.rs"));
        assert_eq!(candidates.last().map(String::as_str), Some("src/zeta.rs"));
    }

    #[tokio::test]
    async fn test_get_symbol_context_exact_selector_respects_file_filter() {
        let target = make_file(
            "src/db.rs",
            b"pub fn connect() {}\n",
            vec![make_symbol("connect", SymbolKind::Function, 1, 1)],
        );
        let service = make_file_with_refs(
            "src/service.rs",
            b"use crate::db::connect;\nfn run() { connect(); }\n",
            vec![make_symbol("run", SymbolKind::Function, 2, 2)],
            vec![
                make_ref("db", Some("crate::db"), ReferenceKind::Import, 0, None),
                make_ref(
                    "connect",
                    Some("crate::db::connect"),
                    ReferenceKind::Call,
                    1,
                    Some(0),
                ),
            ],
        );
        let api = make_file_with_refs(
            "src/api.rs",
            b"use crate::db::connect;\nfn expose() { connect(); }\n",
            vec![make_symbol("expose", SymbolKind::Function, 2, 2)],
            vec![
                make_ref("db", Some("crate::db"), ReferenceKind::Import, 0, None),
                make_ref(
                    "connect",
                    Some("crate::db::connect"),
                    ReferenceKind::Call,
                    1,
                    Some(0),
                ),
            ],
        );
        let server = make_server(make_live_index_ready(vec![target, service, api]));

        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "connect".to_string(),
                file: Some("src/service.rs".to_string()),
                path: Some("src/db.rs".to_string()),
                symbol_kind: Some("fn".to_string()),
                symbol_line: Some(2),
                verbosity: None,
                bundle: None,
                sections: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(result.contains("src/service.rs"), "got: {result}");
        assert!(!result.contains("src/api.rs"), "got: {result}");
    }

    #[tokio::test]
    async fn test_analyze_file_impact_reports_symbol_change() {
        let repo = TempDir::new().expect("temp repo");
        fs::create_dir_all(repo.path().join("src")).expect("src dir");
        let source_path = repo.path().join("src").join("lib.rs");
        fs::write(&source_path, "pub fn new_name() {}\n").expect("write updated source");

        let old_symbol = make_symbol("old_name", SymbolKind::Function, 1, 1);
        let (key, file) = make_file("src/lib.rs", b"pub fn old_name() {}\n", vec![old_symbol]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(repo.path().to_path_buf()),
        );

        let result = server
            .analyze_file_impact(Parameters(super::AnalyzeFileImpactInput {
                path: "src/lib.rs".to_string(),
                new_file: None,
                include_co_changes: None,
                co_changes_limit: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("new_name"),
            "impact tool should re-read the file from repo_root and report new symbols; got: {result}"
        );
    }

    #[tokio::test]
    async fn test_analyze_file_impact_unchanged_shows_status() {
        // Use setup_edit_test so the index holds real parsed symbols (correct byte_ranges).
        // Then call analyze_file_impact WITHOUT modifying the file — should report unchanged.
        let source = b"pub fn alpha() {}\npub fn beta() {}\n";
        let (dir, server, _file_path) = setup_edit_test(source);
        let _ = dir; // keep temp dir alive

        let result = server
            .analyze_file_impact(Parameters(super::AnalyzeFileImpactInput {
                path: "src/lib.rs".to_string(),
                new_file: None,
                include_co_changes: None,
                co_changes_limit: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("indexed and unchanged"),
            "unchanged file should report 'indexed and unchanged'; got: {result}"
        );
        assert!(
            result.contains("Symbols: 2"),
            "should show symbol count; got: {result}"
        );
        assert!(
            result.contains("what_changed"),
            "should suggest what_changed; got: {result}"
        );
    }

    #[tokio::test]
    async fn test_analyze_file_impact_deleted_file() {
        let source = b"pub fn gone() {}\n";
        let (dir, server, file_path) = setup_edit_test(source);
        let _ = dir; // keep temp dir alive

        // Delete the file from disk to simulate external deletion.
        fs::remove_file(&file_path).expect("remove source");

        let result = server
            .analyze_file_impact(Parameters(super::AnalyzeFileImpactInput {
                path: "src/lib.rs".to_string(),
                new_file: None,
                include_co_changes: None,
                co_changes_limit: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("not found on disk"),
            "deleted file should report 'not found on disk'; got: {result}"
        );
    }

    #[tokio::test]
    async fn test_analyze_file_impact_auto_indexes_new_file_when_missing_from_index() {
        let repo = TempDir::new().expect("temp repo");
        fs::create_dir_all(repo.path().join("src")).expect("src dir");
        let source_path = repo.path().join("src").join("fresh.rs");
        fs::write(&source_path, "pub fn fresh_symbol() {}\n").expect("write new source");

        let server = make_server_with_root(
            make_live_index_ready(vec![]),
            Some(repo.path().to_path_buf()),
        );

        let result = server
            .analyze_file_impact(Parameters(super::AnalyzeFileImpactInput {
                path: "src/fresh.rs".to_string(),
                new_file: None,
                include_co_changes: None,
                co_changes_limit: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("[Indexed, 0 callers yet]"),
            "new on-disk file should auto-index even without new_file=true; got: {result}"
        );
        assert!(
            result.contains("fresh_symbol") || result.contains("1 fn"),
            "auto-indexed new file should report symbol summary; got: {result}"
        );
    }

    #[tokio::test]
    async fn test_index_folder_rebinds_repo_root_for_local_impact_analysis() {
        let repo = TempDir::new().expect("temp repo");
        fs::create_dir_all(repo.path().join("scratch")).expect("scratch dir");
        let source_path = repo.path().join("scratch").join("impact_case.rs");
        fs::write(&source_path, "pub fn old_name() {}\n").expect("write initial source");

        let server = make_server(make_live_index_empty());
        let index_result = server
            .index_folder(Parameters(super::IndexFolderInput {
                path: repo.path().display().to_string(),
                idempotency_key: None,
            }))
            .await;

        assert!(
            index_result.contains("Indexed 1 files"),
            "index_folder should load the temp repo, got: {index_result}"
        );

        fs::write(&source_path, "pub fn new_name() {}\n").expect("write updated source");
        let outside = TempDir::new().expect("outside cwd");
        let _cwd_guard = CwdGuard::set(outside.path());

        let impact = server
            .analyze_file_impact(Parameters(super::AnalyzeFileImpactInput {
                path: "scratch/impact_case.rs".to_string(),
                new_file: None,
                include_co_changes: None,
                co_changes_limit: None,
                estimate: None,
            }))
            .await;

        assert!(
            impact.contains("new_name"),
            "impact analysis should keep using the indexed repo root after index_folder, got: {impact}"
        );

        let outline = server
            .get_file_context(Parameters(super::GetFileContextInput {
                path: "scratch/impact_case.rs".to_string(),
                max_tokens: None,
                sections: Some(vec!["outline".to_string()]),
                estimate: None,
            }))
            .await;

        assert!(
            outline.contains("new_name"),
            "impact analysis must not replace the indexed file with an empty parse, got: {outline}"
        );
    }

    #[tokio::test]
    async fn test_index_folder_rebinds_repo_root_for_local_what_changed_git_mode() {
        let repo = init_git_repo();
        fs::create_dir_all(repo.path().join("src")).expect("create src dir");
        fs::write(repo.path().join("src/lib.rs"), "fn foo() {}\n").expect("write initial file");
        run_git(repo.path(), &["add", "."]);
        run_git(repo.path(), &["commit", "-m", "init", "-q"]);

        let server = make_server(make_live_index_empty());
        let index_result = server
            .index_folder(Parameters(super::IndexFolderInput {
                path: repo.path().display().to_string(),
                idempotency_key: None,
            }))
            .await;
        assert!(
            index_result.contains("Indexed 1 files"),
            "index_folder should load the temp repo, got: {index_result}"
        );

        fs::write(
            repo.path().join("src/lib.rs"),
            "fn foo() { println!(\"changed\"); }\n",
        )
        .expect("modify tracked file");
        let outside = TempDir::new().expect("outside cwd");
        let _cwd_guard = CwdGuard::set(outside.path());

        let result = server
            .what_changed(Parameters(super::WhatChangedInput {
                since: None,
                git_ref: None,
                uncommitted: None,
                path_prefix: None,
                language: None,
                code_only: None,
                include_symbol_diff: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("src/lib.rs"),
            "what_changed should keep using the indexed repo root after index_folder, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_index_folder_can_reindex_same_root_twice_locally() {
        let repo = TempDir::new().expect("temp repo");
        fs::create_dir_all(repo.path().join("src")).expect("create src dir");
        fs::write(repo.path().join("src/lib.rs"), "pub fn first() {}\n")
            .expect("write initial file");

        let server = make_server(make_live_index_empty());
        let first = server
            .index_folder(Parameters(super::IndexFolderInput {
                path: repo.path().display().to_string(),
                idempotency_key: None,
            }))
            .await;
        assert!(
            first.contains("Indexed 1 files"),
            "first local index_folder should succeed, got: {first}"
        );

        fs::write(repo.path().join("src/lib.rs"), "pub fn second() {}\n").expect("update file");
        let second = server
            .index_folder(Parameters(super::IndexFolderInput {
                path: repo.path().display().to_string(),
                idempotency_key: None,
            }))
            .await;
        assert!(
            second.contains("Indexed 1 files"),
            "second local index_folder should also succeed, got: {second}"
        );

        let outline = server
            .get_file_context(Parameters(super::GetFileContextInput {
                path: "src/lib.rs".to_string(),
                max_tokens: None,
                sections: Some(vec!["outline".to_string()]),
                estimate: None,
            }))
            .await;
        assert!(
            outline.contains("second"),
            "second local index_folder should refresh the same-root index, got: {outline}"
        );

        let health = server.health_compact().await;
        let root_text = repo
            .path()
            .canonicalize()
            .expect("temp repo should canonicalize")
            .to_string_lossy()
            .replace('\\', "/");
        assert!(
            health.contains(&format!("project_root={root_text}")),
            "health should surface the project root after index_folder, got: {health}"
        );
        assert!(
            health.contains("load_source=fresh_load")
                && health.contains("reset_state=none")
                && health.contains("index_state=fresh_process"),
            "plain index_folder should report a fresh index without reset identity, got: {health}"
        );
        assert!(
            !health.contains("index_state=index_folder_reset"),
            "plain index_folder must not claim reset state, got: {health}"
        );
        assert!(
            health.contains("index_id=index-"),
            "health should keep index identity assertable after reindex, got: {health}"
        );
    }

    #[tokio::test]
    async fn test_index_folder_reset_deletes_snapshot_scope_and_reports_fresh_status() {
        let _reset_env = EnvVarGuard::set(super::INDEX_FOLDER_RESET_ENV, "1");
        let repo = TempDir::new().expect("temp repo");
        let src_dir = repo.path().join("src");
        let symforge_dir = repo.path().join(".symforge");
        fs::create_dir_all(&src_dir).expect("create src dir");
        fs::create_dir_all(&symforge_dir).expect("create .symforge dir");
        fs::write(src_dir.join("lib.rs"), "pub fn reset_fresh() {}\n").expect("write source");
        fs::write(symforge_dir.join("index.bin"), b"stale snapshot").expect("write snapshot");
        fs::write(symforge_dir.join("index.bin.tmp"), b"stale tmp").expect("write tmp");
        fs::write(symforge_dir.join("frecency.db"), b"unrelated").expect("write sentinel");

        let server = make_server(make_live_index_empty());
        let output = server
            .index_folder(Parameters(super::IndexFolderInput {
                path: repo.path().display().to_string(),
                idempotency_key: None,
            }))
            .await;

        assert!(
            output.starts_with("Indexed 1 files"),
            "reset index_folder should still report indexing success first, got: {output}"
        );
        assert!(
            output.contains("Reset: scope=.symforge/index.bin,.symforge/index.bin.tmp | removed=2"),
            "reset output should prove bounded snapshot deletion, got: {output}"
        );
        assert!(
            output.contains("Runtime:") && output.contains("load_source=fresh_load"),
            "reset output should include fresh runtime status, got: {output}"
        );
        assert!(
            output.contains("index_state=index_folder_reset") && output.contains("index_id=index-"),
            "reset output should include assertable fresh identity, got: {output}"
        );
        assert!(
            output.contains("reset_state=current_project:p"),
            "reset output should prove reset identity is tied to the current project generation, got: {output}"
        );
        assert!(!symforge_dir.join("index.bin").exists());
        assert!(!symforge_dir.join("index.bin.tmp").exists());
        assert!(
            symforge_dir.join("frecency.db").exists(),
            "reset must preserve unrelated .symforge state"
        );
        assert!(
            src_dir.join("lib.rs").exists(),
            "reset must never delete source files"
        );

        let health = server.health().await;
        assert!(
            health.contains("load_source=fresh_load")
                && health.contains("reset_state=current_project:p")
                && health.contains("index_state=index_folder_reset")
                && health.contains("index_id=index-"),
            "health after reset should prove fresh identity/source, got: {health}"
        );
    }

    #[tokio::test]
    async fn test_index_folder_idempotency_replays_same_key_same_request_locally() {
        let repo = TempDir::new().expect("temp repo");
        fs::create_dir_all(repo.path().join("src")).expect("create src dir");
        fs::write(repo.path().join("src/lib.rs"), "pub fn first() {}\n")
            .expect("write initial file");

        let server = make_server(make_live_index_empty());
        let first = server
            .index_folder(Parameters(super::IndexFolderInput {
                path: repo.path().display().to_string(),
                idempotency_key: Some("local-replay-key".to_string()),
            }))
            .await;
        assert!(
            first.starts_with("Indexed 1 files"),
            "first idempotent index_folder should index once, got: {first}"
        );

        fs::write(repo.path().join("src/second.rs"), "pub fn second() {}\n")
            .expect("write second file");
        let replay = server
            .index_folder(Parameters(super::IndexFolderInput {
                path: repo.path().display().to_string(),
                idempotency_key: Some("local-replay-key".to_string()),
            }))
            .await;

        assert_eq!(
            replay, first,
            "same idempotency key and same canonical request must replay stored output"
        );
    }

    #[tokio::test]
    async fn test_index_folder_idempotency_rejects_same_key_different_request_locally() {
        let first_repo = TempDir::new().expect("first repo");
        let second_repo = TempDir::new().expect("second repo");
        fs::create_dir_all(first_repo.path().join("src")).expect("create first src dir");
        fs::create_dir_all(second_repo.path().join("src")).expect("create second src dir");
        fs::write(first_repo.path().join("src/lib.rs"), "pub fn first() {}\n")
            .expect("write first source");
        fs::write(
            second_repo.path().join("src/lib.rs"),
            "pub fn second() {}\n",
        )
        .expect("write second source");

        let server = make_server(make_live_index_empty());
        let first = server
            .index_folder(Parameters(super::IndexFolderInput {
                path: first_repo.path().display().to_string(),
                idempotency_key: Some("local-conflict-key".to_string()),
            }))
            .await;
        assert!(
            first.starts_with("Indexed 1 files"),
            "first idempotent index_folder should index once, got: {first}"
        );

        let conflict = server
            .index_folder(Parameters(super::IndexFolderInput {
                path: second_repo.path().display().to_string(),
                idempotency_key: Some("local-conflict-key".to_string()),
            }))
            .await;

        assert!(
            conflict.contains("Idempotency conflict"),
            "same key with a different canonical request must fail deterministically, got: {conflict}"
        );
        assert!(
            !conflict.starts_with("Indexed "),
            "conflict must not report synthetic indexing success: {conflict}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_returns_results() {
        let sym = make_symbol("find_user", SymbolKind::Function, 1, 5);
        let (key, file) = make_file("src/lib.rs", b"fn find_user() {}", vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: Some("find".to_string()),
                kind: None,
                path_prefix: None,
                language: None,
                limit: None,
                include_generated: None,
                include_tests: None,
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("find_user"),
            "should find matching symbol, got: {result}"
        );
        assert!(
            result.contains("Trust: constrained (prefix tier) | current index | parsed"),
            "search_symbols should expose the compact trust line, got: {result}"
        );
        assert!(
            result.contains("Tip:"),
            "search_symbols should include next-step hint"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_kind_filter_returns_only_requested_kind() {
        let function = make_symbol("JobRunner", SymbolKind::Function, 1, 5);
        let class = make_symbol("Job", SymbolKind::Class, 6, 10);
        let (key, file) = make_file(
            "src/lib.rs",
            b"fn JobRunner() {}\nstruct Job {}",
            vec![function, class],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: Some("job".to_string()),
                kind: Some("class".to_string()),
                path_prefix: None,
                language: None,
                limit: None,
                include_generated: None,
                include_tests: None,
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("class Job"),
            "class should remain visible: {result}"
        );
        assert!(
            !result.contains("fn JobRunner"),
            "function should be filtered out: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_hides_generated_and_test_noise_by_default() {
        let server = make_server(make_live_index_ready(vec![
            make_file(
                "src/job.rs",
                b"struct Job {}\n",
                vec![make_symbol("Job", SymbolKind::Class, 1, 1)],
            ),
            make_file(
                "src/generated/job_generated.rs",
                b"struct JobGenerated {}\n",
                vec![make_symbol("JobGenerated", SymbolKind::Class, 2, 2)],
            ),
            make_file(
                "tests/job_test.rs",
                b"struct JobTest {}\n",
                vec![make_symbol("JobTest", SymbolKind::Class, 3, 3)],
            ),
        ]));

        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: Some("job".to_string()),
                kind: Some("class".to_string()),
                path_prefix: None,
                language: None,
                limit: None,
                include_generated: None,
                include_tests: None,
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("class Job"),
            "expected primary hit: {result}"
        );
        assert!(
            !result.contains("JobGenerated"),
            "generated symbol noise should be hidden by default: {result}"
        );
        assert!(
            !result.contains("JobTest"),
            "test symbol noise should be hidden by default: {result}"
        );
        assert!(
            result.contains("vendor filtered"),
            "scope should report vendor filtering status: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_hides_vendor_by_default_and_reports_filter() {
        let server = make_server(make_live_index_ready(vec![
            make_file(
                "src/job.rs",
                b"struct Job {}\n",
                vec![make_symbol("Job", SymbolKind::Class, 1, 1)],
            ),
            make_file(
                "vendor/somelib/job.rs",
                b"struct JobVendor {}\n",
                vec![make_symbol("JobVendor", SymbolKind::Class, 1, 1)],
            ),
        ]));

        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: Some("job".to_string()),
                kind: Some("class".to_string()),
                path_prefix: None,
                language: None,
                limit: None,
                include_generated: None,
                include_tests: None,
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("class Job"),
            "source symbol should remain visible: {result}"
        );
        assert!(
            !result.contains("JobVendor"),
            "vendor symbol should be hidden by default: {result}"
        );
        assert!(
            result.contains("vendor filtered")
                && result.contains("1 noise-filtered match(es) suppressed"),
            "vendor filtering should be explicit in envelope: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_includes_vendor_when_flag_true() {
        let server = make_server(make_live_index_ready(vec![
            make_file(
                "src/job.rs",
                b"struct Job {}\n",
                vec![make_symbol("Job", SymbolKind::Class, 1, 1)],
            ),
            make_file(
                "vendor/somelib/job.rs",
                b"struct JobVendor {}\n",
                vec![make_symbol("JobVendor", SymbolKind::Class, 1, 1)],
            ),
        ]));

        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: Some("job".to_string()),
                kind: Some("class".to_string()),
                path_prefix: None,
                language: None,
                limit: None,
                include_generated: None,
                include_tests: None,
                include_vendor: Some(true),
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("JobVendor"),
            "include_vendor=true should surface vendor symbols: {result}"
        );
        assert!(
            result.contains("vendor included"),
            "scope should report vendor inclusion: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_counts_suppressed_personal_tooling_when_vendor_included() {
        let server = make_server(make_live_index_ready(vec![
            make_file(
                "src/job.rs",
                b"struct Job {}\n",
                vec![make_symbol("Job", SymbolKind::Class, 1, 1)],
            ),
            make_file(
                ".claude/gsd-local-patches/job.rs",
                b"struct JobPersonal {}\n",
                vec![make_symbol("JobPersonal", SymbolKind::Class, 1, 1)],
            ),
        ]));

        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: Some("job".to_string()),
                kind: Some("class".to_string()),
                path_prefix: None,
                language: None,
                limit: None,
                include_generated: None,
                include_tests: None,
                include_vendor: Some(true),
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("class Job"),
            "source symbol should remain visible: {result}"
        );
        assert!(
            !result.contains("JobPersonal"),
            "personal-tooling symbol should stay hidden by default: {result}"
        );
        assert!(
            result.contains("vendor included")
                && result.contains("personal tooling filtered")
                && result.contains("1 noise-filtered match(es) suppressed"),
            "suppressed personal-tooling count should be explicit even when vendor is included: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_hides_inline_test_module_symbols() {
        // src/lib.rs is NOT a test file, but contains `mod tests { struct TestHelper; }`.
        // With include_tests=false (default), symbols inside the inline test module
        // should be hidden even though the file itself is classified as source code.
        let content = b"struct Foo {}\nmod tests {\n  struct TestHelper;\n}\n";
        let symbols = vec![
            SymbolRecord {
                name: "Foo".to_string(),
                kind: SymbolKind::Struct,
                depth: 0,
                sort_order: 0,
                byte_range: (0, 13),
                item_byte_range: Some((0, 13)),
                line_range: (0, 0),
                doc_byte_range: None,
            },
            SymbolRecord {
                name: "tests".to_string(),
                kind: SymbolKind::Module,
                depth: 0,
                sort_order: 1,
                byte_range: (14, 48),
                item_byte_range: Some((14, 48)),
                line_range: (1, 3),
                doc_byte_range: None,
            },
            SymbolRecord {
                name: "TestHelper".to_string(),
                kind: SymbolKind::Struct,
                depth: 1,
                sort_order: 2,
                byte_range: (28, 46),
                item_byte_range: Some((28, 46)),
                line_range: (2, 2),
                doc_byte_range: None,
            },
        ];
        let server = make_server(make_live_index_ready(vec![make_file(
            "src/lib.rs",
            content,
            symbols,
        )]));

        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: None,
                kind: Some("struct".to_string()),
                path_prefix: Some("src/".to_string()),
                language: None,
                limit: None,
                include_generated: None,
                include_tests: None, // default: false
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("Foo"),
            "source struct should appear: {result}"
        );
        assert!(
            !result.contains("TestHelper"),
            "struct inside inline mod tests should be hidden: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_tool_can_include_generated_without_tests() {
        let server = make_server(make_live_index_ready(vec![
            make_file(
                "src/job.rs",
                b"struct Job {}\n",
                vec![make_symbol("Job", SymbolKind::Class, 1, 1)],
            ),
            make_file(
                "src/generated/job_generated.rs",
                b"struct JobGenerated {}\n",
                vec![make_symbol("JobGenerated", SymbolKind::Class, 2, 2)],
            ),
            make_file(
                "tests/job_test.rs",
                b"struct JobTest {}\n",
                vec![make_symbol("JobTest", SymbolKind::Class, 3, 3)],
            ),
        ]));

        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: Some("job".to_string()),
                kind: Some("class".to_string()),
                path_prefix: None,
                language: None,
                limit: None,
                include_generated: Some(true),
                include_tests: None,
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("class Job"),
            "expected primary hit: {result}"
        );
        assert!(
            result.contains("JobGenerated"),
            "generated symbol should be visible when opted in: {result}"
        );
        assert!(
            !result.contains("JobTest"),
            "test noise should stay hidden without explicit opt-in: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_tool_can_include_tests_without_generated() {
        let server = make_server(make_live_index_ready(vec![
            make_file(
                "src/job.rs",
                b"struct Job {}\n",
                vec![make_symbol("Job", SymbolKind::Class, 1, 1)],
            ),
            make_file(
                "src/generated/job_generated.rs",
                b"struct JobGenerated {}\n",
                vec![make_symbol("JobGenerated", SymbolKind::Class, 2, 2)],
            ),
            make_file(
                "tests/job_test.rs",
                b"struct JobTest {}\n",
                vec![make_symbol("JobTest", SymbolKind::Class, 3, 3)],
            ),
        ]));

        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: Some("job".to_string()),
                kind: Some("class".to_string()),
                path_prefix: None,
                language: None,
                limit: None,
                include_generated: None,
                include_tests: Some(true),
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("class Job"),
            "expected primary hit: {result}"
        );
        assert!(
            !result.contains("JobGenerated"),
            "generated noise should stay hidden without explicit opt-in: {result}"
        );
        assert!(
            result.contains("JobTest"),
            "test symbol should be visible when opted in: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_tool_respects_scope_language_limit_and_kind() {
        let rust_model = make_file(
            "src/models/job.rs",
            b"struct Job {}\nfn JobRunner() {}\n",
            vec![
                make_symbol("Job", SymbolKind::Class, 1, 1),
                make_symbol("JobRunner", SymbolKind::Function, 2, 2),
            ],
        );
        let mut ts_ui = make_file(
            "src/ui/job.ts",
            b"class JobCard {}\nclass JobList {}\n",
            vec![
                make_symbol("JobCard", SymbolKind::Class, 1, 1),
                make_symbol("JobList", SymbolKind::Class, 2, 2),
            ],
        );
        ts_ui.1.language = LanguageId::TypeScript;
        let server = make_server(make_live_index_ready(vec![rust_model, ts_ui]));

        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: Some("job".to_string()),
                kind: Some("class".to_string()),
                path_prefix: Some("src/ui".to_string()),
                language: Some("TypeScript".to_string()),
                limit: Some(1),
                include_generated: None,
                include_tests: None,
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("1 matches in 1 files"),
            "expected bounded output: {result}"
        );
        assert!(
            result.contains("class JobCard"),
            "expected scoped class hit: {result}"
        );
        assert!(
            !result.contains("JobList"),
            "limit should truncate later hits: {result}"
        );
        assert!(
            !result.contains("src/models/job.rs"),
            "path scope should exclude rust model: {result}"
        );
        assert!(
            !result.contains("fn JobRunner"),
            "kind filter should exclude function: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_browse_by_kind_without_query() {
        let sym_fn = make_symbol("do_work", SymbolKind::Function, 1, 5);
        let sym_struct = make_symbol("Worker", SymbolKind::Class, 6, 10);
        let (key, file) = make_file(
            "src/protocol/worker.rs",
            b"fn do_work() {}\nstruct Worker {}",
            vec![sym_fn, sym_struct],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: None,
                kind: Some("fn".to_string()),
                path_prefix: Some("src/protocol/".to_string()),
                language: None,
                limit: None,
                include_generated: None,
                include_tests: None,
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("do_work"),
            "browse mode should return fn symbols, got: {result}"
        );
        assert!(
            !result.contains("Worker"),
            "browse mode kind filter should exclude structs, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_rejects_fully_unbounded() {
        let sym = make_symbol("anything", SymbolKind::Function, 1, 5);
        let (key, file) = make_file("src/lib.rs", b"fn anything() {}", vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: None,
                kind: None,
                path_prefix: None,
                language: None,
                limit: None,
                include_generated: None,
                include_tests: None,
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("requires at least one of"),
            "fully unbounded browse should be rejected, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_browse_default_limit_20() {
        // Create 25 distinct symbols — browse mode should return only 20
        let syms: Vec<_> = (1..=25)
            .map(|i| make_symbol(&format!("sym_{i:02}"), SymbolKind::Function, i, i))
            .collect();
        let content = b"fn placeholder() {}";
        let (key, file) = make_file("src/lib.rs", content, syms);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: None,
                kind: Some("fn".to_string()),
                path_prefix: None,
                language: None,
                limit: None,
                include_generated: None,
                include_tests: None,
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("20 matches in"),
            "browse mode default limit should be 20, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_query_mode_still_defaults_to_50() {
        // Create 55 distinct symbols — query mode should return 50
        let syms: Vec<_> = (1..=55)
            .map(|i| make_symbol(&format!("item_{i:02}"), SymbolKind::Function, i, i))
            .collect();
        let content = b"fn placeholder() {}";
        let (key, file) = make_file("src/lib.rs", content, syms);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_symbols(Parameters(super::SearchSymbolsInput {
                query: Some("item".to_string()),
                kind: None,
                path_prefix: None,
                language: None,
                limit: None,
                include_generated: None,
                include_tests: None,
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("50 matches in"),
            "query mode default limit should remain 50, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_returns_results() {
        let (key, file) = make_file("src/lib.rs", b"fn find_user() {}", vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("find".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;
        assert!(
            result.contains("find_user"),
            "should find matching text, got: {result}"
        );
        assert!(
            result.contains("Trust: constrained (literal)"),
            "search_text should expose trust envelope, got: {result}"
        );
        assert!(
            result.contains("Scope: repo-wide; tests filtered; generated filtered"),
            "search_text should expose applied scope, got: {result}"
        );
        assert!(
            result.contains("Tip:"),
            "search_text should include next-step hint"
        );
    }

    #[tokio::test]
    async fn test_search_text_empty_result_reports_matching_untracked_file() {
        let dir = init_git_repo();
        fs::create_dir_all(dir.path().join("src")).unwrap();
        fs::write(dir.path().join("src/lib.rs"), "fn tracked() {}\n").unwrap();
        run_git(dir.path(), &["add", "."]);
        run_git(dir.path(), &["commit", "-m", "tracked"]);
        fs::write(
            dir.path().join("src/new_text.rs"),
            "fn new_text() { let _ = \"unique_untracked_needle\"; }\n",
        )
        .unwrap();

        let (key, file) = make_file("src/lib.rs", b"fn tracked() {}\n", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(dir.path().into()),
        );

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("unique_untracked_needle".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("No matches for 'unique_untracked_needle'"),
            "got: {result}"
        );
        assert!(
            result.contains("untracked file may match: 1 untracked path(s) are not indexed"),
            "missing diagnostic count: {result}"
        );
        assert!(
            result.contains("analyze_file_impact(\"src/new_text.rs\", new_file=true)"),
            "missing recovery call: {result}"
        );
        assert!(server.index.read().get_file("src/new_text.rs").is_none());
    }

    #[tokio::test]
    async fn test_search_text_non_empty_result_omits_untracked_diagnostic() {
        let dir = init_git_repo();
        fs::create_dir_all(dir.path().join("src")).unwrap();
        fs::write(dir.path().join("src/lib.rs"), "fn tracked() {}\n").unwrap();
        run_git(dir.path(), &["add", "."]);
        run_git(dir.path(), &["commit", "-m", "tracked"]);
        fs::write(
            dir.path().join("src/new_text.rs"),
            "fn new_text() { let _ = \"shared_needle\"; }\n",
        )
        .unwrap();

        let (key, file) = make_file(
            "src/lib.rs",
            b"fn tracked() { let _ = \"shared_needle\"; }\n",
            vec![],
        );
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(dir.path().into()),
        );

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("shared_needle".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(result.contains("src/lib.rs"), "got: {result}");
        assert!(
            !result.contains("untracked file may match"),
            "diagnostic must be zero-hit only: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_structural_envelope_reports_ast_grep_match_type() {
        let (key, file) = make_file("src/lib.rs", b"fn find_user() {}", vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("fn $NAME($$$) { $$$ }".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: Some(true),
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("find_user"),
            "should find matching function, got: {result}"
        );
        assert!(
            result.contains("Trust: structural (ast-grep)"),
            "structural search should expose ast-grep match type, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_trust_envelope_reports_partial_parse_and_truncation() {
        let (key, mut file) = make_file(
            "Cargo.toml",
            b"needle one\nneedle two\nneedle three\n",
            vec![make_symbol("package", SymbolKind::Key, 1, 1)],
        );
        let diagnostic = crate::domain::ParseDiagnostic {
            parser: "toml_edit".to_string(),
            message: "missing closing quote".to_string(),
            line: Some(3),
            column: Some(11),
            byte_span: Some((28, 41)),
            fallback_used: true,
        };
        file.language = LanguageId::Toml;
        file.parse_status = ParseStatus::PartialParse {
            warning: diagnostic.summary(),
        };
        file.parse_diagnostic = Some(diagnostic);
        let server = make_server(make_live_index_ready(vec![(key, file)]));

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("needle".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: Some(2),
                max_per_file: Some(2),
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(result.contains("Parse state: partial"), "got: {result}");
        assert!(
            result.contains("Completeness: truncated by result cap (1 more omitted)"),
            "got: {result}"
        );
        assert!(
            result.contains("Evidence: line anchors `Cargo.toml:1`"),
            "got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_terms_or_returns_results() {
        let (key, file) = make_file(
            "src/lib.rs",
            b"// TODO: first\n// FIXME: second\n// NOTE: ignored",
            vec![],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: None,
                terms: Some(vec!["TODO".to_string(), "FIXME".to_string()]),
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;
        assert!(
            result.contains("TODO: first"),
            "TODO term should match: {result}"
        );
        assert!(
            result.contains("FIXME: second"),
            "FIXME term should match: {result}"
        );
        assert!(
            !result.contains("NOTE: ignored"),
            "unmatched line should be absent: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_terms_annotates_matched_term() {
        let sym_a = make_symbol("fn_alpha", SymbolKind::Function, 0, 0);
        let sym_b = make_symbol("fn_beta", SymbolKind::Function, 1, 1);
        let file = make_file(
            "src/lib.rs",
            b"fn fn_alpha() { alpha_value }\nfn fn_beta() { beta_value }\n",
            vec![sym_a, sym_b],
        );
        let server = make_server(make_live_index_ready(vec![file]));

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: None,
                terms: Some(vec!["alpha_value".to_string(), "beta_value".to_string()]),
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("[term: alpha_value]"),
            "should annotate alpha term: {result}"
        );
        assert!(
            result.contains("[term: beta_value]"),
            "should annotate beta term: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_hides_generated_and_test_noise_by_default() {
        let server = make_server(make_live_index_ready(vec![
            make_file("src/real.rs", b"needle visible", vec![]),
            make_file("tests/generated/noise.rs", b"needle hidden", vec![]),
        ]));
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("needle".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("src/real.rs"),
            "expected visible file: {result}"
        );
        assert!(
            !result.contains("tests/generated/noise.rs"),
            "generated/test noise should be hidden by default: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_tool_respects_scope_language_and_caps() {
        let mut ts_app = make_file(
            "src/app.ts",
            b"needle one\nneedle two\nneedle three\n",
            vec![],
        );
        ts_app.1.language = LanguageId::TypeScript;
        let mut ts_lib = make_file("src/lib.ts", b"needle four\nneedle five\n", vec![]);
        ts_lib.1.language = LanguageId::TypeScript;
        let noise = make_file(
            "tests/generated/noise.ts",
            b"needle hidden\nneedle hidden two\n",
            vec![],
        );
        let rust = make_file("src/lib.rs", b"needle rust\nneedle rust two\n", vec![]);
        let server = make_server(make_live_index_ready(vec![ts_app, ts_lib, noise, rust]));

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("needle".to_string()),
                terms: None,
                regex: None,
                path_prefix: Some("src".to_string()),
                language: Some("TypeScript".to_string()),
                limit: Some(3),
                max_per_file: Some(2),
                include_generated: Some(false),
                include_tests: Some(false),
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(result.contains("src/app.ts"), "expected app.ts: {result}");
        assert!(result.contains("src/lib.ts"), "expected lib.ts: {result}");
        assert!(
            !result.contains("needle three"),
            "per-file cap should truncate app.ts: {result}"
        );
        assert!(
            !result.contains("needle five"),
            "total cap should truncate final result set: {result}"
        );
        assert!(
            !result.contains("tests/generated/noise.ts"),
            "noise file should be excluded: {result}"
        );
        assert!(
            !result.contains("src/lib.rs"),
            "language filter should exclude Rust: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_tool_context_renders_windows() {
        let server = make_server(make_live_index_ready(vec![make_file(
            "src/lib.rs",
            b"line 1\nline 2\nneedle 3\nline 4\nneedle 5\nline 6\nline 7\nline 8\nneedle 9\nline 10\n",
            vec![],
        )]));

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("needle".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: Some(1),
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("  2: line 2"),
            "context line missing: {result}"
        );
        assert!(
            result.contains("> 3: needle 3"),
            "match marker missing: {result}"
        );
        assert!(result.contains("  ..."), "separator missing: {result}");
    }

    #[tokio::test]
    async fn test_search_text_tool_respects_glob_and_exclude_glob() {
        let server = make_server(make_live_index_ready(vec![
            make_file("src/app.ts", b"needle app\n", vec![]),
            make_file("src/app.spec.ts", b"needle spec\n", vec![]),
            make_file("src/nested/feature.ts", b"needle nested\n", vec![]),
            make_file("src/lib.rs", b"needle rust\n", vec![]),
        ]));

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("needle".to_string()),
                terms: None,
                regex: None,
                path_prefix: Some("src".to_string()),
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: Some("src/**/*.ts".to_string()),
                exclude_glob: Some("**/*.spec.ts".to_string()),
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(result.contains("src/app.ts"), "expected app.ts: {result}");
        assert!(
            result.contains("src/nested/feature.ts"),
            "expected nested ts file: {result}"
        );
        assert!(
            !result.contains("src/app.spec.ts"),
            "exclude_glob should suppress spec file: {result}"
        );
        assert!(
            !result.contains("src/lib.rs"),
            "include glob should suppress rust file: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_tool_reports_invalid_glob() {
        let server = make_server(make_live_index_ready(vec![make_file(
            "src/app.ts",
            b"needle app\n",
            vec![],
        )]));

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("needle".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: Some("[".to_string()),
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("Invalid glob for `glob`"),
            "expected invalid glob error, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_tool_respects_case_sensitive_and_whole_word() {
        let server = make_server(make_live_index_ready(vec![make_file(
            "src/lib.rs",
            b"Needle\nneedle\nNeedleCase\nNeedle suffix\n",
            vec![],
        )]));

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("Needle".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: Some(true),
                whole_word: Some(true),
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("  1: Needle"),
            "exact whole-word match missing: {result}"
        );
        assert!(
            result.contains("  4: Needle suffix"),
            "whole-word prefix match on a line should remain visible: {result}"
        );
        assert!(
            !result.contains("  2: needle"),
            "case-sensitive search should exclude lowercase line: {result}"
        );
        assert!(
            !result.contains("  3: NeedleCase"),
            "whole-word search should exclude embedded identifier match: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_tool_reports_regex_whole_word_rejection() {
        let server = make_server(make_live_index_ready(vec![make_file(
            "src/lib.rs",
            b"needle\n",
            vec![],
        )]));

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("needle".to_string()),
                terms: None,
                regex: Some(true),
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: Some(true),
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("whole_word is not supported when `regex=true`"),
            "expected regex/whole_word rejection, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_auto_corrects_double_escaped_regex() {
        let (key, file) = make_file("src/handler.rs", b"fn handle_request() {}\n", vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));

        // Use a double-escaped \s (arrives as \\s in the JSON string)
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some(r"fn\\s+handle_".to_string()),
                terms: None,
                regex: Some(true),
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("handle_request"),
            "should find match after auto-correcting double-escaped regex, got: {result}"
        );
        assert!(
            result.contains("auto-corrected"),
            "should include auto-correction note, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_returns_ranked_paths() {
        let server = make_server(make_live_index_ready(vec![
            make_file("src/protocol/tools.rs", b"fn a() {}", vec![]),
            make_file("src/sidecar/tools.rs", b"fn b() {}", vec![]),
            make_file("src/protocol/tools_helper.rs", b"fn c() {}", vec![]),
        ]));
        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "protocol/tools.rs".to_string(),
                limit: Some(20),
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;
        assert!(result.contains("2 matching files"), "got: {result}");
        assert!(
            result.contains("── Strong path matches ──"),
            "got: {result}"
        );
        assert!(
            result.contains("Trust: constrained (tiered path relevance)"),
            "search_files should expose trust envelope, got: {result}"
        );
        assert!(
            result.contains("Scope: ranked indexed file paths"),
            "search_files should expose search scope, got: {result}"
        );
        assert!(result.contains("── Basename matches ──"), "got: {result}");
        assert!(result.contains("src/protocol/tools.rs"), "got: {result}");
        assert!(result.contains("src/sidecar/tools.rs"), "got: {result}");
        assert!(!result.contains("tools_helper.rs"), "got: {result}");
    }

    #[tokio::test]
    async fn test_search_files_not_found() {
        let server = make_server(make_live_index_ready(vec![]));
        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "src/service.rs".to_string(),
                limit: None,
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;
        assert!(
            result.contains("No indexed source files matching 'src/service.rs'"),
            "got: {result}"
        );
        assert!(
            result.contains("filters: vendor filtered; personal tooling filtered"),
            "not-found search_files output should disclose active filters: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_empty_result_reports_matching_untracked_file() {
        let dir = init_git_repo();
        fs::create_dir_all(dir.path().join("src")).unwrap();
        fs::write(dir.path().join("src/lib.rs"), "fn tracked() {}\n").unwrap();
        run_git(dir.path(), &["add", "."]);
        run_git(dir.path(), &["commit", "-m", "tracked"]);
        fs::write(
            dir.path().join("src/new_service.rs"),
            "fn new_service() {}\n",
        )
        .unwrap();

        let (key, file) = make_file("src/lib.rs", b"fn tracked() {}\n", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(dir.path().into()),
        );

        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "src/new_service.rs".to_string(),
                limit: None,
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("No indexed source files matching 'src/new_service.rs'"),
            "got: {result}"
        );
        assert!(
            result.contains("untracked file may match: 1 untracked path(s) are not indexed"),
            "missing diagnostic count: {result}"
        );
        assert!(
            result.contains("analyze_file_impact(\"src/new_service.rs\", new_file=true)"),
            "missing recovery call: {result}"
        );
        assert!(server.index.read().get_file("src/new_service.rs").is_none());
    }

    #[tokio::test]
    async fn test_search_files_non_empty_result_omits_untracked_diagnostic() {
        let dir = init_git_repo();
        fs::create_dir_all(dir.path().join("src")).unwrap();
        fs::write(dir.path().join("src/service.rs"), "fn service() {}\n").unwrap();
        run_git(dir.path(), &["add", "."]);
        run_git(dir.path(), &["commit", "-m", "tracked"]);
        fs::write(
            dir.path().join("src/new_service.rs"),
            "fn new_service() {}\n",
        )
        .unwrap();

        let (key, file) = make_file("src/service.rs", b"fn service() {}\n", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(dir.path().into()),
        );

        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "service.rs".to_string(),
                limit: None,
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(result.contains("src/service.rs"), "got: {result}");
        assert!(
            !result.contains("untracked file may match"),
            "diagnostic must be zero-hit only: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_changed_with_returns_graceful_message() {
        // Without git temporal data loaded, should return informative message
        let (key, file) = make_file("src/daemon.rs", b"fn foo() {}", vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: String::new(),
                limit: None,
                current_file: None,
                changed_with: Some("src/daemon.rs".to_string()),
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;
        // Without git temporal data, should return informative message (not an error/panic)
        assert!(!result.contains("panic"), "should not panic, got: {result}");
        assert!(
            result.contains("temporal") || result.contains("git"),
            "should mention temporal data status, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_changed_with_surfaces_weak_candidates() {
        let (key_a, file_a) = make_file("src/auth.rs", b"fn auth() {}", vec![]);
        let (key_b, file_b) = make_file("src/routes.rs", b"fn routes() {}", vec![]);
        let server = make_server(make_live_index_ready(vec![
            (key_a, file_a),
            (key_b, file_b),
        ]));

        server
            .index
            .update_git_temporal(crate::live_index::git_temporal::GitTemporalIndex {
                files: HashMap::from([(
                    "src/auth.rs".to_string(),
                    crate::live_index::git_temporal::GitFileHistory {
                        commit_count: 4,
                        churn_score: 0.7,
                        last_commit: crate::live_index::git_temporal::CommitSummary {
                            hash: "abc1234".to_string(),
                            timestamp: "2026-04-02T12:00:00Z".to_string(),
                            author: "Tester".to_string(),
                            message_head: "touch auth".to_string(),
                            days_ago: 1.0,
                        },
                        contributors: vec![],
                        co_changes: vec![],
                        weak_co_changes: vec![crate::live_index::git_temporal::CoChangeEntry {
                            path: "src/routes.rs".to_string(),
                            coupling_score: 0.12,
                            shared_commits: 1,
                        }],
                    },
                )]),
                stats: crate::live_index::git_temporal::GitTemporalStats {
                    total_commits_analyzed: 12,
                    analysis_window_days: 90,
                    hotspots: vec![],
                    most_coupled: vec![],
                    computed_at: std::time::SystemTime::now(),
                    compute_duration: Duration::ZERO,
                },
                state: crate::live_index::git_temporal::GitTemporalState::Ready,
            });

        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: String::new(),
                limit: None,
                current_file: None,
                changed_with: Some("src/auth.rs".to_string()),
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("weak heuristic (git-temporal coupling)"),
            "{result}"
        );
        assert!(
            result.contains("Deprecation warning")
                && result.contains("rank_by=\"path+cochange\"")
                && result.contains("anchor_path"),
            "changed_with output should point callers at the fused co-change path: {result}"
        );
        assert!(result.contains("Low confidence"), "{result}");
        assert!(result.contains("src/routes.rs"), "{result}");
    }

    #[tokio::test]
    async fn test_search_files_path_cochange_uses_coupling_store_anchor_neighbors() {
        let store = crate::live_index::coupling::CouplingStore::open_in_memory().unwrap();
        store
            .bulk_upsert(&[crate::live_index::coupling::CouplingRow {
                anchor: crate::live_index::coupling::AnchorKey::file("src/auth/routes.rs"),
                partner: crate::live_index::coupling::AnchorKey::file("src/server/routes.rs"),
                shared_commits: 3,
                weighted_score: 11.0,
                last_commit_ts: 1_700_000_000,
            }])
            .unwrap();
        let server = make_server(make_live_index_ready_with_coupling_store(
            vec![
                make_file("src/auth/routes.rs", b"fn auth_routes() {}", vec![]),
                make_file("src/server/routes.rs", b"fn server_routes() {}", vec![]),
                make_file("src/client/routes.rs", b"fn client_routes() {}", vec![]),
            ],
            store,
        ));

        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "routes.rs".to_string(),
                limit: Some(20),
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: Some("path+cochange".to_string()),
                anchor_path: Some("src/auth/routes.rs".to_string()),
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("── Co-changed files (coupling store) ──"),
            "got: {result}"
        );
        let partner_pos = result.find("src/server/routes.rs").expect("partner");
        let anchor_pos = result.find("src/auth/routes.rs").expect("anchor");
        assert!(
            partner_pos < anchor_pos,
            "co-change partner should be promoted above anchor: {result}"
        );
        assert!(
            result.contains("3 shared commits"),
            "coupling evidence should be rendered: {result}"
        );
        assert!(
            result.contains("Capability: co-change ranking applied")
                && result.contains("loaded 1 usable coupling partner"),
            "rank_by=path+cochange should report applied evidence: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_path_cochange_env_unset_missing_store_reports_preparing() {
        let _env = EnvVarGuard::remove("SYMFORGE_COUPLING");
        let repo = init_git_repo();
        let db_path = repo.path().join(crate::paths::SYMFORGE_COUPLING_DB_PATH);
        assert!(!db_path.exists(), "test starts without a coupling store");
        let server = make_server_with_root(
            make_live_index_ready(vec![
                make_file("src/auth/routes.rs", b"fn auth_routes() {}", vec![]),
                make_file("src/server/routes.rs", b"fn server_routes() {}", vec![]),
            ]),
            Some(repo.path().to_path_buf()),
        );

        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "routes.rs".to_string(),
                limit: Some(20),
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: Some("path+cochange".to_string()),
                anchor_path: Some("src/auth/routes.rs".to_string()),
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("Capability: co-change ranking preparing"),
            "env-unset missing-store request should report lazy preparation evidence: {result}"
        );
        assert!(
            result.contains("path ranking returned"),
            "preparing fallback should preserve path-ranked results: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_path_cochange_disabled_policy_reports_disabled() {
        let _env = EnvVarGuard::set("SYMFORGE_COUPLING", "disabled");
        let repo = init_git_repo();
        let db_path = repo.path().join(crate::paths::SYMFORGE_COUPLING_DB_PATH);
        let server = make_server_with_root(
            make_live_index_ready(vec![
                make_file("src/auth/routes.rs", b"fn auth_routes() {}", vec![]),
                make_file("src/server/routes.rs", b"fn server_routes() {}", vec![]),
            ]),
            Some(repo.path().to_path_buf()),
        );

        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "routes.rs".to_string(),
                limit: Some(20),
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: Some("path+cochange".to_string()),
                anchor_path: Some("src/auth/routes.rs".to_string()),
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("Capability: co-change ranking disabled by policy"),
            "disabled policy should be reported explicitly: {result}"
        );
        assert!(
            !db_path.exists(),
            "disabled policy must not create a coupling store"
        );
    }

    #[tokio::test]
    async fn test_search_files_path_cochange_non_git_reports_unavailable() {
        let _env = EnvVarGuard::remove("SYMFORGE_COUPLING");
        let root = TempDir::new().expect("temp non-git root");
        let server = make_server_with_root(
            make_live_index_ready(vec![
                make_file("src/auth/routes.rs", b"fn auth_routes() {}", vec![]),
                make_file("src/server/routes.rs", b"fn server_routes() {}", vec![]),
            ]),
            Some(root.path().to_path_buf()),
        );

        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "routes.rs".to_string(),
                limit: Some(20),
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: Some("path+cochange".to_string()),
                anchor_path: Some("src/auth/routes.rs".to_string()),
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("Capability: co-change ranking unavailable")
                && result.contains("not a git repository"),
            "non-git repo should be reported explicitly: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_path_cochange_missing_anchor_reports_precise_evidence() {
        let _env = EnvVarGuard::remove("SYMFORGE_COUPLING");
        let repo = init_git_repo();
        let server = make_server_with_root(
            make_live_index_ready(vec![
                make_file("src/auth/routes.rs", b"fn auth_routes() {}", vec![]),
                make_file("src/server/routes.rs", b"fn server_routes() {}", vec![]),
            ]),
            Some(repo.path().to_path_buf()),
        );

        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "routes.rs".to_string(),
                limit: Some(20),
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: Some("path+cochange".to_string()),
                anchor_path: Some("src/missing/routes.rs".to_string()),
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("Capability: co-change ranking fallback used")
                && result.contains("anchor_path=src/missing/routes.rs")
                && result.contains("not indexed"),
            "missing anchor should be precise evidence, not silent path fallback: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_path_cochange_empty_neighbor_set_reports_fallback_evidence() {
        let store = crate::live_index::coupling::CouplingStore::open_in_memory().unwrap();
        let server = make_server(make_live_index_ready_with_coupling_store(
            vec![
                make_file("src/auth/routes.rs", b"fn auth_routes() {}", vec![]),
                make_file("src/server/routes.rs", b"fn server_routes() {}", vec![]),
            ],
            store,
        ));

        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "routes.rs".to_string(),
                limit: Some(20),
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: Some("path+cochange".to_string()),
                anchor_path: Some("src/auth/routes.rs".to_string()),
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("Capability: co-change ranking fallback used")
                && result.contains("no usable partner rows"),
            "empty neighbor set should be explicit fallback evidence: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_path_cochange_corrupt_store_reports_unavailable() {
        let _env = EnvVarGuard::remove("SYMFORGE_COUPLING");
        let repo = init_git_repo();
        let db_path = repo.path().join(crate::paths::SYMFORGE_COUPLING_DB_PATH);
        fs::create_dir_all(db_path.parent().unwrap()).unwrap();
        fs::write(&db_path, b"not sqlite").unwrap();
        let server = make_server_with_root(
            make_live_index_ready(vec![
                make_file("src/auth/routes.rs", b"fn auth_routes() {}", vec![]),
                make_file("src/server/routes.rs", b"fn server_routes() {}", vec![]),
            ]),
            Some(repo.path().to_path_buf()),
        );

        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "routes.rs".to_string(),
                limit: Some(20),
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: Some("path+cochange".to_string()),
                anchor_path: Some("src/auth/routes.rs".to_string()),
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("Capability: co-change ranking unavailable")
                && result.contains("unable to open coupling store"),
            "corrupt store should be explicit unavailable evidence: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_path_cochange_without_anchor_path_falls_back_to_default() {
        let store = crate::live_index::coupling::CouplingStore::open_in_memory().unwrap();
        let server = make_server(make_live_index_ready_with_coupling_store(
            vec![
                make_file("src/auth/routes.rs", b"fn auth_routes() {}", vec![]),
                make_file("src/server/routes.rs", b"fn server_routes() {}", vec![]),
            ],
            store,
        ));
        let default = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "routes.rs".to_string(),
                limit: Some(20),
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        let path_cochange = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "routes.rs".to_string(),
                limit: Some(20),
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: Some("path+cochange".to_string()),
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            path_cochange.contains("src/auth/routes.rs")
                && path_cochange.contains("src/server/routes.rs"),
            "fallback should preserve path results: {path_cochange}"
        );
        assert!(
            path_cochange.contains("Capability: co-change ranking fallback used")
                && path_cochange.contains("requires `anchor_path=<path>`"),
            "fallback should explain missing anchor: {path_cochange}"
        );
        assert!(
            !default.contains("Capability: co-change ranking"),
            "baseline should not contain rank_by diagnostics: {default}"
        );
    }

    #[tokio::test]
    async fn test_search_files_path_cochange_without_coupling_store_falls_back_to_default() {
        let server = make_server(make_live_index_ready(vec![
            make_file("src/auth/routes.rs", b"fn auth_routes() {}", vec![]),
            make_file("src/server/routes.rs", b"fn server_routes() {}", vec![]),
        ]));
        let default = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "routes.rs".to_string(),
                limit: Some(20),
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        let path_cochange = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "routes.rs".to_string(),
                limit: Some(20),
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: Some("path+cochange".to_string()),
                anchor_path: Some("src/auth/routes.rs".to_string()),
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            path_cochange.contains("src/auth/routes.rs")
                && path_cochange.contains("src/server/routes.rs"),
            "fallback should preserve path results: {path_cochange}"
        );
        assert!(
            path_cochange.contains("Capability: co-change ranking unavailable")
                && path_cochange.contains("no repository root is bound"),
            "fallback should explain unavailable/empty coupling evidence: {path_cochange}"
        );
        assert!(
            !default.contains("Capability: co-change ranking"),
            "baseline should not contain rank_by diagnostics: {default}"
        );
    }

    #[tokio::test]
    async fn test_search_files_hides_vendor_paths_by_default() {
        let server = make_server(make_live_index_ready_with_vendor_file());
        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "bar.rs".to_string(),
                limit: None,
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            !result.contains("vendor/foo/bar.rs"),
            "vendor path should be suppressed by default: {result}"
        );
        assert!(
            result.contains("No indexed source files matching 'bar.rs'"),
            "filtered query should not resolve only-vendor matches: {result}"
        );
        assert!(
            result.contains("1 vendor/personal-tooling path candidate hidden by default"),
            "suppressed-noise note should be visible: {result}"
        );
        assert!(
            result.contains("filters: vendor filtered; personal tooling filtered"),
            "active search_files filters should be disclosed even on no-result output: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_includes_vendor_when_flag_true() {
        let server = make_server(make_live_index_ready_with_vendor_file());
        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "bar.rs".to_string(),
                limit: None,
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: Some(true),
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("vendor/foo/bar.rs"),
            "include_vendor=true should surface vendor path: {result}"
        );
        assert!(
            result.contains("filters: vendor included; personal tooling filtered"),
            "active search_files filters should be disclosed in the envelope: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_resolve_hides_vendor_paths_by_default() {
        let server = make_server(make_live_index_ready_with_vendor_file());
        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "bar.rs".to_string(),
                limit: None,
                current_file: None,
                changed_with: None,
                resolve: Some(true),
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            !result.contains("vendor/foo/bar.rs"),
            "resolve=true should suppress vendor by default: {result}"
        );
        assert!(
            result.contains("No indexed source path matched 'bar.rs'"),
            "resolve=true should not resolve only-vendor matches by default: {result}"
        );
        assert!(
            result.contains("1 vendor/personal-tooling path candidate hidden by default"),
            "resolve=true should explain suppressed vendor candidate: {result}"
        );
        assert!(
            result.contains("filters: vendor filtered; personal tooling filtered"),
            "resolve=true output should disclose active search_files filters: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_resolve_includes_vendor_when_flag_true() {
        let server = make_server(make_live_index_ready_with_vendor_file());
        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "bar.rs".to_string(),
                limit: None,
                current_file: None,
                changed_with: None,
                resolve: Some(true),
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: Some(true),
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("vendor/foo/bar.rs"),
            "include_vendor=true should resolve vendor path: {result}"
        );
        assert!(
            result.contains("Trust: exact (resolve)"),
            "include_vendor=true should keep normal resolve envelope: {result}"
        );
        assert!(
            result.contains("filters: vendor included; personal tooling filtered"),
            "include_vendor resolve output should disclose active filters: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_glob_hides_vendor_paths_by_default() {
        let server = make_server(make_live_index_ready_with_vendor_file());
        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "vendor/**/*.rs".to_string(),
                limit: None,
                current_file: None,
                changed_with: None,
                resolve: None,
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            !result.contains("vendor/foo/bar.rs"),
            "glob search should suppress vendor by default: {result}"
        );
        assert!(
            result.contains("1 vendor/personal-tooling path candidate hidden by default"),
            "glob search should report suppressed vendor candidate: {result}"
        );
        assert!(
            result.contains("filters: vendor filtered; personal tooling filtered"),
            "glob search output should disclose active filters: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_files_resolve_returns_exact_match() {
        let (key, file) = make_file("src/protocol/tools.rs", b"fn tool() {}", vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "src/protocol/tools.rs".to_string(),
                limit: None,
                current_file: None,
                changed_with: None,
                resolve: Some(true),
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;
        assert!(
            result.contains("Trust: exact (resolve) | current index | parsed"),
            "got: {result}"
        );
        assert!(
            result.contains("filters: vendor filtered; personal tooling filtered"),
            "resolve=true envelope should disclose active filters: {result}"
        );
        assert!(result.contains("src/protocol/tools.rs"), "got: {result}");
    }

    #[tokio::test]
    async fn test_search_files_resolve_returns_ambiguous_matches() {
        let server = make_server(make_live_index_ready(vec![
            make_file("src/lib.rs", b"fn src_lib() {}", vec![]),
            make_file("tests/lib.rs", b"fn test_lib() {}", vec![]),
        ]));
        let result = server
            .search_files(Parameters(super::SearchFilesInput {
                query: "lib.rs".to_string(),
                limit: None,
                current_file: None,
                changed_with: None,
                resolve: Some(true),
                estimate: None,
                max_tokens: None,
                debug_ranking: None,
                rank_by: None,
                anchor_path: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;
        assert!(
            result.contains("Ambiguous path hint 'lib.rs'"),
            "got: {result}"
        );
        assert!(
            result.contains("Trust: constrained (resolve candidates)"),
            "got: {result}"
        );
        assert!(result.contains("src/lib.rs"), "got: {result}");
        assert!(result.contains("tests/lib.rs"), "got: {result}");
        assert!(
            result.contains("filters: vendor filtered; personal tooling filtered"),
            "ambiguous resolve output should disclose active filters: {result}"
        );
    }

    #[tokio::test]
    async fn test_health_returns_status_fields() {
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server.health().await;
        assert!(result.contains("Status:"), "should have Status field");
        assert!(result.contains("Files:"), "should have Files field");
        assert!(result.contains("Symbols:"), "should have Symbols field");
    }

    #[tokio::test]
    async fn test_health_surfaces_runtime_identity_for_fresh_local_process() {
        let temp = TempDir::new().expect("tempdir should be created");
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(temp.path().to_path_buf()),
        );

        let result = server.health().await;
        assert!(
            result.contains("Runtime: mode=local_process"),
            "health should identify local runtime mode: {result}"
        );
        let root_text = temp.path().to_string_lossy().replace('\\', "/");
        assert!(
            result.contains(&format!("project_root={root_text}")),
            "health should identify active project root: {result}"
        );
        assert!(
            result.contains("project_id=project-"),
            "health should identify a stable project id: {result}"
        );
        assert!(
            result.contains("session_id=local-process-"),
            "health should identify the local session/process: {result}"
        );
        assert!(
            result.contains("index_id=index-"),
            "health should identify the active index: {result}"
        );
        assert!(
            result.contains("load_source=fresh_load"),
            "health should expose the load source: {result}"
        );
        assert!(
            result.contains("reset_state=none"),
            "fresh local health should not claim reset identity: {result}"
        );
        assert!(
            result.contains("index_state=fresh_process"),
            "fresh local health should distinguish a fresh process: {result}"
        );
    }

    #[tokio::test]
    async fn test_health_surfaces_runtime_identity_for_daemon_reused_session() {
        let temp = TempDir::new().expect("tempdir should be created");
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(temp.path().to_path_buf()),
        );
        let project_id = "project-daemon-test".to_string();
        let session_id = "session-daemon-test".to_string();
        let root_text = temp.path().to_string_lossy().replace('\\', "/");

        let full = server.health_for_daemon_session(
            project_id.clone(),
            session_id.clone(),
            temp.path().to_path_buf(),
        );
        assert!(
            full.contains("Runtime: mode=daemon_reused_session")
                && full.contains("runtime_state=daemon_reused_session"),
            "full health should identify reused daemon runtime: {full}"
        );
        assert!(
            full.contains(&format!("project_root={root_text}"))
                && full.contains(&format!("project_id={project_id}"))
                && full.contains(&format!("session_id={session_id}"))
                && full.contains("index_id=index-"),
            "full health should expose daemon project/session/index identity: {full}"
        );
        assert!(
            full.contains("load_source=fresh_load")
                && full.contains("reset_state=none")
                && full.contains("index_state=fresh_process"),
            "full daemon health should distinguish fresh index state without reset: {full}"
        );

        let compact = server.health_compact_for_daemon_session(
            project_id.clone(),
            session_id.clone(),
            temp.path().to_path_buf(),
        );
        assert!(
            compact.contains("Runtime: mode=daemon_reused_session")
                && compact.contains(&format!("project_root={root_text}"))
                && compact.contains(&format!("project_id={project_id}"))
                && compact.contains(&format!("session_id={session_id}"))
                && compact.contains("index_id=index-"),
            "compact health should retain daemon project/session/index identity: {compact}"
        );
        assert!(
            compact.contains("load_source=fresh_load")
                && compact.contains("reset_state=none")
                && compact.contains("index_state=fresh_process"),
            "compact daemon health should retain bounded source/reset state: {compact}"
        );
    }

    #[tokio::test]
    async fn test_health_surfaces_runtime_identity_for_warm_snapshot_reuse() {
        let temp = TempDir::new().expect("tempdir should be created");
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let mut index = make_live_index_ready(vec![(key, file)]);
        index.load_source = crate::live_index::store::IndexLoadSource::SnapshotRestore;
        let server = make_server_with_root(index, Some(temp.path().to_path_buf()));

        let full = server.health().await;
        assert!(
            full.contains("load_source=snapshot_restore")
                && full.contains("reset_state=none")
                && full.contains("index_state=snapshot_loaded_reused"),
            "full health should distinguish warm snapshot reuse without reset: {full}"
        );

        let compact = server.health_compact().await;
        assert!(
            compact.contains("load_source=snapshot_restore")
                && compact.contains("reset_state=none")
                && compact.contains("index_state=snapshot_loaded_reused"),
            "compact health should retain warm snapshot source/reset state: {compact}"
        );
    }

    #[tokio::test]
    async fn test_checkpoint_now_writes_snapshot_and_reports_success() {
        let temp = TempDir::new().expect("tempdir should be created");
        fs::create_dir_all(temp.path().join("src")).expect("create fixture source dir");
        fs::write(temp.path().join("src/lib.rs"), b"fn foo() {}\n").expect("write fixture source");
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}\n", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(temp.path().to_path_buf()),
        );

        let result = server
            .checkpoint_now(Parameters(super::CheckpointNowInput {
                verify_after_write: Some(true),
            }))
            .await;

        assert!(
            result.contains("Checkpoint complete"),
            "checkpoint should report success: {result}"
        );
        // Guard (review finding 2): the degraded-fallback disclosure is ONLY
        // for daemon-proxy fallback; a plain local checkpoint must not carry it.
        assert!(
            !result.contains("daemon unreachable"),
            "local-mode checkpoint must not claim a daemon fallback: {result}"
        );
        assert!(
            temp.path().join(".symforge").join("index.bin").exists(),
            "checkpoint_now should create .symforge/index.bin"
        );
        assert!(
            crate::live_index::persist::load_snapshot(temp.path()).is_some(),
            "checkpoint_now should write a readable snapshot"
        );
    }

    #[tokio::test]
    async fn test_checkpoint_now_reports_failure_without_success_text() {
        let temp = TempDir::new().expect("tempdir should be created");
        fs::create_dir_all(temp.path().join("src")).expect("create fixture source dir");
        fs::write(temp.path().join("src/lib.rs"), b"fn foo() {}\n").expect("write fixture source");
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}\n", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(temp.path().to_path_buf()),
        );
        fs::write(temp.path().join(".symforge"), b"not a directory")
            .expect("block .symforge directory creation");

        let result = server
            .checkpoint_now(Parameters(super::CheckpointNowInput::default()))
            .await;

        assert!(
            result.contains("Checkpoint failed"),
            "checkpoint write failure should be explicit: {result}"
        );
        assert!(
            !result.contains("Checkpoint complete"),
            "failure output must not contain success text: {result}"
        );
    }

    #[tokio::test]
    async fn test_health_surfaces_alive_sidecar_pid_and_state() {
        let temp = TempDir::new().expect("tempdir should be created");
        let listener = std::net::TcpListener::bind("127.0.0.1:0")
            .expect("test listener should bind to an ephemeral port");
        let port = listener
            .local_addr()
            .expect("test listener should have a local address")
            .port();
        write_sidecar_state(temp.path(), Some(port), Some("4242"));

        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(temp.path().to_path_buf()),
        );

        let result = server.health().await;
        assert!(
            result.contains(&format!("Sidecar: pid=4242 port={port} state=alive")),
            "full health should surface alive sidecar state: {result}"
        );
    }

    #[tokio::test]
    async fn test_health_distinguishes_no_sidecar_state() {
        let temp = TempDir::new().expect("tempdir should be created");
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(temp.path().to_path_buf()),
        );

        let result = server.health().await;
        assert!(
            result.contains("Sidecar: none"),
            "full health should distinguish no sidecar state: {result}"
        );
    }

    #[tokio::test]
    async fn test_health_compact_keeps_core_fields_small() {
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server.health_compact().await;
        assert!(result.contains("Status:"), "should have Status field");
        assert!(result.contains("Files:"), "should have Files field");
        assert!(result.contains("Symbols:"), "should have Symbols field");
        assert!(
            result.contains("Runtime: mode=local_process"),
            "compact health should retain runtime identity: {result}"
        );
        assert!(
            result.contains("index_id=index-"),
            "compact health should retain index identity: {result}"
        );
        assert!(
            result.contains("load_source=fresh_load")
                && result.contains("reset_state=none")
                && result.contains("index_state=fresh_process"),
            "compact health should retain bounded source/reset state: {result}"
        );
        assert!(
            !result.contains("Partial parse files"),
            "compact health should omit expanded path lists"
        );
        assert!(
            !result.contains("Snapshot:"),
            "fresh compact health should omit snapshot verification lines"
        );
    }

    #[tokio::test]
    async fn test_health_surfaces_snapshot_verify_running_state() {
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let mut index = make_live_index_ready(vec![(key, file)]);
        index.load_source = crate::live_index::store::IndexLoadSource::SnapshotRestore;
        index.snapshot_verify_state = crate::live_index::store::SnapshotVerifyState::Running;
        let server = make_server(index);

        let full = server.health().await;
        assert!(
            full.contains("Snapshot verify: load_source=snapshot_restore state=running"),
            "full health should expose background snapshot verification progress: {full}"
        );

        let compact = server.health_compact().await;
        assert!(
            compact.contains("Snapshot: load_source=snapshot_restore verify=running"),
            "compact health should retain snapshot verify progress: {compact}"
        );
    }

    #[tokio::test]
    async fn test_health_surfaces_snapshot_verify_mismatch_summary() {
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let mut index = make_live_index_ready(vec![(key, file)]);
        index.load_source = crate::live_index::store::IndexLoadSource::SnapshotRestore;
        index.snapshot_verify_state =
            crate::live_index::store::SnapshotVerifyState::completed_with_mismatches(
                (0..12)
                    .rev()
                    .map(|i| format!("src/mismatch_{i:02}.rs"))
                    .collect::<Vec<_>>(),
            );
        let server = make_server(index);

        let full = server.health().await;
        assert!(
            full.contains(
                "Snapshot verify: load_source=snapshot_restore state=completed mismatches=12 showing=10 omitted=2",
            ),
            "full health should expose mismatch count and truncation: {full}"
        );
        assert!(
            full.contains("src/mismatch_00.rs") && full.contains("src/mismatch_09.rs"),
            "full health should include bounded mismatch path examples: {full}"
        );
        assert!(
            !full.contains("src/mismatch_10.rs"),
            "full health should truncate mismatch path examples: {full}"
        );

        let compact = server.health_compact().await;
        assert!(
            compact.contains(
                "Snapshot: load_source=snapshot_restore verify=completed mismatches=12 showing=3 omitted=9",
            ),
            "compact health should retain bounded mismatch summary: {compact}"
        );
        assert!(
            compact.contains("src/mismatch_00.rs") && !compact.contains("src/mismatch_03.rs"),
            "compact health should include only compact path examples: {compact}"
        );
    }

    #[tokio::test]
    async fn test_health_compact_surfaces_dead_sidecar_pid_and_state() {
        let temp = TempDir::new().expect("tempdir should be created");
        let port = 0;
        write_sidecar_state(temp.path(), Some(port), Some("4242"));

        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(temp.path().to_path_buf()),
        );

        let result = server.health_compact().await;
        assert!(
            result.contains(&format!("Sidecar: dead pid=4242 port={port}")),
            "compact health should surface dead sidecar state: {result}"
        );
    }

    #[tokio::test]
    async fn test_health_surfaces_unknown_sidecar_state_for_malformed_port_file() {
        let temp = TempDir::new().expect("tempdir should be created");
        let dir = crate::paths::ensure_symforge_dir(temp.path())
            .expect("test repo root should allow .symforge creation");
        fs::write(dir.join("sidecar.port"), "not-a-port")
            .expect("sidecar port file should be writable");
        fs::write(dir.join("sidecar.pid"), "4242").expect("sidecar pid file should be writable");

        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(temp.path().to_path_buf()),
        );

        let result = server.health().await;
        assert!(
            result.contains("Sidecar: pid=4242 port=unknown state=unknown"),
            "full health should surface unknown sidecar state: {result}"
        );
        assert!(
            result.contains("sidecar.port invalid"),
            "full health should explain malformed sidecar state: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_symbol_batch_symbol_lookup() {
        let sym = make_symbol("bar", SymbolKind::Function, 1, 3);
        let (key, file) = make_file("src/lib.rs", b"fn bar() {}", vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_symbol(Parameters(super::GetSymbolInput {
                path: String::new(),
                name: String::new(),
                kind: None,
                symbol_line: None,
                targets: Some(vec![super::SymbolTarget {
                    path: "src/lib.rs".to_string(),
                    name: Some("bar".to_string()),
                    kind: None,
                    symbol_line: None,
                    start_byte: None,
                    end_byte: None,
                }]),
                estimate: None,
            }))
            .await;
        assert!(
            !result.starts_with("Index"),
            "should not return guard message, got: {result}"
        );
        assert!(
            result.contains("fn bar() {"),
            "symbol lookup branch should return symbol body, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_symbol_batch_code_slice() {
        let content = b"fn foo() { let x = 1; }";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_symbol(Parameters(super::GetSymbolInput {
                path: String::new(),
                name: String::new(),
                kind: None,
                symbol_line: None,
                targets: Some(vec![super::SymbolTarget {
                    path: "src/lib.rs".to_string(),
                    name: None,
                    kind: None,
                    symbol_line: None,
                    start_byte: Some(0),
                    end_byte: Some(8),
                }]),
                estimate: None,
            }))
            .await;
        assert!(
            result.contains("src/lib.rs"),
            "code slice should include path header, got: {result}"
        );
        assert!(
            result.contains("fn foo()"),
            "code slice should include content, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_what_changed_returns_result() {
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        // since=0 (far past) → all files are "newer"
        let result = server
            .what_changed(Parameters(super::WhatChangedInput {
                since: Some(0),
                git_ref: None,
                uncommitted: None,
                path_prefix: None,
                language: None,
                code_only: None,
                include_symbol_diff: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("src/lib.rs"),
            "what_changed since epoch should list all files, got: {result}"
        );
        assert!(
            result.contains("Trust: exact (timestamp compare) | current index | parsed"),
            "timestamp mode should report the compact trust line: {result}"
        );
    }

    #[tokio::test]
    async fn test_what_changed_timestamp_respects_filters() {
        let (rust_key, rust_file) = make_file("src/lib.rs", b"fn foo() {}", vec![]);
        let (doc_key, doc_file) = make_file("docs/readme.md", b"# hi", vec![]);
        let server = make_server(make_live_index_ready(vec![
            (rust_key, rust_file),
            (doc_key, doc_file),
        ]));
        let result = server
            .what_changed(Parameters(super::WhatChangedInput {
                since: Some(0),
                git_ref: None,
                uncommitted: None,
                path_prefix: Some("src".to_string()),
                language: Some("rust".to_string()),
                code_only: None,
                include_symbol_diff: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("src/lib.rs"),
            "filtered timestamp mode should keep Rust path: {result}"
        );
        assert!(
            !result.contains("docs/readme.md"),
            "filtered timestamp mode should exclude non-matching path: {result}"
        );
        assert!(
            result.contains("Scope: timestamp since `0`; path prefix `src`; language `rust`"),
            "filtered timestamp mode should expose scope: {result}"
        );
    }

    #[tokio::test]
    async fn test_what_changed_defaults_to_uncommitted_git_changes() {
        let repo = init_git_repo();
        fs::create_dir_all(repo.path().join("src")).expect("create src dir");
        fs::write(repo.path().join("src/lib.rs"), "fn foo() {}\n").expect("write initial file");
        run_git(repo.path(), &["add", "."]);
        run_git(repo.path(), &["commit", "-m", "init", "-q"]);
        fs::write(
            repo.path().join("src/lib.rs"),
            "fn foo() { println!(\"changed\"); }\n",
        )
        .expect("modify tracked file");

        let (key, file) = make_file(
            "src/lib.rs",
            b"fn foo() { println!(\"changed\"); }\n",
            vec![],
        );
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(repo.path().to_path_buf()),
        );
        let result = server
            .what_changed(Parameters(super::WhatChangedInput {
                since: None,
                git_ref: None,
                uncommitted: None,
                path_prefix: None,
                language: None,
                code_only: None,
                include_symbol_diff: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("src/lib.rs"),
            "default mode should surface uncommitted git changes: {result}"
        );
        assert!(
            result.contains("Source authority: git working tree"),
            "uncommitted mode should expose git authority: {result}"
        );
    }

    #[tokio::test]
    async fn test_what_changed_include_symbol_diff_appends_compact_symbol_summary() {
        let repo = init_git_repo();
        fs::create_dir_all(repo.path().join("src")).expect("create src dir");
        fs::create_dir_all(repo.path().join("docs")).expect("create docs dir");
        fs::write(repo.path().join("src/lib.rs"), "fn existing() {}\n")
            .expect("write initial file");
        fs::write(repo.path().join("docs/readme.md"), "# initial\n").expect("write initial docs");
        run_git(repo.path(), &["add", "."]);
        run_git(repo.path(), &["commit", "-m", "init", "-q"]);
        fs::write(
            repo.path().join("src/lib.rs"),
            "fn existing() {}\nfn added_symbol() {}\n",
        )
        .expect("modify tracked code file");
        fs::write(repo.path().join("docs/readme.md"), "# changed\n")
            .expect("modify tracked docs file");

        let (key, file) = make_file(
            "src/lib.rs",
            b"fn existing() {}\nfn added_symbol() {}\n",
            vec![],
        );
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(repo.path().to_path_buf()),
        );
        let result = server
            .what_changed(Parameters(super::WhatChangedInput {
                since: None,
                git_ref: None,
                uncommitted: Some(true),
                path_prefix: None,
                language: None,
                code_only: Some(true),
                include_symbol_diff: Some(true),
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains(
                "Scope: uncommitted working tree; code-only filter; symbol diff appended"
            ),
            "scope should report the combined code-only symbol-diff mode: {result}"
        );
        assert!(
            result.contains("Symbol diff: HEAD...working tree"),
            "symbol diff section missing: {result}"
        );
        assert!(
            result.contains("src/lib.rs (+1: added_symbol)"),
            "compact symbol summary should name added symbols: {result}"
        );
        assert!(
            !result.contains("docs/readme.md"),
            "code_only must keep docs out of both path list and symbol diff: {result}"
        );
    }

    #[tokio::test]
    async fn test_what_changed_recovers_repo_root_from_cwd_when_missing() {
        let repo = init_git_repo();
        fs::create_dir_all(repo.path().join("src")).expect("create src dir");
        fs::write(repo.path().join("src/lib.rs"), "fn foo() {}\n").expect("write initial file");
        run_git(repo.path(), &["add", "."]);
        run_git(repo.path(), &["commit", "-m", "init", "-q"]);
        fs::write(
            repo.path().join("src/lib.rs"),
            "fn foo() { println!(\"changed\"); }\n",
        )
        .expect("modify tracked file");

        let (key, file) = make_file(
            "src/lib.rs",
            b"fn foo() { println!(\"changed\"); }\n",
            vec![],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let _cwd_guard = CwdGuard::set(repo.path());

        let result = server
            .what_changed(Parameters(super::WhatChangedInput {
                since: None,
                git_ref: None,
                uncommitted: None,
                path_prefix: None,
                language: None,
                code_only: None,
                include_symbol_diff: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("src/lib.rs"),
            "what_changed should lazily recover the git root from cwd: {result}"
        );
        assert!(
            result.contains("Source authority: git working tree"),
            "recovered git mode should expose git authority: {result}"
        );
    }

    #[tokio::test]
    async fn test_what_changed_reports_repo_root_guidance_when_missing() {
        let home = dirs::home_dir().expect("home dir");
        let _cwd_guard = CwdGuard::set(&home);
        let server = make_server(make_live_index_empty());

        let result = server
            .what_changed(Parameters(super::WhatChangedInput {
                since: None,
                git_ref: None,
                uncommitted: None,
                path_prefix: None,
                language: None,
                code_only: None,
                include_symbol_diff: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("No repo root attached; call index_folder(path=...) or pass since=..."),
            "missing repo root should return actionable guidance: {result}"
        );
    }

    #[tokio::test]
    async fn test_what_changed_git_ref_reports_diffed_files() {
        let repo = init_git_repo();
        fs::create_dir_all(repo.path().join("src")).expect("create src dir");
        fs::write(repo.path().join("src/lib.rs"), "fn foo() {}\n").expect("write initial file");
        run_git(repo.path(), &["add", "."]);
        run_git(repo.path(), &["commit", "-m", "init", "-q"]);
        fs::write(
            repo.path().join("src/lib.rs"),
            "fn foo() { println!(\"changed\"); }\n",
        )
        .expect("modify tracked file");

        let (key, file) = make_file(
            "src/lib.rs",
            b"fn foo() { println!(\"changed\"); }\n",
            vec![],
        );
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(repo.path().to_path_buf()),
        );
        let result = server
            .what_changed(Parameters(super::WhatChangedInput {
                since: None,
                git_ref: Some("HEAD".to_string()),
                uncommitted: None,
                path_prefix: None,
                language: None,
                code_only: None,
                include_symbol_diff: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("src/lib.rs"),
            "git_ref mode should show changed files: {result}"
        );
        assert!(
            result.contains("Source authority: git ref diff"),
            "git_ref mode should expose git diff authority: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_file_content_returns_content() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("line 1"),
            "should return file content, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_file_content_text_can_carry_result_status_without_changing_text() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let human_text = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;

        let result = crate::protocol::result_status::ResultStatus::new(
            crate::protocol::result_status::OutcomeClass::Found,
        )
        .into_call_tool_result(human_text.clone());
        let serialized = serde_json::to_value(&result).unwrap();

        assert_eq!(
            serialized["content"][0]["text"],
            serde_json::json!(human_text)
        );
        assert_eq!(
            serialized["_meta"][crate::protocol::result_status::RESULT_STATUS_META_KEY]["outcome_class"],
            serde_json::json!("found")
        );
    }

    #[tokio::test]
    async fn test_read_tools_emit_result_status_contract() {
        let source = "fn present() {}\nfn duplicate() {}\nfn duplicate() {}\n";
        let present_start = source.find("fn present").unwrap() as u32;
        let first_duplicate_start = source.find("fn duplicate").unwrap() as u32;
        let second_duplicate_start = source.rfind("fn duplicate").unwrap() as u32;
        let present_end = present_start + "fn present() {}".len() as u32;
        let duplicate_len = "fn duplicate() {}".len() as u32;
        let (key, file) = make_file(
            "src/lib.rs",
            source.as_bytes(),
            vec![
                make_symbol_with_bytes(
                    "present",
                    SymbolKind::Function,
                    0,
                    0,
                    (present_start, present_end),
                ),
                make_symbol_with_bytes(
                    "duplicate",
                    SymbolKind::Function,
                    1,
                    1,
                    (first_duplicate_start, first_duplicate_start + duplicate_len),
                ),
                make_symbol_with_bytes(
                    "duplicate",
                    SymbolKind::Function,
                    2,
                    2,
                    (
                        second_duplicate_start,
                        second_duplicate_start + duplicate_len,
                    ),
                ),
            ],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));

        let found_file = serialized_tool_result(
            server
                .get_file_content_tool(Parameters(get_file_content_input("src/lib.rs")))
                .await,
        );
        assert_tool_result_status(&found_file, OutcomeClass::Found);
        assert_eq!(tool_result_text(&found_file), source);

        let missing_file = serialized_tool_result(
            server
                .get_file_content_tool(Parameters(get_file_content_input("src/missing.rs")))
                .await,
        );
        assert_tool_result_status(&missing_file, OutcomeClass::NotFound);
        assert!(tool_result_text(&missing_file).contains("File not found"));

        let mut invalid_read = get_file_content_input("src/lib.rs");
        invalid_read.start_line = Some(1);
        invalid_read.around_line = Some(1);
        let invalid_read =
            serialized_tool_result(server.get_file_content_tool(Parameters(invalid_read)).await);
        assert_tool_result_status(&invalid_read, OutcomeClass::InvalidRequest);

        let mut ambiguous_read = get_file_content_input("src/lib.rs");
        ambiguous_read.around_symbol = Some("duplicate".to_string());
        let ambiguous_read = serialized_tool_result(
            server
                .get_file_content_tool(Parameters(ambiguous_read))
                .await,
        );
        assert_tool_result_status(&ambiguous_read, OutcomeClass::Ambiguous);
        assert!(tool_result_text(&ambiguous_read).contains("Ambiguous symbol selector"));

        let found_symbol = serialized_tool_result(
            server
                .get_symbol_tool(Parameters(get_symbol_input("src/lib.rs", "present")))
                .await,
        );
        assert_tool_result_status(&found_symbol, OutcomeClass::Found);
        assert!(tool_result_text(&found_symbol).contains("fn present() {}"));

        let missing_symbol = serialized_tool_result(
            server
                .get_symbol_tool(Parameters(get_symbol_input("src/lib.rs", "missing_symbol")))
                .await,
        );
        assert_tool_result_status(&missing_symbol, OutcomeClass::NotFound);
        assert!(tool_result_text(&missing_symbol).contains("No symbol missing_symbol"));

        let ambiguous_symbol = serialized_tool_result(
            server
                .get_symbol_tool(Parameters(get_symbol_input("src/lib.rs", "duplicate")))
                .await,
        );
        assert_tool_result_status(&ambiguous_symbol, OutcomeClass::Ambiguous);
        assert!(tool_result_text(&ambiguous_symbol).contains("Ambiguous"));
    }

    #[test]
    fn test_classify_get_file_content_output_flags_match_and_chunk_misses() {
        // around_match miss (inferred mode) — previously misclassified Found via a
        // dead `Match '` branch; the real message is `No matches for '...'`.
        assert_eq!(
            super::classify_get_file_content_output("No matches for 'needle' in src/lib.rs"),
            OutcomeClass::NotFound
        );
        // around_match miss carrying an explicit-mode annotation prefix.
        assert_eq!(
            super::classify_get_file_content_output(
                "── mode: match (explicit) ──\nNo matches for 'needle' in src/lib.rs"
            ),
            OutcomeClass::NotFound
        );
        // match-occurrence overflow miss.
        assert_eq!(
            super::classify_get_file_content_output(
                "Match occurrence 2 for 'todo' not found in src/lib.rs; 1 match(es) available at lines 2"
            ),
            OutcomeClass::NotFound
        );
        // chunk index past EOF — an invalid paging request, not a successful read.
        assert_eq!(
            super::classify_get_file_content_output(
                "Chunk 3 out of range for src/lib.rs (2 chunks)"
            ),
            OutcomeClass::InvalidRequest
        );
        assert_eq!(
            super::classify_get_file_content_output(
                "── mode: chunk (explicit) ──\nChunk 3 out of range for src/lib.rs (2 chunks)"
            ),
            OutcomeClass::InvalidRequest
        );
        // Robustness: a successful read whose CONTENT mentions these phrases (not at
        // body-start) stays Found — the fix anchors on the message, not a bare match.
        assert_eq!(
            super::classify_get_file_content_output(
                "1: // index out of range for the array\n2: ok"
            ),
            OutcomeClass::Found
        );
        assert_eq!(
            super::classify_get_file_content_output("1: let s = \"No matches for 'x'\";"),
            OutcomeClass::Found
        );
    }

    #[tokio::test]
    async fn test_search_tools_emit_result_status_contract() {
        let server = make_server(make_live_index_ready(vec![
            make_file(
                "src/lib.rs",
                b"fn present() {}\nconst NEEDLE: &str = \"needle\";\n",
                vec![make_symbol("present", SymbolKind::Function, 0, 0)],
            ),
            make_file("tests/lib.rs", b"fn test_lib() {}", vec![]),
        ]));

        let found_symbols = serialized_tool_result(
            server
                .search_symbols_tool(Parameters(search_symbols_input(Some("present"))))
                .await,
        );
        assert_tool_result_status(&found_symbols, OutcomeClass::Found);
        assert!(tool_result_text(&found_symbols).contains("present"));

        let empty_symbols = serialized_tool_result(
            server
                .search_symbols_tool(Parameters(search_symbols_input(Some("absent_symbol"))))
                .await,
        );
        assert_tool_result_status(&empty_symbols, OutcomeClass::EmptyResult);
        assert!(tool_result_text(&empty_symbols).contains("No symbols matching"));

        let invalid_symbols = serialized_tool_result(
            server
                .search_symbols_tool(Parameters(search_symbols_input(None)))
                .await,
        );
        assert_tool_result_status(&invalid_symbols, OutcomeClass::InvalidRequest);

        let found_text = serialized_tool_result(
            server
                .search_text_tool(Parameters(super::SearchTextInput {
                    query: Some("needle".to_string()),
                    ..Default::default()
                }))
                .await,
        );
        assert_tool_result_status(&found_text, OutcomeClass::Found);
        assert!(tool_result_text(&found_text).contains("needle"));

        let empty_text = serialized_tool_result(
            server
                .search_text_tool(Parameters(super::SearchTextInput {
                    query: Some("absent_text".to_string()),
                    ..Default::default()
                }))
                .await,
        );
        assert_tool_result_status(&empty_text, OutcomeClass::EmptyResult);
        assert!(tool_result_text(&empty_text).contains("No matches"));

        let invalid_text = serialized_tool_result(
            server
                .search_text_tool(Parameters(super::SearchTextInput {
                    query: Some("[".to_string()),
                    regex: Some(true),
                    ..Default::default()
                }))
                .await,
        );
        assert_tool_result_status(&invalid_text, OutcomeClass::InvalidRequest);

        let found_file = serialized_tool_result(
            server
                .search_files_tool(Parameters(search_files_input("src/lib.rs")))
                .await,
        );
        assert_tool_result_status(&found_file, OutcomeClass::Found);
        assert!(tool_result_text(&found_file).contains("src/lib.rs"));

        let missing_file = serialized_tool_result(
            server
                .search_files_tool(Parameters(search_files_input("missing.rs")))
                .await,
        );
        assert_tool_result_status(&missing_file, OutcomeClass::NotFound);

        let mut ambiguous_file_input = search_files_input("lib.rs");
        ambiguous_file_input.resolve = Some(true);
        let ambiguous_file = serialized_tool_result(
            server
                .search_files_tool(Parameters(ambiguous_file_input))
                .await,
        );
        assert_tool_result_status(&ambiguous_file, OutcomeClass::Ambiguous);
        assert!(tool_result_text(&ambiguous_file).contains("Ambiguous path hint"));
    }

    #[tokio::test]
    async fn test_find_references_emits_result_status_contract() {
        let target = make_file(
            "src/db.rs",
            b"pub fn connect() {}\n",
            vec![make_symbol("connect", SymbolKind::Function, 0, 0)],
        );
        let dependent = make_file_with_refs(
            "src/service.rs",
            b"use crate::db::connect;\nfn run() { connect(); }\n",
            vec![make_symbol("run", SymbolKind::Function, 1, 1)],
            vec![
                make_ref("db", Some("crate::db"), ReferenceKind::Import, 0, None),
                make_ref(
                    "connect",
                    Some("crate::db::connect"),
                    ReferenceKind::Call,
                    1,
                    Some(0),
                ),
            ],
        );
        let lonely = make_file(
            "src/lonely.rs",
            b"fn lonely() {}\n",
            vec![make_symbol("lonely", SymbolKind::Function, 0, 0)],
        );
        let duplicate = make_file(
            "src/duplicate.rs",
            b"fn duplicate() {}\nfn duplicate() {}\n",
            vec![
                make_symbol("duplicate", SymbolKind::Function, 0, 0),
                make_symbol("duplicate", SymbolKind::Function, 1, 1),
            ],
        );
        let server = make_server(make_live_index_ready(vec![
            target, dependent, lonely, duplicate,
        ]));

        let mut found_input = find_references_input("connect");
        found_input.kind = Some("call".to_string());
        found_input.path = Some("src/db.rs".to_string());
        found_input.symbol_kind = Some("fn".to_string());
        found_input.symbol_line = Some(1);
        let found =
            serialized_tool_result(server.find_references_tool(Parameters(found_input)).await);
        assert_tool_result_status(&found, OutcomeClass::Found);
        assert!(tool_result_text(&found).contains("src/service.rs"));

        let mut empty_input = find_references_input("lonely");
        empty_input.path = Some("src/lonely.rs".to_string());
        empty_input.symbol_kind = Some("fn".to_string());
        empty_input.symbol_line = Some(1);
        let empty =
            serialized_tool_result(server.find_references_tool(Parameters(empty_input)).await);
        assert_tool_result_status(&empty, OutcomeClass::EmptyResult);
        assert!(tool_result_text(&empty).contains("No references found"));

        let mut missing_path = find_references_input("missing");
        missing_path.path = Some("src/missing.rs".to_string());
        let missing_path =
            serialized_tool_result(server.find_references_tool(Parameters(missing_path)).await);
        assert_tool_result_status(&missing_path, OutcomeClass::NotFound);
        assert!(tool_result_text(&missing_path).contains("File not found"));

        let mut ambiguous = find_references_input("duplicate");
        ambiguous.kind = Some("call".to_string());
        ambiguous.path = Some("src/duplicate.rs".to_string());
        ambiguous.symbol_kind = Some("fn".to_string());
        let ambiguous =
            serialized_tool_result(server.find_references_tool(Parameters(ambiguous)).await);
        assert_tool_result_status(&ambiguous, OutcomeClass::Ambiguous);
        assert!(tool_result_text(&ambiguous).contains("Ambiguous symbol selector"));
    }

    #[tokio::test]
    async fn test_edit_tools_emit_result_status_contract() {
        use crate::protocol::edit::ReplaceSymbolBodyInput;

        let success_source = b"fn present() {\n    println!(\"old\");\n}\nfn other() {}\n";
        let (_dir, server, file_path) = setup_edit_test(success_source);
        let success = serialized_tool_result(
            server
                .replace_symbol_body_tool(Parameters(ReplaceSymbolBodyInput {
                    path: "src/lib.rs".to_string(),
                    name: "present".to_string(),
                    kind: None,
                    symbol_line: None,
                    new_body: "fn present() {\n    println!(\"new\");\n}".to_string(),
                    dry_run: Some(false),
                    idempotency_key: None,
                    working_directory: None,
                }))
                .await,
        );
        assert_tool_result_edit_status(&success, "success", OutcomeClass::Found);
        assert!(tool_result_text(&success).contains("replaced"));
        assert!(
            std::fs::read_to_string(&file_path)
                .unwrap()
                .contains("\"new\"")
        );

        let dry_run_source = b"fn present() {\n    println!(\"old\");\n}\n";
        let (_dir, server, file_path) = setup_edit_test(dry_run_source);
        let dry_run = serialized_tool_result(
            server
                .replace_symbol_body_tool(Parameters(ReplaceSymbolBodyInput {
                    path: "src/lib.rs".to_string(),
                    name: "present".to_string(),
                    kind: None,
                    symbol_line: None,
                    new_body: "fn present() {\n    println!(\"planned\");\n}".to_string(),
                    dry_run: Some(true),
                    idempotency_key: None,
                    working_directory: None,
                }))
                .await,
        );
        assert_tool_result_edit_status(&dry_run, "dry_run_success", OutcomeClass::Found);
        assert!(tool_result_text(&dry_run).contains("[DRY RUN]"));
        assert!(
            std::fs::read_to_string(&file_path)
                .unwrap()
                .contains("\"old\"")
        );

        let not_found = serialized_tool_result(
            server
                .replace_symbol_body_tool(Parameters(ReplaceSymbolBodyInput {
                    path: "src/lib.rs".to_string(),
                    name: "missing_symbol".to_string(),
                    kind: None,
                    symbol_line: None,
                    new_body: "fn missing_symbol() {}".to_string(),
                    dry_run: Some(false),
                    idempotency_key: None,
                    working_directory: None,
                }))
                .await,
        );
        assert_tool_result_edit_status(&not_found, "not_found", OutcomeClass::NotFound);
        assert!(tool_result_text(&not_found).contains("Symbol not found"));

        let ambiguous_source = b"fn duplicate() {}\nfn duplicate() {}\n";
        let (_dir, server, _file_path) = setup_edit_test(ambiguous_source);
        let ambiguous = serialized_tool_result(
            server
                .replace_symbol_body_tool(Parameters(ReplaceSymbolBodyInput {
                    path: "src/lib.rs".to_string(),
                    name: "duplicate".to_string(),
                    kind: None,
                    symbol_line: None,
                    new_body: "fn duplicate() { changed(); }".to_string(),
                    dry_run: Some(false),
                    idempotency_key: None,
                    working_directory: None,
                }))
                .await,
        );
        assert_tool_result_edit_status(&ambiguous, "ambiguous", OutcomeClass::Ambiguous);
        assert!(tool_result_text(&ambiguous).contains("Ambiguous"));

        let invalid_request = serialized_tool_result(
            server
                .batch_edit_tool(Parameters(crate::protocol::edit::BatchEditInput {
                    edits: vec![crate::protocol::edit::SingleEdit {
                        path: "../outside.rs".to_string(),
                        name: "duplicate".to_string(),
                        kind: None,
                        symbol_line: None,
                        operation: crate::protocol::edit::EditOperation::Delete,
                        working_directory: None,
                    }],
                    dry_run: Some(false),
                    idempotency_key: None,
                    working_directory: None,
                }))
                .await,
        );
        assert_tool_result_edit_status(
            &invalid_request,
            "invalid_request",
            OutcomeClass::InvalidRequest,
        );
        assert!(tool_result_text(&invalid_request).contains("Error:"));

        let source = b"fn present() {}\n";
        let parse_result = crate::parsing::process_file("src/lib.rs", source, LanguageId::Rust);
        let indexed = IndexedFile::from_parse_result(parse_result, source.to_vec());
        let server = make_server(make_live_index_ready(vec![(
            "src/lib.rs".to_string(),
            indexed,
        )]));
        let internal_failure = serialized_tool_result(
            server
                .replace_symbol_body_tool(Parameters(ReplaceSymbolBodyInput {
                    path: "src/lib.rs".to_string(),
                    name: "present".to_string(),
                    kind: None,
                    symbol_line: None,
                    new_body: "fn present() { changed(); }".to_string(),
                    dry_run: Some(false),
                    idempotency_key: None,
                    working_directory: None,
                }))
                .await,
        );
        assert_tool_result_edit_status(
            &internal_failure,
            "internal_failure",
            OutcomeClass::InternalFailure,
        );
        assert_eq!(internal_failure["isError"], serde_json::json!(true));
    }

    #[tokio::test]
    async fn test_tool_call_completion_instrumentation_records_representative_tools() {
        use crate::analytics::{
            AnalyticsConfig, AnalyticsRecorder, AnalyticsScope, AnalyticsStore,
            SqliteAnalyticsStore,
        };
        use crate::protocol::edit::ReplaceSymbolBodyInput;

        let (_dir, server) = setup_loaded_edit_test(&[(
            "src/lib.rs",
            "fn present() {\n    println!(\"old\");\n}\n",
        )]);
        let db_path = _dir.path().join(".symforge").join("analytics.db");
        let store = AnalyticsStore::open(AnalyticsConfig::enabled(&db_path)).unwrap();
        server.set_analytics_recorder_for_tests(AnalyticsRecorder::start(
            store,
            AnalyticsScope::Session,
            16,
        ));

        let read = serialized_tool_result(
            server
                .get_file_content_tool(Parameters(get_file_content_input("src/lib.rs")))
                .await,
        );
        assert_tool_result_status(&read, OutcomeClass::Found);

        let search = serialized_tool_result(
            server
                .search_symbols_tool(Parameters(search_symbols_input(Some("present"))))
                .await,
        );
        assert_tool_result_status(&search, OutcomeClass::Found);

        let dry_run_edit = serialized_tool_result(
            server
                .replace_symbol_body_tool(Parameters(ReplaceSymbolBodyInput {
                    path: "src/lib.rs".to_string(),
                    name: "present".to_string(),
                    kind: None,
                    symbol_line: None,
                    new_body: "fn present() {\n    println!(\"new\");\n}".to_string(),
                    dry_run: Some(true),
                    idempotency_key: None,
                    working_directory: None,
                }))
                .await,
        );
        assert_tool_result_edit_status(&dry_run_edit, "dry_run_success", OutcomeClass::Found);

        assert_eq!(server.analytics_queue_status_for_tests().enqueued, 3);
        drop(server);

        let records = SqliteAnalyticsStore::open(&db_path)
            .unwrap()
            .recent_records(10)
            .unwrap();
        let tool_names = records
            .iter()
            .map(|record| record.tool_name.as_str())
            .collect::<Vec<_>>();

        assert!(tool_names.contains(&"get_file_content"));
        assert!(tool_names.contains(&"search_symbols"));
        assert!(tool_names.contains(&"replace_symbol_body"));
        assert!(records.iter().all(|record| record.surface == "tool"
            && record.configured_scope == "session"
            && record.response_bytes > 0
            && record.estimated_tokens.is_some()
            && record.outcome_class == "found"));
    }

    struct BlockingAnalyticsWriter {
        started: std::sync::mpsc::Sender<()>,
        release: std::sync::mpsc::Receiver<()>,
        blocked_once: bool,
    }

    impl crate::analytics::AnalyticsWriter for BlockingAnalyticsWriter {
        fn write(
            &mut self,
            _observation: &crate::analytics::AnalyticsObservation,
        ) -> anyhow::Result<crate::analytics::AnalyticsWriteOutcome> {
            if !self.blocked_once {
                self.blocked_once = true;
                self.started.send(()).unwrap();
                self.release.recv().unwrap();
            }
            Ok(crate::analytics::AnalyticsWriteOutcome::Recorded { id: 1 })
        }
    }

    #[tokio::test]
    async fn test_tool_handler_enqueues_without_waiting_for_background_writer() {
        use crate::analytics::{AnalyticsRecorder, AnalyticsScope};

        let (started_tx, started_rx) = std::sync::mpsc::channel();
        let (release_tx, release_rx) = std::sync::mpsc::channel();
        let content = b"fn present() {}\n";
        let (key, file) = make_file(
            "src/lib.rs",
            content,
            vec![make_symbol("present", SymbolKind::Function, 0, 0)],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        server.set_analytics_recorder_for_tests(AnalyticsRecorder::start_with_writer_for_tests(
            AnalyticsScope::Session,
            1,
            BlockingAnalyticsWriter {
                started: started_tx,
                release: release_rx,
                blocked_once: false,
            },
        ));

        let result = tokio::time::timeout(
            Duration::from_millis(200),
            server.get_file_content_tool(Parameters(get_file_content_input("src/lib.rs"))),
        )
        .await
        .expect("tool handler must not wait for the analytics writer");
        let result = serialized_tool_result(result);
        assert_tool_result_status(&result, OutcomeClass::Found);
        started_rx
            .recv_timeout(Duration::from_secs(1))
            .expect("background writer should receive the enqueued observation");

        let status = server.analytics_queue_status_for_tests();
        assert_eq!(status.enqueued, 1);
        assert_eq!(status.recorded, 0);

        release_tx.send(()).unwrap();
        drop(server);
    }

    #[tokio::test]
    async fn test_batch_edit_and_insert_statuses_include_per_operation_status() {
        use crate::protocol::edit::{
            BatchEditInput, BatchInsertInput, EditOperation, InsertPosition, InsertTarget,
            SingleEdit,
        };

        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir(dir.path().join(".git")).unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();
        let a_content = b"fn alpha() { old(); }\n";
        let b_content = b"fn beta() { old(); }\n";
        std::fs::write(src_dir.join("a.rs"), a_content).unwrap();
        std::fs::write(src_dir.join("b.rs"), b_content).unwrap();

        let mut files = vec![];
        for (path, content) in [
            ("src/a.rs", a_content as &[u8]),
            ("src/b.rs", b_content as &[u8]),
        ] {
            let result = crate::parsing::process_file(path, content, LanguageId::Rust);
            let indexed = IndexedFile::from_parse_result(result, content.to_vec());
            files.push((path.to_string(), indexed));
        }
        let server =
            make_server_with_root(make_live_index_ready(files), Some(dir.path().to_path_buf()));

        let dry_run_batch_edit = serialized_tool_result(
            server
                .batch_edit_tool(Parameters(BatchEditInput {
                    edits: vec![
                        SingleEdit {
                            path: "src/a.rs".to_string(),
                            name: "alpha".to_string(),
                            kind: None,
                            symbol_line: None,
                            operation: EditOperation::Replace {
                                new_body: "fn alpha() { new(); }".to_string(),
                            },
                            working_directory: None,
                        },
                        SingleEdit {
                            path: "src/b.rs".to_string(),
                            name: "beta".to_string(),
                            kind: None,
                            symbol_line: None,
                            operation: EditOperation::Delete,
                            working_directory: None,
                        },
                    ],
                    dry_run: Some(true),
                    idempotency_key: None,
                    working_directory: None,
                }))
                .await,
        );
        assert_tool_result_edit_status(&dry_run_batch_edit, "dry_run_success", OutcomeClass::Found);
        let operation_statuses = dry_run_batch_edit["_meta"][RESULT_STATUS_META_KEY]["operations"]
            .as_array()
            .expect("batch edit status should include per-operation statuses");
        assert_eq!(operation_statuses.len(), 2);
        assert!(
            operation_statuses
                .iter()
                .all(|entry| entry["status"] == serde_json::json!("dry_run_success"))
        );
        assert!(tool_result_text(&dry_run_batch_edit).contains("[DRY RUN]"));
        assert!(
            std::fs::read_to_string(src_dir.join("a.rs"))
                .unwrap()
                .contains("old")
        );

        let successful_batch_insert = serialized_tool_result(
            server
                .batch_insert_tool(Parameters(BatchInsertInput {
                    content: "fn logging() {}".to_string(),
                    position: InsertPosition::After,
                    targets: vec![
                        InsertTarget {
                            path: "src/a.rs".to_string(),
                            name: "alpha".to_string(),
                            kind: None,
                            symbol_line: None,
                            working_directory: None,
                        },
                        InsertTarget {
                            path: "src/b.rs".to_string(),
                            name: "beta".to_string(),
                            kind: None,
                            symbol_line: None,
                            working_directory: None,
                        },
                    ],
                    dry_run: Some(false),
                    idempotency_key: None,
                    working_directory: None,
                }))
                .await,
        );
        assert_tool_result_edit_status(&successful_batch_insert, "success", OutcomeClass::Found);
        let operation_statuses =
            successful_batch_insert["_meta"][RESULT_STATUS_META_KEY]["operations"]
                .as_array()
                .expect("batch insert status should include per-operation statuses");
        assert_eq!(operation_statuses.len(), 2);
        assert!(
            operation_statuses
                .iter()
                .all(|entry| entry["status"] == serde_json::json!("success"))
        );

        let failed_batch_edit = serialized_tool_result(
            server
                .batch_edit_tool(Parameters(BatchEditInput {
                    edits: vec![
                        SingleEdit {
                            path: "src/a.rs".to_string(),
                            name: "alpha".to_string(),
                            kind: None,
                            symbol_line: None,
                            operation: EditOperation::Delete,
                            working_directory: None,
                        },
                        SingleEdit {
                            path: "src/b.rs".to_string(),
                            name: "missing".to_string(),
                            kind: None,
                            symbol_line: None,
                            operation: EditOperation::Delete,
                            working_directory: None,
                        },
                    ],
                    dry_run: Some(false),
                    idempotency_key: None,
                    working_directory: None,
                }))
                .await,
        );
        assert_tool_result_edit_status(&failed_batch_edit, "not_found", OutcomeClass::NotFound);
        assert!(tool_result_text(&failed_batch_edit).contains("ROLLED BACK"));
        let operation_statuses = failed_batch_edit["_meta"][RESULT_STATUS_META_KEY]["operations"]
            .as_array()
            .expect("failed batch edit status should identify the failing operation");
        assert_eq!(operation_statuses.len(), 1);
        assert_eq!(
            operation_statuses[0]["operation_index"],
            serde_json::json!(2)
        );
        assert_eq!(
            operation_statuses[0]["status"],
            serde_json::json!("not_found")
        );
    }

    #[tokio::test]
    async fn test_edit_idempotency_replays_same_key_same_request_without_second_write() {
        let source = b"fn present() {\n    old();\n}\n";
        let (_dir, server, file_path) = setup_edit_test(source);
        let request = serde_json::json!({
            "path": "src/lib.rs",
            "name": "present",
            "new_body": "fn present() {\n    new();\n}",
            "dry_run": false,
            "idempotency_key": "edit-replay-same-request",
        });

        let first = server
            .replace_symbol_body(Parameters(replace_symbol_body_input_from_json(
                request.clone(),
            )))
            .await;
        assert!(first.contains("replaced"));
        let written_once = std::fs::read_to_string(&file_path).unwrap();
        assert!(written_once.contains("new();"));

        let second = server
            .replace_symbol_body(Parameters(replace_symbol_body_input_from_json(request)))
            .await;

        assert_eq!(second, first);
        assert_eq!(std::fs::read_to_string(&file_path).unwrap(), written_once);
    }

    #[tokio::test]
    async fn test_edit_idempotency_rejects_same_key_different_request_before_file_changes() {
        let source = b"fn present() {\n    old();\n}\n";
        let (_dir, server, file_path) = setup_edit_test(source);
        let first_request = serde_json::json!({
            "path": "src/lib.rs",
            "name": "present",
            "new_body": "fn present() {\n    first();\n}",
            "dry_run": false,
            "idempotency_key": "edit-conflict-same-key",
        });
        let second_request = serde_json::json!({
            "path": "src/lib.rs",
            "name": "present",
            "new_body": "fn present() {\n    second();\n}",
            "dry_run": false,
            "idempotency_key": "edit-conflict-same-key",
        });

        let first = server
            .replace_symbol_body(Parameters(replace_symbol_body_input_from_json(
                first_request,
            )))
            .await;
        assert!(first.contains("replaced"));

        let conflict = server
            .replace_symbol_body(Parameters(replace_symbol_body_input_from_json(
                second_request,
            )))
            .await;
        let content = std::fs::read_to_string(&file_path).unwrap();

        assert!(conflict.contains("Idempotency conflict"));
        assert!(content.contains("first();"));
        assert!(!content.contains("second();"));
    }

    #[tokio::test]
    async fn test_edit_idempotency_dry_run_does_not_poison_committed_replay_state() {
        let source = b"fn present() {\n    old();\n}\n";
        let (_dir, server, file_path) = setup_edit_test(source);
        let dry_run_request = serde_json::json!({
            "path": "src/lib.rs",
            "name": "present",
            "new_body": "fn present() {\n    planned();\n}",
            "dry_run": true,
            "idempotency_key": "edit-dry-run-ignored",
        });
        let commit_request = serde_json::json!({
            "path": "src/lib.rs",
            "name": "present",
            "new_body": "fn present() {\n    committed();\n}",
            "dry_run": false,
            "idempotency_key": "edit-dry-run-ignored",
        });

        let dry_run = server
            .replace_symbol_body(Parameters(replace_symbol_body_input_from_json(
                dry_run_request,
            )))
            .await;
        assert!(dry_run.contains("[DRY RUN]"));
        assert!(
            std::fs::read_to_string(&file_path)
                .unwrap()
                .contains("old();")
        );

        let committed = server
            .replace_symbol_body(Parameters(replace_symbol_body_input_from_json(
                commit_request,
            )))
            .await;
        assert!(committed.contains("replaced"));
        assert!(
            std::fs::read_to_string(&file_path)
                .unwrap()
                .contains("committed();")
        );
    }

    #[tokio::test]
    async fn test_delete_symbol_idempotency_replays_after_symbol_is_removed() {
        let source = b"fn target() {}\nfn keep() {}\n";
        let (_dir, server, file_path) = setup_edit_test(source);
        let request = serde_json::json!({
            "path": "src/lib.rs",
            "name": "target",
            "dry_run": false,
            "idempotency_key": "delete-symbol-replay-after-removal",
        });

        let first = server
            .delete_symbol(Parameters(delete_symbol_input_from_json(request.clone())))
            .await;
        assert!(first.contains("deleted"));
        let written_once = std::fs::read_to_string(&file_path).unwrap();
        assert!(!written_once.contains("fn target"));
        assert!(written_once.contains("fn keep"));

        let second = server
            .delete_symbol(Parameters(delete_symbol_input_from_json(request)))
            .await;

        assert_eq!(second, first);
        assert_eq!(std::fs::read_to_string(&file_path).unwrap(), written_once);
    }

    #[tokio::test]
    async fn test_edit_within_symbol_idempotency_replays_after_old_text_is_replaced() {
        let source = b"fn present() {\n    old();\n}\n";
        let (_dir, server, file_path) = setup_edit_test(source);
        let request = serde_json::json!({
            "path": "src/lib.rs",
            "name": "present",
            "old_text": "old();",
            "new_text": "new();",
            "replace_all": false,
            "dry_run": false,
            "idempotency_key": "edit-within-replay-after-replacement",
        });

        let first = server
            .edit_within_symbol(Parameters(edit_within_symbol_input_from_json(
                request.clone(),
            )))
            .await;
        assert!(first.contains("edited"));
        let written_once = std::fs::read_to_string(&file_path).unwrap();
        assert!(written_once.contains("new();"));
        assert!(!written_once.contains("old();"));

        let second = server
            .edit_within_symbol(Parameters(edit_within_symbol_input_from_json(request)))
            .await;

        assert_eq!(second, first);
        assert_eq!(std::fs::read_to_string(&file_path).unwrap(), written_once);
    }

    #[tokio::test]
    async fn test_batch_edit_idempotency_replay_preserves_operation_status_metadata() {
        let source = b"fn alpha() {\n    old();\n}\nfn beta() {}\n";
        let (_dir, server, file_path) = setup_edit_test(source);
        let request = batch_replace_request_json(
            "batch-edit-replay-same-request",
            "fn alpha() {\n    new();\n}",
        );

        let first = serialized_tool_result(
            server
                .batch_edit_tool(Parameters(batch_edit_input_from_json(request.clone())))
                .await,
        );
        assert_tool_result_edit_status(&first, "success", OutcomeClass::Found);
        assert_eq!(
            first["_meta"][RESULT_STATUS_META_KEY]["operations"][0]["status"],
            serde_json::json!("success")
        );
        let written_once = std::fs::read_to_string(&file_path).unwrap();
        assert!(written_once.contains("new();"));

        let second = serialized_tool_result(
            server
                .batch_edit_tool(Parameters(batch_edit_input_from_json(request)))
                .await,
        );

        assert_eq!(second, first);
        assert_eq!(std::fs::read_to_string(&file_path).unwrap(), written_once);
    }

    #[tokio::test]
    async fn test_batch_edit_idempotency_rejects_same_key_different_request_before_file_changes() {
        let source = b"fn alpha() {\n    old();\n}\nfn beta() {}\n";
        let (_dir, server, file_path) = setup_edit_test(source);
        let first_request = batch_replace_request_json(
            "batch-edit-conflict-same-key",
            "fn alpha() {\n    first();\n}",
        );
        let second_request = batch_replace_request_json(
            "batch-edit-conflict-same-key",
            "fn alpha() {\n    second();\n}",
        );

        let first = server
            .batch_edit(Parameters(batch_edit_input_from_json(first_request)))
            .await;
        assert!(first.contains("replaced"));

        let conflict = server
            .batch_edit(Parameters(batch_edit_input_from_json(second_request)))
            .await;
        let content = std::fs::read_to_string(&file_path).unwrap();

        assert!(conflict.contains("Idempotency conflict"));
        assert!(content.contains("first();"));
        assert!(!content.contains("second();"));
    }

    #[tokio::test]
    async fn test_get_file_content_honors_max_tokens_budget() {
        let content = b"line one is long\nline two is also long\nline three is also long";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: Some(8),
            }))
            .await;
        assert!(
            result.contains("[truncated"),
            "budgeted read should include truncation notice, got: {result}"
        );
        assert!(
            result.contains("budget is 8 tokens"),
            "truncation notice should include requested budget, got: {result}"
        );
        assert!(
            !result.contains("line three"),
            "budgeted read should omit content past budget, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_file_content_not_found() {
        let server = make_server(make_live_index_ready(vec![]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "nonexistent.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "File not found: nonexistent.rs. Use search_files to find the correct path."
        );
    }

    #[tokio::test]
    async fn test_get_file_content_line_range_preserves_public_contract() {
        let content = b"line 1\nline 2\nline 3\nline 4";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: Some(2),
                end_line: Some(3),
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "line 2\nline 3");
    }

    #[tokio::test]
    async fn test_get_file_content_show_line_numbers_renders_numbered_full_read() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: Some(true),
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "1: line 1\n2: line 2\n3: line 3");
    }

    #[tokio::test]
    async fn test_get_file_content_header_and_line_numbers_render_range_shell() {
        let content = b"line 1\nline 2\nline 3\nline 4";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: Some(2),
                end_line: Some(3),
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: Some(true),
                header: Some(true),
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "src/lib.rs [lines 2-3]\n2: line 2\n3: line 3");
    }

    #[tokio::test]
    async fn test_get_file_content_around_line_renders_numbered_excerpt() {
        let content = b"line 1\nline 2\nline 3\nline 4\nline 5";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: Some(3),
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "2: line 2\n3: line 3\n4: line 4");
    }

    #[tokio::test]
    async fn test_get_file_content_allows_header_with_around_line() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: Some(2),
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: Some(true),
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        // Should succeed (not reject) — header is now allowed with around_line.
        assert!(
            !result.starts_with("Invalid"),
            "header + around_line should be allowed, got: {result}"
        );
        assert!(
            result.contains("line 2"),
            "should contain the around_line content, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_file_content_rejects_around_line_with_explicit_range() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: Some(2),
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: Some(2),
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "Invalid get_file_content request: `around_line` cannot be combined with `start_line` or `end_line`. Valid with `around_line`: `context_lines`."
        );
    }

    #[tokio::test]
    async fn test_get_file_content_around_match_renders_first_numbered_excerpt() {
        let content = b"line 1\nTODO first\nline 3\nTODO second\nline 5";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: Some("todo".to_string()),
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "1: line 1\n2: TODO first\n3: line 3");
    }

    #[tokio::test]
    async fn test_get_file_content_chunked_read_renders_header_and_numbered_lines() {
        let content = b"line 1\nline 2\nline 3\nline 4\nline 5";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: Some(2),
                max_lines: Some(2),
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "src/lib.rs [chunk 2/3, lines 3-4]\n3: line 3\n4: line 4"
        );
    }

    #[tokio::test]
    async fn test_get_file_content_reports_out_of_range_chunk() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: Some(3),
                max_lines: Some(2),
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "Chunk 3 out of range for src/lib.rs (2 chunks)");
    }

    #[tokio::test]
    async fn test_get_file_content_reports_missing_around_match() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: Some("needle".to_string()),
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "No matches for 'needle' in src/lib.rs");
    }

    #[tokio::test]
    async fn test_get_file_content_selects_requested_match_occurrence() {
        let content = b"line 1\nTODO first\nline 3\nTODO second\nline 5";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: Some("todo".to_string()),
                match_occurrence: Some(2),
                around_symbol: None,
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "3: line 3\n4: TODO second\n5: line 5");
    }

    #[tokio::test]
    async fn test_get_file_content_reports_missing_match_occurrence() {
        let content = b"line 1\nTODO first\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: Some("todo".to_string()),
                match_occurrence: Some(2),
                around_symbol: None,
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "Match occurrence 2 for 'todo' not found in src/lib.rs; 1 match(es) available at lines 2"
        );
    }

    #[tokio::test]
    async fn test_get_file_content_freshens_stale_indexed_file_before_rendering() {
        let repo = tempfile::tempdir().unwrap();
        let src_dir = repo.path().join("src");
        fs::create_dir_all(&src_dir).unwrap();
        let disk_path = src_dir.join("lib.rs");
        fs::write(&disk_path, "fn fresh() {}\n").unwrap();

        let (key, file) = make_file("src/lib.rs", b"fn stale() {}\n", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(repo.path().to_path_buf()),
        );

        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("fn fresh() {}"),
            "expected fresh disk content, got: {result}"
        );
        assert!(
            !result.contains("fn stale() {}"),
            "stale indexed content should not be served after refresh: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_file_content_normalizes_exact_path_before_freshness_and_lookup() {
        let repo = tempfile::tempdir().unwrap();
        let src_dir = repo.path().join("src");
        fs::create_dir_all(&src_dir).unwrap();
        let disk_path = src_dir.join("lib.rs");
        fs::write(&disk_path, "fn fresh() {}\n").unwrap();

        let (key, file) = make_file("src/lib.rs", b"fn stale() {}\n", vec![]);
        let server = make_server_with_root(
            make_live_index_ready(vec![(key, file)]),
            Some(repo.path().to_path_buf()),
        );

        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "./src\\lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("fn fresh() {}"),
            "expected fresh disk content, got: {result}"
        );

        let guard = server.index.read();
        assert!(guard.get_file("src/lib.rs").is_some());
        assert!(guard.get_file("./src\\lib.rs").is_none());
    }

    #[tokio::test]
    async fn test_get_file_content_around_symbol_renders_numbered_excerpt() {
        let content = b"line 1\nfn connect() {}\nline 3";
        let (key, file) = make_file(
            "src/lib.rs",
            content,
            vec![make_symbol("connect", SymbolKind::Function, 1, 1)],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: Some("connect".to_string()),
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "1: line 1\n2: fn connect() {}\n3: line 3");
    }

    #[tokio::test]
    async fn test_get_file_content_reports_ambiguous_around_symbol_without_symbol_line() {
        let content = b"fn connect() {}\nline 2\nfn connect() {}";
        let (key, file) = make_file(
            "src/lib.rs",
            content,
            vec![
                make_symbol("connect", SymbolKind::Function, 0, 0),
                make_symbol("connect", SymbolKind::Function, 2, 2),
            ],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: Some("connect".to_string()),
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "Ambiguous symbol selector for connect in src/lib.rs; pass `symbol_line` to disambiguate. Candidates: 0, 2"
        );
    }

    #[tokio::test]
    async fn test_get_file_content_around_symbol_symbol_line_disambiguates() {
        let content = b"fn connect() {}\nline 2\nfn connect() {}";
        let (key, file) = make_file(
            "src/lib.rs",
            content,
            vec![
                make_symbol("connect", SymbolKind::Function, 0, 0),
                make_symbol("connect", SymbolKind::Function, 2, 2),
            ],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: Some("connect".to_string()),
                symbol_line: Some(3),
                context_lines: Some(0),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "3: fn connect() {}");
    }

    #[tokio::test]
    async fn test_get_file_content_reports_missing_around_symbol() {
        let content = b"fn helper() {}\nline 2";
        let (key, file) = make_file(
            "src/lib.rs",
            content,
            vec![make_symbol("helper", SymbolKind::Function, 1, 1)],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: Some("connect".to_string()),
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "No symbol connect in src/lib.rs. Close matches: helper. Use get_file_context with sections=['outline'] for the full list (1 symbols)."
        );
    }

    #[tokio::test]
    async fn test_get_file_content_rejects_chunked_read_with_other_selectors() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: Some(2),
                end_line: None,
                chunk_index: Some(1),
                max_lines: Some(2),
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "Invalid get_file_content request: chunked reads (`chunk_index` + `max_lines`) cannot be combined with `start_line`, `end_line`, `around_line`, or `around_match`."
        );
    }

    #[tokio::test]
    async fn test_get_file_content_rejects_chunk_index_without_max_lines() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: Some(1),
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "Invalid get_file_content request: `chunk_index` requires `max_lines`."
        );
    }

    #[tokio::test]
    async fn test_get_file_content_rejects_around_symbol_with_other_selectors() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: Some(2),
                end_line: None,
                chunk_index: Some(1),
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: Some("connect".to_string()),
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "Invalid get_file_content request: `around_symbol` cannot be combined with `start_line`, `end_line`, `around_line`, `around_match`, or `chunk_index`. Valid with `around_symbol`: `symbol_line`, `context_lines`, `max_lines`."
        );
    }

    #[tokio::test]
    async fn test_get_file_content_rejects_symbol_line_without_around_symbol() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: Some(2),
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "Invalid get_file_content request: `symbol_line` requires `around_symbol`."
        );
    }

    #[tokio::test]
    async fn test_get_file_content_rejects_around_match_with_other_selectors() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: Some(3),
                chunk_index: None,
                max_lines: None,
                around_line: Some(2),
                around_match: Some("line".to_string()),
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "Invalid get_file_content request: `around_match` cannot be combined with `start_line`, `end_line`, or `around_line`. Valid with `around_match`: `context_lines`."
        );
    }

    // ── Mode enum tests ────────────────────────────────────────────────────

    #[tokio::test]
    async fn test_mode_symbol_with_around_symbol_works() {
        let content = b"line 1\nfn connect() {}\nline 3";
        let (key, file) = make_file(
            "src/lib.rs",
            content,
            vec![make_symbol("connect", SymbolKind::Function, 2, 2)],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("symbol".to_string()),
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: Some("connect".to_string()),
                symbol_line: None,
                context_lines: Some(1),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("fn connect()"),
            "expected symbol content, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_mode_symbol_without_around_symbol_errors() {
        let content = b"line 1\nline 2";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("symbol".to_string()),
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "mode=symbol requires around_symbol");
    }

    #[tokio::test]
    async fn test_mode_lines_with_cross_mode_flag_errors() {
        let content = b"line 1\nline 2";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("lines".to_string()),
                start_line: Some(1),
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: Some("foo".to_string()),
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("mode=lines conflicts with around_symbol"),
            "expected cross-mode error, got: {result}"
        );
        assert!(
            result.contains("Use mode=symbol"),
            "expected guidance, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_mode_lines_rejects_match_occurrence_cross_mode_flag() {
        let content = b"line 1\nline 2";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("lines".to_string()),
                start_line: Some(1),
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: Some(2),
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("mode=lines conflicts with match_occurrence"),
            "expected cross-mode error, got: {result}"
        );
        assert!(
            result.contains("Use mode=match"),
            "expected guidance, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_no_mode_legacy_flags_backward_compatible() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: Some(1),
                end_line: Some(2),
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("line 1"),
            "expected file content, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_mode_search_not_implemented() {
        let content = b"line 1";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("search".to_string()),
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "mode 'search' is not yet implemented");
    }

    #[tokio::test]
    async fn test_mode_symbol_with_context_lines_works() {
        let content = b"line 1\nfn connect() {}\nline 3";
        let (key, file) = make_file(
            "src/lib.rs",
            content,
            vec![make_symbol("connect", SymbolKind::Function, 2, 2)],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("symbol".to_string()),
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: Some("connect".to_string()),
                symbol_line: None,
                context_lines: Some(10),
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("fn connect()"),
            "expected symbol content with context, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_mode_symbol_rejects_match_occurrence_cross_mode_flag() {
        let content = b"line 1\nfn connect() {}\nline 3";
        let (key, file) = make_file(
            "src/lib.rs",
            content,
            vec![make_symbol("connect", SymbolKind::Function, 2, 2)],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("symbol".to_string()),
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: Some(2),
                around_symbol: Some("connect".to_string()),
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("mode=symbol conflicts with match_occurrence"),
            "expected cross-mode error, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_mode_unknown_errors() {
        let content = b"line 1";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("fancy".to_string()),
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "Unknown mode 'fancy'. Valid modes: lines, symbol, match, chunk."
        );
    }

    #[tokio::test]
    async fn test_mode_match_without_around_match_errors() {
        let content = b"line 1";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("match".to_string()),
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "mode=match requires around_match");
    }

    #[tokio::test]
    async fn test_match_occurrence_requires_around_match() {
        let content = b"line 1";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: Some(1),
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "Invalid get_file_content request: `match_occurrence` requires `around_match`."
        );
    }

    #[tokio::test]
    async fn test_match_occurrence_must_be_positive() {
        let content = b"line 1\nTODO";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("match".to_string()),
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: Some("todo".to_string()),
                match_occurrence: Some(0),
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(
            result,
            "Invalid get_file_content request: `match_occurrence` must be 1 or greater."
        );
    }

    #[tokio::test]
    async fn test_mode_chunk_without_required_flags_errors() {
        let content = b"line 1";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("chunk".to_string()),
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, "mode=chunk requires chunk_index");
    }

    #[tokio::test]
    async fn test_mode_chunk_cross_mode_errors() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("chunk".to_string()),
                start_line: None,
                end_line: None,
                chunk_index: Some(1),
                max_lines: Some(10),
                around_line: None,
                around_match: Some("line".to_string()),
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("mode=chunk conflicts with around_match"),
            "expected cross-mode error, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_mode_chunk_rejects_match_occurrence_cross_mode_flag() {
        let content = b"line 1\nline 2\nline 3";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: Some("chunk".to_string()),
                start_line: None,
                end_line: None,
                chunk_index: Some(1),
                max_lines: Some(10),
                around_line: None,
                around_match: None,
                match_occurrence: Some(2),
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: None,
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("mode=chunk conflicts with match_occurrence"),
            "expected cross-mode error, got: {result}"
        );
        assert!(
            result.contains("Use mode=match"),
            "expected guidance, got: {result}"
        );
    }

    // ── get_file_content serde / alias tests ───────────────────────────────

    #[test]
    fn test_get_file_content_input_deserializes_offset_and_limit() {
        let json = r#"{"path":"x","offset":10,"limit":5}"#;
        let input: super::GetFileContentInput = serde_json::from_str(json).unwrap();
        assert_eq!(input.offset, Some(10));
        assert_eq!(input.limit, Some(5));
        assert!(input.start_line.is_none());
        assert!(input.end_line.is_none());
    }

    #[test]
    fn test_get_file_content_input_deserializes_max_tokens() {
        let json = r#"{"path":"x","max_tokens":1200}"#;
        let input: super::GetFileContentInput = serde_json::from_str(json).unwrap();
        assert_eq!(input.max_tokens, Some(1200));
    }

    #[test]
    fn test_get_file_content_input_defaults_all_none() {
        let json = r#"{"path":"x"}"#;
        let input: super::GetFileContentInput = serde_json::from_str(json).unwrap();
        assert!(input.offset.is_none());
        assert!(input.limit.is_none());
        assert!(input.start_line.is_none());
        assert!(input.max_tokens.is_none());
    }

    #[test]
    fn test_get_file_content_input_rejects_unknown_field() {
        let json = r#"{"path":"x","typo_field":1}"#;
        let result = serde_json::from_str::<super::GetFileContentInput>(json);
        assert!(result.is_err(), "unknown field should be rejected");
        let err = result.err().unwrap().to_string();
        assert!(
            err.contains("typo_field"),
            "error should name the unknown field, got: {err}"
        );
    }

    #[test]
    fn test_get_file_content_input_rejects_non_numeric_offset() {
        let json = r#"{"path":"x","offset":"not-a-number"}"#;
        assert!(
            serde_json::from_str::<super::GetFileContentInput>(json).is_err(),
            "non-numeric offset should fail to deserialize"
        );
    }

    // ── normalize_file_content_aliases unit tests ───────────────────────────

    fn make_alias_input(offset: Option<u32>, limit: Option<u32>) -> super::GetFileContentInput {
        super::GetFileContentInput {
            path: "x".to_string(),
            mode: None,
            start_line: None,
            end_line: None,
            chunk_index: None,
            max_lines: None,
            around_line: None,
            around_match: None,
            match_occurrence: None,
            around_symbol: None,
            symbol_line: None,
            context_lines: None,
            show_line_numbers: None,
            header: None,
            estimate: None,
            offset,
            limit,
            max_tokens: None,
        }
    }

    #[test]
    fn test_normalize_aliases_offset_and_limit() {
        let mut input = make_alias_input(Some(10), Some(5));
        super::normalize_file_content_aliases(&mut input).unwrap();
        assert_eq!(input.start_line, Some(11));
        assert_eq!(input.end_line, Some(15));
        assert!(input.offset.is_none());
        assert!(input.limit.is_none());
    }

    #[test]
    fn test_normalize_aliases_zero_offset() {
        let mut input = make_alias_input(Some(0), Some(100));
        super::normalize_file_content_aliases(&mut input).unwrap();
        assert_eq!(input.start_line, Some(1));
        assert_eq!(input.end_line, Some(100));
    }

    #[test]
    fn test_normalize_aliases_no_limit_reads_to_end() {
        let mut input = make_alias_input(Some(50), None);
        super::normalize_file_content_aliases(&mut input).unwrap();
        assert_eq!(input.start_line, Some(51));
        assert!(input.end_line.is_none());
    }

    #[test]
    fn test_normalize_aliases_no_offset_defaults_to_start() {
        let mut input = make_alias_input(None, Some(20));
        super::normalize_file_content_aliases(&mut input).unwrap();
        assert_eq!(input.start_line, Some(1));
        assert_eq!(input.end_line, Some(20));
    }

    #[test]
    fn test_normalize_aliases_single_line() {
        let mut input = make_alias_input(Some(0), Some(1));
        super::normalize_file_content_aliases(&mut input).unwrap();
        assert_eq!(input.start_line, Some(1));
        assert_eq!(input.end_line, Some(1));
    }

    #[test]
    fn test_normalize_aliases_neither_set_is_noop() {
        let mut input = make_alias_input(None, None);
        super::normalize_file_content_aliases(&mut input).unwrap();
        assert!(input.start_line.is_none());
        assert!(input.end_line.is_none());
    }

    #[test]
    fn test_normalize_aliases_rejects_conflict_with_start_line() {
        let mut input = make_alias_input(Some(10), None);
        input.start_line = Some(5);
        let err = super::normalize_file_content_aliases(&mut input)
            .err()
            .unwrap();
        assert!(err.contains("`start_line`"), "got: {err}");
    }

    #[test]
    fn test_normalize_aliases_rejects_conflict_with_end_line() {
        let mut input = make_alias_input(Some(10), Some(5));
        input.end_line = Some(20);
        let err = super::normalize_file_content_aliases(&mut input)
            .err()
            .unwrap();
        assert!(err.contains("`end_line`"), "got: {err}");
    }

    #[test]
    fn test_normalize_aliases_rejects_conflict_with_around_symbol() {
        let mut input = make_alias_input(Some(10), None);
        input.around_symbol = Some("foo".to_string());
        let err = super::normalize_file_content_aliases(&mut input)
            .err()
            .unwrap();
        assert!(err.contains("`around_symbol`"), "got: {err}");
    }

    #[test]
    fn test_normalize_aliases_rejects_limit_zero() {
        let mut input = make_alias_input(None, Some(0));
        let err = super::normalize_file_content_aliases(&mut input)
            .err()
            .unwrap();
        assert!(err.contains("must be 1 or greater"), "got: {err}");
    }

    #[test]
    fn test_normalize_aliases_rejects_conflict_with_mode() {
        let mut input = make_alias_input(Some(10), None);
        input.mode = Some("lines".to_string());
        let err = super::normalize_file_content_aliases(&mut input)
            .err()
            .unwrap();
        assert!(err.contains("`mode`"), "got: {err}");
    }

    #[tokio::test]
    async fn test_get_file_content_offset_limit_returns_sliced_window() {
        let content = b"line 1\nline 2\nline 3\nline 4\nline 5";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: None,
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: Some(1),
                limit: Some(2),
                max_tokens: None,
            }))
            .await;
        // offset=1 → start_line=2, limit=2 → end_line=3
        assert_eq!(result, "line 2\nline 3", "got: {result}");
    }

    #[tokio::test]
    async fn test_get_file_content_offset_limit_conflict_rejected() {
        let content = b"line 1\nline 2";
        let (key, file) = make_file("src/lib.rs", content, vec![]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_file_content(Parameters(super::GetFileContentInput {
                path: "src/lib.rs".to_string(),
                mode: None,
                start_line: Some(1),
                end_line: None,
                chunk_index: None,
                max_lines: None,
                around_line: None,
                around_match: None,
                match_occurrence: None,
                around_symbol: None,
                symbol_line: None,
                context_lines: None,
                show_line_numbers: None,
                header: None,
                estimate: None,
                offset: Some(1),
                limit: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("cannot be combined"),
            "conflict should be rejected, got: {result}"
        );
    }

    // ── Explore tool tests ──────────────────────────────────────────────────

    #[tokio::test]
    async fn test_explore_output_footer_documents_ranking() {
        let sym = make_symbol("Error", SymbolKind::Enum, 0, 5);
        let content = b"pub enum Error {\n    NotFound,\n}\n";
        let (key, file) = make_file("src/error.rs", content, vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "error handling".to_string(),
                limit: None,
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("ranked by:"),
            "explore output must include rank-signal footer; got:\n{result}"
        );
        assert!(
            result.contains(
                "concept match + symbol-token alignment + path proximity + caller density"
            ),
            "footer must enumerate the four ranking signals verbatim; got:\n{result}"
        );
    }

    #[tokio::test]
    async fn test_explore_concept_returns_results() {
        let sym = make_symbol("Error", SymbolKind::Enum, 0, 5);
        let content = b"pub enum Error {\n    NotFound,\n    Io(std::io::Error),\n}\nimpl Error {\n    fn is_retryable(&self) -> bool { false }\n}\n";
        let (key, file) = make_file("src/error.rs", content, vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "error handling".to_string(),
                limit: Some(5),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("Exploring: Error Handling"),
            "should have concept label, got: {result}"
        );
        assert!(
            result.contains("Error"),
            "should find Error symbol, got: {result}"
        );
        assert!(
            !result.contains("Auto-derived cluster:"),
            "static concept matches should not emit fallback-derived clustering: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_concept_enrichment_shows_annotation() {
        let sym = make_symbol("Error", SymbolKind::Enum, 0, 5);
        let content = b"use thiserror::Error;\npub enum Error {}\n";
        let refs = vec![make_ref(
            "thiserror",
            Some("thiserror::Error"),
            ReferenceKind::Import,
            0,
            None,
        )];
        let (key, file) = make_file_with_refs("src/error.rs", content, vec![sym], refs);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "error handling".to_string(),
                limit: Some(5),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("Enriched with project imports: thiserror"),
            "should annotate convention enrichment, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_concept_enrichment_from_cargo_manifest() {
        // thiserror has NO import references — only a Cargo.toml dependency symbol.
        // The enrichment should still surface it via the manifest path.
        let sym = make_symbol("Error", SymbolKind::Enum, 0, 5);
        let content = b"#[derive(thiserror::Error)]\npub enum Error {}\n";
        let (key, file) = make_file("src/error.rs", content, vec![sym]);

        // Cargo.toml with a dependencies.thiserror symbol (no import refs needed).
        let cargo_sym = SymbolRecord {
            name: "dependencies.thiserror".to_string(),
            kind: SymbolKind::Key,
            depth: 1,
            sort_order: 0,
            byte_range: (0, 20),
            item_byte_range: Some((0, 20)),
            line_range: (0, 0),
            doc_byte_range: None,
        };
        let cargo_content = b"[dependencies]\nthiserror = \"2.0\"\n";
        let cargo_file = IndexedFile {
            relative_path: "Cargo.toml".to_string(),
            language: LanguageId::Toml,
            classification: crate::domain::FileClassification::for_code_path("Cargo.toml"),
            content: cargo_content.to_vec(),
            symbols: vec![cargo_sym],
            parse_status: ParseStatus::Parsed,
            parse_diagnostic: None,
            byte_len: cargo_content.len() as u64,
            content_hash: "test".to_string(),
            references: vec![],
            alias_map: std::collections::HashMap::new(),
            mtime_secs: 0,
        };

        let server = make_server(make_live_index_ready(vec![
            (key, file),
            ("Cargo.toml".to_string(), cargo_file),
        ]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "error handling".to_string(),
                limit: Some(5),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("Enriched with project imports: thiserror"),
            "should enrich from Cargo.toml manifest deps, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_fallback_returns_results() {
        let content = b"fn process_data() { let x = 42; }\n";
        let sym = make_symbol("process_data", SymbolKind::Function, 0, 0);
        let (key, file) = make_file("src/main.rs", content, vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "process data".to_string(),
                limit: Some(5),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("Exploring:"),
            "should have explore header, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_empty_query() {
        let server = make_server(make_live_index_ready(vec![]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "".to_string(),
                limit: None,
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("Explore requires a non-empty query"),
            "should reject empty query, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_depth_2_shows_signatures() {
        let content = b"pub enum Error {\n    NotFound,\n    Io(std::io::Error),\n}\nimpl Error {\n    fn is_retryable(&self) -> bool { false }\n}\n";
        let sym = SymbolRecord {
            name: "Error".to_string(),
            kind: SymbolKind::Enum,
            depth: 0,
            sort_order: 0,
            byte_range: (0, content.len() as u32),
            line_range: (0, 5),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let (key, file) = make_file("src/error.rs", content, vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "error handling".to_string(),
                limit: Some(5),
                depth: Some(2),
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("pub enum Error"),
            "depth 2 should show signature, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_multi_term_scoring_injects_enclosing_symbol() {
        // BurstTracker contains "debounce" in its body; it won't match the symbol search for
        // "debounce" (name mismatch) but the text search will find the word and inject the
        // enclosing symbol into match_counts, so it should appear in results.
        // Query uses "burst debounce" — no concept match — to exercise fallback multi-term scoring.
        let content = b"pub struct BurstTracker {\n    debounce: u32,\n    burst_count: u32,\n}\n";
        let burst_tracker = SymbolRecord {
            name: "BurstTracker".to_string(),
            kind: SymbolKind::Struct,
            depth: 0,
            sort_order: 0,
            byte_range: (0, content.len() as u32),
            line_range: (0, 3),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let (key, file) = make_file("src/watcher.rs", content, vec![burst_tracker]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "burst debounce".to_string(),
                limit: Some(10),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("BurstTracker"),
            "BurstTracker should appear via enclosing-symbol injection from debounce text hit, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_cooccurrence_ranks_multi_term_symbol_above_single_term_noise() {
        let actor_content =
            b"pub fn handle_supervisor_evt() -> Result<(), ActorProcessingErr> { Ok(()) }\n";
        let actor_sym = SymbolRecord {
            name: "handle_supervisor_evt".to_string(),
            kind: SymbolKind::Function,
            depth: 0,
            sort_order: 0,
            byte_range: (0, actor_content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let noise_content = b"pub fn recovery_words() {}\n";
        let noise_sym = SymbolRecord {
            name: "recovery_words".to_string(),
            kind: SymbolKind::Function,
            depth: 0,
            sort_order: 0,
            byte_range: (0, noise_content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let actor_file = make_file("src/actors/supervisor.rs", actor_content, vec![actor_sym]);
        let noise_file = make_file("src/auth/recovery.rs", noise_content, vec![noise_sym]);
        let server = make_server(make_live_index_ready(vec![actor_file, noise_file]));

        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "actor supervision and error recovery".to_string(),
                limit: Some(10),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        let actor_pos = result.find("handle_supervisor_evt");
        let noise_pos = result.find("recovery_words");
        assert!(
            actor_pos.is_some(),
            "actor-supervision symbol should appear in results: {result}"
        );
        assert!(
            noise_pos.is_none() || actor_pos < noise_pos,
            "multi-term actor match should outrank single-term recovery noise: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_fallback_surfaces_auto_derived_cluster() {
        let content = b"pub struct EventFanoutBus;\npub fn dispatch_delivery_event() {}\n";
        let bus_symbol = SymbolRecord {
            name: "EventFanoutBus".to_string(),
            kind: SymbolKind::Struct,
            depth: 0,
            sort_order: 0,
            byte_range: (0, 23),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let delivery_symbol = SymbolRecord {
            name: "dispatch_delivery_event".to_string(),
            kind: SymbolKind::Function,
            depth: 0,
            sort_order: 1,
            byte_range: (24, content.len() as u32),
            line_range: (1, 1),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let (key, file) = make_file(
            "src/bus/fanout.rs",
            content,
            vec![bus_symbol, delivery_symbol],
        );
        let server = make_server(make_live_index_ready(vec![(key, file)]));

        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "event fanout delivery".to_string(),
                limit: Some(10),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("Auto-derived cluster:"),
            "fallback explore should surface a derived cluster: {result}"
        );
        assert!(
            result.contains("Promoted signals:"),
            "derived cluster should expose promoted symbols: {result}"
        );
        assert!(
            result.contains("EventFanoutBus") || result.contains("dispatch_delivery_event"),
            "derived cluster should promote repo-specific symbol names: {result}"
        );
        assert!(
            result.contains("src/bus/fanout.rs"),
            "derived cluster should surface the seed file it learned from: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_hides_test_scaffolding_for_broad_fallback_query() {
        let code_content =
            b"pub struct AuthenticationMiddleware;\npub fn enforce_authentication_middleware() {}\n";
        let code_symbols = vec![
            SymbolRecord {
                name: "AuthenticationMiddleware".to_string(),
                kind: SymbolKind::Struct,
                depth: 0,
                sort_order: 0,
                byte_range: (0, 36),
                line_range: (0, 0),
                doc_byte_range: None,
                item_byte_range: None,
            },
            SymbolRecord {
                name: "enforce_authentication_middleware".to_string(),
                kind: SymbolKind::Function,
                depth: 0,
                sort_order: 1,
                byte_range: (37, code_content.len() as u32),
                line_range: (1, 1),
                doc_byte_range: None,
                item_byte_range: None,
            },
        ];
        let test_content = b"pub fn test_authentication_middleware_format() {}\n";
        let test_symbol = SymbolRecord {
            name: "test_authentication_middleware_format".to_string(),
            kind: SymbolKind::Function,
            depth: 0,
            sort_order: 0,
            byte_range: (0, test_content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let reason_content = b"pub enum ReasonCode { Authentication, RateLimit }\n";
        let reason_symbol = SymbolRecord {
            name: "ReasonCode".to_string(),
            kind: SymbolKind::Enum,
            depth: 0,
            sort_order: 0,
            byte_range: (0, reason_content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let server = make_server(make_live_index_ready(vec![
            make_file("src/auth/middleware.rs", code_content, code_symbols),
            make_file(
                "src/protocol/format/tests.rs",
                test_content,
                vec![test_symbol],
            ),
            make_file(
                "src/frontend/scanner.rs",
                reason_content,
                vec![reason_symbol],
            ),
        ]));

        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "authentication middleware".to_string(),
                limit: Some(10),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("AuthenticationMiddleware"),
            "real auth middleware symbol should appear in results: {result}"
        );
        assert!(
            !result.contains("test_authentication_middleware_format"),
            "test scaffolding should be hidden by default for broad fallback explore: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_multi_term_fallback_demotes_single_term_code_noise() {
        let auth_content = b"pub fn attach_authentication_middleware() { enforce_guard(); }\n";
        let auth_symbol = SymbolRecord {
            name: "attach_authentication_middleware".to_string(),
            kind: SymbolKind::Function,
            depth: 0,
            sort_order: 0,
            byte_range: (0, auth_content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let noise_content = b"pub struct RateLimitKey;\n";
        let noise_symbol = SymbolRecord {
            name: "RateLimitKey".to_string(),
            kind: SymbolKind::Struct,
            depth: 0,
            sort_order: 0,
            byte_range: (0, noise_content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let server = make_server(make_live_index_ready(vec![
            make_file("src/auth/middleware.rs", auth_content, vec![auth_symbol]),
            make_file("src/rate_limit/key.rs", noise_content, vec![noise_symbol]),
        ]));

        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "authentication middleware guard".to_string(),
                limit: Some(10),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        let auth_pos = result.find("attach_authentication_middleware");
        let noise_pos = result.find("RateLimitKey");
        assert!(
            auth_pos.is_some(),
            "multi-term auth middleware symbol should appear in results: {result}"
        );
        assert!(
            noise_pos.is_none() || auth_pos < noise_pos,
            "multi-term domain hit should outrank single-term code noise: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_module_path_boosting() {
        let content = b"pub struct WatcherInfo {\n    debounce_ms: u64,\n}\n";
        let watcher_sym = SymbolRecord {
            name: "WatcherInfo".to_string(),
            kind: SymbolKind::Struct,
            depth: 0,
            sort_order: 0,
            byte_range: (0, content.len() as u32),
            line_range: (0, 2),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let (key, file) = make_file("src/watcher/mod.rs", content, vec![watcher_sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "watcher".to_string(),
                limit: Some(10),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("WatcherInfo"),
            "WatcherInfo should appear via module-path boosting: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_concept_plus_remainder() {
        let content = b"pub fn handle_watcher_error() {}\n";
        let sym = SymbolRecord {
            name: "handle_watcher_error".to_string(),
            kind: SymbolKind::Function,
            depth: 0,
            sort_order: 0,
            byte_range: (0, content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let (key, file) = make_file("src/watcher/errors.rs", content, vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "error handling in the watcher".to_string(),
                limit: Some(10),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("handle_watcher_error") || result.contains("watcher"),
            "concept+remainder should surface watcher results: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_exact_concept_no_remainder() {
        let content = b"pub enum SymForgeError {}\n";
        let sym = SymbolRecord {
            name: "SymForgeError".to_string(),
            kind: SymbolKind::Enum,
            depth: 0,
            sort_order: 0,
            byte_range: (0, content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let (key, file) = make_file("src/error.rs", content, vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "error handling".to_string(),
                limit: Some(10),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("SymForgeError"),
            "exact concept should find error symbols: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_hides_vendor_noise_by_default() {
        // Vendor file should be filtered out when include_noise is false (default).
        let vendor_content = b"pub fn vendor_func() {}\n";
        let vendor_sym = SymbolRecord {
            name: "vendor_func".to_string(),
            kind: SymbolKind::Function,
            depth: 0,
            sort_order: 0,
            byte_range: (0, vendor_content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let src_content = b"pub fn process_error() {}\n";
        let src_sym = SymbolRecord {
            name: "process_error".to_string(),
            kind: SymbolKind::Function,
            depth: 0,
            sort_order: 0,
            byte_range: (0, src_content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let (vkey, vfile) = make_file("vendor/somelib/lib.rs", vendor_content, vec![vendor_sym]);
        let (skey, sfile) = make_file("src/error.rs", src_content, vec![src_sym]);
        let server = make_server(make_live_index_ready(vec![(vkey, vfile), (skey, sfile)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "error handling".to_string(),
                limit: Some(10),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            !result.contains("vendor_func"),
            "vendor symbol should be hidden by default: {result}"
        );
        assert!(
            result.contains("process_error"),
            "src symbol should still appear: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_includes_vendor_when_include_noise_true() {
        let vendor_content = b"pub struct VendorError {}\n";
        let vendor_sym = SymbolRecord {
            name: "VendorError".to_string(),
            kind: SymbolKind::Struct,
            depth: 0,
            sort_order: 0,
            byte_range: (0, vendor_content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let (vkey, vfile) = make_file("vendor/somelib/lib.rs", vendor_content, vec![vendor_sym]);
        let server = make_server(make_live_index_ready(vec![(vkey, vfile)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "error handling".to_string(),
                limit: Some(10),
                depth: None,
                include_noise: Some(true),
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("VendorError"),
            "vendor symbol should appear with include_noise=true: {result}"
        );
    }

    #[tokio::test]
    async fn test_explore_noise_hint_shown_when_results_hidden() {
        let vendor_content = b"pub struct VendorError {}\n";
        let vendor_sym = SymbolRecord {
            name: "VendorError".to_string(),
            kind: SymbolKind::Struct,
            depth: 0,
            sort_order: 0,
            byte_range: (0, vendor_content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let (vkey, vfile) = make_file("vendor/somelib/lib.rs", vendor_content, vec![vendor_sym]);
        let server = make_server(make_live_index_ready(vec![(vkey, vfile)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "error handling".to_string(),
                limit: Some(10),
                depth: None,
                include_noise: None,
                include_vendor: None,
                include_personal_tooling: None,
                language: None,
                path_prefix: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("vendor/generated files hidden")
                && result.contains("include_noise=true"),
            "should show noise hint when results are hidden: {result}"
        );
    }

    // --- Task 2.2 path-filter tests --------------------------------------

    #[tokio::test]
    async fn test_search_text_hides_vendor_paths_by_default() {
        let server = make_server(make_live_index_ready_with_vendor_file());
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("noisy_token".to_string()),
                ..Default::default()
            }))
            .await;
        assert!(
            !result.contains("vendor/foo/bar.rs"),
            "vendor paths must be suppressed by default; got:\n{result}"
        );
        assert!(
            result.contains("noise-filtered"),
            "filtered-count footer expected; got:\n{result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_includes_vendor_when_flag_true() {
        let server = make_server(make_live_index_ready_with_vendor_file());
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("noisy_token".to_string()),
                include_vendor: Some(true),
                ..Default::default()
            }))
            .await;
        assert!(
            result.contains("vendor/foo/bar.rs"),
            "vendor paths must appear when include_vendor=true; got:\n{result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_vendor_filter_runs_before_result_cap() {
        let vendor_content = b"needle one\nneedle two\nneedle three\n";
        let source_content = b"needle source\n";
        let server = make_server(make_live_index_ready(vec![
            make_file("vendor/noisy/lib.rs", vendor_content, vec![]),
            make_file("src/normal.rs", source_content, vec![]),
        ]));

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("needle".to_string()),
                limit: Some(1),
                ..Default::default()
            }))
            .await;

        assert!(
            result.contains("src/normal.rs"),
            "non-vendor hit must survive even when vendor has more matches: {result}"
        );
        assert!(
            !result.contains("vendor/noisy/lib.rs"),
            "vendor path should remain suppressed by default: {result}"
        );
        assert!(
            result.contains("3 noise-filtered match(es) suppressed"),
            "suppressed vendor matches should be counted: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_suppressed_count_caps_broad_vendor_matches() {
        let mut vendor_content = String::new();
        for index in 0..150 {
            vendor_content.push_str(&format!("needle hidden_{index}\n"));
        }
        let source_content = b"needle source\n";
        let server = make_server(make_live_index_ready(vec![
            make_file("vendor/noisy/lib.rs", vendor_content.as_bytes(), vec![]),
            make_file("src/normal.rs", source_content, vec![]),
        ]));

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("needle".to_string()),
                limit: Some(1),
                ..Default::default()
            }))
            .await;

        assert!(
            result.contains("src/normal.rs"),
            "non-vendor hit must remain visible: {result}"
        );
        assert!(
            result.contains("100+ noise-filtered match(es) suppressed"),
            "broad hidden counts should be capped instead of exact-scanning vendor trees: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_hides_personal_tooling_paths_by_default() {
        let server = make_server(make_live_index_ready_with_vendor_file());
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("noisy_token".to_string()),
                ..Default::default()
            }))
            .await;
        assert!(
            !result.contains(".claude/gsd-local-patches/"),
            "personal tooling paths must be suppressed by default; got:\n{result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_includes_personal_tooling_when_flag_true() {
        let server = make_server(make_live_index_ready_with_vendor_file());
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("noisy_token".to_string()),
                include_personal_tooling: Some(true),
                ..Default::default()
            }))
            .await;
        assert!(
            result.contains(".claude/gsd-local-patches/"),
            "personal tooling paths must appear when include_personal_tooling=true; got:\n{result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_suppression_footer_counts_vendor() {
        let server = make_server(make_live_index_ready_with_vendor_file());
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("noisy_token".to_string()),
                ..Default::default()
            }))
            .await;
        // Fixture seeds 3 files (src + vendor + personal-tooling), each with one
        // match for `noisy_token`. Default-suppress hides 2 (vendor + personal),
        // so the envelope footer must report at least 2 noise-filtered matches.
        assert!(
            result.contains("noise-filtered match(es) suppressed"),
            "footer must report noise-filtered count; got:\n{result}"
        );
        let count_ok = result.contains("2 noise-filtered") || result.contains("3 noise-filtered");
        assert!(
            count_ok,
            "footer must report >=2 suppressed matches (vendor + personal-tooling); got:\n{result}"
        );
    }

    #[tokio::test]
    async fn test_explore_finer_grained_include_vendor_flag() {
        // B1 additive: include_vendor=true alone, include_noise unset, must surface vendor.
        // Mirrors the fixture from test_explore_includes_vendor_when_include_noise_true so
        // the concept-query "error handling" finds `VendorError` via the "Error" token.
        let vendor_content = b"pub struct VendorError {}\n";
        let vendor_sym = SymbolRecord {
            name: "VendorError".to_string(),
            kind: SymbolKind::Struct,
            depth: 0,
            sort_order: 0,
            byte_range: (0, vendor_content.len() as u32),
            line_range: (0, 0),
            doc_byte_range: None,
            item_byte_range: None,
        };
        let (vkey, vfile) = make_file("vendor/somelib/lib.rs", vendor_content, vec![vendor_sym]);
        let server = make_server(make_live_index_ready(vec![(vkey, vfile)]));
        let result = server
            .explore(Parameters(super::ExploreInput {
                query: "error handling".to_string(),
                limit: Some(10),
                include_vendor: Some(true),
                ..Default::default()
            }))
            .await;
        assert!(
            result.contains("VendorError"),
            "include_vendor=true alone must surface vendor (B1 additive); got: {result}"
        );
    }

    // ── INFR-05: No v1 tools in server ──────────────────────────────────────

    #[test]
    fn test_retired_v1_tools_stay_out_of_server() {
        // Build the tool list by inspecting what tool_router() generates
        let server = make_server(make_live_index_ready(vec![]));
        let router = server.tool_router.clone();
        let tool_names: Vec<String> = router
            .list_all()
            .iter()
            .map(|t| t.name.to_string())
            .collect();

        assert!(
            tool_names.iter().any(|name| name == "checkpoint_now"),
            "checkpoint_now is a current v7 checkpoint tool and must remain registered; found: {tool_names:?}"
        );

        let retired_v1_tools = [
            "cancel_index_run",
            "resume_index_run",
            "get_provenance",
            "get_trust",
            "verify_chunk",
        ];

        for v1_tool in &retired_v1_tools {
            assert!(
                !tool_names.iter().any(|n| n == v1_tool),
                "v1 tool '{v1_tool}' must not appear in server tool list (INFR-05); found: {tool_names:?}"
            );
        }
    }

    #[test]
    fn test_tools_registered_count_is_stable() {
        let server = make_server(make_live_index_ready(vec![]));
        let tool_count = server.tool_router.list_all().len();
        // Sanity check: we should have a reasonable number of tools.
        // Update this lower bound when removing tools; it prevents accidental regressions.
        assert!(
            tool_count >= 24,
            "server should expose at least 24 tools; found {tool_count}"
        );
    }

    #[tokio::test]
    async fn test_trace_symbol_delegates_to_formatter() {
        let target = make_file(
            "src/lib.rs",
            b"fn process() {}\n",
            vec![make_symbol("process", SymbolKind::Function, 1, 1)],
        );
        let server = make_server(make_live_index_ready(vec![target]));

        let result = server
            .trace_symbol(Parameters(super::TraceSymbolInput {
                path: "src/lib.rs".to_string(),
                name: "process".to_string(),
                kind: None,
                symbol_line: None,
                sections: None,
                verbosity: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(result.contains("fn process"), "got: {result}");
        assert!(result.contains("Callers (0)"), "got: {result}");
    }

    #[tokio::test]
    async fn test_get_symbol_context_trace_mode() {
        let target = make_file(
            "src/lib.rs",
            b"fn process() {}\n",
            vec![make_symbol("process", SymbolKind::Function, 1, 1)],
        );
        let server = make_server(make_live_index_ready(vec![target]));

        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "process".to_string(),
                file: None,
                path: Some("src/lib.rs".to_string()),
                symbol_kind: None,
                symbol_line: None,
                verbosity: None,
                bundle: None,
                sections: Some(vec!["dependents".to_string()]),
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("fn process"),
            "trace mode should show definition, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_symbol_context_trace_mode_requires_path() {
        let target = make_file(
            "src/lib.rs",
            b"fn process() {}\n",
            vec![make_symbol("process", SymbolKind::Function, 1, 1)],
        );
        let server = make_server(make_live_index_ready(vec![target]));

        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "process".to_string(),
                file: None,
                path: None,
                symbol_kind: None,
                symbol_line: None,
                verbosity: None,
                bundle: None,
                sections: Some(vec![]),
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("Error: sections requires"),
            "should require path, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_inspect_match_delegates_to_formatter() {
        let target = make_file(
            "src/lib.rs",
            b"fn process() {\n    let x = 1;\n}\n",
            vec![make_symbol("process", SymbolKind::Function, 0, 2)],
        );
        let server = make_server(make_live_index_ready(vec![target]));

        let result = server
            .inspect_match(Parameters(super::InspectMatchInput {
                path: "src/lib.rs".to_string(),
                line: 2,
                context: None,
                sibling_limit: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        // Verify excerpt
        assert!(result.contains("2:     let x = 1;"), "got: {result}");
        // Verify enclosing symbol (line_range is 0-based internally, displayed as 1-based)
        assert!(
            result.contains("Enclosing symbol: fn process (lines 1-3)"),
            "got: {result}"
        );
    }

    #[tokio::test]
    async fn test_inspect_match_default_sibling_limit_caps_at_10() {
        // Build a file with 15 top-level functions (depth 0).
        let mut syms = Vec::new();
        for i in 0u32..15 {
            syms.push(make_symbol(&format!("fn_{i}"), SymbolKind::Function, i, i));
        }
        let content = (0u32..15)
            .map(|i| format!("fn fn_{i}() {{}}"))
            .collect::<Vec<_>>()
            .join("\n");
        let target = make_file("src/lib.rs", content.as_bytes(), syms);
        let server = make_server(make_live_index_ready(vec![target]));

        // No sibling_limit — defaults to 10.
        let result = server
            .inspect_match(Parameters(super::InspectMatchInput {
                path: "src/lib.rs".to_string(),
                line: 1,
                context: Some(0),
                sibling_limit: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        // Count sibling rows (each rendered with two-space indent).
        let sibling_rows = result
            .lines()
            .filter(|l| l.starts_with("  ") && !l.starts_with("  ..."))
            .count();
        assert!(
            sibling_rows <= 10,
            "expected ≤10 siblings, got {sibling_rows}; output:\n{result}"
        );
        assert!(
            result.contains("... and 5 more siblings"),
            "expected overflow hint; got:\n{result}"
        );
    }

    #[tokio::test]
    async fn test_inspect_match_sibling_limit_5_shows_exactly_5() {
        let mut syms = Vec::new();
        for i in 0u32..12 {
            syms.push(make_symbol(&format!("fn_{i}"), SymbolKind::Function, i, i));
        }
        let content = (0u32..12)
            .map(|i| format!("fn fn_{i}() {{}}"))
            .collect::<Vec<_>>()
            .join("\n");
        let target = make_file("src/lib.rs", content.as_bytes(), syms);
        let server = make_server(make_live_index_ready(vec![target]));

        let result = server
            .inspect_match(Parameters(super::InspectMatchInput {
                path: "src/lib.rs".to_string(),
                line: 1,
                context: Some(0),
                sibling_limit: Some(5),
                estimate: None,
                max_tokens: None,
            }))
            .await;

        let sibling_rows = result
            .lines()
            .filter(|l| l.starts_with("  ") && !l.starts_with("  ..."))
            .count();
        assert_eq!(
            sibling_rows, 5,
            "expected exactly 5 siblings; output:\n{result}"
        );
        assert!(
            result.contains("... and 7 more siblings"),
            "expected overflow hint; got:\n{result}"
        );
    }

    #[tokio::test]
    async fn test_inspect_match_sibling_limit_0_hides_siblings() {
        let mut syms = Vec::new();
        for i in 0u32..5 {
            syms.push(make_symbol(&format!("fn_{i}"), SymbolKind::Function, i, i));
        }
        let content = (0u32..5)
            .map(|i| format!("fn fn_{i}() {{}}"))
            .collect::<Vec<_>>()
            .join("\n");
        let target = make_file("src/lib.rs", content.as_bytes(), syms);
        let server = make_server(make_live_index_ready(vec![target]));

        let result = server
            .inspect_match(Parameters(super::InspectMatchInput {
                path: "src/lib.rs".to_string(),
                line: 1,
                context: Some(0),
                sibling_limit: Some(0),
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            !result.contains("Nearby siblings:"),
            "siblings should be hidden; got:\n{result}"
        );
    }

    #[tokio::test]
    async fn test_get_repo_map_tree_returns_tree() {
        let sym = make_symbol("main", SymbolKind::Function, 1, 5);
        let (key, file) = make_file("src/main.rs", b"fn main() {}", vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .get_repo_map(Parameters(super::GetRepoMapInput {
                detail: Some("tree".to_string()),
                path: None,
                depth: None,
                max_files: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("main.rs"),
            "get_repo_map(tree) should include file name; got: {result}"
        );
        assert!(
            result.contains("symbol"),
            "get_repo_map(tree) should show symbol count; got: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_repo_map_tree_loading_guard_empty() {
        let server = make_server(make_live_index_empty());
        let result = server
            .get_repo_map(Parameters(super::GetRepoMapInput {
                detail: Some("tree".to_string()),
                path: None,
                depth: None,
                max_files: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, crate::protocol::format::empty_guard_message());
    }

    #[tokio::test]
    async fn test_find_references_loading_guard_empty() {
        let server = make_server(make_live_index_empty());
        let result = server
            .find_references(Parameters(super::FindReferencesInput {
                name: "process".to_string(),
                kind: None,
                path: None,
                symbol_kind: None,
                symbol_line: None,
                limit: None,
                max_per_file: None,
                compact: None,
                mode: None,
                direction: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, crate::protocol::format::empty_guard_message());
    }

    #[tokio::test]
    async fn test_find_dependents_loading_guard_empty() {
        let server = make_server(make_live_index_empty());
        let result = server
            .find_dependents(Parameters(super::FindDependentsInput {
                path: "src/lib.rs".to_string(),
                name: None,
                limit: None,
                max_per_file: None,
                format: None,
                compact: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert_eq!(result, crate::protocol::format::empty_guard_message());
    }

    #[tokio::test]
    async fn test_get_symbol_context_bundle_loading_guard_empty() {
        let server = make_server(make_live_index_empty());
        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "process".to_string(),
                file: None,
                path: Some("src/lib.rs".to_string()),
                symbol_kind: None,
                symbol_line: None,
                verbosity: None,
                bundle: Some(true),
                sections: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;
        assert_eq!(result, crate::protocol::format::empty_guard_message());
    }

    #[tokio::test]
    async fn test_get_symbol_context_bundle_delegates_to_formatter() {
        let server = make_server(make_live_index_ready(vec![]));
        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "process".to_string(),
                file: None,
                path: Some("src/nonexistent.rs".to_string()),
                symbol_kind: None,
                symbol_line: None,
                verbosity: None,
                bundle: Some(true),
                sections: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;
        assert!(result.contains("File not found"), "got: {result}");
    }

    #[tokio::test]
    async fn test_get_symbol_context_bundle_exact_selector_uses_line_and_exact_callers() {
        let target = make_file(
            "src/db.rs",
            b"fn connect() { first(); }\nfn connect() { second(); }\n",
            vec![
                make_symbol("connect", SymbolKind::Function, 1, 1),
                make_symbol("connect", SymbolKind::Function, 2, 2),
            ],
        );
        let dependent = make_file_with_refs(
            "src/service.rs",
            b"use crate::db::connect;\nfn run() { connect(); }\n",
            vec![make_symbol("run", SymbolKind::Function, 2, 2)],
            vec![
                make_ref("db", Some("crate::db"), ReferenceKind::Import, 0, None),
                make_ref(
                    "connect",
                    Some("crate::db::connect"),
                    ReferenceKind::Call,
                    1,
                    Some(0),
                ),
            ],
        );
        let unrelated = make_file_with_refs(
            "src/other.rs",
            b"fn run() { connect(); }\n",
            vec![make_symbol("run", SymbolKind::Function, 1, 1)],
            vec![make_ref("connect", None, ReferenceKind::Call, 0, Some(0))],
        );
        let server = make_server(make_live_index_ready(vec![target, dependent, unrelated]));

        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "connect".to_string(),
                file: None,
                path: Some("src/db.rs".to_string()),
                symbol_kind: Some("fn".to_string()),
                symbol_line: Some(2),
                verbosity: None,
                bundle: Some(true),
                sections: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("src/service.rs"),
            "expected dependent hit: {result}"
        );
        assert!(
            result.contains("Trust: exact | current index | parsed"),
            "bundle mode should surface the compact trust line; got: {result}"
        );
        assert!(
            result.contains("Scope: path `src/db.rs`; bundle mode"),
            "bundle mode should surface scope; got: {result}"
        );
        assert!(
            result.contains("Evidence: symbol anchor `src/db.rs:"),
            "bundle mode should surface evidence anchor; got: {result}"
        );
        assert!(
            !result.contains("src/other.rs"),
            "unrelated same-name file should be excluded: {result}"
        );
    }

    #[tokio::test]
    async fn test_get_symbol_context_bundle_exact_selector_requires_line_for_ambiguous_symbol() {
        let target = make_file(
            "src/db.rs",
            b"fn connect() {}\nfn connect() {}\n",
            vec![
                make_symbol("connect", SymbolKind::Function, 1, 1),
                make_symbol("connect", SymbolKind::Function, 2, 2),
            ],
        );
        let server = make_server(make_live_index_ready(vec![target]));

        let result = server
            .get_symbol_context(Parameters(super::GetSymbolContextInput {
                name: "connect".to_string(),
                file: None,
                path: Some("src/db.rs".to_string()),
                symbol_kind: Some("fn".to_string()),
                symbol_line: None,
                verbosity: None,
                bundle: Some(true),
                sections: None,
                max_tokens: None,
                estimate: None,
            }))
            .await;

        assert!(
            result.contains("Ambiguous symbol selector"),
            "got: {result}"
        );
        assert!(result.contains("1"), "got: {result}");
        assert!(result.contains("2"), "got: {result}");
    }

    #[tokio::test]
    async fn test_find_references_delegates_to_formatter() {
        let server = make_server(make_live_index_ready(vec![]));
        let result = server
            .find_references(Parameters(super::FindReferencesInput {
                name: "nonexistent_xyz".to_string(),
                kind: None,
                path: None,
                symbol_kind: None,
                symbol_line: None,
                limit: None,
                max_per_file: None,
                compact: None,
                mode: None,
                direction: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        // Should get "No references found" not a guard message
        assert!(result.contains("No references found"), "got: {result}");
    }

    #[tokio::test]
    async fn test_find_references_exact_selector_excludes_unrelated_same_name_hits() {
        let target = make_file(
            "src/db.rs",
            b"pub fn connect() {}\n",
            vec![make_symbol("connect", SymbolKind::Function, 1, 1)],
        );
        let dependent = make_file_with_refs(
            "src/service.rs",
            b"use crate::db::connect;\nfn run() { connect(); }\n",
            vec![make_symbol("run", SymbolKind::Function, 2, 2)],
            vec![
                make_ref("db", Some("crate::db"), ReferenceKind::Import, 0, None),
                make_ref(
                    "connect",
                    Some("crate::db::connect"),
                    ReferenceKind::Call,
                    1,
                    Some(0),
                ),
            ],
        );
        let unrelated = make_file_with_refs(
            "src/other.rs",
            b"fn run() { connect(); }\n",
            vec![make_symbol("run", SymbolKind::Function, 1, 1)],
            vec![make_ref("connect", None, ReferenceKind::Call, 0, Some(0))],
        );
        let server = make_server(make_live_index_ready(vec![target, dependent, unrelated]));

        let result = server
            .find_references(Parameters(super::FindReferencesInput {
                name: "connect".to_string(),
                kind: Some("call".to_string()),
                path: Some("src/db.rs".to_string()),
                symbol_kind: Some("fn".to_string()),
                symbol_line: Some(2),
                limit: None,
                max_per_file: None,
                compact: None,
                mode: None,
                direction: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("src/service.rs"),
            "expected dependent hit: {result}"
        );
        assert!(
            !result.contains("src/other.rs"),
            "unrelated same-name file should be excluded: {result}"
        );
        assert!(result.contains("Trust: exact"), "got: {result}");
        assert!(
            result.contains("Scope: path `src/db.rs`; exact selector line 2; symbol kind `fn`; reference kind `call`"),
            "exact selector scope should be explicit: {result}"
        );
        assert!(
            result.contains("Evidence: reference anchors `src/service.rs:2`"),
            "reference output should include evidence anchors: {result}"
        );
    }

    #[tokio::test]
    async fn test_find_references_exact_selector_requires_line_for_ambiguous_symbol() {
        let target = make_file(
            "src/db.rs",
            b"fn connect() {}\nfn connect() {}\n",
            vec![
                make_symbol("connect", SymbolKind::Function, 1, 1),
                make_symbol("connect", SymbolKind::Function, 10, 10),
            ],
        );
        let server = make_server(make_live_index_ready(vec![target]));

        let result = server
            .find_references(Parameters(super::FindReferencesInput {
                name: "connect".to_string(),
                kind: Some("call".to_string()),
                path: Some("src/db.rs".to_string()),
                symbol_kind: Some("fn".to_string()),
                symbol_line: None,
                limit: None,
                max_per_file: None,
                compact: None,
                mode: None,
                direction: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("Ambiguous symbol selector"),
            "got: {result}"
        );
        assert!(result.contains("1"), "got: {result}");
        assert!(result.contains("10"), "got: {result}");
    }

    #[tokio::test]
    async fn test_find_dependents_delegates_to_formatter() {
        let server = make_server(make_live_index_ready(vec![]));
        let result = server
            .find_dependents(Parameters(super::FindDependentsInput {
                path: "src/nonexistent.rs".to_string(),
                name: None,
                limit: None,
                max_per_file: None,
                format: None,
                compact: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("No file-level dependents found"),
            "got: {result}"
        );
        assert!(result.contains("find_references"), "got: {result}");
    }

    #[tokio::test]
    async fn test_find_dependents_symbol_shaped_query_redirects_to_find_references() {
        // L1: passing a `name` alongside `path` is a symbol-level query. The
        // handler must redirect to find_references rather than silently return a
        // file-level import graph that reads like symbol callers.
        let server = make_server(make_live_index_ready(vec![]));
        let result = server
            .find_dependents(Parameters(super::FindDependentsInput {
                path: "src/lib.rs".to_string(),
                name: Some("my_symbol".to_string()),
                limit: None,
                max_per_file: None,
                format: None,
                compact: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("file-level only"),
            "redirect must explain find_dependents is file-level, got: {result}"
        );
        assert!(
            result.contains("find_references(name=\"my_symbol\")"),
            "redirect must point at find_references for the symbol, got: {result}"
        );
        // Must NOT have produced a file-dependency graph header.
        assert!(
            !result.contains("No file-level dependents found"),
            "symbol-shaped call must not fall through to the file graph, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_find_dependents_blank_name_still_runs_file_graph() {
        // A blank/whitespace `name` is not a real symbol query — the file-level
        // path must still run normally rather than redirecting.
        let server = make_server(make_live_index_ready(vec![]));
        let result = server
            .find_dependents(Parameters(super::FindDependentsInput {
                path: "src/nonexistent.rs".to_string(),
                name: Some("   ".to_string()),
                limit: None,
                max_per_file: None,
                format: None,
                compact: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            !result.contains("file-level only"),
            "blank name must not trigger the symbol redirect, got: {result}"
        );
        assert!(
            result.contains("No file-level dependents found"),
            "blank name must still run the file-level graph, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_find_dependents_mermaid_includes_symbol_names_in_edges() {
        // dep_file imports from src/target.rs (stem "target") and uses TargetType.
        // find_dependents_for_file should prefer the symbol-level TypeUsage ref,
        // so the mermaid edge label should include the symbol name "TargetType".
        let target_sym = make_symbol("TargetType", SymbolKind::Struct, 0, 2);
        let import_ref = make_ref("target", None, ReferenceKind::Import, 0, None);
        let usage_ref = make_ref("TargetType", None, ReferenceKind::TypeUsage, 1, Some(0));
        let dep_sym = make_symbol("consumer", SymbolKind::Function, 0, 1);
        let target_file = make_file(
            "src/target.rs",
            b"pub struct TargetType {}\n",
            vec![target_sym],
        );
        let dep_file = make_file_with_refs(
            "src/dep.rs",
            b"use target::TargetType;\nfn consumer() { TargetType }\n",
            vec![dep_sym],
            vec![import_ref, usage_ref],
        );
        let server = make_server(make_live_index_ready(vec![target_file, dep_file]));

        let result = server
            .find_dependents(Parameters(super::FindDependentsInput {
                path: "src/target.rs".to_string(),
                name: None,
                compact: None,
                format: Some("mermaid".to_string()),
                limit: None,
                max_per_file: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("TargetType"),
            "mermaid edge should include symbol name: {result}"
        );
    }

    #[tokio::test]
    async fn test_find_dependents_excludes_non_pub_name_collision() {
        // target.rs defines a non-pub `run` function.
        // other.rs imports from target (triggering the symbol_refs path) AND calls
        // its own local `run` — same name, different function.
        // Without the pub filter, the "run" Call ref would be falsely attributed to
        // target.rs's symbol. With the pub filter, symbol_refs is empty and the
        // result falls back to the import ref — "run" must not appear as attributed.
        let target_sym = make_symbol("run", SymbolKind::Function, 0, 1);
        let target_file = make_file(
            "src/target.rs",
            b"fn run() { internal }\n",
            vec![target_sym],
        );

        let other_sym = make_symbol("main", SymbolKind::Function, 2, 4);
        let other_import = make_ref(
            "target",
            Some("crate::target"),
            ReferenceKind::Import,
            0,
            None,
        );
        let other_call = make_ref("run", None, ReferenceKind::Call, 3, Some(0));
        let other_file = make_file_with_refs(
            "src/other.rs",
            b"use crate::target;\nfn run() {}\nfn main() {\n    run();\n}\n",
            vec![other_sym],
            vec![other_import, other_call],
        );

        let server = make_server(make_live_index_ready(vec![target_file, other_file]));

        let result = server
            .find_dependents(Parameters(super::FindDependentsInput {
                path: "src/target.rs".to_string(),
                name: None,
                compact: None,
                format: None,
                limit: None,
                max_per_file: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        // other.rs still appears as a dependent (via the import), but the
        // non-pub "run" symbol must NOT be surfaced as an attributed reference.
        assert!(
            result.contains("src/other.rs"),
            "other.rs should appear via the import ref: {result}"
        );
        assert!(
            !result.contains("run"),
            "non-pub 'run' collision must not be attributed as a symbol reference: {result}"
        );
    }

    #[tokio::test]
    async fn test_find_dependents_includes_pub_symbol_references() {
        let target_sym = make_symbol("PublicApi", SymbolKind::Struct, 0, 1);
        let target_file = make_file(
            "src/target.rs",
            b"pub struct PublicApi {}\n",
            vec![target_sym],
        );

        let consumer_sym = make_symbol("use_it", SymbolKind::Function, 1, 3);
        let consumer_import = ReferenceRecord {
            name: "target".to_string(),
            qualified_name: Some("crate::target".to_string()),
            kind: ReferenceKind::Import,
            byte_range: (0, 20),
            line_range: (0, 0),
            enclosing_symbol_index: None,
        };
        let consumer_ref = make_ref("PublicApi", None, ReferenceKind::TypeUsage, 2, Some(0));
        let consumer_file = make_file_with_refs(
            "src/consumer.rs",
            b"use crate::target;\nfn use_it() {\n    PublicApi {}\n}\n",
            vec![consumer_sym],
            vec![consumer_import, consumer_ref],
        );

        let server = make_server(make_live_index_ready(vec![target_file, consumer_file]));

        let result = server
            .find_dependents(Parameters(super::FindDependentsInput {
                path: "src/target.rs".to_string(),
                name: None,
                compact: None,
                format: None,
                limit: None,
                max_per_file: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("src/consumer.rs"),
            "pub symbol with import should be a real dependent: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_symbols_rejects_empty_query() {
        let sym = make_symbol("Foo", SymbolKind::Class, 1, 3);
        let (key, file) = make_file("src/lib.rs", b"struct Foo {}", vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));

        // Empty/whitespace query with no kind or path_prefix is fully unbounded — must be rejected
        for query in ["", "   ", "\t"] {
            let result = server
                .search_symbols(Parameters(super::SearchSymbolsInput {
                    query: Some(query.to_string()),
                    kind: None,
                    path_prefix: None,
                    language: None,
                    limit: None,
                    include_generated: None,
                    include_tests: None,
                    include_vendor: None,
                    include_personal_tooling: None,
                    estimate: None,
                    max_tokens: None,
                }))
                .await;
            assert!(
                result.contains("requires at least one of"),
                "empty query '{query}' with no kind/path_prefix should be rejected, got: {result}"
            );
        }
    }

    #[tokio::test]
    async fn test_inspect_match_out_of_bounds_line() {
        let sym = make_symbol("foo", SymbolKind::Function, 0, 0);
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}\n", vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));

        let result = server
            .inspect_match(Parameters(super::InspectMatchInput {
                path: "src/lib.rs".to_string(),
                line: 999999,
                context: None,
                sibling_limit: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("out of bounds"),
            "should report out of bounds, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_shows_enclosing_symbol() {
        let sym = make_symbol("handle_request", SymbolKind::Function, 0, 2);
        let content = b"fn handle_request() {\n    let db = connect();\n}\n";
        let (key, file) = make_file("src/handler.rs", content, vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("connect".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;
        assert!(
            result.contains("handle_request"),
            "should show enclosing symbol name, got: {result}"
        );
        assert!(
            result.contains("in fn handle_request"),
            "should show kind and name, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_uses_innermost_enclosing_symbol_for_nested_items() {
        let outer = make_symbol("outer", SymbolKind::Function, 0, 5);
        let inner = make_symbol("inner", SymbolKind::Function, 2, 4);
        let content = b"fn outer() {\n    let target_marker = \"outer\";\n    fn inner() {\n        let target_marker = \"inner\";\n    }\n}\n";
        let (key, file) = make_file("src/nested.rs", content, vec![inner, outer]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));

        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("target_marker".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("in fn outer (lines 1-6):"),
            "outer-item match should still resolve to outer: {result}"
        );
        assert!(
            result.contains("    > 2:     let target_marker = \"outer\";"),
            "outer-item match line missing: {result}"
        );
        assert!(
            result.contains("in fn inner (lines 3-5):"),
            "nested-item match should resolve to innermost symbol: {result}"
        );
        assert!(
            result.contains("    > 4:         let target_marker = \"inner\";"),
            "inner-item match line missing: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_group_by_symbol_deduplicates() {
        let sym = make_symbol("connect", SymbolKind::Function, 0, 4);
        let content = b"fn connect() {\n    let url = db_url();\n    let pool = Pool::new(url);\n    pool.connect()\n}\n";
        let (key, file) = make_file("src/db.rs", content, vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("pool".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: Some("symbol".to_string()),
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;
        // With group_by: "symbol", should show symbol name and match count
        assert!(
            result.contains("connect"),
            "should show symbol name: {result}"
        );
        assert!(
            result.contains("2 matches") || result.contains("match"),
            "should show match count: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_group_by_names_returns_flat_unique_symbol_names() {
        let fetch_user = make_symbol("fetch_user", SymbolKind::Function, 0, 3);
        let fetch_team = make_symbol("fetch_team", SymbolKind::Function, 4, 6);
        let content = b"fn fetch_user() {\n    let needle = 1;\n    let needle = 2;\n}\nfn fetch_team() {\n    let needle = 3;\n}\n";
        let (key, file) = make_file("src/search.rs", content, vec![fetch_user, fetch_team]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("needle".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: Some("names".to_string()),
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        assert!(
            result.contains("3 matches in 1 files"),
            "header should keep match/file evidence: {result}"
        );
        assert_eq!(
            result.matches("fetch_user").count(),
            1,
            "fetch_user should be listed once despite two matching lines: {result}"
        );
        assert_eq!(
            result.matches("fetch_team").count(),
            1,
            "fetch_team should be listed once: {result}"
        );
        assert!(
            !result.contains("let needle"),
            "names mode should not echo source lines: {result}"
        );
        assert!(
            !result.contains("in fn"),
            "names mode should be a flat name list, not grouped source snippets: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_group_by_usage_filters_imports() {
        let content = b"use crate::db::connect;\nfn handler() { connect() }\n";
        let sym = make_symbol("handler", SymbolKind::Function, 1, 1);
        let (key, file) = make_file("src/api.rs", content, vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("connect".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: Some("usage".to_string()),
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;
        // Should exclude the "use" import line
        assert!(
            !result.contains("use crate"),
            "should filter out imports: {result}"
        );
        assert!(
            result.contains("handler"),
            "should keep usage matches: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_group_by_usage_keeps_doc_comments_visible() {
        let content = b"// usage_token in ordinary comment\n/// usage_token in doc comment\nfn handler() {\n    let _value = usage_token();\n}\n";
        let sym = make_symbol("handler", SymbolKind::Function, 1, 4);
        let (key, file) = make_file("src/api.rs", content, vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("usage_token".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: Some("usage".to_string()),
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        // SFB07 decision: KEEP_DOC_MARKDOWN_USAGE_VISIBLE. Usage grouping filters
        // ordinary line-noise comments, but doc comments remain searchable context.
        assert!(
            !result.contains("// usage_token in ordinary comment"),
            "ordinary comments should stay filtered by usage grouping: {result}"
        );
        assert!(
            result.contains("/// usage_token in doc comment"),
            "doc comments should remain visible usage context: {result}"
        );
        assert!(
            result.contains("let _value = usage_token();"),
            "code usage should remain visible: {result}"
        );
        assert!(
            result.contains("1 import/comment match(es) excluded by usage filter"),
            "filtered ordinary comment count should be explicit: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_group_by_usage_keeps_markdown_body_visible() {
        let content = b"# usage_token heading\n\nusage_token appears in Markdown body\n";
        let (key, mut file) = make_file("README.md", content, vec![]);
        file.language = LanguageId::Markdown;
        let server = make_server(make_live_index_ready(vec![(key, file)]));
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("usage_token".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: Some("usage".to_string()),
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;

        // SFB07 decision: KEEP_DOC_MARKDOWN_USAGE_VISIBLE. Markdown body text remains
        // visible; heading lines keep the existing hash-comment noise filtering.
        assert!(
            result.contains("README.md"),
            "markdown file should be searched: {result}"
        );
        assert!(
            result.contains("usage_token appears in Markdown body"),
            "markdown body text should remain usage-visible: {result}"
        );
        assert!(
            !result.contains("# usage_token heading"),
            "hash-heading lines keep existing comment-like usage filtering: {result}"
        );
        assert!(
            result.contains("1 import/comment match(es) excluded by usage filter"),
            "filtered markdown heading count should be explicit: {result}"
        );
    }

    #[tokio::test]
    async fn test_inspect_match_line_zero_is_out_of_bounds() {
        let sym = make_symbol("foo", SymbolKind::Function, 0, 0);
        let (key, file) = make_file("src/lib.rs", b"fn foo() {}\n", vec![sym]);
        let server = make_server(make_live_index_ready(vec![(key, file)]));

        let result = server
            .inspect_match(Parameters(super::InspectMatchInput {
                path: "src/lib.rs".to_string(),
                line: 0,
                context: None,
                sibling_limit: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;
        assert!(
            result.contains("out of bounds"),
            "line 0 should be out of bounds (1-based), got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_follow_refs_includes_callers() {
        // Build an index with cross-references
        let sym_a = make_symbol("connect", SymbolKind::Function, 0, 1);
        let file_a_content = b"fn connect() {\n    db_open()\n}\n";
        let (key_a, file_a) = make_file("src/db.rs", file_a_content, vec![sym_a]);

        let sym_b = make_symbol("handler", SymbolKind::Function, 0, 1);
        let file_b_content = b"fn handler() {\n    connect()\n}\n";
        let (key_b, file_b) = make_file_with_refs(
            "src/api.rs",
            file_b_content,
            vec![sym_b],
            vec![make_ref("connect", None, ReferenceKind::Call, 1, Some(0))],
        );

        let server = make_server(make_live_index_ready(vec![
            (key_a, file_a),
            (key_b, file_b),
        ]));
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("db_open".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: Some(true),
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;
        // Should show that connect() is called by handler() in src/api.rs
        assert!(
            result.contains("handler") || result.contains("api.rs"),
            "should show callers of enclosing symbol, got: {result}"
        );
        assert!(
            result.contains("Called by"),
            "should have Called by section, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_search_text_follow_refs_empty_callers_signal() {
        // A file with a function that has no callers anywhere
        let sym_a = make_symbol("orphan_fn", SymbolKind::Function, 0, 2);
        let file_a_content = b"fn orphan_fn() {\n    do_work()\n}\n";
        let (key_a, file_a) = make_file("src/orphan.rs", file_a_content, vec![sym_a]);

        let server = make_server(make_live_index_ready(vec![(key_a, file_a)]));
        let result = server
            .search_text(Parameters(super::SearchTextInput {
                query: Some("do_work".to_string()),
                terms: None,
                regex: None,
                path_prefix: None,
                language: None,
                limit: None,
                max_per_file: None,
                include_generated: None,
                include_tests: None,
                glob: None,
                exclude_glob: None,
                context: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: Some(true),
                follow_refs_limit: None,
                ranked: None,
                estimate: None,
                max_tokens: None,
                structural: None,
                include_vendor: None,
                include_personal_tooling: None,
            }))
            .await;
        // follow_refs ran but found no cross-references — should signal this explicitly
        assert!(
            result.contains("no cross-references found"),
            "should show empty-callers signal when follow_refs found nothing, got: {result}"
        );
        assert!(
            !result.contains("Called by"),
            "should not show 'Called by' when callers list is empty, got: {result}"
        );
    }

    // ── Lenient deserialization tests ────────────────────────────────────

    #[test]
    fn test_lenient_u32_accepts_string() {
        let json = r#"{"query":"test","limit":"10"}"#;
        let input: super::SearchFilesInput = serde_json::from_str(json).unwrap();
        assert_eq!(input.limit, Some(10));
    }

    #[test]
    fn test_lenient_u32_accepts_number() {
        let json = r#"{"query":"test","limit":10}"#;
        let input: super::SearchFilesInput = serde_json::from_str(json).unwrap();
        assert_eq!(input.limit, Some(10));
    }

    #[test]
    fn test_lenient_u32_accepts_null() {
        let json = r#"{"query":"test","limit":null}"#;
        let input: super::SearchFilesInput = serde_json::from_str(json).unwrap();
        assert_eq!(input.limit, None);
    }

    #[test]
    fn test_lenient_u32_accepts_absent() {
        let json = r#"{"query":"test"}"#;
        let input: super::SearchFilesInput = serde_json::from_str(json).unwrap();
        assert_eq!(input.limit, None);
    }

    #[test]
    fn test_lenient_bool_accepts_string_true() {
        let json = r#"{"uncommitted":"true"}"#;
        let input: super::WhatChangedInput = serde_json::from_str(json).unwrap();
        assert_eq!(input.uncommitted, Some(true));
    }

    #[test]
    fn test_lenient_bool_accepts_string_false() {
        let json = r#"{"uncommitted":"false"}"#;
        let input: super::WhatChangedInput = serde_json::from_str(json).unwrap();
        assert_eq!(input.uncommitted, Some(false));
    }

    #[test]
    fn test_lenient_bool_accepts_native_bool() {
        let json = r#"{"uncommitted":true}"#;
        let input: super::WhatChangedInput = serde_json::from_str(json).unwrap();
        assert_eq!(input.uncommitted, Some(true));
    }

    #[test]
    fn test_lenient_u32_required_accepts_string() {
        let json = r#"{"path":"src/lib.rs","line":"42"}"#;
        let input: super::InspectMatchInput = serde_json::from_str(json).unwrap();
        assert_eq!(input.line, 42);
    }

    #[test]
    fn test_lenient_u32_required_accepts_number() {
        let json = r#"{"path":"src/lib.rs","line":42}"#;
        let input: super::InspectMatchInput = serde_json::from_str(json).unwrap();
        assert_eq!(input.line, 42);
    }

    #[test]
    fn test_lenient_depth_accepts_string() {
        let json = r#"{"detail":"tree","depth":"1"}"#;
        let input: super::GetRepoMapInput = serde_json::from_str(json).unwrap();
        assert_eq!(input.depth, Some(1));
    }

    #[tokio::test]
    async fn test_analyze_file_impact_co_changes_returns_loading_message_when_no_git_data() {
        // With an empty index (no git temporal data computed), the tool with include_co_changes
        // should return the "still loading" or "unavailable" message in the co-changes section.
        let server = make_server(make_live_index_empty());
        let result = server
            .analyze_file_impact(Parameters(super::AnalyzeFileImpactInput {
                path: "src/lib.rs".to_string(),
                new_file: None,
                include_co_changes: Some(true),
                co_changes_limit: None,
                estimate: None,
            }))
            .await;
        // Git temporal starts as Pending in tests (no tokio runtime spawns it) — but the main
        // impact analysis uses the loading guard and returns early, so the co-changes append
        // won't be reached. The loading guard message is returned instead.
        assert!(
            result.contains("still loading")
                || result.contains("unavailable")
                || result == crate::protocol::format::empty_guard_message(),
            "expected loading/unavailable/guard message, got: {result}"
        );
    }

    // ─── Edit tool integration tests ─────────────────────────────────────────

    /// Helper: write a file to disk and build a server with it indexed.
    fn setup_edit_test(original: &[u8]) -> (TempDir, SymForgeServer, PathBuf) {
        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir(dir.path().join(".git")).unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();
        let file_path = src_dir.join("lib.rs");
        std::fs::write(&file_path, original).unwrap();

        let result = crate::parsing::process_file("src/lib.rs", original, LanguageId::Rust);
        let indexed = IndexedFile::from_parse_result(result, original.to_vec());
        let index = make_live_index_ready(vec![("src/lib.rs".to_string(), indexed)]);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));
        (dir, server, file_path)
    }

    fn replace_symbol_body_input_from_json(
        value: serde_json::Value,
    ) -> crate::protocol::edit::ReplaceSymbolBodyInput {
        serde_json::from_value(value).expect("valid replace_symbol_body input")
    }

    fn delete_symbol_input_from_json(
        value: serde_json::Value,
    ) -> crate::protocol::edit::DeleteSymbolInput {
        serde_json::from_value(value).expect("valid delete_symbol input")
    }

    fn edit_within_symbol_input_from_json(
        value: serde_json::Value,
    ) -> crate::protocol::edit::EditWithinSymbolInput {
        serde_json::from_value(value).expect("valid edit_within_symbol input")
    }

    fn batch_edit_input_from_json(
        value: serde_json::Value,
    ) -> crate::protocol::edit::BatchEditInput {
        serde_json::from_value(value).expect("valid batch_edit input")
    }

    fn batch_replace_request_json(idempotency_key: &str, new_body: &str) -> serde_json::Value {
        let edits = serde_json::to_value(vec![crate::protocol::edit::SingleEdit {
            path: "src/lib.rs".to_string(),
            name: "alpha".to_string(),
            kind: None,
            symbol_line: None,
            operation: crate::protocol::edit::EditOperation::Replace {
                new_body: new_body.to_string(),
            },
            working_directory: None,
        }])
        .expect("single edit should serialize");

        serde_json::json!({
            "edits": edits,
            "dry_run": false,
            "idempotency_key": idempotency_key,
        })
    }

    fn setup_loaded_edit_test(files: &[(&str, &str)]) -> (TempDir, SymForgeServer) {
        let dir = tempfile::tempdir().unwrap();
        for (relative_path, content) in files {
            let path = dir.path().join(relative_path);
            if let Some(parent) = path.parent() {
                std::fs::create_dir_all(parent).unwrap();
            }
            std::fs::write(path, content).unwrap();
        }

        let index = LiveIndex::load(dir.path()).unwrap();
        let watcher_info = Arc::new(parking_lot::Mutex::new(
            crate::watcher::WatcherInfo::default(),
        ));
        let server = SymForgeServer::new(
            index,
            "loaded_edit_test".to_string(),
            watcher_info,
            Some(dir.path().to_path_buf()),
            None,
        );
        (dir, server)
    }

    #[test]
    fn prepare_exact_path_for_edit_returns_err_on_confirmed_absent() {
        let (dir, server) = setup_loaded_edit_test(&[]);
        let relative_path = "src/missing.rs";
        let expected_path = super::safe_repo_path_for_freshen(dir.path(), relative_path).unwrap();

        let result = super::prepare_exact_path_for_edit(&server, relative_path);

        assert_eq!(
            result.unwrap_err(),
            format!(
                "{}",
                super::EditError::PathNotFound {
                    path: expected_path
                }
            )
        );
    }

    #[test]
    fn freshen_exact_path_for_targeted_retrieval_removes_on_confirmed_absent_with_fence() {
        let (dir, server) = setup_loaded_edit_test(&[("src/lib.rs", "pub fn gone() {}\n")]);
        std::fs::remove_file(dir.path().join("src/lib.rs")).unwrap();

        let refreshed = super::freshen_exact_path_for_targeted_retrieval(
            &server,
            &super::search::PathScope::exact("src/lib.rs"),
        );

        assert!(refreshed);
        assert!(server.index().read().get_file("src/lib.rs").is_none());
    }

    #[test]
    fn prepare_batch_paths_for_edit_partial_succeeds_with_skipped_paths() {
        let (dir, server) = setup_loaded_edit_test(&[("src/lib.rs", "pub fn keep() {}\n")]);
        let paths = vec!["src/lib.rs".to_string(), "src/missing.rs".to_string()];

        let (repo_root, source_authority) =
            super::prepare_batch_paths_for_edit(&server, &paths).expect("batch freshen");

        assert_eq!(repo_root, dir.path());
        assert_eq!(
            source_authority,
            crate::protocol::edit_format::EditSourceAuthority::CurrentIndex
        );
        assert!(server.index().read().get_file("src/lib.rs").is_some());
    }

    #[test]
    fn freshen_helper_returns_generation_mismatch_when_gen_stale() {
        let project_a = tempfile::tempdir().unwrap();
        let project_b = tempfile::tempdir().unwrap();
        std::fs::create_dir_all(project_a.path().join("src")).unwrap();
        std::fs::create_dir_all(project_b.path().join("src")).unwrap();
        std::fs::write(project_a.path().join("src/a.rs"), "pub fn a() {}\n").unwrap();
        std::fs::write(project_b.path().join("src/b.rs"), "pub fn b() {}\n").unwrap();

        let index = LiveIndex::load(project_a.path()).unwrap();
        let stale_gen = index.current_project_generation();
        index.reload(project_b.path()).unwrap();

        let result = crate::watcher::freshen_file_if_stale(
            "src/b.rs",
            &project_a.path().join("src/b.rs"),
            &index,
            stale_gen,
        );

        assert!(matches!(
            result,
            crate::watcher::FreshenResult::GenerationMismatch
        ));
        assert!(index.read().get_file("src/b.rs").is_some());
    }

    #[tokio::test]
    async fn test_replace_symbol_body_replaces_and_reindexes() {
        let original = b"fn hello() {\n    println!(\"hello\");\n}\n\nfn world() {\n    println!(\"world\");\n}\n";
        let (dir, server, file_path) = setup_edit_test(original);

        let input = crate::protocol::edit::ReplaceSymbolBodyInput {
            path: "src/lib.rs".to_string(),
            name: "hello".to_string(),
            kind: None,
            symbol_line: None,
            new_body: "fn hello() {\n    println!(\"HELLO\");\n}".to_string(),
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.replace_symbol_body(Parameters(input)).await;
        assert!(result.contains("replaced"), "result was: {result}");
        assert!(result.contains("hello"), "result was: {result}");
        assert!(result.contains("Edit safety: structural-edit-safe"));
        assert!(result.contains("Path authority: repository-bound"));
        assert!(result.contains("Source authority: disk-refreshed"));
        assert!(result.contains("Write semantics: atomic write + reindex"));
        assert!(result.contains("Evidence: symbol anchor `src/lib.rs:1`"));
        assert!(result.contains("Tee snapshot:"), "result was: {result}");
        assert!(result.contains(".symforge/tee/"), "result was: {result}");

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(on_disk.contains("HELLO"), "disk: {on_disk}");
        assert!(on_disk.contains("world"), "other symbol intact: {on_disk}");
        let tee_dir = dir.path().join(".symforge").join("tee");
        let snapshots = std::fs::read_dir(&tee_dir)
            .unwrap()
            .collect::<Result<Vec<_>, _>>()
            .unwrap();
        assert_eq!(
            snapshots.len(),
            1,
            "expected one tee snapshot in {tee_dir:?}"
        );
        assert_eq!(std::fs::read(snapshots[0].path()).unwrap(), original);

        let guard = server.index.read();
        let file = guard.get_file("src/lib.rs").unwrap();
        assert!(file.symbols.iter().any(|s| s.name == "hello"));
        assert!(file.symbols.iter().any(|s| s.name == "world"));
    }

    /// When `new_body` does NOT supply its own doc comment, the existing
    /// attached `/// ...` doc must be preserved. Previously the splice
    /// range extended past the doc unconditionally, silently deleting it.
    #[tokio::test]
    async fn test_replace_symbol_body_preserves_attached_doc_without_new_doc() {
        let original =
            b"/// Adds two numbers.\npub fn add(a: i32, b: i32) -> i32 {\n    a + b\n}\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let input = crate::protocol::edit::ReplaceSymbolBodyInput {
            path: "src/lib.rs".to_string(),
            name: "add".to_string(),
            kind: None,
            symbol_line: None,
            new_body: "pub fn add(a: i32, b: i32) -> i32 {\n    a.saturating_add(b)\n}".to_string(),
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.replace_symbol_body(Parameters(input)).await;
        assert!(result.contains("replaced"), "result was: {result}");

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            on_disk.contains("/// Adds two numbers."),
            "existing doc must be preserved; disk was:\n{on_disk}"
        );
        assert!(
            on_disk.contains("saturating_add"),
            "new body must be written; disk was:\n{on_disk}"
        );
        // No duplicated doc line.
        assert_eq!(
            on_disk.matches("/// Adds two numbers.").count(),
            1,
            "doc must appear exactly once; disk was:\n{on_disk}"
        );
    }

    /// When `new_body` DOES supply its own doc comment, the existing doc
    /// must be replaced — not kept alongside, which would duplicate it.
    #[tokio::test]
    async fn test_replace_symbol_body_replaces_attached_doc_when_new_body_supplies_one() {
        let original = b"/// Old doc.\npub fn add(a: i32, b: i32) -> i32 {\n    a + b\n}\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let input = crate::protocol::edit::ReplaceSymbolBodyInput {
            path: "src/lib.rs".to_string(),
            name: "add".to_string(),
            kind: None,
            symbol_line: None,
            new_body:
                "/// New doc.\npub fn add(a: i32, b: i32) -> i32 {\n    a.saturating_add(b)\n}"
                    .to_string(),
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.replace_symbol_body(Parameters(input)).await;
        assert!(result.contains("replaced"), "result was: {result}");

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            on_disk.contains("/// New doc."),
            "new doc must be written; disk was:\n{on_disk}"
        );
        assert!(
            !on_disk.contains("/// Old doc."),
            "old doc must be replaced when new one is supplied; disk was:\n{on_disk}"
        );
        // Exactly one doc line (no duplication).
        assert_eq!(
            on_disk.matches("/// ").count(),
            1,
            "exactly one doc line expected; disk was:\n{on_disk}"
        );
    }

    /// Attributes like `#[inline]` attached to the symbol must be preserved
    /// when `new_body` has neither a doc nor the attribute. This is the
    /// `#[test]`-duplication bug we hit while building Unit 5's tests.
    #[tokio::test]
    async fn test_replace_symbol_body_preserves_attribute_without_duplicating_it() {
        let original = b"#[inline]\npub fn add(a: i32, b: i32) -> i32 {\n    a + b\n}\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let input = crate::protocol::edit::ReplaceSymbolBodyInput {
            path: "src/lib.rs".to_string(),
            name: "add".to_string(),
            kind: None,
            symbol_line: None,
            new_body: "pub fn add(a: i32, b: i32) -> i32 {\n    a.saturating_add(b)\n}".to_string(),
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.replace_symbol_body(Parameters(input)).await;
        assert!(result.contains("replaced"), "result was: {result}");

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert_eq!(
            on_disk.matches("#[inline]").count(),
            1,
            "attribute must appear exactly once; disk was:\n{on_disk}"
        );
        assert!(
            on_disk.contains("saturating_add"),
            "new body must be written; disk was:\n{on_disk}"
        );
    }

    /// Orphaned doc comments (separated from the symbol by a blank line)
    /// must NOT be swallowed when new_body has no doc of its own. This is
    /// the narrowest behavioral delta between the old and new splice math,
    /// and the case the plan's Risks table explicitly flagged for fixture
    /// coverage.
    #[tokio::test]
    async fn test_replace_symbol_body_preserves_orphan_doc_without_new_doc() {
        let original =
            b"/// Intro remark about the next item.\n\npub fn add(a: i32, b: i32) -> i32 {\n    a + b\n}\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let input = crate::protocol::edit::ReplaceSymbolBodyInput {
            path: "src/lib.rs".to_string(),
            name: "add".to_string(),
            kind: None,
            symbol_line: None,
            new_body: "pub fn add(a: i32, b: i32) -> i32 {\n    a.saturating_add(b)\n}".to_string(),
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.replace_symbol_body(Parameters(input)).await;
        assert!(result.contains("replaced"), "result was: {result}");

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            on_disk.contains("/// Intro remark about the next item."),
            "orphaned doc must survive a body-only replace; disk was:\n{on_disk}"
        );
        assert!(
            on_disk.contains("saturating_add"),
            "new body must be written; disk was:\n{on_disk}"
        );
        assert_eq!(
            on_disk
                .matches("/// Intro remark about the next item.")
                .count(),
            1,
            "orphan doc must not duplicate; disk was:\n{on_disk}"
        );
    }

    /// TypeScript/JSDoc variant: `/** ... */` doc block on a separate line
    /// must be preserved when new_body has no doc. Plan Risks table named
    /// this specifically — multi-language coverage proves the helper works
    /// off of the language-agnostic doc-marker list in edit.rs.
    #[tokio::test]
    async fn test_replace_symbol_body_preserves_jsdoc_without_new_doc() {
        let original = b"/** Adds two numbers. */\nexport function add(a: number, b: number): number {\n    return a + b;\n}\n";
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();
        let file_path = src_dir.join("math.ts");
        std::fs::write(&file_path, original).unwrap();
        let parse_result =
            crate::parsing::process_file("src/math.ts", original, LanguageId::TypeScript);
        let indexed = IndexedFile::from_parse_result(parse_result, original.to_vec());
        let index = make_live_index_ready(vec![("src/math.ts".to_string(), indexed)]);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = crate::protocol::edit::ReplaceSymbolBodyInput {
            path: "src/math.ts".to_string(),
            name: "add".to_string(),
            kind: None,
            symbol_line: None,
            new_body: "export function add(a: number, b: number): number {\n    return a + b;\n}"
                .to_string(),
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.replace_symbol_body(Parameters(input)).await;
        assert!(result.contains("replaced"), "result was: {result}");

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            on_disk.contains("/** Adds two numbers. */"),
            "JSDoc block must survive a body-only replace; disk was:\n{on_disk}"
        );
        assert_eq!(
            on_disk.matches("/** Adds two numbers. */").count(),
            1,
            "JSDoc must not duplicate; disk was:\n{on_disk}"
        );
    }

    #[tokio::test]
    async fn test_replace_symbol_body_preserves_same_line_jsdoc_without_new_doc() {
        let original = b"/** @deprecated */ export function legacy(): number {\n    return 1;\n}\n";
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();
        let file_path = src_dir.join("legacy.ts");
        std::fs::write(&file_path, original).unwrap();
        let parse_result =
            crate::parsing::process_file("src/legacy.ts", original, LanguageId::TypeScript);
        let indexed = IndexedFile::from_parse_result(parse_result, original.to_vec());
        let index = make_live_index_ready(vec![("src/legacy.ts".to_string(), indexed)]);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = crate::protocol::edit::ReplaceSymbolBodyInput {
            path: "src/legacy.ts".to_string(),
            name: "legacy".to_string(),
            kind: None,
            symbol_line: None,
            new_body: "export function legacy(): number {\n    return 2;\n}".to_string(),
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.replace_symbol_body(Parameters(input)).await;
        assert!(result.contains("replaced"), "result was: {result}");

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            on_disk.starts_with("/** @deprecated */ export function legacy"),
            "same-line JSDoc prefix must survive a body-only replace; disk was:\n{on_disk}"
        );
        assert_eq!(
            on_disk.matches("/** @deprecated */").count(),
            1,
            "same-line JSDoc must not duplicate; disk was:\n{on_disk}"
        );
        assert!(
            on_disk.contains("return 2;"),
            "new body must be written; disk was:\n{on_disk}"
        );
    }

    #[tokio::test]
    async fn test_replace_symbol_body_preserves_same_line_rust_block_doc_without_new_doc() {
        let original = b"/** @deprecated */ pub fn legacy() -> i32 {\n    1\n}\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let input = crate::protocol::edit::ReplaceSymbolBodyInput {
            path: "src/lib.rs".to_string(),
            name: "legacy".to_string(),
            kind: None,
            symbol_line: None,
            new_body: "pub fn legacy() -> i32 {\n    2\n}".to_string(),
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.replace_symbol_body(Parameters(input)).await;
        assert!(result.contains("replaced"), "result was: {result}");

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            on_disk.starts_with("/** @deprecated */ pub fn legacy"),
            "same-line Rust block doc must survive a body-only replace; disk was:\n{on_disk}"
        );
        assert_eq!(
            on_disk.matches("/** @deprecated */").count(),
            1,
            "same-line Rust block doc must not duplicate; disk was:\n{on_disk}"
        );
        assert!(
            on_disk.contains("    2"),
            "new body must be written; disk was:\n{on_disk}"
        );
    }

    #[tokio::test]
    async fn test_replace_symbol_body_preserves_indentation() {
        // Simulates a method inside a class — symbol is indented 4 spaces.
        let original = b"mod outer {\n    fn inner() {\n        old_body();\n    }\n}\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        // Provide unindented replacement — tool should auto-indent to match.
        let input = crate::protocol::edit::ReplaceSymbolBodyInput {
            path: "src/lib.rs".to_string(),
            name: "inner".to_string(),
            kind: None,
            symbol_line: None,
            new_body: "fn inner() {\n    new_body();\n}".to_string(),
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.replace_symbol_body(Parameters(input)).await;
        assert!(result.contains("replaced"), "result: {result}");

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        // Every line of the replacement should be indented 4 spaces.
        assert!(
            on_disk.contains("    fn inner() {\n        new_body();\n    }"),
            "indentation preserved: {on_disk}"
        );
    }

    #[tokio::test]
    async fn test_replace_symbol_body_not_found() {
        let original = b"fn hello() {}\n";
        let (_dir, server, _) = setup_edit_test(original);

        let input = crate::protocol::edit::ReplaceSymbolBodyInput {
            path: "nonexistent.rs".to_string(),
            name: "foo".to_string(),
            kind: None,
            symbol_line: None,
            new_body: "fn foo() {}".to_string(),
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.replace_symbol_body(Parameters(input)).await;
        assert!(
            result.contains("not found") || result.contains("Not found"),
            "result: {result}"
        );
    }

    #[tokio::test]
    async fn test_insert_symbol_after_works() {
        let original = b"fn hello() {\n    println!(\"hello\");\n}\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let input = crate::protocol::edit::InsertSymbolInput {
            path: "src/lib.rs".to_string(),
            name: "hello".to_string(),
            kind: None,
            symbol_line: None,
            content: "fn world() {\n    println!(\"world\");\n}".to_string(),
            position: None, // defaults to "after"
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.insert_symbol(Parameters(input)).await;
        assert!(result.contains("inserted"), "result: {result}");
        assert!(result.contains("after"), "result: {result}");
        assert!(result.contains("Edit safety: structural-edit-safe"));
        assert!(result.contains("Write semantics: atomic write + reindex"));
        assert!(result.contains("Evidence: symbol anchor `src/lib.rs:1`"));

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(on_disk.contains("hello"), "original intact: {on_disk}");
        assert!(on_disk.contains("world"), "new symbol: {on_disk}");

        let guard = server.index.read();
        let file = guard.get_file("src/lib.rs").unwrap();
        assert!(file.symbols.iter().any(|s| s.name == "world"));
    }

    #[tokio::test]
    async fn test_insert_symbol_before_works() {
        let original = b"fn world() {\n    println!(\"world\");\n}\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let input = crate::protocol::edit::InsertSymbolInput {
            path: "src/lib.rs".to_string(),
            name: "world".to_string(),
            kind: None,
            symbol_line: None,
            content: "fn hello() {\n    println!(\"hello\");\n}".to_string(),
            position: Some("before".to_string()),
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.insert_symbol(Parameters(input)).await;
        assert!(result.contains("inserted"), "result: {result}");
        assert!(result.contains("before"), "result: {result}");

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        let hello_pos = on_disk.find("hello").unwrap();
        let world_pos = on_disk.find("world").unwrap();
        assert!(hello_pos < world_pos, "hello before world: {on_disk}");
    }

    #[tokio::test]
    async fn test_delete_symbol_removes_and_reindexes() {
        let original = b"fn hello() {\n    println!(\"hello\");\n}\n\nfn world() {\n    println!(\"world\");\n}\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let input = crate::protocol::edit::DeleteSymbolInput {
            path: "src/lib.rs".to_string(),
            name: "hello".to_string(),
            kind: None,
            symbol_line: None,
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.delete_symbol(Parameters(input)).await;
        assert!(result.contains("deleted"), "result: {result}");
        assert!(result.contains("Edit safety: structural-edit-safe"));
        assert!(result.contains("Write semantics: atomic write + reindex"));
        assert!(result.contains("Evidence: symbol anchor `src/lib.rs:1`"));

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(!on_disk.contains("hello"), "hello removed: {on_disk}");
        assert!(on_disk.contains("world"), "world intact: {on_disk}");

        let guard = server.index.read();
        let file = guard.get_file("src/lib.rs").unwrap();
        assert!(!file.symbols.iter().any(|s| s.name == "hello"));
        assert!(file.symbols.iter().any(|s| s.name == "world"));
    }

    #[test]
    fn test_check_edit_capability_blocks_structural_for_frontend() {
        // replace_symbol_body requires StructuralEditSafe; Html is TextEditSafe → blocked
        let warning = SymForgeServer::check_edit_capability(
            &crate::domain::LanguageId::Html,
            crate::parsing::config_extractors::EditCapability::StructuralEditSafe,
            "replace_symbol_body",
        );
        assert!(
            warning.is_some(),
            "replace_symbol_body should be blocked for HTML"
        );
        assert!(warning.as_ref().unwrap().contains("edit safety blocked"));
        assert!(
            warning
                .as_ref()
                .unwrap()
                .contains("Required safety: structural-edit-safe")
        );
        assert!(
            warning
                .as_ref()
                .unwrap()
                .contains("Available safety: text-edit-safe")
        );
    }

    #[test]
    fn test_check_edit_capability_allows_text_edit_for_frontend() {
        // edit_within_symbol requires TextEditSafe; Css is TextEditSafe → allowed
        let warning = SymForgeServer::check_edit_capability(
            &crate::domain::LanguageId::Css,
            crate::parsing::config_extractors::EditCapability::TextEditSafe,
            "edit_within_symbol",
        );
        assert!(
            warning.is_none(),
            "edit_within_symbol should be allowed for CSS"
        );
    }

    #[tokio::test]
    async fn test_edit_within_symbol_replaces_text() {
        let original = b"fn hello() {\n    println!(\"hello\");\n}\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let input = crate::protocol::edit::EditWithinSymbolInput {
            path: "src/lib.rs".to_string(),
            name: "hello".to_string(),
            kind: None,
            symbol_line: None,
            old_text: "\"hello\"".to_string(),
            new_text: "\"HELLO\"".to_string(),
            replace_all: false,
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.edit_within_symbol(Parameters(input)).await;
        assert!(result.contains("edited within"), "result: {result}");
        assert!(result.contains("1 replacement"), "result: {result}");
        assert!(result.contains("Edit safety: text-edit-safe"));
        assert!(result.contains("Write semantics: atomic write + reindex"));
        assert!(result.contains("Evidence: symbol anchor `src/lib.rs:1`"));

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(on_disk.contains("HELLO"), "edited: {on_disk}");
        assert!(!on_disk.contains("\"hello\""), "old text gone: {on_disk}");
    }

    #[tokio::test]
    async fn test_replace_symbol_body_reports_disk_refreshed_authority_for_stale_file() {
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let stale_indexed = b"fn hello() {\n    println!(\"stale\");\n}\n";
        let fresh_on_disk = b"fn hello() {\n    println!(\"fresh\");\n}\n";
        let file_path = src_dir.join("lib.rs");
        std::fs::write(&file_path, fresh_on_disk).unwrap();

        let result = crate::parsing::process_file("src/lib.rs", stale_indexed, LanguageId::Rust);
        let indexed = IndexedFile::from_parse_result(result, stale_indexed.to_vec());
        let server = make_server_with_root(
            make_live_index_ready(vec![("src/lib.rs".to_string(), indexed)]),
            Some(dir.path().to_path_buf()),
        );

        let input = crate::protocol::edit::ReplaceSymbolBodyInput {
            path: "src/lib.rs".to_string(),
            name: "hello".to_string(),
            kind: None,
            symbol_line: None,
            new_body: "fn hello() {\n    println!(\"new body\");\n}".to_string(),
            dry_run: Some(true),
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.replace_symbol_body(Parameters(input)).await;
        assert!(result.contains("Source authority: disk-refreshed"));
        assert!(result.contains("Write semantics: dry run (no writes)"));

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            on_disk.contains("fresh"),
            "dry run should leave disk untouched: {on_disk}"
        );
    }

    #[tokio::test]
    async fn test_edit_within_symbol_not_found_text() {
        let original = b"fn hello() {\n    println!(\"hello\");\n}\n";
        let (_dir, server, _) = setup_edit_test(original);

        let input = crate::protocol::edit::EditWithinSymbolInput {
            path: "src/lib.rs".to_string(),
            name: "hello".to_string(),
            kind: None,
            symbol_line: None,
            old_text: "nonexistent".to_string(),
            new_text: "replacement".to_string(),
            replace_all: false,
            dry_run: None,
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.edit_within_symbol(Parameters(input)).await;
        assert!(result.contains("not found within"), "result: {result}");
    }

    #[tokio::test]
    async fn test_replace_symbol_body_dry_run_skips_write() {
        let original = b"fn foo() { old }\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let result = server
            .replace_symbol_body(Parameters(crate::protocol::edit::ReplaceSymbolBodyInput {
                path: "src/lib.rs".to_string(),
                name: "foo".to_string(),
                kind: None,
                symbol_line: None,
                new_body: "fn foo() { new }".to_string(),
                dry_run: Some(true),
                idempotency_key: None,
                working_directory: None,
            }))
            .await;

        assert!(
            result.contains("[DRY RUN]"),
            "should show dry run: {result}"
        );
        assert!(result.contains("Path authority: repository-bound"));
        assert!(result.contains("Write semantics: dry run (no writes)"));
        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            on_disk.contains("old"),
            "file should be unchanged: {on_disk}"
        );
    }

    #[tokio::test]
    async fn test_insert_symbol_dry_run_skips_write() {
        let original = b"fn anchor() {}\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let result = server
            .insert_symbol(Parameters(crate::protocol::edit::InsertSymbolInput {
                path: "src/lib.rs".to_string(),
                name: "anchor".to_string(),
                kind: None,
                symbol_line: None,
                content: "fn new_fn() {}".to_string(),
                position: Some("after".to_string()),
                dry_run: Some(true),
                idempotency_key: None,
                working_directory: None,
            }))
            .await;

        assert!(
            result.contains("[DRY RUN]"),
            "should show dry run: {result}"
        );
        assert!(result.contains("Path authority: repository-bound"));
        assert!(result.contains("Write semantics: dry run (no writes)"));
        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            !on_disk.contains("new_fn"),
            "file should be unchanged: {on_disk}"
        );
    }

    #[tokio::test]
    async fn test_delete_symbol_dry_run_skips_write() {
        let original = b"fn target() {}\nfn keep() {}\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let result = server
            .delete_symbol(Parameters(crate::protocol::edit::DeleteSymbolInput {
                path: "src/lib.rs".to_string(),
                name: "target".to_string(),
                kind: None,
                symbol_line: None,
                dry_run: Some(true),
                idempotency_key: None,
                working_directory: None,
            }))
            .await;

        assert!(
            result.contains("[DRY RUN]"),
            "should show dry run: {result}"
        );
        assert!(result.contains("Path authority: repository-bound"));
        assert!(result.contains("Write semantics: dry run (no writes)"));
        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            on_disk.contains("target"),
            "file should be unchanged: {on_disk}"
        );
    }

    #[tokio::test]
    async fn test_edit_within_symbol_dry_run_skips_write() {
        let original = b"fn foo() { old_text }\n";
        let (_dir, server, file_path) = setup_edit_test(original);

        let result = server
            .edit_within_symbol(Parameters(crate::protocol::edit::EditWithinSymbolInput {
                path: "src/lib.rs".to_string(),
                name: "foo".to_string(),
                kind: None,
                symbol_line: None,
                old_text: "old_text".to_string(),
                new_text: "new_text".to_string(),
                replace_all: false,
                dry_run: Some(true),
                idempotency_key: None,
                working_directory: None,
            }))
            .await;

        assert!(
            result.contains("[DRY RUN]"),
            "should show dry run: {result}"
        );
        assert!(result.contains("Path authority: repository-bound"));
        assert!(result.contains("Write semantics: dry run (no writes)"));
        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            on_disk.contains("old_text"),
            "file should be unchanged: {on_disk}"
        );
    }

    // ── Tier 2 batch tool integration tests ──

    #[tokio::test]
    async fn test_batch_edit_applies_across_files() {
        use crate::protocol::edit::{BatchEditInput, EditOperation, SingleEdit};

        let dir = tempfile::tempdir().unwrap();
        std::fs::create_dir(dir.path().join(".git")).unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let a_content = b"fn alpha() { old }\n";
        let b_content = b"fn beta() { keep }\n";
        std::fs::write(src_dir.join("a.rs"), a_content).unwrap();
        std::fs::write(src_dir.join("b.rs"), b_content).unwrap();

        let mut files = vec![];
        for (path, content) in [
            ("src/a.rs", a_content as &[u8]),
            ("src/b.rs", b_content as &[u8]),
        ] {
            let result = crate::parsing::process_file(path, content, LanguageId::Rust);
            let indexed =
                crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
            files.push((path.to_string(), indexed));
        }
        let index = make_live_index_ready(files);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = BatchEditInput {
            edits: vec![
                SingleEdit {
                    path: "src/a.rs".to_string(),
                    name: "alpha".to_string(),
                    kind: None,
                    symbol_line: None,
                    operation: EditOperation::Replace {
                        new_body: "fn alpha() { new }".to_string(),
                    },
                    working_directory: None,
                },
                SingleEdit {
                    path: "src/b.rs".to_string(),
                    name: "beta".to_string(),
                    kind: None,
                    symbol_line: None,
                    operation: EditOperation::Delete,
                    working_directory: None,
                },
            ],
            dry_run: Some(false),
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.batch_edit(Parameters(input)).await;
        assert!(result.contains("2 edit(s)"), "result: {result}");
        assert!(
            result.contains("Edit safety: structural-edit-safe"),
            "result: {result}"
        );
        assert!(result.contains("Match type: exact"), "result: {result}");
        assert!(
            result.contains("Write semantics: transactional write + rollback + reindex"),
            "result: {result}"
        );
        assert!(result.contains("Tee snapshot:"), "result: {result}");
        assert!(result.contains(".symforge/tee/"), "result: {result}");

        let a = std::fs::read_to_string(src_dir.join("a.rs")).unwrap();
        assert!(a.contains("new"), "a.rs: {a}");
        let b = std::fs::read_to_string(src_dir.join("b.rs")).unwrap();
        assert!(!b.contains("beta"), "b.rs: {b}");
        let tee_dir = dir.path().join(".symforge").join("tee");
        let snapshots = std::fs::read_dir(&tee_dir)
            .unwrap()
            .collect::<Result<Vec<_>, _>>()
            .unwrap();
        assert_eq!(
            snapshots.len(),
            2,
            "expected two tee snapshots in {tee_dir:?}"
        );
        let snapshot_bytes = snapshots
            .iter()
            .map(|entry| std::fs::read(entry.path()).unwrap())
            .collect::<Vec<_>>();
        assert!(
            snapshot_bytes.iter().any(|bytes| bytes == a_content),
            "a.rs original should be teed"
        );
        assert!(
            snapshot_bytes.iter().any(|bytes| bytes == b_content),
            "b.rs original should be teed"
        );
    }

    #[tokio::test]
    async fn test_batch_edit_reports_disk_refreshed_authority_for_stale_file() {
        use crate::protocol::edit::{BatchEditInput, EditOperation, SingleEdit};

        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let stale_indexed = b"fn alpha() { stale }\n";
        let fresh_on_disk = b"fn alpha() { fresh }\n";
        let file_path = src_dir.join("a.rs");
        std::fs::write(&file_path, fresh_on_disk).unwrap();

        let result = crate::parsing::process_file("src/a.rs", stale_indexed, LanguageId::Rust);
        let indexed = crate::live_index::store::IndexedFile::from_parse_result(
            result,
            stale_indexed.to_vec(),
        );
        let index = make_live_index_ready(vec![("src/a.rs".to_string(), indexed)]);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = BatchEditInput {
            edits: vec![SingleEdit {
                path: "src/a.rs".to_string(),
                name: "alpha".to_string(),
                kind: None,
                symbol_line: None,
                operation: EditOperation::Replace {
                    new_body: "fn alpha() { next }".to_string(),
                },
                working_directory: None,
            }],
            dry_run: Some(true),
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.batch_edit(Parameters(input)).await;
        assert!(
            result.contains("Source authority: disk-refreshed"),
            "result: {result}"
        );
        assert!(
            result.contains("Write semantics: dry run (no writes)"),
            "result: {result}"
        );

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            on_disk.contains("fresh"),
            "dry run should leave disk untouched: {on_disk}"
        );
    }

    #[tokio::test]
    async fn test_batch_rename_renames_def_and_refs() {
        use crate::protocol::edit::BatchRenameInput;

        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let lib_content = b"fn old_name() {}\n";
        let main_content = b"fn caller() { old_name(); }\n";
        std::fs::write(src_dir.join("lib.rs"), lib_content).unwrap();
        std::fs::write(src_dir.join("main.rs"), main_content).unwrap();

        let mut files = vec![];
        for (path, content) in [
            ("src/lib.rs", lib_content as &[u8]),
            ("src/main.rs", main_content as &[u8]),
        ] {
            let result = crate::parsing::process_file(path, content, LanguageId::Rust);
            let indexed =
                crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
            files.push((path.to_string(), indexed));
        }
        let index = make_live_index_ready(files);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = BatchRenameInput {
            path: "src/lib.rs".to_string(),
            name: "old_name".to_string(),
            kind: None,
            symbol_line: None,
            new_name: "new_name".to_string(),
            dry_run: None,
            idempotency_key: None,
            code_only: None,
            working_directory: None,
        };
        let result = server.batch_rename(Parameters(input)).await;
        assert!(result.contains("Renamed"), "result: {result}");
        assert!(result.contains("new_name"), "result: {result}");
        assert!(
            result.contains("Match type: constrained"),
            "result: {result}"
        );
        assert!(
            result.contains("Write semantics: transactional write + rollback + reindex"),
            "result: {result}"
        );

        let lib = std::fs::read_to_string(src_dir.join("lib.rs")).unwrap();
        assert!(lib.contains("new_name"), "lib.rs: {lib}");
        assert!(!lib.contains("old_name"), "lib.rs: {lib}");
    }

    #[tokio::test]
    async fn test_batch_rename_reports_disk_refreshed_authority_after_reconcile() {
        use crate::protocol::edit::BatchRenameInput;

        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let stale_lib = b"fn old_name() { stale(); }\n";
        let stale_main = b"fn caller() { old_name(); stale(); }\n";
        let fresh_lib = b"fn old_name() { fresh(); }\n";
        let fresh_main = b"fn caller() { old_name(); fresh(); }\n";
        std::fs::write(src_dir.join("lib.rs"), fresh_lib).unwrap();
        std::fs::write(src_dir.join("main.rs"), fresh_main).unwrap();

        let mut files = vec![];
        for (path, content) in [
            ("src/lib.rs", stale_lib as &[u8]),
            ("src/main.rs", stale_main as &[u8]),
        ] {
            let result = crate::parsing::process_file(path, content, LanguageId::Rust);
            let indexed =
                crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
            files.push((path.to_string(), indexed));
        }
        let index = make_live_index_ready(files);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = BatchRenameInput {
            path: "src/lib.rs".to_string(),
            name: "old_name".to_string(),
            kind: None,
            symbol_line: None,
            new_name: "new_name".to_string(),
            dry_run: Some(true),
            idempotency_key: None,
            code_only: None,
            working_directory: None,
        };
        let result = server.batch_rename(Parameters(input)).await;
        assert!(
            result.contains("Source authority: disk-refreshed"),
            "result: {result}"
        );
        assert!(
            result.contains("Write semantics: dry run (no writes)"),
            "result: {result}"
        );

        let lib = std::fs::read_to_string(src_dir.join("lib.rs")).unwrap();
        assert!(
            lib.contains("fresh"),
            "dry run should leave disk untouched: {lib}"
        );
    }

    #[tokio::test]
    async fn test_batch_rename_catches_path_qualified_calls() {
        // Regression: batch_rename must catch Type::new() path-qualified calls,
        // not just simple name references. The index tracks "new" as the call
        // target, but "Widget" as a path prefix must also be renamed.
        use crate::protocol::edit::BatchRenameInput;

        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let lib_content = b"pub struct Widget { pub name: String }\nimpl Widget {\n    pub fn new(name: &str) -> Self { Widget { name: name.to_string() } }\n}\n";
        let main_content =
            b"use crate::Widget;\nfn make() -> Widget {\n    Widget::new(\"default\")\n}\n";
        std::fs::write(src_dir.join("lib.rs"), lib_content).unwrap();
        std::fs::write(src_dir.join("main.rs"), main_content).unwrap();

        let mut files = vec![];
        for (path, content) in [
            ("src/lib.rs", lib_content as &[u8]),
            ("src/main.rs", main_content as &[u8]),
        ] {
            let result = crate::parsing::process_file(path, content, LanguageId::Rust);
            let indexed =
                crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
            files.push((path.to_string(), indexed));
        }
        let index = make_live_index_ready(files);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = BatchRenameInput {
            path: "src/lib.rs".to_string(),
            name: "Widget".to_string(),
            kind: None,
            symbol_line: None,
            new_name: "Gadget".to_string(),
            dry_run: None,
            idempotency_key: None,
            code_only: None,
            working_directory: None,
        };
        let result = server.batch_rename(Parameters(input)).await;
        assert!(result.contains("Renamed"), "result: {result}");

        // Verify disk: Widget::new() in main.rs must become Gadget::new()
        let main = std::fs::read_to_string(src_dir.join("main.rs")).unwrap();
        assert!(
            !main.contains("Widget"),
            "main.rs should have no 'Widget' left after rename, got: {main}"
        );
        assert!(
            main.contains("Gadget::new"),
            "main.rs should contain Gadget::new, got: {main}"
        );
    }

    #[tokio::test]
    async fn test_search_text_agrees_with_disk_after_rename() {
        // Regression: after batch_rename, search_text must agree with on-disk
        // content. If rename misses some sites, search_text must still find
        // the old name (because it's still on disk), not report zero matches.
        use crate::protocol::edit::BatchRenameInput;

        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let lib_content = b"pub struct Widget { pub name: String }\nimpl Widget {\n    pub fn new(name: &str) -> Self { Widget { name: name.to_string() } }\n}\n";
        let main_content =
            b"use crate::Widget;\nfn make() -> Widget {\n    Widget::new(\"default\")\n}\n";
        std::fs::write(src_dir.join("lib.rs"), lib_content).unwrap();
        std::fs::write(src_dir.join("main.rs"), main_content).unwrap();

        let mut files = vec![];
        for (path, content) in [
            ("src/lib.rs", lib_content as &[u8]),
            ("src/main.rs", main_content as &[u8]),
        ] {
            let result = crate::parsing::process_file(path, content, LanguageId::Rust);
            let indexed =
                crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
            files.push((path.to_string(), indexed));
        }
        let index = make_live_index_ready(files);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        // Do the rename
        let input = BatchRenameInput {
            path: "src/lib.rs".to_string(),
            name: "Widget".to_string(),
            kind: None,
            symbol_line: None,
            new_name: "Gadget".to_string(),
            dry_run: None,
            idempotency_key: None,
            code_only: None,
            working_directory: None,
        };
        let _result = server.batch_rename(Parameters(input)).await;

        // Now check: if "Widget" is still on disk anywhere, search_text must find it.
        let disk_has_widget = std::fs::read_to_string(src_dir.join("main.rs"))
            .unwrap()
            .contains("Widget")
            || std::fs::read_to_string(src_dir.join("lib.rs"))
                .unwrap()
                .contains("Widget");

        if disk_has_widget {
            // search_text should find it too — index must not lie
            let search_input = crate::protocol::tools::SearchTextInput {
                query: Some("Widget".to_string()),
                terms: None,
                path_prefix: None,
                glob: None,
                exclude_glob: None,
                language: None,
                regex: None,
                case_sensitive: None,
                whole_word: None,
                group_by: None,
                follow_refs: None,
                follow_refs_limit: None,
                ranked: None,
                context: None,
                limit: None,
                max_per_file: None,
                include_tests: None,
                include_generated: None,
                include_vendor: None,
                include_personal_tooling: None,
                estimate: None,
                max_tokens: None,
                structural: None,
            };
            let search_result = server.search_text(Parameters(search_input)).await;
            assert!(
                !search_result.contains("0 matches"),
                "search_text says 0 matches but Widget is still on disk! Index/disk desync. search_result: {search_result}"
            );
        }
    }

    #[tokio::test]
    async fn test_batch_rename_catches_qualified_call() {
        // Supplemental qualified scan must catch OldType::new() and rename it.
        use crate::protocol::edit::BatchRenameInput;

        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let lib_content =
            b"pub struct OldType;\nimpl OldType {\n    pub fn new() -> Self { OldType }\n}\n";
        let main_content = b"fn make() { let _ = OldType::new(); }\n";
        std::fs::write(src_dir.join("lib.rs"), lib_content).unwrap();
        std::fs::write(src_dir.join("main.rs"), main_content).unwrap();

        let mut files = vec![];
        for (path, content) in [
            ("src/lib.rs", lib_content as &[u8]),
            ("src/main.rs", main_content as &[u8]),
        ] {
            let result = crate::parsing::process_file(path, content, LanguageId::Rust);
            let indexed =
                crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
            files.push((path.to_string(), indexed));
        }
        let index = make_live_index_ready(files);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = BatchRenameInput {
            path: "src/lib.rs".to_string(),
            name: "OldType".to_string(),
            kind: None,
            symbol_line: None,
            new_name: "NewType".to_string(),
            dry_run: None,
            idempotency_key: None,
            code_only: None,
            working_directory: None,
        };
        let result = server.batch_rename(Parameters(input)).await;
        assert!(result.contains("Renamed"), "result: {result}");

        let main = std::fs::read_to_string(src_dir.join("main.rs")).unwrap();
        assert!(
            main.contains("NewType::new()"),
            "main.rs should contain NewType::new(), got: {main}"
        );
        assert!(
            !main.contains("OldType"),
            "main.rs should have no OldType left, got: {main}"
        );
    }

    #[tokio::test]
    async fn test_batch_rename_dry_run_separates_confident_uncertain() {
        // Dry run must show separate sections for confident and uncertain matches.
        use crate::protocol::edit::BatchRenameInput;

        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        // lib.rs: definition only (confident)
        // main.rs: code usage (confident) + comment usage (uncertain)
        let lib_content = b"pub struct OldType;\n";
        let main_content =
            b"fn use_it() { let _ = OldType::new(); }\n// OldType::new() creates an instance\n";
        std::fs::write(src_dir.join("lib.rs"), lib_content).unwrap();
        std::fs::write(src_dir.join("main.rs"), main_content).unwrap();

        let mut files = vec![];
        for (path, content) in [
            ("src/lib.rs", lib_content as &[u8]),
            ("src/main.rs", main_content as &[u8]),
        ] {
            let result = crate::parsing::process_file(path, content, LanguageId::Rust);
            let indexed =
                crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
            files.push((path.to_string(), indexed));
        }
        let index = make_live_index_ready(files);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = BatchRenameInput {
            path: "src/lib.rs".to_string(),
            name: "OldType".to_string(),
            kind: None,
            symbol_line: None,
            new_name: "NewType".to_string(),
            dry_run: Some(true),
            idempotency_key: None,
            code_only: None,
            working_directory: None,
        };
        let result = server.batch_rename(Parameters(input)).await;

        assert!(
            result.contains("Confident matches"),
            "dry_run must have confident section, got: {result}"
        );
        assert!(
            result.contains("Uncertain matches"),
            "dry_run must have uncertain section, got: {result}"
        );
        assert!(
            result.contains("NOT applied"),
            "uncertain section must say NOT applied, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_batch_rename_uncertain_not_applied_by_default() {
        // Uncertain matches (comments) must NOT be modified during a live rename.
        use crate::protocol::edit::BatchRenameInput;

        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        // File with OldType only in a comment — no code usage.
        let lib_content = b"pub struct OldType;\n";
        let main_content = b"fn dummy() {}\n// OldType::new() creates an instance\n";
        std::fs::write(src_dir.join("lib.rs"), lib_content).unwrap();
        std::fs::write(src_dir.join("main.rs"), main_content).unwrap();

        let mut files = vec![];
        for (path, content) in [
            ("src/lib.rs", lib_content as &[u8]),
            ("src/main.rs", main_content as &[u8]),
        ] {
            let result = crate::parsing::process_file(path, content, LanguageId::Rust);
            let indexed =
                crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
            files.push((path.to_string(), indexed));
        }
        let index = make_live_index_ready(files);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = BatchRenameInput {
            path: "src/lib.rs".to_string(),
            name: "OldType".to_string(),
            kind: None,
            symbol_line: None,
            new_name: "NewType".to_string(),
            dry_run: None,
            idempotency_key: None,
            code_only: None,
            working_directory: None,
        };
        let result = server.batch_rename(Parameters(input)).await;
        assert!(result.contains("Renamed"), "result: {result}");

        // Comment in main.rs must remain unchanged.
        let main = std::fs::read_to_string(src_dir.join("main.rs")).unwrap();
        assert!(
            main.contains("OldType"),
            "comment in main.rs must not be modified, got: {main}"
        );
        // But the result should surface the uncertain match as a warning.
        assert!(
            result.contains("Uncertain matches") || result.contains("NOT applied"),
            "result should warn about uncertain match, got: {result}"
        );
    }

    #[tokio::test]
    async fn test_batch_rename_scopes_common_name_to_target() {
        // Renaming "new" scoped to Target should not touch SomeOther::new().
        use crate::protocol::edit::BatchRenameInput;

        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let lib_content =
            b"pub struct Target;\nimpl Target {\n    pub fn new() -> Self { Target }\n}\npub struct SomeOther;\nimpl SomeOther {\n    pub fn new() -> Self { SomeOther }\n}\n";
        let main_content = b"fn make() { let _a = Target::new(); let _b = SomeOther::new(); }\n";
        std::fs::write(src_dir.join("lib.rs"), lib_content).unwrap();
        std::fs::write(src_dir.join("main.rs"), main_content).unwrap();

        let mut files = vec![];
        for (path, content) in [
            ("src/lib.rs", lib_content as &[u8]),
            ("src/main.rs", main_content as &[u8]),
        ] {
            let result = crate::parsing::process_file(path, content, LanguageId::Rust);
            let indexed =
                crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
            files.push((path.to_string(), indexed));
        }
        let index = make_live_index_ready(files);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        // Rename the Target struct itself, not its "new" method.
        let input = BatchRenameInput {
            path: "src/lib.rs".to_string(),
            name: "Target".to_string(),
            kind: None,
            symbol_line: None,
            new_name: "Renamed".to_string(),
            dry_run: None,
            idempotency_key: None,
            code_only: None,
            working_directory: None,
        };
        let result = server.batch_rename(Parameters(input)).await;
        assert!(result.contains("Renamed"), "result: {result}");

        let main = std::fs::read_to_string(src_dir.join("main.rs")).unwrap();
        assert!(
            main.contains("Renamed::new()"),
            "Target::new() must become Renamed::new(), got: {main}"
        );
        assert!(
            main.contains("SomeOther::new()"),
            "SomeOther::new() must be untouched, got: {main}"
        );
        assert!(
            !main.contains("Target::"),
            "Target:: reference must be gone, got: {main}"
        );
    }

    #[tokio::test]
    async fn test_batch_insert_adds_to_multiple_files() {
        use crate::protocol::edit::{BatchInsertInput, InsertPosition, InsertTarget};

        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let a_content = b"fn handler_a() {}\n";
        let b_content = b"fn handler_b() {}\n";
        std::fs::write(src_dir.join("a.rs"), a_content).unwrap();
        std::fs::write(src_dir.join("b.rs"), b_content).unwrap();

        let mut files = vec![];
        for (path, content) in [
            ("src/a.rs", a_content as &[u8]),
            ("src/b.rs", b_content as &[u8]),
        ] {
            let result = crate::parsing::process_file(path, content, LanguageId::Rust);
            let indexed =
                crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
            files.push((path.to_string(), indexed));
        }
        let index = make_live_index_ready(files);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = BatchInsertInput {
            content: "fn logging() {}".to_string(),
            position: InsertPosition::After,
            targets: vec![
                InsertTarget {
                    path: "src/a.rs".to_string(),
                    name: "handler_a".to_string(),
                    kind: None,
                    symbol_line: None,
                    working_directory: None,
                },
                InsertTarget {
                    path: "src/b.rs".to_string(),
                    name: "handler_b".to_string(),
                    kind: None,
                    symbol_line: None,
                    working_directory: None,
                },
            ],
            dry_run: Some(false),
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.batch_insert(Parameters(input)).await;
        assert!(result.contains("2 edit(s)"), "result: {result}");
        assert!(
            result.contains("Edit safety: structural-edit-safe"),
            "result: {result}"
        );
        assert!(result.contains("Match type: exact"), "result: {result}");
        assert!(
            result.contains("Write semantics: transactional write + rollback + reindex"),
            "result: {result}"
        );

        let a = std::fs::read_to_string(src_dir.join("a.rs")).unwrap();
        assert!(a.contains("logging"), "a.rs: {a}");
        let b = std::fs::read_to_string(src_dir.join("b.rs")).unwrap();
        assert!(b.contains("logging"), "b.rs: {b}");
    }

    #[tokio::test]
    async fn test_batch_insert_reports_disk_refreshed_authority_for_stale_file() {
        use crate::protocol::edit::{BatchInsertInput, InsertPosition, InsertTarget};

        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let stale_indexed = b"fn handler_a() { stale(); }\n";
        let fresh_on_disk = b"fn handler_a() { fresh(); }\n";
        let file_path = src_dir.join("a.rs");
        std::fs::write(&file_path, fresh_on_disk).unwrap();

        let result = crate::parsing::process_file("src/a.rs", stale_indexed, LanguageId::Rust);
        let indexed = crate::live_index::store::IndexedFile::from_parse_result(
            result,
            stale_indexed.to_vec(),
        );
        let index = make_live_index_ready(vec![("src/a.rs".to_string(), indexed)]);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = BatchInsertInput {
            content: "fn logging() {}".to_string(),
            position: InsertPosition::After,
            targets: vec![InsertTarget {
                path: "src/a.rs".to_string(),
                name: "handler_a".to_string(),
                kind: None,
                symbol_line: None,
                working_directory: None,
            }],
            dry_run: Some(true),
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.batch_insert(Parameters(input)).await;
        assert!(
            result.contains("Source authority: disk-refreshed"),
            "result: {result}"
        );
        assert!(
            result.contains("Write semantics: dry run (no writes)"),
            "result: {result}"
        );

        let on_disk = std::fs::read_to_string(&file_path).unwrap();
        assert!(
            on_disk.contains("fresh"),
            "dry run should leave disk untouched: {on_disk}"
        );
    }

    #[tokio::test]
    async fn test_batch_insert_dry_run_skips_write() {
        use crate::protocol::edit::{BatchInsertInput, InsertPosition, InsertTarget};

        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let a_content = b"fn handler_a() {}\n";
        let b_content = b"fn handler_b() {}\n";
        std::fs::write(src_dir.join("a.rs"), a_content).unwrap();
        std::fs::write(src_dir.join("b.rs"), b_content).unwrap();

        let mut files = vec![];
        for (path, content) in [
            ("src/a.rs", a_content as &[u8]),
            ("src/b.rs", b_content as &[u8]),
        ] {
            let result = crate::parsing::process_file(path, content, LanguageId::Rust);
            let indexed =
                crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
            files.push((path.to_string(), indexed));
        }
        let index = make_live_index_ready(files);
        let server = make_server_with_root(index, Some(dir.path().to_path_buf()));

        let input = BatchInsertInput {
            content: "fn logging() {}".to_string(),
            position: InsertPosition::After,
            targets: vec![
                InsertTarget {
                    path: "src/a.rs".to_string(),
                    name: "handler_a".to_string(),
                    kind: None,
                    symbol_line: None,
                    working_directory: None,
                },
                InsertTarget {
                    path: "src/b.rs".to_string(),
                    name: "handler_b".to_string(),
                    kind: None,
                    symbol_line: None,
                    working_directory: None,
                },
            ],
            dry_run: Some(true),
            idempotency_key: None,
            working_directory: None,
        };
        let result = server.batch_insert(Parameters(input)).await;
        assert!(result.contains("[DRY RUN]"), "result: {result}");
        assert!(result.contains("Match type: exact"), "result: {result}");
        assert!(
            result.contains("Write semantics: dry run (no writes)"),
            "result: {result}"
        );

        // Files must be unchanged.
        let a = std::fs::read_to_string(src_dir.join("a.rs")).unwrap();
        assert!(!a.contains("logging"), "dry_run must not write: {a}");
        let b = std::fs::read_to_string(src_dir.join("b.rs")).unwrap();
        assert!(!b.contains("logging"), "dry_run must not write: {b}");
    }

    #[tokio::test]
    async fn test_ask_reports_exact_route_confidence() {
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let content = b"fn helper() {}\nfn caller() { helper(); }\n";
        std::fs::write(src_dir.join("lib.rs"), content).unwrap();

        let result = crate::parsing::process_file("src/lib.rs", content, LanguageId::Rust);
        let indexed =
            crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
        let server = make_server_with_root(
            make_live_index_ready(vec![("src/lib.rs".to_string(), indexed)]),
            Some(dir.path().to_path_buf()),
        );

        let input = super::SmartQueryInput {
            query: "who calls helper".to_string(),
            max_tokens: None,
        };
        let result = server.ask(Parameters(input)).await;
        assert!(
            result.contains("Route confidence: exact"),
            "result: {result}"
        );
        assert!(
            result.contains("Chosen tool: find_references"),
            "result: {result}"
        );
        assert!(
            result.contains("Invocation: find_references"),
            "result: {result}"
        );
        assert!(
            result.contains("matched explicit caller/reference phrasing"),
            "result: {result}"
        );
    }

    #[tokio::test]
    async fn test_ask_tool_help_returns_impact_analysis_catalog() {
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let content = b"fn helper() {}\n";
        std::fs::write(src_dir.join("lib.rs"), content).unwrap();

        let result = crate::parsing::process_file("src/lib.rs", content, LanguageId::Rust);
        let indexed =
            crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
        let server = make_server_with_root(
            make_live_index_ready(vec![("src/lib.rs".to_string(), indexed)]),
            Some(dir.path().to_path_buf()),
        );

        let input = super::SmartQueryInput {
            query: "what tools can I use for impact analysis?".to_string(),
            max_tokens: None,
        };
        let result = server.ask(Parameters(input)).await;

        // Routed to the tool catalog, not a code search.
        assert!(result.contains("Chosen tool: ask"), "result: {result}");
        // The impact-analysis catalog group must list all impact tools.
        for tool in [
            "find_references",
            "find_dependents",
            "get_symbol_context",
            "analyze_file_impact",
            "what_changed",
            "diff_symbols",
        ] {
            assert!(
                result.contains(tool),
                "impact-analysis catalog missing `{tool}`; result:\n{result}"
            );
        }
    }

    #[tokio::test]
    async fn test_ask_reports_inferred_route_confidence() {
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let content = b"struct LiveIndex;\n";
        std::fs::write(src_dir.join("lib.rs"), content).unwrap();

        let result = crate::parsing::process_file("src/lib.rs", content, LanguageId::Rust);
        let indexed =
            crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
        let server = make_server_with_root(
            make_live_index_ready(vec![("src/lib.rs".to_string(), indexed)]),
            Some(dir.path().to_path_buf()),
        );

        let input = super::SmartQueryInput {
            query: "LiveIndex".to_string(),
            max_tokens: None,
        };
        let result = server.ask(Parameters(input)).await;
        assert!(
            result.contains("Route confidence: inferred"),
            "result: {result}"
        );
        assert!(
            result.contains("Chosen tool: search_symbols"),
            "result: {result}"
        );
        assert!(result.contains("Suggested next step:"), "result: {result}");
    }

    #[tokio::test]
    async fn test_ask_compound_lookup_extracts_leading_symbol() {
        // SF-005: a compound lookup like "Where is X defined and what module
        // imports it?" must route to search_symbols with ONLY the leading symbol
        // token, report `inferred` confidence (not a confident false negative), and
        // chain a follow-up hint naming search_symbols -> find_references.
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();
        std::fs::write(
            src_dir.join("controller.ts"),
            b"class TestingController {}\n",
        )
        .unwrap();

        let mut indexed = make_file(
            "src/controller.ts",
            b"class TestingController {}\n",
            vec![make_symbol("TestingController", SymbolKind::Class, 1, 1)],
        );
        indexed.1.language = LanguageId::TypeScript;
        let server = make_server_with_root(
            make_live_index_ready(vec![indexed]),
            Some(dir.path().to_path_buf()),
        );

        let input = super::SmartQueryInput {
            query: "Where is TestingController defined and what module imports it?".to_string(),
            max_tokens: None,
        };
        let result = server.ask(Parameters(input)).await;

        assert!(
            result.contains("Route confidence: inferred"),
            "compound lookup must downgrade to inferred, not exact: {result}"
        );
        assert!(
            result.contains("Chosen tool: search_symbols"),
            "result: {result}"
        );
        assert!(
            result.contains("Invocation: search_symbols(query=\"TestingController\")"),
            "invocation must echo the leading symbol token only: {result}"
        );
        assert!(
            !result.contains("imports it"),
            "the trailing clause must not leak into the symbol query: {result}"
        );
        assert!(
            result.contains("Suggested next step:"),
            "downgraded route must chain a follow-up hint: {result}"
        );
        assert!(
            result.contains("search_symbols -> find_references"),
            "follow-up hint must name the chained sequence: {result}"
        );
    }

    #[tokio::test]
    async fn test_ask_reports_fallback_route_confidence() {
        let server = make_server(make_live_index_ready(vec![]));
        let input = super::SmartQueryInput {
            query: "error handling patterns".to_string(),
            max_tokens: None,
        };
        let result = server.ask(Parameters(input)).await;
        assert!(
            result.contains("Route confidence: fallback"),
            "result: {result}"
        );
        assert!(result.contains("Chosen tool: explore"), "result: {result}");
        assert!(
            result.contains(
                "Rationale: no stronger route matched, so SymForge fell back to conceptual exploration"
            ),
            "fallback ask routes should explain why explore was chosen: {result}"
        );
        assert!(result.contains("Suggested next step:"), "result: {result}");
    }

    #[tokio::test]
    async fn test_ask_upgrades_exact_symbol_understanding_query() {
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let content = b"struct BusActor;\nimpl BusActor { fn handle(&self) {} }\n";
        std::fs::write(src_dir.join("bus.rs"), content).unwrap();

        let result = crate::parsing::process_file("src/bus.rs", content, LanguageId::Rust);
        let indexed =
            crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
        let server = make_server_with_root(
            make_live_index_ready(vec![("src/bus.rs".to_string(), indexed)]),
            Some(dir.path().to_path_buf()),
        );

        let input = super::SmartQueryInput {
            query: "how does BusActor work?".to_string(),
            max_tokens: None,
        };
        let result = server.ask(Parameters(input)).await;

        assert!(
            result.contains("Route confidence: inferred"),
            "result: {result}"
        );
        assert!(
            result.contains("Chosen tool: get_symbol_context"),
            "result: {result}"
        );
        assert!(
            result.contains("Invocation: get_symbol_context(name=\"BusActor\")"),
            "result: {result}"
        );
        assert!(
            result.contains("detected an exact indexed symbol inside a broad explanation query"),
            "result: {result}"
        );
    }

    #[tokio::test]
    async fn test_ask_upgrades_multi_definition_symbol_prefers_src() {
        // Symbol "MyHandler" appears in both src/ and tests/; the src/ definition
        // must win and the query must still upgrade to get_symbol_context.
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        let tests_dir = dir.path().join("tests");
        std::fs::create_dir_all(&src_dir).unwrap();
        std::fs::create_dir_all(&tests_dir).unwrap();

        // src/handler.rs — large definition (lines 0-40)
        let src_content: &[u8] = b"struct MyHandler;\nimpl MyHandler {\n    fn run(&self) {}\n    fn stop(&self) {}\n    fn pause(&self) {}\n    fn resume(&self) {}\n    fn status(&self) {}\n    fn reset(&self) {}\n    fn init(&self) {}\n    fn close(&self) {}\n    fn open(&self) {}\n    fn flush(&self) {}\n    fn drain(&self) {}\n    fn process(&self) {}\n    fn submit(&self) {}\n    fn cancel(&self) {}\n    fn wait(&self) {}\n    fn poll(&self) {}\n    fn tick(&self) {}\n    fn advance(&self) {}\n    fn check(&self) {}\n}\n";
        std::fs::write(src_dir.join("handler.rs"), src_content).unwrap();

        // tests/test_handler.rs — short test stub (lines 0-5)
        let test_content: &[u8] = b"struct MyHandler;\n#[test]\nfn it_works() {}\n";
        std::fs::write(tests_dir.join("test_handler.rs"), test_content).unwrap();

        let src_result =
            crate::parsing::process_file("src/handler.rs", src_content, LanguageId::Rust);
        let src_indexed = crate::live_index::store::IndexedFile::from_parse_result(
            src_result,
            src_content.to_vec(),
        );

        let test_result =
            crate::parsing::process_file("tests/test_handler.rs", test_content, LanguageId::Rust);
        let test_indexed = crate::live_index::store::IndexedFile::from_parse_result(
            test_result,
            test_content.to_vec(),
        );

        let server = make_server_with_root(
            make_live_index_ready(vec![
                ("src/handler.rs".to_string(), src_indexed),
                ("tests/test_handler.rs".to_string(), test_indexed),
            ]),
            Some(dir.path().to_path_buf()),
        );

        let input = super::SmartQueryInput {
            query: "how does MyHandler work?".to_string(),
            max_tokens: None,
        };
        let result = server.ask(Parameters(input)).await;

        assert!(
            result.contains("Chosen tool: get_symbol_context"),
            "multi-definition symbol should still upgrade to get_symbol_context: {result}"
        );
        assert!(
            result.contains("Invocation: get_symbol_context(name=\"MyHandler\")"),
            "result: {result}"
        );
    }

    #[tokio::test]
    async fn test_ask_keeps_broad_explore_for_generic_understanding_query() {
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let content = b"fn handle() {}\n";
        std::fs::write(src_dir.join("lib.rs"), content).unwrap();

        let result = crate::parsing::process_file("src/lib.rs", content, LanguageId::Rust);
        let indexed =
            crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
        let server = make_server_with_root(
            make_live_index_ready(vec![("src/lib.rs".to_string(), indexed)]),
            Some(dir.path().to_path_buf()),
        );

        let input = super::SmartQueryInput {
            query: "how does handle work?".to_string(),
            max_tokens: None,
        };
        let result = server.ask(Parameters(input)).await;

        assert!(result.contains("Chosen tool: explore"), "result: {result}");
        assert!(
            !result.contains("Chosen tool: get_symbol_context"),
            "generic symbol names should not hijack broad explain queries: {result}"
        );
    }

    #[tokio::test]
    async fn test_ask_upgrades_broad_type_query_to_find_implementations() {
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let content = b"trait Actor {}\nstruct BusActor;\nimpl Actor for BusActor {}\nstruct WorkerActor;\nimpl Actor for WorkerActor {}\n";
        std::fs::write(src_dir.join("actors.rs"), content).unwrap();

        let result = crate::parsing::process_file("src/actors.rs", content, LanguageId::Rust);
        let indexed =
            crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
        let server = make_server_with_root(
            make_live_index_ready(vec![("src/actors.rs".to_string(), indexed)]),
            Some(dir.path().to_path_buf()),
        );

        let input = super::SmartQueryInput {
            query: "what are the main actor types?".to_string(),
            max_tokens: None,
        };
        let result = server.ask(Parameters(input)).await;

        assert!(
            result.contains("Route confidence: inferred"),
            "result: {result}"
        );
        assert!(
            result.contains("Chosen tool: find_references"),
            "result: {result}"
        );
        assert!(
            result
                .contains("Invocation: find_references(name=\"Actor\", mode=\"implementations\")"),
            "result: {result}"
        );
        assert!(
            result.contains("trait-like symbol inside a broad explanation query"),
            "result: {result}"
        );
    }

    #[tokio::test]
    async fn test_ask_keeps_broad_explore_for_generic_type_query_without_trait_candidate() {
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let content = b"trait Actor {}\nstruct BusActor;\nimpl Actor for BusActor {}\n";
        std::fs::write(src_dir.join("actors.rs"), content).unwrap();

        let result = crate::parsing::process_file("src/actors.rs", content, LanguageId::Rust);
        let indexed =
            crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
        let server = make_server_with_root(
            make_live_index_ready(vec![("src/actors.rs".to_string(), indexed)]),
            Some(dir.path().to_path_buf()),
        );

        let input = super::SmartQueryInput {
            query: "what are the main types?".to_string(),
            max_tokens: None,
        };
        let result = server.ask(Parameters(input)).await;

        assert!(result.contains("Chosen tool: explore"), "result: {result}");
        assert!(
            !result.contains("Chosen tool: find_references"),
            "generic type phrasing should stay broad without a distinctive trait candidate: {result}"
        );
    }

    #[tokio::test]
    async fn test_ask_preserves_path_scope_hint_for_caller_query() {
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let content = b"fn helper() {}\nfn caller() { helper(); }\n";
        std::fs::write(src_dir.join("lib.rs"), content).unwrap();

        let result = crate::parsing::process_file("src/lib.rs", content, LanguageId::Rust);
        let indexed =
            crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
        let server = make_server_with_root(
            make_live_index_ready(vec![("src/lib.rs".to_string(), indexed)]),
            Some(dir.path().to_path_buf()),
        );

        let input = super::SmartQueryInput {
            query: "who calls helper in src/lib.rs".to_string(),
            max_tokens: None,
        };
        let result = server.ask(Parameters(input)).await;

        assert!(
            result.contains("Invocation: find_references(name=\"helper\", path=\"src/lib.rs\")"),
            "result: {result}"
        );
        assert!(result.contains("src/lib.rs"), "result: {result}");
        assert!(result.contains("caller"), "result: {result}");
    }

    #[tokio::test]
    async fn test_edit_plan_accepts_path_qualified_symbol_target() {
        let dir = tempfile::tempdir().unwrap();
        let src_dir = dir.path().join("src");
        std::fs::create_dir_all(&src_dir).unwrap();

        let content = b"fn helper() {}\n";
        std::fs::write(src_dir.join("lib.rs"), content).unwrap();

        let result = crate::parsing::process_file("src/lib.rs", content, LanguageId::Rust);
        let indexed =
            crate::live_index::store::IndexedFile::from_parse_result(result, content.to_vec());
        let server = make_server_with_root(
            make_live_index_ready(vec![("src/lib.rs".to_string(), indexed)]),
            Some(dir.path().to_path_buf()),
        );

        let input = super::EditPlanInput {
            target: "src/lib.rs::helper".to_string(),
        };
        let result = server.edit_plan(Parameters(input)).await;

        assert!(
            result.contains("Found 1 symbol(s) matching"),
            "result: {result}"
        );
        assert!(result.contains("src/lib.rs"), "result: {result}");
        assert!(result.contains("helper"), "result: {result}");
        assert!(!result.contains("not found"), "result: {result}");
    }

    #[test]
    fn test_filter_paths_code_only() {
        let paths = vec![
            "src/main.rs".to_string(),
            "README.md".to_string(),
            "Cargo.toml".to_string(),
            "src/lib.rs".to_string(),
            ".github/workflows/ci.yml".to_string(),
            "package.json".to_string(),
        ];
        let result = super::filter_paths_by_prefix_and_language(paths, None, None, true).unwrap();
        assert_eq!(result, vec!["src/main.rs", "src/lib.rs"]);
    }

    #[test]
    fn test_parse_language_filter_frontend_and_data_languages() {
        use super::parse_language_filter;
        use crate::domain::index::LanguageId;

        // Frontend languages previously missing from the filter
        assert_eq!(
            parse_language_filter(Some("html")),
            Ok(Some(LanguageId::Html))
        );
        assert_eq!(
            parse_language_filter(Some("HTML")),
            Ok(Some(LanguageId::Html))
        );
        assert_eq!(
            parse_language_filter(Some("css")),
            Ok(Some(LanguageId::Css))
        );
        assert_eq!(
            parse_language_filter(Some("CSS")),
            Ok(Some(LanguageId::Css))
        );
        assert_eq!(
            parse_language_filter(Some("scss")),
            Ok(Some(LanguageId::Scss))
        );
        assert_eq!(
            parse_language_filter(Some("SCSS")),
            Ok(Some(LanguageId::Scss))
        );

        // Data / config languages previously missing
        assert_eq!(
            parse_language_filter(Some("json")),
            Ok(Some(LanguageId::Json))
        );
        assert_eq!(
            parse_language_filter(Some("JSON")),
            Ok(Some(LanguageId::Json))
        );
        assert_eq!(
            parse_language_filter(Some("toml")),
            Ok(Some(LanguageId::Toml))
        );
        assert_eq!(
            parse_language_filter(Some("TOML")),
            Ok(Some(LanguageId::Toml))
        );
        assert_eq!(
            parse_language_filter(Some("yaml")),
            Ok(Some(LanguageId::Yaml))
        );
        assert_eq!(
            parse_language_filter(Some("YAML")),
            Ok(Some(LanguageId::Yaml))
        );
        assert_eq!(
            parse_language_filter(Some("markdown")),
            Ok(Some(LanguageId::Markdown))
        );
        assert_eq!(
            parse_language_filter(Some("md")),
            Ok(Some(LanguageId::Markdown))
        );
        assert_eq!(
            parse_language_filter(Some("env")),
            Ok(Some(LanguageId::Env))
        );

        // Existing languages still work
        assert_eq!(
            parse_language_filter(Some("Rust")),
            Ok(Some(LanguageId::Rust))
        );
        assert_eq!(
            parse_language_filter(Some("TypeScript")),
            Ok(Some(LanguageId::TypeScript))
        );

        // Empty / None returns Ok(None)
        assert_eq!(parse_language_filter(None), Ok(None));
        assert_eq!(parse_language_filter(Some("")), Ok(None));

        // Unknown language returns an error
        assert!(parse_language_filter(Some("COBOL")).is_err());
    }

    #[test]
    fn test_normalize_search_text_glob() {
        use super::normalize_search_text_glob;
        // Bare filename → auto-prefix with **/
        assert_eq!(
            normalize_search_text_glob(Some("foo.rs")),
            Some("**/foo.rs".to_string())
        );
        // Path with separator → no prefix
        assert_eq!(
            normalize_search_text_glob(Some("src/foo.rs")),
            Some("src/foo.rs".to_string())
        );
        // Already has glob char → no prefix
        assert_eq!(
            normalize_search_text_glob(Some("*.rs")),
            Some("*.rs".to_string())
        );
        assert_eq!(
            normalize_search_text_glob(Some("[test]*.rs")),
            Some("[test]*.rs".to_string())
        );
        assert_eq!(
            normalize_search_text_glob(Some("src/**/*.ts")),
            Some("src/**/*.ts".to_string())
        );
        // Leading ./ stripped, then bare → auto-prefix
        assert_eq!(
            normalize_search_text_glob(Some("./foo.rs")),
            Some("**/foo.rs".to_string())
        );
        // Backslash replaced, leading / stripped, then bare → auto-prefix
        assert_eq!(
            normalize_search_text_glob(Some("\\foo.rs")),
            Some("**/foo.rs".to_string())
        );
        // Empty / whitespace → None
        assert_eq!(normalize_search_text_glob(Some("")), None);
        assert_eq!(normalize_search_text_glob(Some("  ")), None);
        assert_eq!(normalize_search_text_glob(None), None);
    }

    #[test]
    fn test_lenient_option_vec_native_array() {
        use serde::Deserialize;
        #[derive(Deserialize)]
        struct T {
            #[serde(default, deserialize_with = "super::lenient_option_vec")]
            items: Option<Vec<String>>,
        }
        let t: T = serde_json::from_str(r#"{"items": ["a", "b"]}"#).unwrap();
        assert_eq!(t.items, Some(vec!["a".to_string(), "b".to_string()]));
    }

    #[test]
    fn test_lenient_option_vec_stringified_array() {
        use serde::Deserialize;
        #[derive(Deserialize)]
        struct T {
            #[serde(default, deserialize_with = "super::lenient_option_vec")]
            items: Option<Vec<String>>,
        }
        let t: T = serde_json::from_str(r#"{"items": "[\"a\", \"b\"]"}"#).unwrap();
        assert_eq!(t.items, Some(vec!["a".to_string(), "b".to_string()]));
    }

    #[test]
    fn test_lenient_option_vec_null() {
        use serde::Deserialize;
        #[derive(Deserialize)]
        struct T {
            #[serde(default, deserialize_with = "super::lenient_option_vec")]
            items: Option<Vec<String>>,
        }
        let t: T = serde_json::from_str(r#"{"items": null}"#).unwrap();
        assert_eq!(t.items, None);
    }

    #[test]
    fn test_lenient_option_vec_empty_string() {
        use serde::Deserialize;
        #[derive(Deserialize)]
        struct T {
            #[serde(default, deserialize_with = "super::lenient_option_vec")]
            items: Option<Vec<String>>,
        }
        let t: T = serde_json::from_str(r#"{"items": ""}"#).unwrap();
        assert_eq!(t.items, None);
    }

    #[test]
    fn test_lenient_vec_required_native() {
        use serde::Deserialize;
        #[derive(Deserialize)]
        struct T {
            #[serde(deserialize_with = "super::lenient_vec_required")]
            items: Vec<String>,
        }
        let t: T = serde_json::from_str(r#"{"items": ["x"]}"#).unwrap();
        assert_eq!(t.items, vec!["x".to_string()]);
    }

    #[test]
    fn test_lenient_vec_required_stringified() {
        use serde::Deserialize;
        #[derive(Deserialize)]
        struct T {
            #[serde(deserialize_with = "super::lenient_vec_required")]
            items: Vec<String>,
        }
        let t: T = serde_json::from_str(r#"{"items": "[\"x\"]"}"#).unwrap();
        assert_eq!(t.items, vec!["x".to_string()]);
    }

    #[test]
    fn test_diff_symbols_compact_shows_omission_note() {
        let dir = init_git_repo();
        let a_path = dir.path().join("a.rs");
        let b_path = dir.path().join("b.rs");
        std::fs::write(&a_path, "fn old_func() {}\n").unwrap();
        std::fs::write(&b_path, "// comment\n").unwrap();
        run_git(dir.path(), &["add", "."]);
        run_git(dir.path(), &["commit", "-m", "base"]);

        std::fs::write(&a_path, "fn old_func() {}\nfn new_func() {}\n").unwrap();
        std::fs::write(&b_path, "// changed comment\n").unwrap();
        run_git(dir.path(), &["add", "."]);
        run_git(dir.path(), &["commit", "-m", "changes"]);

        let repo = crate::git::GitRepo::open(dir.path()).expect("open git repo");

        let result = super::format::diff_symbols_result_view(
            "HEAD~1",
            "HEAD",
            &["a.rs", "b.rs"],
            &repo,
            true,
            false,
        );

        assert!(
            result.contains("1 file(s) with only non-symbol changes omitted"),
            "compact mode should note omitted files: {result}"
        );
    }

    #[tokio::test]
    async fn test_diff_symbols_reports_trust_envelope() {
        let repo = init_git_repo();
        let file_path = repo.path().join("src/lib.rs");
        std::fs::create_dir_all(repo.path().join("src")).unwrap();
        std::fs::write(&file_path, "fn old_func() {}\n").unwrap();
        run_git(repo.path(), &["add", "."]);
        run_git(repo.path(), &["commit", "-m", "base"]);
        std::fs::write(&file_path, "fn old_func() {}\nfn new_func() {}\n").unwrap();
        run_git(repo.path(), &["add", "."]);
        run_git(repo.path(), &["commit", "-m", "changes"]);

        let server = make_server_with_root(
            make_live_index_ready(vec![]),
            Some(repo.path().to_path_buf()),
        );
        let result = server
            .diff_symbols(Parameters(super::DiffSymbolsInput {
                base: Some("HEAD~1".to_string()),
                target: Some("HEAD".to_string()),
                path_prefix: None,
                language: None,
                code_only: None,
                compact: Some(true),
                summary_only: None,
                estimate: None,
                max_tokens: None,
            }))
            .await;

        assert!(
            result.contains("Match type: exact (git ref diff)"),
            "diff_symbols should report match type: {result}"
        );
        assert!(
            result.contains("Source authority: git ref diff"),
            "diff_symbols should report git authority: {result}"
        );
        assert!(
            result.contains("Parse state: high (tree-sitter AST extraction for supported languages, regex fallback for others)"),
            "diff_symbols should report AST parse state: {result}"
        );
        assert!(
            result.contains("Scope: git diff `HEAD~1`...`HEAD`; compact output"),
            "diff_symbols should report scope: {result}"
        );
    }

    // Frecency bump/no-bump wiring is verified end-to-end against a real
    // `FrecencyStore` on a tempdir in `tests/frecency_ranking.rs`. That suite
    // is the canonical home for those assertions.
}
