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 = "lsp_health",
241 description = "Check LSP (Language Server Protocol) health status. Use this at session start to determine whether navigation tools (get_definition, analyze_impact, read_with_deep_context) will return real data or degraded results.\\n\\n**Response fields:**\\n- \\`status\\` — overall readiness: \\\"ready\\\", \\\"warming_up\\\", \\\"starting\\\", or \\\"unavailable\\\".\\n- \\`languages\\` — per-language details with \\`language\\`, \\`status\\`, and optional \\`uptime\\`.\\n\\n**Status values:**\\n- \\\"ready\\\" — LSP has finished indexing. Navigation tools should work reliably.\\n- \\\"warming_up\\\" — LSP is running but still indexing the workspace. Navigation tools may return empty or incomplete results.\\n- \\\"starting\\\" — LSP process has started but not yet initialized.\\n- \\\"unavailable\\\" — No LSP available for this language.\\n\\nWhen \\`status\\` is not \\\"ready\\\", agents should:\\n1. Use Tree-sitter-based tools instead (search_codebase, read_symbol_scope, read_source_file)\\\n2. Wait and retry later, or\\\n3. Treat empty navigation results as UNVERIFIED rather than \\\"confirmed zero\\\".\\n\\n**Optional parameter:** \\`language\\` — filter to a specific language (e.g., \\\"rust\\\", \\\"typescript\\\"). If omitted, checks all available languages."
242 )]
243 async fn lsp_health(
244 &self,
245 Parameters(params): Parameters<crate::server::types::LspHealthParams>,
246 ) -> Result<
247 rmcp::handler::server::wrapper::Json<crate::server::types::LspHealthResponse>,
248 ErrorData,
249 > {
250 self.lsp_health_impl(params).await
251 }
252
253 #[tool(
254 name = "replace_body",
255 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."
256 )]
257 async fn replace_body(
258 &self,
259 Parameters(params): Parameters<ReplaceBodyParams>,
260 ) -> Result<Json<EditResponse>, ErrorData> {
261 self.replace_body_impl(params).await
262 }
263
264 #[tool(
265 name = "replace_full",
266 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."
267 )]
268 async fn replace_full(
269 &self,
270 Parameters(params): Parameters<ReplaceFullParams>,
271 ) -> Result<Json<EditResponse>, ErrorData> {
272 self.replace_full_impl(params).await
273 }
274
275 #[tool(
276 name = "insert_before",
277 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."
278 )]
279 async fn insert_before(
280 &self,
281 Parameters(params): Parameters<InsertBeforeParams>,
282 ) -> Result<Json<EditResponse>, ErrorData> {
283 self.insert_before_impl(params).await
284 }
285
286 #[tool(
287 name = "insert_after",
288 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."
289 )]
290 async fn insert_after(
291 &self,
292 Parameters(params): Parameters<InsertAfterParams>,
293 ) -> Result<Json<EditResponse>, ErrorData> {
294 self.insert_after_impl(params).await
295 }
296
297 #[tool(
298 name = "insert_into",
299 description = "Insert new code at the END of a container symbol's body \
300 (Module, Class, Struct, Impl, Interface). This is the correct tool \
301 for adding new functions to a test module, new methods to a struct, \
302 or new items to any scope. IMPORTANT: semantic_path must target a \
303 container symbol (e.g. 'src/lib.rs::tests'), NOT a bare file path. \
304 For inserting before/after a specific sibling symbol, use insert_before \
305 or insert_after instead.\n\nbase_version accepts either the full SHA-256 hash \
306 (e.g., \"sha256:4ec5a8a...\") or a short 7-character prefix (e.g., \"sha256:4ec5a8a\"), \
307 matching Git convention."
308 )]
309 async fn insert_into(
310 &self,
311 Parameters(params): Parameters<crate::server::types::InsertIntoParams>,
312 ) -> Result<Json<EditResponse>, ErrorData> {
313 self.insert_into_impl(params).await
314 }
315
316 #[tool(
317 name = "delete_symbol",
318 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."
319 )]
320 async fn delete_symbol(
321 &self,
322 Parameters(params): Parameters<DeleteSymbolParams>,
323 ) -> Result<Json<EditResponse>, ErrorData> {
324 self.delete_symbol_impl(params).await
325 }
326
327 #[tool(
328 name = "validate_only",
329 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."
330 )]
331 async fn validate_only(
332 &self,
333 Parameters(params): Parameters<ValidateOnlyParams>,
334 ) -> Result<Json<EditResponse>, ErrorData> {
335 self.validate_only_impl(params).await
336 }
337
338 #[tool(
339 name = "create_file",
340 description = "Create a new file with initial content. Parent directories are created automatically. Returns a version_hash for subsequent edits."
341 )]
342 async fn create_file(
343 &self,
344 Parameters(params): Parameters<CreateFileParams>,
345 ) -> Result<Json<CreateFileResponse>, ErrorData> {
346 self.create_file_impl(params).await
347 }
348
349 #[tool(
350 name = "delete_file",
351 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."
352 )]
353 async fn delete_file(
354 &self,
355 Parameters(params): Parameters<DeleteFileParams>,
356 ) -> Result<Json<DeleteFileResponse>, ErrorData> {
357 self.delete_file_impl(params).await
358 }
359
360 #[tool(
361 name = "read_file",
362 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."
363 )]
364 async fn read_file(
365 &self,
366 Parameters(params): Parameters<ReadFileParams>,
367 ) -> Result<rmcp::model::CallToolResult, ErrorData> {
368 self.read_file_impl(params).await
369 }
370
371 #[tool(
372 name = "write_file",
373 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."
374 )]
375 async fn write_file(
376 &self,
377 Parameters(params): Parameters<WriteFileParams>,
378 ) -> Result<rmcp::model::CallToolResult, ErrorData> {
379 self.write_file_impl(params).await
380 }
381}
382
383#[tool_handler]
386impl ServerHandler for PathfinderServer {
387 fn get_info(&self) -> ServerInfo {
388 ServerInfo::new(ServerCapabilities::builder().enable_tools().build())
389 .with_server_info(Implementation::new("pathfinder", env!("CARGO_PKG_VERSION")))
390 }
391}
392
393#[cfg(test)]
396#[allow(clippy::expect_used, clippy::unwrap_used)]
397mod tests {
398 use super::*;
399 use crate::server::types::Replacement;
400 use pathfinder_common::types::{FilterMode, VersionHash};
401 use pathfinder_search::{MockScout, SearchMatch, SearchResult};
402 use pathfinder_treesitter::mock::MockSurgeon;
403 use rmcp::model::ErrorCode;
404 use std::fs;
405 use tempfile::tempdir;
406
407 #[tokio::test]
408 async fn test_get_repo_map_success() {
409 let ws_dir = tempdir().expect("temp dir");
410 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
411 let config = PathfinderConfig::default();
412 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
413
414 let mock_surgeon = MockSurgeon::new();
415 mock_surgeon
416 .generate_skeleton_results
417 .lock()
418 .unwrap()
419 .push(Ok(pathfinder_treesitter::repo_map::RepoMapResult {
420 skeleton: "class Mock {}".to_string(),
421 tech_stack: vec!["TypeScript".to_string()],
422 files_scanned: 1,
423 files_truncated: 0,
424 files_in_scope: 1,
425 coverage_percent: 100,
426 version_hashes: std::collections::HashMap::default(),
427 }));
428
429 let server = PathfinderServer::with_engines(
430 ws,
431 config,
432 sandbox,
433 Arc::new(MockScout::default()),
434 Arc::new(mock_surgeon),
435 );
436
437 let params = GetRepoMapParams {
438 path: ".".to_owned(),
439 max_tokens: 16_000,
440 depth: 3,
441 visibility: pathfinder_common::types::Visibility::Public,
442 max_tokens_per_file: 2000,
443 changed_since: String::default(),
444 include_extensions: vec![],
445 exclude_extensions: vec![],
446 include_imports: pathfinder_common::types::IncludeImports::None,
447 };
448
449 let result = server.get_repo_map(Parameters(params)).await;
450 assert!(result.is_ok());
451 let call_res = result.unwrap();
452 let skeleton = match &call_res.content[0].raw {
453 rmcp::model::RawContent::Text(t) => t.text.clone(),
454 _ => panic!("expected text content"),
455 };
456 let response: crate::server::types::GetRepoMapMetadata =
457 serde_json::from_value(call_res.structured_content.unwrap()).unwrap();
458 assert_eq!(skeleton, "class Mock {}");
459 assert_eq!(response.files_scanned, 1);
460 assert_eq!(response.coverage_percent, 100);
461 assert_eq!(response.visibility_degraded, None);
463 }
464
465 #[tokio::test]
466 async fn test_get_repo_map_visibility_not_degraded() {
467 let ws_dir = tempdir().expect("temp dir");
470 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
471 let config = PathfinderConfig::default();
472 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
473
474 let mock_surgeon = MockSurgeon::new();
475 mock_surgeon
476 .generate_skeleton_results
477 .lock()
478 .unwrap()
479 .push(Ok(pathfinder_treesitter::repo_map::RepoMapResult {
480 skeleton: String::default(),
481 tech_stack: vec![],
482 files_scanned: 0,
483 files_truncated: 0,
484 files_in_scope: 0,
485 coverage_percent: 100,
486 version_hashes: std::collections::HashMap::default(),
487 }));
488
489 let server = PathfinderServer::with_engines(
490 ws,
491 config,
492 sandbox,
493 Arc::new(MockScout::default()),
494 Arc::new(mock_surgeon),
495 );
496
497 let params = GetRepoMapParams {
498 visibility: pathfinder_common::types::Visibility::All,
499 ..Default::default()
500 };
501 let result = server
502 .get_repo_map(Parameters(params))
503 .await
504 .expect("should succeed");
505 let meta: crate::server::types::GetRepoMapMetadata =
506 serde_json::from_value(result.structured_content.unwrap()).unwrap();
507 assert_eq!(
508 meta.visibility_degraded, None,
509 "visibility filtering is implemented; visibility_degraded must be None"
510 );
511 }
512
513 #[tokio::test]
514 async fn test_get_repo_map_access_denied() {
515 let ws_dir = tempdir().expect("temp dir");
516 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
517 let config = PathfinderConfig::default();
518 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
519
520 let mock_surgeon = MockSurgeon::new();
521 let server = PathfinderServer::with_engines(
522 ws,
523 config,
524 sandbox,
525 Arc::new(MockScout::default()),
526 Arc::new(mock_surgeon),
527 );
528
529 let params = GetRepoMapParams {
530 path: ".env".to_string(), ..Default::default()
532 };
533
534 let Err(err) = server.get_repo_map(Parameters(params)).await else {
535 panic!("Expected ACCESS_DENIED error");
536 };
537 assert_eq!(err.code, ErrorCode(-32001));
538 }
539
540 #[tokio::test]
541 async fn test_create_file_success_and_already_exists() {
542 let ws_dir = tempdir().expect("temp dir");
543 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
544 let config = PathfinderConfig::default();
545 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
546 let mock_scout = MockScout::default();
547 let server = PathfinderServer::with_engines(
548 ws,
549 config,
550 sandbox,
551 Arc::new(mock_scout),
552 Arc::new(MockSurgeon::new()),
553 );
554
555 let filepath = "src/new_file.ts";
556 let content = "console.log('hello');";
557 let params = CreateFileParams {
558 filepath: filepath.to_owned(),
559 content: content.to_owned(),
560 };
561
562 let result = server.create_file(Parameters(params.clone())).await;
564 assert!(result.is_ok(), "Expected success, got {:#?}", result.err());
565 let val = result.expect("create_file should succeed").0;
566 assert!(val.success);
567 assert_eq!(val.validation.status, "passed");
568
569 let expected_hash = VersionHash::compute(content.as_bytes());
570 assert_eq!(val.version_hash, expected_hash.short());
571
572 let absolute_path = ws_dir.path().join(filepath);
574 assert!(absolute_path.exists());
575 let read_content = fs::read_to_string(&absolute_path).expect("read file");
576 assert_eq!(read_content, content);
577
578 let result2 = server.create_file(Parameters(params)).await;
580 assert!(result2.is_err());
581 if let Err(err) = result2 {
582 let code = err
583 .data
584 .as_ref()
585 .and_then(|d| d.get("error"))
586 .and_then(|v| v.as_str())
587 .unwrap_or("");
588 assert_eq!(code, "FILE_ALREADY_EXISTS", "got data: {:?}", err.data);
589 } else {
590 panic!("Expected error mapping to FILE_ALREADY_EXISTS");
591 }
592
593 let deny_params = CreateFileParams {
595 filepath: ".git/objects/some_file".to_owned(),
596 content: "payload".to_owned(),
597 };
598 let result3 = server.create_file(Parameters(deny_params)).await;
599 assert!(result3.is_err());
600 if let Err(err) = result3 {
601 let code = err
602 .data
603 .as_ref()
604 .and_then(|d| d.get("error"))
605 .and_then(|v| v.as_str())
606 .unwrap_or("");
607 assert_eq!(code, "ACCESS_DENIED", "got data: {:?}", err.data);
608 } else {
609 panic!("Expected error mapping to ACCESS_DENIED");
610 }
611 }
612
613 #[tokio::test]
614 async fn test_search_codebase_routes_to_scout_and_handles_success() {
615 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
616 let config = PathfinderConfig::default();
617 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
618
619 let mock_scout = MockScout::default();
620 mock_scout.set_result(Ok(SearchResult {
621 matches: vec![SearchMatch {
622 file: "src/main.rs".to_owned(),
623 line: 10,
624 column: 5,
625 content: "test_query()".to_owned(),
626 context_before: vec![],
627 context_after: vec![],
628 enclosing_semantic_path: None,
629 version_hash: "sha256:123".to_owned(),
630 known: None,
631 }],
632 total_matches: 1,
633 truncated: false,
634 }));
635
636 let mock_surgeon = Arc::new(MockSurgeon::new());
637 mock_surgeon
638 .enclosing_symbol_results
639 .lock()
640 .unwrap()
641 .push(Ok(Some("test_query_func".to_owned())));
642
643 let server = PathfinderServer::with_engines(
644 ws,
645 config,
646 sandbox,
647 Arc::new(mock_scout.clone()),
648 mock_surgeon.clone(),
649 );
650 let params = SearchCodebaseParams {
651 query: "test_query".to_owned(),
652 is_regex: true,
653 ..Default::default()
654 };
655
656 let result = server.search_codebase(Parameters(params)).await;
657 let val = result.expect("search_codebase should succeed").0;
659
660 assert_eq!(val.total_matches, 1);
661 assert!(!val.truncated);
662 let matches = val.matches;
663 assert_eq!(matches[0].file, "src/main.rs");
664 assert_eq!(matches[0].content, "test_query()");
665 assert_eq!(
666 matches[0].enclosing_semantic_path.as_deref(),
667 Some("src/main.rs::test_query_func")
668 );
669
670 let calls = mock_scout.calls();
671 assert_eq!(calls.len(), 1);
672 assert_eq!(calls[0].query, "test_query");
673 assert!(calls[0].is_regex);
674
675 let surgeon_calls = mock_surgeon.enclosing_symbol_calls.lock().unwrap();
676 assert_eq!(surgeon_calls.len(), 1);
677 assert_eq!(surgeon_calls[0].1, std::path::PathBuf::from("src/main.rs"));
678 assert_eq!(surgeon_calls[0].2, 10);
679 }
680
681 #[tokio::test]
682 async fn test_search_codebase_handles_scout_error() {
683 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
684 let config = PathfinderConfig::default();
685 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
686
687 let mock_scout = MockScout::default();
688 mock_scout.set_result(Err("simulated engine error".to_owned()));
689
690 let server = PathfinderServer::with_engines(
691 ws,
692 config,
693 sandbox,
694 Arc::new(mock_scout),
695 Arc::new(MockSurgeon::new()),
696 );
697 let params = SearchCodebaseParams {
698 query: "test".to_owned(),
699 ..Default::default()
700 };
701
702 let result = server.search_codebase(Parameters(params)).await;
703
704 let err = result
705 .err()
706 .expect("search_codebase should return error on scout failure");
707 assert_eq!(err.code, ErrorCode::INTERNAL_ERROR);
708 assert_eq!(err.message, "search engine error: simulated engine error");
709 }
710
711 fn make_search_match(file: &str, line: u64, content: &str) -> SearchMatch {
714 SearchMatch {
715 file: file.to_owned(),
716 line,
717 column: 0,
718 content: content.to_owned(),
719 context_before: vec![],
720 context_after: vec![],
721 enclosing_semantic_path: None,
722 version_hash: "sha256:abc".to_owned(),
723 known: None,
724 }
725 }
726
727 #[tokio::test]
728 async fn test_search_codebase_filter_mode_code_only_drops_comments() {
729 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
730 let config = PathfinderConfig::default();
731 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
732
733 let mock_scout = MockScout::default();
734 mock_scout.set_result(Ok(SearchResult {
735 matches: vec![
736 make_search_match("src/a.go", 1, "code line"),
737 make_search_match("src/a.go", 2, "// comment line"),
738 make_search_match("src/a.go", 3, "another code line"),
739 ],
740 total_matches: 3,
741 truncated: false,
742 }));
743
744 let mock_surgeon = Arc::new(MockSurgeon::new());
745 mock_surgeon
749 .enclosing_symbol_results
750 .lock()
751 .unwrap()
752 .extend([Ok(None), Ok(None), Ok(None)]);
753 mock_surgeon
754 .node_type_at_position_results
755 .lock()
756 .unwrap()
757 .extend([
758 Ok("code".to_owned()),
759 Ok("comment".to_owned()),
760 Ok("code".to_owned()),
761 ]);
762
763 let server =
764 PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
765
766 let params = SearchCodebaseParams {
767 query: "line".to_owned(),
768 filter_mode: FilterMode::CodeOnly,
769 ..Default::default()
770 };
771
772 let result = server
773 .search_codebase(Parameters(params))
774 .await
775 .expect("should succeed")
776 .0;
777
778 assert_eq!(result.matches.len(), 2, "code_only should drop comments");
780 assert_eq!(result.matches[0].content, "code line");
781 assert_eq!(result.matches[1].content, "another code line");
782 assert_eq!(result.total_matches, 3);
784 assert!(!result.degraded);
786 }
787
788 #[tokio::test]
789 async fn test_search_codebase_filter_mode_comments_only_keeps_comments() {
790 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
791 let config = PathfinderConfig::default();
792 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
793
794 let mock_scout = MockScout::default();
795 mock_scout.set_result(Ok(SearchResult {
796 matches: vec![
797 make_search_match("src/b.go", 1, "func HelloWorld() {}"),
798 make_search_match("src/b.go", 2, "// HelloWorld says hello"),
799 make_search_match("src/b.go", 3, r#"msg := "Hello World""#),
800 ],
801 total_matches: 3,
802 truncated: false,
803 }));
804
805 let mock_surgeon = Arc::new(MockSurgeon::new());
806 mock_surgeon
807 .enclosing_symbol_results
808 .lock()
809 .unwrap()
810 .extend([Ok(None), Ok(None), Ok(None)]);
811 mock_surgeon
812 .node_type_at_position_results
813 .lock()
814 .unwrap()
815 .extend([
816 Ok("code".to_owned()),
817 Ok("comment".to_owned()),
818 Ok("string".to_owned()),
819 ]);
820
821 let server =
822 PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
823
824 let params = SearchCodebaseParams {
825 query: "Hello".to_owned(),
826 filter_mode: FilterMode::CommentsOnly,
827 ..Default::default()
828 };
829
830 let result = server
831 .search_codebase(Parameters(params))
832 .await
833 .expect("should succeed")
834 .0;
835
836 assert_eq!(result.matches.len(), 2, "comments_only should drop code");
838 assert_eq!(result.matches[0].content, "// HelloWorld says hello");
839 assert_eq!(result.matches[1].content, r#"msg := "Hello World""#);
840 assert!(!result.degraded);
841 }
842
843 #[tokio::test]
844 async fn test_search_codebase_filter_mode_all_returns_everything() {
845 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
846 let config = PathfinderConfig::default();
847 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
848
849 let mock_scout = MockScout::default();
850 mock_scout.set_result(Ok(SearchResult {
851 matches: vec![
852 make_search_match("src/c.go", 1, "code"),
853 make_search_match("src/c.go", 2, "// comment"),
854 make_search_match("src/c.go", 3, r#"\"string\""#),
855 ],
856 total_matches: 3,
857 truncated: false,
858 }));
859
860 let mock_surgeon = Arc::new(MockSurgeon::default());
861 mock_surgeon
863 .enclosing_symbol_results
864 .lock()
865 .unwrap()
866 .extend([Ok(None), Ok(None), Ok(None)]);
867 let server =
872 PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
873
874 let params = SearchCodebaseParams {
875 query: "test".to_owned(),
876 filter_mode: FilterMode::All,
877 ..Default::default()
878 };
879
880 let result = server
881 .search_codebase(Parameters(params))
882 .await
883 .expect("should succeed")
884 .0;
885
886 assert_eq!(result.matches.len(), 3);
888 assert!(!result.degraded);
889 }
890
891 #[tokio::test]
894 async fn test_delete_file_success_and_occ_failure() {
895 let ws_dir = tempdir().expect("temp dir");
896 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
897 let config = PathfinderConfig::default();
898 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
899 let server = PathfinderServer::with_engines(
900 ws,
901 config,
902 sandbox,
903 Arc::new(MockScout::default()),
904 Arc::new(MockSurgeon::new()),
905 );
906
907 let filepath = "to_delete.txt";
909 let content = "goodbye";
910 let abs = ws_dir.path().join(filepath);
911 fs::write(&abs, content).expect("write");
912 let hash = VersionHash::compute(content.as_bytes());
913
914 let result = server
916 .delete_file(Parameters(DeleteFileParams {
917 filepath: filepath.to_owned(),
918 base_version: hash.as_str().to_owned(),
919 }))
920 .await;
921 assert!(result.is_ok(), "Expected success, got {:?}", result.err());
922 assert!(!abs.exists(), "File should be gone");
923
924 let result2 = server
926 .delete_file(Parameters(DeleteFileParams {
927 filepath: filepath.to_owned(),
928 base_version: hash.as_str().to_owned(),
929 }))
930 .await;
931 assert!(result2.is_err());
932 let Err(err) = result2 else {
933 panic!("expected error")
934 };
935 let code = err
936 .data
937 .as_ref()
938 .and_then(|d| d.get("error"))
939 .and_then(|v| v.as_str())
940 .unwrap_or("");
941 assert_eq!(code, "FILE_NOT_FOUND", "got: {err:?}");
942
943 fs::write(&abs, content).expect("write");
945 let result3 = server
946 .delete_file(Parameters(DeleteFileParams {
947 filepath: filepath.to_owned(),
948 base_version: "sha256:wrong".to_owned(),
949 }))
950 .await;
951 assert!(result3.is_err());
952 let Err(err) = result3 else {
953 panic!("expected error")
954 };
955 let code = err
956 .data
957 .as_ref()
958 .and_then(|d| d.get("error"))
959 .and_then(|v| v.as_str())
960 .unwrap_or("");
961 assert_eq!(code, "VERSION_MISMATCH", "got: {err:?}");
962
963 let result4 = server
965 .delete_file(Parameters(DeleteFileParams {
966 filepath: ".git/objects/x".to_owned(),
967 base_version: "sha256:any".to_owned(),
968 }))
969 .await;
970 assert!(result4.is_err());
971 let Err(err) = result4 else {
972 panic!("expected error")
973 };
974 let code = err
975 .data
976 .as_ref()
977 .and_then(|d| d.get("error"))
978 .and_then(|v| v.as_str())
979 .unwrap_or("");
980 assert_eq!(code, "ACCESS_DENIED", "got: {err:?}");
981 }
982
983 #[tokio::test]
986 async fn test_read_file_pagination() {
987 let ws_dir = tempdir().expect("temp dir");
988 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
989 let config = PathfinderConfig::default();
990 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
991 let server = PathfinderServer::with_engines(
992 ws,
993 config,
994 sandbox,
995 Arc::new(MockScout::default()),
996 Arc::new(MockSurgeon::new()),
997 );
998
999 let filepath = "config.yaml";
1001 let lines: Vec<String> = (1..=10).map(|i| format!("line{i}: value")).collect();
1002 let content = lines.join("\n");
1003 fs::write(ws_dir.path().join(filepath), &content).expect("write");
1004
1005 let result = server
1007 .read_file(Parameters(ReadFileParams {
1008 filepath: filepath.to_owned(),
1009 start_line: 1,
1010 max_lines: 500,
1011 }))
1012 .await
1013 .expect("should succeed");
1014 let val: crate::server::types::ReadFileMetadata =
1015 serde_json::from_value(result.structured_content.unwrap()).unwrap();
1016 assert_eq!(val.total_lines, 10);
1017 assert_eq!(val.lines_returned, 10);
1018 assert!(!val.truncated);
1019 assert_eq!(val.language, "yaml");
1020
1021 let result2 = server
1023 .read_file(Parameters(ReadFileParams {
1024 filepath: filepath.to_owned(),
1025 start_line: 3,
1026 max_lines: 3,
1027 }))
1028 .await
1029 .expect("should succeed");
1030 let val2: crate::server::types::ReadFileMetadata =
1031 serde_json::from_value(result2.structured_content.unwrap()).unwrap();
1032 assert_eq!(val2.start_line, 3);
1033 assert_eq!(val2.lines_returned, 3);
1034 assert!(val2.truncated);
1035 let text_content = match &result2.content[0].raw {
1036 rmcp::model::RawContent::Text(t) => t.text.clone(),
1037 _ => panic!("expected text content"),
1038 };
1039 assert!(text_content.contains("line3"));
1040 assert!(text_content.contains("line5"));
1041 assert!(!text_content.contains("line6"));
1042
1043 let result3 = server
1045 .read_file(Parameters(ReadFileParams {
1046 filepath: "nonexistent.yaml".to_owned(),
1047 start_line: 1,
1048 max_lines: 500,
1049 }))
1050 .await;
1051 assert!(result3.is_err());
1052 let Err(err) = result3 else {
1053 panic!("expected error")
1054 };
1055 let code = err
1056 .data
1057 .as_ref()
1058 .and_then(|d| d.get("error"))
1059 .and_then(|v| v.as_str())
1060 .unwrap_or("");
1061 assert_eq!(code, "FILE_NOT_FOUND", "got: {err:?}");
1062 }
1063
1064 #[tokio::test]
1067 async fn test_read_symbol_scope_routes_to_surgeon_and_handles_success() {
1068 let ws_dir = tempdir().expect("temp dir");
1069 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1070 let config = PathfinderConfig::default();
1071 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1072 let mock_surgeon = Arc::new(MockSurgeon::new());
1073
1074 let content = "func Login() {}";
1075 let expected_scope = pathfinder_common::types::SymbolScope {
1076 content: content.to_owned(),
1077 start_line: 5,
1078 end_line: 7,
1079 name_column: 0,
1080 version_hash: VersionHash::compute(content.as_bytes()),
1081 language: "go".to_owned(),
1082 };
1083 mock_surgeon
1084 .read_symbol_scope_results
1085 .lock()
1086 .unwrap()
1087 .push(Ok(expected_scope.clone()));
1088
1089 let server = PathfinderServer::with_engines(
1090 ws,
1091 config,
1092 sandbox,
1093 Arc::new(MockScout::default()),
1094 mock_surgeon.clone(),
1095 );
1096
1097 let params = ReadSymbolScopeParams {
1098 semantic_path: "src/auth.go::Login".to_owned(),
1099 };
1100
1101 let result = server.read_symbol_scope(Parameters(params)).await;
1102 let val = result.expect("should succeed");
1103
1104 let rmcp::model::RawContent::Text(t) = &val.content[0].raw else {
1105 panic!("Expected text content");
1106 };
1107 assert_eq!(t.text, expected_scope.content);
1108
1109 let metadata: crate::server::types::ReadSymbolScopeMetadata =
1110 serde_json::from_value(val.structured_content.expect("missing structured_content"))
1111 .expect("valid metadata");
1112
1113 assert_eq!(metadata.start_line, expected_scope.start_line);
1114 assert_eq!(metadata.end_line, expected_scope.end_line);
1115 assert_eq!(metadata.version_hash, expected_scope.version_hash.short());
1116 assert_eq!(metadata.language, expected_scope.language);
1117
1118 let calls = mock_surgeon.read_symbol_scope_calls.lock().unwrap();
1119 assert_eq!(calls.len(), 1);
1120 }
1121
1122 #[tokio::test]
1123 async fn test_read_symbol_scope_handles_surgeon_error() {
1124 let ws_dir = tempdir().expect("temp dir");
1125 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1126 let config = PathfinderConfig::default();
1127 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1128 let mock_surgeon = Arc::new(MockSurgeon::new());
1129
1130 mock_surgeon
1131 .read_symbol_scope_results
1132 .lock()
1133 .unwrap()
1134 .push(Err(pathfinder_treesitter::SurgeonError::SymbolNotFound {
1135 path: "src/auth.go::Login".to_owned(),
1136 did_you_mean: vec!["Logout".to_owned()],
1137 }));
1138
1139 let server = PathfinderServer::with_engines(
1140 ws,
1141 config,
1142 sandbox,
1143 Arc::new(MockScout::default()),
1144 mock_surgeon,
1145 );
1146
1147 let params = ReadSymbolScopeParams {
1148 semantic_path: "src/auth.go::Login".to_owned(),
1149 };
1150
1151 let Err(err) = server.read_symbol_scope(Parameters(params)).await else {
1152 panic!("Expected failed response");
1153 };
1154
1155 assert_eq!(err.code, ErrorCode::INVALID_PARAMS); let code = err
1157 .data
1158 .as_ref()
1159 .unwrap()
1160 .get("error")
1161 .unwrap()
1162 .as_str()
1163 .unwrap();
1164 assert_eq!(code, "SYMBOL_NOT_FOUND");
1165 }
1166
1167 #[tokio::test]
1170 async fn test_write_file_full_replacement() {
1171 let ws_dir = tempdir().expect("temp dir");
1172 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1173 let config = PathfinderConfig::default();
1174 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1175 let server = PathfinderServer::with_engines(
1176 ws,
1177 config,
1178 sandbox,
1179 Arc::new(MockScout::default()),
1180 Arc::new(MockSurgeon::new()),
1181 );
1182
1183 let filepath = "config.toml";
1184 let original = "[server]\nport = 8080";
1185 let abs = ws_dir.path().join(filepath);
1186 fs::write(&abs, original).expect("write");
1187 let hash = VersionHash::compute(original.as_bytes());
1188
1189 let replacement = "[server]\nport = 9090";
1191 let result = server
1192 .write_file(Parameters(WriteFileParams {
1193 filepath: filepath.to_owned(),
1194 base_version: hash.as_str().to_owned(),
1195 content: Some(replacement.to_owned()),
1196 replacements: None,
1197 }))
1198 .await
1199 .expect("should succeed");
1200 let val: crate::server::types::WriteFileMetadata =
1201 serde_json::from_value(result.structured_content.unwrap()).unwrap();
1202 assert!(val.success);
1203 let on_disk = fs::read_to_string(&abs).expect("read");
1204 assert_eq!(on_disk, replacement);
1205 let new_hash = VersionHash::compute(replacement.as_bytes());
1206 assert_eq!(val.new_version_hash, new_hash.short());
1207
1208 let result2 = server
1210 .write_file(Parameters(WriteFileParams {
1211 filepath: filepath.to_owned(),
1212 base_version: hash.as_str().to_owned(), content: Some("something else".to_owned()),
1214 replacements: None,
1215 }))
1216 .await;
1217 assert!(result2.is_err());
1218 let Err(err) = result2 else {
1219 panic!("expected error")
1220 };
1221 let code = err
1222 .data
1223 .as_ref()
1224 .and_then(|d| d.get("error"))
1225 .and_then(|v| v.as_str())
1226 .unwrap_or("");
1227 assert_eq!(code, "VERSION_MISMATCH", "got: {err:?}");
1228 }
1229
1230 #[tokio::test]
1231 async fn test_write_file_search_and_replace() {
1232 let ws_dir = tempdir().expect("temp dir");
1233 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1234 let config = PathfinderConfig::default();
1235 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1236 let server = PathfinderServer::with_engines(
1237 ws,
1238 config,
1239 sandbox,
1240 Arc::new(MockScout::default()),
1241 Arc::new(MockSurgeon::new()),
1242 );
1243
1244 let filepath = "docker-compose.yml";
1245 let original = "image: postgres:15\nports:\n - 5432:5432";
1246 let abs = ws_dir.path().join(filepath);
1247 fs::write(&abs, original).expect("write");
1248 let hash = VersionHash::compute(original.as_bytes());
1249
1250 let result = server
1252 .write_file(Parameters(WriteFileParams {
1253 filepath: filepath.to_owned(),
1254 base_version: hash.as_str().to_owned(),
1255 content: None,
1256 replacements: Some(vec![Replacement {
1257 old_text: "postgres:15".to_owned(),
1258 new_text: "postgres:16-alpine".to_owned(),
1259 }]),
1260 }))
1261 .await
1262 .expect("should succeed");
1263 let val: crate::server::types::WriteFileMetadata =
1264 serde_json::from_value(result.structured_content.unwrap()).unwrap();
1265 assert!(val.success);
1266 let on_disk = fs::read_to_string(&abs).expect("read");
1267 assert!(on_disk.contains("postgres:16-alpine"));
1268 let new_hash_val = val.new_version_hash;
1269
1270 let result2 = server
1272 .write_file(Parameters(WriteFileParams {
1273 filepath: filepath.to_owned(),
1274 base_version: new_hash_val.clone(),
1275 content: None,
1276 replacements: Some(vec![Replacement {
1277 old_text: "postgres:15".to_owned(), new_text: "postgres:17".to_owned(),
1279 }]),
1280 }))
1281 .await;
1282 assert!(result2.is_err());
1283 let Err(err) = result2 else {
1284 panic!("expected error")
1285 };
1286 let code = err
1287 .data
1288 .as_ref()
1289 .and_then(|d| d.get("error"))
1290 .and_then(|v| v.as_str())
1291 .unwrap_or("");
1292 assert_eq!(code, "MATCH_NOT_FOUND", "got: {err:?}");
1293
1294 let ambiguous = "tag: v1\ntag: v1";
1296 fs::write(&abs, ambiguous).expect("write");
1297 let ambig_hash = VersionHash::compute(ambiguous.as_bytes());
1298 let result3 = server
1299 .write_file(Parameters(WriteFileParams {
1300 filepath: filepath.to_owned(),
1301 base_version: ambig_hash.as_str().to_owned(),
1302 content: None,
1303 replacements: Some(vec![Replacement {
1304 old_text: "tag: v1".to_owned(),
1305 new_text: "tag: v2".to_owned(),
1306 }]),
1307 }))
1308 .await;
1309 assert!(result3.is_err());
1310 let Err(err) = result3 else {
1311 panic!("expected error")
1312 };
1313 let code = err
1314 .data
1315 .as_ref()
1316 .and_then(|d| d.get("error"))
1317 .and_then(|v| v.as_str())
1318 .unwrap_or("");
1319 assert_eq!(code, "AMBIGUOUS_MATCH", "got: {err:?}");
1320 }
1321
1322 #[tokio::test]
1327 async fn test_search_codebase_known_files_suppresses_context() {
1328 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
1329 let config = PathfinderConfig::default();
1330 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1331
1332 let mock_scout = MockScout::default();
1333 mock_scout.set_result(Ok(SearchResult {
1334 matches: vec![
1335 SearchMatch {
1336 file: "src/auth.ts".to_owned(),
1337 line: 10,
1338 column: 1,
1339 content: "secret content".to_owned(),
1340 context_before: vec!["before".to_owned()],
1341 context_after: vec!["after".to_owned()],
1342 enclosing_semantic_path: None,
1343 version_hash: "sha256:abc".to_owned(),
1344 known: None,
1345 },
1346 SearchMatch {
1347 file: "src/main.ts".to_owned(),
1348 line: 5,
1349 column: 1,
1350 content: "visible content".to_owned(),
1351 context_before: vec!["ctx_before".to_owned()],
1352 context_after: vec!["ctx_after".to_owned()],
1353 enclosing_semantic_path: None,
1354 version_hash: "sha256:xyz".to_owned(),
1355 known: None,
1356 },
1357 ],
1358 total_matches: 2,
1359 truncated: false,
1360 }));
1361
1362 let mock_surgeon = Arc::new(MockSurgeon::new());
1363 mock_surgeon
1365 .enclosing_symbol_results
1366 .lock()
1367 .unwrap()
1368 .extend([Ok(None), Ok(None)]);
1369
1370 let server =
1371 PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
1372
1373 let params = SearchCodebaseParams {
1374 query: "content".to_owned(),
1375 known_files: vec!["src/auth.ts".to_owned()],
1376 ..Default::default()
1377 };
1378
1379 let result = server
1380 .search_codebase(Parameters(params))
1381 .await
1382 .expect("should succeed")
1383 .0;
1384
1385 assert_eq!(result.matches.len(), 2);
1386
1387 let known_match = result
1389 .matches
1390 .iter()
1391 .find(|m| m.file == "src/auth.ts")
1392 .unwrap();
1393 assert!(
1394 known_match.content.is_empty(),
1395 "content should be suppressed for known file"
1396 );
1397 assert!(
1398 known_match.context_before.is_empty(),
1399 "context_before should be empty"
1400 );
1401 assert!(
1402 known_match.context_after.is_empty(),
1403 "context_after should be empty"
1404 );
1405 assert_eq!(
1406 known_match.known,
1407 Some(true),
1408 "known flag must be set for known-file matches"
1409 );
1410
1411 let normal_match = result
1413 .matches
1414 .iter()
1415 .find(|m| m.file == "src/main.ts")
1416 .unwrap();
1417 assert_eq!(normal_match.content, "visible content");
1418 assert_eq!(normal_match.context_before, vec!["ctx_before"]);
1419 assert_eq!(normal_match.context_after, vec!["ctx_after"]);
1420 assert_eq!(
1421 normal_match.known, None,
1422 "unknown-file matches must not have known flag"
1423 );
1424 }
1425
1426 #[tokio::test]
1428 async fn test_search_codebase_known_files_path_normalisation() {
1429 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
1430 let config = PathfinderConfig::default();
1431 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1432
1433 let mock_scout = MockScout::default();
1434 mock_scout.set_result(Ok(SearchResult {
1435 matches: vec![SearchMatch {
1436 file: "src/auth.ts".to_owned(),
1437 line: 1,
1438 column: 1,
1439 content: "should be stripped".to_owned(),
1440 context_before: vec!["before".to_owned()],
1441 context_after: vec![],
1442 enclosing_semantic_path: None,
1443 version_hash: "sha256:abc".to_owned(),
1444 known: None,
1445 }],
1446 total_matches: 1,
1447 truncated: false,
1448 }));
1449
1450 let mock_surgeon = Arc::new(MockSurgeon::new());
1451 mock_surgeon
1452 .enclosing_symbol_results
1453 .lock()
1454 .unwrap()
1455 .push(Ok(None));
1456
1457 let server =
1458 PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
1459
1460 let params = SearchCodebaseParams {
1462 query: "stripped".to_owned(),
1463 known_files: vec!["./src/auth.ts".to_owned()],
1464 ..Default::default()
1465 };
1466
1467 let result = server
1468 .search_codebase(Parameters(params))
1469 .await
1470 .expect("should succeed")
1471 .0;
1472
1473 let m = &result.matches[0];
1474 assert!(
1475 m.content.is_empty(),
1476 "content should be suppressed despite ./ prefix"
1477 );
1478 assert!(m.context_before.is_empty());
1479 assert_eq!(m.known, Some(true), "known flag must be set");
1480 }
1481
1482 #[tokio::test]
1485 async fn test_search_codebase_group_by_file() {
1486 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
1487 let config = PathfinderConfig::default();
1488 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1489
1490 let mock_scout = MockScout::default();
1491 mock_scout.set_result(Ok(SearchResult {
1492 matches: vec![
1493 SearchMatch {
1495 file: "src/auth.ts".to_owned(),
1496 line: 1,
1497 column: 1,
1498 content: "known line 1".to_owned(),
1499 context_before: vec![],
1500 context_after: vec![],
1501 enclosing_semantic_path: None,
1502 version_hash: "sha256:auth".to_owned(),
1503 known: None,
1504 },
1505 SearchMatch {
1506 file: "src/auth.ts".to_owned(),
1507 line: 2,
1508 column: 1,
1509 content: "known line 2".to_owned(),
1510 context_before: vec![],
1511 context_after: vec![],
1512 enclosing_semantic_path: None,
1513 version_hash: "sha256:auth".to_owned(),
1514 known: None,
1515 },
1516 SearchMatch {
1518 file: "src/main.ts".to_owned(),
1519 line: 5,
1520 column: 1,
1521 content: "main content".to_owned(),
1522 context_before: vec!["prev".to_owned()],
1523 context_after: vec![],
1524 enclosing_semantic_path: None,
1525 version_hash: "sha256:main".to_owned(),
1526 known: None,
1527 },
1528 ],
1529 total_matches: 3,
1530 truncated: false,
1531 }));
1532
1533 let mock_surgeon = Arc::new(MockSurgeon::new());
1534 mock_surgeon
1536 .enclosing_symbol_results
1537 .lock()
1538 .unwrap()
1539 .extend([Ok(None), Ok(None), Ok(None)]);
1540
1541 let server =
1542 PathfinderServer::with_engines(ws, config, sandbox, Arc::new(mock_scout), mock_surgeon);
1543
1544 let params = SearchCodebaseParams {
1545 query: "line".to_owned(),
1546 known_files: vec!["src/auth.ts".to_owned()],
1547 group_by_file: true,
1548 ..Default::default()
1549 };
1550
1551 let result = server
1552 .search_codebase(Parameters(params))
1553 .await
1554 .expect("should succeed")
1555 .0;
1556
1557 let groups = result
1558 .file_groups
1559 .expect("file_groups should be Some when group_by_file=true");
1560 assert_eq!(groups.len(), 2);
1561
1562 let auth_group = groups.iter().find(|g| g.file == "src/auth.ts").unwrap();
1563 assert_eq!(auth_group.version_hash, "sha256:auth");
1564 assert!(
1565 auth_group.matches.is_empty(),
1566 "known file should have no full matches"
1567 );
1568 assert_eq!(
1569 auth_group.known_matches.len(),
1570 2,
1571 "known file should have 2 known_matches"
1572 );
1573 assert!(auth_group.known_matches[0].known);
1574
1575 let main_group = groups.iter().find(|g| g.file == "src/main.ts").unwrap();
1576 assert_eq!(main_group.version_hash, "sha256:main");
1577 assert_eq!(main_group.matches.len(), 1);
1578 assert_eq!(main_group.matches[0].content, "main content");
1580 assert_eq!(main_group.matches[0].line, 5);
1581 assert!(main_group.known_matches.is_empty());
1582 }
1583
1584 #[tokio::test]
1586 async fn test_search_codebase_exclude_glob_forwarded_to_scout() {
1587 let ws = WorkspaceRoot::new(std::env::temp_dir()).expect("valid root");
1588 let config = PathfinderConfig::default();
1589 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1590
1591 let mock_scout = MockScout::default();
1592 mock_scout.set_result(Ok(SearchResult {
1593 matches: vec![],
1594 total_matches: 0,
1595 truncated: false,
1596 }));
1597
1598 let server = PathfinderServer::with_engines(
1599 ws,
1600 config,
1601 sandbox,
1602 Arc::new(mock_scout.clone()),
1603 Arc::new(MockSurgeon::new()),
1604 );
1605
1606 let params = SearchCodebaseParams {
1607 query: "anything".to_owned(),
1608 exclude_glob: "**/*.test.*".to_owned(),
1609 ..Default::default()
1610 };
1611
1612 server
1613 .search_codebase(Parameters(params))
1614 .await
1615 .expect("should succeed");
1616
1617 let calls = mock_scout.calls();
1618 assert_eq!(calls.len(), 1);
1619 assert_eq!(
1620 calls[0].exclude_glob, "**/*.test.*",
1621 "exclude_glob must be forwarded to the scout"
1622 );
1623 }
1624
1625 #[tokio::test]
1628 async fn test_with_all_engines_constructs_functional_server() {
1629 let ws_dir = tempdir().expect("temp dir");
1630 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1631 let config = PathfinderConfig::default();
1632 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1633
1634 let server = PathfinderServer::with_all_engines(
1635 ws,
1636 config,
1637 sandbox,
1638 Arc::new(MockScout::default()),
1639 Arc::new(MockSurgeon::new()),
1640 Arc::new(pathfinder_lsp::MockLawyer::default()),
1641 );
1642
1643 let info = server.get_info();
1645 assert_eq!(info.server_info.name, "pathfinder");
1646 }
1647
1648 #[tokio::test]
1649 async fn test_with_engines_uses_no_op_lawyer() {
1650 let ws_dir = tempdir().expect("temp dir");
1651 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1652 let config = PathfinderConfig::default();
1653 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1654
1655 std::fs::create_dir_all(ws_dir.path().join("src")).unwrap();
1657 std::fs::write(ws_dir.path().join("src/lib.rs"), "fn hello() -> i32 { 1 }").unwrap();
1658
1659 let mock_surgeon = Arc::new(MockSurgeon::new());
1660 mock_surgeon
1661 .read_symbol_scope_results
1662 .lock()
1663 .unwrap()
1664 .push(Ok(pathfinder_common::types::SymbolScope {
1665 content: "fn hello() -> i32 { 1 }".to_owned(),
1666 start_line: 0,
1667 end_line: 0,
1668 name_column: 0,
1669 version_hash: VersionHash::compute(b"fn hello() -> i32 { 1 }"),
1670 language: "rust".to_owned(),
1671 }));
1672
1673 let server = PathfinderServer::with_engines(
1674 ws,
1675 config,
1676 sandbox,
1677 Arc::new(MockScout::default()),
1678 mock_surgeon,
1679 );
1680
1681 let params = crate::server::types::GetDefinitionParams {
1683 semantic_path: "src/lib.rs::hello".to_owned(),
1684 };
1685 let result = server.get_definition_impl(params).await;
1686 assert!(result.is_err());
1688 }
1689
1690 #[tokio::test]
1693 async fn test_create_file_broadcasts_watched_file_event() {
1694 let ws_dir = tempdir().expect("temp dir");
1695 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1696 let config = PathfinderConfig::default();
1697 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1698
1699 let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
1700
1701 let server = PathfinderServer::with_all_engines(
1702 ws,
1703 config,
1704 sandbox,
1705 Arc::new(MockScout::default()),
1706 Arc::new(MockSurgeon::new()),
1707 lawyer.clone(),
1708 );
1709
1710 let params = crate::server::types::CreateFileParams {
1711 filepath: "src/new_file.rs".to_owned(),
1712 content: "fn new() {}".to_owned(),
1713 };
1714 let result = server.create_file_impl(params).await;
1715 let res = result.expect("should succeed");
1716 assert!(res.0.success);
1717
1718 assert!(ws_dir.path().join("src/new_file.rs").exists());
1720
1721 assert_eq!(lawyer.watched_file_changes_count(), 1);
1723 }
1724
1725 #[tokio::test]
1726 async fn test_delete_file_broadcasts_watched_file_event() {
1727 let ws_dir = tempdir().expect("temp dir");
1728 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1729 let config = PathfinderConfig::default();
1730 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1731
1732 let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
1733
1734 std::fs::write(ws_dir.path().join("to_delete.txt"), "content").unwrap();
1736 let hash = VersionHash::compute(b"content");
1737
1738 let server = PathfinderServer::with_all_engines(
1739 ws,
1740 config,
1741 sandbox,
1742 Arc::new(MockScout::default()),
1743 Arc::new(MockSurgeon::new()),
1744 lawyer.clone(),
1745 );
1746
1747 let params = crate::server::types::DeleteFileParams {
1748 filepath: "to_delete.txt".to_owned(),
1749 base_version: hash.as_str().to_owned(),
1750 };
1751 let result = server.delete_file_impl(params).await;
1752 let res = result.expect("should succeed");
1753 assert!(res.0.success);
1754
1755 assert!(!ws_dir.path().join("to_delete.txt").exists());
1757
1758 assert_eq!(lawyer.watched_file_changes_count(), 1);
1760 }
1761
1762 #[tokio::test]
1763 async fn test_delete_file_not_found() {
1764 let ws_dir = tempdir().expect("temp dir");
1765 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1766 let config = PathfinderConfig::default();
1767 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1768
1769 let server = PathfinderServer::with_all_engines(
1770 ws,
1771 config,
1772 sandbox,
1773 Arc::new(MockScout::default()),
1774 Arc::new(MockSurgeon::new()),
1775 Arc::new(pathfinder_lsp::MockLawyer::default()),
1776 );
1777
1778 let params = crate::server::types::DeleteFileParams {
1779 filepath: "nonexistent.txt".to_owned(),
1780 base_version: "sha256:any".to_owned(),
1781 };
1782 let result = server.delete_file_impl(params).await;
1783 let Err(err) = result else {
1784 panic!("expected error");
1785 };
1786 let code = err
1787 .data
1788 .as_ref()
1789 .and_then(|d| d.get("error"))
1790 .and_then(|v| v.as_str())
1791 .unwrap_or("");
1792 assert_eq!(code, "FILE_NOT_FOUND");
1793 }
1794
1795 #[tokio::test]
1796 async fn test_read_file_not_found() {
1797 let ws_dir = tempdir().expect("temp dir");
1798 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1799 let config = PathfinderConfig::default();
1800 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1801
1802 let server = PathfinderServer::with_all_engines(
1803 ws,
1804 config,
1805 sandbox,
1806 Arc::new(MockScout::default()),
1807 Arc::new(MockSurgeon::new()),
1808 Arc::new(pathfinder_lsp::MockLawyer::default()),
1809 );
1810
1811 let params = crate::server::types::ReadFileParams {
1812 filepath: "missing.txt".to_owned(),
1813 start_line: 1,
1814 max_lines: 100,
1815 };
1816 let result = server.read_file_impl(params).await;
1817 let Err(err) = result else {
1818 panic!("expected error");
1819 };
1820 let code = err
1821 .data
1822 .as_ref()
1823 .and_then(|d| d.get("error"))
1824 .and_then(|v| v.as_str())
1825 .unwrap_or("");
1826 assert_eq!(code, "FILE_NOT_FOUND");
1827 }
1828
1829 #[tokio::test]
1830 async fn test_write_file_broadcasts_watched_file_event() {
1831 let ws_dir = tempdir().expect("temp dir");
1832 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1833 let config = PathfinderConfig::default();
1834 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1835
1836 let initial_content = "initial content";
1838 std::fs::write(ws_dir.path().join("config.toml"), initial_content).unwrap();
1839 let hash = VersionHash::compute(initial_content.as_bytes());
1840
1841 let lawyer = Arc::new(pathfinder_lsp::MockLawyer::default());
1842
1843 let server = PathfinderServer::with_all_engines(
1844 ws,
1845 config,
1846 sandbox,
1847 Arc::new(MockScout::default()),
1848 Arc::new(MockSurgeon::new()),
1849 lawyer.clone(),
1850 );
1851
1852 let params = crate::server::types::WriteFileParams {
1853 filepath: "config.toml".to_owned(),
1854 base_version: hash.as_str().to_owned(),
1855 content: Some("updated content".to_owned()),
1856 replacements: None,
1857 };
1858 let result = server.write_file_impl(params).await;
1859 assert!(result.is_ok(), "write should succeed");
1860
1861 let written = std::fs::read_to_string(ws_dir.path().join("config.toml")).unwrap();
1863 assert_eq!(written, "updated content");
1864
1865 assert_eq!(lawyer.watched_file_changes_count(), 1);
1867 }
1868
1869 #[tokio::test]
1870 async fn test_write_file_invalid_params_both_modes() {
1871 let ws_dir = tempdir().expect("temp dir");
1872 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1873 let config = PathfinderConfig::default();
1874 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1875
1876 std::fs::write(ws_dir.path().join("test.txt"), "content").unwrap();
1877
1878 let server = PathfinderServer::with_all_engines(
1879 ws,
1880 config,
1881 sandbox,
1882 Arc::new(MockScout::default()),
1883 Arc::new(MockSurgeon::new()),
1884 Arc::new(pathfinder_lsp::MockLawyer::default()),
1885 );
1886
1887 let hash = VersionHash::compute(b"content");
1889 let params = crate::server::types::WriteFileParams {
1890 filepath: "test.txt".to_owned(),
1891 base_version: hash.as_str().to_owned(),
1892 content: Some("new".to_owned()),
1893 replacements: Some(vec![crate::server::types::Replacement {
1894 old_text: "a".to_string(),
1895 new_text: "b".to_string(),
1896 }]),
1897 };
1898 let result = server.write_file_impl(params).await;
1899 assert!(result.is_err(), "should reject both modes");
1900 }
1901
1902 #[tokio::test]
1903 async fn test_write_file_invalid_params_neither_mode() {
1904 let ws_dir = tempdir().expect("temp dir");
1905 let ws = WorkspaceRoot::new(ws_dir.path()).expect("valid root");
1906 let config = PathfinderConfig::default();
1907 let sandbox = Sandbox::new(ws.path(), &config.sandbox);
1908
1909 std::fs::write(ws_dir.path().join("test.txt"), "content").unwrap();
1910
1911 let server = PathfinderServer::with_all_engines(
1912 ws,
1913 config,
1914 sandbox,
1915 Arc::new(MockScout::default()),
1916 Arc::new(MockSurgeon::new()),
1917 Arc::new(pathfinder_lsp::MockLawyer::default()),
1918 );
1919
1920 let hash = VersionHash::compute(b"content");
1922 let params = crate::server::types::WriteFileParams {
1923 filepath: "test.txt".to_owned(),
1924 base_version: hash.as_str().to_owned(),
1925 content: None,
1926 replacements: None,
1927 };
1928 let result = server.write_file_impl(params).await;
1929 assert!(result.is_err(), "should reject neither mode");
1930 }
1931}