1pub mod logging;
19pub mod metrics;
20
21pub use aptu_coder_core::analyze;
22use aptu_coder_core::types::STDIN_MAX_BYTES;
23use aptu_coder_core::{cache, completion, graph, traversal, types};
24
25pub(crate) const EXCLUDED_DIRS: &[&str] = &[
26 "node_modules",
27 "vendor",
28 ".git",
29 "__pycache__",
30 "target",
31 "dist",
32 "build",
33 ".venv",
34];
35
36use aptu_coder_core::cache::AnalysisCache;
37use aptu_coder_core::formatter::{
38 format_file_details_paginated, format_file_details_summary, format_focused_paginated,
39 format_module_info, format_structure_paginated, format_summary,
40};
41use aptu_coder_core::formatter_defuse::format_focused_paginated_defuse;
42use aptu_coder_core::pagination::{
43 CursorData, DEFAULT_PAGE_SIZE, PaginationMode, decode_cursor, encode_cursor, paginate_slice,
44};
45use aptu_coder_core::traversal::{
46 WalkEntry, changed_files_from_git_ref, filter_entries_by_git_ref, walk_directory,
47};
48use aptu_coder_core::types::{
49 AnalysisMode, AnalyzeDirectoryParams, AnalyzeFileParams, AnalyzeModuleParams,
50 AnalyzeSymbolParams, EditOverwriteOutput, EditOverwriteParams, EditReplaceOutput,
51 EditReplaceParams, SymbolMatchMode,
52};
53use logging::LogEvent;
54use rmcp::handler::server::tool::{ToolRouter, schema_for_type};
55use rmcp::handler::server::wrapper::Parameters;
56use rmcp::model::{
57 CallToolResult, CancelledNotificationParam, CompleteRequestParams, CompleteResult,
58 CompletionInfo, Content, ErrorData, Implementation, InitializeRequestParams, InitializeResult,
59 LoggingLevel, LoggingMessageNotificationParam, Meta, Notification, NumberOrString,
60 ProgressNotificationParam, ProgressToken, ServerCapabilities, ServerNotification,
61 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, RwLock, 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)]
279 pub(crate) tool_router: Arc<RwLock<ToolRouter<Self>>>,
280 cache: AnalysisCache,
281 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
282 log_level_filter: Arc<Mutex<LevelFilter>>,
283 event_rx: Arc<TokioMutex<Option<mpsc::UnboundedReceiver<LogEvent>>>>,
284 metrics_tx: crate::metrics::MetricsSender,
285 session_call_seq: Arc<std::sync::atomic::AtomicU32>,
286 session_id: Arc<TokioMutex<Option<String>>>,
287 profile_meta: Arc<TokioMutex<Option<serde_json::Map<String, serde_json::Value>>>>,
289}
290
291#[tool_router]
292impl CodeAnalyzer {
293 #[must_use]
294 pub fn list_tools() -> Vec<rmcp::model::Tool> {
295 Self::tool_router().list_all()
296 }
297
298 pub fn new(
299 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
300 log_level_filter: Arc<Mutex<LevelFilter>>,
301 event_rx: mpsc::UnboundedReceiver<LogEvent>,
302 metrics_tx: crate::metrics::MetricsSender,
303 ) -> Self {
304 let file_cap: usize = std::env::var("CODE_ANALYZE_FILE_CACHE_CAPACITY")
305 .ok()
306 .and_then(|v| v.parse().ok())
307 .unwrap_or(100);
308 CodeAnalyzer {
309 tool_router: Arc::new(RwLock::new(Self::tool_router())),
310 cache: AnalysisCache::new(file_cap),
311 peer,
312 log_level_filter,
313 event_rx: Arc::new(TokioMutex::new(Some(event_rx))),
314 metrics_tx,
315 session_call_seq: Arc::new(std::sync::atomic::AtomicU32::new(0)),
316 session_id: Arc::new(TokioMutex::new(None)),
317 profile_meta: Arc::new(TokioMutex::new(None)),
318 }
319 }
320
321 #[instrument(skip(self))]
322 async fn emit_progress(
323 &self,
324 peer: Option<Peer<RoleServer>>,
325 token: &ProgressToken,
326 progress: f64,
327 total: f64,
328 message: String,
329 ) {
330 if let Some(peer) = peer {
331 let notification = ServerNotification::ProgressNotification(Notification::new(
332 ProgressNotificationParam {
333 progress_token: token.clone(),
334 progress,
335 total: Some(total),
336 message: Some(message),
337 },
338 ));
339 if let Err(e) = peer.send_notification(notification).await {
340 warn!("Failed to send progress notification: {}", e);
341 }
342 }
343 }
344
345 #[allow(clippy::too_many_lines)] #[allow(clippy::cast_precision_loss)] #[instrument(skip(self, params, ct))]
351 async fn handle_overview_mode(
352 &self,
353 params: &AnalyzeDirectoryParams,
354 ct: tokio_util::sync::CancellationToken,
355 ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, bool), ErrorData> {
356 let path = Path::new(¶ms.path);
357 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
358 let counter_clone = counter.clone();
359 let path_owned = path.to_path_buf();
360 let max_depth = params.max_depth;
361 let ct_clone = ct.clone();
362
363 let all_entries = walk_directory(path, None).map_err(|e| {
365 ErrorData::new(
366 rmcp::model::ErrorCode::INTERNAL_ERROR,
367 format!("Failed to walk directory: {e}"),
368 Some(error_meta(
369 "resource",
370 false,
371 "check path permissions and availability",
372 )),
373 )
374 })?;
375
376 let canonical_max_depth = max_depth.and_then(|d| if d == 0 { None } else { Some(d) });
378
379 let git_ref_val = params.git_ref.as_deref().filter(|s| !s.is_empty());
382 let cache_key = cache::DirectoryCacheKey::from_entries(
383 &all_entries,
384 canonical_max_depth,
385 AnalysisMode::Overview,
386 git_ref_val,
387 );
388
389 if let Some(cached) = self.cache.get_directory(&cache_key) {
391 return Ok((cached, true));
392 }
393
394 let all_entries = if let Some(ref git_ref) = params.git_ref
396 && !git_ref.is_empty()
397 {
398 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
399 ErrorData::new(
400 rmcp::model::ErrorCode::INVALID_PARAMS,
401 format!("git_ref filter failed: {e}"),
402 Some(error_meta(
403 "resource",
404 false,
405 "ensure git is installed and path is inside a git repository",
406 )),
407 )
408 })?;
409 filter_entries_by_git_ref(all_entries, &changed, path)
410 } else {
411 all_entries
412 };
413
414 let subtree_counts = if max_depth.is_some_and(|d| d > 0) {
416 Some(traversal::subtree_counts_from_entries(path, &all_entries))
417 } else {
418 None
419 };
420
421 let entries: Vec<traversal::WalkEntry> = if let Some(depth) = max_depth
423 && depth > 0
424 {
425 all_entries
426 .into_iter()
427 .filter(|e| e.depth <= depth as usize)
428 .collect()
429 } else {
430 all_entries
431 };
432
433 let total_files = entries.iter().filter(|e| !e.is_dir).count();
435
436 let handle = tokio::task::spawn_blocking(move || {
438 analyze::analyze_directory_with_progress(&path_owned, entries, counter_clone, ct_clone)
439 });
440
441 let token = ProgressToken(NumberOrString::String(
443 format!(
444 "analyze-overview-{}",
445 std::time::SystemTime::now()
446 .duration_since(std::time::UNIX_EPOCH)
447 .map(|d| d.as_nanos())
448 .unwrap_or(0)
449 )
450 .into(),
451 ));
452 let peer = self.peer.lock().await.clone();
453 let mut last_progress = 0usize;
454 let mut cancelled = false;
455 loop {
456 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
457 if ct.is_cancelled() {
458 cancelled = true;
459 break;
460 }
461 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
462 if current != last_progress && total_files > 0 {
463 self.emit_progress(
464 peer.clone(),
465 &token,
466 current as f64,
467 total_files as f64,
468 format!("Analyzing {current}/{total_files} files"),
469 )
470 .await;
471 last_progress = current;
472 }
473 if handle.is_finished() {
474 break;
475 }
476 }
477
478 if !cancelled && total_files > 0 {
480 self.emit_progress(
481 peer.clone(),
482 &token,
483 total_files as f64,
484 total_files as f64,
485 format!("Completed analyzing {total_files} files"),
486 )
487 .await;
488 }
489
490 match handle.await {
491 Ok(Ok(mut output)) => {
492 output.subtree_counts = subtree_counts;
493 let arc_output = std::sync::Arc::new(output);
494 self.cache.put_directory(cache_key, arc_output.clone());
495 Ok((arc_output, false))
496 }
497 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
498 rmcp::model::ErrorCode::INTERNAL_ERROR,
499 "Analysis cancelled".to_string(),
500 Some(error_meta("transient", true, "analysis was cancelled")),
501 )),
502 Ok(Err(e)) => Err(ErrorData::new(
503 rmcp::model::ErrorCode::INTERNAL_ERROR,
504 format!("Error analyzing directory: {e}"),
505 Some(error_meta(
506 "resource",
507 false,
508 "check path and file permissions",
509 )),
510 )),
511 Err(e) => Err(ErrorData::new(
512 rmcp::model::ErrorCode::INTERNAL_ERROR,
513 format!("Task join error: {e}"),
514 Some(error_meta("transient", true, "retry the request")),
515 )),
516 }
517 }
518
519 #[instrument(skip(self, params))]
522 async fn handle_file_details_mode(
523 &self,
524 params: &AnalyzeFileParams,
525 ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, bool), ErrorData> {
526 let cache_key = std::fs::metadata(¶ms.path).ok().and_then(|meta| {
528 meta.modified().ok().map(|mtime| cache::CacheKey {
529 path: std::path::PathBuf::from(¶ms.path),
530 modified: mtime,
531 mode: AnalysisMode::FileDetails,
532 })
533 });
534
535 if let Some(ref key) = cache_key
537 && let Some(cached) = self.cache.get(key)
538 {
539 return Ok((cached, true));
540 }
541
542 match analyze::analyze_file(¶ms.path, params.ast_recursion_limit) {
544 Ok(output) => {
545 let arc_output = std::sync::Arc::new(output);
546 if let Some(key) = cache_key {
547 self.cache.put(key, arc_output.clone());
548 }
549 Ok((arc_output, false))
550 }
551 Err(e) => Err(ErrorData::new(
552 rmcp::model::ErrorCode::INTERNAL_ERROR,
553 format!("Error analyzing file: {e}"),
554 Some(error_meta(
555 "resource",
556 false,
557 "check file path and permissions",
558 )),
559 )),
560 }
561 }
562
563 fn validate_impl_only(entries: &[WalkEntry]) -> Result<(), ErrorData> {
565 let has_rust = entries.iter().any(|e| {
566 !e.is_dir
567 && e.path
568 .extension()
569 .and_then(|x: &std::ffi::OsStr| x.to_str())
570 == Some("rs")
571 });
572
573 if !has_rust {
574 return Err(ErrorData::new(
575 rmcp::model::ErrorCode::INVALID_PARAMS,
576 "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(),
577 Some(error_meta(
578 "validation",
579 false,
580 "remove impl_only or point to a directory containing .rs files",
581 )),
582 ));
583 }
584 Ok(())
585 }
586
587 fn validate_import_lookup(import_lookup: Option<bool>, symbol: &str) -> Result<(), ErrorData> {
589 if import_lookup == Some(true) && symbol.is_empty() {
590 return Err(ErrorData::new(
591 rmcp::model::ErrorCode::INVALID_PARAMS,
592 "import_lookup=true requires symbol to contain the module path to search for"
593 .to_string(),
594 Some(error_meta(
595 "validation",
596 false,
597 "set symbol to the module path when using import_lookup=true",
598 )),
599 ));
600 }
601 Ok(())
602 }
603
604 #[allow(clippy::cast_precision_loss)] async fn poll_progress_until_done(
607 &self,
608 analysis_params: &FocusedAnalysisParams,
609 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
610 ct: tokio_util::sync::CancellationToken,
611 entries: std::sync::Arc<Vec<WalkEntry>>,
612 total_files: usize,
613 symbol_display: &str,
614 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
615 let counter_clone = counter.clone();
616 let ct_clone = ct.clone();
617 let entries_clone = std::sync::Arc::clone(&entries);
618 let path_owned = analysis_params.path.clone();
619 let symbol_owned = analysis_params.symbol.clone();
620 let match_mode_owned = analysis_params.match_mode.clone();
621 let follow_depth = analysis_params.follow_depth;
622 let max_depth = analysis_params.max_depth;
623 let ast_recursion_limit = analysis_params.ast_recursion_limit;
624 let use_summary = analysis_params.use_summary;
625 let impl_only = analysis_params.impl_only;
626 let def_use = analysis_params.def_use;
627 let parse_timeout_micros = analysis_params.parse_timeout_micros;
628 let handle = tokio::task::spawn_blocking(move || {
629 let params = analyze::FocusedAnalysisConfig {
630 focus: symbol_owned,
631 match_mode: match_mode_owned,
632 follow_depth,
633 max_depth,
634 ast_recursion_limit,
635 use_summary,
636 impl_only,
637 def_use,
638 parse_timeout_micros,
639 };
640 analyze::analyze_focused_with_progress_with_entries(
641 &path_owned,
642 ¶ms,
643 &counter_clone,
644 &ct_clone,
645 &entries_clone,
646 )
647 });
648
649 let token = ProgressToken(NumberOrString::String(
650 format!(
651 "analyze-symbol-{}",
652 std::time::SystemTime::now()
653 .duration_since(std::time::UNIX_EPOCH)
654 .map(|d| d.as_nanos())
655 .unwrap_or(0)
656 )
657 .into(),
658 ));
659 let peer = self.peer.lock().await.clone();
660 let mut last_progress = 0usize;
661 let mut cancelled = false;
662
663 loop {
664 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
665 if ct.is_cancelled() {
666 cancelled = true;
667 break;
668 }
669 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
670 if current != last_progress && total_files > 0 {
671 self.emit_progress(
672 peer.clone(),
673 &token,
674 current as f64,
675 total_files as f64,
676 format!(
677 "Analyzing {current}/{total_files} files for symbol '{symbol_display}'"
678 ),
679 )
680 .await;
681 last_progress = current;
682 }
683 if handle.is_finished() {
684 break;
685 }
686 }
687
688 if !cancelled && total_files > 0 {
689 self.emit_progress(
690 peer.clone(),
691 &token,
692 total_files as f64,
693 total_files as f64,
694 format!("Completed analyzing {total_files} files for symbol '{symbol_display}'"),
695 )
696 .await;
697 }
698
699 match handle.await {
700 Ok(Ok(output)) => Ok(output),
701 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
702 rmcp::model::ErrorCode::INTERNAL_ERROR,
703 "Analysis cancelled".to_string(),
704 Some(error_meta("transient", true, "analysis was cancelled")),
705 )),
706 Ok(Err(e)) => Err(ErrorData::new(
707 rmcp::model::ErrorCode::INTERNAL_ERROR,
708 format!("Error analyzing symbol: {e}"),
709 Some(error_meta("resource", false, "check symbol name and file")),
710 )),
711 Err(e) => Err(ErrorData::new(
712 rmcp::model::ErrorCode::INTERNAL_ERROR,
713 format!("Task join error: {e}"),
714 Some(error_meta("transient", true, "retry the request")),
715 )),
716 }
717 }
718
719 async fn run_focused_with_auto_summary(
721 &self,
722 params: &AnalyzeSymbolParams,
723 analysis_params: &FocusedAnalysisParams,
724 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
725 ct: tokio_util::sync::CancellationToken,
726 entries: std::sync::Arc<Vec<WalkEntry>>,
727 total_files: usize,
728 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
729 let use_summary_for_task = params.output_control.force != Some(true)
730 && params.output_control.summary == Some(true);
731
732 let analysis_params_initial = FocusedAnalysisParams {
733 use_summary: use_summary_for_task,
734 ..analysis_params.clone()
735 };
736
737 let mut output = self
738 .poll_progress_until_done(
739 &analysis_params_initial,
740 counter.clone(),
741 ct.clone(),
742 entries.clone(),
743 total_files,
744 ¶ms.symbol,
745 )
746 .await?;
747
748 if params.output_control.summary.is_none()
749 && params.output_control.force != Some(true)
750 && output.formatted.len() > SIZE_LIMIT
751 {
752 let counter2 = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
753 let analysis_params_retry = FocusedAnalysisParams {
754 use_summary: true,
755 ..analysis_params.clone()
756 };
757 let summary_result = self
758 .poll_progress_until_done(
759 &analysis_params_retry,
760 counter2,
761 ct,
762 entries,
763 total_files,
764 ¶ms.symbol,
765 )
766 .await;
767
768 if let Ok(summary_output) = summary_result {
769 output.formatted = summary_output.formatted;
770 } else {
771 let estimated_tokens = output.formatted.len() / 4;
772 let message = format!(
773 "Output exceeds 50K chars ({} chars, ~{} tokens). Use summary=true or force=true.",
774 output.formatted.len(),
775 estimated_tokens
776 );
777 return Err(ErrorData::new(
778 rmcp::model::ErrorCode::INVALID_PARAMS,
779 message,
780 Some(error_meta(
781 "validation",
782 false,
783 "use summary=true or force=true",
784 )),
785 ));
786 }
787 } else if output.formatted.len() > SIZE_LIMIT
788 && params.output_control.force != Some(true)
789 && params.output_control.summary == Some(false)
790 {
791 let estimated_tokens = output.formatted.len() / 4;
792 let message = format!(
793 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
794 - force=true to return full output\n\
795 - summary=true to get compact summary\n\
796 - Narrow your scope (smaller directory, specific file)",
797 output.formatted.len(),
798 estimated_tokens
799 );
800 return Err(ErrorData::new(
801 rmcp::model::ErrorCode::INVALID_PARAMS,
802 message,
803 Some(error_meta(
804 "validation",
805 false,
806 "use force=true, summary=true, or narrow scope",
807 )),
808 ));
809 }
810
811 Ok(output)
812 }
813
814 #[instrument(skip(self, params, ct))]
818 async fn handle_focused_mode(
819 &self,
820 params: &AnalyzeSymbolParams,
821 ct: tokio_util::sync::CancellationToken,
822 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
823 let path = Path::new(¶ms.path);
824 let raw_entries = match walk_directory(path, params.max_depth) {
825 Ok(e) => e,
826 Err(e) => {
827 return Err(ErrorData::new(
828 rmcp::model::ErrorCode::INTERNAL_ERROR,
829 format!("Failed to walk directory: {e}"),
830 Some(error_meta(
831 "resource",
832 false,
833 "check path permissions and availability",
834 )),
835 ));
836 }
837 };
838 let filtered_entries = if let Some(ref git_ref) = params.git_ref
840 && !git_ref.is_empty()
841 {
842 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
843 ErrorData::new(
844 rmcp::model::ErrorCode::INVALID_PARAMS,
845 format!("git_ref filter failed: {e}"),
846 Some(error_meta(
847 "resource",
848 false,
849 "ensure git is installed and path is inside a git repository",
850 )),
851 )
852 })?;
853 filter_entries_by_git_ref(raw_entries, &changed, path)
854 } else {
855 raw_entries
856 };
857 let entries = std::sync::Arc::new(filtered_entries);
858
859 if params.impl_only == Some(true) {
860 Self::validate_impl_only(&entries)?;
861 }
862
863 let total_files = entries.iter().filter(|e| !e.is_dir).count();
864 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
865
866 let analysis_params = FocusedAnalysisParams {
867 path: path.to_path_buf(),
868 symbol: params.symbol.clone(),
869 match_mode: params.match_mode.clone().unwrap_or_default(),
870 follow_depth: params.follow_depth.unwrap_or(1),
871 max_depth: params.max_depth,
872 ast_recursion_limit: params.ast_recursion_limit,
873 use_summary: false,
874 impl_only: params.impl_only,
875 def_use: params.def_use.unwrap_or(false),
876 parse_timeout_micros: None,
877 };
878
879 let mut output = self
880 .run_focused_with_auto_summary(
881 params,
882 &analysis_params,
883 counter,
884 ct,
885 entries,
886 total_files,
887 )
888 .await?;
889
890 if params.impl_only == Some(true) {
891 let filter_line = format!(
892 "FILTER: impl_only=true ({} of {} callers shown)\n",
893 output.impl_trait_caller_count, output.unfiltered_caller_count
894 );
895 output.formatted = format!("{}{}", filter_line, output.formatted);
896
897 if output.impl_trait_caller_count == 0 {
898 output.formatted.push_str(
899 "\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"
900 );
901 }
902 }
903
904 Ok(output)
905 }
906
907 #[instrument(skip(self, context))]
908 #[tool(
909 name = "analyze_directory",
910 title = "Analyze Directory",
911 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?",
912 output_schema = schema_for_type::<analyze::AnalysisOutput>(),
913 annotations(
914 title = "Analyze Directory",
915 read_only_hint = true,
916 destructive_hint = false,
917 idempotent_hint = true,
918 open_world_hint = false
919 )
920 )]
921 async fn analyze_directory(
922 &self,
923 params: Parameters<AnalyzeDirectoryParams>,
924 context: RequestContext<RoleServer>,
925 ) -> Result<CallToolResult, ErrorData> {
926 let params = params.0;
927 let _validated_path = match validate_path(¶ms.path, true) {
928 Ok(p) => p,
929 Err(e) => return Ok(err_to_tool_result(e)),
930 };
931 let ct = context.ct.clone();
932 let t_start = std::time::Instant::now();
933 let param_path = params.path.clone();
934 let max_depth_val = params.max_depth;
935 let seq = self
936 .session_call_seq
937 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
938 let sid = self.session_id.lock().await.clone();
939
940 let (arc_output, dir_cache_hit) = match self.handle_overview_mode(¶ms, ct).await {
942 Ok(v) => v,
943 Err(e) => return Ok(err_to_tool_result(e)),
944 };
945 let mut output = match std::sync::Arc::try_unwrap(arc_output) {
948 Ok(owned) => owned,
949 Err(arc) => (*arc).clone(),
950 };
951
952 if summary_cursor_conflict(
955 params.output_control.summary,
956 params.pagination.cursor.as_deref(),
957 ) {
958 return Ok(err_to_tool_result(ErrorData::new(
959 rmcp::model::ErrorCode::INVALID_PARAMS,
960 "summary=true is incompatible with a pagination cursor; use one or the other"
961 .to_string(),
962 Some(error_meta(
963 "validation",
964 false,
965 "remove cursor or set summary=false",
966 )),
967 )));
968 }
969
970 let use_summary = if params.output_control.force == Some(true) {
972 false
973 } else if params.output_control.summary == Some(true) {
974 true
975 } else if params.output_control.summary == Some(false) {
976 false
977 } else {
978 output.formatted.len() > SIZE_LIMIT
979 };
980
981 if use_summary {
982 output.formatted = format_summary(
983 &output.entries,
984 &output.files,
985 params.max_depth,
986 output.subtree_counts.as_deref(),
987 );
988 }
989
990 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
992 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
993 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
994 ErrorData::new(
995 rmcp::model::ErrorCode::INVALID_PARAMS,
996 e.to_string(),
997 Some(error_meta("validation", false, "invalid cursor format")),
998 )
999 }) {
1000 Ok(v) => v,
1001 Err(e) => return Ok(err_to_tool_result(e)),
1002 };
1003 cursor_data.offset
1004 } else {
1005 0
1006 };
1007
1008 let paginated =
1010 match paginate_slice(&output.files, offset, page_size, PaginationMode::Default) {
1011 Ok(v) => v,
1012 Err(e) => {
1013 return Ok(err_to_tool_result(ErrorData::new(
1014 rmcp::model::ErrorCode::INTERNAL_ERROR,
1015 e.to_string(),
1016 Some(error_meta("transient", true, "retry the request")),
1017 )));
1018 }
1019 };
1020
1021 let verbose = params.output_control.verbose.unwrap_or(false);
1022 if !use_summary {
1023 output.formatted = format_structure_paginated(
1024 &paginated.items,
1025 paginated.total,
1026 params.max_depth,
1027 Some(Path::new(¶ms.path)),
1028 verbose,
1029 );
1030 }
1031
1032 if use_summary {
1034 output.next_cursor = None;
1035 } else {
1036 output.next_cursor.clone_from(&paginated.next_cursor);
1037 }
1038
1039 let mut final_text = output.formatted.clone();
1041 if !use_summary && let Some(cursor) = paginated.next_cursor {
1042 final_text.push('\n');
1043 final_text.push_str("NEXT_CURSOR: ");
1044 final_text.push_str(&cursor);
1045 }
1046
1047 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1048 .with_meta(Some(no_cache_meta()));
1049 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1050 result.structured_content = Some(structured);
1051 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1052 self.metrics_tx.send(crate::metrics::MetricEvent {
1053 ts: crate::metrics::unix_ms(),
1054 tool: "analyze_directory",
1055 duration_ms: dur,
1056 output_chars: final_text.len(),
1057 param_path_depth: crate::metrics::path_component_count(¶m_path),
1058 max_depth: max_depth_val,
1059 result: "ok",
1060 error_type: None,
1061 session_id: sid,
1062 seq: Some(seq),
1063 cache_hit: Some(dir_cache_hit),
1064 });
1065 Ok(result)
1066 }
1067
1068 #[instrument(skip(self, _context))]
1069 #[tool(
1070 name = "analyze_file",
1071 title = "Analyze File",
1072 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.",
1073 output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
1074 annotations(
1075 title = "Analyze File",
1076 read_only_hint = true,
1077 destructive_hint = false,
1078 idempotent_hint = true,
1079 open_world_hint = false
1080 )
1081 )]
1082 async fn analyze_file(
1083 &self,
1084 params: Parameters<AnalyzeFileParams>,
1085 _context: RequestContext<RoleServer>,
1086 ) -> Result<CallToolResult, ErrorData> {
1087 let params = params.0;
1088 let _validated_path = match validate_path(¶ms.path, true) {
1089 Ok(p) => p,
1090 Err(e) => return Ok(err_to_tool_result(e)),
1091 };
1092 let t_start = std::time::Instant::now();
1093 let param_path = params.path.clone();
1094 let seq = self
1095 .session_call_seq
1096 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1097 let sid = self.session_id.lock().await.clone();
1098
1099 if std::path::Path::new(¶ms.path).is_dir() {
1101 return Ok(err_to_tool_result(ErrorData::new(
1102 rmcp::model::ErrorCode::INVALID_PARAMS,
1103 format!(
1104 "'{}' is a directory; use analyze_directory instead",
1105 params.path
1106 ),
1107 Some(error_meta(
1108 "validation",
1109 false,
1110 "pass a file path, not a directory",
1111 )),
1112 )));
1113 }
1114
1115 if summary_cursor_conflict(
1117 params.output_control.summary,
1118 params.pagination.cursor.as_deref(),
1119 ) {
1120 return Ok(err_to_tool_result(ErrorData::new(
1121 rmcp::model::ErrorCode::INVALID_PARAMS,
1122 "summary=true is incompatible with a pagination cursor; use one or the other"
1123 .to_string(),
1124 Some(error_meta(
1125 "validation",
1126 false,
1127 "remove cursor or set summary=false",
1128 )),
1129 )));
1130 }
1131
1132 let (arc_output, file_cache_hit) = match self.handle_file_details_mode(¶ms).await {
1134 Ok(v) => v,
1135 Err(e) => return Ok(err_to_tool_result(e)),
1136 };
1137
1138 let mut formatted = arc_output.formatted.clone();
1142 let line_count = arc_output.line_count;
1143
1144 let use_summary = if params.output_control.force == Some(true) {
1146 false
1147 } else if params.output_control.summary == Some(true) {
1148 true
1149 } else if params.output_control.summary == Some(false) {
1150 false
1151 } else {
1152 formatted.len() > SIZE_LIMIT
1153 };
1154
1155 if use_summary {
1156 formatted = format_file_details_summary(&arc_output.semantic, ¶ms.path, line_count);
1157 } else if formatted.len() > SIZE_LIMIT && params.output_control.force != Some(true) {
1158 let estimated_tokens = formatted.len() / 4;
1159 let message = format!(
1160 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1161 - force=true to return full output\n\
1162 - Use fields to limit output to specific sections (functions, classes, or imports)\n\
1163 - Use summary=true for a compact overview",
1164 formatted.len(),
1165 estimated_tokens
1166 );
1167 return Ok(err_to_tool_result(ErrorData::new(
1168 rmcp::model::ErrorCode::INVALID_PARAMS,
1169 message,
1170 Some(error_meta(
1171 "validation",
1172 false,
1173 "use force=true, fields, or summary=true",
1174 )),
1175 )));
1176 }
1177
1178 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1180 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1181 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1182 ErrorData::new(
1183 rmcp::model::ErrorCode::INVALID_PARAMS,
1184 e.to_string(),
1185 Some(error_meta("validation", false, "invalid cursor format")),
1186 )
1187 }) {
1188 Ok(v) => v,
1189 Err(e) => return Ok(err_to_tool_result(e)),
1190 };
1191 cursor_data.offset
1192 } else {
1193 0
1194 };
1195
1196 let top_level_fns: Vec<crate::types::FunctionInfo> = arc_output
1198 .semantic
1199 .functions
1200 .iter()
1201 .filter(|func| {
1202 !arc_output
1203 .semantic
1204 .classes
1205 .iter()
1206 .any(|class| func.line >= class.line && func.end_line <= class.end_line)
1207 })
1208 .cloned()
1209 .collect();
1210
1211 let paginated =
1213 match paginate_slice(&top_level_fns, offset, page_size, PaginationMode::Default) {
1214 Ok(v) => v,
1215 Err(e) => {
1216 return Ok(err_to_tool_result(ErrorData::new(
1217 rmcp::model::ErrorCode::INTERNAL_ERROR,
1218 e.to_string(),
1219 Some(error_meta("transient", true, "retry the request")),
1220 )));
1221 }
1222 };
1223
1224 let verbose = params.output_control.verbose.unwrap_or(false);
1226 if !use_summary {
1227 formatted = format_file_details_paginated(
1229 &paginated.items,
1230 paginated.total,
1231 &arc_output.semantic,
1232 ¶ms.path,
1233 line_count,
1234 offset,
1235 verbose,
1236 params.fields.as_deref(),
1237 );
1238 }
1239
1240 let next_cursor = if use_summary {
1242 None
1243 } else {
1244 paginated.next_cursor.clone()
1245 };
1246
1247 let mut final_text = formatted.clone();
1249 if !use_summary && let Some(ref cursor) = next_cursor {
1250 final_text.push('\n');
1251 final_text.push_str("NEXT_CURSOR: ");
1252 final_text.push_str(cursor);
1253 }
1254
1255 let response_output = analyze::FileAnalysisOutput::new(
1257 formatted,
1258 arc_output.semantic.clone(),
1259 line_count,
1260 next_cursor,
1261 );
1262
1263 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1264 .with_meta(Some(no_cache_meta()));
1265 let structured = serde_json::to_value(&response_output).unwrap_or(Value::Null);
1266 result.structured_content = Some(structured);
1267 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1268 self.metrics_tx.send(crate::metrics::MetricEvent {
1269 ts: crate::metrics::unix_ms(),
1270 tool: "analyze_file",
1271 duration_ms: dur,
1272 output_chars: final_text.len(),
1273 param_path_depth: crate::metrics::path_component_count(¶m_path),
1274 max_depth: None,
1275 result: "ok",
1276 error_type: None,
1277 session_id: sid,
1278 seq: Some(seq),
1279 cache_hit: Some(file_cache_hit),
1280 });
1281 Ok(result)
1282 }
1283
1284 #[instrument(skip(self, context))]
1285 #[tool(
1286 name = "analyze_symbol",
1287 title = "Analyze Symbol",
1288 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.",
1289 output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
1290 annotations(
1291 title = "Analyze Symbol",
1292 read_only_hint = true,
1293 destructive_hint = false,
1294 idempotent_hint = true,
1295 open_world_hint = false
1296 )
1297 )]
1298 async fn analyze_symbol(
1299 &self,
1300 params: Parameters<AnalyzeSymbolParams>,
1301 context: RequestContext<RoleServer>,
1302 ) -> Result<CallToolResult, ErrorData> {
1303 let params = params.0;
1304 let _validated_path = match validate_path(¶ms.path, true) {
1305 Ok(p) => p,
1306 Err(e) => return Ok(err_to_tool_result(e)),
1307 };
1308 let ct = context.ct.clone();
1309 let t_start = std::time::Instant::now();
1310 let param_path = params.path.clone();
1311 let max_depth_val = params.follow_depth;
1312 let seq = self
1313 .session_call_seq
1314 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1315 let sid = self.session_id.lock().await.clone();
1316
1317 if std::path::Path::new(¶ms.path).is_file() {
1319 return Ok(err_to_tool_result(ErrorData::new(
1320 rmcp::model::ErrorCode::INVALID_PARAMS,
1321 format!(
1322 "'{}' is a file; analyze_symbol requires a directory path",
1323 params.path
1324 ),
1325 Some(error_meta(
1326 "validation",
1327 false,
1328 "pass a directory path, not a file",
1329 )),
1330 )));
1331 }
1332
1333 if summary_cursor_conflict(
1335 params.output_control.summary,
1336 params.pagination.cursor.as_deref(),
1337 ) {
1338 return Ok(err_to_tool_result(ErrorData::new(
1339 rmcp::model::ErrorCode::INVALID_PARAMS,
1340 "summary=true is incompatible with a pagination cursor; use one or the other"
1341 .to_string(),
1342 Some(error_meta(
1343 "validation",
1344 false,
1345 "remove cursor or set summary=false",
1346 )),
1347 )));
1348 }
1349
1350 if let Err(e) = Self::validate_import_lookup(params.import_lookup, ¶ms.symbol) {
1352 return Ok(err_to_tool_result(e));
1353 }
1354
1355 if params.import_lookup == Some(true) {
1357 let path_owned = PathBuf::from(¶ms.path);
1358 let symbol = params.symbol.clone();
1359 let git_ref = params.git_ref.clone();
1360 let max_depth = params.max_depth;
1361 let ast_recursion_limit = params.ast_recursion_limit;
1362
1363 let handle = tokio::task::spawn_blocking(move || {
1364 let path = path_owned.as_path();
1365 let raw_entries = match walk_directory(path, max_depth) {
1366 Ok(e) => e,
1367 Err(e) => {
1368 return Err(ErrorData::new(
1369 rmcp::model::ErrorCode::INTERNAL_ERROR,
1370 format!("Failed to walk directory: {e}"),
1371 Some(error_meta(
1372 "resource",
1373 false,
1374 "check path permissions and availability",
1375 )),
1376 ));
1377 }
1378 };
1379 let entries = if let Some(ref git_ref_val) = git_ref
1381 && !git_ref_val.is_empty()
1382 {
1383 let changed = match changed_files_from_git_ref(path, git_ref_val) {
1384 Ok(c) => c,
1385 Err(e) => {
1386 return Err(ErrorData::new(
1387 rmcp::model::ErrorCode::INVALID_PARAMS,
1388 format!("git_ref filter failed: {e}"),
1389 Some(error_meta(
1390 "resource",
1391 false,
1392 "ensure git is installed and path is inside a git repository",
1393 )),
1394 ));
1395 }
1396 };
1397 filter_entries_by_git_ref(raw_entries, &changed, path)
1398 } else {
1399 raw_entries
1400 };
1401 let output = match analyze::analyze_import_lookup(
1402 path,
1403 &symbol,
1404 &entries,
1405 ast_recursion_limit,
1406 ) {
1407 Ok(v) => v,
1408 Err(e) => {
1409 return Err(ErrorData::new(
1410 rmcp::model::ErrorCode::INTERNAL_ERROR,
1411 format!("import_lookup failed: {e}"),
1412 Some(error_meta(
1413 "resource",
1414 false,
1415 "check path and file permissions",
1416 )),
1417 ));
1418 }
1419 };
1420 Ok(output)
1421 });
1422
1423 let output = match handle.await {
1424 Ok(Ok(v)) => v,
1425 Ok(Err(e)) => return Ok(err_to_tool_result(e)),
1426 Err(e) => {
1427 return Ok(err_to_tool_result(ErrorData::new(
1428 rmcp::model::ErrorCode::INTERNAL_ERROR,
1429 format!("spawn_blocking failed: {e}"),
1430 Some(error_meta("resource", false, "internal error")),
1431 )));
1432 }
1433 };
1434
1435 let final_text = output.formatted.clone();
1436 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1437 .with_meta(Some(no_cache_meta()));
1438 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1439 result.structured_content = Some(structured);
1440 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1441 self.metrics_tx.send(crate::metrics::MetricEvent {
1442 ts: crate::metrics::unix_ms(),
1443 tool: "analyze_symbol",
1444 duration_ms: dur,
1445 output_chars: final_text.len(),
1446 param_path_depth: crate::metrics::path_component_count(¶m_path),
1447 max_depth: max_depth_val,
1448 result: "ok",
1449 error_type: None,
1450 session_id: sid,
1451 seq: Some(seq),
1452 cache_hit: Some(false),
1453 });
1454 return Ok(result);
1455 }
1456
1457 let mut output = match self.handle_focused_mode(¶ms, ct).await {
1459 Ok(v) => v,
1460 Err(e) => return Ok(err_to_tool_result(e)),
1461 };
1462
1463 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1465 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1466 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1467 ErrorData::new(
1468 rmcp::model::ErrorCode::INVALID_PARAMS,
1469 e.to_string(),
1470 Some(error_meta("validation", false, "invalid cursor format")),
1471 )
1472 }) {
1473 Ok(v) => v,
1474 Err(e) => return Ok(err_to_tool_result(e)),
1475 };
1476 cursor_data.offset
1477 } else {
1478 0
1479 };
1480
1481 let cursor_mode = if let Some(ref cursor_str) = params.pagination.cursor {
1483 decode_cursor(cursor_str)
1484 .map(|c| c.mode)
1485 .unwrap_or(PaginationMode::Callers)
1486 } else {
1487 PaginationMode::Callers
1488 };
1489
1490 let mut use_summary = params.output_control.summary == Some(true);
1491 if params.output_control.force == Some(true) {
1492 use_summary = false;
1493 }
1494 let verbose = params.output_control.verbose.unwrap_or(false);
1495
1496 let mut callee_cursor = match cursor_mode {
1497 PaginationMode::Callers => {
1498 let (paginated_items, paginated_next) = match paginate_focus_chains(
1499 &output.prod_chains,
1500 PaginationMode::Callers,
1501 offset,
1502 page_size,
1503 ) {
1504 Ok(v) => v,
1505 Err(e) => return Ok(err_to_tool_result(e)),
1506 };
1507
1508 if !use_summary
1509 && (paginated_next.is_some()
1510 || offset > 0
1511 || !verbose
1512 || !output.outgoing_chains.is_empty())
1513 {
1514 let base_path = Path::new(¶ms.path);
1515 output.formatted = format_focused_paginated(
1516 &paginated_items,
1517 output.prod_chains.len(),
1518 PaginationMode::Callers,
1519 ¶ms.symbol,
1520 &output.prod_chains,
1521 &output.test_chains,
1522 &output.outgoing_chains,
1523 output.def_count,
1524 offset,
1525 Some(base_path),
1526 verbose,
1527 );
1528 paginated_next
1529 } else {
1530 None
1531 }
1532 }
1533 PaginationMode::Callees => {
1534 let (paginated_items, paginated_next) = match paginate_focus_chains(
1535 &output.outgoing_chains,
1536 PaginationMode::Callees,
1537 offset,
1538 page_size,
1539 ) {
1540 Ok(v) => v,
1541 Err(e) => return Ok(err_to_tool_result(e)),
1542 };
1543
1544 if paginated_next.is_some() || offset > 0 || !verbose {
1545 let base_path = Path::new(¶ms.path);
1546 output.formatted = format_focused_paginated(
1547 &paginated_items,
1548 output.outgoing_chains.len(),
1549 PaginationMode::Callees,
1550 ¶ms.symbol,
1551 &output.prod_chains,
1552 &output.test_chains,
1553 &output.outgoing_chains,
1554 output.def_count,
1555 offset,
1556 Some(base_path),
1557 verbose,
1558 );
1559 paginated_next
1560 } else {
1561 None
1562 }
1563 }
1564 PaginationMode::Default => {
1565 return Ok(err_to_tool_result(ErrorData::new(
1566 rmcp::model::ErrorCode::INVALID_PARAMS,
1567 "invalid cursor: unknown pagination mode".to_string(),
1568 Some(error_meta(
1569 "validation",
1570 false,
1571 "use a cursor returned by a previous analyze_symbol call",
1572 )),
1573 )));
1574 }
1575 PaginationMode::DefUse => {
1576 let total_sites = output.def_use_sites.len();
1577 let (paginated_sites, paginated_next) = match paginate_slice(
1578 &output.def_use_sites,
1579 offset,
1580 page_size,
1581 PaginationMode::DefUse,
1582 ) {
1583 Ok(r) => (r.items, r.next_cursor),
1584 Err(e) => return Ok(err_to_tool_result_from_pagination(e)),
1585 };
1586
1587 if !use_summary {
1590 let base_path = Path::new(¶ms.path);
1591 output.formatted = format_focused_paginated_defuse(
1592 &paginated_sites,
1593 total_sites,
1594 ¶ms.symbol,
1595 offset,
1596 Some(base_path),
1597 verbose,
1598 );
1599 }
1600
1601 output.def_use_sites = paginated_sites;
1604
1605 paginated_next
1606 }
1607 };
1608
1609 if callee_cursor.is_none()
1614 && cursor_mode == PaginationMode::Callers
1615 && !output.outgoing_chains.is_empty()
1616 && !use_summary
1617 && let Ok(cursor) = encode_cursor(&CursorData {
1618 mode: PaginationMode::Callees,
1619 offset: 0,
1620 })
1621 {
1622 callee_cursor = Some(cursor);
1623 }
1624
1625 if callee_cursor.is_none()
1632 && matches!(
1633 cursor_mode,
1634 PaginationMode::Callees | PaginationMode::Callers
1635 )
1636 && !output.def_use_sites.is_empty()
1637 && !use_summary
1638 && let Ok(cursor) = encode_cursor(&CursorData {
1639 mode: PaginationMode::DefUse,
1640 offset: 0,
1641 })
1642 {
1643 if cursor_mode == PaginationMode::Callees || output.outgoing_chains.is_empty() {
1646 callee_cursor = Some(cursor);
1647 }
1648 }
1649
1650 output.next_cursor.clone_from(&callee_cursor);
1652
1653 let mut final_text = output.formatted.clone();
1655 if let Some(cursor) = callee_cursor {
1656 final_text.push('\n');
1657 final_text.push_str("NEXT_CURSOR: ");
1658 final_text.push_str(&cursor);
1659 }
1660
1661 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1662 .with_meta(Some(no_cache_meta()));
1663 if cursor_mode != PaginationMode::DefUse {
1667 output.def_use_sites = Vec::new();
1668 }
1669 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1670 result.structured_content = Some(structured);
1671 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1672 self.metrics_tx.send(crate::metrics::MetricEvent {
1673 ts: crate::metrics::unix_ms(),
1674 tool: "analyze_symbol",
1675 duration_ms: dur,
1676 output_chars: final_text.len(),
1677 param_path_depth: crate::metrics::path_component_count(¶m_path),
1678 max_depth: max_depth_val,
1679 result: "ok",
1680 error_type: None,
1681 session_id: sid,
1682 seq: Some(seq),
1683 cache_hit: Some(false),
1684 });
1685 Ok(result)
1686 }
1687
1688 #[instrument(skip(self, _context))]
1689 #[tool(
1690 name = "analyze_module",
1691 title = "Analyze Module",
1692 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?",
1693 output_schema = schema_for_type::<types::ModuleInfo>(),
1694 annotations(
1695 title = "Analyze Module",
1696 read_only_hint = true,
1697 destructive_hint = false,
1698 idempotent_hint = true,
1699 open_world_hint = false
1700 )
1701 )]
1702 async fn analyze_module(
1703 &self,
1704 params: Parameters<AnalyzeModuleParams>,
1705 _context: RequestContext<RoleServer>,
1706 ) -> Result<CallToolResult, ErrorData> {
1707 let params = params.0;
1708 let _validated_path = match validate_path(¶ms.path, true) {
1709 Ok(p) => p,
1710 Err(e) => return Ok(err_to_tool_result(e)),
1711 };
1712 let t_start = std::time::Instant::now();
1713 let param_path = params.path.clone();
1714 let seq = self
1715 .session_call_seq
1716 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1717 let sid = self.session_id.lock().await.clone();
1718
1719 if std::fs::metadata(¶ms.path)
1721 .map(|m| m.is_dir())
1722 .unwrap_or(false)
1723 {
1724 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1725 self.metrics_tx.send(crate::metrics::MetricEvent {
1726 ts: crate::metrics::unix_ms(),
1727 tool: "analyze_module",
1728 duration_ms: dur,
1729 output_chars: 0,
1730 param_path_depth: crate::metrics::path_component_count(¶m_path),
1731 max_depth: None,
1732 result: "error",
1733 error_type: Some("invalid_params".to_string()),
1734 session_id: sid.clone(),
1735 seq: Some(seq),
1736 cache_hit: None,
1737 });
1738 return Ok(err_to_tool_result(ErrorData::new(
1739 rmcp::model::ErrorCode::INVALID_PARAMS,
1740 format!(
1741 "'{}' is a directory. Use analyze_directory to analyze a directory, or pass a specific file path to analyze_module.",
1742 params.path
1743 ),
1744 Some(error_meta(
1745 "validation",
1746 false,
1747 "use analyze_directory for directories",
1748 )),
1749 )));
1750 }
1751
1752 let module_cache_key = std::fs::metadata(¶ms.path).ok().and_then(|meta| {
1754 meta.modified().ok().map(|mtime| cache::CacheKey {
1755 path: std::path::PathBuf::from(¶ms.path),
1756 modified: mtime,
1757 mode: AnalysisMode::FileDetails,
1758 })
1759 });
1760 let (module_info, module_cache_hit) = if let Some(ref key) = module_cache_key
1761 && let Some(cached_file) = self.cache.get(key)
1762 {
1763 let file_path = std::path::Path::new(¶ms.path);
1767 let name = file_path
1768 .file_name()
1769 .and_then(|n: &std::ffi::OsStr| n.to_str())
1770 .unwrap_or("unknown")
1771 .to_string();
1772 let language = file_path
1773 .extension()
1774 .and_then(|e| e.to_str())
1775 .and_then(aptu_coder_core::lang::language_for_extension)
1776 .unwrap_or("unknown")
1777 .to_string();
1778 let mut mi = types::ModuleInfo::default();
1779 mi.name = name;
1780 mi.line_count = cached_file.line_count;
1781 mi.language = language;
1782 mi.functions = cached_file
1783 .semantic
1784 .functions
1785 .iter()
1786 .map(|f| {
1787 let mut mfi = types::ModuleFunctionInfo::default();
1788 mfi.name = f.name.clone();
1789 mfi.line = f.line;
1790 mfi
1791 })
1792 .collect();
1793 mi.imports = cached_file
1794 .semantic
1795 .imports
1796 .iter()
1797 .map(|i| {
1798 let mut mii = types::ModuleImportInfo::default();
1799 mii.module = i.module.clone();
1800 mii.items = i.items.clone();
1801 mii
1802 })
1803 .collect();
1804 (mi, true)
1805 } else {
1806 let file_output = match analyze::analyze_file(¶ms.path, None) {
1810 Ok(v) => v,
1811 Err(e) => {
1812 let error_data = match &e {
1813 analyze::AnalyzeError::Io(io_err) => match io_err.kind() {
1814 std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied => {
1815 ErrorData::new(
1816 rmcp::model::ErrorCode::INVALID_PARAMS,
1817 format!("Failed to analyze module: {e}"),
1818 Some(error_meta(
1819 "validation",
1820 false,
1821 "ensure file exists, is readable, and has a supported extension",
1822 )),
1823 )
1824 }
1825 _ => ErrorData::new(
1826 rmcp::model::ErrorCode::INTERNAL_ERROR,
1827 format!("Failed to analyze module: {e}"),
1828 Some(error_meta("internal", false, "report this as a bug")),
1829 ),
1830 },
1831 analyze::AnalyzeError::UnsupportedLanguage(_)
1832 | analyze::AnalyzeError::InvalidRange { .. }
1833 | analyze::AnalyzeError::NotAFile(_) => ErrorData::new(
1834 rmcp::model::ErrorCode::INVALID_PARAMS,
1835 format!("Failed to analyze module: {e}"),
1836 Some(error_meta(
1837 "validation",
1838 false,
1839 "ensure the path is a supported source file",
1840 )),
1841 ),
1842 _ => ErrorData::new(
1843 rmcp::model::ErrorCode::INTERNAL_ERROR,
1844 format!("Failed to analyze module: {e}"),
1845 Some(error_meta("internal", false, "report this as a bug")),
1846 ),
1847 };
1848 return Ok(err_to_tool_result(error_data));
1849 }
1850 };
1851 let arc_output = std::sync::Arc::new(file_output);
1852 if let Some(key) = module_cache_key.clone() {
1853 self.cache.put(key, arc_output.clone());
1854 }
1855 let file_path = std::path::Path::new(¶ms.path);
1856 let name = file_path
1857 .file_name()
1858 .and_then(|n: &std::ffi::OsStr| n.to_str())
1859 .unwrap_or("unknown")
1860 .to_string();
1861 let language = file_path
1862 .extension()
1863 .and_then(|e| e.to_str())
1864 .and_then(aptu_coder_core::lang::language_for_extension)
1865 .unwrap_or("unknown")
1866 .to_string();
1867 let mut mi = types::ModuleInfo::default();
1868 mi.name = name;
1869 mi.line_count = arc_output.line_count;
1870 mi.language = language;
1871 mi.functions = arc_output
1872 .semantic
1873 .functions
1874 .iter()
1875 .map(|f| {
1876 let mut mfi = types::ModuleFunctionInfo::default();
1877 mfi.name = f.name.clone();
1878 mfi.line = f.line;
1879 mfi
1880 })
1881 .collect();
1882 mi.imports = arc_output
1883 .semantic
1884 .imports
1885 .iter()
1886 .map(|i| {
1887 let mut mii = types::ModuleImportInfo::default();
1888 mii.module = i.module.clone();
1889 mii.items = i.items.clone();
1890 mii
1891 })
1892 .collect();
1893 (mi, false)
1894 };
1895
1896 let text = format_module_info(&module_info);
1897 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
1898 .with_meta(Some(no_cache_meta()));
1899 let structured = match serde_json::to_value(&module_info).map_err(|e| {
1900 ErrorData::new(
1901 rmcp::model::ErrorCode::INTERNAL_ERROR,
1902 format!("serialization failed: {e}"),
1903 Some(error_meta("internal", false, "report this as a bug")),
1904 )
1905 }) {
1906 Ok(v) => v,
1907 Err(e) => return Ok(err_to_tool_result(e)),
1908 };
1909 result.structured_content = Some(structured);
1910 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1911 self.metrics_tx.send(crate::metrics::MetricEvent {
1912 ts: crate::metrics::unix_ms(),
1913 tool: "analyze_module",
1914 duration_ms: dur,
1915 output_chars: text.len(),
1916 param_path_depth: crate::metrics::path_component_count(¶m_path),
1917 max_depth: None,
1918 result: "ok",
1919 error_type: None,
1920 session_id: sid,
1921 seq: Some(seq),
1922 cache_hit: Some(module_cache_hit),
1923 });
1924 Ok(result)
1925 }
1926
1927 #[instrument(skip(self, _context))]
1928 #[tool(
1929 name = "edit_overwrite",
1930 title = "Edit Overwrite",
1931 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. Example queries: Overwrite src/config.rs with updated content.",
1932 output_schema = schema_for_type::<EditOverwriteOutput>(),
1933 annotations(
1934 title = "Edit Overwrite",
1935 read_only_hint = false,
1936 destructive_hint = true,
1937 idempotent_hint = false,
1938 open_world_hint = false
1939 )
1940 )]
1941 async fn edit_overwrite(
1942 &self,
1943 params: Parameters<EditOverwriteParams>,
1944 _context: RequestContext<RoleServer>,
1945 ) -> Result<CallToolResult, ErrorData> {
1946 let params = params.0;
1947 let _validated_path = match validate_path(¶ms.path, false) {
1948 Ok(p) => p,
1949 Err(e) => return Ok(err_to_tool_result(e)),
1950 };
1951 let t_start = std::time::Instant::now();
1952 let param_path = params.path.clone();
1953 let seq = self
1954 .session_call_seq
1955 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1956 let sid = self.session_id.lock().await.clone();
1957
1958 if std::fs::metadata(¶ms.path)
1960 .map(|m| m.is_dir())
1961 .unwrap_or(false)
1962 {
1963 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1964 self.metrics_tx.send(crate::metrics::MetricEvent {
1965 ts: crate::metrics::unix_ms(),
1966 tool: "edit_overwrite",
1967 duration_ms: dur,
1968 output_chars: 0,
1969 param_path_depth: crate::metrics::path_component_count(¶m_path),
1970 max_depth: None,
1971 result: "error",
1972 error_type: Some("invalid_params".to_string()),
1973 session_id: sid.clone(),
1974 seq: Some(seq),
1975 cache_hit: None,
1976 });
1977 return Ok(err_to_tool_result(ErrorData::new(
1978 rmcp::model::ErrorCode::INVALID_PARAMS,
1979 "path is a directory; cannot write to a directory".to_string(),
1980 Some(error_meta(
1981 "validation",
1982 false,
1983 "provide a file path, not a directory",
1984 )),
1985 )));
1986 }
1987
1988 let path = std::path::PathBuf::from(¶ms.path);
1989 let content = params.content.clone();
1990 let handle = tokio::task::spawn_blocking(move || {
1991 aptu_coder_core::edit_overwrite_content(&path, &content)
1992 });
1993
1994 let output = match handle.await {
1995 Ok(Ok(v)) => v,
1996 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
1997 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1998 self.metrics_tx.send(crate::metrics::MetricEvent {
1999 ts: crate::metrics::unix_ms(),
2000 tool: "edit_overwrite",
2001 duration_ms: dur,
2002 output_chars: 0,
2003 param_path_depth: crate::metrics::path_component_count(¶m_path),
2004 max_depth: None,
2005 result: "error",
2006 error_type: Some("invalid_params".to_string()),
2007 session_id: sid.clone(),
2008 seq: Some(seq),
2009 cache_hit: None,
2010 });
2011 return Ok(err_to_tool_result(ErrorData::new(
2012 rmcp::model::ErrorCode::INVALID_PARAMS,
2013 "path is a directory".to_string(),
2014 Some(error_meta(
2015 "validation",
2016 false,
2017 "provide a file path, not a directory",
2018 )),
2019 )));
2020 }
2021 Ok(Err(e)) => {
2022 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2023 self.metrics_tx.send(crate::metrics::MetricEvent {
2024 ts: crate::metrics::unix_ms(),
2025 tool: "edit_overwrite",
2026 duration_ms: dur,
2027 output_chars: 0,
2028 param_path_depth: crate::metrics::path_component_count(¶m_path),
2029 max_depth: None,
2030 result: "error",
2031 error_type: Some("internal_error".to_string()),
2032 session_id: sid.clone(),
2033 seq: Some(seq),
2034 cache_hit: None,
2035 });
2036 return Ok(err_to_tool_result(ErrorData::new(
2037 rmcp::model::ErrorCode::INTERNAL_ERROR,
2038 e.to_string(),
2039 Some(error_meta(
2040 "resource",
2041 false,
2042 "check file path and permissions",
2043 )),
2044 )));
2045 }
2046 Err(e) => {
2047 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2048 self.metrics_tx.send(crate::metrics::MetricEvent {
2049 ts: crate::metrics::unix_ms(),
2050 tool: "edit_overwrite",
2051 duration_ms: dur,
2052 output_chars: 0,
2053 param_path_depth: crate::metrics::path_component_count(¶m_path),
2054 max_depth: None,
2055 result: "error",
2056 error_type: Some("internal_error".to_string()),
2057 session_id: sid.clone(),
2058 seq: Some(seq),
2059 cache_hit: None,
2060 });
2061 return Ok(err_to_tool_result(ErrorData::new(
2062 rmcp::model::ErrorCode::INTERNAL_ERROR,
2063 e.to_string(),
2064 Some(error_meta(
2065 "resource",
2066 false,
2067 "check file path and permissions",
2068 )),
2069 )));
2070 }
2071 };
2072
2073 let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2074 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2075 .with_meta(Some(no_cache_meta()));
2076 let structured = match serde_json::to_value(&output).map_err(|e| {
2077 ErrorData::new(
2078 rmcp::model::ErrorCode::INTERNAL_ERROR,
2079 format!("serialization failed: {e}"),
2080 Some(error_meta("internal", false, "report this as a bug")),
2081 )
2082 }) {
2083 Ok(v) => v,
2084 Err(e) => return Ok(err_to_tool_result(e)),
2085 };
2086 result.structured_content = Some(structured);
2087 self.cache
2088 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2089 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2090 self.metrics_tx.send(crate::metrics::MetricEvent {
2091 ts: crate::metrics::unix_ms(),
2092 tool: "edit_overwrite",
2093 duration_ms: dur,
2094 output_chars: text.len(),
2095 param_path_depth: crate::metrics::path_component_count(¶m_path),
2096 max_depth: None,
2097 result: "ok",
2098 error_type: None,
2099 session_id: sid,
2100 seq: Some(seq),
2101 cache_hit: None,
2102 });
2103 Ok(result)
2104 }
2105
2106 #[instrument(skip(self, _context))]
2107 #[tool(
2108 name = "edit_replace",
2109 title = "Edit Replace",
2110 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. Example queries: Update the function signature in lib.rs.",
2111 output_schema = schema_for_type::<EditReplaceOutput>(),
2112 annotations(
2113 title = "Edit Replace",
2114 read_only_hint = false,
2115 destructive_hint = true,
2116 idempotent_hint = false,
2117 open_world_hint = false
2118 )
2119 )]
2120 async fn edit_replace(
2121 &self,
2122 params: Parameters<EditReplaceParams>,
2123 _context: RequestContext<RoleServer>,
2124 ) -> Result<CallToolResult, ErrorData> {
2125 let params = params.0;
2126 let _validated_path = match validate_path(¶ms.path, true) {
2127 Ok(p) => p,
2128 Err(e) => return Ok(err_to_tool_result(e)),
2129 };
2130 let t_start = std::time::Instant::now();
2131 let param_path = params.path.clone();
2132 let seq = self
2133 .session_call_seq
2134 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2135 let sid = self.session_id.lock().await.clone();
2136
2137 if std::fs::metadata(¶ms.path)
2139 .map(|m| m.is_dir())
2140 .unwrap_or(false)
2141 {
2142 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2143 self.metrics_tx.send(crate::metrics::MetricEvent {
2144 ts: crate::metrics::unix_ms(),
2145 tool: "edit_replace",
2146 duration_ms: dur,
2147 output_chars: 0,
2148 param_path_depth: crate::metrics::path_component_count(¶m_path),
2149 max_depth: None,
2150 result: "error",
2151 error_type: Some("invalid_params".to_string()),
2152 session_id: sid.clone(),
2153 seq: Some(seq),
2154 cache_hit: None,
2155 });
2156 return Ok(err_to_tool_result(ErrorData::new(
2157 rmcp::model::ErrorCode::INVALID_PARAMS,
2158 "path is a directory; cannot edit a directory".to_string(),
2159 Some(error_meta(
2160 "validation",
2161 false,
2162 "provide a file path, not a directory",
2163 )),
2164 )));
2165 }
2166
2167 let path = std::path::PathBuf::from(¶ms.path);
2168 let old_text = params.old_text.clone();
2169 let new_text = params.new_text.clone();
2170 let handle = tokio::task::spawn_blocking(move || {
2171 aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2172 });
2173
2174 let output = match handle.await {
2175 Ok(Ok(v)) => v,
2176 Ok(Err(aptu_coder_core::EditError::NotFound { path: _ })) => {
2177 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2178 self.metrics_tx.send(crate::metrics::MetricEvent {
2179 ts: crate::metrics::unix_ms(),
2180 tool: "edit_replace",
2181 duration_ms: dur,
2182 output_chars: 0,
2183 param_path_depth: crate::metrics::path_component_count(¶m_path),
2184 max_depth: None,
2185 result: "error",
2186 error_type: Some("invalid_params".to_string()),
2187 session_id: sid.clone(),
2188 seq: Some(seq),
2189 cache_hit: None,
2190 });
2191 return Ok(err_to_tool_result(ErrorData::new(
2192 rmcp::model::ErrorCode::INVALID_PARAMS,
2193 "old_text not found in file — verify the text matches exactly, including whitespace and newlines".to_string(),
2194 Some(error_meta(
2195 "validation",
2196 false,
2197 "check that old_text appears in the file",
2198 )),
2199 )));
2200 }
2201 Ok(Err(aptu_coder_core::EditError::Ambiguous { count, path: _ })) => {
2202 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2203 self.metrics_tx.send(crate::metrics::MetricEvent {
2204 ts: crate::metrics::unix_ms(),
2205 tool: "edit_replace",
2206 duration_ms: dur,
2207 output_chars: 0,
2208 param_path_depth: crate::metrics::path_component_count(¶m_path),
2209 max_depth: None,
2210 result: "error",
2211 error_type: Some("invalid_params".to_string()),
2212 session_id: sid.clone(),
2213 seq: Some(seq),
2214 cache_hit: None,
2215 });
2216 return Ok(err_to_tool_result(ErrorData::new(
2217 rmcp::model::ErrorCode::INVALID_PARAMS,
2218 format!(
2219 "old_text appears {count} times in file — make old_text longer and more specific to uniquely identify the block"
2220 ),
2221 Some(error_meta(
2222 "validation",
2223 false,
2224 "include more context in old_text to make it unique",
2225 )),
2226 )));
2227 }
2228 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2229 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2230 self.metrics_tx.send(crate::metrics::MetricEvent {
2231 ts: crate::metrics::unix_ms(),
2232 tool: "edit_replace",
2233 duration_ms: dur,
2234 output_chars: 0,
2235 param_path_depth: crate::metrics::path_component_count(¶m_path),
2236 max_depth: None,
2237 result: "error",
2238 error_type: Some("invalid_params".to_string()),
2239 session_id: sid.clone(),
2240 seq: Some(seq),
2241 cache_hit: None,
2242 });
2243 return Ok(err_to_tool_result(ErrorData::new(
2244 rmcp::model::ErrorCode::INVALID_PARAMS,
2245 "path is a directory".to_string(),
2246 Some(error_meta(
2247 "validation",
2248 false,
2249 "provide a file path, not a directory",
2250 )),
2251 )));
2252 }
2253 Ok(Err(e)) => {
2254 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2255 self.metrics_tx.send(crate::metrics::MetricEvent {
2256 ts: crate::metrics::unix_ms(),
2257 tool: "edit_replace",
2258 duration_ms: dur,
2259 output_chars: 0,
2260 param_path_depth: crate::metrics::path_component_count(¶m_path),
2261 max_depth: None,
2262 result: "error",
2263 error_type: Some("internal_error".to_string()),
2264 session_id: sid.clone(),
2265 seq: Some(seq),
2266 cache_hit: None,
2267 });
2268 return Ok(err_to_tool_result(ErrorData::new(
2269 rmcp::model::ErrorCode::INTERNAL_ERROR,
2270 e.to_string(),
2271 Some(error_meta(
2272 "resource",
2273 false,
2274 "check file path and permissions",
2275 )),
2276 )));
2277 }
2278 Err(e) => {
2279 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2280 self.metrics_tx.send(crate::metrics::MetricEvent {
2281 ts: crate::metrics::unix_ms(),
2282 tool: "edit_replace",
2283 duration_ms: dur,
2284 output_chars: 0,
2285 param_path_depth: crate::metrics::path_component_count(¶m_path),
2286 max_depth: None,
2287 result: "error",
2288 error_type: Some("internal_error".to_string()),
2289 session_id: sid.clone(),
2290 seq: Some(seq),
2291 cache_hit: None,
2292 });
2293 return Ok(err_to_tool_result(ErrorData::new(
2294 rmcp::model::ErrorCode::INTERNAL_ERROR,
2295 e.to_string(),
2296 Some(error_meta(
2297 "resource",
2298 false,
2299 "check file path and permissions",
2300 )),
2301 )));
2302 }
2303 };
2304
2305 let text = format!(
2306 "Edited {}: {} bytes -> {} bytes",
2307 output.path, output.bytes_before, output.bytes_after
2308 );
2309 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2310 .with_meta(Some(no_cache_meta()));
2311 let structured = match serde_json::to_value(&output).map_err(|e| {
2312 ErrorData::new(
2313 rmcp::model::ErrorCode::INTERNAL_ERROR,
2314 format!("serialization failed: {e}"),
2315 Some(error_meta("internal", false, "report this as a bug")),
2316 )
2317 }) {
2318 Ok(v) => v,
2319 Err(e) => return Ok(err_to_tool_result(e)),
2320 };
2321 result.structured_content = Some(structured);
2322 self.cache
2323 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2324 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2325 self.metrics_tx.send(crate::metrics::MetricEvent {
2326 ts: crate::metrics::unix_ms(),
2327 tool: "edit_replace",
2328 duration_ms: dur,
2329 output_chars: text.len(),
2330 param_path_depth: crate::metrics::path_component_count(¶m_path),
2331 max_depth: None,
2332 result: "ok",
2333 error_type: None,
2334 session_id: sid,
2335 seq: Some(seq),
2336 cache_hit: None,
2337 });
2338 Ok(result)
2339 }
2340
2341 #[tool(
2342 name = "exec_command",
2343 title = "Exec Command",
2344 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. Pass stdin to pipe UTF-8 content into the process (max 1 MB). For file creation and edits, prefer the edit_* tools. Example queries: Run the test suite and capture output.",
2345 output_schema = schema_for_type::<types::ShellOutput>(),
2346 annotations(
2347 title = "Exec Command",
2348 read_only_hint = false,
2349 destructive_hint = true,
2350 idempotent_hint = false,
2351 open_world_hint = true
2352 )
2353 )]
2354 #[instrument(skip(self, context))]
2355 pub async fn exec_command(
2356 &self,
2357 params: Parameters<types::ExecCommandParams>,
2358 context: RequestContext<RoleServer>,
2359 ) -> Result<CallToolResult, ErrorData> {
2360 let t_start = std::time::Instant::now();
2361 let params = params.0;
2362
2363 let working_dir_path = if let Some(ref wd) = params.working_dir {
2365 match validate_path(wd, true) {
2366 Ok(p) => {
2367 if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
2369 return Ok(err_to_tool_result(ErrorData::new(
2370 rmcp::model::ErrorCode::INVALID_PARAMS,
2371 "working_dir must be a directory".to_string(),
2372 Some(error_meta(
2373 "validation",
2374 false,
2375 "provide a valid directory path",
2376 )),
2377 )));
2378 }
2379 Some(p)
2380 }
2381 Err(e) => {
2382 return Ok(err_to_tool_result(e));
2383 }
2384 }
2385 } else {
2386 None
2387 };
2388
2389 let param_path = params.working_dir.clone();
2390 let seq = self
2391 .session_call_seq
2392 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2393 let sid = self.session_id.lock().await.clone();
2394
2395 if let Some(ref stdin_content) = params.stdin
2397 && stdin_content.len() > STDIN_MAX_BYTES
2398 {
2399 return Ok(err_to_tool_result(ErrorData::new(
2400 rmcp::model::ErrorCode::INVALID_PARAMS,
2401 "stdin exceeds 1 MB limit".to_string(),
2402 Some(error_meta("validation", false, "reduce stdin content size")),
2403 )));
2404 }
2405
2406 let command = params.command.clone();
2407 let timeout_secs = params.timeout_secs;
2408
2409 let peer = self.peer.lock().await.clone();
2411 let progress_token = context.meta.get_progress_token();
2412
2413 let progress_handle: Option<tokio::task::JoinHandle<()>> =
2415 if timeout_secs.is_none_or(|t| t > 10) {
2416 if let (Some(token), Some(peer_conn)) = (progress_token.clone(), peer.clone()) {
2417 let self_clone = self.clone();
2418 Some(tokio::spawn(async move {
2419 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
2420 interval.tick().await; let mut tick = 0u64;
2422 loop {
2423 interval.tick().await;
2424 tick += 1;
2425 let progress = match timeout_secs {
2426 Some(secs) => ((tick * 5) as f64 / secs as f64).min(0.99),
2427 None => 0.0,
2428 };
2429 self_clone
2430 .emit_progress(
2431 Some(peer_conn.clone()),
2432 &token,
2433 progress,
2434 1.0,
2435 "command running".to_string(),
2436 )
2437 .await;
2438 }
2439 }))
2440 } else {
2441 None
2442 }
2443 } else {
2444 None
2445 };
2446
2447 let shell = resolve_shell();
2449
2450 let mut cmd = tokio::process::Command::new(shell);
2451 cmd.arg("-c").arg(&command);
2452
2453 if let Some(ref wd) = working_dir_path {
2454 cmd.current_dir(wd);
2455 }
2456
2457 cmd.stdout(std::process::Stdio::piped())
2458 .stderr(std::process::Stdio::piped());
2459
2460 if params.stdin.is_some() {
2462 cmd.stdin(std::process::Stdio::piped());
2463 } else {
2464 cmd.stdin(std::process::Stdio::null());
2465 }
2466
2467 #[cfg(unix)]
2468 {
2469 let memory_limit_mb = params.memory_limit_mb;
2470 let cpu_limit_secs = params.cpu_limit_secs;
2471 #[cfg(not(target_os = "linux"))]
2472 if memory_limit_mb.is_some() {
2473 warn!("memory_limit_mb is not enforced on this platform (Linux only)");
2474 }
2475 if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
2476 unsafe {
2480 cmd.pre_exec(move || {
2481 #[cfg(target_os = "linux")]
2482 if let Some(mb) = memory_limit_mb {
2483 let bytes = mb.saturating_mul(1024 * 1024);
2484 setrlimit(Resource::RLIMIT_AS, bytes, bytes)
2485 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
2486 }
2487 if let Some(cpu) = cpu_limit_secs {
2488 setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
2489 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
2490 }
2491 Ok(())
2492 });
2493 }
2494 }
2495 }
2496
2497 let mut child = match cmd.spawn() {
2498 Ok(c) => c,
2499 Err(e) => {
2500 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2501 self.metrics_tx.send(crate::metrics::MetricEvent {
2502 ts: crate::metrics::unix_ms(),
2503 tool: "exec_command",
2504 duration_ms: dur,
2505 output_chars: 0,
2506 param_path_depth: crate::metrics::path_component_count(
2507 param_path.as_deref().unwrap_or(""),
2508 ),
2509 max_depth: None,
2510 result: "error",
2511 error_type: Some("internal_error".to_string()),
2512 session_id: sid.clone(),
2513 seq: Some(seq),
2514 cache_hit: None,
2515 });
2516 return Ok(err_to_tool_result(ErrorData::new(
2517 rmcp::model::ErrorCode::INTERNAL_ERROR,
2518 format!("failed to spawn command: {e}"),
2519 Some(error_meta(
2520 "resource",
2521 false,
2522 "check command syntax and permissions",
2523 )),
2524 )));
2525 }
2526 };
2527
2528 const MAX_BYTES: usize = 50 * 1024;
2530
2531 let stdout_pipe = child.stdout.take();
2532 let stderr_pipe = child.stderr.take();
2533
2534 if let Some(stdin_content) = params.stdin
2536 && let Some(mut stdin_handle) = child.stdin.take()
2537 {
2538 use tokio::io::AsyncWriteExt as _;
2539 match stdin_handle.write_all(stdin_content.as_bytes()).await {
2540 Ok(()) => {
2541 drop(stdin_handle); }
2543 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
2544 }
2546 Err(e) => {
2547 warn!("failed to write stdin: {e}");
2548 }
2549 }
2550 }
2551
2552 use std::sync::Arc;
2553 use tokio::io::AsyncBufReadExt as _;
2554 use tokio::sync::Mutex as TokioMutex;
2555 use tokio_stream::StreamExt as TokioStreamExt;
2556 use tokio_stream::wrappers::LinesStream;
2557
2558 let stdout_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
2561 let stderr_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
2562 let interleaved_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
2563
2564 let so_acc = Arc::clone(&stdout_shared);
2565 let se_acc = Arc::clone(&stderr_shared);
2566 let il_acc = Arc::clone(&interleaved_shared);
2567
2568 let mut drain_task = tokio::spawn(async move {
2571 let mut so_bytes = 0usize;
2572 let mut se_bytes = 0usize;
2573 let mut il_bytes = 0usize;
2574
2575 let so_stream = stdout_pipe.map(|p| {
2576 LinesStream::new(tokio::io::BufReader::new(p).lines())
2577 .map(|l| l.map(|s| (false, s)))
2578 });
2579 let se_stream = stderr_pipe.map(|p| {
2580 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
2581 });
2582
2583 match (so_stream, se_stream) {
2584 (Some(so), Some(se)) => {
2585 let mut merged = so.merge(se);
2586 while let Some(item) = merged.next().await {
2587 if let Ok((is_stderr, line)) = item {
2588 let entry = format!("{line}\n");
2589 if is_stderr {
2590 if se_bytes < MAX_BYTES {
2591 se_bytes += entry.len();
2592 se_acc.lock().await.push_str(&entry);
2593 if il_bytes < 2 * MAX_BYTES {
2594 il_bytes += entry.len();
2595 il_acc.lock().await.push_str(&entry);
2596 }
2597 }
2598 } else if so_bytes < MAX_BYTES {
2599 so_bytes += entry.len();
2600 so_acc.lock().await.push_str(&entry);
2601 if il_bytes < 2 * MAX_BYTES {
2602 il_bytes += entry.len();
2603 il_acc.lock().await.push_str(&entry);
2604 }
2605 }
2606 }
2607 }
2608 }
2609 (Some(so), None) => {
2610 let mut stream = so;
2611 while let Some(item) = stream.next().await {
2612 if let Ok((_, line)) = item
2613 && so_bytes < MAX_BYTES
2614 {
2615 let entry = format!("{line}\n");
2616 so_bytes += entry.len();
2617 so_acc.lock().await.push_str(&entry);
2618 if il_bytes < 2 * MAX_BYTES {
2619 il_bytes += entry.len();
2620 il_acc.lock().await.push_str(&entry);
2621 }
2622 }
2623 }
2624 }
2625 (None, Some(se)) => {
2626 let mut stream = se;
2627 while let Some(item) = stream.next().await {
2628 if let Ok((_, line)) = item
2629 && se_bytes < MAX_BYTES
2630 {
2631 let entry = format!("{line}\n");
2632 se_bytes += entry.len();
2633 se_acc.lock().await.push_str(&entry);
2634 if il_bytes < 2 * MAX_BYTES {
2635 il_bytes += entry.len();
2636 il_acc.lock().await.push_str(&entry);
2637 }
2638 }
2639 }
2640 }
2641 (None, None) => {}
2642 }
2643 });
2644
2645 let (exit_code, timed_out, mut output_truncated, output_collection_error) = tokio::select! {
2646 _ = &mut drain_task => {
2647 let (status, drain_truncated) = match tokio::time::timeout(
2650 std::time::Duration::from_millis(500),
2651 child.wait()
2652 ).await {
2653 Ok(Ok(s)) => (Some(s), false),
2654 Ok(Err(_)) => (None, false),
2655 Err(_) => {
2656 child.start_kill().ok();
2657 let _ = child.wait().await;
2658 (None, true)
2659 }
2660 };
2661 let exit_code = status.and_then(|s| s.code());
2662 let ocerr = if drain_truncated {
2663 Some("post-exit drain timeout: background process held pipes".to_string())
2664 } else {
2665 None
2666 };
2667 (exit_code, false, drain_truncated, ocerr)
2668 }
2669 _ = async {
2670 if let Some(secs) = timeout_secs {
2671 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
2672 } else {
2673 std::future::pending::<()>().await;
2674 }
2675 } => {
2676 let _ = child.kill().await;
2678 let _ = child.wait().await;
2679 drain_task.abort();
2680 (None, true, false, None)
2682 }
2683 };
2684
2685 if let Some(handle) = progress_handle {
2687 handle.abort();
2688 }
2689
2690 let stdout_str = std::mem::take(&mut *stdout_shared.lock().await);
2692 let stderr_str = std::mem::take(&mut *stderr_shared.lock().await);
2693 let interleaved_str = std::mem::take(&mut *interleaved_shared.lock().await);
2694
2695 let slot = seq % 8;
2697 let (stdout, stderr, overflow_notice) =
2698 handle_output_overflow(stdout_str, stderr_str, slot);
2699 output_truncated = output_truncated || overflow_notice.is_some();
2700
2701 let mut output = types::ShellOutput::new(
2702 stdout,
2703 stderr,
2704 interleaved_str,
2705 exit_code,
2706 timed_out,
2707 output_truncated,
2708 );
2709 output.output_collection_error = output_collection_error;
2710
2711 let output_text = if output.interleaved.is_empty() {
2713 format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
2714 } else {
2715 format!("Output:\n{}", output.interleaved)
2716 };
2717
2718 let text = format!(
2719 "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
2720 params.command,
2721 exit_code
2722 .map(|c| c.to_string())
2723 .unwrap_or_else(|| "null".to_string()),
2724 timed_out,
2725 output_truncated,
2726 output_text,
2727 );
2728
2729 let mut content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
2730 if let Some(notice) = overflow_notice {
2731 content_blocks.push(Content::text(notice).with_priority(0.0));
2732 }
2733
2734 let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
2739
2740 let mut result = if command_failed {
2741 CallToolResult::error(content_blocks)
2742 } else {
2743 CallToolResult::success(content_blocks)
2744 }
2745 .with_meta(Some(no_cache_meta()));
2746
2747 let structured = match serde_json::to_value(&output).map_err(|e| {
2748 ErrorData::new(
2749 rmcp::model::ErrorCode::INTERNAL_ERROR,
2750 format!("serialization failed: {e}"),
2751 Some(error_meta("internal", false, "report this as a bug")),
2752 )
2753 }) {
2754 Ok(v) => v,
2755 Err(e) => {
2756 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2757 self.metrics_tx.send(crate::metrics::MetricEvent {
2758 ts: crate::metrics::unix_ms(),
2759 tool: "exec_command",
2760 duration_ms: dur,
2761 output_chars: 0,
2762 param_path_depth: crate::metrics::path_component_count(
2763 param_path.as_deref().unwrap_or(""),
2764 ),
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(e));
2773 }
2774 };
2775
2776 result.structured_content = Some(structured);
2777 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2778 self.metrics_tx.send(crate::metrics::MetricEvent {
2779 ts: crate::metrics::unix_ms(),
2780 tool: "exec_command",
2781 duration_ms: dur,
2782 output_chars: text.len(),
2783 param_path_depth: crate::metrics::path_component_count(
2784 param_path.as_deref().unwrap_or(""),
2785 ),
2786 max_depth: None,
2787 result: "ok",
2788 error_type: None,
2789 session_id: sid,
2790 seq: Some(seq),
2791 cache_hit: None,
2792 });
2793 Ok(result)
2794 }
2795}
2796
2797fn handle_output_overflow(
2803 stdout: String,
2804 stderr: String,
2805 slot: u32,
2806) -> (String, String, Option<String>) {
2807 const MAX_OUTPUT_LINES: usize = 2000;
2808 const OVERFLOW_PREVIEW_LINES: usize = 50;
2809
2810 let stdout_lines: Vec<&str> = stdout.lines().collect();
2811 let stderr_lines: Vec<&str> = stderr.lines().collect();
2812
2813 if stdout_lines.len() <= MAX_OUTPUT_LINES && stderr_lines.len() <= MAX_OUTPUT_LINES {
2814 return (stdout, stderr, None);
2815 }
2816
2817 let base = std::env::temp_dir()
2819 .join("aptu-coder-overflow")
2820 .join(format!("slot-{slot}"));
2821 let _ = std::fs::create_dir_all(&base);
2822
2823 let stdout_path = base.join("stdout");
2824 let stderr_path = base.join("stderr");
2825
2826 let _ = std::fs::write(&stdout_path, stdout.as_bytes());
2827 let _ = std::fs::write(&stderr_path, stderr.as_bytes());
2828
2829 let stdout_preview = if stdout_lines.len() > MAX_OUTPUT_LINES {
2831 stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
2832 } else {
2833 stdout
2834 };
2835 let stderr_preview = if stderr_lines.len() > MAX_OUTPUT_LINES {
2836 stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
2837 } else {
2838 stderr
2839 };
2840
2841 let notice = format!(
2842 "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 {}",
2843 stdout_path.display(),
2844 stderr_path.display(),
2845 stdout_path.display(),
2846 );
2847
2848 (stdout_preview, stderr_preview, Some(notice))
2849}
2850
2851#[derive(Clone)]
2855struct FocusedAnalysisParams {
2856 path: std::path::PathBuf,
2857 symbol: String,
2858 match_mode: SymbolMatchMode,
2859 follow_depth: u32,
2860 max_depth: Option<u32>,
2861 ast_recursion_limit: Option<usize>,
2862 use_summary: bool,
2863 impl_only: Option<bool>,
2864 def_use: bool,
2865 parse_timeout_micros: Option<u64>,
2866}
2867
2868#[tool_handler]
2869impl ServerHandler for CodeAnalyzer {
2870 async fn initialize(
2871 &self,
2872 _request: InitializeRequestParams,
2873 context: RequestContext<RoleServer>,
2874 ) -> Result<InitializeResult, ErrorData> {
2875 if let Some(meta) = context.extensions.get::<Meta>() {
2878 let mut meta_lock = self.profile_meta.lock().await;
2879 *meta_lock = Some(meta.0.clone());
2880 }
2881 Ok(self.get_info())
2882 }
2883
2884 fn get_info(&self) -> InitializeResult {
2885 let excluded = crate::EXCLUDED_DIRS.join(", ");
2886 let instructions = format!(
2887 "Recommended workflow:\n\
2888 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
2889 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\
2890 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
2891 4. Use analyze_symbol to trace call graphs.\n\
2892 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."
2893 );
2894 let capabilities = ServerCapabilities::builder()
2895 .enable_logging()
2896 .enable_tools()
2897 .enable_tool_list_changed()
2898 .enable_completions()
2899 .build();
2900 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
2901 .with_title("Aptu Coder")
2902 .with_description("MCP server for code structure analysis using tree-sitter");
2903 InitializeResult::new(capabilities)
2904 .with_server_info(server_info)
2905 .with_instructions(&instructions)
2906 }
2907
2908 async fn list_tools(
2909 &self,
2910 _request: Option<rmcp::model::PaginatedRequestParams>,
2911 _context: RequestContext<RoleServer>,
2912 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
2913 let router = self.tool_router.read().await;
2914 Ok(rmcp::model::ListToolsResult {
2915 tools: router.list_all(),
2916 meta: None,
2917 next_cursor: None,
2918 })
2919 }
2920
2921 async fn call_tool(
2922 &self,
2923 request: rmcp::model::CallToolRequestParams,
2924 context: RequestContext<RoleServer>,
2925 ) -> Result<CallToolResult, ErrorData> {
2926 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
2927 let router = self.tool_router.read().await;
2928 router.call(tcc).await
2929 }
2930
2931 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
2932 let mut peer_lock = self.peer.lock().await;
2933 *peer_lock = Some(context.peer.clone());
2934 drop(peer_lock);
2935
2936 let millis = std::time::SystemTime::now()
2938 .duration_since(std::time::UNIX_EPOCH)
2939 .unwrap_or_default()
2940 .as_millis()
2941 .try_into()
2942 .unwrap_or(u64::MAX);
2943 let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
2944 let sid = format!("{millis}-{counter}");
2945 {
2946 let mut session_id_lock = self.session_id.lock().await;
2947 *session_id_lock = Some(sid);
2948 }
2949 self.session_call_seq
2950 .store(0, std::sync::atomic::Ordering::Relaxed);
2951
2952 let meta_lock = self.profile_meta.lock().await;
2956 let meta_profile = meta_lock
2957 .as_ref()
2958 .and_then(|m| m.get("io.clouatre-labs/profile"))
2959 .and_then(|v| v.as_str())
2960 .map(str::to_owned);
2961 drop(meta_lock);
2962
2963 let active_profile = meta_profile.or(std::env::var("APTU_CODER_PROFILE").ok());
2965
2966 if let Some(ref profile) = active_profile {
2967 let mut router = self.tool_router.write().await;
2968 match profile.as_str() {
2969 "edit" => {
2970 router.disable_route("analyze_directory");
2972 router.disable_route("analyze_file");
2973 router.disable_route("analyze_module");
2974 router.disable_route("analyze_symbol");
2975 }
2976 "analyze" => {
2977 router.disable_route("edit_replace");
2979 router.disable_route("edit_overwrite");
2980 }
2981 _ => {
2982 }
2984 }
2985 router.bind_peer_notifier(&context.peer);
2987 }
2988
2989 let peer = self.peer.clone();
2991 let event_rx = self.event_rx.clone();
2992
2993 tokio::spawn(async move {
2994 let rx = {
2995 let mut rx_lock = event_rx.lock().await;
2996 rx_lock.take()
2997 };
2998
2999 if let Some(mut receiver) = rx {
3000 let mut buffer = Vec::with_capacity(64);
3001 loop {
3002 receiver.recv_many(&mut buffer, 64).await;
3004
3005 if buffer.is_empty() {
3006 break;
3008 }
3009
3010 let peer_lock = peer.lock().await;
3012 if let Some(peer) = peer_lock.as_ref() {
3013 for log_event in buffer.drain(..) {
3014 let notification = ServerNotification::LoggingMessageNotification(
3015 Notification::new(LoggingMessageNotificationParam {
3016 level: log_event.level,
3017 logger: Some(log_event.logger),
3018 data: log_event.data,
3019 }),
3020 );
3021 if let Err(e) = peer.send_notification(notification).await {
3022 warn!("Failed to send logging notification: {}", e);
3023 }
3024 }
3025 }
3026 }
3027 }
3028 });
3029 }
3030
3031 #[instrument(skip(self, _context))]
3032 async fn on_cancelled(
3033 &self,
3034 notification: CancelledNotificationParam,
3035 _context: NotificationContext<RoleServer>,
3036 ) {
3037 tracing::info!(
3038 request_id = ?notification.request_id,
3039 reason = ?notification.reason,
3040 "Received cancellation notification"
3041 );
3042 }
3043
3044 #[instrument(skip(self, _context))]
3045 async fn complete(
3046 &self,
3047 request: CompleteRequestParams,
3048 _context: RequestContext<RoleServer>,
3049 ) -> Result<CompleteResult, ErrorData> {
3050 let argument_name = &request.argument.name;
3052 let argument_value = &request.argument.value;
3053
3054 let completions = match argument_name.as_str() {
3055 "path" => {
3056 let root = Path::new(".");
3058 completion::path_completions(root, argument_value)
3059 }
3060 "symbol" => {
3061 let path_arg = request
3063 .context
3064 .as_ref()
3065 .and_then(|ctx| ctx.get_argument("path"));
3066
3067 match path_arg {
3068 Some(path_str) => {
3069 let path = Path::new(path_str);
3070 completion::symbol_completions(&self.cache, path, argument_value)
3071 }
3072 None => Vec::new(),
3073 }
3074 }
3075 _ => Vec::new(),
3076 };
3077
3078 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
3080 let (values, has_more) = if completions.len() > 100 {
3081 (completions.into_iter().take(100).collect(), true)
3082 } else {
3083 (completions, false)
3084 };
3085
3086 let completion_info =
3087 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
3088 Ok(info) => info,
3089 Err(_) => {
3090 CompletionInfo::with_all_values(Vec::new())
3092 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
3093 }
3094 };
3095
3096 Ok(CompleteResult::new(completion_info))
3097 }
3098
3099 async fn set_level(
3100 &self,
3101 params: SetLevelRequestParams,
3102 _context: RequestContext<RoleServer>,
3103 ) -> Result<(), ErrorData> {
3104 let level_filter = match params.level {
3105 LoggingLevel::Debug => LevelFilter::DEBUG,
3106 LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
3107 LoggingLevel::Warning => LevelFilter::WARN,
3108 LoggingLevel::Error
3109 | LoggingLevel::Critical
3110 | LoggingLevel::Alert
3111 | LoggingLevel::Emergency => LevelFilter::ERROR,
3112 };
3113
3114 let mut filter_lock = self
3115 .log_level_filter
3116 .lock()
3117 .unwrap_or_else(|e| e.into_inner());
3118 *filter_lock = level_filter;
3119 Ok(())
3120 }
3121}
3122
3123#[cfg(test)]
3124mod tests {
3125 use super::*;
3126
3127 #[tokio::test]
3128 async fn test_emit_progress_none_peer_is_noop() {
3129 let peer = Arc::new(TokioMutex::new(None));
3130 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3131 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3132 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3133 let analyzer = CodeAnalyzer::new(
3134 peer,
3135 log_level_filter,
3136 rx,
3137 crate::metrics::MetricsSender(metrics_tx),
3138 );
3139 let token = ProgressToken(NumberOrString::String("test".into()));
3140 analyzer
3142 .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
3143 .await;
3144 }
3145
3146 fn make_analyzer() -> CodeAnalyzer {
3147 let peer = Arc::new(TokioMutex::new(None));
3148 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3149 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3150 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3151 CodeAnalyzer::new(
3152 peer,
3153 log_level_filter,
3154 rx,
3155 crate::metrics::MetricsSender(metrics_tx),
3156 )
3157 }
3158
3159 #[test]
3160 fn test_summary_cursor_conflict() {
3161 assert!(summary_cursor_conflict(Some(true), Some("cursor")));
3162 assert!(!summary_cursor_conflict(Some(true), None));
3163 assert!(!summary_cursor_conflict(None, Some("x")));
3164 assert!(!summary_cursor_conflict(None, None));
3165 }
3166
3167 #[tokio::test]
3168 async fn test_validate_impl_only_non_rust_returns_invalid_params() {
3169 use tempfile::TempDir;
3170
3171 let dir = TempDir::new().unwrap();
3172 std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
3173
3174 let analyzer = make_analyzer();
3175 let entries: Vec<traversal::WalkEntry> =
3178 traversal::walk_directory(dir.path(), None).unwrap_or_default();
3179 let result = CodeAnalyzer::validate_impl_only(&entries);
3180 assert!(result.is_err());
3181 let err = result.unwrap_err();
3182 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
3183 drop(analyzer); }
3185
3186 #[tokio::test]
3187 async fn test_no_cache_meta_on_analyze_directory_result() {
3188 use aptu_coder_core::types::{
3189 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3190 };
3191 use tempfile::TempDir;
3192
3193 let dir = TempDir::new().unwrap();
3194 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
3195
3196 let analyzer = make_analyzer();
3197 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3198 "path": dir.path().to_str().unwrap(),
3199 }))
3200 .unwrap();
3201 let ct = tokio_util::sync::CancellationToken::new();
3202 let (arc_output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
3203 let meta = no_cache_meta();
3205 assert_eq!(
3206 meta.0.get("cache_hint").and_then(|v| v.as_str()),
3207 Some("no-cache"),
3208 );
3209 drop(arc_output);
3210 }
3211
3212 #[test]
3213 fn test_complete_path_completions_returns_suggestions() {
3214 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
3219 let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
3220 let suggestions = completion::path_completions(workspace_root, "aptu-");
3221 assert!(
3222 !suggestions.is_empty(),
3223 "expected completions for prefix 'aptu-' in workspace root"
3224 );
3225 }
3226
3227 #[tokio::test]
3228 async fn test_handle_overview_mode_verbose_no_summary_block() {
3229 use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
3230 use aptu_coder_core::types::{
3231 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3232 };
3233 use tempfile::TempDir;
3234
3235 let tmp = TempDir::new().unwrap();
3236 std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
3237
3238 let peer = Arc::new(TokioMutex::new(None));
3239 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3240 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3241 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3242 let analyzer = CodeAnalyzer::new(
3243 peer,
3244 log_level_filter,
3245 rx,
3246 crate::metrics::MetricsSender(metrics_tx),
3247 );
3248
3249 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3250 "path": tmp.path().to_str().unwrap(),
3251 "verbose": true,
3252 }))
3253 .unwrap();
3254
3255 let ct = tokio_util::sync::CancellationToken::new();
3256 let (output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
3257
3258 let use_summary = output.formatted.len() > SIZE_LIMIT; let paginated =
3261 paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
3262 let verbose = true;
3263 let formatted = if !use_summary {
3264 format_structure_paginated(
3265 &paginated.items,
3266 paginated.total,
3267 params.max_depth,
3268 Some(std::path::Path::new(¶ms.path)),
3269 verbose,
3270 )
3271 } else {
3272 output.formatted.clone()
3273 };
3274
3275 assert!(
3277 !formatted.contains("SUMMARY:"),
3278 "verbose=true must not emit SUMMARY: block; got: {}",
3279 &formatted[..formatted.len().min(300)]
3280 );
3281 assert!(
3282 formatted.contains("PAGINATED:"),
3283 "verbose=true must emit PAGINATED: header"
3284 );
3285 assert!(
3286 formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
3287 "verbose=true must emit FILES section header"
3288 );
3289 }
3290
3291 #[tokio::test]
3294 async fn test_analyze_directory_cache_hit_metrics() {
3295 use aptu_coder_core::types::{
3296 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3297 };
3298 use tempfile::TempDir;
3299
3300 let dir = TempDir::new().unwrap();
3302 std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
3303 let analyzer = make_analyzer();
3304 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3305 "path": dir.path().to_str().unwrap(),
3306 }))
3307 .unwrap();
3308
3309 let ct1 = tokio_util::sync::CancellationToken::new();
3311 let (_out1, hit1) = analyzer.handle_overview_mode(¶ms, ct1).await.unwrap();
3312
3313 let ct2 = tokio_util::sync::CancellationToken::new();
3315 let (_out2, hit2) = analyzer.handle_overview_mode(¶ms, ct2).await.unwrap();
3316
3317 assert!(!hit1, "first call must be a cache miss");
3319 assert!(hit2, "second call must be a cache hit");
3320 }
3321
3322 #[tokio::test]
3323 async fn test_analyze_module_cache_hit_metrics() {
3324 use std::io::Write as _;
3325 use tempfile::NamedTempFile;
3326
3327 let mut f = NamedTempFile::with_suffix(".rs").unwrap();
3329 writeln!(f, "fn bar() {{}}").unwrap();
3330 let path = f.path().to_str().unwrap().to_string();
3331
3332 let analyzer = make_analyzer();
3333
3334 let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
3336 file_params.path = path.clone();
3337 file_params.ast_recursion_limit = None;
3338 file_params.fields = None;
3339 file_params.pagination.cursor = None;
3340 file_params.pagination.page_size = None;
3341 file_params.output_control.summary = None;
3342 file_params.output_control.force = None;
3343 file_params.output_control.verbose = None;
3344 let (_cached, _) = analyzer
3345 .handle_file_details_mode(&file_params)
3346 .await
3347 .unwrap();
3348
3349 let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
3351 module_params.path = path.clone();
3352
3353 let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
3355 meta.modified()
3356 .ok()
3357 .map(|mtime| aptu_coder_core::cache::CacheKey {
3358 path: std::path::PathBuf::from(&path),
3359 modified: mtime,
3360 mode: aptu_coder_core::types::AnalysisMode::FileDetails,
3361 })
3362 });
3363 let cache_hit = module_cache_key
3364 .as_ref()
3365 .and_then(|k| analyzer.cache.get(k))
3366 .is_some();
3367
3368 assert!(
3370 cache_hit,
3371 "analyze_module should find the file in the shared file cache"
3372 );
3373 drop(module_params);
3374 }
3375
3376 #[test]
3379 fn test_analyze_symbol_import_lookup_invalid_params() {
3380 let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
3384
3385 assert!(
3387 result.is_err(),
3388 "import_lookup=true with empty symbol must return Err"
3389 );
3390 let err = result.unwrap_err();
3391 assert_eq!(
3392 err.code,
3393 rmcp::model::ErrorCode::INVALID_PARAMS,
3394 "expected INVALID_PARAMS; got {:?}",
3395 err.code
3396 );
3397 }
3398
3399 #[tokio::test]
3400 async fn test_analyze_symbol_import_lookup_found() {
3401 use tempfile::TempDir;
3402
3403 let dir = TempDir::new().unwrap();
3405 std::fs::write(
3406 dir.path().join("main.rs"),
3407 "use std::collections::HashMap;\nfn main() {}\n",
3408 )
3409 .unwrap();
3410
3411 let entries = traversal::walk_directory(dir.path(), None).unwrap();
3412
3413 let output =
3415 analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
3416
3417 assert!(
3419 output.formatted.contains("MATCHES: 1"),
3420 "expected 1 match; got: {}",
3421 output.formatted
3422 );
3423 assert!(
3424 output.formatted.contains("main.rs"),
3425 "expected main.rs in output; got: {}",
3426 output.formatted
3427 );
3428 }
3429
3430 #[tokio::test]
3431 async fn test_analyze_symbol_import_lookup_empty() {
3432 use tempfile::TempDir;
3433
3434 let dir = TempDir::new().unwrap();
3436 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
3437
3438 let entries = traversal::walk_directory(dir.path(), None).unwrap();
3439
3440 let output =
3442 analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
3443
3444 assert!(
3446 output.formatted.contains("MATCHES: 0"),
3447 "expected 0 matches; got: {}",
3448 output.formatted
3449 );
3450 }
3451
3452 #[tokio::test]
3455 async fn test_analyze_directory_git_ref_non_git_repo() {
3456 use aptu_coder_core::traversal::changed_files_from_git_ref;
3457 use tempfile::TempDir;
3458
3459 let dir = TempDir::new().unwrap();
3461 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
3462
3463 let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
3465
3466 assert!(result.is_err(), "non-git dir must return an error");
3468 let err_msg = result.unwrap_err().to_string();
3469 assert!(
3470 err_msg.contains("git"),
3471 "error must mention git; got: {err_msg}"
3472 );
3473 }
3474
3475 #[tokio::test]
3476 async fn test_analyze_directory_git_ref_filters_changed_files() {
3477 use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
3478 use std::collections::HashSet;
3479 use tempfile::TempDir;
3480
3481 let dir = TempDir::new().unwrap();
3483 let changed_file = dir.path().join("changed.rs");
3484 let unchanged_file = dir.path().join("unchanged.rs");
3485 std::fs::write(&changed_file, "fn changed() {}").unwrap();
3486 std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
3487
3488 let entries = traversal::walk_directory(dir.path(), None).unwrap();
3489 let total_files = entries.iter().filter(|e| !e.is_dir).count();
3490 assert_eq!(total_files, 2, "sanity: 2 files before filtering");
3491
3492 let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
3494 changed.insert(changed_file.clone());
3495
3496 let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
3498 let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
3499
3500 assert_eq!(
3502 filtered_files.len(),
3503 1,
3504 "only 1 file must remain after git_ref filter"
3505 );
3506 assert_eq!(
3507 filtered_files[0].path, changed_file,
3508 "the remaining file must be the changed one"
3509 );
3510
3511 let _ = changed_files_from_git_ref;
3513 }
3514
3515 #[tokio::test]
3516 async fn test_handle_overview_mode_git_ref_filters_via_handler() {
3517 use aptu_coder_core::types::{
3518 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3519 };
3520 use std::process::Command;
3521 use tempfile::TempDir;
3522
3523 let dir = TempDir::new().unwrap();
3525 let repo = dir.path();
3526
3527 let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
3530 let mut cmd = std::process::Command::new("git");
3531 cmd.args(["-c", "core.hooksPath=/dev/null"]);
3532 cmd.args(args);
3533 cmd.current_dir(repo_path);
3534 let out = cmd.output().unwrap();
3535 assert!(out.status.success(), "{out:?}");
3536 };
3537 git_no_hook(repo, &["init"]);
3538 git_no_hook(
3539 repo,
3540 &[
3541 "-c",
3542 "user.email=ci@example.com",
3543 "-c",
3544 "user.name=CI",
3545 "commit",
3546 "--allow-empty",
3547 "-m",
3548 "initial",
3549 ],
3550 );
3551
3552 std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
3554 git_no_hook(repo, &["add", "file_a.rs"]);
3555 git_no_hook(
3556 repo,
3557 &[
3558 "-c",
3559 "user.email=ci@example.com",
3560 "-c",
3561 "user.name=CI",
3562 "commit",
3563 "-m",
3564 "add a",
3565 ],
3566 );
3567
3568 std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
3570 git_no_hook(repo, &["add", "file_b.rs"]);
3571 git_no_hook(
3572 repo,
3573 &[
3574 "-c",
3575 "user.email=ci@example.com",
3576 "-c",
3577 "user.name=CI",
3578 "commit",
3579 "-m",
3580 "add b",
3581 ],
3582 );
3583
3584 let canon_repo = std::fs::canonicalize(repo).unwrap();
3590 let analyzer = make_analyzer();
3591 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3592 "path": canon_repo.to_str().unwrap(),
3593 "git_ref": "HEAD~1",
3594 }))
3595 .unwrap();
3596 let ct = tokio_util::sync::CancellationToken::new();
3597 let (arc_output, _cache_hit) = analyzer
3598 .handle_overview_mode(¶ms, ct)
3599 .await
3600 .expect("handle_overview_mode with git_ref must succeed");
3601
3602 let formatted = &arc_output.formatted;
3604 assert!(
3605 formatted.contains("file_b.rs"),
3606 "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
3607 );
3608 assert!(
3609 !formatted.contains("file_a.rs"),
3610 "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
3611 );
3612 }
3613
3614 #[test]
3615 fn test_validate_path_rejects_absolute_path_outside_cwd() {
3616 let result = validate_path("/etc/passwd", true);
3619 assert!(
3620 result.is_err(),
3621 "validate_path should reject /etc/passwd (outside CWD)"
3622 );
3623 let err = result.unwrap_err();
3624 let err_msg = err.message.to_lowercase();
3625 assert!(
3626 err_msg.contains("outside") || err_msg.contains("not found"),
3627 "Error message should mention 'outside' or 'not found': {}",
3628 err.message
3629 );
3630 }
3631
3632 #[test]
3633 fn test_validate_path_accepts_relative_path_in_cwd() {
3634 let result = validate_path("Cargo.toml", true);
3637 assert!(
3638 result.is_ok(),
3639 "validate_path should accept Cargo.toml (exists in CWD)"
3640 );
3641 }
3642
3643 #[test]
3644 fn test_validate_path_creates_parent_for_nonexistent_file() {
3645 let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
3648 assert!(
3649 result.is_ok(),
3650 "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
3651 );
3652 let path = result.unwrap();
3653 let cwd = std::env::current_dir().expect("should get cwd");
3654 let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
3655 assert!(
3656 path.starts_with(&canonical_cwd),
3657 "Resolved path should be within CWD: {:?} should start with {:?}",
3658 path,
3659 canonical_cwd
3660 );
3661 }
3662
3663 #[test]
3664 fn test_tool_annotations() {
3665 let tools = CodeAnalyzer::list_tools();
3667
3668 let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
3670 let exec_command = tools.iter().find(|t| t.name == "exec_command");
3671
3672 let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
3674 let analyze_dir_annot = analyze_dir_tool
3675 .annotations
3676 .as_ref()
3677 .expect("analyze_directory should have annotations");
3678 assert_eq!(
3679 analyze_dir_annot.read_only_hint,
3680 Some(true),
3681 "analyze_directory read_only_hint should be true"
3682 );
3683 assert_eq!(
3684 analyze_dir_annot.destructive_hint,
3685 Some(false),
3686 "analyze_directory destructive_hint should be false"
3687 );
3688
3689 let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
3691 let exec_cmd_annot = exec_cmd_tool
3692 .annotations
3693 .as_ref()
3694 .expect("exec_command should have annotations");
3695 assert_eq!(
3696 exec_cmd_annot.open_world_hint,
3697 Some(true),
3698 "exec_command open_world_hint should be true"
3699 );
3700 }
3701
3702 #[test]
3703 fn test_exec_stdin_size_cap_validation() {
3704 let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
3707
3708 assert!(
3710 oversized_stdin.len() > STDIN_MAX_BYTES,
3711 "test setup: oversized stdin should exceed 1 MB"
3712 );
3713
3714 let max_stdin = "y".repeat(STDIN_MAX_BYTES);
3716 assert_eq!(
3717 max_stdin.len(),
3718 STDIN_MAX_BYTES,
3719 "test setup: max stdin should be exactly 1 MB"
3720 );
3721 }
3722
3723 #[tokio::test]
3724 async fn test_exec_stdin_cat_roundtrip() {
3725 let stdin_content = "hello world";
3728
3729 let mut child = tokio::process::Command::new("sh")
3731 .arg("-c")
3732 .arg("cat")
3733 .stdin(std::process::Stdio::piped())
3734 .stdout(std::process::Stdio::piped())
3735 .stderr(std::process::Stdio::piped())
3736 .spawn()
3737 .expect("spawn cat");
3738
3739 if let Some(mut stdin_handle) = child.stdin.take() {
3740 use tokio::io::AsyncWriteExt as _;
3741 stdin_handle
3742 .write_all(stdin_content.as_bytes())
3743 .await
3744 .expect("write stdin");
3745 drop(stdin_handle);
3746 }
3747
3748 let output = child.wait_with_output().await.expect("wait for cat");
3749
3750 let stdout_str = String::from_utf8_lossy(&output.stdout);
3752 assert!(
3753 stdout_str.contains(stdin_content),
3754 "stdout should contain stdin content: {}",
3755 stdout_str
3756 );
3757 }
3758
3759 #[tokio::test]
3760 async fn test_exec_stdin_none_no_regression() {
3761 let child = tokio::process::Command::new("sh")
3764 .arg("-c")
3765 .arg("echo hi")
3766 .stdin(std::process::Stdio::null())
3767 .stdout(std::process::Stdio::piped())
3768 .stderr(std::process::Stdio::piped())
3769 .spawn()
3770 .expect("spawn echo");
3771
3772 let output = child.wait_with_output().await.expect("wait for echo");
3773
3774 let stdout_str = String::from_utf8_lossy(&output.stdout);
3776 assert!(
3777 stdout_str.contains("hi"),
3778 "stdout should contain echo output: {}",
3779 stdout_str
3780 );
3781 }
3782}