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