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