1mod helpers;
16mod tools;
17pub 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#[derive(Clone)]
47pub struct PathfinderServer {
48 workspace_root: Arc<WorkspaceRoot>,
49 sandbox: Arc<Sandbox>,
50 scout: Arc<dyn Scout>,
51 surgeon: Arc<dyn Surgeon>,
52 lawyer: Arc<dyn Lawyer>,
53 tool_router: ToolRouter<Self>,
54}
55
56impl PathfinderServer {
57 #[must_use]
68 pub async fn new(workspace_root: WorkspaceRoot, config: PathfinderConfig) -> Self {
69 let sandbox = Sandbox::new(workspace_root.path(), &config.sandbox);
70
71 let lawyer: Arc<dyn Lawyer> =
72 match LspClient::new(workspace_root.path(), Arc::new(config.clone())).await {
73 Ok(client) => {
74 client.warm_start();
78 tracing::info!(
79 workspace = %workspace_root.path().display(),
80 "LspClient initialised (warm start in progress)"
81 );
82 Arc::new(client)
83 }
84 Err(e) => {
85 tracing::warn!(
86 error = %e,
87 "LSP Zero-Config detection failed — degraded mode (NoOpLawyer)"
88 );
89 Arc::new(NoOpLawyer)
90 }
91 };
92
93 Self::with_all_engines(
94 workspace_root,
95 config,
96 sandbox,
97 Arc::new(RipgrepScout),
98 Arc::new(TreeSitterSurgeon::new(100)), lawyer,
100 )
101 }
102
103 #[must_use]
107 #[cfg_attr(not(test), allow(dead_code))]
108 pub fn with_engines(
109 workspace_root: WorkspaceRoot,
110 config: PathfinderConfig,
111 sandbox: Sandbox,
112 scout: Arc<dyn Scout>,
113 surgeon: Arc<dyn Surgeon>,
114 ) -> Self {
115 Self::with_all_engines(
116 workspace_root,
117 config,
118 sandbox,
119 scout,
120 surgeon,
121 Arc::new(NoOpLawyer),
122 )
123 }
124
125 #[must_use]
127 #[allow(clippy::needless_pass_by_value)] pub fn with_all_engines(
129 workspace_root: WorkspaceRoot,
130 config: PathfinderConfig,
131 sandbox: Sandbox,
132 scout: Arc<dyn Scout>,
133 surgeon: Arc<dyn Surgeon>,
134 lawyer: Arc<dyn Lawyer>,
135 ) -> Self {
136 let _ = config;
137 Self {
138 workspace_root: Arc::new(workspace_root),
139 sandbox: Arc::new(sandbox),
140 scout,
141 surgeon,
142 lawyer,
143 tool_router: Self::tool_router(),
144 }
145 }
146}
147
148#[tool_router]
151impl PathfinderServer {
152 #[tool(
153 name = "search_codebase",
154 description = "Search the codebase for a text pattern. Returns matching lines with surrounding context. Each match includes an 'enclosing_semantic_path' (the AST symbol containing the match) and 'version_hash' (for immediate editing without a separate read). The version_hash in each match is immediately usable as base_version for edit tools — no additional read required. Use path_glob to narrow the search scope.\n\n**E4 parameters (token efficiency):**\n- `exclude_glob` — Glob pattern for files to exclude before search (e.g. `**/*.test.*`). Applied at the file-walk level so excluded files are never read.\n- `known_files` — List of file paths already in agent context. Matches in these files are returned with minimal metadata only (`file`, `line`, `column`, `enclosing_semantic_path`, `version_hash`) — `content` and context lines are omitted.\n- `group_by_file` — When `true`, results are returned in `file_groups` (one group per file with a single shared `version_hash`). Known-file matches appear in `known_matches`; others in `matches` inside each group."
155 )]
156 async fn search_codebase(
157 &self,
158 Parameters(params): Parameters<SearchCodebaseParams>,
159 ) -> Result<Json<SearchCodebaseResponse>, ErrorData> {
160 self.search_codebase_impl(params).await
161 }
162
163 #[tool(
164 name = "get_repo_map",
165 description = "Returns the structural skeleton of the project as an indented tree of classes, functions, and type signatures. IMPORTANT: Each symbol has its full semantic path in a trailing comment. You MUST copy-paste these EXACT paths into read/edit tools. Also returns version_hashes per file for immediate editing. The version_hashes are immediately usable as base_version for edit tools — no additional read required. Two budget knobs control coverage: `max_tokens` is the total token budget (default 16000); `max_tokens_per_file` caps detail per file before collapsing to a stub (default 2000). When `coverage_percent` is low, increase `max_tokens`. When files show `[TRUNCATED DUE TO SIZE]`, increase `max_tokens_per_file`. Use `visibility=all` to include private symbols for auditing. Module scopes (e.g., Rust `mod tests`, `mod types`) are only shown when `visibility` is set to `\"all\"`. They are hidden in public-only maps. The `depth` parameter (default 5) controls directory traversal depth; increase it for deeply-nested repos when `coverage_percent` is low.\n\n**Temporal & extension filters (Epic E6):**\n- `changed_since` — Git ref or duration to show only recently-modified files (e.g., `HEAD~5`, `3h`, `2024-01-01`). Useful for reviewing what changed in a PR or recent session. When git is unavailable the parameter is silently ignored and `degraded: true` is set in the response.\n- `include_extensions` — Only include files with these extensions (e.g., `[\"ts\", \"tsx\"]`). Mutually exclusive with `exclude_extensions`.\n- `exclude_extensions` — Exclude files with these extensions (e.g., `[\"md\", \"json\"]`). Mutually exclusive with `include_extensions`."
166 )]
167 #[allow(clippy::unused_self)]
168 async fn get_repo_map(
169 &self,
170 Parameters(params): Parameters<GetRepoMapParams>,
171 ) -> Result<rmcp::model::CallToolResult, rmcp::model::ErrorData> {
172 self.get_repo_map_impl(params).await
173 }
174
175 #[tool(
176 name = "read_symbol_scope",
177 description = "Extract the exact source code of a single symbol (function, class, method) by its semantic path. IMPORTANT: semantic_path must ALWAYS include the file path and '::', e.g., 'src/client/process.rs::send'. Returns the code, line range, and version_hash for OCC. The version_hash is immediately usable as base_version for any edit tool — no additional read required."
178 )]
179 async fn read_symbol_scope(
180 &self,
181 Parameters(params): Parameters<ReadSymbolScopeParams>,
182 ) -> Result<rmcp::model::CallToolResult, ErrorData> {
183 self.read_symbol_scope_impl(params).await
184 }
185
186 #[tool(
187 name = "read_source_file",
188 description = "**AST-only.** Only call this on source code files (.rs, .ts, .tsx, .go, .py, .vue, .jsx, .js). For configuration or documentation files (YAML, TOML, JSON, Markdown, Dockerfile, .env, XML), use `read_file` instead — calling this tool on those file types returns UNSUPPORTED_LANGUAGE.\n\nRead an entire source file and extract its complete AST symbol hierarchy. Returns the full file context, the language detected, OCC hashes, and a nested tree of symbols with their semantic paths. Use this instead of read_symbol_scope when you need broader context beyond a single symbol. The version_hash is immediately usable as base_version for any edit tool — no additional read required.\n\n**detail_level parameter:** `compact` (default) — full source + flat symbol list; `symbols` — symbol tree only, no source; `full` — full source + complete nested AST (v4 behaviour). Use `start_line`/`end_line` to restrict output to a region of interest."
189 )]
190 async fn read_source_file(
191 &self,
192 Parameters(params): Parameters<ReadSourceFileParams>,
193 ) -> Result<rmcp::model::CallToolResult, ErrorData> {
194 self.read_source_file_impl(params).await
195 }
196
197 #[tool(
198 name = "replace_batch",
199 description = "Apply multiple AST-aware edits sequentially within a single source file using a single atomic write. Accepts a list of edits, applies them from the end of the file backwards to prevent offset shifting, and uses a single OCC base_version guard. Use this for refactors touching multiple non-contiguous symbols in one file. IMPORTANT: For each edit, semantic_path must ALWAYS include the file path and '::' (e.g. 'src/mod.rs::func').\n\n**Two targeting modes per edit (E3.1 — Hybrid Batch):**\n\n**Option A — Semantic targeting (existing):** Set `semantic_path`, `edit_type`, and optionally `new_code`. Use for source-code constructs that have a parseable AST symbol.\n\n**Option B — Text targeting (new):** Set `old_text`, `context_line`, and optionally `replacement_text`. Use for Vue `<template>`/`<style>` zones or any region with no usable semantic path. The search scans ±25 lines around `context_line` (1-indexed) for an exact match of `old_text`. Set `normalize_whitespace: true` to collapse `\\s+` → single space before matching (useful for HTML where indentation may vary; do NOT use for Python or YAML).\n\nBoth targeting modes can appear in the same batch — the batch is fully atomic (all-or-nothing). If any edit fails (e.g., `TEXT_NOT_FOUND`), the entire batch is rolled back.\n\n**Schema quick-reference:**\n Option A: { \"semantic_path\": \"src/file.rs::MyStruct.my_fn\", \"edit_type\": \"replace_body\", \"new_code\": \"...\" }\n edit_type values: replace_body | replace_full | insert_before | insert_after | insert_into | delete\n Option B: { \"old_text\": \"<old html>\", \"context_line\": 42, \"replacement_text\": \"<new html>\" }\n Both modes may be mixed in one batch. `context_line` is required for text targeting.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
200 )]
201 async fn replace_batch(
202 &self,
203 Parameters(params): Parameters<crate::server::types::ReplaceBatchParams>,
204 ) -> Result<Json<EditResponse>, ErrorData> {
205 self.replace_batch_impl(params).await
206 }
207
208 #[tool(
209 name = "read_with_deep_context",
210 description = "Extract a symbol's source code PLUS the signatures of all functions it calls. Use this when you need to understand a function's dependencies before editing it. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/auth.ts::AuthService.login').\n\nReturns a hybrid response: raw source code in `content[0].text` for direct reading, and structured metadata in `structured_content` (JSON) containing `version_hash`, `start_line`, `end_line`, `language`, `dependencies` (callee signatures), `degraded`, and `degraded_reason`.\n\n**Latency note:** The first call may take significantly longer (30–120 seconds) due to LSP server warm-up. Subsequent calls are fast. This is expected behaviour — not a bug.\n\n**Degraded mode:** When no LSP is available, `degraded=true` and `degraded_reason=\"no_lsp\"`. The response still returns source code and Tree-sitter context, but `dependencies` will be empty. Check `degraded` before relying on dependency data."
211 )]
212 async fn read_with_deep_context(
213 &self,
214 Parameters(params): Parameters<ReadWithDeepContextParams>,
215 ) -> Result<rmcp::model::CallToolResult, ErrorData> {
216 self.read_with_deep_context_impl(params).await
217 }
218
219 #[tool(
220 name = "get_definition",
221 description = "Jump to where a symbol is defined. Provide a semantic path to a reference and get back the definition's file, line, and a code preview. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/auth.ts::AuthService.login')."
222 )]
223 async fn get_definition(
224 &self,
225 Parameters(params): Parameters<GetDefinitionParams>,
226 ) -> Result<Json<GetDefinitionResponse>, ErrorData> {
227 self.get_definition_impl(params).await
228 }
229
230 #[tool(
231 name = "analyze_impact",
232 description = "Find all callers of a symbol (incoming) and all symbols it calls (outgoing). Use this BEFORE refactoring to understand the blast radius of a change. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/mod.rs::func'). Returns version_hashes for all referenced files. The version_hashes are immediately usable as base_version for edit tools — no additional read required."
233 )]
234 async fn analyze_impact(
235 &self,
236 Parameters(params): Parameters<AnalyzeImpactParams>,
237 ) -> Result<rmcp::model::CallToolResult, ErrorData> {
238 self.analyze_impact_impl(params).await
239 }
240
241 #[tool(
242 name = "replace_body",
243 description = "Replace the internal logic of a block-scoped construct (function, method, class body, impl block), keeping the signature intact. Provide ONLY the body content — DO NOT include the outer braces or function signature. DO NOT wrap your code in markdown code blocks. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/mod.rs::func').\n\n**LSP validation:** Edit responses include a `validation` field. If `validation_skipped` is true, check `validation_skipped_reason` for why (e.g., `no_lsp`, `lsp_crash`). To see LSP status before editing, call `get_repo_map` and inspect `capabilities.lsp.per_language`.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
244 )]
245 async fn replace_body(
246 &self,
247 Parameters(params): Parameters<ReplaceBodyParams>,
248 ) -> Result<Json<EditResponse>, ErrorData> {
249 self.replace_body_impl(params).await
250 }
251
252 #[tool(
253 name = "replace_full",
254 description = "Replace an entire declaration including its signature, body, decorators, and doc comments. Provide the COMPLETE replacement — anything you omit (decorators, doc comments) will be removed. DO NOT wrap your code in markdown code blocks. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/mod.rs::func').\n\n**LSP validation:** Edit responses include a `validation` field. If `validation_skipped` is true, check `validation_skipped_reason` for why (e.g., `no_lsp`, `lsp_crash`). To see LSP status before editing, call `get_repo_map` and inspect `capabilities.lsp.per_language`.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
255 )]
256 async fn replace_full(
257 &self,
258 Parameters(params): Parameters<ReplaceFullParams>,
259 ) -> Result<Json<EditResponse>, ErrorData> {
260 self.replace_full_impl(params).await
261 }
262
263 #[tool(
264 name = "insert_before",
265 description = "Insert new code BEFORE a target symbol. IMPORTANT: To target a symbol, semantic_path must include the file path and '::' (e.g. 'src/mod.rs::func'). To insert at the TOP of a file (e.g., adding imports), use a bare file path without '::' (e.g. 'src/mod.rs'). Pathfinder automatically adds one blank line between your code and the target.\n\n**LSP validation:** Edit responses include a `validation` field. If `validation_skipped` is true, check `validation_skipped_reason` for why. Call `get_repo_map` and inspect `capabilities.lsp.per_language` to see LSP status upfront.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
266 )]
267 async fn insert_before(
268 &self,
269 Parameters(params): Parameters<InsertBeforeParams>,
270 ) -> Result<Json<EditResponse>, ErrorData> {
271 self.insert_before_impl(params).await
272 }
273
274 #[tool(
275 name = "insert_after",
276 description = "Insert new code AFTER a target symbol. IMPORTANT: To target a symbol, semantic_path must include the file path and '::' (e.g. 'src/mod.rs::func'). To append to the BOTTOM of a file (e.g., adding new classes), use a bare file path without '::' (e.g. 'src/mod.rs'). Pathfinder automatically adds one blank line between the target and your code.\n\n**LSP validation:** Edit responses include a `validation` field. If `validation_skipped` is true, check `validation_skipped_reason` for why. Call `get_repo_map` and inspect `capabilities.lsp.per_language` to see LSP status upfront.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
277 )]
278 async fn insert_after(
279 &self,
280 Parameters(params): Parameters<InsertAfterParams>,
281 ) -> Result<Json<EditResponse>, ErrorData> {
282 self.insert_after_impl(params).await
283 }
284
285 #[tool(
286 name = "insert_into",
287 description = "Insert new code at the END of a container symbol's body \
288 (Module, Class, Struct, Impl, Interface). This is the correct tool \
289 for adding new functions to a test module, new methods to a struct, \
290 or new items to any scope. IMPORTANT: semantic_path must target a \
291 container symbol (e.g. 'src/lib.rs::tests'), NOT a bare file path. \
292 For inserting before/after a specific sibling symbol, use insert_before \
293 or insert_after instead.\n\nbase_version accepts either the full SHA-256 hash \
294 (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), \
295 matching Git convention."
296 )]
297 async fn insert_into(
298 &self,
299 Parameters(params): Parameters<crate::server::types::InsertIntoParams>,
300 ) -> Result<Json<EditResponse>, ErrorData> {
301 self.insert_into_impl(params).await
302 }
303
304 #[tool(
305 name = "delete_symbol",
306 description = "Delete a symbol and all its associated decorators, attributes, and doc comments. If the target is a class, the ENTIRE class is deleted. If the target is a method, only that method is deleted. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/auth.ts::AuthService.login').\n\n**LSP validation:** Edit responses include a `validation` field. If `validation_skipped` is true, check `validation_skipped_reason` for why. Call `get_repo_map` and inspect `capabilities.lsp.per_language` to see LSP status upfront.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
307 )]
308 async fn delete_symbol(
309 &self,
310 Parameters(params): Parameters<DeleteSymbolParams>,
311 ) -> Result<Json<EditResponse>, ErrorData> {
312 self.delete_symbol_impl(params).await
313 }
314
315 #[tool(
316 name = "validate_only",
317 description = "Dry-run an edit WITHOUT writing to disk. Use this to pre-check risky changes. Returns the same validation results as a real edit. IMPORTANT: semantic_path must ALWAYS include the file path and '::' (e.g. 'src/mod.rs::func'). new_version_hash will be null because nothing was written. Reuse your original base_version for the real edit.\n\n**LSP validation:** If `validation_skipped` is true, check `validation_skipped_reason` for why (e.g., `no_lsp`, `lsp_crash`). Call `get_repo_map` and inspect `capabilities.lsp.per_language` to see LSP status upfront.\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
318 )]
319 async fn validate_only(
320 &self,
321 Parameters(params): Parameters<ValidateOnlyParams>,
322 ) -> Result<Json<EditResponse>, ErrorData> {
323 self.validate_only_impl(params).await
324 }
325
326 #[tool(
327 name = "create_file",
328 description = "Create a new file with initial content. Parent directories are created automatically. Returns a version_hash for subsequent edits."
329 )]
330 async fn create_file(
331 &self,
332 Parameters(params): Parameters<CreateFileParams>,
333 ) -> Result<Json<CreateFileResponse>, ErrorData> {
334 self.create_file_impl(params).await
335 }
336
337 #[tool(
338 name = "delete_file",
339 description = "Delete a file. Requires base_version (OCC) to prevent deleting a file that was modified after you last read it. base_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
340 )]
341 async fn delete_file(
342 &self,
343 Parameters(params): Parameters<DeleteFileParams>,
344 ) -> Result<Json<DeleteFileResponse>, ErrorData> {
345 self.delete_file_impl(params).await
346 }
347
348 #[tool(
349 name = "read_file",
350 description = "Read raw file content. Use ONLY for configuration files (.env, Dockerfile, YAML, TOML, package.json). For source code, use read_symbol_scope instead. Supports pagination via start_line for large files."
351 )]
352 async fn read_file(
353 &self,
354 Parameters(params): Parameters<ReadFileParams>,
355 ) -> Result<rmcp::model::CallToolResult, ErrorData> {
356 self.read_file_impl(params).await
357 }
358
359 #[tool(
360 name = "write_file",
361 description = "WARNING: This bypasses AST validation and formatting. DO NOT use for source code (TypeScript, Python, Go, Rust). ONLY use for configuration files (.env, .gitignore, Dockerfile, YAML). For source code, use replace_body or replace_full instead. Provide EITHER 'content' for full replacement OR 'replacements' for surgical search-and-replace edits (e.g., {old_text: 'postgres:15', new_text: 'postgres:16'}). Use replacements when changing specific text in large files. Requires base_version (OCC).\n\nbase_version accepts either the full SHA-256 hash (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), matching Git convention."
362 )]
363 async fn write_file(
364 &self,
365 Parameters(params): Parameters<WriteFileParams>,
366 ) -> Result<rmcp::model::CallToolResult, ErrorData> {
367 self.write_file_impl(params).await
368 }
369}
370
371#[tool_handler]
374impl ServerHandler for PathfinderServer {
375 fn get_info(&self) -> ServerInfo {
376 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
377 .with_server_info(Implementation::new("pathfinder", env!("CARGO_PKG_VERSION")))
378 }
379}
380
381#[cfg(test)]
384#[allow(clippy::expect_used, clippy::unwrap_used)]
385mod tests {
386 use super::*;
387 use crate::server::types::Replacement;
388 use pathfinder_common::types::{FilterMode, VersionHash};
389 use pathfinder_search::{MockScout, SearchMatch, SearchResult};
390 use pathfinder_treesitter::mock::MockSurgeon;
391 use rmcp::model::ErrorCode;
392 use std::fs;
393 use tempfile::tempdir;
394
395 #[tokio::test]
396 async fn test_get_repo_map_success() {
397 let ws_dir = tempdir().expect("temp dir");
398 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
399 let config = PathfinderConfig::default();
400 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
401
402 let mock_surgeon = MockSurgeon::new();
403 mock_surgeon
404 .generate_skeleton_results
405 .lock()
406 .unwrap()
407 .push(Ok(pathfinder_treesitter::repo_map::RepoMapResult {
408 skeleton: "class Mock {}".to_string(),
409 tech_stack: vec!["TypeScript".to_string()],
410 files_scanned: 1,
411 files_truncated: 0,
412 files_in_scope: 1,
413 coverage_percent: 100,
414 version_hashes: std::collections::HashMap::default(),
415 }));
416
417 let server = PathfinderServer::with_engines(
418 ws,
419 config,
420 sandbox,
421 Arc::new(MockScout::default()),
422 Arc::new(mock_surgeon),
423 );
424
425 let params = GetRepoMapParams {
426 path: ".".to_owned(),
427 max_tokens: 16_000,
428 depth: 3,
429 visibility: pathfinder_common::types::Visibility::Public,
430 max_tokens_per_file: 2000,
431 changed_since: String::default(),
432 include_extensions: vec![],
433 exclude_extensions: vec![],
434 include_imports: pathfinder_common::types::IncludeImports::None,
435 };
436
437 let result = server.get_repo_map(Parameters(params)).await;
438 assert!(result.is_ok());
439 let call_res = result.unwrap();
440 let skeleton = match &call_res.content[0].raw {
441 rmcp::model::RawContent::Text(t) => t.text.clone(),
442 _ => panic!("expected text content"),
443 };
444 let response: crate::server::types::GetRepoMapMetadata =
445 serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
446 assert_eq!(skeleton, "class Mock {}");
447 assert_eq!(response.files_scanned, 1);
448 assert_eq!(response.coverage_percent, 100);
449 assert_eq!(response.visibility_degraded, None);
451 }
452
453 #[tokio::test]
454 async fn test_get_repo_map_visibility_not_degraded() {
455 let ws_dir = tempdir().expect("temp dir");
458 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
459 let config = PathfinderConfig::default();
460 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
461
462 let mock_surgeon = MockSurgeon::new();
463 mock_surgeon
464 .generate_skeleton_results
465 .lock()
466 .unwrap()
467 .push(Ok(pathfinder_treesitter::repo_map::RepoMapResult {
468 skeleton: String::default(),
469 tech_stack: vec![],
470 files_scanned: 0,
471 files_truncated: 0,
472 files_in_scope: 0,
473 coverage_percent: 100,
474 version_hashes: std::collections::HashMap::default(),
475 }));
476
477 let server = PathfinderServer::with_engines(
478 ws,
479 config,
480 sandbox,
481 Arc::new(MockScout::default()),
482 Arc::new(mock_surgeon),
483 );
484
485 let params = GetRepoMapParams {
486 visibility: pathfinder_common::types::Visibility::All,
487 ..Default::default()
488 };
489 let result = server
490 .get_repo_map(Parameters(params))
491 .await
492 .expect("should succeed");
493 let meta: crate::server::types::GetRepoMapMetadata =
494 serde_json::from_value(result.structured_content.unwrap()).unwrap();
495 assert_eq!(
496 meta.visibility_degraded, None,
497 "visibility filtering is implemented; visibility_degraded must be None"
498 );
499 }
500
501 #[tokio::test]
502 async fn test_get_repo_map_access_denied() {
503 let ws_dir = tempdir().expect("temp dir");
504 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
505 let config = PathfinderConfig::default();
506 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
507
508 let mock_surgeon = MockSurgeon::new();
509 let server = PathfinderServer::with_engines(
510 ws,
511 config,
512 sandbox,
513 Arc::new(MockScout::default()),
514 Arc::new(mock_surgeon),
515 );
516
517 let params = GetRepoMapParams {
518 path: ".env".to_string(), ..Default::default()
520 };
521
522 let Err(err) = server.get_repo_map(Parameters(params)).await else {
523 panic!("Expected ACCESS_DENIED error");
524 };
525 assert_eq!(err.code, ErrorCode(-32001));
526 }
527
528 #[tokio::test]
529 async fn test_create_file_success_and_already_exists() {
530 let ws_dir = tempdir().expect("temp dir");
531 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
532 let config = PathfinderConfig::default();
533 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
534 let mock_scout = MockScout::default();
535 let server = PathfinderServer::with_engines(
536 ws,
537 config,
538 sandbox,
539 Arc::new(mock_scout),
540 Arc::new(MockSurgeon::new()),
541 );
542
543 let filepath = "src/new_file.ts";
544 let content = "console.log('hello');";
545 let params = CreateFileParams {
546 filepath: filepath.to_owned(),
547 content: content.to_owned(),
548 };
549
550 let result = server.create_file(Parameters(params.clone())).await;
552 assert!(result.is_ok(), "Expected success, got {:#?}", result.err());
553 let val = result.expect("create_file should succeed").0;
554 assert!(val.success);
555 assert_eq!(val.validation.status, "passed");
556
557 let expected_hash = VersionHash::compute(content.as_bytes());
558 assert_eq!(val.version_hash, expected_hash.short());
559
560 let absolute_path = ws_dir.path().join(filepath);
562 assert!(absolute_path.exists());
563 let read_content = fs::read_to_string(&absolute_path).expect("read file");
564 assert_eq!(read_content, content);
565
566 let result2 = server.create_file(Parameters(params)).await;
568 assert!(result2.is_err());
569 if let Err(err) = result2 {
570 let code = err
571 .data
572 .as_ref()
573 .and_then(|d| d.get("error"))
574 .and_then(|v| v.as_str())
575 .unwrap_or("");
576 assert_eq!(code, "FILE_ALREADY_EXISTS", "got data: {:?}", err.data);
577 } else {
578 panic!("Expected error mapping to FILE_ALREADY_EXISTS");
579 }
580
581 let deny_params = CreateFileParams {
583 filepath: ".git/objects/some_file".to_owned(),
584 content: "payload".to_owned(),
585 };
586 let result3 = server.create_file(Parameters(deny_params)).await;
587 assert!(result3.is_err());
588 if let Err(err) = result3 {
589 let code = err
590 .data
591 .as_ref()
592 .and_then(|d| d.get("error"))
593 .and_then(|v| v.as_str())
594 .unwrap_or("");
595 assert_eq!(code, "ACCESS_DENIED", "got data: {:?}", err.data);
596 } else {
597 panic!("Expected error mapping to ACCESS_DENIED");
598 }
599 }
600
601 #[tokio::test]
602 async fn test_search_codebase_routes_to_scout_and_handles_success() {
603 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
604 let config = PathfinderConfig::default();
605 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
606
607 let mock_scout = MockScout::default();
608 mock_scout.set_result(Ok(SearchResult {
609 matches: vec![SearchMatch {
610 file: "src/main.rs".to_owned(),
611 line: 10,
612 column: 5,
613 content: "test_query()".to_owned(),
614 context_before: vec![],
615 context_after: vec![],
616 enclosing_semantic_path: None,
617 version_hash: "sha256:123".to_owned(),
618 known: None,
619 }],
620 total_matches: 1,
621 truncated: false,
622 }));
623
624 let mock_surgeon = Arc::new(MockSurgeon::new());
625 mock_surgeon
626 .enclosing_symbol_results
627 .lock()
628 .unwrap()
629 .push(Ok(Some("test_query_func".to_owned())));
630
631 let server = PathfinderServer::with_engines(
632 ws,
633 config,
634 sandbox,
635 Arc::new(mock_scout.clone()),
636 mock_surgeon.clone(),
637 );
638 let params = SearchCodebaseParams {
639 query: "test_query".to_owned(),
640 is_regex: true,
641 ..Default::default()
642 };
643
644 let result = server.search_codebase(Parameters(params)).await;
645 let val = result.expect("search_codebase should succeed").0;
647
648 assert_eq!(val.total_matches, 1);
649 assert!(!val.truncated);
650 let matches = val.matches;
651 assert_eq!(matches[0].file, "src/main.rs");
652 assert_eq!(matches[0].content, "test_query()");
653 assert_eq!(
654 matches[0].enclosing_semantic_path.as_deref(),
655 Some("src/main.rs::test_query_func")
656 );
657
658 let calls = mock_scout.calls();
659 assert_eq!(calls.len(), 1);
660 assert_eq!(calls[0].query, "test_query");
661 assert!(calls[0].is_regex);
662
663 let surgeon_calls = mock_surgeon.enclosing_symbol_calls.lock().unwrap();
664 assert_eq!(surgeon_calls.len(), 1);
665 assert_eq!(surgeon_calls[0].1, std::path::PathBuf::from("src/main.rs"));
666 assert_eq!(surgeon_calls[0].2, 10);
667 }
668
669 #[tokio::test]
670 async fn test_search_codebase_handles_scout_error() {
671 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
672 let config = PathfinderConfig::default();
673 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
674
675 let mock_scout = MockScout::default();
676 mock_scout.set_result(Err("simulated engine error".to_owned()));
677
678 let server = PathfinderServer::with_engines(
679 ws,
680 config,
681 sandbox,
682 Arc::new(mock_scout),
683 Arc::new(MockSurgeon::new()),
684 );
685 let params = SearchCodebaseParams {
686 query: "test".to_owned(),
687 ..Default::default()
688 };
689
690 let result = server.search_codebase(Parameters(params)).await;
691
692 let err = result
693 .err()
694 .expect("search_codebase should return error on scout failure");
695 assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
696 assert_eq!(err.message, "search engine error: simulated engine error");
697 }
698
699 fn make_search_match(file: &str, line: u64, content: &str) -> SearchMatch {
702 SearchMatch {
703 file: file.to_owned(),
704 line,
705 column: 0,
706 content: content.to_owned(),
707 context_before: vec![],
708 context_after: vec![],
709 enclosing_semantic_path: None,
710 version_hash: "sha256:abc".to_owned(),
711 known: None,
712 }
713 }
714
715 #[tokio::test]
716 async fn test_search_codebase_filter_mode_code_only_drops_comments() {
717 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
718 let config = PathfinderConfig::default();
719 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
720
721 let mock_scout = MockScout::default();
722 mock_scout.set_result(Ok(SearchResult {
723 matches: vec![
724 make_search_match("src/a.go", 1, "code line"),
725 make_search_match("src/a.go", 2, "// comment line"),
726 make_search_match("src/a.go", 3, "another code line"),
727 ],
728 total_matches: 3,
729 truncated: false,
730 }));
731
732 let mock_surgeon = Arc::new(MockSurgeon::new());
733 mock_surgeon
737 .enclosing_symbol_results
738 .lock()
739 .unwrap()
740 .extend([Ok(None), Ok(None), Ok(None)]);
741 mock_surgeon
742 .node_type_at_position_results
743 .lock()
744 .unwrap()
745 .extend([
746 Ok("code".to_owned()),
747 Ok("comment".to_owned()),
748 Ok("code".to_owned()),
749 ]);
750
751 let server =
752 PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
753
754 let params = SearchCodebaseParams {
755 query: "line".to_owned(),
756 filter_mode: FilterMode::CodeOnly,
757 ..Default::default()
758 };
759
760 let result = server
761 .search_codebase(Parameters(params))
762 .await
763 .expect("should succeed")
764 .0;
765
766 assert_eq!(result.matches.len(), 2, "code_only should drop comments");
768 assert_eq!(result.matches[0].content, "code line");
769 assert_eq!(result.matches[1].content, "another code line");
770 assert_eq!(result.total_matches, 3);
772 assert!(!result.degraded);
774 }
775
776 #[tokio::test]
777 async fn test_search_codebase_filter_mode_comments_only_keeps_comments() {
778 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
779 let config = PathfinderConfig::default();
780 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
781
782 let mock_scout = MockScout::default();
783 mock_scout.set_result(Ok(SearchResult {
784 matches: vec![
785 make_search_match("src/b.go", 1, "func HelloWorld() {}"),
786 make_search_match("src/b.go", 2, "// HelloWorld says hello"),
787 make_search_match("src/b.go", 3, r#"msg := "Hello World""#),
788 ],
789 total_matches: 3,
790 truncated: false,
791 }));
792
793 let mock_surgeon = Arc::new(MockSurgeon::new());
794 mock_surgeon
795 .enclosing_symbol_results
796 .lock()
797 .unwrap()
798 .extend([Ok(None), Ok(None), Ok(None)]);
799 mock_surgeon
800 .node_type_at_position_results
801 .lock()
802 .unwrap()
803 .extend([
804 Ok("code".to_owned()),
805 Ok("comment".to_owned()),
806 Ok("string".to_owned()),
807 ]);
808
809 let server =
810 PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
811
812 let params = SearchCodebaseParams {
813 query: "Hello".to_owned(),
814 filter_mode: FilterMode::CommentsOnly,
815 ..Default::default()
816 };
817
818 let result = server
819 .search_codebase(Parameters(params))
820 .await
821 .expect("should succeed")
822 .0;
823
824 assert_eq!(result.matches.len(), 2, "comments_only should drop code");
826 assert_eq!(result.matches[0].content, "// HelloWorld says hello");
827 assert_eq!(result.matches[1].content, r#"msg := "Hello World""#);
828 assert!(!result.degraded);
829 }
830
831 #[tokio::test]
832 async fn test_search_codebase_filter_mode_all_returns_everything() {
833 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
834 let config = PathfinderConfig::default();
835 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
836
837 let mock_scout = MockScout::default();
838 mock_scout.set_result(Ok(SearchResult {
839 matches: vec![
840 make_search_match("src/c.go", 1, "code"),
841 make_search_match("src/c.go", 2, "// comment"),
842 make_search_match("src/c.go", 3, r#"\"string\""#),
843 ],
844 total_matches: 3,
845 truncated: false,
846 }));
847
848 let mock_surgeon = Arc::new(MockSurgeon::default());
849 mock_surgeon
851 .enclosing_symbol_results
852 .lock()
853 .unwrap()
854 .extend([Ok(None), Ok(None), Ok(None)]);
855 let server =
860 PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
861
862 let params = SearchCodebaseParams {
863 query: "test".to_owned(),
864 filter_mode: FilterMode::All,
865 ..Default::default()
866 };
867
868 let result = server
869 .search_codebase(Parameters(params))
870 .await
871 .expect("should succeed")
872 .0;
873
874 assert_eq!(result.matches.len(), 3);
876 assert!(!result.degraded);
877 }
878
879 #[tokio::test]
882 async fn test_delete_file_success_and_occ_failure() {
883 let ws_dir = tempdir().expect("temp dir");
884 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
885 let config = PathfinderConfig::default();
886 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
887 let server = PathfinderServer::with_engines(
888 ws,
889 config,
890 sandbox,
891 Arc::new(MockScout::default()),
892 Arc::new(MockSurgeon::new()),
893 );
894
895 let filepath = "to_delete.txt";
897 let content = "goodbye";
898 let abs = ws_dir.path().join(filepath);
899 fs::write(&abs, content).expect("write");
900 let hash = VersionHash::compute(content.as_bytes());
901
902 let result = server
904 .delete_file(Parameters(DeleteFileParams {
905 filepath: filepath.to_owned(),
906 base_version: hash.as_str().to_owned(),
907 }))
908 .await;
909 assert!(result.is_ok(), "Expected success, got {:?}", result.err());
910 assert!(!abs.exists(), "File should be gone");
911
912 let result2 = server
914 .delete_file(Parameters(DeleteFileParams {
915 filepath: filepath.to_owned(),
916 base_version: hash.as_str().to_owned(),
917 }))
918 .await;
919 assert!(result2.is_err());
920 let Err(err) = result2 else {
921 panic!("expected error")
922 };
923 let code = err
924 .data
925 .as_ref()
926 .and_then(|d| d.get("error"))
927 .and_then(|v| v.as_str())
928 .unwrap_or("");
929 assert_eq!(code, "FILE_NOT_FOUND", "got: {err:?}");
930
931 fs::write(&abs, content).expect("write");
933 let result3 = server
934 .delete_file(Parameters(DeleteFileParams {
935 filepath: filepath.to_owned(),
936 base_version: "sha256:wrong".to_owned(),
937 }))
938 .await;
939 assert!(result3.is_err());
940 let Err(err) = result3 else {
941 panic!("expected error")
942 };
943 let code = err
944 .data
945 .as_ref()
946 .and_then(|d| d.get("error"))
947 .and_then(|v| v.as_str())
948 .unwrap_or("");
949 assert_eq!(code, "VERSION_MISMATCH", "got: {err:?}");
950
951 let result4 = server
953 .delete_file(Parameters(DeleteFileParams {
954 filepath: ".git/objects/x".to_owned(),
955 base_version: "sha256:any".to_owned(),
956 }))
957 .await;
958 assert!(result4.is_err());
959 let Err(err) = result4 else {
960 panic!("expected error")
961 };
962 let code = err
963 .data
964 .as_ref()
965 .and_then(|d| d.get("error"))
966 .and_then(|v| v.as_str())
967 .unwrap_or("");
968 assert_eq!(code, "ACCESS_DENIED", "got: {err:?}");
969 }
970
971 #[tokio::test]
974 async fn test_read_file_pagination() {
975 let ws_dir = tempdir().expect("temp dir");
976 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
977 let config = PathfinderConfig::default();
978 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
979 let server = PathfinderServer::with_engines(
980 ws,
981 config,
982 sandbox,
983 Arc::new(MockScout::default()),
984 Arc::new(MockSurgeon::new()),
985 );
986
987 let filepath = "config.yaml";
989 let lines: Vec<String> = (1..=10).map(|i| format!("line{i}: value")).collect();
990 let content = lines.join("\n");
991 fs::write(ws_dir.path().join(filepath), &content).expect("write");
992
993 let result = server
995 .read_file(Parameters(ReadFileParams {
996 filepath: filepath.to_owned(),
997 start_line: 1,
998 max_lines: 500,
999 }))
1000 .await
1001 .expect("should succeed");
1002 let val: crate::server::types::ReadFileMetadata =
1003 serde_json::from_value(result.structured_content.unwrap()).unwrap();
1004 assert_eq!(val.total_lines, 10);
1005 assert_eq!(val.lines_returned, 10);
1006 assert!(!val.truncated);
1007 assert_eq!(val.language, "yaml");
1008
1009 let result2 = server
1011 .read_file(Parameters(ReadFileParams {
1012 filepath: filepath.to_owned(),
1013 start_line: 3,
1014 max_lines: 3,
1015 }))
1016 .await
1017 .expect("should succeed");
1018 let val2: crate::server::types::ReadFileMetadata =
1019 serde_json::from_value(result2.structured_content.unwrap()).unwrap();
1020 assert_eq!(val2.start_line, 3);
1021 assert_eq!(val2.lines_returned, 3);
1022 assert!(val2.truncated);
1023 let text_content = match &result2.content[0].raw {
1024 rmcp::model::RawContent::Text(t) => t.text.clone(),
1025 _ => panic!("expected text content"),
1026 };
1027 assert!(text_content.contains("line3"));
1028 assert!(text_content.contains("line5"));
1029 assert!(!text_content.contains("line6"));
1030
1031 let result3 = server
1033 .read_file(Parameters(ReadFileParams {
1034 filepath: "nonexistent.yaml".to_owned(),
1035 start_line: 1,
1036 max_lines: 500,
1037 }))
1038 .await;
1039 assert!(result3.is_err());
1040 let Err(err) = result3 else {
1041 panic!("expected error")
1042 };
1043 let code = err
1044 .data
1045 .as_ref()
1046 .and_then(|d| d.get("error"))
1047 .and_then(|v| v.as_str())
1048 .unwrap_or("");
1049 assert_eq!(code, "FILE_NOT_FOUND", "got: {err:?}");
1050 }
1051
1052 #[tokio::test]
1055 async fn test_read_symbol_scope_routes_to_surgeon_and_handles_success() {
1056 let ws_dir = tempdir().expect("temp dir");
1057 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1058 let config = PathfinderConfig::default();
1059 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1060 let mock_surgeon = Arc::new(MockSurgeon::new());
1061
1062 let content = "func Login() {}";
1063 let expected_scope = pathfinder_common::types::SymbolScope {
1064 content: content.to_owned(),
1065 start_line: 5,
1066 end_line: 7,
1067 version_hash: VersionHash::compute(content.as_bytes()),
1068 language: "go".to_owned(),
1069 };
1070 mock_surgeon
1071 .read_symbol_scope_results
1072 .lock()
1073 .unwrap()
1074 .push(Ok(expected_scope.clone()));
1075
1076 let server = PathfinderServer::with_engines(
1077 ws,
1078 config,
1079 sandbox,
1080 Arc::new(MockScout::default()),
1081 mock_surgeon.clone(),
1082 );
1083
1084 let params = ReadSymbolScopeParams {
1085 semantic_path: "src/auth.go::Login".to_owned(),
1086 };
1087
1088 let result = server.read_symbol_scope(Parameters(params)).await;
1089 let val = result.expect("should succeed");
1090
1091 let rmcp::model::RawContent::Text(t) = &val.content[0].raw else {
1092 panic!("Expected text content");
1093 };
1094 assert_eq!(t.text, expected_scope.content);
1095
1096 let metadata: crate::server::types::ReadSymbolScopeMetadata =
1097 serde_json::from_value(val.structured_content.expect("missing structured_content"))
1098 .expect("valid metadata");
1099
1100 assert_eq!(metadata.start_line, expected_scope.start_line);
1101 assert_eq!(metadata.end_line, expected_scope.end_line);
1102 assert_eq!(metadata.version_hash, expected_scope.version_hash.short());
1103 assert_eq!(metadata.language, expected_scope.language);
1104
1105 let calls = mock_surgeon.read_symbol_scope_calls.lock().unwrap();
1106 assert_eq!(calls.len(), 1);
1107 }
1108
1109 #[tokio::test]
1110 async fn test_read_symbol_scope_handles_surgeon_error() {
1111 let ws_dir = tempdir().expect("temp dir");
1112 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1113 let config = PathfinderConfig::default();
1114 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1115 let mock_surgeon = Arc::new(MockSurgeon::new());
1116
1117 mock_surgeon
1118 .read_symbol_scope_results
1119 .lock()
1120 .unwrap()
1121 .push(Err(pathfinder_treesitter::SurgeonError::SymbolNotFound {
1122 path: "src/auth.go::Login".to_owned(),
1123 did_you_mean: vec!["Logout".to_owned()],
1124 }));
1125
1126 let server = PathfinderServer::with_engines(
1127 ws,
1128 config,
1129 sandbox,
1130 Arc::new(MockScout::default()),
1131 mock_surgeon,
1132 );
1133
1134 let params = ReadSymbolScopeParams {
1135 semantic_path: "src/auth.go::Login".to_owned(),
1136 };
1137
1138 let Err(err) = server.read_symbol_scope(Parameters(params)).await else {
1139 panic!("Expected failed response");
1140 };
1141
1142 assert_eq!(err.code, ErrorCode::INVALID_PARAMS); let code = err
1144 .data
1145 .as_ref()
1146 .unwrap()
1147 .get("error")
1148 .unwrap()
1149 .as_str()
1150 .unwrap();
1151 assert_eq!(code, "SYMBOL_NOT_FOUND");
1152 }
1153
1154 #[tokio::test]
1157 async fn test_write_file_full_replacement() {
1158 let ws_dir = tempdir().expect("temp dir");
1159 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1160 let config = PathfinderConfig::default();
1161 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1162 let server = PathfinderServer::with_engines(
1163 ws,
1164 config,
1165 sandbox,
1166 Arc::new(MockScout::default()),
1167 Arc::new(MockSurgeon::new()),
1168 );
1169
1170 let filepath = "config.toml";
1171 let original = "[server]\nport = 8080";
1172 let abs = ws_dir.path().join(filepath);
1173 fs::write(&abs, original).expect("write");
1174 let hash = VersionHash::compute(original.as_bytes());
1175
1176 let replacement = "[server]\nport = 9090";
1178 let result = server
1179 .write_file(Parameters(WriteFileParams {
1180 filepath: filepath.to_owned(),
1181 base_version: hash.as_str().to_owned(),
1182 content: Some(replacement.to_owned()),
1183 replacements: None,
1184 }))
1185 .await
1186 .expect("should succeed");
1187 let val: crate::server::types::WriteFileMetadata =
1188 serde_json::from_value(result.structured_content.unwrap()).unwrap();
1189 assert!(val.success);
1190 let on_disk = fs::read_to_string(&abs).expect("read");
1191 assert_eq!(on_disk, replacement);
1192 let new_hash = VersionHash::compute(replacement.as_bytes());
1193 assert_eq!(val.new_version_hash, new_hash.short());
1194
1195 let result2 = server
1197 .write_file(Parameters(WriteFileParams {
1198 filepath: filepath.to_owned(),
1199 base_version: hash.as_str().to_owned(), content: Some("something else".to_owned()),
1201 replacements: None,
1202 }))
1203 .await;
1204 assert!(result2.is_err());
1205 let Err(err) = result2 else {
1206 panic!("expected error")
1207 };
1208 let code = err
1209 .data
1210 .as_ref()
1211 .and_then(|d| d.get("error"))
1212 .and_then(|v| v.as_str())
1213 .unwrap_or("");
1214 assert_eq!(code, "VERSION_MISMATCH", "got: {err:?}");
1215 }
1216
1217 #[tokio::test]
1218 async fn test_write_file_search_and_replace() {
1219 let ws_dir = tempdir().expect("temp dir");
1220 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1221 let config = PathfinderConfig::default();
1222 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1223 let server = PathfinderServer::with_engines(
1224 ws,
1225 config,
1226 sandbox,
1227 Arc::new(MockScout::default()),
1228 Arc::new(MockSurgeon::new()),
1229 );
1230
1231 let filepath = "docker-compose.yml";
1232 let original = "image: postgres:15\nports:\n - 5432:5432";
1233 let abs = ws_dir.path().join(filepath);
1234 fs::write(&abs, original).expect("write");
1235 let hash = VersionHash::compute(original.as_bytes());
1236
1237 let result = server
1239 .write_file(Parameters(WriteFileParams {
1240 filepath: filepath.to_owned(),
1241 base_version: hash.as_str().to_owned(),
1242 content: None,
1243 replacements: Some(vec![Replacement {
1244 old_text: "postgres:15".to_owned(),
1245 new_text: "postgres:16-alpine".to_owned(),
1246 }]),
1247 }))
1248 .await
1249 .expect("should succeed");
1250 let val: crate::server::types::WriteFileMetadata =
1251 serde_json::from_value(result.structured_content.unwrap()).unwrap();
1252 assert!(val.success);
1253 let on_disk = fs::read_to_string(&abs).expect("read");
1254 assert!(on_disk.contains("postgres:16-alpine"));
1255 let new_hash_val = val.new_version_hash;
1256
1257 let result2 = server
1259 .write_file(Parameters(WriteFileParams {
1260 filepath: filepath.to_owned(),
1261 base_version: new_hash_val.clone(),
1262 content: None,
1263 replacements: Some(vec![Replacement {
1264 old_text: "postgres:15".to_owned(), new_text: "postgres:17".to_owned(),
1266 }]),
1267 }))
1268 .await;
1269 assert!(result2.is_err());
1270 let Err(err) = result2 else {
1271 panic!("expected error")
1272 };
1273 let code = err
1274 .data
1275 .as_ref()
1276 .and_then(|d| d.get("error"))
1277 .and_then(|v| v.as_str())
1278 .unwrap_or("");
1279 assert_eq!(code, "MATCH_NOT_FOUND", "got: {err:?}");
1280
1281 let ambiguous = "tag: v1\ntag: v1";
1283 fs::write(&abs, ambiguous).expect("write");
1284 let ambig_hash = VersionHash::compute(ambiguous.as_bytes());
1285 let result3 = server
1286 .write_file(Parameters(WriteFileParams {
1287 filepath: filepath.to_owned(),
1288 base_version: ambig_hash.as_str().to_owned(),
1289 content: None,
1290 replacements: Some(vec![Replacement {
1291 old_text: "tag: v1".to_owned(),
1292 new_text: "tag: v2".to_owned(),
1293 }]),
1294 }))
1295 .await;
1296 assert!(result3.is_err());
1297 let Err(err) = result3 else {
1298 panic!("expected error")
1299 };
1300 let code = err
1301 .data
1302 .as_ref()
1303 .and_then(|d| d.get("error"))
1304 .and_then(|v| v.as_str())
1305 .unwrap_or("");
1306 assert_eq!(code, "AMBIGUOUS_MATCH", "got: {err:?}");
1307 }
1308
1309 #[tokio::test]
1314 async fn test_search_codebase_known_files_suppresses_context() {
1315 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
1316 let config = PathfinderConfig::default();
1317 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1318
1319 let mock_scout = MockScout::default();
1320 mock_scout.set_result(Ok(SearchResult {
1321 matches: vec![
1322 SearchMatch {
1323 file: "src/auth.ts".to_owned(),
1324 line: 10,
1325 column: 1,
1326 content: "secret content".to_owned(),
1327 context_before: vec!["before".to_owned()],
1328 context_after: vec!["after".to_owned()],
1329 enclosing_semantic_path: None,
1330 version_hash: "sha256:abc".to_owned(),
1331 known: None,
1332 },
1333 SearchMatch {
1334 file: "src/main.ts".to_owned(),
1335 line: 5,
1336 column: 1,
1337 content: "visible content".to_owned(),
1338 context_before: vec!["ctx_before".to_owned()],
1339 context_after: vec!["ctx_after".to_owned()],
1340 enclosing_semantic_path: None,
1341 version_hash: "sha256:xyz".to_owned(),
1342 known: None,
1343 },
1344 ],
1345 total_matches: 2,
1346 truncated: false,
1347 }));
1348
1349 let mock_surgeon = Arc::new(MockSurgeon::new());
1350 mock_surgeon
1352 .enclosing_symbol_results
1353 .lock()
1354 .unwrap()
1355 .extend([Ok(None), Ok(None)]);
1356
1357 let server =
1358 PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
1359
1360 let params = SearchCodebaseParams {
1361 query: "content".to_owned(),
1362 known_files: vec!["src/auth.ts".to_owned()],
1363 ..Default::default()
1364 };
1365
1366 let result = server
1367 .search_codebase(Parameters(params))
1368 .await
1369 .expect("should succeed")
1370 .0;
1371
1372 assert_eq!(result.matches.len(), 2);
1373
1374 let known_match = result
1376 .matches
1377 .iter()
1378 .find(|m| m.file == "src/auth.ts")
1379 .unwrap();
1380 assert!(
1381 known_match.content.is_empty(),
1382 "content should be suppressed for known file"
1383 );
1384 assert!(
1385 known_match.context_before.is_empty(),
1386 "context_before should be empty"
1387 );
1388 assert!(
1389 known_match.context_after.is_empty(),
1390 "context_after should be empty"
1391 );
1392 assert_eq!(
1393 known_match.known,
1394 Some(true),
1395 "known flag must be set for known-file matches"
1396 );
1397
1398 let normal_match = result
1400 .matches
1401 .iter()
1402 .find(|m| m.file == "src/main.ts")
1403 .unwrap();
1404 assert_eq!(normal_match.content, "visible content");
1405 assert_eq!(normal_match.context_before, vec!["ctx_before"]);
1406 assert_eq!(normal_match.context_after, vec!["ctx_after"]);
1407 assert_eq!(
1408 normal_match.known, None,
1409 "unknown-file matches must not have known flag"
1410 );
1411 }
1412
1413 #[tokio::test]
1415 async fn test_search_codebase_known_files_path_normalisation() {
1416 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
1417 let config = PathfinderConfig::default();
1418 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1419
1420 let mock_scout = MockScout::default();
1421 mock_scout.set_result(Ok(SearchResult {
1422 matches: vec![SearchMatch {
1423 file: "src/auth.ts".to_owned(),
1424 line: 1,
1425 column: 1,
1426 content: "should be stripped".to_owned(),
1427 context_before: vec!["before".to_owned()],
1428 context_after: vec![],
1429 enclosing_semantic_path: None,
1430 version_hash: "sha256:abc".to_owned(),
1431 known: None,
1432 }],
1433 total_matches: 1,
1434 truncated: false,
1435 }));
1436
1437 let mock_surgeon = Arc::new(MockSurgeon::new());
1438 mock_surgeon
1439 .enclosing_symbol_results
1440 .lock()
1441 .unwrap()
1442 .push(Ok(None));
1443
1444 let server =
1445 PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
1446
1447 let params = SearchCodebaseParams {
1449 query: "stripped".to_owned(),
1450 known_files: vec!["./src/auth.ts".to_owned()],
1451 ..Default::default()
1452 };
1453
1454 let result = server
1455 .search_codebase(Parameters(params))
1456 .await
1457 .expect("should succeed")
1458 .0;
1459
1460 let m = &result.matches[0];
1461 assert!(
1462 m.content.is_empty(),
1463 "content should be suppressed despite ./ prefix"
1464 );
1465 assert!(m.context_before.is_empty());
1466 assert_eq!(m.known, Some(true), "known flag must be set");
1467 }
1468
1469 #[tokio::test]
1472 async fn test_search_codebase_group_by_file() {
1473 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
1474 let config = PathfinderConfig::default();
1475 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1476
1477 let mock_scout = MockScout::default();
1478 mock_scout.set_result(Ok(SearchResult {
1479 matches: vec![
1480 SearchMatch {
1482 file: "src/auth.ts".to_owned(),
1483 line: 1,
1484 column: 1,
1485 content: "known line 1".to_owned(),
1486 context_before: vec![],
1487 context_after: vec![],
1488 enclosing_semantic_path: None,
1489 version_hash: "sha256:auth".to_owned(),
1490 known: None,
1491 },
1492 SearchMatch {
1493 file: "src/auth.ts".to_owned(),
1494 line: 2,
1495 column: 1,
1496 content: "known line 2".to_owned(),
1497 context_before: vec![],
1498 context_after: vec![],
1499 enclosing_semantic_path: None,
1500 version_hash: "sha256:auth".to_owned(),
1501 known: None,
1502 },
1503 SearchMatch {
1505 file: "src/main.ts".to_owned(),
1506 line: 5,
1507 column: 1,
1508 content: "main content".to_owned(),
1509 context_before: vec!["prev".to_owned()],
1510 context_after: vec![],
1511 enclosing_semantic_path: None,
1512 version_hash: "sha256:main".to_owned(),
1513 known: None,
1514 },
1515 ],
1516 total_matches: 3,
1517 truncated: false,
1518 }));
1519
1520 let mock_surgeon = Arc::new(MockSurgeon::new());
1521 mock_surgeon
1523 .enclosing_symbol_results
1524 .lock()
1525 .unwrap()
1526 .extend([Ok(None), Ok(None), Ok(None)]);
1527
1528 let server =
1529 PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
1530
1531 let params = SearchCodebaseParams {
1532 query: "line".to_owned(),
1533 known_files: vec!["src/auth.ts".to_owned()],
1534 group_by_file: true,
1535 ..Default::default()
1536 };
1537
1538 let result = server
1539 .search_codebase(Parameters(params))
1540 .await
1541 .expect("should succeed")
1542 .0;
1543
1544 let groups = result
1545 .file_groups
1546 .expect("file_groups should be Some when group_by_file=true");
1547 assert_eq!(groups.len(), 2);
1548
1549 let auth_group = groups.iter().find(|g| g.file == "src/auth.ts").unwrap();
1550 assert_eq!(auth_group.version_hash, "sha256:auth");
1551 assert!(
1552 auth_group.matches.is_empty(),
1553 "known file should have no full matches"
1554 );
1555 assert_eq!(
1556 auth_group.known_matches.len(),
1557 2,
1558 "known file should have 2 known_matches"
1559 );
1560 assert!(auth_group.known_matches[0].known);
1561
1562 let main_group = groups.iter().find(|g| g.file == "src/main.ts").unwrap();
1563 assert_eq!(main_group.version_hash, "sha256:main");
1564 assert_eq!(main_group.matches.len(), 1);
1565 assert_eq!(main_group.matches[0].content, "main content");
1567 assert_eq!(main_group.matches[0].line, 5);
1568 assert!(main_group.known_matches.is_empty());
1569 }
1570
1571 #[tokio::test]
1573 async fn test_search_codebase_exclude_glob_forwarded_to_scout() {
1574 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
1575 let config = PathfinderConfig::default();
1576 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1577
1578 let mock_scout = MockScout::default();
1579 mock_scout.set_result(Ok(SearchResult {
1580 matches: vec![],
1581 total_matches: 0,
1582 truncated: false,
1583 }));
1584
1585 let server = PathfinderServer::with_engines(
1586 ws,
1587 config,
1588 sandbox,
1589 Arc::new(mock_scout.clone()),
1590 Arc::new(MockSurgeon::new()),
1591 );
1592
1593 let params = SearchCodebaseParams {
1594 query: "anything".to_owned(),
1595 exclude_glob: "**/*.test.*".to_owned(),
1596 ..Default::default()
1597 };
1598
1599 server
1600 .search_codebase(Parameters(params))
1601 .await
1602 .expect("should succeed");
1603
1604 let calls = mock_scout.calls();
1605 assert_eq!(calls.len(), 1);
1606 assert_eq!(
1607 calls[0].exclude_glob, "**/*.test.*",
1608 "exclude_glob must be forwarded to the scout"
1609 );
1610 }
1611
1612 #[tokio::test]
1615 async fn test_with_all_engines_constructs_functional_server() {
1616 let ws_dir = tempdir().expect("temp dir");
1617 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1618 let config = PathfinderConfig::default();
1619 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1620
1621 let server = PathfinderServer::with_all_engines(
1622 ws,
1623 config,
1624 sandbox,
1625 Arc::new(MockScout::default()),
1626 Arc::new(MockSurgeon::new()),
1627 Arc::new(pathfinder_lsp::MockLawyer::default()),
1628 );
1629
1630 let info = server.get_info();
1632 assert_eq!(info.server_info.name, "pathfinder");
1633 }
1634
1635 #[tokio::test]
1636 async fn test_with_engines_uses_no_op_lawyer() {
1637 let ws_dir = tempdir().expect("temp dir");
1638 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1639 let config = PathfinderConfig::default();
1640 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1641
1642 std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
1644 std::fs::write(ws_dir.path().join("src/lib.rs"), "fn hello() -> i32 { 1 }").unwrap();
1645
1646 let mock_surgeon = Arc::new(MockSurgeon::new());
1647 mock_surgeon
1648 .read_symbol_scope_results
1649 .lock()
1650 .unwrap()
1651 .push(Ok(pathfinder_common::types::SymbolScope {
1652 content: "fn hello() -> i32 { 1 }".to_owned(),
1653 start_line: 0,
1654 end_line: 0,
1655 version_hash: VersionHash::compute(b"fn hello() -> i32 { 1 }"),
1656 language: "rust".to_owned(),
1657 }));
1658
1659 let server = PathfinderServer::with_engines(
1660 ws,
1661 config,
1662 sandbox,
1663 Arc::new(MockScout::default()),
1664 mock_surgeon,
1665 );
1666
1667 let params = crate::server::types::GetDefinitionParams {
1669 semantic_path: "src/lib.rs::hello".to_owned(),
1670 };
1671 let result = server.get_definition_impl(params).await;
1672 assert!(result.is_err());
1674 }
1675
1676 #[tokio::test]
1679 async fn test_create_file_broadcasts_watched_file_event() {
1680 let ws_dir = tempdir().expect("temp dir");
1681 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1682 let config = PathfinderConfig::default();
1683 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1684
1685 let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
1686
1687 let server = PathfinderServer::with_all_engines(
1688 ws,
1689 config,
1690 sandbox,
1691 Arc::new(MockScout::default()),
1692 Arc::new(MockSurgeon::new()),
1693 lawyer.clone(),
1694 );
1695
1696 let params = crate::server::types::CreateFileParams {
1697 filepath: "src/new_file.rs".to_owned(),
1698 content: "fn new() {}".to_owned(),
1699 };
1700 let result = server.create_file_impl(params).await;
1701 let res = result.expect("should succeed");
1702 assert!(res.0.success);
1703
1704 assert!(ws_dir.path().join("src/new_file.rs").exists());
1706
1707 assert_eq!(lawyer.watched_file_changes_count(), 1);
1709 }
1710
1711 #[tokio::test]
1712 async fn test_delete_file_broadcasts_watched_file_event() {
1713 let ws_dir = tempdir().expect("temp dir");
1714 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1715 let config = PathfinderConfig::default();
1716 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1717
1718 let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
1719
1720 std::fs::write(ws_dir.path().join("to_delete.txt"), "content").unwrap();
1722 let hash = VersionHash::compute(b"content");
1723
1724 let server = PathfinderServer::with_all_engines(
1725 ws,
1726 config,
1727 sandbox,
1728 Arc::new(MockScout::default()),
1729 Arc::new(MockSurgeon::new()),
1730 lawyer.clone(),
1731 );
1732
1733 let params = crate::server::types::DeleteFileParams {
1734 filepath: "to_delete.txt".to_owned(),
1735 base_version: hash.as_str().to_owned(),
1736 };
1737 let result = server.delete_file_impl(params).await;
1738 let res = result.expect("should succeed");
1739 assert!(res.0.success);
1740
1741 assert!(!ws_dir.path().join("to_delete.txt").exists());
1743
1744 assert_eq!(lawyer.watched_file_changes_count(), 1);
1746 }
1747
1748 #[tokio::test]
1749 async fn test_delete_file_not_found() {
1750 let ws_dir = tempdir().expect("temp dir");
1751 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1752 let config = PathfinderConfig::default();
1753 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1754
1755 let server = PathfinderServer::with_all_engines(
1756 ws,
1757 config,
1758 sandbox,
1759 Arc::new(MockScout::default()),
1760 Arc::new(MockSurgeon::new()),
1761 Arc::new(pathfinder_lsp::MockLawyer::default()),
1762 );
1763
1764 let params = crate::server::types::DeleteFileParams {
1765 filepath: "nonexistent.txt".to_owned(),
1766 base_version: "sha256:any".to_owned(),
1767 };
1768 let result = server.delete_file_impl(params).await;
1769 let Err(err) = result else {
1770 panic!("expected error");
1771 };
1772 let code = err
1773 .data
1774 .as_ref()
1775 .and_then(|d| d.get("error"))
1776 .and_then(|v| v.as_str())
1777 .unwrap_or("");
1778 assert_eq!(code, "FILE_NOT_FOUND");
1779 }
1780
1781 #[tokio::test]
1782 async fn test_read_file_not_found() {
1783 let ws_dir = tempdir().expect("temp dir");
1784 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1785 let config = PathfinderConfig::default();
1786 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1787
1788 let server = PathfinderServer::with_all_engines(
1789 ws,
1790 config,
1791 sandbox,
1792 Arc::new(MockScout::default()),
1793 Arc::new(MockSurgeon::new()),
1794 Arc::new(pathfinder_lsp::MockLawyer::default()),
1795 );
1796
1797 let params = crate::server::types::ReadFileParams {
1798 filepath: "missing.txt".to_owned(),
1799 start_line: 1,
1800 max_lines: 100,
1801 };
1802 let result = server.read_file_impl(params).await;
1803 let Err(err) = result else {
1804 panic!("expected error");
1805 };
1806 let code = err
1807 .data
1808 .as_ref()
1809 .and_then(|d| d.get("error"))
1810 .and_then(|v| v.as_str())
1811 .unwrap_or("");
1812 assert_eq!(code, "FILE_NOT_FOUND");
1813 }
1814
1815 #[tokio::test]
1816 async fn test_write_file_broadcasts_watched_file_event() {
1817 let ws_dir = tempdir().expect("temp dir");
1818 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1819 let config = PathfinderConfig::default();
1820 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1821
1822 let initial_content = "initial content";
1824 std::fs::write(ws_dir.path().join("config.toml"), initial_content).unwrap();
1825 let hash = VersionHash::compute(initial_content.as_bytes());
1826
1827 let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
1828
1829 let server = PathfinderServer::with_all_engines(
1830 ws,
1831 config,
1832 sandbox,
1833 Arc::new(MockScout::default()),
1834 Arc::new(MockSurgeon::new()),
1835 lawyer.clone(),
1836 );
1837
1838 let params = crate::server::types::WriteFileParams {
1839 filepath: "config.toml".to_owned(),
1840 base_version: hash.as_str().to_owned(),
1841 content: Some("updated content".to_owned()),
1842 replacements: None,
1843 };
1844 let result = server.write_file_impl(params).await;
1845 assert!(result.is_ok(), "write should succeed");
1846
1847 let written = std::fs::read_to_string(ws_dir.path().join("config.toml")).unwrap();
1849 assert_eq!(written, "updated content");
1850
1851 assert_eq!(lawyer.watched_file_changes_count(), 1);
1853 }
1854
1855 #[tokio::test]
1856 async fn test_write_file_invalid_params_both_modes() {
1857 let ws_dir = tempdir().expect("temp dir");
1858 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1859 let config = PathfinderConfig::default();
1860 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1861
1862 std::fs::write(ws_dir.path().join("test.txt"), "content").unwrap();
1863
1864 let server = PathfinderServer::with_all_engines(
1865 ws,
1866 config,
1867 sandbox,
1868 Arc::new(MockScout::default()),
1869 Arc::new(MockSurgeon::new()),
1870 Arc::new(pathfinder_lsp::MockLawyer::default()),
1871 );
1872
1873 let hash = VersionHash::compute(b"content");
1875 let params = crate::server::types::WriteFileParams {
1876 filepath: "test.txt".to_owned(),
1877 base_version: hash.as_str().to_owned(),
1878 content: Some("new".to_owned()),
1879 replacements: Some(vec![crate::server::types::Replacement {
1880 old_text: "a".to_string(),
1881 new_text: "b".to_string(),
1882 }]),
1883 };
1884 let result = server.write_file_impl(params).await;
1885 assert!(result.is_err(), "should reject both modes");
1886 }
1887
1888 #[tokio::test]
1889 async fn test_write_file_invalid_params_neither_mode() {
1890 let ws_dir = tempdir().expect("temp dir");
1891 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1892 let config = PathfinderConfig::default();
1893 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1894
1895 std::fs::write(ws_dir.path().join("test.txt"), "content").unwrap();
1896
1897 let server = PathfinderServer::with_all_engines(
1898 ws,
1899 config,
1900 sandbox,
1901 Arc::new(MockScout::default()),
1902 Arc::new(MockSurgeon::new()),
1903 Arc::new(pathfinder_lsp::MockLawyer::default()),
1904 );
1905
1906 let hash = VersionHash::compute(b"content");
1908 let params = crate::server::types::WriteFileParams {
1909 filepath: "test.txt".to_owned(),
1910 base_version: hash.as_str().to_owned(),
1911 content: None,
1912 replacements: None,
1913 };
1914 let result = server.write_file_impl(params).await;
1915 assert!(result.is_err(), "should reject neither mode");
1916 }
1917}