Skip to main content

pathfinder_lib/
server.rs

1//! Pathfinder MCP Server — tool registration and dispatch.
2//!
3//! Implements `rmcp::ServerHandler` with all 18 Pathfinder tools.
4//!
5//! # Module Layout
6//! - [`helpers`] — error conversion, stub builder, language detection
7//! - [`types`] — all parameter and response structs
8//! - [`tools`] — handler logic, one submodule per tool group:
9//!   - [`tools::search`] — `search_codebase`
10//!   - [`tools::repo_map`] — `get_repo_map`
11//!   - [`tools::symbols`] — `read_symbol_scope`, `read_with_deep_context`
12//!   - [`tools::navigation`] — `get_definition`, `analyze_impact`
13//!   - [`tools::file_ops`] — `create_file`, `delete_file`, `read_file`, `write_file`
14
15/// Duration after which a negative probe cache entry expires.
16/// Allows re-probing an LSP that was still starting when first checked.
17const PROBE_NEGATIVE_TTL_SECS: u64 = 60;
18
19/// A cached probe result with optional expiry for negative entries.
20///
21/// Positive entries (success) are cached indefinitely for liveness re-probe.
22/// Negative entries (failure) expire after `PROBE_NEGATIVE_TTL_SECS` to allow
23/// an LSP that was still starting to be re-probed later.
24#[derive(Clone)]
25pub(crate) struct ProbeCacheEntry {
26    /// Whether the probe succeeded.
27    pub(crate) success: bool,
28    /// When this entry was created. Used to check TTL for negative entries and age for liveness re-probe.
29    pub(crate) created_at: std::time::Instant,
30    /// Optional TTL for expiration (negative entries only). Positive entries use age-based re-probe.
31    pub(crate) ttl: Option<std::time::Duration>,
32}
33
34impl ProbeCacheEntry {
35    pub(crate) fn new(success: bool) -> Self {
36        Self {
37            success,
38            created_at: std::time::Instant::now(),
39            ttl: if success {
40                None // Positive entries: use age-based re-probe instead of expiry
41            } else {
42                Some(std::time::Duration::from_secs(PROBE_NEGATIVE_TTL_SECS))
43            },
44        }
45    }
46
47    /// Returns true if this entry is still valid.
48    /// Positive entries never expire (liveness re-probe handles staleness).
49    /// Negative entries expire after `PROBE_NEGATIVE_TTL_SECS`.
50    pub(crate) fn is_valid(&self) -> bool {
51        match self.ttl {
52            Some(ttl) => self.created_at.elapsed() < ttl,
53            None => true, // Positive entries never expire (liveness re-probe handles staleness)
54        }
55    }
56
57    /// How old is this cache entry in seconds?
58    /// Used by liveness probe to determine when to re-probe "ready" languages.
59    pub(crate) fn age_secs(&self) -> u64 {
60        self.created_at.elapsed().as_secs()
61    }
62}
63
64mod helpers;
65mod tools;
66/// Module containing type definitions.
67pub mod types;
68
69use types::{
70    AnalyzeImpactParams, CreateFileParams, CreateFileResponse, DeleteFileParams,
71    DeleteFileResponse, DeleteSymbolParams, EditResponse, GetDefinitionParams,
72    GetDefinitionResponse, GetRepoMapParams, InsertAfterParams, InsertBeforeParams, ReadFileParams,
73    ReadSourceFileParams, ReadSymbolScopeParams, ReadWithDeepContextParams, ReplaceBodyParams,
74    ReplaceFullParams, SearchCodebaseParams, SearchCodebaseResponse, ValidateOnlyParams,
75    WriteFileParams,
76};
77
78use pathfinder_common::config::PathfinderConfig;
79use pathfinder_common::sandbox::Sandbox;
80use pathfinder_common::types::WorkspaceRoot;
81use pathfinder_lsp::{Lawyer, LspClient, NoOpLawyer};
82use pathfinder_search::{RipgrepScout, Scout};
83use pathfinder_treesitter::{Surgeon, TreeSitterSurgeon};
84
85use rmcp::handler::server::tool::ToolRouter;
86use rmcp::handler::server::wrapper::{Json, Parameters};
87use rmcp::model::{ErrorData, Implementation, ServerCapabilities, ServerInfo};
88use rmcp::{tool, tool_handler, tool_router, ServerHandler};
89
90use std::sync::Arc;
91
92/// The main Pathfinder MCP server.
93///
94/// Holds shared workspace state and dispatches MCP tool calls.
95#[derive(Clone)]
96pub struct PathfinderServer {
97    workspace_root: Arc<WorkspaceRoot>,
98    sandbox: Arc<Sandbox>,
99    scout: Arc<dyn Scout>,
100    surgeon: Arc<dyn Surgeon>,
101    lawyer: Arc<dyn Lawyer>,
102    tool_router: ToolRouter<Self>,
103    /// Cache of probe results per language to avoid redundant LSP calls.
104    ///
105    /// Positive results (true) are cached indefinitely — once a language's LSP
106    /// responds to a probe, it stays "ready" for the session.
107    ///
108    /// Negative results (false) are cached with a TTL of 60s. This prevents
109    /// hammering a still-starting LSP with probes on every `lsp_health` call,
110    /// while allowing recovery once the LSP finishes initializing.
111    probe_cache: Arc<std::sync::Mutex<std::collections::HashMap<String, ProbeCacheEntry>>>,
112}
113
114impl PathfinderServer {
115    /// Create a new Pathfinder server backed by the real Ripgrep scout, Tree-sitter
116    /// surgeon, and `LspClient` for LSP operations.
117    ///
118    /// Zero-Config language detection (PRD §6.5) runs synchronously during construction.
119    /// LSP processes are started **lazily** — only when the first LSP-dependent tool call
120    /// is made for a given language.
121    ///
122    /// If Zero-Config detection fails (e.g., unreadable workspace directory), the server
123    /// falls back to `NoOpLawyer` and logs a warning. All tools remain functional in
124    /// degraded mode.
125    #[must_use]
126    pub async fn new(workspace_root: WorkspaceRoot, config: PathfinderConfig) -> Self {
127        let sandbox = Sandbox::new(workspace_root.path(), &config.sandbox);
128
129        let lawyer: Arc<dyn Lawyer> =
130            match LspClient::new(workspace_root.path(), Arc::new(config.clone())).await {
131                Ok(client) => {
132                    // Kick off background initialization so LSP processes are
133                    // already loading while the agent issues its first non-LSP
134                    // tool calls (get_repo_map, search_codebase, etc.).
135                    client.warm_start();
136                    tracing::info!(
137                        workspace = %workspace_root.path().display(),
138                        "LspClient initialised (warm start in progress)"
139                    );
140                    Arc::new(client)
141                }
142                Err(e) => {
143                    tracing::warn!(
144                        error = %e,
145                        "LSP Zero-Config detection failed — degraded mode (NoOpLawyer)"
146                    );
147                    Arc::new(NoOpLawyer)
148                }
149            };
150
151        Self::with_all_engines(
152            workspace_root,
153            config,
154            sandbox,
155            Arc::new(RipgrepScout),
156            Arc::new(TreeSitterSurgeon::new(100)), // Cache capacity of 100 files
157            lawyer,
158        )
159    }
160
161    /// Create a server with injected Scout and Surgeon engines (for testing).
162    ///
163    /// Uses a `NoOpLawyer` for LSP operations — keeps existing tests unchanged.
164    #[must_use]
165    #[cfg_attr(not(test), allow(dead_code))]
166    pub fn with_engines(
167        workspace_root: WorkspaceRoot,
168        config: PathfinderConfig,
169        sandbox: Sandbox,
170        scout: Arc<dyn Scout>,
171        surgeon: Arc<dyn Surgeon>,
172    ) -> Self {
173        Self::with_all_engines(
174            workspace_root,
175            config,
176            sandbox,
177            scout,
178            surgeon,
179            Arc::new(NoOpLawyer),
180        )
181    }
182
183    /// Create a server with all three engines injected (for testing with a `MockLawyer`).
184    #[must_use]
185    #[allow(clippy::needless_pass_by_value)] // Preserve API compatibility; 20+ call sites in tests
186    pub fn with_all_engines(
187        workspace_root: WorkspaceRoot,
188        _config: PathfinderConfig,
189        sandbox: Sandbox,
190        scout: Arc<dyn Scout>,
191        surgeon: Arc<dyn Surgeon>,
192        lawyer: Arc<dyn Lawyer>,
193    ) -> Self {
194        Self {
195            workspace_root: Arc::new(workspace_root),
196            sandbox: Arc::new(sandbox),
197            scout,
198            surgeon,
199            lawyer,
200            tool_router: Self::tool_router(),
201            probe_cache: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
202        }
203    }
204}
205
206// ── Tool Router (defines all 18 tools) ──────────────────────────────
207
208#[tool_router]
209impl PathfinderServer {
210    #[tool(
211        name = "search_codebase",
212        description = "Search the codebase for a text pattern. Returns matching lines with surrounding context. Each match includes an 'enclosing_semantic_path' (the AST symbol containing the match) and 'version_hash' (for immediate editing without a separate read). The version_hash in each match is immediately usable as base_version for edit tools — no additional read required. Use path_glob to narrow the search scope.\n\n**E4 parameters (token efficiency):**\n- `exclude_glob` — Glob pattern for files to exclude before search (e.g. `**/*.test.*`). Applied at the file-walk level so excluded files are never read.\n- `known_files` — List of file paths already in agent context. Matches in these files are returned with minimal metadata only (`file`, `line`, `column`, `enclosing_semantic_path`, `version_hash`) — `content` and context lines are omitted.\n- `group_by_file` — When `true`, results are returned in `file_groups` (one group per file with a single shared `version_hash`). Known-file matches appear in `known_matches`; others in `matches` inside each group."
213    )]
214    async fn search_codebase(
215        &self,
216        Parameters(params): Parameters<SearchCodebaseParams>,
217    ) -> Result<Json<SearchCodebaseResponse>, ErrorData> {
218        self.search_codebase_impl(params).await
219    }
220
221    #[tool(
222        name = "get_repo_map",
223        description = "Returns the structural skeleton of the project as an indented tree of classes, functions, and type signatures. IMPORTANT: Each symbol has its full semantic path in a trailing comment. You MUST copy-paste these EXACT paths into read/edit tools. Also returns version_hashes per file for immediate editing. The version_hashes are immediately usable as base_version for edit tools — no additional read required. Two budget knobs control coverage: `max_tokens` is the total token budget (default 16000); `max_tokens_per_file` caps detail per file before collapsing to a stub (default 2000). When `coverage_percent` is low, increase `max_tokens`. When files show `[TRUNCATED DUE TO SIZE]`, increase `max_tokens_per_file`. Use `visibility=all` to include private symbols for auditing. Module scopes (e.g., Rust `mod tests`, `mod types`) are only shown when `visibility` is set to `\"all\"`. They are hidden in public-only maps. The `depth` parameter (default 5) controls directory traversal depth; increase it for deeply-nested repos when `coverage_percent` is low.\n\n**Temporal & extension filters (Epic E6):**\n- `changed_since` — Git ref or duration to show only recently-modified files (e.g., `HEAD~5`, `3h`, `2024-01-01`). Useful for reviewing what changed in a PR or recent session. When git is unavailable the parameter is silently ignored and `degraded: true` is set in the response.\n- `include_extensions` — Only include files with these extensions (e.g., `[\"ts\", \"tsx\"]`). Mutually exclusive with `exclude_extensions`.\n- `exclude_extensions` — Exclude files with these extensions (e.g., `[\"md\", \"json\"]`). Mutually exclusive with `include_extensions`."
224    )]
225    async fn get_repo_map(
226        &self,
227        Parameters(params): Parameters<GetRepoMapParams>,
228    ) -> Result<rmcp::model::CallToolResult, rmcp::model::ErrorData> {
229        self.get_repo_map_impl(params).await
230    }
231
232    #[tool(
233        name = "read_symbol_scope",
234        description = "Extract the exact source code of a single symbol (function, class, method) by its semantic path. IMPORTANT: semantic_path must ALWAYS include the file path and '::', e.g., 'src/client/process.rs::send'. Returns the code, line range, and version_hash for OCC. The version_hash is immediately usable as base_version for any edit tool — no additional read required."
235    )]
236    async fn read_symbol_scope(
237        &self,
238        Parameters(params): Parameters<ReadSymbolScopeParams>,
239    ) -> Result<rmcp::model::CallToolResult, ErrorData> {
240        self.read_symbol_scope_impl(params).await
241    }
242
243    #[tool(
244        name = "read_source_file",
245        description = "**AST-only.** Only call this on source code files (.rs, .ts, .tsx, .go, .py, .vue, .jsx, .js). For configuration or documentation files (YAML, TOML, JSON, Markdown, Dockerfile, .env, XML), use `read_file` instead — calling this tool on those file types returns UNSUPPORTED_LANGUAGE.\n\nRead an entire source file and extract its complete AST symbol hierarchy. Returns the full file context, the language detected, OCC hashes, and a nested tree of symbols with their semantic paths. Use this instead of read_symbol_scope when you need broader context beyond a single symbol. The version_hash is immediately usable as base_version for any edit tool — no additional read required.\n\n**detail_level parameter:** `compact` (default) — full source + flat symbol list; `symbols` — symbol tree only, no source; `full` — full source + complete nested AST (v4 behaviour). Use `start_line`/`end_line` to restrict output to a region of interest."
246    )]
247    async fn read_source_file(
248        &self,
249        Parameters(params): Parameters<ReadSourceFileParams>,
250    ) -> Result<rmcp::model::CallToolResult, ErrorData> {
251        self.read_source_file_impl(params).await
252    }
253
254    #[tool(
255        name = "replace_batch",
256        description = "Apply multiple AST-aware edits sequentially within a single source file using a single atomic write. Accepts a list of edits, applies them from the end of the file backwards to prevent offset shifting, and uses a single OCC base_version guard. Use this for refactors touching multiple non-contiguous symbols in one file. IMPORTANT: For each edit, semantic_path must ALWAYS include the file path and '::' (e.g. 'src/mod.rs::func').\n\n**Two targeting modes per edit (E3.1 — Hybrid Batch):**\n\n**Option A — Semantic targeting (existing):** Set `semantic_path`, `edit_type`, and optionally `new_code`. Use for source-code constructs that have a parseable AST symbol.\n\n**Option B — Text targeting (new):** Set `old_text`, `context_line`, and optionally `replacement_text`. Use for Vue `<template>`/`<style>` zones or any region with no usable semantic path. The search scans ±25 lines around `context_line` (1-indexed) for an exact match of `old_text`. Set `normalize_whitespace: true` to collapse `\\s+` → single space before matching (useful for HTML where indentation may vary; do NOT use for Python or YAML).\n\nBoth targeting modes can appear in the same batch — the batch is fully atomic (all-or-nothing). If any edit fails (e.g., `TEXT_NOT_FOUND`), the entire batch is rolled back.\n\n**Schema quick-reference:**\n  Option A: { \"semantic_path\": \"src/file.rs::MyStruct.my_fn\", \"edit_type\": \"replace_body\", \"new_code\": \"...\" }\n  edit_type values: replace_body | replace_full | insert_before | insert_after | insert_into | delete\n  Option B: { \"old_text\": \"<old html>\", \"context_line\": 42, \"replacement_text\": \"<new html>\" }\n  Both modes may be mixed in one batch. `context_line` is required for text targeting.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
257    )]
258    async fn replace_batch(
259        &self,
260        Parameters(params): Parameters<crate::server::types::ReplaceBatchParams>,
261    ) -> Result<Json<EditResponse>, ErrorData> {
262        self.replace_batch_impl(params).await
263    }
264
265    #[tool(
266        name = "read_with_deep_context",
267        description = "Extract a symbol's source code PLUS the signatures of all functions it calls. Use this when you need to understand a function's dependencies before editing it. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/auth.ts::AuthService.login').\n\nReturns a hybrid response: raw source code in `content[0].text` for direct reading, and structured metadata in `structured_content` (JSON) containing `version_hash`, `start_line`, `end_line`, `language`, `dependencies` (callee signatures), `degraded`, and `degraded_reason`.\n\n**Latency note:** The first call after an LSP server starts may take longer while the server indexes the workspace (typically 5–30s for most projects; up to 60s for large Rust projects). Pathfinder automatically opens the target file in the LSP before querying and retries once if the LSP returns no result during warmup. Subsequent calls are fast.\n\n**Degraded mode:** When the LSP is unavailable or still warming up, `degraded=true` with a `degraded_reason` explaining why. The response still returns source code and Tree-sitter context, but `dependencies` will be empty or incomplete. Check `degraded` before relying on dependency data.\n- `no_lsp` — No language server available for this language.\n- `lsp_warmup_empty_unverified` — LSP is indexing; empty dependency list is unverified.\n- `lsp_error` — LSP returned an error; dependencies are from Tree-sitter only."
268    )]
269    async fn read_with_deep_context(
270        &self,
271        Parameters(params): Parameters<ReadWithDeepContextParams>,
272    ) -> Result<rmcp::model::CallToolResult, ErrorData> {
273        self.read_with_deep_context_impl(params).await
274    }
275
276    #[tool(
277        name = "get_definition",
278        description = "Jump to where a symbol is defined. Provide a semantic path to a reference and get back the definition's file, line, and a code preview. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/auth.ts::AuthService.login').\n\n**How it works:** Uses LSP (Language Server Protocol) for precise, cross-file navigation that follows imports, re-exports, and type aliases. When the LSP is still warming up or unavailable, falls back to a multi-strategy ripgrep search (file-scoped → impl-block-scoped → global) and returns `degraded: true` with a `degraded_reason` explaining the fallback.\n\n**Degraded reasons:**\n- `lsp_warmup_grep_fallback` — LSP returned no result (likely still indexing); result is from ripgrep. Verify with `read_source_file`.\n- `grep_fallback_file_scoped` — No LSP; result from file-scoped ripgrep search.\n- `grep_fallback_impl_scoped` — No LSP; result from impl-block ripgrep search.\n- `grep_fallback_global` — No LSP; result from global ripgrep search. Least precise.\n\nWhen `degraded: true`, the result is a best-effort approximation. Always verify with `read_source_file` before relying on it for edits."
279    )]
280    async fn get_definition(
281        &self,
282        Parameters(params): Parameters<GetDefinitionParams>,
283    ) -> Result<Json<GetDefinitionResponse>, ErrorData> {
284        self.get_definition_impl(params).await
285    }
286
287    #[tool(
288        name = "analyze_impact",
289        description = "Find all callers of a symbol (incoming) and all symbols it calls (outgoing). Use this BEFORE refactoring to understand the blast radius of a change. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/mod.rs::func'). Returns version_hashes for all referenced files. The version_hashes are immediately usable as base_version for edit tools — no additional read required.\n\n**How it works:** Uses LSP call hierarchy for precise caller/callee resolution. When the LSP is still warming up, Pathfinder runs a verification probe — if the probe also returns no result, the response is marked `degraded: true` to indicate the empty results may be due to LSP warmup rather than genuinely zero callers.\n\n**Interpreting results:**\n- `degraded: false` — LSP confirmed the results. Empty lists mean genuinely zero callers/callees.\n- `degraded: true` + `degraded_reason: \"lsp_warmup_empty_unverified\"` — LSP may still be indexing. Empty lists are UNVERIFIED — there may be callers/callees the LSP hasn't found yet. Do NOT treat empty as confirmed-zero. Re-run after LSP finishes indexing.\n- `degraded: true` + `degraded_reason: \"no_lsp\"` — No LSP available at all. Results are from grep heuristics only."
290    )]
291    async fn analyze_impact(
292        &self,
293        Parameters(params): Parameters<AnalyzeImpactParams>,
294    ) -> Result<rmcp::model::CallToolResult, ErrorData> {
295        self.analyze_impact_impl(params).await
296    }
297
298    #[tool(
299        name = "lsp_health",
300        description = "Check LSP (Language Server Protocol) health status. Use this at session start to determine whether navigation tools (get_definition, analyze_impact, read_with_deep_context) will return real data or degraded results.\\n\\n**Response fields:**\\n- \\`status\\` — overall readiness: \\\"ready\\\", \\\"warming_up\\\", \\\"starting\\\", or \\\"unavailable\\\".\\n- \\`languages\\` — per-language details with \\`language\\`, \\`status\\`, and optional \\`uptime\\`.\\n\\n**Status values:**\\n- \\\"ready\\\" — LSP has finished indexing. Navigation tools should work reliably.\\n- \\\"warming_up\\\" — LSP is running but still indexing the workspace. Navigation tools may return empty or incomplete results.\\n- \\\"starting\\\" — LSP process has started but not yet initialized.\\n- \\\"unavailable\\\" — No LSP available for this language.\\n\\nWhen \\`status\\` is not \\\"ready\\\", agents should:\\n1. Use Tree-sitter-based tools instead (search_codebase, read_symbol_scope, read_source_file)\\\n2. Wait and retry later, or\\\n3. Treat empty navigation results as UNVERIFIED rather than \\\"confirmed zero\\\".\\n\\n**Optional parameter:** \\`language\\` — filter to a specific language (e.g., \\\"rust\\\", \\\"typescript\\\"). If omitted, checks all available languages."
301    )]
302    async fn lsp_health(
303        &self,
304        Parameters(params): Parameters<crate::server::types::LspHealthParams>,
305    ) -> Result<
306        rmcp::handler::server::wrapper::Json<crate::server::types::LspHealthResponse>,
307        ErrorData,
308    > {
309        self.lsp_health_impl(params).await
310    }
311
312    #[tool(
313        name = "replace_body",
314        description = "Replace the internal logic of a block-scoped construct (function, method, class body, impl block), keeping the signature intact. Provide ONLY the body content — DO NOT include the outer braces or function signature. DO NOT wrap your code in markdown code blocks. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/mod.rs::func').\n\n**LSP validation:** Edit responses include a `validation` field. If `validation_skipped` is true, check `validation_skipped_reason` for why (e.g., `no_lsp`, `lsp_crash`). To see LSP status before editing, call `get_repo_map` and inspect `capabilities.lsp.per_language`.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
315    )]
316    async fn replace_body(
317        &self,
318        Parameters(params): Parameters<ReplaceBodyParams>,
319    ) -> Result<Json<EditResponse>, ErrorData> {
320        self.replace_body_impl(params).await
321    }
322
323    #[tool(
324        name = "replace_full",
325        description = "Replace an entire declaration including its signature, body, decorators, and doc comments. Provide the COMPLETE replacement — anything you omit (decorators, doc comments) will be removed. DO NOT wrap your code in markdown code blocks. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/mod.rs::func').\n\n**LSP validation:** Edit responses include a `validation` field. If `validation_skipped` is true, check `validation_skipped_reason` for why (e.g., `no_lsp`, `lsp_crash`). To see LSP status before editing, call `get_repo_map` and inspect `capabilities.lsp.per_language`.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
326    )]
327    async fn replace_full(
328        &self,
329        Parameters(params): Parameters<ReplaceFullParams>,
330    ) -> Result<Json<EditResponse>, ErrorData> {
331        self.replace_full_impl(params).await
332    }
333
334    #[tool(
335        name = "insert_before",
336        description = "Insert new code BEFORE a target symbol. IMPORTANT: To target a symbol, semantic_path must include the file path and '::' (e.g. 'src/mod.rs::func'). To insert at the TOP of a file (e.g., adding imports), use a bare file path without '::' (e.g. 'src/mod.rs'). Pathfinder automatically adds one blank line between your code and the target.\n\n**LSP validation:** Edit responses include a `validation` field. If `validation_skipped` is true, check `validation_skipped_reason` for why. Call `get_repo_map` and inspect `capabilities.lsp.per_language` to see LSP status upfront.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
337    )]
338    async fn insert_before(
339        &self,
340        Parameters(params): Parameters<InsertBeforeParams>,
341    ) -> Result<Json<EditResponse>, ErrorData> {
342        self.insert_before_impl(params).await
343    }
344
345    #[tool(
346        name = "insert_after",
347        description = "Insert new code AFTER a target symbol. IMPORTANT: To target a symbol, semantic_path must include the file path and '::' (e.g. 'src/mod.rs::func'). To append to the BOTTOM of a file (e.g., adding new classes), use a bare file path without '::' (e.g. 'src/mod.rs'). Pathfinder automatically adds one blank line between the target and your code.\n\n**LSP validation:** Edit responses include a `validation` field. If `validation_skipped` is true, check `validation_skipped_reason` for why. Call `get_repo_map` and inspect `capabilities.lsp.per_language` to see LSP status upfront.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
348    )]
349    async fn insert_after(
350        &self,
351        Parameters(params): Parameters<InsertAfterParams>,
352    ) -> Result<Json<EditResponse>, ErrorData> {
353        self.insert_after_impl(params).await
354    }
355
356    #[tool(
357        name = "insert_into",
358        description = "Insert new code at the END of a container symbol's body \
359            (Module, Class, Struct, Impl, Interface). This is the correct tool \
360            for adding new functions to a test module, new methods to a struct, \
361            or new items to any scope. IMPORTANT: semantic_path must target a \
362            container symbol (e.g. 'src/lib.rs::tests'), NOT a bare file path. \
363            For inserting before/after a specific sibling symbol, use insert_before \
364            or insert_after instead.\n\nbase_version accepts either the full SHA-256 hash \
365            (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), \
366            matching Git convention."
367    )]
368    async fn insert_into(
369        &self,
370        Parameters(params): Parameters<crate::server::types::InsertIntoParams>,
371    ) -> Result<Json<EditResponse>, ErrorData> {
372        self.insert_into_impl(params).await
373    }
374
375    #[tool(
376        name = "delete_symbol",
377        description = "Delete a symbol and all its associated decorators, attributes, and doc comments. If the target is a class, the ENTIRE class is deleted. If the target is a method, only that method is deleted. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/auth.ts::AuthService.login').\n\n**LSP validation:** Edit responses include a `validation` field. If `validation_skipped` is true, check `validation_skipped_reason` for why. Call `get_repo_map` and inspect `capabilities.lsp.per_language` to see LSP status upfront.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
378    )]
379    async fn delete_symbol(
380        &self,
381        Parameters(params): Parameters<DeleteSymbolParams>,
382    ) -> Result<Json<EditResponse>, ErrorData> {
383        self.delete_symbol_impl(params).await
384    }
385
386    #[tool(
387        name = "validate_only",
388        description = "Dry-run an edit WITHOUT writing to disk. Use this to pre-check risky changes. Returns the same validation results as a real edit. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/mod.rs::func'). new_version_hash will be null because nothing was written. Reuse your original base_version for the real edit.\n\n**LSP validation:** If `validation_skipped` is true, check `validation_skipped_reason` for why (e.g., `no_lsp`, `lsp_crash`). Call `get_repo_map` and inspect `capabilities.lsp.per_language` to see LSP status upfront.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
389    )]
390    async fn validate_only(
391        &self,
392        Parameters(params): Parameters<ValidateOnlyParams>,
393    ) -> Result<Json<EditResponse>, ErrorData> {
394        self.validate_only_impl(params).await
395    }
396
397    #[tool(
398        name = "create_file",
399        description = "Create a new file with initial content. Parent directories are created automatically. Returns a version_hash for subsequent edits."
400    )]
401    async fn create_file(
402        &self,
403        Parameters(params): Parameters<CreateFileParams>,
404    ) -> Result<Json<CreateFileResponse>, ErrorData> {
405        self.create_file_impl(params).await
406    }
407
408    #[tool(
409        name = "delete_file",
410        description = "Delete a file. Requires base_version (OCC) to prevent deleting a file that was modified after you last read it. base_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
411    )]
412    async fn delete_file(
413        &self,
414        Parameters(params): Parameters<DeleteFileParams>,
415    ) -> Result<Json<DeleteFileResponse>, ErrorData> {
416        self.delete_file_impl(params).await
417    }
418
419    #[tool(
420        name = "read_file",
421        description = "Read raw file content. Use ONLY for configuration files (.env, Dockerfile, YAML, TOML, package.json). For source code, use read_symbol_scope instead. Supports pagination via start_line for large files."
422    )]
423    async fn read_file(
424        &self,
425        Parameters(params): Parameters<ReadFileParams>,
426    ) -> Result<rmcp::model::CallToolResult, ErrorData> {
427        self.read_file_impl(params).await
428    }
429
430    #[tool(
431        name = "write_file",
432        description = "WARNING: This bypasses AST validation and formatting. DO NOT use for source code (TypeScript, Python, Go, Rust). ONLY use for configuration files (.env, .gitignore, Dockerfile, YAML). For source code, use replace_body or replace_full instead. Provide EITHER 'content' for full replacement OR 'replacements' for surgical search-and-replace edits (e.g., {old_text: 'postgres:15', new_text: 'postgres:16'}). Use replacements when changing specific text in large files. Requires base_version (OCC).\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
433    )]
434    async fn write_file(
435        &self,
436        Parameters(params): Parameters<WriteFileParams>,
437    ) -> Result<rmcp::model::CallToolResult, ErrorData> {
438        self.write_file_impl(params).await
439    }
440}
441
442// ── ServerHandler trait impl ────────────────────────────────────────
443
444#[tool_handler]
445impl ServerHandler for PathfinderServer {
446    fn get_info(&self) -> ServerInfo {
447        ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
448            .with_server_info(Implementation::new("pathfinder", env!("CARGO_PKG_VERSION")))
449    }
450}
451
452// ── Language Detection ──────────────────────────────────────────────
453
454#[cfg(test)]
455#[allow(clippy::expect_used, clippy::unwrap_used)]
456mod tests {
457    use super::*;
458    use crate::server::types::Replacement;
459    use pathfinder_common::types::{FilterMode, VersionHash};
460    use pathfinder_search::{MockScout, SearchMatch, SearchResult};
461    use pathfinder_treesitter::mock::MockSurgeon;
462    use rmcp::model::ErrorCode;
463    use std::fs;
464    use tempfile::tempdir;
465
466    #[tokio::test]
467    async fn test_get_repo_map_success() {
468        let ws_dir = tempdir().expect("temp dir");
469        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
470        let config = PathfinderConfig::default();
471        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
472
473        let mock_surgeon = MockSurgeon::new();
474        mock_surgeon
475            .generate_skeleton_results
476            .lock()
477            .unwrap()
478            .push(Ok(pathfinder_treesitter::repo_map::RepoMapResult {
479                skeleton: "class Mock {}".to_string(),
480                tech_stack: vec!["TypeScript".to_string()],
481                files_scanned: 1,
482                files_truncated: 0,
483                files_in_scope: 1,
484                coverage_percent: 100,
485                version_hashes: std::collections::HashMap::default(),
486            }));
487
488        let server = PathfinderServer::with_engines(
489            ws,
490            config,
491            sandbox,
492            Arc::new(MockScout::default()),
493            Arc::new(mock_surgeon),
494        );
495
496        let params = GetRepoMapParams {
497            path: ".".to_owned(),
498            max_tokens: 16_000,
499            depth: 3,
500            visibility: pathfinder_common::types::Visibility::Public,
501            max_tokens_per_file: 2000,
502            changed_since: String::default(),
503            include_extensions: vec![],
504            exclude_extensions: vec![],
505            include_imports: pathfinder_common::types::IncludeImports::None,
506        };
507
508        let result = server.get_repo_map(Parameters(params)).await;
509        assert!(result.is_ok());
510        let call_res = result.unwrap();
511        let skeleton = match &call_res.content[0].raw {
512            rmcp::model::RawContent::Text(t) => t.text.clone(),
513            _ => panic!("expected text content"),
514        };
515        let response: crate::server::types::GetRepoMapMetadata =
516            serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
517        assert_eq!(skeleton, "class Mock {}");
518        assert_eq!(response.files_scanned, 1);
519        assert_eq!(response.coverage_percent, 100);
520        // Visibility filtering is now implemented via name-convention heuristics.
521        assert_eq!(response.visibility_degraded, None);
522    }
523
524    #[tokio::test]
525    async fn test_get_repo_map_visibility_not_degraded() {
526        // Both visibility modes should return visibility_degraded: None
527        // because visibility filtering is now implemented via name-convention heuristics.
528        let ws_dir = tempdir().expect("temp dir");
529        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
530        let config = PathfinderConfig::default();
531        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
532
533        let mock_surgeon = MockSurgeon::new();
534        mock_surgeon
535            .generate_skeleton_results
536            .lock()
537            .unwrap()
538            .push(Ok(pathfinder_treesitter::repo_map::RepoMapResult {
539                skeleton: String::default(),
540                tech_stack: vec![],
541                files_scanned: 0,
542                files_truncated: 0,
543                files_in_scope: 0,
544                coverage_percent: 100,
545                version_hashes: std::collections::HashMap::default(),
546            }));
547
548        let server = PathfinderServer::with_engines(
549            ws,
550            config,
551            sandbox,
552            Arc::new(MockScout::default()),
553            Arc::new(mock_surgeon),
554        );
555
556        let params = GetRepoMapParams {
557            visibility: pathfinder_common::types::Visibility::All,
558            ..Default::default()
559        };
560        let result = server
561            .get_repo_map(Parameters(params))
562            .await
563            .expect("should succeed");
564        let meta: crate::server::types::GetRepoMapMetadata =
565            serde_json::from_value(result.structured_content.unwrap()).unwrap();
566        assert_eq!(
567            meta.visibility_degraded, None,
568            "visibility filtering is implemented; visibility_degraded must be None"
569        );
570    }
571
572    #[tokio::test]
573    async fn test_get_repo_map_access_denied() {
574        let ws_dir = tempdir().expect("temp dir");
575        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
576        let config = PathfinderConfig::default();
577        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
578
579        let mock_surgeon = MockSurgeon::new();
580        let server = PathfinderServer::with_engines(
581            ws,
582            config,
583            sandbox,
584            Arc::new(MockScout::default()),
585            Arc::new(mock_surgeon),
586        );
587
588        let params = GetRepoMapParams {
589            path: ".env".to_string(), // Sandbox should deny this
590            ..Default::default()
591        };
592
593        let Err(err) = server.get_repo_map(Parameters(params)).await else {
594            panic!("Expected ACCESS_DENIED error");
595        };
596        assert_eq!(err.code, ErrorCode(-32001));
597    }
598
599    #[tokio::test]
600    async fn test_create_file_success_and_already_exists() {
601        let ws_dir = tempdir().expect("temp dir");
602        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
603        let config = PathfinderConfig::default();
604        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
605        let mock_scout = MockScout::default();
606        let server = PathfinderServer::with_engines(
607            ws,
608            config,
609            sandbox,
610            Arc::new(mock_scout),
611            Arc::new(MockSurgeon::new()),
612        );
613
614        let filepath = "src/new_file.ts";
615        let content = "console.log('hello');";
616        let params = CreateFileParams {
617            filepath: filepath.to_owned(),
618            content: content.to_owned(),
619        };
620
621        // 1. First creation should succeed
622        let result = server.create_file(Parameters(params.clone())).await;
623        assert!(result.is_ok(), "Expected success, got {:#?}", result.err());
624        let val = result.expect("create_file should succeed").0;
625        assert!(val.success);
626        assert_eq!(val.validation.status, "passed");
627
628        let expected_hash = VersionHash::compute(content.as_bytes());
629        assert_eq!(val.version_hash, expected_hash.short());
630
631        // Verify file is on disk
632        let absolute_path = ws_dir.path().join(filepath);
633        assert!(absolute_path.exists());
634        let read_content = fs::read_to_string(&absolute_path).expect("read file");
635        assert_eq!(read_content, content);
636
637        // 2. Second creation should fail (FILE_ALREADY_EXISTS)
638        let result2 = server.create_file(Parameters(params)).await;
639        assert!(result2.is_err());
640        if let Err(err) = result2 {
641            let code = err
642                .data
643                .as_ref()
644                .and_then(|d| d.get("error"))
645                .and_then(|v| v.as_str())
646                .unwrap_or("");
647            assert_eq!(code, "FILE_ALREADY_EXISTS", "got data: {:?}", err.data);
648        } else {
649            panic!("Expected error mapping to FILE_ALREADY_EXISTS");
650        }
651
652        // 3. Attempt to create file in a denied location
653        let deny_params = CreateFileParams {
654            filepath: ".git/objects/some_file".to_owned(),
655            content: "payload".to_owned(),
656        };
657        let result3 = server.create_file(Parameters(deny_params)).await;
658        assert!(result3.is_err());
659        if let Err(err) = result3 {
660            let code = err
661                .data
662                .as_ref()
663                .and_then(|d| d.get("error"))
664                .and_then(|v| v.as_str())
665                .unwrap_or("");
666            assert_eq!(code, "ACCESS_DENIED", "got data: {:?}", err.data);
667        } else {
668            panic!("Expected error mapping to ACCESS_DENIED");
669        }
670    }
671
672    #[tokio::test]
673    async fn test_search_codebase_routes_to_scout_and_handles_success() {
674        let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
675        let config = PathfinderConfig::default();
676        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
677
678        let mock_scout = MockScout::default();
679        mock_scout.set_result(Ok(SearchResult {
680            matches: vec![SearchMatch {
681                file: "src/main.rs".to_owned(),
682                line: 10,
683                column: 5,
684                content: "test_query()".to_owned(),
685                context_before: vec![],
686                context_after: vec![],
687                enclosing_semantic_path: None,
688                version_hash: "sha256:123".to_owned(),
689                known: None,
690            }],
691            total_matches: 1,
692            truncated: false,
693        }));
694
695        let mock_surgeon = Arc::new(MockSurgeon::new());
696        mock_surgeon
697            .enclosing_symbol_results
698            .lock()
699            .unwrap()
700            .push(Ok(Some("test_query_func".to_owned())));
701
702        let server = PathfinderServer::with_engines(
703            ws,
704            config,
705            sandbox,
706            Arc::new(mock_scout.clone()),
707            mock_surgeon.clone(),
708        );
709        let params = SearchCodebaseParams {
710            query: "test_query".to_owned(),
711            is_regex: true,
712            ..Default::default()
713        };
714
715        let result = server.search_codebase(Parameters(params)).await;
716        // Json(val) gives us val.0
717        let val = result.expect("search_codebase should succeed").0;
718
719        assert_eq!(val.total_matches, 1);
720        assert!(!val.truncated);
721        let matches = val.matches;
722        assert_eq!(matches[0].file, "src/main.rs");
723        assert_eq!(matches[0].content, "test_query()");
724        assert_eq!(
725            matches[0].enclosing_semantic_path.as_deref(),
726            Some("src/main.rs::test_query_func")
727        );
728
729        let calls = mock_scout.calls();
730        assert_eq!(calls.len(), 1);
731        assert_eq!(calls[0].query, "test_query");
732        assert!(calls[0].is_regex);
733
734        let surgeon_calls = mock_surgeon.enclosing_symbol_calls.lock().unwrap();
735        assert_eq!(surgeon_calls.len(), 1);
736        assert_eq!(surgeon_calls[0].1, std::path::PathBuf::from("src/main.rs"));
737        assert_eq!(surgeon_calls[0].2, 10);
738    }
739
740    #[tokio::test]
741    async fn test_search_codebase_handles_scout_error() {
742        let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
743        let config = PathfinderConfig::default();
744        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
745
746        let mock_scout = MockScout::default();
747        mock_scout.set_result(Err("simulated engine error".to_owned()));
748
749        let server = PathfinderServer::with_engines(
750            ws,
751            config,
752            sandbox,
753            Arc::new(mock_scout),
754            Arc::new(MockSurgeon::new()),
755        );
756        let params = SearchCodebaseParams {
757            query: "test".to_owned(),
758            ..Default::default()
759        };
760
761        let result = server.search_codebase(Parameters(params)).await;
762
763        let err = result
764            .err()
765            .expect("search_codebase should return error on scout failure");
766        assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
767        assert_eq!(err.message, "search engine error: simulated engine error");
768    }
769
770    // ── filter_mode unit tests ────────────────────────────────────────
771
772    fn make_search_match(file: &str, line: u64, content: &str) -> SearchMatch {
773        SearchMatch {
774            file: file.to_owned(),
775            line,
776            column: 0,
777            content: content.to_owned(),
778            context_before: vec![],
779            context_after: vec![],
780            enclosing_semantic_path: None,
781            version_hash: "sha256:abc".to_owned(),
782            known: None,
783        }
784    }
785
786    #[tokio::test]
787    async fn test_search_codebase_filter_mode_code_only_drops_comments() {
788        let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
789        let config = PathfinderConfig::default();
790        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
791
792        let mock_scout = MockScout::default();
793        mock_scout.set_result(Ok(SearchResult {
794            matches: vec![
795                make_search_match("src/a.go", 1, "code line"),
796                make_search_match("src/a.go", 2, "// comment line"),
797                make_search_match("src/a.go", 3, "another code line"),
798            ],
799            total_matches: 3,
800            truncated: false,
801        }));
802
803        let mock_surgeon = Arc::new(MockSurgeon::new());
804        // 3 matches → 3 calls: code, comment, code
805        // enclosing_symbol called 3 times → return None each (default "code" below)
806        // node_type_at_position called 3 times → pre-configure results
807        mock_surgeon
808            .enclosing_symbol_results
809            .lock()
810            .unwrap()
811            .extend([Ok(None), Ok(None), Ok(None)]);
812        mock_surgeon
813            .node_type_at_position_results
814            .lock()
815            .unwrap()
816            .extend([
817                Ok("code".to_owned()),
818                Ok("comment".to_owned()),
819                Ok("code".to_owned()),
820            ]);
821
822        let server =
823            PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
824
825        let params = SearchCodebaseParams {
826            query: "line".to_owned(),
827            filter_mode: FilterMode::CodeOnly,
828            ..Default::default()
829        };
830
831        let result = server
832            .search_codebase(Parameters(params))
833            .await
834            .expect("should succeed")
835            .0;
836
837        // Only the 2 code matches should survive
838        assert_eq!(result.matches.len(), 2, "code_only should drop comments");
839        assert_eq!(result.matches[0].content, "code line");
840        assert_eq!(result.matches[1].content, "another code line");
841        // total_matches reflects the ORIGINAL ripgrep count, not filtered count
842        assert_eq!(result.total_matches, 3);
843        // No degraded flag — filtering was real
844        assert!(!result.degraded);
845    }
846
847    #[tokio::test]
848    async fn test_search_codebase_filter_mode_comments_only_keeps_comments() {
849        let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
850        let config = PathfinderConfig::default();
851        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
852
853        let mock_scout = MockScout::default();
854        mock_scout.set_result(Ok(SearchResult {
855            matches: vec![
856                make_search_match("src/b.go", 1, "func HelloWorld() {}"),
857                make_search_match("src/b.go", 2, "// HelloWorld says hello"),
858                make_search_match("src/b.go", 3, r#"msg := "Hello World""#),
859            ],
860            total_matches: 3,
861            truncated: false,
862        }));
863
864        let mock_surgeon = Arc::new(MockSurgeon::new());
865        mock_surgeon
866            .enclosing_symbol_results
867            .lock()
868            .unwrap()
869            .extend([Ok(None), Ok(None), Ok(None)]);
870        mock_surgeon
871            .node_type_at_position_results
872            .lock()
873            .unwrap()
874            .extend([
875                Ok("code".to_owned()),
876                Ok("comment".to_owned()),
877                Ok("string".to_owned()),
878            ]);
879
880        let server =
881            PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
882
883        let params = SearchCodebaseParams {
884            query: "Hello".to_owned(),
885            filter_mode: FilterMode::CommentsOnly,
886            ..Default::default()
887        };
888
889        let result = server
890            .search_codebase(Parameters(params))
891            .await
892            .expect("should succeed")
893            .0;
894
895        // Comment and string matches should survive; code match should be dropped
896        assert_eq!(result.matches.len(), 2, "comments_only should drop code");
897        assert_eq!(result.matches[0].content, "// HelloWorld says hello");
898        assert_eq!(result.matches[1].content, r#"msg := "Hello World""#);
899        assert!(!result.degraded);
900    }
901
902    #[tokio::test]
903    async fn test_search_codebase_filter_mode_all_returns_everything() {
904        let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
905        let config = PathfinderConfig::default();
906        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
907
908        let mock_scout = MockScout::default();
909        mock_scout.set_result(Ok(SearchResult {
910            matches: vec![
911                make_search_match("src/c.go", 1, "code"),
912                make_search_match("src/c.go", 2, "// comment"),
913                make_search_match("src/c.go", 3, r#"\"string\""#),
914            ],
915            total_matches: 3,
916            truncated: false,
917        }));
918
919        let mock_surgeon = Arc::new(MockSurgeon::default());
920        // enclosing_symbol: all return None
921        mock_surgeon
922            .enclosing_symbol_results
923            .lock()
924            .unwrap()
925            .extend([Ok(None), Ok(None), Ok(None)]);
926        // node_type_at_position: will use default "code" since queue is empty
927        // (FilterMode::All skips classification entirely — but mock still gets called;
928        // the default return value is "code" so no pre-configuration needed)
929
930        let server =
931            PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
932
933        let params = SearchCodebaseParams {
934            query: "test".to_owned(),
935            filter_mode: FilterMode::All,
936            ..Default::default()
937        };
938
939        let result = server
940            .search_codebase(Parameters(params))
941            .await
942            .expect("should succeed")
943            .0;
944
945        // All 3 matches returned, no filtering
946        assert_eq!(result.matches.len(), 3);
947        assert!(!result.degraded);
948    }
949
950    // ── delete_file tests ────────────────────────────────────────────
951
952    #[tokio::test]
953    async fn test_delete_file_success_and_occ_failure() {
954        let ws_dir = tempdir().expect("temp dir");
955        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
956        let config = PathfinderConfig::default();
957        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
958        let server = PathfinderServer::with_engines(
959            ws,
960            config,
961            sandbox,
962            Arc::new(MockScout::default()),
963            Arc::new(MockSurgeon::new()),
964        );
965
966        // Create a file to delete
967        let filepath = "to_delete.txt";
968        let content = "goodbye";
969        let abs = ws_dir.path().join(filepath);
970        fs::write(&abs, content).expect("write");
971        let hash = VersionHash::compute(content.as_bytes());
972
973        // Happy path
974        let result = server
975            .delete_file(Parameters(DeleteFileParams {
976                filepath: filepath.to_owned(),
977                base_version: hash.as_str().to_owned(),
978            }))
979            .await;
980        assert!(result.is_ok(), "Expected success, got {:?}", result.err());
981        assert!(!abs.exists(), "File should be gone");
982
983        // FILE_NOT_FOUND — file is already deleted, now handled via tfs::read NotFound (no pre-check race)
984        let result2 = server
985            .delete_file(Parameters(DeleteFileParams {
986                filepath: filepath.to_owned(),
987                base_version: hash.as_str().to_owned(),
988            }))
989            .await;
990        assert!(result2.is_err());
991        let Err(err) = result2 else {
992            panic!("expected error")
993        };
994        let code = err
995            .data
996            .as_ref()
997            .and_then(|d| d.get("error"))
998            .and_then(|v| v.as_str())
999            .unwrap_or("");
1000        assert_eq!(code, "FILE_NOT_FOUND", "got: {err:?}");
1001
1002        // VERSION_MISMATCH — recreate file, pass wrong hash
1003        fs::write(&abs, content).expect("write");
1004        let result3 = server
1005            .delete_file(Parameters(DeleteFileParams {
1006                filepath: filepath.to_owned(),
1007                base_version: "sha256:wrong".to_owned(),
1008            }))
1009            .await;
1010        assert!(result3.is_err());
1011        let Err(err) = result3 else {
1012            panic!("expected error")
1013        };
1014        let code = err
1015            .data
1016            .as_ref()
1017            .and_then(|d| d.get("error"))
1018            .and_then(|v| v.as_str())
1019            .unwrap_or("");
1020        assert_eq!(code, "VERSION_MISMATCH", "got: {err:?}");
1021
1022        // ACCESS_DENIED — sandbox-protected path
1023        let result4 = server
1024            .delete_file(Parameters(DeleteFileParams {
1025                filepath: ".git/objects/x".to_owned(),
1026                base_version: "sha256:any".to_owned(),
1027            }))
1028            .await;
1029        assert!(result4.is_err());
1030        let Err(err) = result4 else {
1031            panic!("expected error")
1032        };
1033        let code = err
1034            .data
1035            .as_ref()
1036            .and_then(|d| d.get("error"))
1037            .and_then(|v| v.as_str())
1038            .unwrap_or("");
1039        assert_eq!(code, "ACCESS_DENIED", "got: {err:?}");
1040    }
1041
1042    // ── read_file tests ──────────────────────────────────────────────
1043
1044    #[tokio::test]
1045    async fn test_read_file_pagination() {
1046        let ws_dir = tempdir().expect("temp dir");
1047        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1048        let config = PathfinderConfig::default();
1049        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1050        let server = PathfinderServer::with_engines(
1051            ws,
1052            config,
1053            sandbox,
1054            Arc::new(MockScout::default()),
1055            Arc::new(MockSurgeon::new()),
1056        );
1057
1058        // Write a 10-line file
1059        let filepath = "config.yaml";
1060        let lines: Vec<String> = (1..=10).map(|i| format!("line{i}: value")).collect();
1061        let content = lines.join("\n");
1062        fs::write(ws_dir.path().join(filepath), &content).expect("write");
1063
1064        // Full read
1065        let result = server
1066            .read_file(Parameters(ReadFileParams {
1067                filepath: filepath.to_owned(),
1068                start_line: 1,
1069                max_lines: 500,
1070            }))
1071            .await
1072            .expect("should succeed");
1073        let val: crate::server::types::ReadFileMetadata =
1074            serde_json::from_value(result.structured_content.unwrap()).unwrap();
1075        assert_eq!(val.total_lines, 10);
1076        assert_eq!(val.lines_returned, 10);
1077        assert!(!val.truncated);
1078        assert_eq!(val.language, "yaml");
1079
1080        // Paginated read — lines 3-5
1081        let result2 = server
1082            .read_file(Parameters(ReadFileParams {
1083                filepath: filepath.to_owned(),
1084                start_line: 3,
1085                max_lines: 3,
1086            }))
1087            .await
1088            .expect("should succeed");
1089        let val2: crate::server::types::ReadFileMetadata =
1090            serde_json::from_value(result2.structured_content.unwrap()).unwrap();
1091        assert_eq!(val2.start_line, 3);
1092        assert_eq!(val2.lines_returned, 3);
1093        assert!(val2.truncated);
1094        let text_content = match &result2.content[0].raw {
1095            rmcp::model::RawContent::Text(t) => t.text.clone(),
1096            _ => panic!("expected text content"),
1097        };
1098        assert!(text_content.contains("line3"));
1099        assert!(text_content.contains("line5"));
1100        assert!(!text_content.contains("line6"));
1101
1102        // FILE_NOT_FOUND
1103        let result3 = server
1104            .read_file(Parameters(ReadFileParams {
1105                filepath: "nonexistent.yaml".to_owned(),
1106                start_line: 1,
1107                max_lines: 500,
1108            }))
1109            .await;
1110        assert!(result3.is_err());
1111        let Err(err) = result3 else {
1112            panic!("expected error")
1113        };
1114        let code = err
1115            .data
1116            .as_ref()
1117            .and_then(|d| d.get("error"))
1118            .and_then(|v| v.as_str())
1119            .unwrap_or("");
1120        assert_eq!(code, "FILE_NOT_FOUND", "got: {err:?}");
1121    }
1122
1123    // ── read_symbol_scope tests ─────────────────────────────────────
1124
1125    #[tokio::test]
1126    async fn test_read_symbol_scope_routes_to_surgeon_and_handles_success() {
1127        let ws_dir = tempdir().expect("temp dir");
1128        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1129        let config = PathfinderConfig::default();
1130        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1131        let mock_surgeon = Arc::new(MockSurgeon::new());
1132
1133        let content = "func Login() {}";
1134        let expected_scope = pathfinder_common::types::SymbolScope {
1135            content: content.to_owned(),
1136            start_line: 5,
1137            end_line: 7,
1138            name_column: 0,
1139            version_hash: VersionHash::compute(content.as_bytes()),
1140            language: "go".to_owned(),
1141        };
1142        mock_surgeon
1143            .read_symbol_scope_results
1144            .lock()
1145            .unwrap()
1146            .push(Ok(expected_scope.clone()));
1147
1148        let server = PathfinderServer::with_engines(
1149            ws,
1150            config,
1151            sandbox,
1152            Arc::new(MockScout::default()),
1153            mock_surgeon.clone(),
1154        );
1155
1156        let params = ReadSymbolScopeParams {
1157            semantic_path: "src/auth.go::Login".to_owned(),
1158        };
1159
1160        let result = server.read_symbol_scope(Parameters(params)).await;
1161        let val = result.expect("should succeed");
1162
1163        let rmcp::model::RawContent::Text(t) = &val.content[0].raw else {
1164            panic!("Expected text content");
1165        };
1166        // After GAP-004, the text content includes a version_hash footer
1167        let expected_text = format!(
1168            "{}\n---\nversion_hash: {}",
1169            expected_scope.content,
1170            expected_scope.version_hash.short()
1171        );
1172        assert_eq!(t.text, expected_text);
1173
1174        let metadata: crate::server::types::ReadSymbolScopeMetadata =
1175            serde_json::from_value(val.structured_content.expect("missing structured_content"))
1176                .expect("valid metadata");
1177
1178        assert_eq!(metadata.start_line, expected_scope.start_line);
1179        assert_eq!(metadata.end_line, expected_scope.end_line);
1180        assert_eq!(metadata.version_hash, expected_scope.version_hash.short());
1181        assert_eq!(metadata.language, expected_scope.language);
1182
1183        let calls = mock_surgeon.read_symbol_scope_calls.lock().unwrap();
1184        assert_eq!(calls.len(), 1);
1185    }
1186
1187    #[tokio::test]
1188    async fn test_read_symbol_scope_handles_surgeon_error() {
1189        let ws_dir = tempdir().expect("temp dir");
1190        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1191        let config = PathfinderConfig::default();
1192        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1193        let mock_surgeon = Arc::new(MockSurgeon::new());
1194
1195        mock_surgeon
1196            .read_symbol_scope_results
1197            .lock()
1198            .unwrap()
1199            .push(Err(pathfinder_treesitter::SurgeonError::SymbolNotFound {
1200                path: "src/auth.go::Login".to_owned(),
1201                did_you_mean: vec!["Logout".to_owned()],
1202            }));
1203
1204        let server = PathfinderServer::with_engines(
1205            ws,
1206            config,
1207            sandbox,
1208            Arc::new(MockScout::default()),
1209            mock_surgeon,
1210        );
1211
1212        let params = ReadSymbolScopeParams {
1213            semantic_path: "src/auth.go::Login".to_owned(),
1214        };
1215
1216        let Err(err) = server.read_symbol_scope(Parameters(params)).await else {
1217            panic!("Expected failed response");
1218        };
1219
1220        assert_eq!(err.code, ErrorCode::INVALID_PARAMS); // SymbolNotFound maps to INVALID_PARAMS
1221        let code = err
1222            .data
1223            .as_ref()
1224            .unwrap()
1225            .get("error")
1226            .unwrap()
1227            .as_str()
1228            .unwrap();
1229        assert_eq!(code, "SYMBOL_NOT_FOUND");
1230    }
1231
1232    // ── write_file tests ─────────────────────────────────────────────
1233
1234    #[tokio::test]
1235    async fn test_write_file_full_replacement() {
1236        let ws_dir = tempdir().expect("temp dir");
1237        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1238        let config = PathfinderConfig::default();
1239        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1240        let server = PathfinderServer::with_engines(
1241            ws,
1242            config,
1243            sandbox,
1244            Arc::new(MockScout::default()),
1245            Arc::new(MockSurgeon::new()),
1246        );
1247
1248        let filepath = "config.toml";
1249        let original = "[server]\nport = 8080";
1250        let abs = ws_dir.path().join(filepath);
1251        fs::write(&abs, original).expect("write");
1252        let hash = VersionHash::compute(original.as_bytes());
1253
1254        // Happy path — full replacement
1255        let replacement = "[server]\nport = 9090";
1256        let result = server
1257            .write_file(Parameters(WriteFileParams {
1258                filepath: filepath.to_owned(),
1259                base_version: hash.as_str().to_owned(),
1260                content: Some(replacement.to_owned()),
1261                replacements: None,
1262            }))
1263            .await
1264            .expect("should succeed");
1265        let val: crate::server::types::WriteFileMetadata =
1266            serde_json::from_value(result.structured_content.unwrap()).unwrap();
1267        assert!(val.success);
1268        let on_disk = fs::read_to_string(&abs).expect("read");
1269        assert_eq!(on_disk, replacement);
1270        let new_hash = VersionHash::compute(replacement.as_bytes());
1271        assert_eq!(val.new_version_hash, new_hash.short());
1272
1273        // VERSION_MISMATCH — use old hash
1274        let result2 = server
1275            .write_file(Parameters(WriteFileParams {
1276                filepath: filepath.to_owned(),
1277                base_version: hash.as_str().to_owned(), // stale
1278                content: Some("something else".to_owned()),
1279                replacements: None,
1280            }))
1281            .await;
1282        assert!(result2.is_err());
1283        let Err(err) = result2 else {
1284            panic!("expected error")
1285        };
1286        let code = err
1287            .data
1288            .as_ref()
1289            .and_then(|d| d.get("error"))
1290            .and_then(|v| v.as_str())
1291            .unwrap_or("");
1292        assert_eq!(code, "VERSION_MISMATCH", "got: {err:?}");
1293    }
1294
1295    #[tokio::test]
1296    async fn test_write_file_search_and_replace() {
1297        let ws_dir = tempdir().expect("temp dir");
1298        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1299        let config = PathfinderConfig::default();
1300        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1301        let server = PathfinderServer::with_engines(
1302            ws,
1303            config,
1304            sandbox,
1305            Arc::new(MockScout::default()),
1306            Arc::new(MockSurgeon::new()),
1307        );
1308
1309        let filepath = "docker-compose.yml";
1310        let original = "image: postgres:15\nports:\n  - 5432:5432";
1311        let abs = ws_dir.path().join(filepath);
1312        fs::write(&abs, original).expect("write");
1313        let hash = VersionHash::compute(original.as_bytes());
1314
1315        // Happy path — single match
1316        let result = server
1317            .write_file(Parameters(WriteFileParams {
1318                filepath: filepath.to_owned(),
1319                base_version: hash.as_str().to_owned(),
1320                content: None,
1321                replacements: Some(vec![Replacement {
1322                    old_text: "postgres:15".to_owned(),
1323                    new_text: "postgres:16-alpine".to_owned(),
1324                }]),
1325            }))
1326            .await
1327            .expect("should succeed");
1328        let val: crate::server::types::WriteFileMetadata =
1329            serde_json::from_value(result.structured_content.unwrap()).unwrap();
1330        assert!(val.success);
1331        let on_disk = fs::read_to_string(&abs).expect("read");
1332        assert!(on_disk.contains("postgres:16-alpine"));
1333        let new_hash_val = val.new_version_hash;
1334
1335        // MATCH_NOT_FOUND — old text no longer exists
1336        let result2 = server
1337            .write_file(Parameters(WriteFileParams {
1338                filepath: filepath.to_owned(),
1339                base_version: new_hash_val.clone(),
1340                content: None,
1341                replacements: Some(vec![Replacement {
1342                    old_text: "postgres:15".to_owned(), // already replaced
1343                    new_text: "postgres:17".to_owned(),
1344                }]),
1345            }))
1346            .await;
1347        assert!(result2.is_err());
1348        let Err(err) = result2 else {
1349            panic!("expected error")
1350        };
1351        let code = err
1352            .data
1353            .as_ref()
1354            .and_then(|d| d.get("error"))
1355            .and_then(|v| v.as_str())
1356            .unwrap_or("");
1357        assert_eq!(code, "MATCH_NOT_FOUND", "got: {err:?}");
1358
1359        // AMBIGUOUS_MATCH — inject a file where old_text appears twice
1360        let ambiguous = "tag: v1\ntag: v1";
1361        fs::write(&abs, ambiguous).expect("write");
1362        let ambig_hash = VersionHash::compute(ambiguous.as_bytes());
1363        let result3 = server
1364            .write_file(Parameters(WriteFileParams {
1365                filepath: filepath.to_owned(),
1366                base_version: ambig_hash.as_str().to_owned(),
1367                content: None,
1368                replacements: Some(vec![Replacement {
1369                    old_text: "tag: v1".to_owned(),
1370                    new_text: "tag: v2".to_owned(),
1371                }]),
1372            }))
1373            .await;
1374        assert!(result3.is_err());
1375        let Err(err) = result3 else {
1376            panic!("expected error")
1377        };
1378        let code = err
1379            .data
1380            .as_ref()
1381            .and_then(|d| d.get("error"))
1382            .and_then(|v| v.as_str())
1383            .unwrap_or("");
1384        assert_eq!(code, "AMBIGUOUS_MATCH", "got: {err:?}");
1385    }
1386
1387    // ── E4 tests ─────────────────────────────────────────────────────
1388
1389    /// E4.1: Matches in `known_files` must have content + context stripped,
1390    /// while matches in other files must retain full content.
1391    #[tokio::test]
1392    async fn test_search_codebase_known_files_suppresses_context() {
1393        let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
1394        let config = PathfinderConfig::default();
1395        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1396
1397        let mock_scout = MockScout::default();
1398        mock_scout.set_result(Ok(SearchResult {
1399            matches: vec![
1400                SearchMatch {
1401                    file: "src/auth.ts".to_owned(),
1402                    line: 10,
1403                    column: 1,
1404                    content: "secret content".to_owned(),
1405                    context_before: vec!["before".to_owned()],
1406                    context_after: vec!["after".to_owned()],
1407                    enclosing_semantic_path: None,
1408                    version_hash: "sha256:abc".to_owned(),
1409                    known: None,
1410                },
1411                SearchMatch {
1412                    file: "src/main.ts".to_owned(),
1413                    line: 5,
1414                    column: 1,
1415                    content: "visible content".to_owned(),
1416                    context_before: vec!["ctx_before".to_owned()],
1417                    context_after: vec!["ctx_after".to_owned()],
1418                    enclosing_semantic_path: None,
1419                    version_hash: "sha256:xyz".to_owned(),
1420                    known: None,
1421                },
1422            ],
1423            total_matches: 2,
1424            truncated: false,
1425        }));
1426
1427        let mock_surgeon = Arc::new(MockSurgeon::new());
1428        // Two matches → two enrichment calls
1429        mock_surgeon
1430            .enclosing_symbol_results
1431            .lock()
1432            .unwrap()
1433            .extend([Ok(None), Ok(None)]);
1434
1435        let server =
1436            PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
1437
1438        let params = SearchCodebaseParams {
1439            query: "content".to_owned(),
1440            known_files: vec!["src/auth.ts".to_owned()],
1441            ..Default::default()
1442        };
1443
1444        let result = server
1445            .search_codebase(Parameters(params))
1446            .await
1447            .expect("should succeed")
1448            .0;
1449
1450        assert_eq!(result.matches.len(), 2);
1451
1452        // Known file match — content + context stripped, known=true
1453        let known_match = result
1454            .matches
1455            .iter()
1456            .find(|m| m.file == "src/auth.ts")
1457            .unwrap();
1458        assert!(
1459            known_match.content.is_empty(),
1460            "content should be suppressed for known file"
1461        );
1462        assert!(
1463            known_match.context_before.is_empty(),
1464            "context_before should be empty"
1465        );
1466        assert!(
1467            known_match.context_after.is_empty(),
1468            "context_after should be empty"
1469        );
1470        assert_eq!(
1471            known_match.known,
1472            Some(true),
1473            "known flag must be set for known-file matches"
1474        );
1475
1476        // Unknown file match — content retained, no known flag
1477        let normal_match = result
1478            .matches
1479            .iter()
1480            .find(|m| m.file == "src/main.ts")
1481            .unwrap();
1482        assert_eq!(normal_match.content, "visible content");
1483        assert_eq!(normal_match.context_before, vec!["ctx_before"]);
1484        assert_eq!(normal_match.context_after, vec!["ctx_after"]);
1485        assert_eq!(
1486            normal_match.known, None,
1487            "unknown-file matches must not have known flag"
1488        );
1489    }
1490
1491    /// E4.1: `known_files` path normalisation — `./src/auth.ts` must match `src/auth.ts`.
1492    #[tokio::test]
1493    async fn test_search_codebase_known_files_path_normalisation() {
1494        let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
1495        let config = PathfinderConfig::default();
1496        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1497
1498        let mock_scout = MockScout::default();
1499        mock_scout.set_result(Ok(SearchResult {
1500            matches: vec![SearchMatch {
1501                file: "src/auth.ts".to_owned(),
1502                line: 1,
1503                column: 1,
1504                content: "should be stripped".to_owned(),
1505                context_before: vec!["before".to_owned()],
1506                context_after: vec![],
1507                enclosing_semantic_path: None,
1508                version_hash: "sha256:abc".to_owned(),
1509                known: None,
1510            }],
1511            total_matches: 1,
1512            truncated: false,
1513        }));
1514
1515        let mock_surgeon = Arc::new(MockSurgeon::new());
1516        mock_surgeon
1517            .enclosing_symbol_results
1518            .lock()
1519            .unwrap()
1520            .push(Ok(None));
1521
1522        let server =
1523            PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
1524
1525        // Pass with leading "./" — should still match "src/auth.ts"
1526        let params = SearchCodebaseParams {
1527            query: "stripped".to_owned(),
1528            known_files: vec!["./src/auth.ts".to_owned()],
1529            ..Default::default()
1530        };
1531
1532        let result = server
1533            .search_codebase(Parameters(params))
1534            .await
1535            .expect("should succeed")
1536            .0;
1537
1538        let m = &result.matches[0];
1539        assert!(
1540            m.content.is_empty(),
1541            "content should be suppressed despite ./ prefix"
1542        );
1543        assert!(m.context_before.is_empty());
1544        assert_eq!(m.known, Some(true), "known flag must be set");
1545    }
1546
1547    /// E4.2: `group_by_file=true` groups matches by file with shared `version_hash`;
1548    /// known files go into `known_matches` with minimal info.
1549    #[tokio::test]
1550    async fn test_search_codebase_group_by_file() {
1551        let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
1552        let config = PathfinderConfig::default();
1553        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1554
1555        let mock_scout = MockScout::default();
1556        mock_scout.set_result(Ok(SearchResult {
1557            matches: vec![
1558                // Two matches in the same known file
1559                SearchMatch {
1560                    file: "src/auth.ts".to_owned(),
1561                    line: 1,
1562                    column: 1,
1563                    content: "known line 1".to_owned(),
1564                    context_before: vec![],
1565                    context_after: vec![],
1566                    enclosing_semantic_path: None,
1567                    version_hash: "sha256:auth".to_owned(),
1568                    known: None,
1569                },
1570                SearchMatch {
1571                    file: "src/auth.ts".to_owned(),
1572                    line: 2,
1573                    column: 1,
1574                    content: "known line 2".to_owned(),
1575                    context_before: vec![],
1576                    context_after: vec![],
1577                    enclosing_semantic_path: None,
1578                    version_hash: "sha256:auth".to_owned(),
1579                    known: None,
1580                },
1581                // One match in a normal file
1582                SearchMatch {
1583                    file: "src/main.ts".to_owned(),
1584                    line: 5,
1585                    column: 1,
1586                    content: "main content".to_owned(),
1587                    context_before: vec!["prev".to_owned()],
1588                    context_after: vec![],
1589                    enclosing_semantic_path: None,
1590                    version_hash: "sha256:main".to_owned(),
1591                    known: None,
1592                },
1593            ],
1594            total_matches: 3,
1595            truncated: false,
1596        }));
1597
1598        let mock_surgeon = Arc::new(MockSurgeon::new());
1599        // 3 enrichments
1600        mock_surgeon
1601            .enclosing_symbol_results
1602            .lock()
1603            .unwrap()
1604            .extend([Ok(None), Ok(None), Ok(None)]);
1605
1606        let server =
1607            PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
1608
1609        let params = SearchCodebaseParams {
1610            query: "line".to_owned(),
1611            known_files: vec!["src/auth.ts".to_owned()],
1612            group_by_file: true,
1613            ..Default::default()
1614        };
1615
1616        let result = server
1617            .search_codebase(Parameters(params))
1618            .await
1619            .expect("should succeed")
1620            .0;
1621
1622        let groups = result
1623            .file_groups
1624            .expect("file_groups should be Some when group_by_file=true");
1625        assert_eq!(groups.len(), 2);
1626
1627        let auth_group = groups.iter().find(|g| g.file == "src/auth.ts").unwrap();
1628        assert_eq!(auth_group.version_hash, "sha256:auth");
1629        assert!(
1630            auth_group.matches.is_empty(),
1631            "known file should have no full matches"
1632        );
1633        assert_eq!(
1634            auth_group.known_matches.len(),
1635            2,
1636            "known file should have 2 known_matches"
1637        );
1638        assert!(auth_group.known_matches[0].known);
1639
1640        let main_group = groups.iter().find(|g| g.file == "src/main.ts").unwrap();
1641        assert_eq!(main_group.version_hash, "sha256:main");
1642        assert_eq!(main_group.matches.len(), 1);
1643        // GroupedMatch has no file/version_hash — those are at group level only
1644        assert_eq!(main_group.matches[0].content, "main content");
1645        assert_eq!(main_group.matches[0].line, 5);
1646        assert!(main_group.known_matches.is_empty());
1647    }
1648
1649    /// E4.3: `exclude_glob` is forwarded to the scout as part of `SearchParams`.
1650    #[tokio::test]
1651    async fn test_search_codebase_exclude_glob_forwarded_to_scout() {
1652        let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
1653        let config = PathfinderConfig::default();
1654        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1655
1656        let mock_scout = MockScout::default();
1657        mock_scout.set_result(Ok(SearchResult {
1658            matches: vec![],
1659            total_matches: 0,
1660            truncated: false,
1661        }));
1662
1663        let server = PathfinderServer::with_engines(
1664            ws,
1665            config,
1666            sandbox,
1667            Arc::new(mock_scout.clone()),
1668            Arc::new(MockSurgeon::new()),
1669        );
1670
1671        let params = SearchCodebaseParams {
1672            query: "anything".to_owned(),
1673            exclude_glob: "**/*.test.*".to_owned(),
1674            ..Default::default()
1675        };
1676
1677        server
1678            .search_codebase(Parameters(params))
1679            .await
1680            .expect("should succeed");
1681
1682        let calls = mock_scout.calls();
1683        assert_eq!(calls.len(), 1);
1684        assert_eq!(
1685            calls[0].exclude_glob, "**/*.test.*",
1686            "exclude_glob must be forwarded to the scout"
1687        );
1688    }
1689
1690    // ── Server constructor tests (WP-5) ─────────────────────────────────
1691
1692    #[tokio::test]
1693    async fn test_with_all_engines_constructs_functional_server() {
1694        let ws_dir = tempdir().expect("temp dir");
1695        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1696        let config = PathfinderConfig::default();
1697        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1698
1699        let server = PathfinderServer::with_all_engines(
1700            ws,
1701            config,
1702            sandbox,
1703            Arc::new(MockScout::default()),
1704            Arc::new(MockSurgeon::new()),
1705            Arc::new(pathfinder_lsp::MockLawyer::default()),
1706        );
1707
1708        // Verify server functions — get_info should work
1709        let info = server.get_info();
1710        assert_eq!(info.server_info.name, "pathfinder");
1711    }
1712
1713    #[tokio::test]
1714    async fn test_with_engines_uses_no_op_lawyer() {
1715        let ws_dir = tempdir().expect("temp dir");
1716        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1717        let config = PathfinderConfig::default();
1718        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1719
1720        // Create a Rust file for surgeon to read
1721        std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
1722        std::fs::write(ws_dir.path().join("src/lib.rs"), "fn hello() -> i32 { 1 }").unwrap();
1723
1724        let mock_surgeon = Arc::new(MockSurgeon::new());
1725        mock_surgeon
1726            .read_symbol_scope_results
1727            .lock()
1728            .unwrap()
1729            .push(Ok(pathfinder_common::types::SymbolScope {
1730                content: "fn hello() -> i32 { 1 }".to_owned(),
1731                start_line: 0,
1732                end_line: 0,
1733                name_column: 0,
1734                version_hash: VersionHash::compute(b"fn hello() -> i32 { 1 }"),
1735                language: "rust".to_owned(),
1736            }));
1737
1738        let server = PathfinderServer::with_engines(
1739            ws,
1740            config,
1741            sandbox,
1742            Arc::new(MockScout::default()),
1743            mock_surgeon,
1744        );
1745
1746        // Navigation with NoOpLawyer should degrade gracefully
1747        let params = crate::server::types::GetDefinitionParams {
1748            semantic_path: "src/lib.rs::hello".to_owned(),
1749        };
1750        let result = server.get_definition_impl(params).await;
1751        // Should fail because NoOpLawyer returns NoLspAvailable and no grep fallback match
1752        assert!(result.is_err());
1753    }
1754
1755    // ── file_ops edge case tests (WP-6) ──────────────────────────────────
1756
1757    #[tokio::test]
1758    async fn test_create_file_broadcasts_watched_file_event() {
1759        let ws_dir = tempdir().expect("temp dir");
1760        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1761        let config = PathfinderConfig::default();
1762        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1763
1764        let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
1765
1766        let server = PathfinderServer::with_all_engines(
1767            ws,
1768            config,
1769            sandbox,
1770            Arc::new(MockScout::default()),
1771            Arc::new(MockSurgeon::new()),
1772            lawyer.clone(),
1773        );
1774
1775        let params = crate::server::types::CreateFileParams {
1776            filepath: "src/new_file.rs".to_owned(),
1777            content: "fn new() {}".to_owned(),
1778        };
1779        let result = server.create_file_impl(params).await;
1780        let res = result.expect("should succeed");
1781        assert!(res.0.success);
1782
1783        // Verify the file was created
1784        assert!(ws_dir.path().join("src/new_file.rs").exists());
1785
1786        // Verify watched file event was broadcast
1787        assert_eq!(lawyer.watched_file_changes_count(), 1);
1788    }
1789
1790    #[tokio::test]
1791    async fn test_delete_file_broadcasts_watched_file_event() {
1792        let ws_dir = tempdir().expect("temp dir");
1793        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1794        let config = PathfinderConfig::default();
1795        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1796
1797        let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
1798
1799        // Create a file to delete
1800        std::fs::write(ws_dir.path().join("to_delete.txt"), "content").unwrap();
1801        let hash = VersionHash::compute(b"content");
1802
1803        let server = PathfinderServer::with_all_engines(
1804            ws,
1805            config,
1806            sandbox,
1807            Arc::new(MockScout::default()),
1808            Arc::new(MockSurgeon::new()),
1809            lawyer.clone(),
1810        );
1811
1812        let params = crate::server::types::DeleteFileParams {
1813            filepath: "to_delete.txt".to_owned(),
1814            base_version: hash.as_str().to_owned(),
1815        };
1816        let result = server.delete_file_impl(params).await;
1817        let res = result.expect("should succeed");
1818        assert!(res.0.success);
1819
1820        // Verify the file was deleted
1821        assert!(!ws_dir.path().join("to_delete.txt").exists());
1822
1823        // Verify watched file event was broadcast
1824        assert_eq!(lawyer.watched_file_changes_count(), 1);
1825    }
1826
1827    #[tokio::test]
1828    async fn test_delete_file_not_found() {
1829        let ws_dir = tempdir().expect("temp dir");
1830        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1831        let config = PathfinderConfig::default();
1832        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1833
1834        let server = PathfinderServer::with_all_engines(
1835            ws,
1836            config,
1837            sandbox,
1838            Arc::new(MockScout::default()),
1839            Arc::new(MockSurgeon::new()),
1840            Arc::new(pathfinder_lsp::MockLawyer::default()),
1841        );
1842
1843        let params = crate::server::types::DeleteFileParams {
1844            filepath: "nonexistent.txt".to_owned(),
1845            base_version: "sha256:any".to_owned(),
1846        };
1847        let result = server.delete_file_impl(params).await;
1848        let Err(err) = result else {
1849            panic!("expected error");
1850        };
1851        let code = err
1852            .data
1853            .as_ref()
1854            .and_then(|d| d.get("error"))
1855            .and_then(|v| v.as_str())
1856            .unwrap_or("");
1857        assert_eq!(code, "FILE_NOT_FOUND");
1858    }
1859
1860    #[tokio::test]
1861    async fn test_read_file_not_found() {
1862        let ws_dir = tempdir().expect("temp dir");
1863        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1864        let config = PathfinderConfig::default();
1865        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1866
1867        let server = PathfinderServer::with_all_engines(
1868            ws,
1869            config,
1870            sandbox,
1871            Arc::new(MockScout::default()),
1872            Arc::new(MockSurgeon::new()),
1873            Arc::new(pathfinder_lsp::MockLawyer::default()),
1874        );
1875
1876        let params = crate::server::types::ReadFileParams {
1877            filepath: "missing.txt".to_owned(),
1878            start_line: 1,
1879            max_lines: 100,
1880        };
1881        let result = server.read_file_impl(params).await;
1882        let Err(err) = result else {
1883            panic!("expected error");
1884        };
1885        let code = err
1886            .data
1887            .as_ref()
1888            .and_then(|d| d.get("error"))
1889            .and_then(|v| v.as_str())
1890            .unwrap_or("");
1891        assert_eq!(code, "FILE_NOT_FOUND");
1892    }
1893
1894    #[tokio::test]
1895    async fn test_write_file_broadcasts_watched_file_event() {
1896        let ws_dir = tempdir().expect("temp dir");
1897        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1898        let config = PathfinderConfig::default();
1899        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1900
1901        // Write initial file
1902        let initial_content = "initial content";
1903        std::fs::write(ws_dir.path().join("config.toml"), initial_content).unwrap();
1904        let hash = VersionHash::compute(initial_content.as_bytes());
1905
1906        let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
1907
1908        let server = PathfinderServer::with_all_engines(
1909            ws,
1910            config,
1911            sandbox,
1912            Arc::new(MockScout::default()),
1913            Arc::new(MockSurgeon::new()),
1914            lawyer.clone(),
1915        );
1916
1917        let params = crate::server::types::WriteFileParams {
1918            filepath: "config.toml".to_owned(),
1919            base_version: hash.as_str().to_owned(),
1920            content: Some("updated content".to_owned()),
1921            replacements: None,
1922        };
1923        let result = server.write_file_impl(params).await;
1924        assert!(result.is_ok(), "write should succeed");
1925
1926        // Verify content updated
1927        let written = std::fs::read_to_string(ws_dir.path().join("config.toml")).unwrap();
1928        assert_eq!(written, "updated content");
1929
1930        // Verify watched file event was broadcast
1931        assert_eq!(lawyer.watched_file_changes_count(), 1);
1932    }
1933
1934    #[tokio::test]
1935    async fn test_write_file_invalid_params_both_modes() {
1936        let ws_dir = tempdir().expect("temp dir");
1937        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1938        let config = PathfinderConfig::default();
1939        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1940
1941        std::fs::write(ws_dir.path().join("test.txt"), "content").unwrap();
1942
1943        let server = PathfinderServer::with_all_engines(
1944            ws,
1945            config,
1946            sandbox,
1947            Arc::new(MockScout::default()),
1948            Arc::new(MockSurgeon::new()),
1949            Arc::new(pathfinder_lsp::MockLawyer::default()),
1950        );
1951
1952        // Both content and replacements set — invalid
1953        let hash = VersionHash::compute(b"content");
1954        let params = crate::server::types::WriteFileParams {
1955            filepath: "test.txt".to_owned(),
1956            base_version: hash.as_str().to_owned(),
1957            content: Some("new".to_owned()),
1958            replacements: Some(vec![crate::server::types::Replacement {
1959                old_text: "a".to_string(),
1960                new_text: "b".to_string(),
1961            }]),
1962        };
1963        let result = server.write_file_impl(params).await;
1964        assert!(result.is_err(), "should reject both modes");
1965    }
1966
1967    #[tokio::test]
1968    async fn test_write_file_invalid_params_neither_mode() {
1969        let ws_dir = tempdir().expect("temp dir");
1970        let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1971        let config = PathfinderConfig::default();
1972        let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1973
1974        std::fs::write(ws_dir.path().join("test.txt"), "content").unwrap();
1975
1976        let server = PathfinderServer::with_all_engines(
1977            ws,
1978            config,
1979            sandbox,
1980            Arc::new(MockScout::default()),
1981            Arc::new(MockSurgeon::new()),
1982            Arc::new(pathfinder_lsp::MockLawyer::default()),
1983        );
1984
1985        // Neither content nor replacements — invalid
1986        let hash = VersionHash::compute(b"content");
1987        let params = crate::server::types::WriteFileParams {
1988            filepath: "test.txt".to_owned(),
1989            base_version: hash.as_str().to_owned(),
1990            content: None,
1991            replacements: None,
1992        };
1993        let result = server.write_file_impl(params).await;
1994        assert!(result.is_err(), "should reject neither mode");
1995    }
1996}