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