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