1pub mod logging;
19pub mod metrics;
20
21pub use aptu_coder_core::analyze;
22use aptu_coder_core::{cache, completion, graph, traversal, types};
23
24pub(crate) const EXCLUDED_DIRS: &[&str] = &[
25 "node_modules",
26 "vendor",
27 ".git",
28 "__pycache__",
29 "target",
30 "dist",
31 "build",
32 ".venv",
33];
34
35use aptu_coder_core::cache::AnalysisCache;
36use aptu_coder_core::formatter::{
37 format_file_details_paginated, format_file_details_summary, format_focused_paginated,
38 format_module_info, format_structure_paginated, format_summary,
39};
40use aptu_coder_core::formatter_defuse::format_focused_paginated_defuse;
41use aptu_coder_core::pagination::{
42 CursorData, DEFAULT_PAGE_SIZE, PaginationMode, decode_cursor, encode_cursor, paginate_slice,
43};
44use aptu_coder_core::traversal::{
45 WalkEntry, changed_files_from_git_ref, filter_entries_by_git_ref, walk_directory,
46};
47use aptu_coder_core::types::{
48 AnalysisMode, AnalyzeDirectoryParams, AnalyzeFileParams, AnalyzeModuleParams,
49 AnalyzeSymbolParams, EditInsertOutput, EditInsertParams, EditOverwriteOutput,
50 EditOverwriteParams, EditRenameOutput, EditRenameParams, EditReplaceOutput, EditReplaceParams,
51 SymbolMatchMode,
52};
53use aptu_coder_core::{edit_insert_at_symbol, edit_rename_in_file};
54use logging::LogEvent;
55use rmcp::handler::server::tool::{ToolRouter, schema_for_type};
56use rmcp::handler::server::wrapper::Parameters;
57use rmcp::model::{
58 CallToolResult, CancelledNotificationParam, CompleteRequestParams, CompleteResult,
59 CompletionInfo, Content, ErrorData, Implementation, InitializeResult, LoggingLevel,
60 LoggingMessageNotificationParam, Meta, Notification, NumberOrString, ProgressNotificationParam,
61 ProgressToken, ServerCapabilities, ServerNotification, SetLevelRequestParams,
62};
63use rmcp::service::{NotificationContext, RequestContext};
64use rmcp::{Peer, RoleServer, ServerHandler, tool, tool_handler, tool_router};
65use serde_json::Value;
66use std::path::{Path, PathBuf};
67use std::sync::{Arc, Mutex};
68use tokio::sync::{Mutex as TokioMutex, mpsc};
69use tracing::{instrument, warn};
70use tracing_subscriber::filter::LevelFilter;
71
72#[cfg(unix)]
73use nix::sys::resource::{Resource, setrlimit};
74
75static GLOBAL_SESSION_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
76
77const SIZE_LIMIT: usize = 50_000;
78
79#[must_use]
82pub fn summary_cursor_conflict(summary: Option<bool>, cursor: Option<&str>) -> bool {
83 summary == Some(true) && cursor.is_some()
84}
85
86#[must_use]
87fn error_meta(
88 category: &'static str,
89 is_retryable: bool,
90 suggested_action: &'static str,
91) -> serde_json::Value {
92 serde_json::json!({
93 "errorCategory": category,
94 "isRetryable": is_retryable,
95 "suggestedAction": suggested_action,
96 })
97}
98
99#[must_use]
100fn err_to_tool_result(e: ErrorData) -> CallToolResult {
101 CallToolResult::error(vec![Content::text(e.message)])
102}
103
104fn err_to_tool_result_from_pagination(
105 e: aptu_coder_core::pagination::PaginationError,
106) -> CallToolResult {
107 let msg = format!("Pagination error: {}", e);
108 CallToolResult::error(vec![Content::text(msg)])
109}
110
111fn no_cache_meta() -> Meta {
112 let mut m = serde_json::Map::new();
113 m.insert(
114 "cache_hint".to_string(),
115 serde_json::Value::String("no-cache".to_string()),
116 );
117 Meta(m)
118}
119
120fn validate_path(path: &str, require_exists: bool) -> Result<std::path::PathBuf, ErrorData> {
124 let allowed_root = std::fs::canonicalize(std::env::current_dir().map_err(|_| {
126 ErrorData::new(
127 rmcp::model::ErrorCode::INVALID_PARAMS,
128 "path is outside the allowed root".to_string(),
129 Some(error_meta(
130 "validation",
131 false,
132 "ensure the working directory is accessible",
133 )),
134 )
135 })?)
136 .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
137
138 let canonical_path = if require_exists {
139 std::fs::canonicalize(path).map_err(|e| {
140 let msg = match e.kind() {
141 std::io::ErrorKind::NotFound => format!("path not found: {path}"),
142 std::io::ErrorKind::PermissionDenied => format!("permission denied: {path}"),
143 _ => "path is outside the allowed root".to_string(),
144 };
145 ErrorData::new(
146 rmcp::model::ErrorCode::INVALID_PARAMS,
147 msg,
148 Some(error_meta(
149 "validation",
150 false,
151 "provide a valid path within the working directory",
152 )),
153 )
154 })?
155 } else {
156 let p = std::path::Path::new(path);
158 let mut ancestor = p.to_path_buf();
159 let mut suffix = std::path::PathBuf::new();
160
161 loop {
162 if ancestor.exists() {
163 break;
164 }
165 if let Some(parent) = ancestor.parent() {
166 if let Some(file_name) = ancestor.file_name() {
167 suffix = std::path::PathBuf::from(file_name).join(&suffix);
168 }
169 ancestor = parent.to_path_buf();
170 } else {
171 ancestor = allowed_root.clone();
173 break;
174 }
175 }
176
177 let canonical_base =
178 std::fs::canonicalize(&ancestor).unwrap_or_else(|_| allowed_root.clone());
179 canonical_base.join(&suffix)
180 };
181
182 if !canonical_path.starts_with(&allowed_root) {
183 return Err(ErrorData::new(
184 rmcp::model::ErrorCode::INVALID_PARAMS,
185 "path is outside the allowed root".to_string(),
186 Some(error_meta(
187 "validation",
188 false,
189 "provide a path within the current working directory",
190 )),
191 ));
192 }
193
194 Ok(canonical_path)
195}
196
197fn paginate_focus_chains(
200 chains: &[graph::InternalCallChain],
201 mode: PaginationMode,
202 offset: usize,
203 page_size: usize,
204) -> Result<(Vec<graph::InternalCallChain>, Option<String>), ErrorData> {
205 let paginated = paginate_slice(chains, offset, page_size, mode).map_err(|e| {
206 ErrorData::new(
207 rmcp::model::ErrorCode::INTERNAL_ERROR,
208 e.to_string(),
209 Some(error_meta("transient", true, "retry the request")),
210 )
211 })?;
212
213 if paginated.next_cursor.is_none() && offset == 0 {
214 return Ok((paginated.items, None));
215 }
216
217 let next = if let Some(raw_cursor) = paginated.next_cursor {
218 let decoded = decode_cursor(&raw_cursor).map_err(|e| {
219 ErrorData::new(
220 rmcp::model::ErrorCode::INVALID_PARAMS,
221 e.to_string(),
222 Some(error_meta("validation", false, "invalid cursor format")),
223 )
224 })?;
225 Some(
226 encode_cursor(&CursorData {
227 mode,
228 offset: decoded.offset,
229 })
230 .map_err(|e| {
231 ErrorData::new(
232 rmcp::model::ErrorCode::INVALID_PARAMS,
233 e.to_string(),
234 Some(error_meta("validation", false, "invalid cursor format")),
235 )
236 })?,
237 )
238 } else {
239 None
240 };
241
242 Ok((paginated.items, next))
243}
244
245fn resolve_shell() -> String {
249 if let Ok(shell) = std::env::var("APTU_SHELL") {
250 return shell;
251 }
252 #[cfg(unix)]
253 {
254 if which::which("bash").is_ok() {
255 return "bash".to_string();
256 }
257 "/bin/sh".to_string()
258 }
259 #[cfg(not(unix))]
260 {
261 "cmd".to_string()
262 }
263}
264
265#[derive(Clone)]
270pub struct CodeAnalyzer {
271 #[allow(dead_code)]
274 pub(crate) tool_router: ToolRouter<Self>,
275 cache: AnalysisCache,
276 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
277 log_level_filter: Arc<Mutex<LevelFilter>>,
278 event_rx: Arc<TokioMutex<Option<mpsc::UnboundedReceiver<LogEvent>>>>,
279 metrics_tx: crate::metrics::MetricsSender,
280 session_call_seq: Arc<std::sync::atomic::AtomicU32>,
281 session_id: Arc<TokioMutex<Option<String>>>,
282}
283
284#[tool_router]
285impl CodeAnalyzer {
286 #[must_use]
287 pub fn list_tools() -> Vec<rmcp::model::Tool> {
288 Self::tool_router().list_all()
289 }
290
291 pub fn new(
292 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
293 log_level_filter: Arc<Mutex<LevelFilter>>,
294 event_rx: mpsc::UnboundedReceiver<LogEvent>,
295 metrics_tx: crate::metrics::MetricsSender,
296 ) -> Self {
297 let file_cap: usize = std::env::var("CODE_ANALYZE_FILE_CACHE_CAPACITY")
298 .ok()
299 .and_then(|v| v.parse().ok())
300 .unwrap_or(100);
301 CodeAnalyzer {
302 tool_router: Self::tool_router(),
303 cache: AnalysisCache::new(file_cap),
304 peer,
305 log_level_filter,
306 event_rx: Arc::new(TokioMutex::new(Some(event_rx))),
307 metrics_tx,
308 session_call_seq: Arc::new(std::sync::atomic::AtomicU32::new(0)),
309 session_id: Arc::new(TokioMutex::new(None)),
310 }
311 }
312
313 #[instrument(skip(self))]
314 async fn emit_progress(
315 &self,
316 peer: Option<Peer<RoleServer>>,
317 token: &ProgressToken,
318 progress: f64,
319 total: f64,
320 message: String,
321 ) {
322 if let Some(peer) = peer {
323 let notification = ServerNotification::ProgressNotification(Notification::new(
324 ProgressNotificationParam {
325 progress_token: token.clone(),
326 progress,
327 total: Some(total),
328 message: Some(message),
329 },
330 ));
331 if let Err(e) = peer.send_notification(notification).await {
332 warn!("Failed to send progress notification: {}", e);
333 }
334 }
335 }
336
337 #[allow(clippy::too_many_lines)] #[allow(clippy::cast_precision_loss)] #[instrument(skip(self, params, ct))]
343 async fn handle_overview_mode(
344 &self,
345 params: &AnalyzeDirectoryParams,
346 ct: tokio_util::sync::CancellationToken,
347 ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, bool), ErrorData> {
348 let path = Path::new(¶ms.path);
349 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
350 let counter_clone = counter.clone();
351 let path_owned = path.to_path_buf();
352 let max_depth = params.max_depth;
353 let ct_clone = ct.clone();
354
355 let all_entries = walk_directory(path, None).map_err(|e| {
357 ErrorData::new(
358 rmcp::model::ErrorCode::INTERNAL_ERROR,
359 format!("Failed to walk directory: {e}"),
360 Some(error_meta(
361 "resource",
362 false,
363 "check path permissions and availability",
364 )),
365 )
366 })?;
367
368 let canonical_max_depth = max_depth.and_then(|d| if d == 0 { None } else { Some(d) });
370
371 let git_ref_val = params.git_ref.as_deref().filter(|s| !s.is_empty());
374 let cache_key = cache::DirectoryCacheKey::from_entries(
375 &all_entries,
376 canonical_max_depth,
377 AnalysisMode::Overview,
378 git_ref_val,
379 );
380
381 if let Some(cached) = self.cache.get_directory(&cache_key) {
383 return Ok((cached, true));
384 }
385
386 let all_entries = if let Some(ref git_ref) = params.git_ref
388 && !git_ref.is_empty()
389 {
390 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
391 ErrorData::new(
392 rmcp::model::ErrorCode::INVALID_PARAMS,
393 format!("git_ref filter failed: {e}"),
394 Some(error_meta(
395 "resource",
396 false,
397 "ensure git is installed and path is inside a git repository",
398 )),
399 )
400 })?;
401 filter_entries_by_git_ref(all_entries, &changed, path)
402 } else {
403 all_entries
404 };
405
406 let subtree_counts = if max_depth.is_some_and(|d| d > 0) {
408 Some(traversal::subtree_counts_from_entries(path, &all_entries))
409 } else {
410 None
411 };
412
413 let entries: Vec<traversal::WalkEntry> = if let Some(depth) = max_depth
415 && depth > 0
416 {
417 all_entries
418 .into_iter()
419 .filter(|e| e.depth <= depth as usize)
420 .collect()
421 } else {
422 all_entries
423 };
424
425 let total_files = entries.iter().filter(|e| !e.is_dir).count();
427
428 let handle = tokio::task::spawn_blocking(move || {
430 analyze::analyze_directory_with_progress(&path_owned, entries, counter_clone, ct_clone)
431 });
432
433 let token = ProgressToken(NumberOrString::String(
435 format!(
436 "analyze-overview-{}",
437 std::time::SystemTime::now()
438 .duration_since(std::time::UNIX_EPOCH)
439 .map(|d| d.as_nanos())
440 .unwrap_or(0)
441 )
442 .into(),
443 ));
444 let peer = self.peer.lock().await.clone();
445 let mut last_progress = 0usize;
446 let mut cancelled = false;
447 loop {
448 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
449 if ct.is_cancelled() {
450 cancelled = true;
451 break;
452 }
453 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
454 if current != last_progress && total_files > 0 {
455 self.emit_progress(
456 peer.clone(),
457 &token,
458 current as f64,
459 total_files as f64,
460 format!("Analyzing {current}/{total_files} files"),
461 )
462 .await;
463 last_progress = current;
464 }
465 if handle.is_finished() {
466 break;
467 }
468 }
469
470 if !cancelled && total_files > 0 {
472 self.emit_progress(
473 peer.clone(),
474 &token,
475 total_files as f64,
476 total_files as f64,
477 format!("Completed analyzing {total_files} files"),
478 )
479 .await;
480 }
481
482 match handle.await {
483 Ok(Ok(mut output)) => {
484 output.subtree_counts = subtree_counts;
485 let arc_output = std::sync::Arc::new(output);
486 self.cache.put_directory(cache_key, arc_output.clone());
487 Ok((arc_output, false))
488 }
489 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
490 rmcp::model::ErrorCode::INTERNAL_ERROR,
491 "Analysis cancelled".to_string(),
492 Some(error_meta("transient", true, "analysis was cancelled")),
493 )),
494 Ok(Err(e)) => Err(ErrorData::new(
495 rmcp::model::ErrorCode::INTERNAL_ERROR,
496 format!("Error analyzing directory: {e}"),
497 Some(error_meta(
498 "resource",
499 false,
500 "check path and file permissions",
501 )),
502 )),
503 Err(e) => Err(ErrorData::new(
504 rmcp::model::ErrorCode::INTERNAL_ERROR,
505 format!("Task join error: {e}"),
506 Some(error_meta("transient", true, "retry the request")),
507 )),
508 }
509 }
510
511 #[instrument(skip(self, params))]
514 async fn handle_file_details_mode(
515 &self,
516 params: &AnalyzeFileParams,
517 ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, bool), ErrorData> {
518 let cache_key = std::fs::metadata(¶ms.path).ok().and_then(|meta| {
520 meta.modified().ok().map(|mtime| cache::CacheKey {
521 path: std::path::PathBuf::from(¶ms.path),
522 modified: mtime,
523 mode: AnalysisMode::FileDetails,
524 })
525 });
526
527 if let Some(ref key) = cache_key
529 && let Some(cached) = self.cache.get(key)
530 {
531 return Ok((cached, true));
532 }
533
534 match analyze::analyze_file(¶ms.path, params.ast_recursion_limit) {
536 Ok(output) => {
537 let arc_output = std::sync::Arc::new(output);
538 if let Some(key) = cache_key {
539 self.cache.put(key, arc_output.clone());
540 }
541 Ok((arc_output, false))
542 }
543 Err(e) => Err(ErrorData::new(
544 rmcp::model::ErrorCode::INTERNAL_ERROR,
545 format!("Error analyzing file: {e}"),
546 Some(error_meta(
547 "resource",
548 false,
549 "check file path and permissions",
550 )),
551 )),
552 }
553 }
554
555 fn validate_impl_only(entries: &[WalkEntry]) -> Result<(), ErrorData> {
557 let has_rust = entries.iter().any(|e| {
558 !e.is_dir
559 && e.path
560 .extension()
561 .and_then(|x: &std::ffi::OsStr| x.to_str())
562 == Some("rs")
563 });
564
565 if !has_rust {
566 return Err(ErrorData::new(
567 rmcp::model::ErrorCode::INVALID_PARAMS,
568 "impl_only=true requires Rust source files. No .rs files found in the given path. Use analyze_symbol without impl_only for cross-language analysis.".to_string(),
569 Some(error_meta(
570 "validation",
571 false,
572 "remove impl_only or point to a directory containing .rs files",
573 )),
574 ));
575 }
576 Ok(())
577 }
578
579 fn validate_import_lookup(import_lookup: Option<bool>, symbol: &str) -> Result<(), ErrorData> {
581 if import_lookup == Some(true) && symbol.is_empty() {
582 return Err(ErrorData::new(
583 rmcp::model::ErrorCode::INVALID_PARAMS,
584 "import_lookup=true requires symbol to contain the module path to search for"
585 .to_string(),
586 Some(error_meta(
587 "validation",
588 false,
589 "set symbol to the module path when using import_lookup=true",
590 )),
591 ));
592 }
593 Ok(())
594 }
595
596 #[allow(clippy::cast_precision_loss)] async fn poll_progress_until_done(
599 &self,
600 analysis_params: &FocusedAnalysisParams,
601 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
602 ct: tokio_util::sync::CancellationToken,
603 entries: std::sync::Arc<Vec<WalkEntry>>,
604 total_files: usize,
605 symbol_display: &str,
606 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
607 let counter_clone = counter.clone();
608 let ct_clone = ct.clone();
609 let entries_clone = std::sync::Arc::clone(&entries);
610 let path_owned = analysis_params.path.clone();
611 let symbol_owned = analysis_params.symbol.clone();
612 let match_mode_owned = analysis_params.match_mode.clone();
613 let follow_depth = analysis_params.follow_depth;
614 let max_depth = analysis_params.max_depth;
615 let ast_recursion_limit = analysis_params.ast_recursion_limit;
616 let use_summary = analysis_params.use_summary;
617 let impl_only = analysis_params.impl_only;
618 let def_use = analysis_params.def_use;
619 let handle = tokio::task::spawn_blocking(move || {
620 let params = analyze::FocusedAnalysisConfig {
621 focus: symbol_owned,
622 match_mode: match_mode_owned,
623 follow_depth,
624 max_depth,
625 ast_recursion_limit,
626 use_summary,
627 impl_only,
628 def_use,
629 };
630 analyze::analyze_focused_with_progress_with_entries(
631 &path_owned,
632 ¶ms,
633 &counter_clone,
634 &ct_clone,
635 &entries_clone,
636 )
637 });
638
639 let token = ProgressToken(NumberOrString::String(
640 format!(
641 "analyze-symbol-{}",
642 std::time::SystemTime::now()
643 .duration_since(std::time::UNIX_EPOCH)
644 .map(|d| d.as_nanos())
645 .unwrap_or(0)
646 )
647 .into(),
648 ));
649 let peer = self.peer.lock().await.clone();
650 let mut last_progress = 0usize;
651 let mut cancelled = false;
652
653 loop {
654 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
655 if ct.is_cancelled() {
656 cancelled = true;
657 break;
658 }
659 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
660 if current != last_progress && total_files > 0 {
661 self.emit_progress(
662 peer.clone(),
663 &token,
664 current as f64,
665 total_files as f64,
666 format!(
667 "Analyzing {current}/{total_files} files for symbol '{symbol_display}'"
668 ),
669 )
670 .await;
671 last_progress = current;
672 }
673 if handle.is_finished() {
674 break;
675 }
676 }
677
678 if !cancelled && total_files > 0 {
679 self.emit_progress(
680 peer.clone(),
681 &token,
682 total_files as f64,
683 total_files as f64,
684 format!("Completed analyzing {total_files} files for symbol '{symbol_display}'"),
685 )
686 .await;
687 }
688
689 match handle.await {
690 Ok(Ok(output)) => Ok(output),
691 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
692 rmcp::model::ErrorCode::INTERNAL_ERROR,
693 "Analysis cancelled".to_string(),
694 Some(error_meta("transient", true, "analysis was cancelled")),
695 )),
696 Ok(Err(e)) => Err(ErrorData::new(
697 rmcp::model::ErrorCode::INTERNAL_ERROR,
698 format!("Error analyzing symbol: {e}"),
699 Some(error_meta("resource", false, "check symbol name and file")),
700 )),
701 Err(e) => Err(ErrorData::new(
702 rmcp::model::ErrorCode::INTERNAL_ERROR,
703 format!("Task join error: {e}"),
704 Some(error_meta("transient", true, "retry the request")),
705 )),
706 }
707 }
708
709 async fn run_focused_with_auto_summary(
711 &self,
712 params: &AnalyzeSymbolParams,
713 analysis_params: &FocusedAnalysisParams,
714 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
715 ct: tokio_util::sync::CancellationToken,
716 entries: std::sync::Arc<Vec<WalkEntry>>,
717 total_files: usize,
718 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
719 let use_summary_for_task = params.output_control.force != Some(true)
720 && params.output_control.summary == Some(true);
721
722 let analysis_params_initial = FocusedAnalysisParams {
723 use_summary: use_summary_for_task,
724 ..analysis_params.clone()
725 };
726
727 let mut output = self
728 .poll_progress_until_done(
729 &analysis_params_initial,
730 counter.clone(),
731 ct.clone(),
732 entries.clone(),
733 total_files,
734 ¶ms.symbol,
735 )
736 .await?;
737
738 if params.output_control.summary.is_none()
739 && params.output_control.force != Some(true)
740 && output.formatted.len() > SIZE_LIMIT
741 {
742 let counter2 = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
743 let analysis_params_retry = FocusedAnalysisParams {
744 use_summary: true,
745 ..analysis_params.clone()
746 };
747 let summary_result = self
748 .poll_progress_until_done(
749 &analysis_params_retry,
750 counter2,
751 ct,
752 entries,
753 total_files,
754 ¶ms.symbol,
755 )
756 .await;
757
758 if let Ok(summary_output) = summary_result {
759 output.formatted = summary_output.formatted;
760 } else {
761 let estimated_tokens = output.formatted.len() / 4;
762 let message = format!(
763 "Output exceeds 50K chars ({} chars, ~{} tokens). Use summary=true or force=true.",
764 output.formatted.len(),
765 estimated_tokens
766 );
767 return Err(ErrorData::new(
768 rmcp::model::ErrorCode::INVALID_PARAMS,
769 message,
770 Some(error_meta(
771 "validation",
772 false,
773 "use summary=true or force=true",
774 )),
775 ));
776 }
777 } else if output.formatted.len() > SIZE_LIMIT
778 && params.output_control.force != Some(true)
779 && params.output_control.summary == Some(false)
780 {
781 let estimated_tokens = output.formatted.len() / 4;
782 let message = format!(
783 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
784 - force=true to return full output\n\
785 - summary=true to get compact summary\n\
786 - Narrow your scope (smaller directory, specific file)",
787 output.formatted.len(),
788 estimated_tokens
789 );
790 return Err(ErrorData::new(
791 rmcp::model::ErrorCode::INVALID_PARAMS,
792 message,
793 Some(error_meta(
794 "validation",
795 false,
796 "use force=true, summary=true, or narrow scope",
797 )),
798 ));
799 }
800
801 Ok(output)
802 }
803
804 #[instrument(skip(self, params, ct))]
808 async fn handle_focused_mode(
809 &self,
810 params: &AnalyzeSymbolParams,
811 ct: tokio_util::sync::CancellationToken,
812 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
813 let path = Path::new(¶ms.path);
814 let raw_entries = match walk_directory(path, params.max_depth) {
815 Ok(e) => e,
816 Err(e) => {
817 return Err(ErrorData::new(
818 rmcp::model::ErrorCode::INTERNAL_ERROR,
819 format!("Failed to walk directory: {e}"),
820 Some(error_meta(
821 "resource",
822 false,
823 "check path permissions and availability",
824 )),
825 ));
826 }
827 };
828 let filtered_entries = if let Some(ref git_ref) = params.git_ref
830 && !git_ref.is_empty()
831 {
832 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
833 ErrorData::new(
834 rmcp::model::ErrorCode::INVALID_PARAMS,
835 format!("git_ref filter failed: {e}"),
836 Some(error_meta(
837 "resource",
838 false,
839 "ensure git is installed and path is inside a git repository",
840 )),
841 )
842 })?;
843 filter_entries_by_git_ref(raw_entries, &changed, path)
844 } else {
845 raw_entries
846 };
847 let entries = std::sync::Arc::new(filtered_entries);
848
849 if params.impl_only == Some(true) {
850 Self::validate_impl_only(&entries)?;
851 }
852
853 let total_files = entries.iter().filter(|e| !e.is_dir).count();
854 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
855
856 let analysis_params = FocusedAnalysisParams {
857 path: path.to_path_buf(),
858 symbol: params.symbol.clone(),
859 match_mode: params.match_mode.clone().unwrap_or_default(),
860 follow_depth: params.follow_depth.unwrap_or(1),
861 max_depth: params.max_depth,
862 ast_recursion_limit: params.ast_recursion_limit,
863 use_summary: false,
864 impl_only: params.impl_only,
865 def_use: params.def_use.unwrap_or(false),
866 };
867
868 let mut output = self
869 .run_focused_with_auto_summary(
870 params,
871 &analysis_params,
872 counter,
873 ct,
874 entries,
875 total_files,
876 )
877 .await?;
878
879 if params.impl_only == Some(true) {
880 let filter_line = format!(
881 "FILTER: impl_only=true ({} of {} callers shown)\n",
882 output.impl_trait_caller_count, output.unfiltered_caller_count
883 );
884 output.formatted = format!("{}{}", filter_line, output.formatted);
885
886 if output.impl_trait_caller_count == 0 {
887 output.formatted.push_str(
888 "\nNOTE: No impl-trait callers found. The symbol may be a plain function or struct, not a trait method. Remove impl_only to see all callers.\n"
889 );
890 }
891 }
892
893 Ok(output)
894 }
895
896 #[instrument(skip(self, context))]
897 #[tool(
898 name = "analyze_directory",
899 title = "Analyze Directory",
900 description = "Tree-view of directory with LOC, function/class counts, test markers. Respects .gitignore. Returns per-file stats plus next_cursor for pagination. Fails if summary=true and cursor. For 1000+ files, use max_depth=2-3 and summary=true. git_ref restricts to files changed since a branch/tag/commit. Empty directories return zero counts. Example queries: Analyze the src/ directory to understand module structure; What files are in the tests/ directory and how large are they?",
901 output_schema = schema_for_type::<analyze::AnalysisOutput>(),
902 annotations(
903 title = "Analyze Directory",
904 read_only_hint = true,
905 destructive_hint = false,
906 idempotent_hint = true,
907 open_world_hint = false
908 )
909 )]
910 async fn analyze_directory(
911 &self,
912 params: Parameters<AnalyzeDirectoryParams>,
913 context: RequestContext<RoleServer>,
914 ) -> Result<CallToolResult, ErrorData> {
915 let params = params.0;
916 let _validated_path = match validate_path(¶ms.path, true) {
917 Ok(p) => p,
918 Err(e) => return Ok(err_to_tool_result(e)),
919 };
920 let ct = context.ct.clone();
921 let t_start = std::time::Instant::now();
922 let param_path = params.path.clone();
923 let max_depth_val = params.max_depth;
924 let seq = self
925 .session_call_seq
926 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
927 let sid = self.session_id.lock().await.clone();
928
929 let (arc_output, dir_cache_hit) = match self.handle_overview_mode(¶ms, ct).await {
931 Ok(v) => v,
932 Err(e) => return Ok(err_to_tool_result(e)),
933 };
934 let mut output = match std::sync::Arc::try_unwrap(arc_output) {
937 Ok(owned) => owned,
938 Err(arc) => (*arc).clone(),
939 };
940
941 if summary_cursor_conflict(
944 params.output_control.summary,
945 params.pagination.cursor.as_deref(),
946 ) {
947 return Ok(err_to_tool_result(ErrorData::new(
948 rmcp::model::ErrorCode::INVALID_PARAMS,
949 "summary=true is incompatible with a pagination cursor; use one or the other"
950 .to_string(),
951 Some(error_meta(
952 "validation",
953 false,
954 "remove cursor or set summary=false",
955 )),
956 )));
957 }
958
959 let use_summary = if params.output_control.force == Some(true) {
961 false
962 } else if params.output_control.summary == Some(true) {
963 true
964 } else if params.output_control.summary == Some(false) {
965 false
966 } else {
967 output.formatted.len() > SIZE_LIMIT
968 };
969
970 if use_summary {
971 output.formatted = format_summary(
972 &output.entries,
973 &output.files,
974 params.max_depth,
975 output.subtree_counts.as_deref(),
976 );
977 }
978
979 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
981 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
982 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
983 ErrorData::new(
984 rmcp::model::ErrorCode::INVALID_PARAMS,
985 e.to_string(),
986 Some(error_meta("validation", false, "invalid cursor format")),
987 )
988 }) {
989 Ok(v) => v,
990 Err(e) => return Ok(err_to_tool_result(e)),
991 };
992 cursor_data.offset
993 } else {
994 0
995 };
996
997 let paginated =
999 match paginate_slice(&output.files, offset, page_size, PaginationMode::Default) {
1000 Ok(v) => v,
1001 Err(e) => {
1002 return Ok(err_to_tool_result(ErrorData::new(
1003 rmcp::model::ErrorCode::INTERNAL_ERROR,
1004 e.to_string(),
1005 Some(error_meta("transient", true, "retry the request")),
1006 )));
1007 }
1008 };
1009
1010 let verbose = params.output_control.verbose.unwrap_or(false);
1011 if !use_summary {
1012 output.formatted = format_structure_paginated(
1013 &paginated.items,
1014 paginated.total,
1015 params.max_depth,
1016 Some(Path::new(¶ms.path)),
1017 verbose,
1018 );
1019 }
1020
1021 if use_summary {
1023 output.next_cursor = None;
1024 } else {
1025 output.next_cursor.clone_from(&paginated.next_cursor);
1026 }
1027
1028 let mut final_text = output.formatted.clone();
1030 if !use_summary && let Some(cursor) = paginated.next_cursor {
1031 final_text.push('\n');
1032 final_text.push_str("NEXT_CURSOR: ");
1033 final_text.push_str(&cursor);
1034 }
1035
1036 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1037 .with_meta(Some(no_cache_meta()));
1038 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1039 result.structured_content = Some(structured);
1040 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1041 self.metrics_tx.send(crate::metrics::MetricEvent {
1042 ts: crate::metrics::unix_ms(),
1043 tool: "analyze_directory",
1044 duration_ms: dur,
1045 output_chars: final_text.len(),
1046 param_path_depth: crate::metrics::path_component_count(¶m_path),
1047 max_depth: max_depth_val,
1048 result: "ok",
1049 error_type: None,
1050 session_id: sid,
1051 seq: Some(seq),
1052 cache_hit: Some(dir_cache_hit),
1053 });
1054 Ok(result)
1055 }
1056
1057 #[instrument(skip(self, _context))]
1058 #[tool(
1059 name = "analyze_file",
1060 title = "Analyze File",
1061 description = "Functions, types, classes, and imports from a single source file. Returns functions (name, signature, line range), classes (methods, fields, inheritance), imports; paginate with cursor/page_size. Use fields=[\"functions\",\"classes\",\"imports\"] to limit output sections. Fails if directory path supplied; use analyze_directory instead. Fails if summary=true and cursor. git_ref not supported for single-file analysis. Use analyze_module for lightweight function/import index (~75% smaller). Supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#. Example queries: What functions are defined in src/lib.rs?; Show me the classes and their methods in src/analyzer.py.",
1062 output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
1063 annotations(
1064 title = "Analyze File",
1065 read_only_hint = true,
1066 destructive_hint = false,
1067 idempotent_hint = true,
1068 open_world_hint = false
1069 )
1070 )]
1071 async fn analyze_file(
1072 &self,
1073 params: Parameters<AnalyzeFileParams>,
1074 _context: RequestContext<RoleServer>,
1075 ) -> Result<CallToolResult, ErrorData> {
1076 let params = params.0;
1077 let _validated_path = match validate_path(¶ms.path, true) {
1078 Ok(p) => p,
1079 Err(e) => return Ok(err_to_tool_result(e)),
1080 };
1081 let t_start = std::time::Instant::now();
1082 let param_path = params.path.clone();
1083 let seq = self
1084 .session_call_seq
1085 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1086 let sid = self.session_id.lock().await.clone();
1087
1088 if std::path::Path::new(¶ms.path).is_dir() {
1090 return Ok(err_to_tool_result(ErrorData::new(
1091 rmcp::model::ErrorCode::INVALID_PARAMS,
1092 format!(
1093 "'{}' is a directory; use analyze_directory instead",
1094 params.path
1095 ),
1096 Some(error_meta(
1097 "validation",
1098 false,
1099 "pass a file path, not a directory",
1100 )),
1101 )));
1102 }
1103
1104 if summary_cursor_conflict(
1106 params.output_control.summary,
1107 params.pagination.cursor.as_deref(),
1108 ) {
1109 return Ok(err_to_tool_result(ErrorData::new(
1110 rmcp::model::ErrorCode::INVALID_PARAMS,
1111 "summary=true is incompatible with a pagination cursor; use one or the other"
1112 .to_string(),
1113 Some(error_meta(
1114 "validation",
1115 false,
1116 "remove cursor or set summary=false",
1117 )),
1118 )));
1119 }
1120
1121 let (arc_output, file_cache_hit) = match self.handle_file_details_mode(¶ms).await {
1123 Ok(v) => v,
1124 Err(e) => return Ok(err_to_tool_result(e)),
1125 };
1126
1127 let mut formatted = arc_output.formatted.clone();
1131 let line_count = arc_output.line_count;
1132
1133 let use_summary = if params.output_control.force == Some(true) {
1135 false
1136 } else if params.output_control.summary == Some(true) {
1137 true
1138 } else if params.output_control.summary == Some(false) {
1139 false
1140 } else {
1141 formatted.len() > SIZE_LIMIT
1142 };
1143
1144 if use_summary {
1145 formatted = format_file_details_summary(&arc_output.semantic, ¶ms.path, line_count);
1146 } else if formatted.len() > SIZE_LIMIT && params.output_control.force != Some(true) {
1147 let estimated_tokens = formatted.len() / 4;
1148 let message = format!(
1149 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1150 - force=true to return full output\n\
1151 - Use fields to limit output to specific sections (functions, classes, or imports)\n\
1152 - Use summary=true for a compact overview",
1153 formatted.len(),
1154 estimated_tokens
1155 );
1156 return Ok(err_to_tool_result(ErrorData::new(
1157 rmcp::model::ErrorCode::INVALID_PARAMS,
1158 message,
1159 Some(error_meta(
1160 "validation",
1161 false,
1162 "use force=true, fields, or summary=true",
1163 )),
1164 )));
1165 }
1166
1167 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1169 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1170 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1171 ErrorData::new(
1172 rmcp::model::ErrorCode::INVALID_PARAMS,
1173 e.to_string(),
1174 Some(error_meta("validation", false, "invalid cursor format")),
1175 )
1176 }) {
1177 Ok(v) => v,
1178 Err(e) => return Ok(err_to_tool_result(e)),
1179 };
1180 cursor_data.offset
1181 } else {
1182 0
1183 };
1184
1185 let top_level_fns: Vec<crate::types::FunctionInfo> = arc_output
1187 .semantic
1188 .functions
1189 .iter()
1190 .filter(|func| {
1191 !arc_output
1192 .semantic
1193 .classes
1194 .iter()
1195 .any(|class| func.line >= class.line && func.end_line <= class.end_line)
1196 })
1197 .cloned()
1198 .collect();
1199
1200 let paginated =
1202 match paginate_slice(&top_level_fns, offset, page_size, PaginationMode::Default) {
1203 Ok(v) => v,
1204 Err(e) => {
1205 return Ok(err_to_tool_result(ErrorData::new(
1206 rmcp::model::ErrorCode::INTERNAL_ERROR,
1207 e.to_string(),
1208 Some(error_meta("transient", true, "retry the request")),
1209 )));
1210 }
1211 };
1212
1213 let verbose = params.output_control.verbose.unwrap_or(false);
1215 if !use_summary {
1216 formatted = format_file_details_paginated(
1218 &paginated.items,
1219 paginated.total,
1220 &arc_output.semantic,
1221 ¶ms.path,
1222 line_count,
1223 offset,
1224 verbose,
1225 params.fields.as_deref(),
1226 );
1227 }
1228
1229 let next_cursor = if use_summary {
1231 None
1232 } else {
1233 paginated.next_cursor.clone()
1234 };
1235
1236 let mut final_text = formatted.clone();
1238 if !use_summary && let Some(ref cursor) = next_cursor {
1239 final_text.push('\n');
1240 final_text.push_str("NEXT_CURSOR: ");
1241 final_text.push_str(cursor);
1242 }
1243
1244 let response_output = analyze::FileAnalysisOutput::new(
1246 formatted,
1247 arc_output.semantic.clone(),
1248 line_count,
1249 next_cursor,
1250 );
1251
1252 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1253 .with_meta(Some(no_cache_meta()));
1254 let structured = serde_json::to_value(&response_output).unwrap_or(Value::Null);
1255 result.structured_content = Some(structured);
1256 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1257 self.metrics_tx.send(crate::metrics::MetricEvent {
1258 ts: crate::metrics::unix_ms(),
1259 tool: "analyze_file",
1260 duration_ms: dur,
1261 output_chars: final_text.len(),
1262 param_path_depth: crate::metrics::path_component_count(¶m_path),
1263 max_depth: None,
1264 result: "ok",
1265 error_type: None,
1266 session_id: sid,
1267 seq: Some(seq),
1268 cache_hit: Some(file_cache_hit),
1269 });
1270 Ok(result)
1271 }
1272
1273 #[instrument(skip(self, context))]
1274 #[tool(
1275 name = "analyze_symbol",
1276 title = "Analyze Symbol",
1277 description = "Call graph for a named symbol across all files in a directory. Returns callers and callees. Modes: call graph (default), import_lookup (files importing a module path), def_use (write/read sites). Fails if file path supplied; fails if impl_only=true on non-Rust directory; fails if import_lookup=true with empty symbol; fails if summary=true and cursor. match_mode controls name matching (exact/insensitive/prefix/contains). git_ref restricts to changed files. Example queries: Find all callers of parse_config; Find all files that import std::collections.",
1278 output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
1279 annotations(
1280 title = "Analyze Symbol",
1281 read_only_hint = true,
1282 destructive_hint = false,
1283 idempotent_hint = true,
1284 open_world_hint = false
1285 )
1286 )]
1287 async fn analyze_symbol(
1288 &self,
1289 params: Parameters<AnalyzeSymbolParams>,
1290 context: RequestContext<RoleServer>,
1291 ) -> Result<CallToolResult, ErrorData> {
1292 let params = params.0;
1293 let _validated_path = match validate_path(¶ms.path, true) {
1294 Ok(p) => p,
1295 Err(e) => return Ok(err_to_tool_result(e)),
1296 };
1297 let ct = context.ct.clone();
1298 let t_start = std::time::Instant::now();
1299 let param_path = params.path.clone();
1300 let max_depth_val = params.follow_depth;
1301 let seq = self
1302 .session_call_seq
1303 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1304 let sid = self.session_id.lock().await.clone();
1305
1306 if std::path::Path::new(¶ms.path).is_file() {
1308 return Ok(err_to_tool_result(ErrorData::new(
1309 rmcp::model::ErrorCode::INVALID_PARAMS,
1310 format!(
1311 "'{}' is a file; analyze_symbol requires a directory path",
1312 params.path
1313 ),
1314 Some(error_meta(
1315 "validation",
1316 false,
1317 "pass a directory path, not a file",
1318 )),
1319 )));
1320 }
1321
1322 if summary_cursor_conflict(
1324 params.output_control.summary,
1325 params.pagination.cursor.as_deref(),
1326 ) {
1327 return Ok(err_to_tool_result(ErrorData::new(
1328 rmcp::model::ErrorCode::INVALID_PARAMS,
1329 "summary=true is incompatible with a pagination cursor; use one or the other"
1330 .to_string(),
1331 Some(error_meta(
1332 "validation",
1333 false,
1334 "remove cursor or set summary=false",
1335 )),
1336 )));
1337 }
1338
1339 if let Err(e) = Self::validate_import_lookup(params.import_lookup, ¶ms.symbol) {
1341 return Ok(err_to_tool_result(e));
1342 }
1343
1344 if params.import_lookup == Some(true) {
1346 let path_owned = PathBuf::from(¶ms.path);
1347 let symbol = params.symbol.clone();
1348 let git_ref = params.git_ref.clone();
1349 let max_depth = params.max_depth;
1350 let ast_recursion_limit = params.ast_recursion_limit;
1351
1352 let handle = tokio::task::spawn_blocking(move || {
1353 let path = path_owned.as_path();
1354 let raw_entries = match walk_directory(path, max_depth) {
1355 Ok(e) => e,
1356 Err(e) => {
1357 return Err(ErrorData::new(
1358 rmcp::model::ErrorCode::INTERNAL_ERROR,
1359 format!("Failed to walk directory: {e}"),
1360 Some(error_meta(
1361 "resource",
1362 false,
1363 "check path permissions and availability",
1364 )),
1365 ));
1366 }
1367 };
1368 let entries = if let Some(ref git_ref_val) = git_ref
1370 && !git_ref_val.is_empty()
1371 {
1372 let changed = match changed_files_from_git_ref(path, git_ref_val) {
1373 Ok(c) => c,
1374 Err(e) => {
1375 return Err(ErrorData::new(
1376 rmcp::model::ErrorCode::INVALID_PARAMS,
1377 format!("git_ref filter failed: {e}"),
1378 Some(error_meta(
1379 "resource",
1380 false,
1381 "ensure git is installed and path is inside a git repository",
1382 )),
1383 ));
1384 }
1385 };
1386 filter_entries_by_git_ref(raw_entries, &changed, path)
1387 } else {
1388 raw_entries
1389 };
1390 let output = match analyze::analyze_import_lookup(
1391 path,
1392 &symbol,
1393 &entries,
1394 ast_recursion_limit,
1395 ) {
1396 Ok(v) => v,
1397 Err(e) => {
1398 return Err(ErrorData::new(
1399 rmcp::model::ErrorCode::INTERNAL_ERROR,
1400 format!("import_lookup failed: {e}"),
1401 Some(error_meta(
1402 "resource",
1403 false,
1404 "check path and file permissions",
1405 )),
1406 ));
1407 }
1408 };
1409 Ok(output)
1410 });
1411
1412 let output = match handle.await {
1413 Ok(Ok(v)) => v,
1414 Ok(Err(e)) => return Ok(err_to_tool_result(e)),
1415 Err(e) => {
1416 return Ok(err_to_tool_result(ErrorData::new(
1417 rmcp::model::ErrorCode::INTERNAL_ERROR,
1418 format!("spawn_blocking failed: {e}"),
1419 Some(error_meta("resource", false, "internal error")),
1420 )));
1421 }
1422 };
1423
1424 let final_text = output.formatted.clone();
1425 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1426 .with_meta(Some(no_cache_meta()));
1427 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1428 result.structured_content = Some(structured);
1429 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1430 self.metrics_tx.send(crate::metrics::MetricEvent {
1431 ts: crate::metrics::unix_ms(),
1432 tool: "analyze_symbol",
1433 duration_ms: dur,
1434 output_chars: final_text.len(),
1435 param_path_depth: crate::metrics::path_component_count(¶m_path),
1436 max_depth: max_depth_val,
1437 result: "ok",
1438 error_type: None,
1439 session_id: sid,
1440 seq: Some(seq),
1441 cache_hit: Some(false),
1442 });
1443 return Ok(result);
1444 }
1445
1446 let mut output = match self.handle_focused_mode(¶ms, ct).await {
1448 Ok(v) => v,
1449 Err(e) => return Ok(err_to_tool_result(e)),
1450 };
1451
1452 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1454 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1455 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1456 ErrorData::new(
1457 rmcp::model::ErrorCode::INVALID_PARAMS,
1458 e.to_string(),
1459 Some(error_meta("validation", false, "invalid cursor format")),
1460 )
1461 }) {
1462 Ok(v) => v,
1463 Err(e) => return Ok(err_to_tool_result(e)),
1464 };
1465 cursor_data.offset
1466 } else {
1467 0
1468 };
1469
1470 let cursor_mode = if let Some(ref cursor_str) = params.pagination.cursor {
1472 decode_cursor(cursor_str)
1473 .map(|c| c.mode)
1474 .unwrap_or(PaginationMode::Callers)
1475 } else {
1476 PaginationMode::Callers
1477 };
1478
1479 let mut use_summary = params.output_control.summary == Some(true);
1480 if params.output_control.force == Some(true) {
1481 use_summary = false;
1482 }
1483 let verbose = params.output_control.verbose.unwrap_or(false);
1484
1485 let mut callee_cursor = match cursor_mode {
1486 PaginationMode::Callers => {
1487 let (paginated_items, paginated_next) = match paginate_focus_chains(
1488 &output.prod_chains,
1489 PaginationMode::Callers,
1490 offset,
1491 page_size,
1492 ) {
1493 Ok(v) => v,
1494 Err(e) => return Ok(err_to_tool_result(e)),
1495 };
1496
1497 if !use_summary
1498 && (paginated_next.is_some()
1499 || offset > 0
1500 || !verbose
1501 || !output.outgoing_chains.is_empty())
1502 {
1503 let base_path = Path::new(¶ms.path);
1504 output.formatted = format_focused_paginated(
1505 &paginated_items,
1506 output.prod_chains.len(),
1507 PaginationMode::Callers,
1508 ¶ms.symbol,
1509 &output.prod_chains,
1510 &output.test_chains,
1511 &output.outgoing_chains,
1512 output.def_count,
1513 offset,
1514 Some(base_path),
1515 verbose,
1516 );
1517 paginated_next
1518 } else {
1519 None
1520 }
1521 }
1522 PaginationMode::Callees => {
1523 let (paginated_items, paginated_next) = match paginate_focus_chains(
1524 &output.outgoing_chains,
1525 PaginationMode::Callees,
1526 offset,
1527 page_size,
1528 ) {
1529 Ok(v) => v,
1530 Err(e) => return Ok(err_to_tool_result(e)),
1531 };
1532
1533 if paginated_next.is_some() || offset > 0 || !verbose {
1534 let base_path = Path::new(¶ms.path);
1535 output.formatted = format_focused_paginated(
1536 &paginated_items,
1537 output.outgoing_chains.len(),
1538 PaginationMode::Callees,
1539 ¶ms.symbol,
1540 &output.prod_chains,
1541 &output.test_chains,
1542 &output.outgoing_chains,
1543 output.def_count,
1544 offset,
1545 Some(base_path),
1546 verbose,
1547 );
1548 paginated_next
1549 } else {
1550 None
1551 }
1552 }
1553 PaginationMode::Default => {
1554 return Ok(err_to_tool_result(ErrorData::new(
1555 rmcp::model::ErrorCode::INVALID_PARAMS,
1556 "invalid cursor: unknown pagination mode".to_string(),
1557 Some(error_meta(
1558 "validation",
1559 false,
1560 "use a cursor returned by a previous analyze_symbol call",
1561 )),
1562 )));
1563 }
1564 PaginationMode::DefUse => {
1565 let total_sites = output.def_use_sites.len();
1566 let (paginated_sites, paginated_next) = match paginate_slice(
1567 &output.def_use_sites,
1568 offset,
1569 page_size,
1570 PaginationMode::DefUse,
1571 ) {
1572 Ok(r) => (r.items, r.next_cursor),
1573 Err(e) => return Ok(err_to_tool_result_from_pagination(e)),
1574 };
1575
1576 if !use_summary {
1579 let base_path = Path::new(¶ms.path);
1580 output.formatted = format_focused_paginated_defuse(
1581 &paginated_sites,
1582 total_sites,
1583 ¶ms.symbol,
1584 offset,
1585 Some(base_path),
1586 verbose,
1587 );
1588 }
1589
1590 output.def_use_sites = paginated_sites;
1593
1594 paginated_next
1595 }
1596 };
1597
1598 if callee_cursor.is_none()
1603 && cursor_mode == PaginationMode::Callers
1604 && !output.outgoing_chains.is_empty()
1605 && !use_summary
1606 && let Ok(cursor) = encode_cursor(&CursorData {
1607 mode: PaginationMode::Callees,
1608 offset: 0,
1609 })
1610 {
1611 callee_cursor = Some(cursor);
1612 }
1613
1614 if callee_cursor.is_none()
1621 && matches!(
1622 cursor_mode,
1623 PaginationMode::Callees | PaginationMode::Callers
1624 )
1625 && !output.def_use_sites.is_empty()
1626 && !use_summary
1627 && let Ok(cursor) = encode_cursor(&CursorData {
1628 mode: PaginationMode::DefUse,
1629 offset: 0,
1630 })
1631 {
1632 if cursor_mode == PaginationMode::Callees || output.outgoing_chains.is_empty() {
1635 callee_cursor = Some(cursor);
1636 }
1637 }
1638
1639 output.next_cursor.clone_from(&callee_cursor);
1641
1642 let mut final_text = output.formatted.clone();
1644 if let Some(cursor) = callee_cursor {
1645 final_text.push('\n');
1646 final_text.push_str("NEXT_CURSOR: ");
1647 final_text.push_str(&cursor);
1648 }
1649
1650 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1651 .with_meta(Some(no_cache_meta()));
1652 if cursor_mode != PaginationMode::DefUse {
1656 output.def_use_sites = Vec::new();
1657 }
1658 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1659 result.structured_content = Some(structured);
1660 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1661 self.metrics_tx.send(crate::metrics::MetricEvent {
1662 ts: crate::metrics::unix_ms(),
1663 tool: "analyze_symbol",
1664 duration_ms: dur,
1665 output_chars: final_text.len(),
1666 param_path_depth: crate::metrics::path_component_count(¶m_path),
1667 max_depth: max_depth_val,
1668 result: "ok",
1669 error_type: None,
1670 session_id: sid,
1671 seq: Some(seq),
1672 cache_hit: Some(false),
1673 });
1674 Ok(result)
1675 }
1676
1677 #[instrument(skip(self, _context))]
1678 #[tool(
1679 name = "analyze_module",
1680 title = "Analyze Module",
1681 description = "Function and import index for a single source file with minimal token cost: name, line_count, language, function names with line numbers, import list only (~75% smaller than analyze_file). Fails if directory path supplied. Pagination, summary, force, verbose, git_ref not supported. Use analyze_file when you need signatures, types, or class details. Supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#. Example queries: What functions are defined in src/analyze.rs?",
1682 output_schema = schema_for_type::<types::ModuleInfo>(),
1683 annotations(
1684 title = "Analyze Module",
1685 read_only_hint = true,
1686 destructive_hint = false,
1687 idempotent_hint = true,
1688 open_world_hint = false
1689 )
1690 )]
1691 async fn analyze_module(
1692 &self,
1693 params: Parameters<AnalyzeModuleParams>,
1694 _context: RequestContext<RoleServer>,
1695 ) -> Result<CallToolResult, ErrorData> {
1696 let params = params.0;
1697 let _validated_path = match validate_path(¶ms.path, true) {
1698 Ok(p) => p,
1699 Err(e) => return Ok(err_to_tool_result(e)),
1700 };
1701 let t_start = std::time::Instant::now();
1702 let param_path = params.path.clone();
1703 let seq = self
1704 .session_call_seq
1705 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1706 let sid = self.session_id.lock().await.clone();
1707
1708 if std::fs::metadata(¶ms.path)
1710 .map(|m| m.is_dir())
1711 .unwrap_or(false)
1712 {
1713 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1714 self.metrics_tx.send(crate::metrics::MetricEvent {
1715 ts: crate::metrics::unix_ms(),
1716 tool: "analyze_module",
1717 duration_ms: dur,
1718 output_chars: 0,
1719 param_path_depth: crate::metrics::path_component_count(¶m_path),
1720 max_depth: None,
1721 result: "error",
1722 error_type: Some("invalid_params".to_string()),
1723 session_id: sid.clone(),
1724 seq: Some(seq),
1725 cache_hit: None,
1726 });
1727 return Ok(err_to_tool_result(ErrorData::new(
1728 rmcp::model::ErrorCode::INVALID_PARAMS,
1729 format!(
1730 "'{}' is a directory. Use analyze_directory to analyze a directory, or pass a specific file path to analyze_module.",
1731 params.path
1732 ),
1733 Some(error_meta(
1734 "validation",
1735 false,
1736 "use analyze_directory for directories",
1737 )),
1738 )));
1739 }
1740
1741 let module_cache_key = std::fs::metadata(¶ms.path).ok().and_then(|meta| {
1743 meta.modified().ok().map(|mtime| cache::CacheKey {
1744 path: std::path::PathBuf::from(¶ms.path),
1745 modified: mtime,
1746 mode: AnalysisMode::FileDetails,
1747 })
1748 });
1749 let (module_info, module_cache_hit) = if let Some(ref key) = module_cache_key
1750 && let Some(cached_file) = self.cache.get(key)
1751 {
1752 let file_path = std::path::Path::new(¶ms.path);
1756 let name = file_path
1757 .file_name()
1758 .and_then(|n: &std::ffi::OsStr| n.to_str())
1759 .unwrap_or("unknown")
1760 .to_string();
1761 let language = file_path
1762 .extension()
1763 .and_then(|e| e.to_str())
1764 .and_then(aptu_coder_core::lang::language_for_extension)
1765 .unwrap_or("unknown")
1766 .to_string();
1767 let mut mi = types::ModuleInfo::default();
1768 mi.name = name;
1769 mi.line_count = cached_file.line_count;
1770 mi.language = language;
1771 mi.functions = cached_file
1772 .semantic
1773 .functions
1774 .iter()
1775 .map(|f| {
1776 let mut mfi = types::ModuleFunctionInfo::default();
1777 mfi.name = f.name.clone();
1778 mfi.line = f.line;
1779 mfi
1780 })
1781 .collect();
1782 mi.imports = cached_file
1783 .semantic
1784 .imports
1785 .iter()
1786 .map(|i| {
1787 let mut mii = types::ModuleImportInfo::default();
1788 mii.module = i.module.clone();
1789 mii.items = i.items.clone();
1790 mii
1791 })
1792 .collect();
1793 (mi, true)
1794 } else {
1795 let file_output = match analyze::analyze_file(¶ms.path, None) {
1799 Ok(v) => v,
1800 Err(e) => {
1801 let error_data = match &e {
1802 analyze::AnalyzeError::Io(io_err) => match io_err.kind() {
1803 std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied => {
1804 ErrorData::new(
1805 rmcp::model::ErrorCode::INVALID_PARAMS,
1806 format!("Failed to analyze module: {e}"),
1807 Some(error_meta(
1808 "validation",
1809 false,
1810 "ensure file exists, is readable, and has a supported extension",
1811 )),
1812 )
1813 }
1814 _ => ErrorData::new(
1815 rmcp::model::ErrorCode::INTERNAL_ERROR,
1816 format!("Failed to analyze module: {e}"),
1817 Some(error_meta("internal", false, "report this as a bug")),
1818 ),
1819 },
1820 analyze::AnalyzeError::UnsupportedLanguage(_)
1821 | analyze::AnalyzeError::InvalidRange { .. }
1822 | analyze::AnalyzeError::NotAFile(_) => ErrorData::new(
1823 rmcp::model::ErrorCode::INVALID_PARAMS,
1824 format!("Failed to analyze module: {e}"),
1825 Some(error_meta(
1826 "validation",
1827 false,
1828 "ensure the path is a supported source file",
1829 )),
1830 ),
1831 _ => ErrorData::new(
1832 rmcp::model::ErrorCode::INTERNAL_ERROR,
1833 format!("Failed to analyze module: {e}"),
1834 Some(error_meta("internal", false, "report this as a bug")),
1835 ),
1836 };
1837 return Ok(err_to_tool_result(error_data));
1838 }
1839 };
1840 let arc_output = std::sync::Arc::new(file_output);
1841 if let Some(key) = module_cache_key.clone() {
1842 self.cache.put(key, arc_output.clone());
1843 }
1844 let file_path = std::path::Path::new(¶ms.path);
1845 let name = file_path
1846 .file_name()
1847 .and_then(|n: &std::ffi::OsStr| n.to_str())
1848 .unwrap_or("unknown")
1849 .to_string();
1850 let language = file_path
1851 .extension()
1852 .and_then(|e| e.to_str())
1853 .and_then(aptu_coder_core::lang::language_for_extension)
1854 .unwrap_or("unknown")
1855 .to_string();
1856 let mut mi = types::ModuleInfo::default();
1857 mi.name = name;
1858 mi.line_count = arc_output.line_count;
1859 mi.language = language;
1860 mi.functions = arc_output
1861 .semantic
1862 .functions
1863 .iter()
1864 .map(|f| {
1865 let mut mfi = types::ModuleFunctionInfo::default();
1866 mfi.name = f.name.clone();
1867 mfi.line = f.line;
1868 mfi
1869 })
1870 .collect();
1871 mi.imports = arc_output
1872 .semantic
1873 .imports
1874 .iter()
1875 .map(|i| {
1876 let mut mii = types::ModuleImportInfo::default();
1877 mii.module = i.module.clone();
1878 mii.items = i.items.clone();
1879 mii
1880 })
1881 .collect();
1882 (mi, false)
1883 };
1884
1885 let text = format_module_info(&module_info);
1886 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
1887 .with_meta(Some(no_cache_meta()));
1888 let structured = match serde_json::to_value(&module_info).map_err(|e| {
1889 ErrorData::new(
1890 rmcp::model::ErrorCode::INTERNAL_ERROR,
1891 format!("serialization failed: {e}"),
1892 Some(error_meta("internal", false, "report this as a bug")),
1893 )
1894 }) {
1895 Ok(v) => v,
1896 Err(e) => return Ok(err_to_tool_result(e)),
1897 };
1898 result.structured_content = Some(structured);
1899 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1900 self.metrics_tx.send(crate::metrics::MetricEvent {
1901 ts: crate::metrics::unix_ms(),
1902 tool: "analyze_module",
1903 duration_ms: dur,
1904 output_chars: text.len(),
1905 param_path_depth: crate::metrics::path_component_count(¶m_path),
1906 max_depth: None,
1907 result: "ok",
1908 error_type: None,
1909 session_id: sid,
1910 seq: Some(seq),
1911 cache_hit: Some(module_cache_hit),
1912 });
1913 Ok(result)
1914 }
1915
1916 #[instrument(skip(self, _context))]
1917 #[tool(
1918 name = "analyze_raw",
1919 title = "Analyze Raw",
1920 description = "Raw UTF-8 file content with line numbers; no AST parsing. Returns path, total_lines, start_line, end_line, content, next_start_line (null at EOF; pass as start_line to continue pagination). Use start_line/end_line (1-indexed, inclusive) to read a range; defaults to full file. Fails if directory path supplied; fails on binary or non-UTF-8 files. Use analyze_file or analyze_module for AST-structured output. Example queries: Show lines 100-150 of src/lib.rs.",
1921 output_schema = schema_for_type::<types::AnalyzeRawOutput>(),
1922 annotations(
1923 title = "Analyze Raw",
1924 read_only_hint = true,
1925 destructive_hint = false,
1926 idempotent_hint = true,
1927 open_world_hint = false
1928 )
1929 )]
1930 async fn analyze_raw(
1931 &self,
1932 params: Parameters<types::AnalyzeRawParams>,
1933 _context: RequestContext<RoleServer>,
1934 ) -> Result<CallToolResult, ErrorData> {
1935 let params = params.0;
1936 let _validated_path = match validate_path(¶ms.path, true) {
1937 Ok(p) => p,
1938 Err(e) => return Ok(err_to_tool_result(e)),
1939 };
1940 let t_start = std::time::Instant::now();
1941 let param_path = params.path.clone();
1942 let seq = self
1943 .session_call_seq
1944 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1945 let sid = self.session_id.lock().await.clone();
1946
1947 if std::fs::metadata(¶ms.path)
1949 .map(|m| m.is_dir())
1950 .unwrap_or(false)
1951 {
1952 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1953 self.metrics_tx.send(crate::metrics::MetricEvent {
1954 ts: crate::metrics::unix_ms(),
1955 tool: "analyze_raw",
1956 duration_ms: dur,
1957 output_chars: 0,
1958 param_path_depth: crate::metrics::path_component_count(¶m_path),
1959 max_depth: None,
1960 result: "error",
1961 error_type: Some("invalid_params".to_string()),
1962 session_id: sid.clone(),
1963 seq: Some(seq),
1964 cache_hit: None,
1965 });
1966 return Ok(err_to_tool_result(ErrorData::new(
1967 rmcp::model::ErrorCode::INVALID_PARAMS,
1968 "path is a directory; use analyze_directory instead".to_string(),
1969 Some(error_meta(
1970 "validation",
1971 false,
1972 "pass a file path, not a directory",
1973 )),
1974 )));
1975 }
1976
1977 let path = std::path::PathBuf::from(¶ms.path);
1978 let handle = tokio::task::spawn_blocking(move || {
1979 aptu_coder_core::analyze_raw_range(&path, params.start_line, params.end_line)
1980 });
1981
1982 let output = match handle.await {
1983 Ok(Ok(v)) => v,
1984 Ok(Err(aptu_coder_core::AnalyzeError::InvalidRange { start, end, total })) => {
1985 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1986 self.metrics_tx.send(crate::metrics::MetricEvent {
1987 ts: crate::metrics::unix_ms(),
1988 tool: "analyze_raw",
1989 duration_ms: dur,
1990 output_chars: 0,
1991 param_path_depth: crate::metrics::path_component_count(¶m_path),
1992 max_depth: None,
1993 result: "error",
1994 error_type: Some("invalid_params".to_string()),
1995 session_id: sid.clone(),
1996 seq: Some(seq),
1997 cache_hit: None,
1998 });
1999 return Ok(err_to_tool_result(ErrorData::new(
2000 rmcp::model::ErrorCode::INVALID_PARAMS,
2001 format!("invalid range: start ({start}) > end ({end}); file has {total} lines"),
2002 Some(error_meta(
2003 "validation",
2004 false,
2005 "ensure start_line <= end_line",
2006 )),
2007 )));
2008 }
2009 Ok(Err(aptu_coder_core::AnalyzeError::NotAFile(_))) => {
2010 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2011 self.metrics_tx.send(crate::metrics::MetricEvent {
2012 ts: crate::metrics::unix_ms(),
2013 tool: "analyze_raw",
2014 duration_ms: dur,
2015 output_chars: 0,
2016 param_path_depth: crate::metrics::path_component_count(¶m_path),
2017 max_depth: None,
2018 result: "error",
2019 error_type: Some("invalid_params".to_string()),
2020 session_id: sid.clone(),
2021 seq: Some(seq),
2022 cache_hit: None,
2023 });
2024 return Ok(err_to_tool_result(ErrorData::new(
2025 rmcp::model::ErrorCode::INVALID_PARAMS,
2026 "path is not a file".to_string(),
2027 Some(error_meta(
2028 "validation",
2029 false,
2030 "provide a file path, not a directory",
2031 )),
2032 )));
2033 }
2034 Ok(Err(e)) => {
2035 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2036 self.metrics_tx.send(crate::metrics::MetricEvent {
2037 ts: crate::metrics::unix_ms(),
2038 tool: "analyze_raw",
2039 duration_ms: dur,
2040 output_chars: 0,
2041 param_path_depth: crate::metrics::path_component_count(¶m_path),
2042 max_depth: None,
2043 result: "error",
2044 error_type: Some("internal_error".to_string()),
2045 session_id: sid.clone(),
2046 seq: Some(seq),
2047 cache_hit: None,
2048 });
2049 return Ok(err_to_tool_result(ErrorData::new(
2050 rmcp::model::ErrorCode::INTERNAL_ERROR,
2051 e.to_string(),
2052 Some(error_meta(
2053 "resource",
2054 false,
2055 "check file path and permissions",
2056 )),
2057 )));
2058 }
2059 Err(e) => {
2060 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2061 self.metrics_tx.send(crate::metrics::MetricEvent {
2062 ts: crate::metrics::unix_ms(),
2063 tool: "analyze_raw",
2064 duration_ms: dur,
2065 output_chars: 0,
2066 param_path_depth: crate::metrics::path_component_count(¶m_path),
2067 max_depth: None,
2068 result: "error",
2069 error_type: Some("internal_error".to_string()),
2070 session_id: sid.clone(),
2071 seq: Some(seq),
2072 cache_hit: None,
2073 });
2074 return Ok(err_to_tool_result(ErrorData::new(
2075 rmcp::model::ErrorCode::INTERNAL_ERROR,
2076 e.to_string(),
2077 Some(error_meta(
2078 "resource",
2079 false,
2080 "check file path and permissions",
2081 )),
2082 )));
2083 }
2084 };
2085
2086 let text = format!(
2087 "File: {} (total: {} lines, showing: {}-{})\n\n{}",
2088 output.path, output.total_lines, output.start_line, output.end_line, output.content
2089 );
2090 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2091 .with_meta(Some(no_cache_meta()));
2092 let structured = match serde_json::to_value(&output).map_err(|e| {
2093 ErrorData::new(
2094 rmcp::model::ErrorCode::INTERNAL_ERROR,
2095 format!("serialization failed: {e}"),
2096 Some(error_meta("internal", false, "report this as a bug")),
2097 )
2098 }) {
2099 Ok(v) => v,
2100 Err(e) => return Ok(err_to_tool_result(e)),
2101 };
2102 result.structured_content = Some(structured);
2103 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2104 self.metrics_tx.send(crate::metrics::MetricEvent {
2105 ts: crate::metrics::unix_ms(),
2106 tool: "analyze_raw",
2107 duration_ms: dur,
2108 output_chars: text.len(),
2109 param_path_depth: crate::metrics::path_component_count(¶m_path),
2110 max_depth: None,
2111 result: "ok",
2112 error_type: None,
2113 session_id: sid,
2114 seq: Some(seq),
2115 cache_hit: None,
2116 });
2117 Ok(result)
2118 }
2119
2120 #[instrument(skip(self, _context))]
2121 #[tool(
2122 name = "edit_overwrite",
2123 title = "Edit Overwrite",
2124 description = "Creates or overwrites a file with UTF-8 content; creates parent directories if needed. Returns path, bytes_written. Fails if directory path supplied. AST-unaware (no language constraint). Use edit_replace for targeted single-block edits; edit_rename/edit_insert for AST-targeted changes. Example queries: Overwrite src/config.rs with updated content.",
2125 output_schema = schema_for_type::<EditOverwriteOutput>(),
2126 annotations(
2127 title = "Edit Overwrite",
2128 read_only_hint = false,
2129 destructive_hint = true,
2130 idempotent_hint = false,
2131 open_world_hint = false
2132 )
2133 )]
2134 async fn edit_overwrite(
2135 &self,
2136 params: Parameters<EditOverwriteParams>,
2137 _context: RequestContext<RoleServer>,
2138 ) -> Result<CallToolResult, ErrorData> {
2139 let params = params.0;
2140 let _validated_path = match validate_path(¶ms.path, false) {
2141 Ok(p) => p,
2142 Err(e) => return Ok(err_to_tool_result(e)),
2143 };
2144 let t_start = std::time::Instant::now();
2145 let param_path = params.path.clone();
2146 let seq = self
2147 .session_call_seq
2148 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2149 let sid = self.session_id.lock().await.clone();
2150
2151 if std::fs::metadata(¶ms.path)
2153 .map(|m| m.is_dir())
2154 .unwrap_or(false)
2155 {
2156 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2157 self.metrics_tx.send(crate::metrics::MetricEvent {
2158 ts: crate::metrics::unix_ms(),
2159 tool: "edit_overwrite",
2160 duration_ms: dur,
2161 output_chars: 0,
2162 param_path_depth: crate::metrics::path_component_count(¶m_path),
2163 max_depth: None,
2164 result: "error",
2165 error_type: Some("invalid_params".to_string()),
2166 session_id: sid.clone(),
2167 seq: Some(seq),
2168 cache_hit: None,
2169 });
2170 return Ok(err_to_tool_result(ErrorData::new(
2171 rmcp::model::ErrorCode::INVALID_PARAMS,
2172 "path is a directory; cannot write to a directory".to_string(),
2173 Some(error_meta(
2174 "validation",
2175 false,
2176 "provide a file path, not a directory",
2177 )),
2178 )));
2179 }
2180
2181 let path = std::path::PathBuf::from(¶ms.path);
2182 let content = params.content.clone();
2183 let handle = tokio::task::spawn_blocking(move || {
2184 aptu_coder_core::edit_overwrite_content(&path, &content)
2185 });
2186
2187 let output = match handle.await {
2188 Ok(Ok(v)) => v,
2189 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2190 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2191 self.metrics_tx.send(crate::metrics::MetricEvent {
2192 ts: crate::metrics::unix_ms(),
2193 tool: "edit_overwrite",
2194 duration_ms: dur,
2195 output_chars: 0,
2196 param_path_depth: crate::metrics::path_component_count(¶m_path),
2197 max_depth: None,
2198 result: "error",
2199 error_type: Some("invalid_params".to_string()),
2200 session_id: sid.clone(),
2201 seq: Some(seq),
2202 cache_hit: None,
2203 });
2204 return Ok(err_to_tool_result(ErrorData::new(
2205 rmcp::model::ErrorCode::INVALID_PARAMS,
2206 "path is a directory".to_string(),
2207 Some(error_meta(
2208 "validation",
2209 false,
2210 "provide a file path, not a directory",
2211 )),
2212 )));
2213 }
2214 Ok(Err(e)) => {
2215 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2216 self.metrics_tx.send(crate::metrics::MetricEvent {
2217 ts: crate::metrics::unix_ms(),
2218 tool: "edit_overwrite",
2219 duration_ms: dur,
2220 output_chars: 0,
2221 param_path_depth: crate::metrics::path_component_count(¶m_path),
2222 max_depth: None,
2223 result: "error",
2224 error_type: Some("internal_error".to_string()),
2225 session_id: sid.clone(),
2226 seq: Some(seq),
2227 cache_hit: None,
2228 });
2229 return Ok(err_to_tool_result(ErrorData::new(
2230 rmcp::model::ErrorCode::INTERNAL_ERROR,
2231 e.to_string(),
2232 Some(error_meta(
2233 "resource",
2234 false,
2235 "check file path and permissions",
2236 )),
2237 )));
2238 }
2239 Err(e) => {
2240 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2241 self.metrics_tx.send(crate::metrics::MetricEvent {
2242 ts: crate::metrics::unix_ms(),
2243 tool: "edit_overwrite",
2244 duration_ms: dur,
2245 output_chars: 0,
2246 param_path_depth: crate::metrics::path_component_count(¶m_path),
2247 max_depth: None,
2248 result: "error",
2249 error_type: Some("internal_error".to_string()),
2250 session_id: sid.clone(),
2251 seq: Some(seq),
2252 cache_hit: None,
2253 });
2254 return Ok(err_to_tool_result(ErrorData::new(
2255 rmcp::model::ErrorCode::INTERNAL_ERROR,
2256 e.to_string(),
2257 Some(error_meta(
2258 "resource",
2259 false,
2260 "check file path and permissions",
2261 )),
2262 )));
2263 }
2264 };
2265
2266 let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2267 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2268 .with_meta(Some(no_cache_meta()));
2269 let structured = match serde_json::to_value(&output).map_err(|e| {
2270 ErrorData::new(
2271 rmcp::model::ErrorCode::INTERNAL_ERROR,
2272 format!("serialization failed: {e}"),
2273 Some(error_meta("internal", false, "report this as a bug")),
2274 )
2275 }) {
2276 Ok(v) => v,
2277 Err(e) => return Ok(err_to_tool_result(e)),
2278 };
2279 result.structured_content = Some(structured);
2280 self.cache
2281 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2282 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2283 self.metrics_tx.send(crate::metrics::MetricEvent {
2284 ts: crate::metrics::unix_ms(),
2285 tool: "edit_overwrite",
2286 duration_ms: dur,
2287 output_chars: text.len(),
2288 param_path_depth: crate::metrics::path_component_count(¶m_path),
2289 max_depth: None,
2290 result: "ok",
2291 error_type: None,
2292 session_id: sid,
2293 seq: Some(seq),
2294 cache_hit: None,
2295 });
2296 Ok(result)
2297 }
2298
2299 #[instrument(skip(self, _context))]
2300 #[tool(
2301 name = "edit_replace",
2302 title = "Edit Replace",
2303 description = "Replaces a unique exact text block; old_text must match character-for-character and appear exactly once. Returns path, bytes_before, bytes_after. Fails if zero matches; fails if multiple matches (extend old_text to be more specific). Whitespace-sensitive exact match. Use edit_overwrite to replace the whole file; edit_rename/edit_insert for AST-targeted changes. Example queries: Update the function signature in lib.rs.",
2304 output_schema = schema_for_type::<EditReplaceOutput>(),
2305 annotations(
2306 title = "Edit Replace",
2307 read_only_hint = false,
2308 destructive_hint = true,
2309 idempotent_hint = false,
2310 open_world_hint = false
2311 )
2312 )]
2313 async fn edit_replace(
2314 &self,
2315 params: Parameters<EditReplaceParams>,
2316 _context: RequestContext<RoleServer>,
2317 ) -> Result<CallToolResult, ErrorData> {
2318 let params = params.0;
2319 let _validated_path = match validate_path(¶ms.path, true) {
2320 Ok(p) => p,
2321 Err(e) => return Ok(err_to_tool_result(e)),
2322 };
2323 let t_start = std::time::Instant::now();
2324 let param_path = params.path.clone();
2325 let seq = self
2326 .session_call_seq
2327 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2328 let sid = self.session_id.lock().await.clone();
2329
2330 if std::fs::metadata(¶ms.path)
2332 .map(|m| m.is_dir())
2333 .unwrap_or(false)
2334 {
2335 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2336 self.metrics_tx.send(crate::metrics::MetricEvent {
2337 ts: crate::metrics::unix_ms(),
2338 tool: "edit_replace",
2339 duration_ms: dur,
2340 output_chars: 0,
2341 param_path_depth: crate::metrics::path_component_count(¶m_path),
2342 max_depth: None,
2343 result: "error",
2344 error_type: Some("invalid_params".to_string()),
2345 session_id: sid.clone(),
2346 seq: Some(seq),
2347 cache_hit: None,
2348 });
2349 return Ok(err_to_tool_result(ErrorData::new(
2350 rmcp::model::ErrorCode::INVALID_PARAMS,
2351 "path is a directory; cannot edit a directory".to_string(),
2352 Some(error_meta(
2353 "validation",
2354 false,
2355 "provide a file path, not a directory",
2356 )),
2357 )));
2358 }
2359
2360 let path = std::path::PathBuf::from(¶ms.path);
2361 let old_text = params.old_text.clone();
2362 let new_text = params.new_text.clone();
2363 let handle = tokio::task::spawn_blocking(move || {
2364 aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2365 });
2366
2367 let output = match handle.await {
2368 Ok(Ok(v)) => v,
2369 Ok(Err(aptu_coder_core::EditError::NotFound { path: _ })) => {
2370 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2371 self.metrics_tx.send(crate::metrics::MetricEvent {
2372 ts: crate::metrics::unix_ms(),
2373 tool: "edit_replace",
2374 duration_ms: dur,
2375 output_chars: 0,
2376 param_path_depth: crate::metrics::path_component_count(¶m_path),
2377 max_depth: None,
2378 result: "error",
2379 error_type: Some("invalid_params".to_string()),
2380 session_id: sid.clone(),
2381 seq: Some(seq),
2382 cache_hit: None,
2383 });
2384 return Ok(err_to_tool_result(ErrorData::new(
2385 rmcp::model::ErrorCode::INVALID_PARAMS,
2386 "old_text not found in file — verify the text matches exactly, including whitespace and newlines".to_string(),
2387 Some(error_meta(
2388 "validation",
2389 false,
2390 "check that old_text appears in the file",
2391 )),
2392 )));
2393 }
2394 Ok(Err(aptu_coder_core::EditError::Ambiguous { count, path: _ })) => {
2395 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2396 self.metrics_tx.send(crate::metrics::MetricEvent {
2397 ts: crate::metrics::unix_ms(),
2398 tool: "edit_replace",
2399 duration_ms: dur,
2400 output_chars: 0,
2401 param_path_depth: crate::metrics::path_component_count(¶m_path),
2402 max_depth: None,
2403 result: "error",
2404 error_type: Some("invalid_params".to_string()),
2405 session_id: sid.clone(),
2406 seq: Some(seq),
2407 cache_hit: None,
2408 });
2409 return Ok(err_to_tool_result(ErrorData::new(
2410 rmcp::model::ErrorCode::INVALID_PARAMS,
2411 format!(
2412 "old_text appears {count} times in file — make old_text longer and more specific to uniquely identify the block"
2413 ),
2414 Some(error_meta(
2415 "validation",
2416 false,
2417 "include more context in old_text to make it unique",
2418 )),
2419 )));
2420 }
2421 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2422 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2423 self.metrics_tx.send(crate::metrics::MetricEvent {
2424 ts: crate::metrics::unix_ms(),
2425 tool: "edit_replace",
2426 duration_ms: dur,
2427 output_chars: 0,
2428 param_path_depth: crate::metrics::path_component_count(¶m_path),
2429 max_depth: None,
2430 result: "error",
2431 error_type: Some("invalid_params".to_string()),
2432 session_id: sid.clone(),
2433 seq: Some(seq),
2434 cache_hit: None,
2435 });
2436 return Ok(err_to_tool_result(ErrorData::new(
2437 rmcp::model::ErrorCode::INVALID_PARAMS,
2438 "path is a directory".to_string(),
2439 Some(error_meta(
2440 "validation",
2441 false,
2442 "provide a file path, not a directory",
2443 )),
2444 )));
2445 }
2446 Ok(Err(e)) => {
2447 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2448 self.metrics_tx.send(crate::metrics::MetricEvent {
2449 ts: crate::metrics::unix_ms(),
2450 tool: "edit_replace",
2451 duration_ms: dur,
2452 output_chars: 0,
2453 param_path_depth: crate::metrics::path_component_count(¶m_path),
2454 max_depth: None,
2455 result: "error",
2456 error_type: Some("internal_error".to_string()),
2457 session_id: sid.clone(),
2458 seq: Some(seq),
2459 cache_hit: None,
2460 });
2461 return Ok(err_to_tool_result(ErrorData::new(
2462 rmcp::model::ErrorCode::INTERNAL_ERROR,
2463 e.to_string(),
2464 Some(error_meta(
2465 "resource",
2466 false,
2467 "check file path and permissions",
2468 )),
2469 )));
2470 }
2471 Err(e) => {
2472 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2473 self.metrics_tx.send(crate::metrics::MetricEvent {
2474 ts: crate::metrics::unix_ms(),
2475 tool: "edit_replace",
2476 duration_ms: dur,
2477 output_chars: 0,
2478 param_path_depth: crate::metrics::path_component_count(¶m_path),
2479 max_depth: None,
2480 result: "error",
2481 error_type: Some("internal_error".to_string()),
2482 session_id: sid.clone(),
2483 seq: Some(seq),
2484 cache_hit: None,
2485 });
2486 return Ok(err_to_tool_result(ErrorData::new(
2487 rmcp::model::ErrorCode::INTERNAL_ERROR,
2488 e.to_string(),
2489 Some(error_meta(
2490 "resource",
2491 false,
2492 "check file path and permissions",
2493 )),
2494 )));
2495 }
2496 };
2497
2498 let text = format!(
2499 "Edited {}: {} bytes -> {} bytes",
2500 output.path, output.bytes_before, output.bytes_after
2501 );
2502 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2503 .with_meta(Some(no_cache_meta()));
2504 let structured = match serde_json::to_value(&output).map_err(|e| {
2505 ErrorData::new(
2506 rmcp::model::ErrorCode::INTERNAL_ERROR,
2507 format!("serialization failed: {e}"),
2508 Some(error_meta("internal", false, "report this as a bug")),
2509 )
2510 }) {
2511 Ok(v) => v,
2512 Err(e) => return Ok(err_to_tool_result(e)),
2513 };
2514 result.structured_content = Some(structured);
2515 self.cache
2516 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2517 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2518 self.metrics_tx.send(crate::metrics::MetricEvent {
2519 ts: crate::metrics::unix_ms(),
2520 tool: "edit_replace",
2521 duration_ms: dur,
2522 output_chars: text.len(),
2523 param_path_depth: crate::metrics::path_component_count(¶m_path),
2524 max_depth: None,
2525 result: "ok",
2526 error_type: None,
2527 session_id: sid,
2528 seq: Some(seq),
2529 cache_hit: None,
2530 });
2531 Ok(result)
2532 }
2533
2534 #[instrument(skip(self, _context))]
2535 #[tool(
2536 name = "edit_rename",
2537 title = "Edit Rename",
2538 description = "AST-aware rename within a single file. Matches syntactic identifiers only; occurrences in string literals and comments are excluded. Returns path, old_name, new_name, occurrences_renamed. Fails if old_name not found; fails if kind parameter supplied (reserved, not yet supported). Supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#. Example queries: Rename function parse_config to load_config in src/config.rs.",
2539 output_schema = schema_for_type::<EditRenameOutput>(),
2540 annotations(
2541 title = "Edit Rename",
2542 read_only_hint = false,
2543 destructive_hint = true,
2544 idempotent_hint = false,
2545 open_world_hint = false
2546 )
2547 )]
2548 async fn edit_rename(
2549 &self,
2550 params: Parameters<EditRenameParams>,
2551 _context: RequestContext<RoleServer>,
2552 ) -> Result<CallToolResult, ErrorData> {
2553 let params = params.0;
2554 let _validated_path = match validate_path(¶ms.path, true) {
2555 Ok(p) => p,
2556 Err(e) => return Ok(err_to_tool_result(e)),
2557 };
2558 let t_start = std::time::Instant::now();
2559 let param_path = params.path.clone();
2560 let seq = self
2561 .session_call_seq
2562 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2563 let sid = self.session_id.lock().await.clone();
2564
2565 if std::fs::metadata(¶ms.path)
2567 .map(|m| m.is_dir())
2568 .unwrap_or(false)
2569 {
2570 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2571 self.metrics_tx.send(crate::metrics::MetricEvent {
2572 ts: crate::metrics::unix_ms(),
2573 tool: "edit_rename",
2574 duration_ms: dur,
2575 output_chars: 0,
2576 param_path_depth: crate::metrics::path_component_count(¶m_path),
2577 max_depth: None,
2578 result: "error",
2579 error_type: Some("invalid_params".to_string()),
2580 session_id: sid.clone(),
2581 seq: Some(seq),
2582 cache_hit: None,
2583 });
2584 return Ok(err_to_tool_result(ErrorData::new(
2585 rmcp::model::ErrorCode::INVALID_PARAMS,
2586 "edit_rename operates on a single file — provide a file path, not a directory"
2587 .to_string(),
2588 Some(error_meta(
2589 "validation",
2590 false,
2591 "provide a file path, not a directory",
2592 )),
2593 )));
2594 }
2595
2596 let path = std::path::PathBuf::from(¶ms.path);
2597 let old_name = params.old_name.clone();
2598 let new_name = params.new_name.clone();
2599 let kind = params.kind.clone();
2600 let handle = tokio::task::spawn_blocking(move || {
2601 edit_rename_in_file(&path, &old_name, &new_name, kind.as_deref())
2602 });
2603
2604 let output = match handle.await {
2605 Ok(Ok(v)) => v,
2606 Ok(Err(aptu_coder_core::EditError::SymbolNotFound { .. })) => {
2607 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2608 self.metrics_tx.send(crate::metrics::MetricEvent {
2609 ts: crate::metrics::unix_ms(),
2610 tool: "edit_rename",
2611 duration_ms: dur,
2612 output_chars: 0,
2613 param_path_depth: crate::metrics::path_component_count(¶m_path),
2614 max_depth: None,
2615 result: "error",
2616 error_type: Some("invalid_params".to_string()),
2617 session_id: sid.clone(),
2618 seq: Some(seq),
2619 cache_hit: None,
2620 });
2621 return Ok(err_to_tool_result(ErrorData::new(
2622 rmcp::model::ErrorCode::INVALID_PARAMS,
2623 "symbol not found in file".to_string(),
2624 Some(error_meta(
2625 "validation",
2626 false,
2627 "verify the symbol name and file path",
2628 )),
2629 )));
2630 }
2631 Ok(Err(aptu_coder_core::EditError::AmbiguousKind { .. })) => {
2632 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2633 self.metrics_tx.send(crate::metrics::MetricEvent {
2634 ts: crate::metrics::unix_ms(),
2635 tool: "edit_rename",
2636 duration_ms: dur,
2637 output_chars: 0,
2638 param_path_depth: crate::metrics::path_component_count(¶m_path),
2639 max_depth: None,
2640 result: "error",
2641 error_type: Some("invalid_params".to_string()),
2642 session_id: sid.clone(),
2643 seq: Some(seq),
2644 cache_hit: None,
2645 });
2646 return Ok(err_to_tool_result(ErrorData::new(
2647 rmcp::model::ErrorCode::INVALID_PARAMS,
2648 "symbol name is ambiguous".to_string(),
2649 Some(error_meta(
2650 "validation",
2651 false,
2652 "verify the symbol name is unique",
2653 )),
2654 )));
2655 }
2656 Ok(Err(aptu_coder_core::EditError::UnsupportedLanguage(_))) => {
2657 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2658 self.metrics_tx.send(crate::metrics::MetricEvent {
2659 ts: crate::metrics::unix_ms(),
2660 tool: "edit_rename",
2661 duration_ms: dur,
2662 output_chars: 0,
2663 param_path_depth: crate::metrics::path_component_count(¶m_path),
2664 max_depth: None,
2665 result: "error",
2666 error_type: Some("invalid_params".to_string()),
2667 session_id: sid.clone(),
2668 seq: Some(seq),
2669 cache_hit: None,
2670 });
2671 return Ok(err_to_tool_result(ErrorData::new(
2672 rmcp::model::ErrorCode::INVALID_PARAMS,
2673 "file language is not supported".to_string(),
2674 Some(error_meta(
2675 "validation",
2676 false,
2677 "check that the file has a supported language extension",
2678 )),
2679 )));
2680 }
2681 Ok(Err(aptu_coder_core::EditError::KindFilterUnsupported)) => {
2682 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2683 self.metrics_tx.send(crate::metrics::MetricEvent {
2684 ts: crate::metrics::unix_ms(),
2685 tool: "edit_rename",
2686 duration_ms: dur,
2687 output_chars: 0,
2688 param_path_depth: crate::metrics::path_component_count(¶m_path),
2689 max_depth: None,
2690 result: "error",
2691 error_type: Some("invalid_params".to_string()),
2692 session_id: sid.clone(),
2693 seq: Some(seq),
2694 cache_hit: None,
2695 });
2696 return Ok(err_to_tool_result(ErrorData::new(
2697 rmcp::model::ErrorCode::INVALID_PARAMS,
2698 "kind filtering is not supported with the current identifier query infrastructure"
2699 .to_string(),
2700 Some(error_meta(
2701 "validation",
2702 false,
2703 "omit the kind parameter",
2704 )),
2705 )));
2706 }
2707 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2708 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2709 self.metrics_tx.send(crate::metrics::MetricEvent {
2710 ts: crate::metrics::unix_ms(),
2711 tool: "edit_rename",
2712 duration_ms: dur,
2713 output_chars: 0,
2714 param_path_depth: crate::metrics::path_component_count(¶m_path),
2715 max_depth: None,
2716 result: "error",
2717 error_type: Some("invalid_params".to_string()),
2718 session_id: sid.clone(),
2719 seq: Some(seq),
2720 cache_hit: None,
2721 });
2722 return Ok(err_to_tool_result(ErrorData::new(
2723 rmcp::model::ErrorCode::INVALID_PARAMS,
2724 "path is a directory".to_string(),
2725 Some(error_meta(
2726 "validation",
2727 false,
2728 "provide a file path, not a directory",
2729 )),
2730 )));
2731 }
2732 Ok(Err(e)) => {
2733 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2734 self.metrics_tx.send(crate::metrics::MetricEvent {
2735 ts: crate::metrics::unix_ms(),
2736 tool: "edit_rename",
2737 duration_ms: dur,
2738 output_chars: 0,
2739 param_path_depth: crate::metrics::path_component_count(¶m_path),
2740 max_depth: None,
2741 result: "error",
2742 error_type: Some("internal_error".to_string()),
2743 session_id: sid.clone(),
2744 seq: Some(seq),
2745 cache_hit: None,
2746 });
2747 return Ok(err_to_tool_result(ErrorData::new(
2748 rmcp::model::ErrorCode::INTERNAL_ERROR,
2749 e.to_string(),
2750 Some(error_meta(
2751 "resource",
2752 false,
2753 "check file path and permissions",
2754 )),
2755 )));
2756 }
2757 Err(e) => {
2758 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2759 self.metrics_tx.send(crate::metrics::MetricEvent {
2760 ts: crate::metrics::unix_ms(),
2761 tool: "edit_rename",
2762 duration_ms: dur,
2763 output_chars: 0,
2764 param_path_depth: crate::metrics::path_component_count(¶m_path),
2765 max_depth: None,
2766 result: "error",
2767 error_type: Some("internal_error".to_string()),
2768 session_id: sid.clone(),
2769 seq: Some(seq),
2770 cache_hit: None,
2771 });
2772 return Ok(err_to_tool_result(ErrorData::new(
2773 rmcp::model::ErrorCode::INTERNAL_ERROR,
2774 e.to_string(),
2775 Some(error_meta(
2776 "resource",
2777 false,
2778 "check file path and permissions",
2779 )),
2780 )));
2781 }
2782 };
2783
2784 let text = format!(
2785 "Renamed '{}' to '{}' in {} ({} occurrence(s))",
2786 output.old_name, output.new_name, output.path, output.occurrences_renamed
2787 );
2788 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2789 .with_meta(Some(no_cache_meta()));
2790 let structured = match serde_json::to_value(&output).map_err(|e| {
2791 ErrorData::new(
2792 rmcp::model::ErrorCode::INTERNAL_ERROR,
2793 format!("serialization failed: {e}"),
2794 Some(error_meta("internal", false, "report this as a bug")),
2795 )
2796 }) {
2797 Ok(v) => v,
2798 Err(e) => return Ok(err_to_tool_result(e)),
2799 };
2800 result.structured_content = Some(structured);
2801 self.cache
2802 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2803 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2804 self.metrics_tx.send(crate::metrics::MetricEvent {
2805 ts: crate::metrics::unix_ms(),
2806 tool: "edit_rename",
2807 duration_ms: dur,
2808 output_chars: text.len(),
2809 param_path_depth: crate::metrics::path_component_count(¶m_path),
2810 max_depth: None,
2811 result: "ok",
2812 error_type: None,
2813 session_id: sid,
2814 seq: Some(seq),
2815 cache_hit: None,
2816 });
2817 Ok(result)
2818 }
2819
2820 #[instrument(skip(self, _context))]
2821 #[tool(
2822 name = "edit_insert",
2823 title = "Edit Insert",
2824 description = "Insert content immediately before or after a named identifier in a source file. Returns path, symbol_name, position, byte_offset. position is \"before\" or \"after\"; symbol_name must be an identifier (not a keyword or punctuation). Inserts content verbatim at that token's byte boundary; include leading/trailing newlines as needed. Uses first occurrence if symbol_name appears multiple times. Fails if symbol not found; fails if directory path supplied. Supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#. Example queries: Insert a #[instrument] attribute before the handle_request function.",
2825 output_schema = schema_for_type::<EditInsertOutput>(),
2826 annotations(
2827 title = "Edit Insert",
2828 read_only_hint = false,
2829 destructive_hint = true,
2830 idempotent_hint = false,
2831 open_world_hint = false
2832 )
2833 )]
2834 async fn edit_insert(
2835 &self,
2836 params: Parameters<EditInsertParams>,
2837 _context: RequestContext<RoleServer>,
2838 ) -> Result<CallToolResult, ErrorData> {
2839 let params = params.0;
2840 let _validated_path = match validate_path(¶ms.path, true) {
2841 Ok(p) => p,
2842 Err(e) => return Ok(err_to_tool_result(e)),
2843 };
2844 let t_start = std::time::Instant::now();
2845 let param_path = params.path.clone();
2846 let seq = self
2847 .session_call_seq
2848 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2849 let sid = self.session_id.lock().await.clone();
2850
2851 if std::fs::metadata(¶ms.path)
2853 .map(|m| m.is_dir())
2854 .unwrap_or(false)
2855 {
2856 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2857 self.metrics_tx.send(crate::metrics::MetricEvent {
2858 ts: crate::metrics::unix_ms(),
2859 tool: "edit_insert",
2860 duration_ms: dur,
2861 output_chars: 0,
2862 param_path_depth: crate::metrics::path_component_count(¶m_path),
2863 max_depth: None,
2864 result: "error",
2865 error_type: Some("invalid_params".to_string()),
2866 session_id: sid.clone(),
2867 seq: Some(seq),
2868 cache_hit: None,
2869 });
2870 return Ok(err_to_tool_result(ErrorData::new(
2871 rmcp::model::ErrorCode::INVALID_PARAMS,
2872 "edit_insert operates on a single file — provide a file path, not a directory"
2873 .to_string(),
2874 Some(error_meta(
2875 "validation",
2876 false,
2877 "provide a file path, not a directory",
2878 )),
2879 )));
2880 }
2881
2882 let path = std::path::PathBuf::from(¶ms.path);
2883 let symbol_name = params.symbol_name.clone();
2884 let position = params.position;
2885 let content = params.content.clone();
2886 let handle = tokio::task::spawn_blocking(move || {
2887 edit_insert_at_symbol(&path, &symbol_name, position, &content)
2888 });
2889
2890 let output = match handle.await {
2891 Ok(Ok(v)) => v,
2892 Ok(Err(aptu_coder_core::EditError::SymbolNotFound { .. })) => {
2893 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2894 self.metrics_tx.send(crate::metrics::MetricEvent {
2895 ts: crate::metrics::unix_ms(),
2896 tool: "edit_insert",
2897 duration_ms: dur,
2898 output_chars: 0,
2899 param_path_depth: crate::metrics::path_component_count(¶m_path),
2900 max_depth: None,
2901 result: "error",
2902 error_type: Some("invalid_params".to_string()),
2903 session_id: sid.clone(),
2904 seq: Some(seq),
2905 cache_hit: None,
2906 });
2907 return Ok(err_to_tool_result(ErrorData::new(
2908 rmcp::model::ErrorCode::INVALID_PARAMS,
2909 "symbol not found in file".to_string(),
2910 Some(error_meta(
2911 "validation",
2912 false,
2913 "verify the symbol name and file path",
2914 )),
2915 )));
2916 }
2917 Ok(Err(aptu_coder_core::EditError::UnsupportedLanguage(_))) => {
2918 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2919 self.metrics_tx.send(crate::metrics::MetricEvent {
2920 ts: crate::metrics::unix_ms(),
2921 tool: "edit_insert",
2922 duration_ms: dur,
2923 output_chars: 0,
2924 param_path_depth: crate::metrics::path_component_count(¶m_path),
2925 max_depth: None,
2926 result: "error",
2927 error_type: Some("invalid_params".to_string()),
2928 session_id: sid.clone(),
2929 seq: Some(seq),
2930 cache_hit: None,
2931 });
2932 return Ok(err_to_tool_result(ErrorData::new(
2933 rmcp::model::ErrorCode::INVALID_PARAMS,
2934 "file language is not supported".to_string(),
2935 Some(error_meta(
2936 "validation",
2937 false,
2938 "check that the file has a supported language extension",
2939 )),
2940 )));
2941 }
2942 Ok(Err(e)) => {
2943 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2944 self.metrics_tx.send(crate::metrics::MetricEvent {
2945 ts: crate::metrics::unix_ms(),
2946 tool: "edit_insert",
2947 duration_ms: dur,
2948 output_chars: 0,
2949 param_path_depth: crate::metrics::path_component_count(¶m_path),
2950 max_depth: None,
2951 result: "error",
2952 error_type: Some("internal_error".to_string()),
2953 session_id: sid.clone(),
2954 seq: Some(seq),
2955 cache_hit: None,
2956 });
2957 return Ok(err_to_tool_result(ErrorData::new(
2958 rmcp::model::ErrorCode::INTERNAL_ERROR,
2959 e.to_string(),
2960 Some(error_meta(
2961 "resource",
2962 false,
2963 "check file path and permissions",
2964 )),
2965 )));
2966 }
2967 Err(e) => {
2968 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2969 self.metrics_tx.send(crate::metrics::MetricEvent {
2970 ts: crate::metrics::unix_ms(),
2971 tool: "edit_insert",
2972 duration_ms: dur,
2973 output_chars: 0,
2974 param_path_depth: crate::metrics::path_component_count(¶m_path),
2975 max_depth: None,
2976 result: "error",
2977 error_type: Some("internal_error".to_string()),
2978 session_id: sid.clone(),
2979 seq: Some(seq),
2980 cache_hit: None,
2981 });
2982 return Ok(err_to_tool_result(ErrorData::new(
2983 rmcp::model::ErrorCode::INTERNAL_ERROR,
2984 e.to_string(),
2985 Some(error_meta(
2986 "resource",
2987 false,
2988 "check file path and permissions",
2989 )),
2990 )));
2991 }
2992 };
2993
2994 let text = format!(
2995 "Inserted content {} '{}' in {} (at byte offset {})",
2996 output.position, output.symbol_name, output.path, output.byte_offset
2997 );
2998 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2999 .with_meta(Some(no_cache_meta()));
3000 let structured = match serde_json::to_value(&output).map_err(|e| {
3001 ErrorData::new(
3002 rmcp::model::ErrorCode::INTERNAL_ERROR,
3003 format!("serialization failed: {e}"),
3004 Some(error_meta("internal", false, "report this as a bug")),
3005 )
3006 }) {
3007 Ok(v) => v,
3008 Err(e) => return Ok(err_to_tool_result(e)),
3009 };
3010 result.structured_content = Some(structured);
3011 self.cache
3012 .invalidate_file(&std::path::PathBuf::from(¶m_path));
3013 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3014 self.metrics_tx.send(crate::metrics::MetricEvent {
3015 ts: crate::metrics::unix_ms(),
3016 tool: "edit_insert",
3017 duration_ms: dur,
3018 output_chars: text.len(),
3019 param_path_depth: crate::metrics::path_component_count(¶m_path),
3020 max_depth: None,
3021 result: "ok",
3022 error_type: None,
3023 session_id: sid,
3024 seq: Some(seq),
3025 cache_hit: None,
3026 });
3027 Ok(result)
3028 }
3029
3030 #[tool(
3031 name = "exec_command",
3032 title = "Exec Command",
3033 description = "Execute shell command via sh -c (or $SHELL if set). Returns stdout, stderr, interleaved, exit_code, timed_out, output_truncated. Output capped at 2000 lines and 50 KB per stream; use timeout_secs to limit execution time. working_dir sets initial working directory; cd and absolute paths in command string bypass this restriction. Fails if working_dir does not exist, is not a directory, or is outside CWD. Example queries: Run the test suite and capture output.",
3034 output_schema = schema_for_type::<types::ShellOutput>(),
3035 annotations(
3036 title = "Exec Command",
3037 read_only_hint = false,
3038 destructive_hint = true,
3039 idempotent_hint = false,
3040 open_world_hint = true
3041 )
3042 )]
3043 #[instrument(skip(self, context))]
3044 pub async fn exec_command(
3045 &self,
3046 params: Parameters<types::ExecCommandParams>,
3047 context: RequestContext<RoleServer>,
3048 ) -> Result<CallToolResult, ErrorData> {
3049 let t_start = std::time::Instant::now();
3050 let params = params.0;
3051
3052 let working_dir_path = if let Some(ref wd) = params.working_dir {
3054 match validate_path(wd, true) {
3055 Ok(p) => {
3056 if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
3058 return Ok(err_to_tool_result(ErrorData::new(
3059 rmcp::model::ErrorCode::INVALID_PARAMS,
3060 "working_dir must be a directory".to_string(),
3061 Some(error_meta(
3062 "validation",
3063 false,
3064 "provide a valid directory path",
3065 )),
3066 )));
3067 }
3068 Some(p)
3069 }
3070 Err(e) => {
3071 return Ok(err_to_tool_result(e));
3072 }
3073 }
3074 } else {
3075 None
3076 };
3077
3078 let param_path = params.working_dir.clone();
3079 let seq = self
3080 .session_call_seq
3081 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3082 let sid = self.session_id.lock().await.clone();
3083
3084 let command = params.command.clone();
3085 let timeout_secs = params.timeout_secs.unwrap_or(30);
3086
3087 let peer = self.peer.lock().await.clone();
3089 let progress_token = context.meta.get_progress_token();
3090
3091 let progress_handle: Option<tokio::task::JoinHandle<()>> = if timeout_secs > 10 {
3093 if let (Some(token), Some(peer_conn)) = (progress_token.clone(), peer.clone()) {
3094 let self_clone = self.clone();
3095 Some(tokio::spawn(async move {
3096 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
3097 interval.tick().await; let mut tick = 0u64;
3099 loop {
3100 interval.tick().await;
3101 tick += 1;
3102 let progress = ((tick * 5) as f64 / timeout_secs as f64).min(0.99);
3103 self_clone
3104 .emit_progress(
3105 Some(peer_conn.clone()),
3106 &token,
3107 progress,
3108 1.0,
3109 "command running".to_string(),
3110 )
3111 .await;
3112 }
3113 }))
3114 } else {
3115 None
3116 }
3117 } else {
3118 None
3119 };
3120
3121 let shell = resolve_shell();
3123
3124 let mut cmd = tokio::process::Command::new(shell);
3125 cmd.arg("-c").arg(&command);
3126
3127 if let Some(ref wd) = working_dir_path {
3128 cmd.current_dir(wd);
3129 }
3130
3131 cmd.stdout(std::process::Stdio::piped())
3132 .stderr(std::process::Stdio::piped());
3133
3134 #[cfg(unix)]
3135 {
3136 let memory_limit_mb = params.memory_limit_mb;
3137 let cpu_limit_secs = params.cpu_limit_secs;
3138 #[cfg(not(target_os = "linux"))]
3139 if memory_limit_mb.is_some() {
3140 warn!("memory_limit_mb is not enforced on this platform (Linux only)");
3141 }
3142 if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
3143 unsafe {
3147 cmd.pre_exec(move || {
3148 #[cfg(target_os = "linux")]
3149 if let Some(mb) = memory_limit_mb {
3150 let bytes = mb.saturating_mul(1024 * 1024);
3151 setrlimit(Resource::RLIMIT_AS, bytes, bytes)
3152 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3153 }
3154 if let Some(cpu) = cpu_limit_secs {
3155 setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
3156 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3157 }
3158 Ok(())
3159 });
3160 }
3161 }
3162 }
3163
3164 let mut child = match cmd.spawn() {
3165 Ok(c) => c,
3166 Err(e) => {
3167 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3168 self.metrics_tx.send(crate::metrics::MetricEvent {
3169 ts: crate::metrics::unix_ms(),
3170 tool: "exec_command",
3171 duration_ms: dur,
3172 output_chars: 0,
3173 param_path_depth: crate::metrics::path_component_count(
3174 param_path.as_deref().unwrap_or(""),
3175 ),
3176 max_depth: None,
3177 result: "error",
3178 error_type: Some("internal_error".to_string()),
3179 session_id: sid.clone(),
3180 seq: Some(seq),
3181 cache_hit: None,
3182 });
3183 return Ok(err_to_tool_result(ErrorData::new(
3184 rmcp::model::ErrorCode::INTERNAL_ERROR,
3185 format!("failed to spawn command: {e}"),
3186 Some(error_meta(
3187 "resource",
3188 false,
3189 "check command syntax and permissions",
3190 )),
3191 )));
3192 }
3193 };
3194
3195 let timeout_duration = std::time::Duration::from_secs(timeout_secs);
3197 const MAX_BYTES: usize = 50 * 1024;
3198
3199 let stdout_pipe = child.stdout.take();
3200 let stderr_pipe = child.stderr.take();
3201
3202 use std::sync::Arc;
3203 use tokio::io::AsyncBufReadExt as _;
3204 use tokio::sync::Mutex as TokioMutex;
3205 use tokio_stream::StreamExt as TokioStreamExt;
3206 use tokio_stream::wrappers::LinesStream;
3207
3208 let stdout_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
3211 let stderr_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
3212 let interleaved_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
3213
3214 let so_acc = Arc::clone(&stdout_shared);
3215 let se_acc = Arc::clone(&stderr_shared);
3216 let il_acc = Arc::clone(&interleaved_shared);
3217
3218 let mut drain_task = tokio::spawn(async move {
3221 let mut so_bytes = 0usize;
3222 let mut se_bytes = 0usize;
3223 let mut il_bytes = 0usize;
3224
3225 let so_stream = stdout_pipe.map(|p| {
3226 LinesStream::new(tokio::io::BufReader::new(p).lines())
3227 .map(|l| l.map(|s| (false, s)))
3228 });
3229 let se_stream = stderr_pipe.map(|p| {
3230 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3231 });
3232
3233 match (so_stream, se_stream) {
3234 (Some(so), Some(se)) => {
3235 let mut merged = so.merge(se);
3236 while let Some(item) = merged.next().await {
3237 if let Ok((is_stderr, line)) = item {
3238 let entry = format!("{line}\n");
3239 if is_stderr {
3240 if se_bytes < MAX_BYTES {
3241 se_bytes += entry.len();
3242 se_acc.lock().await.push_str(&entry);
3243 if il_bytes < 2 * MAX_BYTES {
3244 il_bytes += entry.len();
3245 il_acc.lock().await.push_str(&entry);
3246 }
3247 }
3248 } else if so_bytes < MAX_BYTES {
3249 so_bytes += entry.len();
3250 so_acc.lock().await.push_str(&entry);
3251 if il_bytes < 2 * MAX_BYTES {
3252 il_bytes += entry.len();
3253 il_acc.lock().await.push_str(&entry);
3254 }
3255 }
3256 }
3257 }
3258 }
3259 (Some(so), None) => {
3260 let mut stream = so;
3261 while let Some(item) = stream.next().await {
3262 if let Ok((_, line)) = item
3263 && so_bytes < MAX_BYTES
3264 {
3265 let entry = format!("{line}\n");
3266 so_bytes += entry.len();
3267 so_acc.lock().await.push_str(&entry);
3268 if il_bytes < 2 * MAX_BYTES {
3269 il_bytes += entry.len();
3270 il_acc.lock().await.push_str(&entry);
3271 }
3272 }
3273 }
3274 }
3275 (None, Some(se)) => {
3276 let mut stream = se;
3277 while let Some(item) = stream.next().await {
3278 if let Ok((_, line)) = item
3279 && se_bytes < MAX_BYTES
3280 {
3281 let entry = format!("{line}\n");
3282 se_bytes += entry.len();
3283 se_acc.lock().await.push_str(&entry);
3284 if il_bytes < 2 * MAX_BYTES {
3285 il_bytes += entry.len();
3286 il_acc.lock().await.push_str(&entry);
3287 }
3288 }
3289 }
3290 }
3291 (None, None) => {}
3292 }
3293 });
3294
3295 let (exit_code, timed_out, mut output_truncated, output_collection_error) = tokio::select! {
3296 _ = &mut drain_task => {
3297 let (status, drain_truncated) = match tokio::time::timeout(
3300 std::time::Duration::from_millis(500),
3301 child.wait()
3302 ).await {
3303 Ok(Ok(s)) => (Some(s), false),
3304 Ok(Err(_)) => (None, false),
3305 Err(_) => {
3306 child.start_kill().ok();
3307 let _ = child.wait().await;
3308 (None, true)
3309 }
3310 };
3311 let exit_code = status.and_then(|s| s.code());
3312 let ocerr = if drain_truncated {
3313 Some("post-exit drain timeout: background process held pipes".to_string())
3314 } else {
3315 None
3316 };
3317 (exit_code, false, drain_truncated, ocerr)
3318 }
3319 _ = tokio::time::sleep(timeout_duration) => {
3320 let _ = child.kill().await;
3322 let _ = child.wait().await;
3323 drain_task.abort();
3324 (None, true, false, None)
3326 }
3327 };
3328
3329 if let Some(handle) = progress_handle {
3331 handle.abort();
3332 }
3333
3334 let stdout_str = std::mem::take(&mut *stdout_shared.lock().await);
3336 let stderr_str = std::mem::take(&mut *stderr_shared.lock().await);
3337 let interleaved_str = std::mem::take(&mut *interleaved_shared.lock().await);
3338
3339 let slot = seq % 8;
3341 let (stdout, stderr, overflow_notice) =
3342 handle_output_overflow(stdout_str, stderr_str, slot);
3343 output_truncated = output_truncated || overflow_notice.is_some();
3344
3345 let mut output = types::ShellOutput::new(
3346 stdout,
3347 stderr,
3348 interleaved_str,
3349 exit_code,
3350 timed_out,
3351 output_truncated,
3352 );
3353 output.output_collection_error = output_collection_error;
3354
3355 let output_text = if output.interleaved.is_empty() {
3357 format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
3358 } else {
3359 format!("Output:\n{}", output.interleaved)
3360 };
3361
3362 let text = format!(
3363 "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
3364 params.command,
3365 exit_code
3366 .map(|c| c.to_string())
3367 .unwrap_or_else(|| "null".to_string()),
3368 timed_out,
3369 output_truncated,
3370 output_text,
3371 );
3372
3373 let mut content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
3374 if let Some(notice) = overflow_notice {
3375 content_blocks.push(Content::text(notice).with_priority(0.0));
3376 }
3377
3378 let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
3383
3384 let mut result = if command_failed {
3385 CallToolResult::error(content_blocks)
3386 } else {
3387 CallToolResult::success(content_blocks)
3388 }
3389 .with_meta(Some(no_cache_meta()));
3390
3391 let structured = match serde_json::to_value(&output).map_err(|e| {
3392 ErrorData::new(
3393 rmcp::model::ErrorCode::INTERNAL_ERROR,
3394 format!("serialization failed: {e}"),
3395 Some(error_meta("internal", false, "report this as a bug")),
3396 )
3397 }) {
3398 Ok(v) => v,
3399 Err(e) => {
3400 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3401 self.metrics_tx.send(crate::metrics::MetricEvent {
3402 ts: crate::metrics::unix_ms(),
3403 tool: "exec_command",
3404 duration_ms: dur,
3405 output_chars: 0,
3406 param_path_depth: crate::metrics::path_component_count(
3407 param_path.as_deref().unwrap_or(""),
3408 ),
3409 max_depth: None,
3410 result: "error",
3411 error_type: Some("internal_error".to_string()),
3412 session_id: sid.clone(),
3413 seq: Some(seq),
3414 cache_hit: None,
3415 });
3416 return Ok(err_to_tool_result(e));
3417 }
3418 };
3419
3420 result.structured_content = Some(structured);
3421 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3422 self.metrics_tx.send(crate::metrics::MetricEvent {
3423 ts: crate::metrics::unix_ms(),
3424 tool: "exec_command",
3425 duration_ms: dur,
3426 output_chars: text.len(),
3427 param_path_depth: crate::metrics::path_component_count(
3428 param_path.as_deref().unwrap_or(""),
3429 ),
3430 max_depth: None,
3431 result: "ok",
3432 error_type: None,
3433 session_id: sid,
3434 seq: Some(seq),
3435 cache_hit: None,
3436 });
3437 Ok(result)
3438 }
3439}
3440
3441fn handle_output_overflow(
3447 stdout: String,
3448 stderr: String,
3449 slot: u32,
3450) -> (String, String, Option<String>) {
3451 const MAX_OUTPUT_LINES: usize = 2000;
3452 const OVERFLOW_PREVIEW_LINES: usize = 50;
3453
3454 let stdout_lines: Vec<&str> = stdout.lines().collect();
3455 let stderr_lines: Vec<&str> = stderr.lines().collect();
3456
3457 if stdout_lines.len() <= MAX_OUTPUT_LINES && stderr_lines.len() <= MAX_OUTPUT_LINES {
3458 return (stdout, stderr, None);
3459 }
3460
3461 let base = std::env::temp_dir()
3463 .join("aptu-coder-overflow")
3464 .join(format!("slot-{slot}"));
3465 let _ = std::fs::create_dir_all(&base);
3466
3467 let stdout_path = base.join("stdout");
3468 let stderr_path = base.join("stderr");
3469
3470 let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3471 let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3472
3473 let stdout_preview = if stdout_lines.len() > MAX_OUTPUT_LINES {
3475 stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3476 } else {
3477 stdout
3478 };
3479 let stderr_preview = if stderr_lines.len() > MAX_OUTPUT_LINES {
3480 stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3481 } else {
3482 stderr
3483 };
3484
3485 let notice = format!(
3486 "Output exceeded {MAX_OUTPUT_LINES} lines and was saved to:\n stdout: {}\n stderr: {}\nThe last {OVERFLOW_PREVIEW_LINES} lines are included above. To read the full output:\n cat {}",
3487 stdout_path.display(),
3488 stderr_path.display(),
3489 stdout_path.display(),
3490 );
3491
3492 (stdout_preview, stderr_preview, Some(notice))
3493}
3494
3495#[derive(Clone)]
3499struct FocusedAnalysisParams {
3500 path: std::path::PathBuf,
3501 symbol: String,
3502 match_mode: SymbolMatchMode,
3503 follow_depth: u32,
3504 max_depth: Option<u32>,
3505 ast_recursion_limit: Option<usize>,
3506 use_summary: bool,
3507 impl_only: Option<bool>,
3508 def_use: bool,
3509}
3510
3511#[tool_handler]
3512impl ServerHandler for CodeAnalyzer {
3513 fn get_info(&self) -> InitializeResult {
3514 let excluded = crate::EXCLUDED_DIRS.join(", ");
3515 let instructions = format!(
3516 "Recommended workflow:\n\
3517 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3518 2. Re-run analyze_directory(path=<source_package>, max_depth=2, summary=true) for module map. Include test directories (tests/, *_test.go, test_*.py, test_*.rs, *.spec.ts, *.spec.js).\n\
3519 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3520 4. Use analyze_symbol to trace call graphs.\n\
3521 Prefer summary=true on 1000+ files. Set max_depth=2; increase if packages too large. Paginate with cursor/page_size. For subagents: DISABLE_PROMPT_CACHING=1."
3522 );
3523 let capabilities = ServerCapabilities::builder()
3524 .enable_logging()
3525 .enable_tools()
3526 .enable_tool_list_changed()
3527 .enable_completions()
3528 .build();
3529 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3530 .with_title("Aptu Coder")
3531 .with_description("MCP server for code structure analysis using tree-sitter");
3532 InitializeResult::new(capabilities)
3533 .with_server_info(server_info)
3534 .with_instructions(&instructions)
3535 }
3536
3537 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
3538 let mut peer_lock = self.peer.lock().await;
3539 *peer_lock = Some(context.peer.clone());
3540 drop(peer_lock);
3541
3542 let millis = std::time::SystemTime::now()
3544 .duration_since(std::time::UNIX_EPOCH)
3545 .unwrap_or_default()
3546 .as_millis()
3547 .try_into()
3548 .unwrap_or(u64::MAX);
3549 let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3550 let sid = format!("{millis}-{counter}");
3551 {
3552 let mut session_id_lock = self.session_id.lock().await;
3553 *session_id_lock = Some(sid);
3554 }
3555 self.session_call_seq
3556 .store(0, std::sync::atomic::Ordering::Relaxed);
3557
3558 let peer = self.peer.clone();
3560 let event_rx = self.event_rx.clone();
3561
3562 tokio::spawn(async move {
3563 let rx = {
3564 let mut rx_lock = event_rx.lock().await;
3565 rx_lock.take()
3566 };
3567
3568 if let Some(mut receiver) = rx {
3569 let mut buffer = Vec::with_capacity(64);
3570 loop {
3571 receiver.recv_many(&mut buffer, 64).await;
3573
3574 if buffer.is_empty() {
3575 break;
3577 }
3578
3579 let peer_lock = peer.lock().await;
3581 if let Some(peer) = peer_lock.as_ref() {
3582 for log_event in buffer.drain(..) {
3583 let notification = ServerNotification::LoggingMessageNotification(
3584 Notification::new(LoggingMessageNotificationParam {
3585 level: log_event.level,
3586 logger: Some(log_event.logger),
3587 data: log_event.data,
3588 }),
3589 );
3590 if let Err(e) = peer.send_notification(notification).await {
3591 warn!("Failed to send logging notification: {}", e);
3592 }
3593 }
3594 }
3595 }
3596 }
3597 });
3598 }
3599
3600 #[instrument(skip(self, _context))]
3601 async fn on_cancelled(
3602 &self,
3603 notification: CancelledNotificationParam,
3604 _context: NotificationContext<RoleServer>,
3605 ) {
3606 tracing::info!(
3607 request_id = ?notification.request_id,
3608 reason = ?notification.reason,
3609 "Received cancellation notification"
3610 );
3611 }
3612
3613 #[instrument(skip(self, _context))]
3614 async fn complete(
3615 &self,
3616 request: CompleteRequestParams,
3617 _context: RequestContext<RoleServer>,
3618 ) -> Result<CompleteResult, ErrorData> {
3619 let argument_name = &request.argument.name;
3621 let argument_value = &request.argument.value;
3622
3623 let completions = match argument_name.as_str() {
3624 "path" => {
3625 let root = Path::new(".");
3627 completion::path_completions(root, argument_value)
3628 }
3629 "symbol" => {
3630 let path_arg = request
3632 .context
3633 .as_ref()
3634 .and_then(|ctx| ctx.get_argument("path"));
3635
3636 match path_arg {
3637 Some(path_str) => {
3638 let path = Path::new(path_str);
3639 completion::symbol_completions(&self.cache, path, argument_value)
3640 }
3641 None => Vec::new(),
3642 }
3643 }
3644 _ => Vec::new(),
3645 };
3646
3647 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
3649 let (values, has_more) = if completions.len() > 100 {
3650 (completions.into_iter().take(100).collect(), true)
3651 } else {
3652 (completions, false)
3653 };
3654
3655 let completion_info =
3656 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
3657 Ok(info) => info,
3658 Err(_) => {
3659 CompletionInfo::with_all_values(Vec::new())
3661 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
3662 }
3663 };
3664
3665 Ok(CompleteResult::new(completion_info))
3666 }
3667
3668 async fn set_level(
3669 &self,
3670 params: SetLevelRequestParams,
3671 _context: RequestContext<RoleServer>,
3672 ) -> Result<(), ErrorData> {
3673 let level_filter = match params.level {
3674 LoggingLevel::Debug => LevelFilter::DEBUG,
3675 LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
3676 LoggingLevel::Warning => LevelFilter::WARN,
3677 LoggingLevel::Error
3678 | LoggingLevel::Critical
3679 | LoggingLevel::Alert
3680 | LoggingLevel::Emergency => LevelFilter::ERROR,
3681 };
3682
3683 let mut filter_lock = self
3684 .log_level_filter
3685 .lock()
3686 .unwrap_or_else(|e| e.into_inner());
3687 *filter_lock = level_filter;
3688 Ok(())
3689 }
3690}
3691
3692#[cfg(test)]
3693mod tests {
3694 use super::*;
3695
3696 #[tokio::test]
3697 async fn test_emit_progress_none_peer_is_noop() {
3698 let peer = Arc::new(TokioMutex::new(None));
3699 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3700 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3701 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3702 let analyzer = CodeAnalyzer::new(
3703 peer,
3704 log_level_filter,
3705 rx,
3706 crate::metrics::MetricsSender(metrics_tx),
3707 );
3708 let token = ProgressToken(NumberOrString::String("test".into()));
3709 analyzer
3711 .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
3712 .await;
3713 }
3714
3715 fn make_analyzer() -> CodeAnalyzer {
3716 let peer = Arc::new(TokioMutex::new(None));
3717 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3718 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3719 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3720 CodeAnalyzer::new(
3721 peer,
3722 log_level_filter,
3723 rx,
3724 crate::metrics::MetricsSender(metrics_tx),
3725 )
3726 }
3727
3728 #[test]
3729 fn test_summary_cursor_conflict() {
3730 assert!(summary_cursor_conflict(Some(true), Some("cursor")));
3731 assert!(!summary_cursor_conflict(Some(true), None));
3732 assert!(!summary_cursor_conflict(None, Some("x")));
3733 assert!(!summary_cursor_conflict(None, None));
3734 }
3735
3736 #[tokio::test]
3737 async fn test_validate_impl_only_non_rust_returns_invalid_params() {
3738 use tempfile::TempDir;
3739
3740 let dir = TempDir::new().unwrap();
3741 std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
3742
3743 let analyzer = make_analyzer();
3744 let entries: Vec<traversal::WalkEntry> =
3747 traversal::walk_directory(dir.path(), None).unwrap_or_default();
3748 let result = CodeAnalyzer::validate_impl_only(&entries);
3749 assert!(result.is_err());
3750 let err = result.unwrap_err();
3751 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
3752 drop(analyzer); }
3754
3755 #[tokio::test]
3756 async fn test_no_cache_meta_on_analyze_directory_result() {
3757 use aptu_coder_core::types::{
3758 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3759 };
3760 use tempfile::TempDir;
3761
3762 let dir = TempDir::new().unwrap();
3763 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
3764
3765 let analyzer = make_analyzer();
3766 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3767 "path": dir.path().to_str().unwrap(),
3768 }))
3769 .unwrap();
3770 let ct = tokio_util::sync::CancellationToken::new();
3771 let (arc_output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
3772 let meta = no_cache_meta();
3774 assert_eq!(
3775 meta.0.get("cache_hint").and_then(|v| v.as_str()),
3776 Some("no-cache"),
3777 );
3778 drop(arc_output);
3779 }
3780
3781 #[test]
3782 fn test_complete_path_completions_returns_suggestions() {
3783 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
3788 let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
3789 let suggestions = completion::path_completions(workspace_root, "aptu-");
3790 assert!(
3791 !suggestions.is_empty(),
3792 "expected completions for prefix 'aptu-' in workspace root"
3793 );
3794 }
3795
3796 #[tokio::test]
3797 async fn test_handle_overview_mode_verbose_no_summary_block() {
3798 use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
3799 use aptu_coder_core::types::{
3800 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3801 };
3802 use tempfile::TempDir;
3803
3804 let tmp = TempDir::new().unwrap();
3805 std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
3806
3807 let peer = Arc::new(TokioMutex::new(None));
3808 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3809 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3810 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3811 let analyzer = CodeAnalyzer::new(
3812 peer,
3813 log_level_filter,
3814 rx,
3815 crate::metrics::MetricsSender(metrics_tx),
3816 );
3817
3818 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3819 "path": tmp.path().to_str().unwrap(),
3820 "verbose": true,
3821 }))
3822 .unwrap();
3823
3824 let ct = tokio_util::sync::CancellationToken::new();
3825 let (output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
3826
3827 let use_summary = output.formatted.len() > SIZE_LIMIT; let paginated =
3830 paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
3831 let verbose = true;
3832 let formatted = if !use_summary {
3833 format_structure_paginated(
3834 &paginated.items,
3835 paginated.total,
3836 params.max_depth,
3837 Some(std::path::Path::new(¶ms.path)),
3838 verbose,
3839 )
3840 } else {
3841 output.formatted.clone()
3842 };
3843
3844 assert!(
3846 !formatted.contains("SUMMARY:"),
3847 "verbose=true must not emit SUMMARY: block; got: {}",
3848 &formatted[..formatted.len().min(300)]
3849 );
3850 assert!(
3851 formatted.contains("PAGINATED:"),
3852 "verbose=true must emit PAGINATED: header"
3853 );
3854 assert!(
3855 formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
3856 "verbose=true must emit FILES section header"
3857 );
3858 }
3859
3860 #[tokio::test]
3863 async fn test_analyze_directory_cache_hit_metrics() {
3864 use aptu_coder_core::types::{
3865 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3866 };
3867 use tempfile::TempDir;
3868
3869 let dir = TempDir::new().unwrap();
3871 std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
3872 let analyzer = make_analyzer();
3873 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3874 "path": dir.path().to_str().unwrap(),
3875 }))
3876 .unwrap();
3877
3878 let ct1 = tokio_util::sync::CancellationToken::new();
3880 let (_out1, hit1) = analyzer.handle_overview_mode(¶ms, ct1).await.unwrap();
3881
3882 let ct2 = tokio_util::sync::CancellationToken::new();
3884 let (_out2, hit2) = analyzer.handle_overview_mode(¶ms, ct2).await.unwrap();
3885
3886 assert!(!hit1, "first call must be a cache miss");
3888 assert!(hit2, "second call must be a cache hit");
3889 }
3890
3891 #[tokio::test]
3892 async fn test_analyze_module_cache_hit_metrics() {
3893 use std::io::Write as _;
3894 use tempfile::NamedTempFile;
3895
3896 let mut f = NamedTempFile::with_suffix(".rs").unwrap();
3898 writeln!(f, "fn bar() {{}}").unwrap();
3899 let path = f.path().to_str().unwrap().to_string();
3900
3901 let analyzer = make_analyzer();
3902
3903 let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
3905 file_params.path = path.clone();
3906 file_params.ast_recursion_limit = None;
3907 file_params.fields = None;
3908 file_params.pagination.cursor = None;
3909 file_params.pagination.page_size = None;
3910 file_params.output_control.summary = None;
3911 file_params.output_control.force = None;
3912 file_params.output_control.verbose = None;
3913 let (_cached, _) = analyzer
3914 .handle_file_details_mode(&file_params)
3915 .await
3916 .unwrap();
3917
3918 let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
3920 module_params.path = path.clone();
3921
3922 let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
3924 meta.modified()
3925 .ok()
3926 .map(|mtime| aptu_coder_core::cache::CacheKey {
3927 path: std::path::PathBuf::from(&path),
3928 modified: mtime,
3929 mode: aptu_coder_core::types::AnalysisMode::FileDetails,
3930 })
3931 });
3932 let cache_hit = module_cache_key
3933 .as_ref()
3934 .and_then(|k| analyzer.cache.get(k))
3935 .is_some();
3936
3937 assert!(
3939 cache_hit,
3940 "analyze_module should find the file in the shared file cache"
3941 );
3942 drop(module_params);
3943 }
3944
3945 #[test]
3948 fn test_analyze_symbol_import_lookup_invalid_params() {
3949 let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
3953
3954 assert!(
3956 result.is_err(),
3957 "import_lookup=true with empty symbol must return Err"
3958 );
3959 let err = result.unwrap_err();
3960 assert_eq!(
3961 err.code,
3962 rmcp::model::ErrorCode::INVALID_PARAMS,
3963 "expected INVALID_PARAMS; got {:?}",
3964 err.code
3965 );
3966 }
3967
3968 #[tokio::test]
3969 async fn test_analyze_symbol_import_lookup_found() {
3970 use tempfile::TempDir;
3971
3972 let dir = TempDir::new().unwrap();
3974 std::fs::write(
3975 dir.path().join("main.rs"),
3976 "use std::collections::HashMap;\nfn main() {}\n",
3977 )
3978 .unwrap();
3979
3980 let entries = traversal::walk_directory(dir.path(), None).unwrap();
3981
3982 let output =
3984 analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
3985
3986 assert!(
3988 output.formatted.contains("MATCHES: 1"),
3989 "expected 1 match; got: {}",
3990 output.formatted
3991 );
3992 assert!(
3993 output.formatted.contains("main.rs"),
3994 "expected main.rs in output; got: {}",
3995 output.formatted
3996 );
3997 }
3998
3999 #[tokio::test]
4000 async fn test_analyze_symbol_import_lookup_empty() {
4001 use tempfile::TempDir;
4002
4003 let dir = TempDir::new().unwrap();
4005 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
4006
4007 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4008
4009 let output =
4011 analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
4012
4013 assert!(
4015 output.formatted.contains("MATCHES: 0"),
4016 "expected 0 matches; got: {}",
4017 output.formatted
4018 );
4019 }
4020
4021 #[tokio::test]
4024 async fn test_analyze_directory_git_ref_non_git_repo() {
4025 use aptu_coder_core::traversal::changed_files_from_git_ref;
4026 use tempfile::TempDir;
4027
4028 let dir = TempDir::new().unwrap();
4030 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4031
4032 let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
4034
4035 assert!(result.is_err(), "non-git dir must return an error");
4037 let err_msg = result.unwrap_err().to_string();
4038 assert!(
4039 err_msg.contains("git"),
4040 "error must mention git; got: {err_msg}"
4041 );
4042 }
4043
4044 #[tokio::test]
4045 async fn test_analyze_directory_git_ref_filters_changed_files() {
4046 use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
4047 use std::collections::HashSet;
4048 use tempfile::TempDir;
4049
4050 let dir = TempDir::new().unwrap();
4052 let changed_file = dir.path().join("changed.rs");
4053 let unchanged_file = dir.path().join("unchanged.rs");
4054 std::fs::write(&changed_file, "fn changed() {}").unwrap();
4055 std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
4056
4057 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4058 let total_files = entries.iter().filter(|e| !e.is_dir).count();
4059 assert_eq!(total_files, 2, "sanity: 2 files before filtering");
4060
4061 let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
4063 changed.insert(changed_file.clone());
4064
4065 let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
4067 let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
4068
4069 assert_eq!(
4071 filtered_files.len(),
4072 1,
4073 "only 1 file must remain after git_ref filter"
4074 );
4075 assert_eq!(
4076 filtered_files[0].path, changed_file,
4077 "the remaining file must be the changed one"
4078 );
4079
4080 let _ = changed_files_from_git_ref;
4082 }
4083
4084 #[tokio::test]
4085 async fn test_handle_overview_mode_git_ref_filters_via_handler() {
4086 use aptu_coder_core::types::{
4087 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4088 };
4089 use std::process::Command;
4090 use tempfile::TempDir;
4091
4092 let dir = TempDir::new().unwrap();
4094 let repo = dir.path();
4095
4096 let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
4099 let mut cmd = std::process::Command::new("git");
4100 cmd.args(["-c", "core.hooksPath=/dev/null"]);
4101 cmd.args(args);
4102 cmd.current_dir(repo_path);
4103 let out = cmd.output().unwrap();
4104 assert!(out.status.success(), "{out:?}");
4105 };
4106 git_no_hook(repo, &["init"]);
4107 git_no_hook(
4108 repo,
4109 &[
4110 "-c",
4111 "user.email=ci@example.com",
4112 "-c",
4113 "user.name=CI",
4114 "commit",
4115 "--allow-empty",
4116 "-m",
4117 "initial",
4118 ],
4119 );
4120
4121 std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
4123 git_no_hook(repo, &["add", "file_a.rs"]);
4124 git_no_hook(
4125 repo,
4126 &[
4127 "-c",
4128 "user.email=ci@example.com",
4129 "-c",
4130 "user.name=CI",
4131 "commit",
4132 "-m",
4133 "add a",
4134 ],
4135 );
4136
4137 std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
4139 git_no_hook(repo, &["add", "file_b.rs"]);
4140 git_no_hook(
4141 repo,
4142 &[
4143 "-c",
4144 "user.email=ci@example.com",
4145 "-c",
4146 "user.name=CI",
4147 "commit",
4148 "-m",
4149 "add b",
4150 ],
4151 );
4152
4153 let canon_repo = std::fs::canonicalize(repo).unwrap();
4159 let analyzer = make_analyzer();
4160 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4161 "path": canon_repo.to_str().unwrap(),
4162 "git_ref": "HEAD~1",
4163 }))
4164 .unwrap();
4165 let ct = tokio_util::sync::CancellationToken::new();
4166 let (arc_output, _cache_hit) = analyzer
4167 .handle_overview_mode(¶ms, ct)
4168 .await
4169 .expect("handle_overview_mode with git_ref must succeed");
4170
4171 let formatted = &arc_output.formatted;
4173 assert!(
4174 formatted.contains("file_b.rs"),
4175 "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
4176 );
4177 assert!(
4178 !formatted.contains("file_a.rs"),
4179 "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
4180 );
4181 }
4182
4183 #[test]
4184 fn test_validate_path_rejects_absolute_path_outside_cwd() {
4185 let result = validate_path("/etc/passwd", true);
4188 assert!(
4189 result.is_err(),
4190 "validate_path should reject /etc/passwd (outside CWD)"
4191 );
4192 let err = result.unwrap_err();
4193 let err_msg = err.message.to_lowercase();
4194 assert!(
4195 err_msg.contains("outside") || err_msg.contains("not found"),
4196 "Error message should mention 'outside' or 'not found': {}",
4197 err.message
4198 );
4199 }
4200
4201 #[test]
4202 fn test_validate_path_accepts_relative_path_in_cwd() {
4203 let result = validate_path("Cargo.toml", true);
4206 assert!(
4207 result.is_ok(),
4208 "validate_path should accept Cargo.toml (exists in CWD)"
4209 );
4210 }
4211
4212 #[test]
4213 fn test_validate_path_creates_parent_for_nonexistent_file() {
4214 let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4217 assert!(
4218 result.is_ok(),
4219 "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4220 );
4221 let path = result.unwrap();
4222 let cwd = std::env::current_dir().expect("should get cwd");
4223 let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4224 assert!(
4225 path.starts_with(&canonical_cwd),
4226 "Resolved path should be within CWD: {:?} should start with {:?}",
4227 path,
4228 canonical_cwd
4229 );
4230 }
4231}