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