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