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