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