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