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