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