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