1#![cfg_attr(test, allow(clippy::unwrap_used))]
27
28mod filters;
29pub mod logging;
30pub mod metrics;
31pub mod otel;
32
33pub use aptu_coder_core::analyze;
34use aptu_coder_core::types::STDIN_MAX_BYTES;
35use aptu_coder_core::{cache, completion, graph, traversal, types};
36
37pub(crate) const EXCLUDED_DIRS: &[&str] = &[
38 "node_modules",
39 "vendor",
40 ".git",
41 "__pycache__",
42 "target",
43 "dist",
44 "build",
45 ".venv",
46];
47
48use aptu_coder_core::cache::{AnalysisCache, CacheTier};
49use aptu_coder_core::formatter::{
50 format_file_details_paginated, format_file_details_summary, format_focused_paginated,
51 format_module_info, format_structure_paginated, format_summary,
52};
53use aptu_coder_core::formatter_defuse::format_focused_paginated_defuse;
54use aptu_coder_core::pagination::{
55 CursorData, DEFAULT_PAGE_SIZE, PaginationMode, decode_cursor, encode_cursor, paginate_slice,
56};
57use aptu_coder_core::parser::ParserError;
58use aptu_coder_core::traversal::{
59 WalkEntry, changed_files_from_git_ref, filter_entries_by_git_ref, walk_directory,
60};
61use aptu_coder_core::types::{
62 AnalysisMode, AnalyzeDirectoryParams, AnalyzeFileParams, AnalyzeModuleParams,
63 AnalyzeSymbolParams, EditOverwriteOutput, EditOverwriteParams, EditReplaceOutput,
64 EditReplaceParams, SymbolMatchMode,
65};
66use filters::{CompiledRule, apply_filter, load_filter_table, maybe_inject_no_stat};
67use logging::LogEvent;
68use rmcp::handler::server::tool::{ToolRouter, schema_for_type};
69use rmcp::handler::server::wrapper::Parameters;
70use rmcp::model::{
71 CallToolResult, CancelledNotificationParam, CompleteRequestParams, CompleteResult,
72 CompletionInfo, Content, ErrorData, Implementation, InitializeRequestParams, InitializeResult,
73 LoggingLevel, LoggingMessageNotificationParam, Meta, Notification, NumberOrString,
74 ProgressNotificationParam, ProgressToken, ServerCapabilities, ServerNotification,
75 SetLevelRequestParams,
76};
77use rmcp::service::{NotificationContext, RequestContext};
78use rmcp::{Peer, RoleServer, ServerHandler, tool, tool_handler, tool_router};
79use serde_json::Value;
80use std::path::{Path, PathBuf};
81use std::sync::{Arc, Mutex};
82use tokio::sync::{Mutex as TokioMutex, RwLock, mpsc};
83use tracing::{instrument, warn};
84use tracing_subscriber::filter::LevelFilter;
85
86#[cfg(unix)]
87use nix::sys::resource::{Resource, setrlimit};
88
89static GLOBAL_SESSION_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
90
91const SIZE_LIMIT: usize = 50_000;
92
93#[must_use]
96pub fn summary_cursor_conflict(summary: Option<bool>, cursor: Option<&str>) -> bool {
97 summary == Some(true) && cursor.is_some()
98}
99
100pub struct ClientMetadata {
102 pub session_id: Option<String>,
103 pub client_name: Option<String>,
104 pub client_version: Option<String>,
105}
106
107pub fn extract_and_set_trace_context(
115 meta: Option<&rmcp::model::Meta>,
116 client_meta: ClientMetadata,
117) {
118 use tracing_opentelemetry::OpenTelemetrySpanExt as _;
119
120 let span = tracing::Span::current();
121
122 if let Some(sid) = client_meta.session_id {
124 span.record("mcp.session.id", &sid);
125 }
126 if let Some(cn) = client_meta.client_name {
127 span.record("client.name", &cn);
128 }
129 if let Some(cv) = client_meta.client_version {
130 span.record("client.version", &cv);
131 }
132
133 if let Some(asi_str) = meta.and_then(|m| m.0.get("agent-session-id").and_then(|v| v.as_str())) {
135 span.record("mcp.client.session.id", asi_str);
136 }
137
138 let Some(meta) = meta else { return };
139
140 let mut propagation_map = std::collections::HashMap::new();
141
142 if let Some(traceparent) = meta.0.get("traceparent")
144 && let Some(tp_str) = traceparent.as_str()
145 {
146 propagation_map.insert("traceparent".to_string(), tp_str.to_string());
147 }
148
149 if let Some(tracestate) = meta.0.get("tracestate")
151 && let Some(ts_str) = tracestate.as_str()
152 {
153 propagation_map.insert("tracestate".to_string(), ts_str.to_string());
154 }
155
156 if propagation_map.is_empty() {
158 return;
159 }
160
161 let parent_cx = opentelemetry::global::get_text_map_propagator(|propagator| {
163 propagator.extract(&ExtractMap(&propagation_map))
164 });
165
166 let _ = span.set_parent(parent_cx);
169}
170
171struct ExtractMap<'a>(&'a std::collections::HashMap<String, String>);
173
174impl<'a> opentelemetry::propagation::Extractor for ExtractMap<'a> {
175 fn get(&self, key: &str) -> Option<&str> {
176 self.0.get(key).map(|s| s.as_str())
177 }
178
179 fn keys(&self) -> Vec<&str> {
180 self.0.keys().map(|k| k.as_str()).collect()
181 }
182}
183
184#[must_use]
185fn error_meta(
186 category: &'static str,
187 is_retryable: bool,
188 suggested_action: &'static str,
189) -> serde_json::Value {
190 serde_json::json!({
191 "errorCategory": category,
192 "isRetryable": is_retryable,
193 "suggestedAction": suggested_action,
194 })
195}
196
197#[must_use]
198fn err_to_tool_result(e: ErrorData) -> CallToolResult {
199 CallToolResult::error(vec![Content::text(e.message)])
200}
201
202fn err_to_tool_result_from_pagination(
203 e: aptu_coder_core::pagination::PaginationError,
204) -> CallToolResult {
205 let msg = format!("Pagination error: {}", e);
206 CallToolResult::error(vec![Content::text(msg)])
207}
208
209fn no_cache_meta() -> Meta {
210 let mut m = serde_json::Map::new();
211 m.insert(
212 "cache_hint".to_string(),
213 serde_json::Value::String("no-cache".to_string()),
214 );
215 Meta(m)
216}
217
218fn validate_path(path: &str, require_exists: bool) -> Result<std::path::PathBuf, ErrorData> {
222 let allowed_root = std::fs::canonicalize(std::env::current_dir().map_err(|_| {
224 ErrorData::new(
225 rmcp::model::ErrorCode::INVALID_PARAMS,
226 "path is outside the allowed root".to_string(),
227 Some(error_meta(
228 "validation",
229 false,
230 "ensure the working directory is accessible",
231 )),
232 )
233 })?)
234 .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
235
236 let canonical_path = if require_exists {
237 std::fs::canonicalize(path).map_err(|e| {
238 let msg = match e.kind() {
239 std::io::ErrorKind::NotFound => format!("path not found: {path}"),
240 std::io::ErrorKind::PermissionDenied => format!("permission denied: {path}"),
241 _ => "path is outside the allowed root".to_string(),
242 };
243 ErrorData::new(
244 rmcp::model::ErrorCode::INVALID_PARAMS,
245 msg,
246 Some(error_meta(
247 "validation",
248 false,
249 "provide a valid path within the working directory",
250 )),
251 )
252 })?
253 } else {
254 let p = std::path::Path::new(path);
256 let mut ancestor = p.to_path_buf();
257 let mut suffix = std::path::PathBuf::new();
258
259 loop {
260 if ancestor.exists() {
261 break;
262 }
263 if let Some(parent) = ancestor.parent()
264 && let Some(file_name) = ancestor.file_name()
265 {
266 suffix = std::path::PathBuf::from(file_name).join(&suffix);
267 ancestor = parent.to_path_buf();
268 } else {
269 ancestor = allowed_root.clone();
271 break;
272 }
273 }
274
275 let canonical_base =
276 std::fs::canonicalize(&ancestor).unwrap_or_else(|_| allowed_root.clone());
277 canonical_base.join(&suffix)
278 };
279
280 if !canonical_path.starts_with(&allowed_root) {
281 return Err(ErrorData::new(
282 rmcp::model::ErrorCode::INVALID_PARAMS,
283 "path is outside the allowed root".to_string(),
284 Some(error_meta(
285 "validation",
286 false,
287 "provide a path within the current working directory",
288 )),
289 ));
290 }
291
292 Ok(canonical_path)
293}
294
295fn io_error_to_path_error(
297 err: &std::io::Error,
298 path_context: &str,
299 suggested_action: &'static str,
300) -> ErrorData {
301 let msg = match err.kind() {
302 std::io::ErrorKind::NotFound => format!("{path_context} not found"),
303 std::io::ErrorKind::PermissionDenied => format!("permission denied: {path_context}"),
304 _ => format!("{path_context} is invalid"),
305 };
306 let mut meta = error_meta("validation", false, suggested_action);
307 if let Some(obj) = meta.as_object_mut() {
309 obj.insert(
310 "ioErrorKind".to_string(),
311 serde_json::json!(format!("{:?}", err.kind())),
312 );
313 obj.insert(
314 "ioErrorSource".to_string(),
315 serde_json::json!(err.to_string()),
316 );
317 }
318 ErrorData::new(rmcp::model::ErrorCode::INVALID_PARAMS, msg, Some(meta))
319}
320
321fn validate_path_in_dir(
325 path: &str,
326 require_exists: bool,
327 working_dir: &std::path::Path,
328) -> Result<std::path::PathBuf, ErrorData> {
329 let canonical_working_dir = std::fs::canonicalize(working_dir).map_err(|e| {
331 io_error_to_path_error(&e, "working_dir", "provide a valid working directory")
332 })?;
333
334 if !std::fs::metadata(&canonical_working_dir)
336 .map(|m| m.is_dir())
337 .unwrap_or(false)
338 {
339 return Err(ErrorData::new(
340 rmcp::model::ErrorCode::INVALID_PARAMS,
341 "working_dir must be a directory".to_string(),
342 Some(error_meta(
343 "validation",
344 false,
345 "provide a valid directory path",
346 )),
347 ));
348 }
349
350 let canonical_path = if require_exists {
362 let target_path = canonical_working_dir.join(path);
363 std::fs::canonicalize(&target_path).map_err(|e| {
364 io_error_to_path_error(
365 &e,
366 path,
367 "provide a valid path within the working directory",
368 )
369 })?
370 } else {
371 let p = std::path::Path::new(path);
377 let mut ancestor = p.to_path_buf();
378 let mut suffix = std::path::PathBuf::new();
379
380 loop {
381 let full_path = canonical_working_dir.join(&ancestor);
382 if full_path.exists() {
383 break;
384 }
385 if let Some(parent) = ancestor.parent()
386 && let Some(file_name) = ancestor.file_name()
387 {
388 suffix = std::path::PathBuf::from(file_name).join(&suffix);
389 ancestor = parent.to_path_buf();
390 } else {
391 ancestor = std::path::PathBuf::new();
394 break;
395 }
396 }
397
398 let canonical_base = canonical_working_dir.join(&ancestor);
399 let canonical_base =
400 std::fs::canonicalize(&canonical_base).unwrap_or(canonical_working_dir.clone());
401 canonical_base.join(&suffix)
402 };
403
404 if !canonical_path.starts_with(&canonical_working_dir) {
412 return Err(ErrorData::new(
413 rmcp::model::ErrorCode::INVALID_PARAMS,
414 "path is outside the working directory".to_string(),
415 Some(error_meta(
416 "validation",
417 false,
418 "provide a path within the working directory",
419 )),
420 ));
421 }
422
423 Ok(canonical_path)
424}
425
426fn paginate_focus_chains(
429 chains: &[graph::InternalCallChain],
430 mode: PaginationMode,
431 offset: usize,
432 page_size: usize,
433) -> Result<(Vec<graph::InternalCallChain>, Option<String>), ErrorData> {
434 let paginated = paginate_slice(chains, offset, page_size, mode).map_err(|e| {
435 ErrorData::new(
436 rmcp::model::ErrorCode::INTERNAL_ERROR,
437 e.to_string(),
438 Some(error_meta("transient", true, "retry the request")),
439 )
440 })?;
441
442 if paginated.next_cursor.is_none() && offset == 0 {
443 return Ok((paginated.items, None));
444 }
445
446 let next = if let Some(raw_cursor) = paginated.next_cursor {
447 let decoded = decode_cursor(&raw_cursor).map_err(|e| {
448 ErrorData::new(
449 rmcp::model::ErrorCode::INVALID_PARAMS,
450 e.to_string(),
451 Some(error_meta("validation", false, "invalid cursor format")),
452 )
453 })?;
454 Some(
455 encode_cursor(&CursorData {
456 mode,
457 offset: decoded.offset,
458 })
459 .map_err(|e| {
460 ErrorData::new(
461 rmcp::model::ErrorCode::INVALID_PARAMS,
462 e.to_string(),
463 Some(error_meta("validation", false, "invalid cursor format")),
464 )
465 })?,
466 )
467 } else {
468 None
469 };
470
471 Ok((paginated.items, next))
472}
473
474fn resolve_shell() -> String {
478 if let Ok(shell) = std::env::var("APTU_SHELL") {
479 return shell;
480 }
481 #[cfg(unix)]
482 {
483 if which::which("bash").is_ok() {
484 return "bash".to_string();
485 }
486 "/bin/sh".to_string()
487 }
488 #[cfg(not(unix))]
489 {
490 "cmd".to_string()
491 }
492}
493
494#[derive(Clone)]
499pub struct CodeAnalyzer {
500 #[allow(dead_code)]
508 pub(crate) tool_router: Arc<RwLock<ToolRouter<Self>>>,
509 cache: AnalysisCache,
510 disk_cache: std::sync::Arc<cache::DiskCache>,
511 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
512 log_level_filter: Arc<Mutex<LevelFilter>>,
513 event_rx: Arc<TokioMutex<Option<mpsc::UnboundedReceiver<LogEvent>>>>,
514 metrics_tx: crate::metrics::MetricsSender,
515 session_call_seq: Arc<std::sync::atomic::AtomicU32>,
516 session_id: Arc<TokioMutex<Option<String>>>,
517 profile_meta: Arc<TokioMutex<Option<serde_json::Map<String, serde_json::Value>>>>,
519 client_name: Arc<TokioMutex<Option<String>>>,
520 client_version: Arc<TokioMutex<Option<String>>>,
521 resolved_path: Arc<Option<String>>,
524 filter_table: Arc<Vec<CompiledRule>>,
527}
528
529#[tool_router]
530impl CodeAnalyzer {
531 #[must_use]
532 pub fn list_tools() -> Vec<rmcp::model::Tool> {
533 Self::tool_router().list_all()
534 }
535
536 pub fn new(
537 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
538 log_level_filter: Arc<Mutex<LevelFilter>>,
539 event_rx: mpsc::UnboundedReceiver<LogEvent>,
540 metrics_tx: crate::metrics::MetricsSender,
541 ) -> Self {
542 let file_cap: usize = std::env::var("APTU_CODER_FILE_CACHE_CAPACITY")
543 .ok()
544 .and_then(|v| v.parse().ok())
545 .unwrap_or(100);
546
547 let xdg_data_home = if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
549 && !xdg_data_home.is_empty()
550 {
551 std::path::PathBuf::from(xdg_data_home)
552 } else if let Ok(home) = std::env::var("HOME") {
553 std::path::PathBuf::from(home).join(".local").join("share")
554 } else {
555 std::path::PathBuf::from(".")
556 };
557 let disk_cache_disabled = std::env::var("APTU_CODER_DISK_CACHE_DISABLED")
558 .map(|v| v == "1")
559 .unwrap_or(false);
560 let disk_cache_dir = std::env::var("APTU_CODER_DISK_CACHE_DIR")
561 .map(std::path::PathBuf::from)
562 .unwrap_or_else(|_| xdg_data_home.join("aptu-coder").join("analysis-cache"));
563 let disk_cache =
564 std::sync::Arc::new(cache::DiskCache::new(disk_cache_dir, disk_cache_disabled));
565
566 let resolved_path = {
575 let snapshot_shell = std::env::var("SHELL")
576 .ok()
577 .filter(|s| !s.is_empty())
578 .unwrap_or_else(|| {
579 let s = resolve_shell();
580 if s.is_empty() {
581 "/bin/sh".to_string()
582 } else {
583 s
584 }
585 });
586 let login_path = match std::process::Command::new(&snapshot_shell)
587 .args(["-l", "-c", "echo $PATH"])
588 .output()
589 {
590 Ok(output) => {
591 let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
592 if path_str.is_empty() {
593 tracing::warn!(
594 shell = %snapshot_shell,
595 "login shell PATH snapshot returned empty string"
596 );
597 None
598 } else {
599 Some(path_str)
600 }
601 }
602 Err(e) => {
603 tracing::warn!(
604 shell = %snapshot_shell,
605 error = %e,
606 "failed to snapshot login shell PATH"
607 );
608 None
609 }
610 };
611 let path = login_path.or_else(|| std::env::var("PATH").ok());
613 Arc::new(path)
614 };
615
616 let filter_table = Arc::new(load_filter_table(Path::new(".")));
617
618 CodeAnalyzer {
619 tool_router: Arc::new(RwLock::new(Self::tool_router())),
620 cache: AnalysisCache::new(file_cap),
621 disk_cache,
622 peer,
623 log_level_filter,
624 event_rx: Arc::new(TokioMutex::new(Some(event_rx))),
625 metrics_tx,
626 session_call_seq: Arc::new(std::sync::atomic::AtomicU32::new(0)),
627 session_id: Arc::new(TokioMutex::new(None)),
628 profile_meta: Arc::new(TokioMutex::new(None)),
629 client_name: Arc::new(TokioMutex::new(None)),
630 client_version: Arc::new(TokioMutex::new(None)),
631 resolved_path,
632 filter_table,
633 }
634 }
635
636 #[instrument(skip(self))]
637 async fn emit_progress(
638 &self,
639 peer: Option<Peer<RoleServer>>,
640 token: &ProgressToken,
641 progress: f64,
642 total: f64,
643 message: String,
644 ) {
645 if let Some(peer) = peer {
646 let notification = ServerNotification::ProgressNotification(Notification::new(
647 ProgressNotificationParam {
648 progress_token: token.clone(),
649 progress,
650 total: Some(total),
651 message: Some(message),
652 },
653 ));
654 if let Err(e) = peer.send_notification(notification).await {
655 warn!("Failed to send progress notification: {}", e);
656 }
657 }
658 }
659
660 #[allow(clippy::too_many_lines)] #[allow(clippy::cast_precision_loss)] #[instrument(skip(self, params, ct))]
666 async fn handle_overview_mode(
667 &self,
668 params: &AnalyzeDirectoryParams,
669 ct: tokio_util::sync::CancellationToken,
670 ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, CacheTier), ErrorData> {
671 let path = Path::new(¶ms.path);
672 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
673 let counter_clone = counter.clone();
674 let path_owned = path.to_path_buf();
675 let max_depth = params.max_depth;
676 let ct_clone = ct.clone();
677
678 let all_entries = walk_directory(path, None).map_err(|e| {
680 ErrorData::new(
681 rmcp::model::ErrorCode::INTERNAL_ERROR,
682 format!("Failed to walk directory: {e}"),
683 Some(error_meta(
684 "resource",
685 false,
686 "check path permissions and availability",
687 )),
688 )
689 })?;
690
691 let canonical_max_depth = max_depth.and_then(|d| if d == 0 { None } else { Some(d) });
693
694 let git_ref_val = params.git_ref.as_deref().filter(|s| !s.is_empty());
697 let cache_key = cache::DirectoryCacheKey::from_entries(
698 &all_entries,
699 canonical_max_depth,
700 AnalysisMode::Overview,
701 git_ref_val,
702 );
703
704 if let Some(cached) = self.cache.get_directory(&cache_key) {
706 tracing::debug!(cache_hit = true, message = "returning cached result");
707 return Ok((cached, CacheTier::L1Memory));
708 }
709
710 let root = std::path::Path::new(¶ms.path);
712 let disk_key = {
713 let mut hasher = blake3::Hasher::new();
714 let mut sorted_entries: Vec<_> = all_entries.iter().collect();
715 sorted_entries.sort_by(|a, b| a.path.cmp(&b.path));
716 for entry in &sorted_entries {
717 let rel = entry.path.strip_prefix(root).unwrap_or(&entry.path);
718 hasher.update(rel.as_os_str().to_string_lossy().as_bytes());
719 let mtime_secs = entry
720 .mtime
721 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
722 .map(|d| d.as_secs())
723 .unwrap_or(0);
724 hasher.update(&mtime_secs.to_le_bytes());
725 }
726 if let Some(depth) = canonical_max_depth {
727 hasher.update(depth.to_string().as_bytes());
728 }
729 if let Some(ref git_ref) = params.git_ref {
730 hasher.update(git_ref.as_bytes());
731 }
732 hasher.finalize()
733 };
734
735 if let Some(cached) = self
737 .disk_cache
738 .get::<analyze::AnalysisOutput>("analyze_directory", &disk_key)
739 {
740 let arc = std::sync::Arc::new(cached);
741 self.cache.put_directory(cache_key.clone(), arc.clone());
742 return Ok((arc, CacheTier::L2Disk));
743 }
744
745 let all_entries = if let Some(ref git_ref) = params.git_ref
747 && !git_ref.is_empty()
748 {
749 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
750 ErrorData::new(
751 rmcp::model::ErrorCode::INVALID_PARAMS,
752 format!("git_ref filter failed: {e}"),
753 Some(error_meta(
754 "resource",
755 false,
756 "ensure git is installed and path is inside a git repository",
757 )),
758 )
759 })?;
760 filter_entries_by_git_ref(all_entries, &changed, path)
761 } else {
762 all_entries
763 };
764
765 let subtree_counts = if max_depth.is_some_and(|d| d > 0) {
767 Some(traversal::subtree_counts_from_entries(path, &all_entries))
768 } else {
769 None
770 };
771
772 let entries: Vec<traversal::WalkEntry> = if let Some(depth) = max_depth
774 && depth > 0
775 {
776 all_entries
777 .into_iter()
778 .filter(|e| e.depth <= depth as usize)
779 .collect()
780 } else {
781 all_entries
782 };
783
784 let total_files = entries.iter().filter(|e| !e.is_dir).count();
786
787 let handle = tokio::task::spawn_blocking(move || {
789 analyze::analyze_directory_with_progress(&path_owned, entries, counter_clone, ct_clone)
790 });
791
792 let token = ProgressToken(NumberOrString::String(
794 format!(
795 "analyze-overview-{}",
796 std::time::SystemTime::now()
797 .duration_since(std::time::UNIX_EPOCH)
798 .map(|d| d.as_nanos())
799 .unwrap_or(0)
800 )
801 .into(),
802 ));
803 let peer = self.peer.lock().await.clone();
804 let mut last_progress = 0usize;
805 let mut cancelled = false;
806 loop {
807 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
808 if ct.is_cancelled() {
809 cancelled = true;
810 break;
811 }
812 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
813 if current != last_progress && total_files > 0 {
814 self.emit_progress(
815 peer.clone(),
816 &token,
817 current as f64,
818 total_files as f64,
819 format!("Analyzing {current}/{total_files} files"),
820 )
821 .await;
822 last_progress = current;
823 }
824 if handle.is_finished() {
825 break;
826 }
827 }
828
829 if !cancelled && total_files > 0 {
831 self.emit_progress(
832 peer.clone(),
833 &token,
834 total_files as f64,
835 total_files as f64,
836 format!("Completed analyzing {total_files} files"),
837 )
838 .await;
839 }
840
841 match handle.await {
842 Ok(Ok(mut output)) => {
843 output.subtree_counts = subtree_counts;
844 let arc_output = std::sync::Arc::new(output);
845 self.cache.put_directory(cache_key, arc_output.clone());
846 {
848 let dc = self.disk_cache.clone();
849 let k = disk_key;
850 let v = arc_output.as_ref().clone();
851 let handle = tokio::task::spawn_blocking(move || {
852 dc.put("analyze_directory", &k, &v);
853 dc.drain_write_failures()
854 });
855 let metrics_tx = self.metrics_tx.clone();
856 let sid = self.session_id.lock().await.clone();
857 tokio::spawn(async move {
858 if let Ok(failures) = handle.await
859 && failures > 0
860 {
861 tracing::warn!(
862 tool = "analyze_directory",
863 failures,
864 "L2 disk cache write failed"
865 );
866 metrics_tx.send(crate::metrics::MetricEvent {
867 ts: crate::metrics::unix_ms(),
868 tool: "analyze_directory",
869 duration_ms: 0,
870 output_chars: 0,
871 param_path_depth: 0,
872 max_depth: None,
873 result: "ok",
874 error_type: None,
875 session_id: sid,
876 seq: None,
877 cache_hit: None,
878 cache_write_failure: Some(true),
879 cache_tier: None,
880 exit_code: None,
881 timed_out: false,
882 output_truncated: None,
883 ..Default::default()
884 });
885 }
886 });
887 }
888 Ok((arc_output, CacheTier::Miss))
889 }
890 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
891 rmcp::model::ErrorCode::INTERNAL_ERROR,
892 "Analysis cancelled".to_string(),
893 Some(error_meta("transient", true, "analysis was cancelled")),
894 )),
895 Ok(Err(e)) => Err(ErrorData::new(
896 rmcp::model::ErrorCode::INTERNAL_ERROR,
897 format!("Error analyzing directory: {e}"),
898 Some(error_meta(
899 "resource",
900 false,
901 "check path and file permissions",
902 )),
903 )),
904 Err(e) => Err(ErrorData::new(
905 rmcp::model::ErrorCode::INTERNAL_ERROR,
906 format!("Task join error: {e}"),
907 Some(error_meta("transient", true, "retry the request")),
908 )),
909 }
910 }
911
912 #[instrument(skip(self, params))]
915 async fn handle_file_details_mode(
916 &self,
917 params: &AnalyzeFileParams,
918 ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, CacheTier), ErrorData> {
919 let cache_key = std::fs::metadata(¶ms.path).ok().and_then(|meta| {
921 meta.modified().ok().map(|mtime| cache::CacheKey {
922 path: std::path::PathBuf::from(¶ms.path),
923 modified: mtime,
924 mode: AnalysisMode::FileDetails,
925 })
926 });
927
928 if let Some(ref key) = cache_key
930 && let Some(cached) = self.cache.get(key)
931 {
932 tracing::debug!(cache_hit = true, message = "returning cached result");
933 return Ok((cached, CacheTier::L1Memory));
934 }
935
936 let file_bytes = std::fs::read(¶ms.path).unwrap_or_default();
938 let disk_key = blake3::hash(&file_bytes);
939
940 if let Some(cached) = self
942 .disk_cache
943 .get::<analyze::FileAnalysisOutput>("analyze_file", &disk_key)
944 {
945 let arc = std::sync::Arc::new(cached);
946 if let Some(ref key) = cache_key {
947 self.cache.put(key.clone(), arc.clone());
948 }
949 return Ok((arc, CacheTier::L2Disk));
950 }
951
952 match analyze::analyze_file(¶ms.path, params.ast_recursion_limit) {
954 Ok(output) => {
955 let arc_output = std::sync::Arc::new(output);
956 if let Some(key) = cache_key {
957 self.cache.put(key, arc_output.clone());
958 }
959 {
961 let dc = self.disk_cache.clone();
962 let k = disk_key;
963 let v = arc_output.as_ref().clone();
964 let handle = tokio::task::spawn_blocking(move || {
965 dc.put("analyze_file", &k, &v);
966 dc.drain_write_failures()
967 });
968 let metrics_tx = self.metrics_tx.clone();
969 let sid = self.session_id.lock().await.clone();
970 tokio::spawn(async move {
971 if let Ok(failures) = handle.await
972 && failures > 0
973 {
974 tracing::warn!(
975 tool = "analyze_file",
976 failures,
977 "L2 disk cache write failed"
978 );
979 metrics_tx.send(crate::metrics::MetricEvent {
980 ts: crate::metrics::unix_ms(),
981 tool: "analyze_file",
982 duration_ms: 0,
983 output_chars: 0,
984 param_path_depth: 0,
985 max_depth: None,
986 result: "ok",
987 error_type: None,
988 session_id: sid,
989 seq: None,
990 cache_hit: None,
991 cache_write_failure: Some(true),
992 cache_tier: None,
993 exit_code: None,
994 timed_out: false,
995 output_truncated: None,
996 ..Default::default()
997 });
998 }
999 });
1000 }
1001 Ok((arc_output, CacheTier::Miss))
1002 }
1003 Err(e) => match &e {
1004 analyze::AnalyzeError::Parser(ParserError::UnsupportedLanguage(lang)) => {
1005 Err(ErrorData::new(
1006 rmcp::model::ErrorCode::INVALID_PARAMS,
1007 format!(
1008 "Unsupported language: {lang}. Supported extensions: {}",
1009 aptu_coder_core::lang::supported_extensions().join(", ")
1010 ),
1011 Some(error_meta(
1012 "invalid_request",
1013 false,
1014 "provide a file with a supported extension",
1015 )),
1016 ))
1017 }
1018 _ => Err(ErrorData::new(
1019 rmcp::model::ErrorCode::INTERNAL_ERROR,
1020 format!("Error analyzing file: {e}"),
1021 Some(error_meta(
1022 "resource",
1023 false,
1024 "check file path and permissions",
1025 )),
1026 )),
1027 },
1028 }
1029 }
1030
1031 fn validate_impl_only(entries: &[WalkEntry]) -> Result<(), ErrorData> {
1033 let has_rust = entries.iter().any(|e| {
1034 !e.is_dir
1035 && e.path
1036 .extension()
1037 .and_then(|x: &std::ffi::OsStr| x.to_str())
1038 == Some("rs")
1039 });
1040
1041 if !has_rust {
1042 return Err(ErrorData::new(
1043 rmcp::model::ErrorCode::INVALID_PARAMS,
1044 "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(),
1045 Some(error_meta(
1046 "validation",
1047 false,
1048 "remove impl_only or point to a directory containing .rs files",
1049 )),
1050 ));
1051 }
1052 Ok(())
1053 }
1054
1055 fn validate_import_lookup(import_lookup: Option<bool>, symbol: &str) -> Result<(), ErrorData> {
1057 if import_lookup == Some(true) && symbol.is_empty() {
1058 return Err(ErrorData::new(
1059 rmcp::model::ErrorCode::INVALID_PARAMS,
1060 "import_lookup=true requires symbol to contain the module path to search for"
1061 .to_string(),
1062 Some(error_meta(
1063 "validation",
1064 false,
1065 "set symbol to the module path when using import_lookup=true",
1066 )),
1067 ));
1068 }
1069 Ok(())
1070 }
1071
1072 #[allow(clippy::cast_precision_loss)] async fn poll_progress_until_done(
1075 &self,
1076 analysis_params: &FocusedAnalysisParams,
1077 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1078 ct: tokio_util::sync::CancellationToken,
1079 entries: std::sync::Arc<Vec<WalkEntry>>,
1080 total_files: usize,
1081 symbol_display: &str,
1082 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1083 let counter_clone = counter.clone();
1084 let ct_clone = ct.clone();
1085 let entries_clone = std::sync::Arc::clone(&entries);
1086 let path_owned = analysis_params.path.clone();
1087 let symbol_owned = analysis_params.symbol.clone();
1088 let match_mode_owned = analysis_params.match_mode.clone();
1089 let follow_depth = analysis_params.follow_depth;
1090 let max_depth = analysis_params.max_depth;
1091 let ast_recursion_limit = analysis_params.ast_recursion_limit;
1092 let use_summary = analysis_params.use_summary;
1093 let impl_only = analysis_params.impl_only;
1094 let def_use = analysis_params.def_use;
1095 let parse_timeout_micros = analysis_params.parse_timeout_micros;
1096 let handle = tokio::task::spawn_blocking(move || {
1097 let params = analyze::FocusedAnalysisConfig {
1098 focus: symbol_owned,
1099 match_mode: match_mode_owned,
1100 follow_depth,
1101 max_depth,
1102 ast_recursion_limit,
1103 use_summary,
1104 impl_only,
1105 def_use,
1106 parse_timeout_micros,
1107 };
1108 analyze::analyze_focused_with_progress_with_entries(
1109 &path_owned,
1110 ¶ms,
1111 &counter_clone,
1112 &ct_clone,
1113 &entries_clone,
1114 )
1115 });
1116
1117 let token = ProgressToken(NumberOrString::String(
1118 format!(
1119 "analyze-symbol-{}",
1120 std::time::SystemTime::now()
1121 .duration_since(std::time::UNIX_EPOCH)
1122 .map(|d| d.as_nanos())
1123 .unwrap_or(0)
1124 )
1125 .into(),
1126 ));
1127 let peer = self.peer.lock().await.clone();
1128 let mut last_progress = 0usize;
1129 let mut cancelled = false;
1130
1131 loop {
1132 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1133 if ct.is_cancelled() {
1134 cancelled = true;
1135 break;
1136 }
1137 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
1138 if current != last_progress && total_files > 0 {
1139 self.emit_progress(
1140 peer.clone(),
1141 &token,
1142 current as f64,
1143 total_files as f64,
1144 format!(
1145 "Analyzing {current}/{total_files} files for symbol '{symbol_display}'"
1146 ),
1147 )
1148 .await;
1149 last_progress = current;
1150 }
1151 if handle.is_finished() {
1152 break;
1153 }
1154 }
1155
1156 if !cancelled && total_files > 0 {
1157 self.emit_progress(
1158 peer.clone(),
1159 &token,
1160 total_files as f64,
1161 total_files as f64,
1162 format!("Completed analyzing {total_files} files for symbol '{symbol_display}'"),
1163 )
1164 .await;
1165 }
1166
1167 match handle.await {
1168 Ok(Ok(output)) => Ok(output),
1169 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
1170 rmcp::model::ErrorCode::INTERNAL_ERROR,
1171 "Analysis cancelled".to_string(),
1172 Some(error_meta("transient", true, "analysis was cancelled")),
1173 )),
1174 Ok(Err(e)) => Err(ErrorData::new(
1175 rmcp::model::ErrorCode::INTERNAL_ERROR,
1176 format!("Error analyzing symbol: {e}"),
1177 Some(error_meta("resource", false, "check symbol name and file")),
1178 )),
1179 Err(e) => Err(ErrorData::new(
1180 rmcp::model::ErrorCode::INTERNAL_ERROR,
1181 format!("Task join error: {e}"),
1182 Some(error_meta("transient", true, "retry the request")),
1183 )),
1184 }
1185 }
1186
1187 async fn run_focused_with_auto_summary(
1189 &self,
1190 params: &AnalyzeSymbolParams,
1191 analysis_params: &FocusedAnalysisParams,
1192 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1193 ct: tokio_util::sync::CancellationToken,
1194 entries: std::sync::Arc<Vec<WalkEntry>>,
1195 total_files: usize,
1196 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1197 let use_summary_for_task = params.output_control.force != Some(true)
1198 && params.output_control.summary == Some(true);
1199
1200 let analysis_params_initial = FocusedAnalysisParams {
1201 use_summary: use_summary_for_task,
1202 ..analysis_params.clone()
1203 };
1204
1205 let mut output = self
1206 .poll_progress_until_done(
1207 &analysis_params_initial,
1208 counter.clone(),
1209 ct.clone(),
1210 entries.clone(),
1211 total_files,
1212 ¶ms.symbol,
1213 )
1214 .await?;
1215
1216 if params.output_control.summary.is_none()
1217 && params.output_control.force != Some(true)
1218 && output.formatted.len() > SIZE_LIMIT
1219 {
1220 tracing::debug!(
1221 auto_summary = true,
1222 message = "output exceeded size limit, retrying with summary"
1223 );
1224 let counter2 = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1225 let analysis_params_retry = FocusedAnalysisParams {
1226 use_summary: true,
1227 ..analysis_params.clone()
1228 };
1229 let summary_result = self
1230 .poll_progress_until_done(
1231 &analysis_params_retry,
1232 counter2,
1233 ct,
1234 entries,
1235 total_files,
1236 ¶ms.symbol,
1237 )
1238 .await;
1239
1240 if let Ok(summary_output) = summary_result {
1241 output.formatted = summary_output.formatted;
1242 } else {
1243 let estimated_tokens = output.formatted.len() / 4;
1244 let message = format!(
1245 "Output exceeds 50K chars ({} chars, ~{} tokens). Use summary=true or force=true.",
1246 output.formatted.len(),
1247 estimated_tokens
1248 );
1249 return Err(ErrorData::new(
1250 rmcp::model::ErrorCode::INVALID_PARAMS,
1251 message,
1252 Some(error_meta(
1253 "validation",
1254 false,
1255 "use summary=true or force=true",
1256 )),
1257 ));
1258 }
1259 } else if output.formatted.len() > SIZE_LIMIT
1260 && params.output_control.force != Some(true)
1261 && params.output_control.summary == Some(false)
1262 {
1263 let estimated_tokens = output.formatted.len() / 4;
1264 let message = format!(
1265 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1266 - force=true to return full output\n\
1267 - summary=true to get compact summary\n\
1268 - Narrow your scope (smaller directory, specific file)",
1269 output.formatted.len(),
1270 estimated_tokens
1271 );
1272 return Err(ErrorData::new(
1273 rmcp::model::ErrorCode::INVALID_PARAMS,
1274 message,
1275 Some(error_meta(
1276 "validation",
1277 false,
1278 "use force=true, summary=true, or narrow scope",
1279 )),
1280 ));
1281 }
1282
1283 Ok(output)
1284 }
1285
1286 #[instrument(skip(self, params, ct))]
1290 async fn handle_focused_mode(
1291 &self,
1292 params: &AnalyzeSymbolParams,
1293 ct: tokio_util::sync::CancellationToken,
1294 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1295 let path = Path::new(¶ms.path);
1296 let raw_entries = match walk_directory(path, params.max_depth) {
1297 Ok(e) => e,
1298 Err(e) => {
1299 return Err(ErrorData::new(
1300 rmcp::model::ErrorCode::INTERNAL_ERROR,
1301 format!("Failed to walk directory: {e}"),
1302 Some(error_meta(
1303 "resource",
1304 false,
1305 "check path permissions and availability",
1306 )),
1307 ));
1308 }
1309 };
1310 let filtered_entries = if let Some(ref git_ref) = params.git_ref
1312 && !git_ref.is_empty()
1313 {
1314 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
1315 ErrorData::new(
1316 rmcp::model::ErrorCode::INVALID_PARAMS,
1317 format!("git_ref filter failed: {e}"),
1318 Some(error_meta(
1319 "resource",
1320 false,
1321 "ensure git is installed and path is inside a git repository",
1322 )),
1323 )
1324 })?;
1325 filter_entries_by_git_ref(raw_entries, &changed, path)
1326 } else {
1327 raw_entries
1328 };
1329 let entries = std::sync::Arc::new(filtered_entries);
1330
1331 if params.impl_only == Some(true) {
1332 Self::validate_impl_only(&entries)?;
1333 }
1334
1335 let total_files = entries.iter().filter(|e| !e.is_dir).count();
1336 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1337
1338 let analysis_params = FocusedAnalysisParams {
1339 path: path.to_path_buf(),
1340 symbol: params.symbol.clone(),
1341 match_mode: params.match_mode.clone().unwrap_or_default(),
1342 follow_depth: params.follow_depth.unwrap_or(1),
1343 max_depth: params.max_depth,
1344 ast_recursion_limit: params.ast_recursion_limit,
1345 use_summary: false,
1346 impl_only: params.impl_only,
1347 def_use: params.def_use.unwrap_or(false),
1348 parse_timeout_micros: None,
1349 };
1350
1351 let mut output = self
1352 .run_focused_with_auto_summary(
1353 params,
1354 &analysis_params,
1355 counter,
1356 ct,
1357 entries,
1358 total_files,
1359 )
1360 .await?;
1361
1362 if params.impl_only == Some(true) {
1363 let filter_line = format!(
1364 "FILTER: impl_only=true ({} of {} callers shown)\n",
1365 output.impl_trait_caller_count, output.unfiltered_caller_count
1366 );
1367 output.formatted = format!("{}{}", filter_line, output.formatted);
1368
1369 if output.impl_trait_caller_count == 0 {
1370 output.formatted.push_str(
1371 "\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"
1372 );
1373 }
1374 }
1375
1376 Ok(output)
1377 }
1378
1379 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
1380 #[tool(
1381 name = "analyze_directory",
1382 title = "Analyze Directory",
1383 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?",
1384 output_schema = schema_for_type::<analyze::AnalysisOutput>(),
1385 annotations(
1386 title = "Analyze Directory",
1387 read_only_hint = true,
1388 destructive_hint = false,
1389 idempotent_hint = true,
1390 open_world_hint = false
1391 )
1392 )]
1393 async fn analyze_directory(
1394 &self,
1395 params: Parameters<AnalyzeDirectoryParams>,
1396 context: RequestContext<RoleServer>,
1397 ) -> Result<CallToolResult, ErrorData> {
1398 let params = params.0;
1399 let session_id = self.session_id.lock().await.clone();
1401 let client_name = self.client_name.lock().await.clone();
1402 let client_version = self.client_version.lock().await.clone();
1403 extract_and_set_trace_context(
1404 Some(&context.meta),
1405 ClientMetadata {
1406 session_id,
1407 client_name,
1408 client_version,
1409 },
1410 );
1411 let span = tracing::Span::current();
1412 span.record("gen_ai.system", "mcp");
1413 span.record("gen_ai.operation.name", "execute_tool");
1414 span.record("gen_ai.tool.name", "analyze_directory");
1415 span.record("path", ¶ms.path);
1416 let _validated_path = match validate_path(¶ms.path, true) {
1417 Ok(p) => p,
1418 Err(e) => {
1419 span.record("error", true);
1420 span.record("error.type", "invalid_params");
1421 return Ok(err_to_tool_result(e));
1422 }
1423 };
1424 let ct = context.ct.clone();
1425 let t_start = std::time::Instant::now();
1426 let param_path = params.path.clone();
1427 let max_depth_val = params.max_depth;
1428 let seq = self
1429 .session_call_seq
1430 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1431 let sid = self.session_id.lock().await.clone();
1432
1433 let (arc_output, dir_cache_hit) = match self.handle_overview_mode(¶ms, ct).await {
1435 Ok(v) => v,
1436 Err(e) => {
1437 span.record("error", true);
1438 span.record("error.type", "internal_error");
1439 return Ok(err_to_tool_result(e));
1440 }
1441 };
1442 let mut output = match std::sync::Arc::try_unwrap(arc_output) {
1445 Ok(owned) => owned,
1446 Err(arc) => (*arc).clone(),
1447 };
1448
1449 if summary_cursor_conflict(
1452 params.output_control.summary,
1453 params.pagination.cursor.as_deref(),
1454 ) {
1455 span.record("error", true);
1456 span.record("error.type", "invalid_params");
1457 return Ok(err_to_tool_result(ErrorData::new(
1458 rmcp::model::ErrorCode::INVALID_PARAMS,
1459 "summary=true is incompatible with a pagination cursor; use one or the other"
1460 .to_string(),
1461 Some(error_meta(
1462 "validation",
1463 false,
1464 "remove cursor or set summary=false",
1465 )),
1466 )));
1467 }
1468
1469 let use_summary = if params.output_control.force == Some(true) {
1471 false
1472 } else if params.output_control.summary == Some(true) {
1473 true
1474 } else if params.output_control.summary == Some(false) {
1475 false
1476 } else {
1477 output.formatted.len() > SIZE_LIMIT
1478 };
1479
1480 if use_summary {
1481 output.formatted = format_summary(
1482 &output.entries,
1483 &output.files,
1484 params.max_depth,
1485 output.subtree_counts.as_deref(),
1486 );
1487 }
1488
1489 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1491 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1492 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1493 ErrorData::new(
1494 rmcp::model::ErrorCode::INVALID_PARAMS,
1495 e.to_string(),
1496 Some(error_meta("validation", false, "invalid cursor format")),
1497 )
1498 }) {
1499 Ok(v) => v,
1500 Err(e) => {
1501 span.record("error", true);
1502 span.record("error.type", "invalid_params");
1503 return Ok(err_to_tool_result(e));
1504 }
1505 };
1506 cursor_data.offset
1507 } else {
1508 0
1509 };
1510
1511 let paginated =
1513 match paginate_slice(&output.files, offset, page_size, PaginationMode::Default) {
1514 Ok(v) => v,
1515 Err(e) => {
1516 span.record("error", true);
1517 span.record("error.type", "internal_error");
1518 return Ok(err_to_tool_result(ErrorData::new(
1519 rmcp::model::ErrorCode::INTERNAL_ERROR,
1520 e.to_string(),
1521 Some(error_meta("transient", true, "retry the request")),
1522 )));
1523 }
1524 };
1525
1526 let verbose = params.output_control.verbose.unwrap_or(false);
1527 if !use_summary {
1528 output.formatted = format_structure_paginated(
1529 &paginated.items,
1530 paginated.total,
1531 params.max_depth,
1532 Some(Path::new(¶ms.path)),
1533 verbose,
1534 );
1535 }
1536
1537 if use_summary {
1539 output.next_cursor = None;
1540 } else {
1541 output.next_cursor.clone_from(&paginated.next_cursor);
1542 }
1543
1544 let mut final_text = output.formatted.clone();
1546 if !use_summary && let Some(cursor) = paginated.next_cursor {
1547 final_text.push('\n');
1548 final_text.push_str("NEXT_CURSOR: ");
1549 final_text.push_str(&cursor);
1550 }
1551
1552 tracing::Span::current().record("cache_tier", dir_cache_hit.as_str());
1554
1555 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1557 let mut meta = no_cache_meta().0;
1558 meta.insert(
1559 "content_hash".to_string(),
1560 serde_json::Value::String(content_hash),
1561 );
1562 let meta = rmcp::model::Meta(meta);
1563
1564 let mut result =
1565 CallToolResult::success(vec![Content::text(final_text.clone())]).with_meta(Some(meta));
1566 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1567 result.structured_content = Some(structured);
1568 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1569 self.metrics_tx.send(crate::metrics::MetricEvent {
1570 ts: crate::metrics::unix_ms(),
1571 tool: "analyze_directory",
1572 duration_ms: dur,
1573 output_chars: final_text.len(),
1574 param_path_depth: crate::metrics::path_component_count(¶m_path),
1575 max_depth: max_depth_val,
1576 result: "ok",
1577 error_type: None,
1578 session_id: sid,
1579 seq: Some(seq),
1580 cache_hit: Some(dir_cache_hit != CacheTier::Miss),
1581 cache_write_failure: None,
1582 cache_tier: Some(dir_cache_hit.as_str()),
1583 exit_code: None,
1584 timed_out: false,
1585 output_truncated: None,
1586 ..Default::default()
1587 });
1588 Ok(result)
1589 }
1590
1591 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
1592 #[tool(
1593 name = "analyze_file",
1594 title = "Analyze File",
1595 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.",
1596 output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
1597 annotations(
1598 title = "Analyze File",
1599 read_only_hint = true,
1600 destructive_hint = false,
1601 idempotent_hint = true,
1602 open_world_hint = false
1603 )
1604 )]
1605 async fn analyze_file(
1606 &self,
1607 params: Parameters<AnalyzeFileParams>,
1608 context: RequestContext<RoleServer>,
1609 ) -> Result<CallToolResult, ErrorData> {
1610 let params = params.0;
1611 let session_id = self.session_id.lock().await.clone();
1613 let client_name = self.client_name.lock().await.clone();
1614 let client_version = self.client_version.lock().await.clone();
1615 extract_and_set_trace_context(
1616 Some(&context.meta),
1617 ClientMetadata {
1618 session_id,
1619 client_name,
1620 client_version,
1621 },
1622 );
1623 let span = tracing::Span::current();
1624 span.record("gen_ai.system", "mcp");
1625 span.record("gen_ai.operation.name", "execute_tool");
1626 span.record("gen_ai.tool.name", "analyze_file");
1627 span.record("path", ¶ms.path);
1628 let _validated_path = match validate_path(¶ms.path, true) {
1629 Ok(p) => p,
1630 Err(e) => {
1631 span.record("error", true);
1632 span.record("error.type", "invalid_params");
1633 return Ok(err_to_tool_result(e));
1634 }
1635 };
1636 let t_start = std::time::Instant::now();
1637 let param_path = params.path.clone();
1638 let seq = self
1639 .session_call_seq
1640 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1641 let sid = self.session_id.lock().await.clone();
1642
1643 if std::path::Path::new(¶ms.path).is_dir() {
1645 span.record("error", true);
1646 span.record("error.type", "invalid_params");
1647 return Ok(err_to_tool_result(ErrorData::new(
1648 rmcp::model::ErrorCode::INVALID_PARAMS,
1649 format!(
1650 "'{}' is a directory; use analyze_directory instead",
1651 params.path
1652 ),
1653 Some(error_meta(
1654 "validation",
1655 false,
1656 "pass a file path, not a directory",
1657 )),
1658 )));
1659 }
1660
1661 if summary_cursor_conflict(
1663 params.output_control.summary,
1664 params.pagination.cursor.as_deref(),
1665 ) {
1666 span.record("error", true);
1667 span.record("error.type", "invalid_params");
1668 return Ok(err_to_tool_result(ErrorData::new(
1669 rmcp::model::ErrorCode::INVALID_PARAMS,
1670 "summary=true is incompatible with a pagination cursor; use one or the other"
1671 .to_string(),
1672 Some(error_meta(
1673 "validation",
1674 false,
1675 "remove cursor or set summary=false",
1676 )),
1677 )));
1678 }
1679
1680 let (arc_output, file_cache_hit) = match self.handle_file_details_mode(¶ms).await {
1682 Ok(v) => v,
1683 Err(e) => {
1684 span.record("error", true);
1685 span.record("error.type", "internal_error");
1686 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1687 let error_type = match e.code {
1688 rmcp::model::ErrorCode::INVALID_PARAMS => Some("invalid_params".to_string()),
1689 rmcp::model::ErrorCode::INTERNAL_ERROR => Some("internal_error".to_string()),
1690 _ => None,
1691 };
1692 self.metrics_tx.send(crate::metrics::MetricEvent {
1693 ts: crate::metrics::unix_ms(),
1694 tool: "analyze_file",
1695 duration_ms: dur,
1696 output_chars: 0,
1697 param_path_depth: crate::metrics::path_component_count(¶m_path),
1698 max_depth: None,
1699 result: "error",
1700 error_type,
1701 session_id: sid.clone(),
1702 seq: Some(seq),
1703 cache_hit: None,
1704 cache_write_failure: None,
1705 cache_tier: None,
1706 exit_code: None,
1707 timed_out: false,
1708 output_truncated: None,
1709 file_ext: crate::metrics::path_file_ext(¶m_path),
1710 ..Default::default()
1711 });
1712 return Ok(err_to_tool_result(e));
1713 }
1714 };
1715
1716 let mut formatted = arc_output.formatted.clone();
1720 let line_count = arc_output.line_count;
1721
1722 let use_summary = if params.output_control.force == Some(true) {
1724 false
1725 } else if params.output_control.summary == Some(true) {
1726 true
1727 } else if params.output_control.summary == Some(false) {
1728 false
1729 } else {
1730 formatted.len() > SIZE_LIMIT
1731 };
1732
1733 if use_summary {
1734 formatted = format_file_details_summary(&arc_output.semantic, ¶ms.path, line_count);
1735 } else if formatted.len() > SIZE_LIMIT && params.output_control.force != Some(true) {
1736 span.record("error", true);
1737 span.record("error.type", "invalid_params");
1738 let estimated_tokens = formatted.len() / 4;
1739 let message = format!(
1740 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1741 - force=true to return full output\n\
1742 - Use fields to limit output to specific sections (functions, classes, or imports)\n\
1743 - Use summary=true for a compact overview",
1744 formatted.len(),
1745 estimated_tokens
1746 );
1747 return Ok(err_to_tool_result(ErrorData::new(
1748 rmcp::model::ErrorCode::INVALID_PARAMS,
1749 message,
1750 Some(error_meta(
1751 "validation",
1752 false,
1753 "use force=true, fields, or summary=true",
1754 )),
1755 )));
1756 }
1757
1758 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1760 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1761 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1762 ErrorData::new(
1763 rmcp::model::ErrorCode::INVALID_PARAMS,
1764 e.to_string(),
1765 Some(error_meta("validation", false, "invalid cursor format")),
1766 )
1767 }) {
1768 Ok(v) => v,
1769 Err(e) => {
1770 span.record("error", true);
1771 span.record("error.type", "invalid_params");
1772 return Ok(err_to_tool_result(e));
1773 }
1774 };
1775 cursor_data.offset
1776 } else {
1777 0
1778 };
1779
1780 let top_level_fns: Vec<crate::types::FunctionInfo> = arc_output
1782 .semantic
1783 .functions
1784 .iter()
1785 .filter(|func| {
1786 !arc_output
1787 .semantic
1788 .classes
1789 .iter()
1790 .any(|class| func.line >= class.line && func.end_line <= class.end_line)
1791 })
1792 .cloned()
1793 .collect();
1794
1795 let paginated =
1797 match paginate_slice(&top_level_fns, offset, page_size, PaginationMode::Default) {
1798 Ok(v) => v,
1799 Err(e) => {
1800 return Ok(err_to_tool_result(ErrorData::new(
1801 rmcp::model::ErrorCode::INTERNAL_ERROR,
1802 e.to_string(),
1803 Some(error_meta("transient", true, "retry the request")),
1804 )));
1805 }
1806 };
1807
1808 let verbose = params.output_control.verbose.unwrap_or(false);
1810 if !use_summary {
1811 formatted = format_file_details_paginated(
1813 &paginated.items,
1814 paginated.total,
1815 &arc_output.semantic,
1816 ¶ms.path,
1817 line_count,
1818 offset,
1819 verbose,
1820 params.fields.as_deref(),
1821 );
1822 }
1823
1824 let next_cursor = if use_summary {
1826 None
1827 } else {
1828 paginated.next_cursor.clone()
1829 };
1830
1831 let mut final_text = formatted.clone();
1833 if !use_summary && let Some(ref cursor) = next_cursor {
1834 final_text.push('\n');
1835 final_text.push_str("NEXT_CURSOR: ");
1836 final_text.push_str(cursor);
1837 }
1838
1839 let response_output = analyze::FileAnalysisOutput::new(
1841 formatted,
1842 arc_output.semantic.clone(),
1843 line_count,
1844 next_cursor,
1845 );
1846
1847 tracing::Span::current().record("cache_tier", file_cache_hit.as_str());
1849
1850 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1852 let mut meta = no_cache_meta().0;
1853 meta.insert(
1854 "content_hash".to_string(),
1855 serde_json::Value::String(content_hash),
1856 );
1857 let meta = rmcp::model::Meta(meta);
1858
1859 let mut result =
1860 CallToolResult::success(vec![Content::text(final_text.clone())]).with_meta(Some(meta));
1861 let structured = serde_json::to_value(&response_output).unwrap_or(Value::Null);
1862 result.structured_content = Some(structured);
1863 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1864 self.metrics_tx.send(crate::metrics::MetricEvent {
1865 ts: crate::metrics::unix_ms(),
1866 tool: "analyze_file",
1867 duration_ms: dur,
1868 output_chars: final_text.len(),
1869 param_path_depth: crate::metrics::path_component_count(¶m_path),
1870 max_depth: None,
1871 result: "ok",
1872 error_type: None,
1873 session_id: sid,
1874 seq: Some(seq),
1875 cache_hit: Some(file_cache_hit != CacheTier::Miss),
1876 cache_write_failure: None,
1877 cache_tier: Some(file_cache_hit.as_str()),
1878 exit_code: None,
1879 timed_out: false,
1880 output_truncated: None,
1881 file_ext: crate::metrics::path_file_ext(¶m_path),
1882 ..Default::default()
1883 });
1884 Ok(result)
1885 }
1886
1887 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, symbol = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
1888 #[tool(
1889 name = "analyze_symbol",
1890 title = "Analyze Symbol",
1891 description = "Use when you need to: find all callers of a function across the codebase, trace transitive call chains, or locate all files importing a module path. Prefer over analyze_file when the question is \"who calls X\" or \"what does X call\" rather than \"what is in this file\".\n\nCall 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.",
1892 output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
1893 annotations(
1894 title = "Analyze Symbol",
1895 read_only_hint = true,
1896 destructive_hint = false,
1897 idempotent_hint = true,
1898 open_world_hint = false
1899 )
1900 )]
1901 async fn analyze_symbol(
1902 &self,
1903 params: Parameters<AnalyzeSymbolParams>,
1904 context: RequestContext<RoleServer>,
1905 ) -> Result<CallToolResult, ErrorData> {
1906 let params = params.0;
1907 let session_id = self.session_id.lock().await.clone();
1909 let client_name = self.client_name.lock().await.clone();
1910 let client_version = self.client_version.lock().await.clone();
1911 extract_and_set_trace_context(
1912 Some(&context.meta),
1913 ClientMetadata {
1914 session_id,
1915 client_name,
1916 client_version,
1917 },
1918 );
1919 let span = tracing::Span::current();
1920 span.record("gen_ai.system", "mcp");
1921 span.record("gen_ai.operation.name", "execute_tool");
1922 span.record("gen_ai.tool.name", "analyze_symbol");
1923 span.record("symbol", ¶ms.symbol);
1924 let _validated_path = match validate_path(¶ms.path, true) {
1925 Ok(p) => p,
1926 Err(e) => {
1927 span.record("error", true);
1928 span.record("error.type", "invalid_params");
1929 return Ok(err_to_tool_result(e));
1930 }
1931 };
1932 let ct = context.ct.clone();
1933 let t_start = std::time::Instant::now();
1934 let param_path = params.path.clone();
1935 let max_depth_val = params.follow_depth;
1936 let seq = self
1937 .session_call_seq
1938 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1939 let sid = self.session_id.lock().await.clone();
1940
1941 if std::path::Path::new(¶ms.path).is_file() {
1943 span.record("error", true);
1944 span.record("error.type", "invalid_params");
1945 return Ok(err_to_tool_result(ErrorData::new(
1946 rmcp::model::ErrorCode::INVALID_PARAMS,
1947 format!(
1948 "'{}' is a file; analyze_symbol requires a directory path",
1949 params.path
1950 ),
1951 Some(error_meta(
1952 "validation",
1953 false,
1954 "pass a directory path, not a file",
1955 )),
1956 )));
1957 }
1958
1959 if summary_cursor_conflict(
1961 params.output_control.summary,
1962 params.pagination.cursor.as_deref(),
1963 ) {
1964 span.record("error", true);
1965 span.record("error.type", "invalid_params");
1966 return Ok(err_to_tool_result(ErrorData::new(
1967 rmcp::model::ErrorCode::INVALID_PARAMS,
1968 "summary=true is incompatible with a pagination cursor; use one or the other"
1969 .to_string(),
1970 Some(error_meta(
1971 "validation",
1972 false,
1973 "remove cursor or set summary=false",
1974 )),
1975 )));
1976 }
1977
1978 if let Err(e) = Self::validate_import_lookup(params.import_lookup, ¶ms.symbol) {
1980 span.record("error", true);
1981 span.record("error.type", "invalid_params");
1982 return Ok(err_to_tool_result(e));
1983 }
1984
1985 if params.import_lookup == Some(true) {
1987 let path_owned = PathBuf::from(¶ms.path);
1988 let symbol = params.symbol.clone();
1989 let git_ref = params.git_ref.clone();
1990 let max_depth = params.max_depth;
1991 let ast_recursion_limit = params.ast_recursion_limit;
1992
1993 let handle = tokio::task::spawn_blocking(move || {
1994 let path = path_owned.as_path();
1995 let raw_entries = match walk_directory(path, max_depth) {
1996 Ok(e) => e,
1997 Err(e) => {
1998 return Err(ErrorData::new(
1999 rmcp::model::ErrorCode::INTERNAL_ERROR,
2000 format!("Failed to walk directory: {e}"),
2001 Some(error_meta(
2002 "resource",
2003 false,
2004 "check path permissions and availability",
2005 )),
2006 ));
2007 }
2008 };
2009 let entries = if let Some(ref git_ref_val) = git_ref
2011 && !git_ref_val.is_empty()
2012 {
2013 let changed = match changed_files_from_git_ref(path, git_ref_val) {
2014 Ok(c) => c,
2015 Err(e) => {
2016 return Err(ErrorData::new(
2017 rmcp::model::ErrorCode::INVALID_PARAMS,
2018 format!("git_ref filter failed: {e}"),
2019 Some(error_meta(
2020 "resource",
2021 false,
2022 "ensure git is installed and path is inside a git repository",
2023 )),
2024 ));
2025 }
2026 };
2027 filter_entries_by_git_ref(raw_entries, &changed, path)
2028 } else {
2029 raw_entries
2030 };
2031 let output = match analyze::analyze_import_lookup(
2032 path,
2033 &symbol,
2034 &entries,
2035 ast_recursion_limit,
2036 ) {
2037 Ok(v) => v,
2038 Err(e) => {
2039 return Err(ErrorData::new(
2040 rmcp::model::ErrorCode::INTERNAL_ERROR,
2041 format!("import_lookup failed: {e}"),
2042 Some(error_meta(
2043 "resource",
2044 false,
2045 "check path and file permissions",
2046 )),
2047 ));
2048 }
2049 };
2050 Ok(output)
2051 });
2052
2053 let output = match handle.await {
2054 Ok(Ok(v)) => v,
2055 Ok(Err(e)) => return Ok(err_to_tool_result(e)),
2056 Err(e) => {
2057 return Ok(err_to_tool_result(ErrorData::new(
2058 rmcp::model::ErrorCode::INTERNAL_ERROR,
2059 format!("spawn_blocking failed: {e}"),
2060 Some(error_meta("resource", false, "internal error")),
2061 )));
2062 }
2063 };
2064
2065 let final_text = output.formatted.clone();
2066
2067 tracing::Span::current().record("cache_tier", "Miss");
2069
2070 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
2072 let mut meta = no_cache_meta().0;
2073 meta.insert(
2074 "content_hash".to_string(),
2075 serde_json::Value::String(content_hash),
2076 );
2077
2078 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
2079 .with_meta(Some(Meta(meta)));
2080 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
2081 result.structured_content = Some(structured);
2082 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2083 self.metrics_tx.send(crate::metrics::MetricEvent {
2084 ts: crate::metrics::unix_ms(),
2085 tool: "analyze_symbol",
2086 duration_ms: dur,
2087 output_chars: final_text.len(),
2088 param_path_depth: crate::metrics::path_component_count(¶m_path),
2089 max_depth: max_depth_val,
2090 result: "ok",
2091 error_type: None,
2092 session_id: sid,
2093 seq: Some(seq),
2094 cache_hit: Some(false),
2095 cache_tier: Some(CacheTier::Miss.as_str()),
2096 cache_write_failure: None,
2097 exit_code: None,
2098 timed_out: false,
2099 output_truncated: None,
2100 ..Default::default()
2101 });
2102 return Ok(result);
2103 }
2104
2105 let mut output = match self.handle_focused_mode(¶ms, ct).await {
2107 Ok(v) => v,
2108 Err(e) => return Ok(err_to_tool_result(e)),
2109 };
2110
2111 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
2113 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
2114 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
2115 ErrorData::new(
2116 rmcp::model::ErrorCode::INVALID_PARAMS,
2117 e.to_string(),
2118 Some(error_meta("validation", false, "invalid cursor format")),
2119 )
2120 }) {
2121 Ok(v) => v,
2122 Err(e) => return Ok(err_to_tool_result(e)),
2123 };
2124 cursor_data.offset
2125 } else {
2126 0
2127 };
2128
2129 let cursor_mode = if let Some(ref cursor_str) = params.pagination.cursor {
2131 decode_cursor(cursor_str)
2132 .map(|c| c.mode)
2133 .unwrap_or(PaginationMode::Callers)
2134 } else {
2135 PaginationMode::Callers
2136 };
2137
2138 let mut use_summary = params.output_control.summary == Some(true);
2139 if params.output_control.force == Some(true) {
2140 use_summary = false;
2141 }
2142 let verbose = params.output_control.verbose.unwrap_or(false);
2143
2144 let mut callee_cursor = match cursor_mode {
2145 PaginationMode::Callers => {
2146 let (paginated_items, paginated_next) = match paginate_focus_chains(
2147 &output.prod_chains,
2148 PaginationMode::Callers,
2149 offset,
2150 page_size,
2151 ) {
2152 Ok(v) => v,
2153 Err(e) => return Ok(err_to_tool_result(e)),
2154 };
2155
2156 if !use_summary
2157 && (paginated_next.is_some()
2158 || offset > 0
2159 || !verbose
2160 || !output.outgoing_chains.is_empty())
2161 {
2162 let base_path = Path::new(¶ms.path);
2163 output.formatted = format_focused_paginated(
2164 &paginated_items,
2165 output.prod_chains.len(),
2166 PaginationMode::Callers,
2167 ¶ms.symbol,
2168 &output.prod_chains,
2169 &output.test_chains,
2170 &output.outgoing_chains,
2171 output.def_count,
2172 offset,
2173 Some(base_path),
2174 verbose,
2175 );
2176 paginated_next
2177 } else {
2178 None
2179 }
2180 }
2181 PaginationMode::Callees => {
2182 let (paginated_items, paginated_next) = match paginate_focus_chains(
2183 &output.outgoing_chains,
2184 PaginationMode::Callees,
2185 offset,
2186 page_size,
2187 ) {
2188 Ok(v) => v,
2189 Err(e) => return Ok(err_to_tool_result(e)),
2190 };
2191
2192 if paginated_next.is_some() || offset > 0 || !verbose {
2193 let base_path = Path::new(¶ms.path);
2194 output.formatted = format_focused_paginated(
2195 &paginated_items,
2196 output.outgoing_chains.len(),
2197 PaginationMode::Callees,
2198 ¶ms.symbol,
2199 &output.prod_chains,
2200 &output.test_chains,
2201 &output.outgoing_chains,
2202 output.def_count,
2203 offset,
2204 Some(base_path),
2205 verbose,
2206 );
2207 paginated_next
2208 } else {
2209 None
2210 }
2211 }
2212 PaginationMode::Default => {
2213 return Ok(err_to_tool_result(ErrorData::new(
2214 rmcp::model::ErrorCode::INVALID_PARAMS,
2215 "invalid cursor: unknown pagination mode".to_string(),
2216 Some(error_meta(
2217 "validation",
2218 false,
2219 "use a cursor returned by a previous analyze_symbol call",
2220 )),
2221 )));
2222 }
2223 PaginationMode::DefUse => {
2224 let total_sites = output.def_use_sites.len();
2225 let (paginated_sites, paginated_next) = match paginate_slice(
2226 &output.def_use_sites,
2227 offset,
2228 page_size,
2229 PaginationMode::DefUse,
2230 ) {
2231 Ok(r) => (r.items, r.next_cursor),
2232 Err(e) => return Ok(err_to_tool_result_from_pagination(e)),
2233 };
2234
2235 if !use_summary {
2238 let base_path = Path::new(¶ms.path);
2239 output.formatted = format_focused_paginated_defuse(
2240 &paginated_sites,
2241 total_sites,
2242 ¶ms.symbol,
2243 offset,
2244 Some(base_path),
2245 verbose,
2246 );
2247 }
2248
2249 output.def_use_sites = paginated_sites;
2252
2253 paginated_next
2254 }
2255 };
2256
2257 if callee_cursor.is_none()
2262 && cursor_mode == PaginationMode::Callers
2263 && !output.outgoing_chains.is_empty()
2264 && !use_summary
2265 && let Ok(cursor) = encode_cursor(&CursorData {
2266 mode: PaginationMode::Callees,
2267 offset: 0,
2268 })
2269 {
2270 callee_cursor = Some(cursor);
2271 }
2272
2273 if callee_cursor.is_none()
2280 && matches!(
2281 cursor_mode,
2282 PaginationMode::Callees | PaginationMode::Callers
2283 )
2284 && !output.def_use_sites.is_empty()
2285 && !use_summary
2286 && let Ok(cursor) = encode_cursor(&CursorData {
2287 mode: PaginationMode::DefUse,
2288 offset: 0,
2289 })
2290 {
2291 if cursor_mode == PaginationMode::Callees || output.outgoing_chains.is_empty() {
2294 callee_cursor = Some(cursor);
2295 }
2296 }
2297
2298 output.next_cursor.clone_from(&callee_cursor);
2300
2301 let mut final_text = output.formatted.clone();
2303 if let Some(cursor) = callee_cursor {
2304 final_text.push('\n');
2305 final_text.push_str("NEXT_CURSOR: ");
2306 final_text.push_str(&cursor);
2307 }
2308
2309 tracing::Span::current().record("cache_tier", "Miss");
2311
2312 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
2314 let mut meta = no_cache_meta().0;
2315 meta.insert(
2316 "content_hash".to_string(),
2317 serde_json::Value::String(content_hash),
2318 );
2319
2320 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
2321 .with_meta(Some(Meta(meta)));
2322 if cursor_mode != PaginationMode::DefUse {
2326 output.def_use_sites = Vec::new();
2327 }
2328 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
2329 result.structured_content = Some(structured);
2330 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2331 self.metrics_tx.send(crate::metrics::MetricEvent {
2332 ts: crate::metrics::unix_ms(),
2333 tool: "analyze_symbol",
2334 duration_ms: dur,
2335 output_chars: final_text.len(),
2336 param_path_depth: crate::metrics::path_component_count(¶m_path),
2337 max_depth: max_depth_val,
2338 result: "ok",
2339 error_type: None,
2340 session_id: sid,
2341 seq: Some(seq),
2342 cache_hit: Some(false),
2343 cache_tier: Some(CacheTier::Miss.as_str()),
2344 cache_write_failure: None,
2345 exit_code: None,
2346 timed_out: false,
2347 output_truncated: None,
2348 ..Default::default()
2349 });
2350 Ok(result)
2351 }
2352
2353 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
2354 #[tool(
2355 name = "analyze_module",
2356 title = "Analyze Module",
2357 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?",
2358 output_schema = schema_for_type::<types::ModuleInfo>(),
2359 annotations(
2360 title = "Analyze Module",
2361 read_only_hint = true,
2362 destructive_hint = false,
2363 idempotent_hint = true,
2364 open_world_hint = false
2365 )
2366 )]
2367 async fn analyze_module(
2368 &self,
2369 params: Parameters<AnalyzeModuleParams>,
2370 context: RequestContext<RoleServer>,
2371 ) -> Result<CallToolResult, ErrorData> {
2372 let params = params.0;
2373 let session_id = self.session_id.lock().await.clone();
2375 let client_name = self.client_name.lock().await.clone();
2376 let client_version = self.client_version.lock().await.clone();
2377 extract_and_set_trace_context(
2378 Some(&context.meta),
2379 ClientMetadata {
2380 session_id,
2381 client_name,
2382 client_version,
2383 },
2384 );
2385 let span = tracing::Span::current();
2386 span.record("gen_ai.system", "mcp");
2387 span.record("gen_ai.operation.name", "execute_tool");
2388 span.record("gen_ai.tool.name", "analyze_module");
2389 span.record("path", ¶ms.path);
2390 let _validated_path = match validate_path(¶ms.path, true) {
2391 Ok(p) => p,
2392 Err(e) => {
2393 span.record("error", true);
2394 span.record("error.type", "invalid_params");
2395 return Ok(err_to_tool_result(e));
2396 }
2397 };
2398 let t_start = std::time::Instant::now();
2399 let param_path = params.path.clone();
2400 let seq = self
2401 .session_call_seq
2402 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2403 let sid = self.session_id.lock().await.clone();
2404
2405 if std::fs::metadata(¶ms.path)
2407 .map(|m| m.is_dir())
2408 .unwrap_or(false)
2409 {
2410 span.record("error", true);
2411 span.record("error.type", "invalid_params");
2412 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2413 self.metrics_tx.send(crate::metrics::MetricEvent {
2414 ts: crate::metrics::unix_ms(),
2415 tool: "analyze_module",
2416 duration_ms: dur,
2417 output_chars: 0,
2418 param_path_depth: crate::metrics::path_component_count(¶m_path),
2419 max_depth: None,
2420 result: "error",
2421 error_type: Some("invalid_params".to_string()),
2422 session_id: sid.clone(),
2423 seq: Some(seq),
2424 cache_hit: None,
2425 cache_write_failure: None,
2426 cache_tier: None,
2427 exit_code: None,
2428 timed_out: false,
2429 output_truncated: None,
2430 ..Default::default()
2431 });
2432 return Ok(err_to_tool_result(ErrorData::new(
2433 rmcp::model::ErrorCode::INVALID_PARAMS,
2434 format!(
2435 "'{}' is a directory. Use analyze_directory to analyze a directory, or pass a specific file path to analyze_module.",
2436 params.path
2437 ),
2438 Some(error_meta(
2439 "validation",
2440 false,
2441 "use analyze_directory for directories",
2442 )),
2443 )));
2444 }
2445
2446 let mut analyze_file_params: AnalyzeFileParams = Default::default();
2448 analyze_file_params.path = params.path.clone();
2449 let (arc_output, module_tier) = match self
2450 .handle_file_details_mode(&analyze_file_params)
2451 .await
2452 {
2453 Ok((output, tier)) => (output, tier),
2454 Err(e) => {
2455 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2456 let error_type = match e.code {
2457 rmcp::model::ErrorCode::INVALID_PARAMS => Some("invalid_params".to_string()),
2458 rmcp::model::ErrorCode::INTERNAL_ERROR => Some("internal_error".to_string()),
2459 _ => None,
2460 };
2461 self.metrics_tx.send(crate::metrics::MetricEvent {
2462 ts: crate::metrics::unix_ms(),
2463 tool: "analyze_module",
2464 duration_ms: dur,
2465 output_chars: 0,
2466 param_path_depth: crate::metrics::path_component_count(¶m_path),
2467 max_depth: None,
2468 result: "error",
2469 error_type,
2470 session_id: sid.clone(),
2471 seq: Some(seq),
2472 cache_hit: None,
2473 cache_write_failure: None,
2474 cache_tier: None,
2475 exit_code: None,
2476 timed_out: false,
2477 output_truncated: None,
2478 file_ext: crate::metrics::path_file_ext(¶m_path),
2479 ..Default::default()
2480 });
2481 let error_data = match e.code {
2482 rmcp::model::ErrorCode::INVALID_PARAMS => e,
2483 _ => ErrorData::new(
2484 rmcp::model::ErrorCode::INTERNAL_ERROR,
2485 format!("Failed to analyze module: {}", e.message),
2486 Some(error_meta("internal", false, "report this as a bug")),
2487 ),
2488 };
2489 return Ok(err_to_tool_result(error_data));
2490 }
2491 };
2492
2493 let file_path = std::path::Path::new(¶ms.path);
2495 let name = file_path
2496 .file_name()
2497 .and_then(|n: &std::ffi::OsStr| n.to_str())
2498 .unwrap_or("unknown")
2499 .to_string();
2500 let language = file_path
2501 .extension()
2502 .and_then(|e| e.to_str())
2503 .and_then(aptu_coder_core::lang::language_for_extension)
2504 .unwrap_or("unknown")
2505 .to_string();
2506 let functions = arc_output
2507 .semantic
2508 .functions
2509 .iter()
2510 .map(|f| {
2511 let mut mfi = types::ModuleFunctionInfo::default();
2512 mfi.name = f.name.clone();
2513 mfi.line = f.line;
2514 mfi
2515 })
2516 .collect();
2517 let imports = arc_output
2518 .semantic
2519 .imports
2520 .iter()
2521 .map(|i| {
2522 let mut mii = types::ModuleImportInfo::default();
2523 mii.module = i.module.clone();
2524 mii.items = i.items.clone();
2525 mii
2526 })
2527 .collect();
2528 let module_info =
2529 types::ModuleInfo::new(name, arc_output.line_count, language, functions, imports);
2530
2531 let text = format_module_info(&module_info);
2532
2533 tracing::Span::current().record("cache_tier", module_tier.as_str());
2535
2536 let content_hash = format!("{}", blake3::hash(text.as_bytes()));
2538 let mut meta = no_cache_meta().0;
2539 meta.insert(
2540 "content_hash".to_string(),
2541 serde_json::Value::String(content_hash),
2542 );
2543
2544 let mut result =
2545 CallToolResult::success(vec![Content::text(text.clone())]).with_meta(Some(Meta(meta)));
2546 let structured = match serde_json::to_value(&module_info).map_err(|e| {
2547 ErrorData::new(
2548 rmcp::model::ErrorCode::INTERNAL_ERROR,
2549 format!("serialization failed: {e}"),
2550 Some(error_meta("internal", false, "report this as a bug")),
2551 )
2552 }) {
2553 Ok(v) => v,
2554 Err(e) => return Ok(err_to_tool_result(e)),
2555 };
2556 result.structured_content = Some(structured);
2557 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2558 self.metrics_tx.send(crate::metrics::MetricEvent {
2559 ts: crate::metrics::unix_ms(),
2560 tool: "analyze_module",
2561 duration_ms: dur,
2562 output_chars: text.len(),
2563 param_path_depth: crate::metrics::path_component_count(¶m_path),
2564 max_depth: None,
2565 result: "ok",
2566 error_type: None,
2567 session_id: sid,
2568 seq: Some(seq),
2569 cache_hit: Some(module_tier != CacheTier::Miss),
2570 cache_tier: Some(module_tier.as_str()),
2571 cache_write_failure: None,
2572 exit_code: None,
2573 timed_out: false,
2574 output_truncated: None,
2575 file_ext: crate::metrics::path_file_ext(¶m_path),
2576 ..Default::default()
2577 });
2578 Ok(result)
2579 }
2580
2581 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
2582 #[tool(
2583 name = "edit_overwrite",
2584 title = "Edit Overwrite",
2585 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.",
2586 output_schema = schema_for_type::<EditOverwriteOutput>(),
2587 annotations(
2588 title = "Edit Overwrite",
2589 read_only_hint = false,
2590 destructive_hint = true,
2591 idempotent_hint = false,
2592 open_world_hint = false
2593 )
2594 )]
2595 async fn edit_overwrite(
2596 &self,
2597 params: Parameters<EditOverwriteParams>,
2598 context: RequestContext<RoleServer>,
2599 ) -> Result<CallToolResult, ErrorData> {
2600 let params = params.0;
2601 let session_id = self.session_id.lock().await.clone();
2603 let client_name = self.client_name.lock().await.clone();
2604 let client_version = self.client_version.lock().await.clone();
2605 extract_and_set_trace_context(
2606 Some(&context.meta),
2607 ClientMetadata {
2608 session_id,
2609 client_name,
2610 client_version,
2611 },
2612 );
2613 let span = tracing::Span::current();
2614 span.record("gen_ai.system", "mcp");
2615 span.record("gen_ai.operation.name", "execute_tool");
2616 span.record("gen_ai.tool.name", "edit_overwrite");
2617 span.record("path", ¶ms.path);
2618 let _validated_path = if let Some(ref wd) = params.working_dir {
2619 match validate_path_in_dir(¶ms.path, false, std::path::Path::new(wd)) {
2620 Ok(p) => p,
2621 Err(e) => {
2622 span.record("error", true);
2623 span.record("error.type", "invalid_params");
2624 return Ok(err_to_tool_result(e));
2625 }
2626 }
2627 } else {
2628 match validate_path(¶ms.path, false) {
2629 Ok(p) => p,
2630 Err(e) => {
2631 span.record("error", true);
2632 span.record("error.type", "invalid_params");
2633 return Ok(err_to_tool_result(e));
2634 }
2635 }
2636 };
2637 let t_start = std::time::Instant::now();
2638 let param_path = params.path.clone();
2639 let seq = self
2640 .session_call_seq
2641 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2642 let sid = self.session_id.lock().await.clone();
2643
2644 if std::fs::metadata(¶ms.path)
2646 .map(|m| m.is_dir())
2647 .unwrap_or(false)
2648 {
2649 span.record("error", true);
2650 span.record("error.type", "invalid_params");
2651 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2652 self.metrics_tx.send(crate::metrics::MetricEvent {
2653 ts: crate::metrics::unix_ms(),
2654 tool: "edit_overwrite",
2655 duration_ms: dur,
2656 output_chars: 0,
2657 param_path_depth: crate::metrics::path_component_count(¶m_path),
2658 max_depth: None,
2659 result: "error",
2660 error_type: Some("invalid_params".to_string()),
2661 session_id: sid.clone(),
2662 seq: Some(seq),
2663 cache_hit: None,
2664 cache_write_failure: None,
2665 cache_tier: None,
2666 exit_code: None,
2667 timed_out: false,
2668 output_truncated: None,
2669 ..Default::default()
2670 });
2671 return Ok(err_to_tool_result(ErrorData::new(
2672 rmcp::model::ErrorCode::INVALID_PARAMS,
2673 "path is a directory; cannot write to a directory".to_string(),
2674 Some(error_meta(
2675 "validation",
2676 false,
2677 "provide a file path, not a directory",
2678 )),
2679 )));
2680 }
2681
2682 let path = std::path::PathBuf::from(¶ms.path);
2683 let content = params.content.clone();
2684 let handle = tokio::task::spawn_blocking(move || {
2685 aptu_coder_core::edit_overwrite_content(&path, &content)
2686 });
2687
2688 let output = match handle.await {
2689 Ok(Ok(v)) => v,
2690 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2691 span.record("error", true);
2692 span.record("error.type", "invalid_params");
2693 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2694 self.metrics_tx.send(crate::metrics::MetricEvent {
2695 ts: crate::metrics::unix_ms(),
2696 tool: "edit_overwrite",
2697 duration_ms: dur,
2698 output_chars: 0,
2699 param_path_depth: crate::metrics::path_component_count(¶m_path),
2700 max_depth: None,
2701 result: "error",
2702 error_type: Some("invalid_params".to_string()),
2703 session_id: sid.clone(),
2704 seq: Some(seq),
2705 cache_hit: None,
2706 cache_write_failure: None,
2707 cache_tier: None,
2708 exit_code: None,
2709 timed_out: false,
2710 output_truncated: None,
2711 ..Default::default()
2712 });
2713 return Ok(err_to_tool_result(ErrorData::new(
2714 rmcp::model::ErrorCode::INVALID_PARAMS,
2715 "path is a directory".to_string(),
2716 Some(error_meta(
2717 "validation",
2718 false,
2719 "provide a file path, not a directory",
2720 )),
2721 )));
2722 }
2723 Ok(Err(e)) => {
2724 span.record("error", true);
2725 span.record("error.type", "internal_error");
2726 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2727 self.metrics_tx.send(crate::metrics::MetricEvent {
2728 ts: crate::metrics::unix_ms(),
2729 tool: "edit_overwrite",
2730 duration_ms: dur,
2731 output_chars: 0,
2732 param_path_depth: crate::metrics::path_component_count(¶m_path),
2733 max_depth: None,
2734 result: "error",
2735 error_type: Some("internal_error".to_string()),
2736 session_id: sid.clone(),
2737 seq: Some(seq),
2738 cache_hit: None,
2739 cache_write_failure: None,
2740 cache_tier: None,
2741 exit_code: None,
2742 timed_out: false,
2743 output_truncated: None,
2744 ..Default::default()
2745 });
2746 return Ok(err_to_tool_result(ErrorData::new(
2747 rmcp::model::ErrorCode::INTERNAL_ERROR,
2748 e.to_string(),
2749 Some(error_meta(
2750 "resource",
2751 false,
2752 "check file path and permissions",
2753 )),
2754 )));
2755 }
2756 Err(e) => {
2757 span.record("error", true);
2758 span.record("error.type", "internal_error");
2759 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2760 self.metrics_tx.send(crate::metrics::MetricEvent {
2761 ts: crate::metrics::unix_ms(),
2762 tool: "edit_overwrite",
2763 duration_ms: dur,
2764 output_chars: 0,
2765 param_path_depth: crate::metrics::path_component_count(¶m_path),
2766 max_depth: None,
2767 result: "error",
2768 error_type: Some("internal_error".to_string()),
2769 session_id: sid.clone(),
2770 seq: Some(seq),
2771 cache_hit: None,
2772 cache_write_failure: None,
2773 cache_tier: None,
2774 exit_code: None,
2775 timed_out: false,
2776 output_truncated: None,
2777 ..Default::default()
2778 });
2779 return Ok(err_to_tool_result(ErrorData::new(
2780 rmcp::model::ErrorCode::INTERNAL_ERROR,
2781 e.to_string(),
2782 Some(error_meta(
2783 "resource",
2784 false,
2785 "check file path and permissions",
2786 )),
2787 )));
2788 }
2789 };
2790
2791 let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2792 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2793 .with_meta(Some(no_cache_meta()));
2794 let structured = match serde_json::to_value(&output).map_err(|e| {
2795 ErrorData::new(
2796 rmcp::model::ErrorCode::INTERNAL_ERROR,
2797 format!("serialization failed: {e}"),
2798 Some(error_meta("internal", false, "report this as a bug")),
2799 )
2800 }) {
2801 Ok(v) => v,
2802 Err(e) => return Ok(err_to_tool_result(e)),
2803 };
2804 result.structured_content = Some(structured);
2805 self.cache
2806 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2807 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2808 self.metrics_tx.send(crate::metrics::MetricEvent {
2809 ts: crate::metrics::unix_ms(),
2810 tool: "edit_overwrite",
2811 duration_ms: dur,
2812 output_chars: text.len(),
2813 param_path_depth: crate::metrics::path_component_count(¶m_path),
2814 max_depth: None,
2815 result: "ok",
2816 error_type: None,
2817 session_id: sid,
2818 seq: Some(seq),
2819 cache_hit: None,
2820 cache_write_failure: None,
2821 cache_tier: None,
2822 exit_code: None,
2823 timed_out: false,
2824 output_truncated: None,
2825 ..Default::default()
2826 });
2827 Ok(result)
2828 }
2829
2830 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
2831 #[tool(
2832 name = "edit_replace",
2833 title = "Edit Replace",
2834 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). If invalid_params is returned, re-read the target file with analyze_file or analyze_module before retrying. 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.",
2835 output_schema = schema_for_type::<EditReplaceOutput>(),
2836 annotations(
2837 title = "Edit Replace",
2838 read_only_hint = false,
2839 destructive_hint = true,
2840 idempotent_hint = false,
2841 open_world_hint = false
2842 )
2843 )]
2844 async fn edit_replace(
2845 &self,
2846 params: Parameters<EditReplaceParams>,
2847 context: RequestContext<RoleServer>,
2848 ) -> Result<CallToolResult, ErrorData> {
2849 let params = params.0;
2850 let session_id = self.session_id.lock().await.clone();
2852 let client_name = self.client_name.lock().await.clone();
2853 let client_version = self.client_version.lock().await.clone();
2854 extract_and_set_trace_context(
2855 Some(&context.meta),
2856 ClientMetadata {
2857 session_id,
2858 client_name,
2859 client_version,
2860 },
2861 );
2862 let span = tracing::Span::current();
2863 span.record("gen_ai.system", "mcp");
2864 span.record("gen_ai.operation.name", "execute_tool");
2865 span.record("gen_ai.tool.name", "edit_replace");
2866 span.record("path", ¶ms.path);
2867 let _validated_path = if let Some(ref wd) = params.working_dir {
2868 match validate_path_in_dir(¶ms.path, true, std::path::Path::new(wd)) {
2869 Ok(p) => p,
2870 Err(e) => {
2871 span.record("error", true);
2872 span.record("error.type", "invalid_params");
2873 return Ok(err_to_tool_result(e));
2874 }
2875 }
2876 } else {
2877 match validate_path(¶ms.path, true) {
2878 Ok(p) => p,
2879 Err(e) => {
2880 span.record("error", true);
2881 span.record("error.type", "invalid_params");
2882 return Ok(err_to_tool_result(e));
2883 }
2884 }
2885 };
2886 let t_start = std::time::Instant::now();
2887 let param_path = params.path.clone();
2888 let seq = self
2889 .session_call_seq
2890 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2891 let sid = self.session_id.lock().await.clone();
2892
2893 if std::fs::metadata(¶ms.path)
2895 .map(|m| m.is_dir())
2896 .unwrap_or(false)
2897 {
2898 span.record("error", true);
2899 span.record("error.type", "invalid_params");
2900 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2901 self.metrics_tx.send(crate::metrics::MetricEvent {
2902 ts: crate::metrics::unix_ms(),
2903 tool: "edit_replace",
2904 duration_ms: dur,
2905 output_chars: 0,
2906 param_path_depth: crate::metrics::path_component_count(¶m_path),
2907 max_depth: None,
2908 result: "error",
2909 error_type: Some("invalid_params".to_string()),
2910 session_id: sid.clone(),
2911 seq: Some(seq),
2912 cache_hit: None,
2913 cache_write_failure: None,
2914 cache_tier: None,
2915 exit_code: None,
2916 timed_out: false,
2917 output_truncated: None,
2918 ..Default::default()
2919 });
2920 return Ok(err_to_tool_result(ErrorData::new(
2921 rmcp::model::ErrorCode::INVALID_PARAMS,
2922 "path is a directory; cannot edit a directory".to_string(),
2923 Some(error_meta(
2924 "validation",
2925 false,
2926 "provide a file path, not a directory",
2927 )),
2928 )));
2929 }
2930
2931 let path = std::path::PathBuf::from(¶ms.path);
2932 let old_text = params.old_text.clone();
2933 let new_text = params.new_text.clone();
2934 let handle = tokio::task::spawn_blocking(move || {
2935 aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2936 });
2937
2938 let output = match handle.await {
2939 Ok(Ok(v)) => v,
2940 Ok(Err(aptu_coder_core::EditError::NotFound {
2941 path: notfound_path,
2942 })) => {
2943 span.record("error", true);
2944 span.record("error.type", "invalid_params");
2945 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2946 self.metrics_tx.send(crate::metrics::MetricEvent {
2947 ts: crate::metrics::unix_ms(),
2948 tool: "edit_replace",
2949 duration_ms: dur,
2950 output_chars: 0,
2951 param_path_depth: crate::metrics::path_component_count(¶m_path),
2952 max_depth: None,
2953 result: "error",
2954 error_type: Some("invalid_params".to_string()),
2955 error_subtype: Some("not_found".to_string()),
2956 session_id: sid.clone(),
2957 seq: Some(seq),
2958 cache_hit: None,
2959 cache_write_failure: None,
2960 cache_tier: None,
2961 exit_code: None,
2962 timed_out: false,
2963 output_truncated: None,
2964 ..Default::default()
2965 });
2966 return Ok(err_to_tool_result(ErrorData::new(
2967 rmcp::model::ErrorCode::INVALID_PARAMS,
2968 format!(
2969 "old_text not found (0 matches) in {notfound_path}. Re-read the file with analyze_file or analyze_module to obtain the current content, then derive old_text from the live file before retrying."
2970 ),
2971 Some(error_meta(
2972 "validation",
2973 false,
2974 "re-read the file with analyze_file or analyze_module, then derive old_text from the live content",
2975 )),
2976 )));
2977 }
2978 Ok(Err(aptu_coder_core::EditError::Ambiguous {
2979 count,
2980 path: ambiguous_path,
2981 })) => {
2982 span.record("error", true);
2983 span.record("error.type", "invalid_params");
2984 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2985 self.metrics_tx.send(crate::metrics::MetricEvent {
2986 ts: crate::metrics::unix_ms(),
2987 tool: "edit_replace",
2988 duration_ms: dur,
2989 output_chars: 0,
2990 param_path_depth: crate::metrics::path_component_count(¶m_path),
2991 max_depth: None,
2992 result: "error",
2993 error_type: Some("invalid_params".to_string()),
2994 error_subtype: Some("ambiguous".to_string()),
2995 session_id: sid.clone(),
2996 seq: Some(seq),
2997 cache_hit: None,
2998 cache_write_failure: None,
2999 cache_tier: None,
3000 exit_code: None,
3001 timed_out: false,
3002 output_truncated: None,
3003 ..Default::default()
3004 });
3005 return Ok(err_to_tool_result(ErrorData::new(
3006 rmcp::model::ErrorCode::INVALID_PARAMS,
3007 format!(
3008 "old_text matched {count} locations in {ambiguous_path}. Extend old_text with more surrounding context to make it unique, or re-read with analyze_file to confirm the exact text."
3009 ),
3010 Some(error_meta(
3011 "validation",
3012 false,
3013 "extend old_text with more surrounding context, or re-read with analyze_file to confirm the exact text",
3014 )),
3015 )));
3016 }
3017 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
3018 span.record("error", true);
3019 span.record("error.type", "invalid_params");
3020 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3021 self.metrics_tx.send(crate::metrics::MetricEvent {
3022 ts: crate::metrics::unix_ms(),
3023 tool: "edit_replace",
3024 duration_ms: dur,
3025 output_chars: 0,
3026 param_path_depth: crate::metrics::path_component_count(¶m_path),
3027 max_depth: None,
3028 result: "error",
3029 error_type: Some("invalid_params".to_string()),
3030 session_id: sid.clone(),
3031 seq: Some(seq),
3032 cache_hit: None,
3033 cache_write_failure: None,
3034 cache_tier: None,
3035 exit_code: None,
3036 timed_out: false,
3037 output_truncated: None,
3038 ..Default::default()
3039 });
3040 return Ok(err_to_tool_result(ErrorData::new(
3041 rmcp::model::ErrorCode::INVALID_PARAMS,
3042 "path is a directory".to_string(),
3043 Some(error_meta(
3044 "validation",
3045 false,
3046 "provide a file path, not a directory",
3047 )),
3048 )));
3049 }
3050 Ok(Err(e)) => {
3051 span.record("error", true);
3052 span.record("error.type", "internal_error");
3053 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3054 self.metrics_tx.send(crate::metrics::MetricEvent {
3055 ts: crate::metrics::unix_ms(),
3056 tool: "edit_replace",
3057 duration_ms: dur,
3058 output_chars: 0,
3059 param_path_depth: crate::metrics::path_component_count(¶m_path),
3060 max_depth: None,
3061 result: "error",
3062 error_type: Some("internal_error".to_string()),
3063 session_id: sid.clone(),
3064 seq: Some(seq),
3065 cache_hit: None,
3066 cache_write_failure: None,
3067 cache_tier: None,
3068 exit_code: None,
3069 timed_out: false,
3070 output_truncated: None,
3071 ..Default::default()
3072 });
3073 return Ok(err_to_tool_result(ErrorData::new(
3074 rmcp::model::ErrorCode::INTERNAL_ERROR,
3075 e.to_string(),
3076 Some(error_meta(
3077 "resource",
3078 false,
3079 "check file path and permissions",
3080 )),
3081 )));
3082 }
3083 Err(e) => {
3084 span.record("error", true);
3085 span.record("error.type", "internal_error");
3086 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3087 self.metrics_tx.send(crate::metrics::MetricEvent {
3088 ts: crate::metrics::unix_ms(),
3089 tool: "edit_replace",
3090 duration_ms: dur,
3091 output_chars: 0,
3092 param_path_depth: crate::metrics::path_component_count(¶m_path),
3093 max_depth: None,
3094 result: "error",
3095 error_type: Some("internal_error".to_string()),
3096 session_id: sid.clone(),
3097 seq: Some(seq),
3098 cache_hit: None,
3099 cache_write_failure: None,
3100 cache_tier: None,
3101 exit_code: None,
3102 timed_out: false,
3103 output_truncated: None,
3104 ..Default::default()
3105 });
3106 return Ok(err_to_tool_result(ErrorData::new(
3107 rmcp::model::ErrorCode::INTERNAL_ERROR,
3108 e.to_string(),
3109 Some(error_meta(
3110 "resource",
3111 false,
3112 "check file path and permissions",
3113 )),
3114 )));
3115 }
3116 };
3117
3118 let text = format!(
3119 "Edited {}: {} bytes -> {} bytes",
3120 output.path, output.bytes_before, output.bytes_after
3121 );
3122 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
3123 .with_meta(Some(no_cache_meta()));
3124 let structured = match serde_json::to_value(&output).map_err(|e| {
3125 ErrorData::new(
3126 rmcp::model::ErrorCode::INTERNAL_ERROR,
3127 format!("serialization failed: {e}"),
3128 Some(error_meta("internal", false, "report this as a bug")),
3129 )
3130 }) {
3131 Ok(v) => v,
3132 Err(e) => return Ok(err_to_tool_result(e)),
3133 };
3134 result.structured_content = Some(structured);
3135 self.cache
3136 .invalidate_file(&std::path::PathBuf::from(¶m_path));
3137 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3138 self.metrics_tx.send(crate::metrics::MetricEvent {
3139 ts: crate::metrics::unix_ms(),
3140 tool: "edit_replace",
3141 duration_ms: dur,
3142 output_chars: text.len(),
3143 param_path_depth: crate::metrics::path_component_count(¶m_path),
3144 max_depth: None,
3145 result: "ok",
3146 error_type: None,
3147 session_id: sid,
3148 seq: Some(seq),
3149 cache_hit: None,
3150 cache_write_failure: None,
3151 cache_tier: None,
3152 exit_code: None,
3153 timed_out: false,
3154 output_truncated: None,
3155 ..Default::default()
3156 });
3157 Ok(result)
3158 }
3159
3160 #[tool(
3161 name = "exec_command",
3162 title = "Exec Command",
3163 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; stdout capped at 30 KB, stderr at 10 KB; 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.",
3164 output_schema = schema_for_type::<types::ShellOutput>(),
3165 annotations(
3166 title = "Exec Command",
3167 read_only_hint = false,
3168 destructive_hint = true,
3169 idempotent_hint = false,
3170 open_world_hint = true
3171 )
3172 )]
3173 #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, command = tracing::field::Empty, exit_code = tracing::field::Empty, timed_out = tracing::field::Empty, output_truncated = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
3174 pub async fn exec_command(
3175 &self,
3176 params: Parameters<types::ExecCommandParams>,
3177 context: RequestContext<RoleServer>,
3178 ) -> Result<CallToolResult, ErrorData> {
3179 let t_start = std::time::Instant::now();
3180 let params = params.0;
3181 let session_id = self.session_id.lock().await.clone();
3183 let client_name = self.client_name.lock().await.clone();
3184 let client_version = self.client_version.lock().await.clone();
3185 extract_and_set_trace_context(
3186 Some(&context.meta),
3187 ClientMetadata {
3188 session_id,
3189 client_name,
3190 client_version,
3191 },
3192 );
3193 let span = tracing::Span::current();
3194 span.record("gen_ai.system", "mcp");
3195 span.record("gen_ai.operation.name", "execute_tool");
3196 span.record("gen_ai.tool.name", "exec_command");
3197 span.record("command", ¶ms.command);
3198
3199 let working_dir_path = if let Some(ref wd) = params.working_dir {
3201 match validate_path(wd, true) {
3202 Ok(p) => {
3203 if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
3205 span.record("error", true);
3206 span.record("error.type", "invalid_params");
3207 return Ok(err_to_tool_result(ErrorData::new(
3208 rmcp::model::ErrorCode::INVALID_PARAMS,
3209 "working_dir must be a directory".to_string(),
3210 Some(error_meta(
3211 "validation",
3212 false,
3213 "provide a valid directory path",
3214 )),
3215 )));
3216 }
3217 Some(p)
3218 }
3219 Err(e) => {
3220 span.record("error", true);
3221 span.record("error.type", "invalid_params");
3222 return Ok(err_to_tool_result(e));
3223 }
3224 }
3225 } else {
3226 None
3227 };
3228
3229 let param_path = params.working_dir.clone();
3230 let seq = self
3231 .session_call_seq
3232 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3233 let sid = self.session_id.lock().await.clone();
3234
3235 if let Some(ref stdin_content) = params.stdin
3237 && stdin_content.len() > STDIN_MAX_BYTES
3238 {
3239 span.record("error", true);
3240 span.record("error.type", "invalid_params");
3241 return Ok(err_to_tool_result(ErrorData::new(
3242 rmcp::model::ErrorCode::INVALID_PARAMS,
3243 "stdin exceeds 1 MB limit".to_string(),
3244 Some(error_meta("validation", false, "reduce stdin content size")),
3245 )));
3246 }
3247
3248 let command = params.command.clone();
3249 let timeout_secs = params.timeout_secs;
3250
3251 let _cache_key = (
3253 command.clone(),
3254 working_dir_path
3255 .as_ref()
3256 .map(|p| p.display().to_string())
3257 .unwrap_or_default(),
3258 );
3259 let resolved_path_str = self.resolved_path.as_ref().as_deref();
3261 let output = run_exec_impl(
3262 command.clone(),
3263 working_dir_path.clone(),
3264 timeout_secs,
3265 params.memory_limit_mb,
3266 params.cpu_limit_secs,
3267 params.stdin.clone(),
3268 seq,
3269 resolved_path_str,
3270 &self.filter_table,
3271 )
3272 .await;
3273
3274 let exit_code = output.exit_code;
3275 let timed_out = output.timed_out;
3276 let mut output_truncated = output.output_truncated;
3277
3278 if let Some(code) = exit_code {
3280 span.record("exit_code", code);
3281 }
3282 span.record("timed_out", timed_out);
3283 span.record("output_truncated", output_truncated);
3284
3285 if output_truncated {
3287 tracing::debug!(truncated = true, message = "output truncated");
3288 }
3289
3290 let output_text = if output.interleaved.is_empty() {
3292 format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
3293 } else {
3294 format!("Output:\n{}", output.interleaved)
3295 };
3296
3297 let mut combined_truncated = false;
3302 let truncated_output_text = if output_text.len() > SIZE_LIMIT {
3303 combined_truncated = true;
3304 let tail_start = output_text.len().saturating_sub(SIZE_LIMIT);
3306 let safe_start = output_text[..tail_start].floor_char_boundary(tail_start);
3307 output_text[safe_start..].to_string()
3308 } else {
3309 output_text
3310 };
3311
3312 output_truncated = output_truncated || combined_truncated;
3314
3315 let text = format!(
3316 "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
3317 params.command,
3318 exit_code
3319 .map(|c| c.to_string())
3320 .unwrap_or_else(|| "null".to_string()),
3321 timed_out,
3322 output_truncated,
3323 truncated_output_text,
3324 );
3325
3326 let content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
3327
3328 let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
3333
3334 let mut result = if command_failed {
3335 CallToolResult::error(content_blocks)
3336 } else {
3337 CallToolResult::success(content_blocks)
3338 }
3339 .with_meta(Some(no_cache_meta()));
3340
3341 let structured = match serde_json::to_value(&output).map_err(|e| {
3342 ErrorData::new(
3343 rmcp::model::ErrorCode::INTERNAL_ERROR,
3344 format!("serialization failed: {e}"),
3345 Some(error_meta("internal", false, "report this as a bug")),
3346 )
3347 }) {
3348 Ok(v) => v,
3349 Err(e) => {
3350 span.record("error", true);
3351 span.record("error.type", "internal_error");
3352 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3353 self.metrics_tx.send(crate::metrics::MetricEvent {
3354 ts: crate::metrics::unix_ms(),
3355 tool: "exec_command",
3356 duration_ms: dur,
3357 output_chars: 0,
3358 param_path_depth: crate::metrics::path_component_count(
3359 param_path.as_deref().unwrap_or(""),
3360 ),
3361 max_depth: None,
3362 result: "error",
3363 error_type: Some("internal_error".to_string()),
3364 session_id: sid.clone(),
3365 seq: Some(seq),
3366 cache_hit: Some(false),
3367 cache_write_failure: None,
3368 cache_tier: None,
3369 exit_code,
3370 timed_out,
3371 output_truncated: Some(output_truncated),
3372 ..Default::default()
3373 });
3374 return Ok(err_to_tool_result(e));
3375 }
3376 };
3377
3378 result.structured_content = Some(structured);
3379 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3380 self.metrics_tx.send(crate::metrics::MetricEvent {
3381 ts: crate::metrics::unix_ms(),
3382 tool: "exec_command",
3383 duration_ms: dur,
3384 output_chars: text.len(),
3385 param_path_depth: crate::metrics::path_component_count(
3386 param_path.as_deref().unwrap_or(""),
3387 ),
3388 max_depth: None,
3389 result: "ok",
3390 error_type: None,
3391 error_subtype: None,
3392 session_id: sid,
3393 seq: Some(seq),
3394 cache_hit: Some(false),
3395 cache_write_failure: None,
3396 cache_tier: None,
3397 exit_code,
3398 timed_out,
3399 output_truncated: Some(output_truncated),
3400 chars_threshold_breach: text.len() > 30_000,
3401 file_ext: None,
3402 });
3403 Ok(result)
3404 }
3405}
3406
3407fn build_exec_command(
3409 command: &str,
3410 working_dir_path: Option<&std::path::PathBuf>,
3411 memory_limit_mb: Option<u64>,
3412 cpu_limit_secs: Option<u64>,
3413 stdin_present: bool,
3414 resolved_path: Option<&str>,
3415) -> tokio::process::Command {
3416 let shell = resolve_shell();
3417 let mut cmd = tokio::process::Command::new(shell);
3418 cmd.arg("-c").arg(command);
3419
3420 if let Some(wd) = working_dir_path {
3421 cmd.current_dir(wd);
3422 }
3423
3424 if let Some(path) = resolved_path {
3426 cmd.env("PATH", path);
3427 }
3428
3429 cmd.stdout(std::process::Stdio::piped())
3430 .stderr(std::process::Stdio::piped());
3431
3432 if stdin_present {
3433 cmd.stdin(std::process::Stdio::piped());
3434 } else {
3435 cmd.stdin(std::process::Stdio::null());
3436 }
3437
3438 #[cfg(unix)]
3439 {
3440 #[cfg(not(target_os = "linux"))]
3441 if memory_limit_mb.is_some() {
3442 warn!("memory_limit_mb is not enforced on this platform (Linux only)");
3443 }
3444 if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
3445 unsafe {
3449 cmd.pre_exec(move || {
3450 #[cfg(target_os = "linux")]
3451 if let Some(mb) = memory_limit_mb {
3452 let bytes = mb.saturating_mul(1024 * 1024);
3453 setrlimit(Resource::RLIMIT_AS, bytes, bytes)
3454 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3455 }
3456 if let Some(cpu) = cpu_limit_secs {
3457 setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
3458 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3459 }
3460 Ok(())
3461 });
3462 }
3463 }
3464 }
3465
3466 cmd
3467}
3468
3469async fn run_with_timeout(
3472 mut child: tokio::process::Child,
3473 timeout_secs: Option<u64>,
3474 tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
3475) -> (Option<i32>, bool, bool, Option<String>) {
3476 use tokio::io::AsyncBufReadExt as _;
3477 use tokio_stream::StreamExt as TokioStreamExt;
3478 use tokio_stream::wrappers::LinesStream;
3479
3480 let stdout_pipe = child.stdout.take();
3481 let stderr_pipe = child.stderr.take();
3482
3483 let mut drain_task = tokio::spawn(async move {
3484 let so_stream = stdout_pipe.map(|p| {
3485 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (false, s)))
3486 });
3487 let se_stream = stderr_pipe.map(|p| {
3488 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3489 });
3490
3491 match (so_stream, se_stream) {
3492 (Some(so), Some(se)) => {
3493 let mut merged = so.merge(se);
3494 while let Some(Ok((is_stderr, line))) = merged.next().await {
3495 let _ = tx.send((is_stderr, line));
3496 }
3497 }
3498 (Some(so), None) => {
3499 let mut stream = so;
3500 while let Some(Ok((_, line))) = stream.next().await {
3501 let _ = tx.send((false, line));
3502 }
3503 }
3504 (None, Some(se)) => {
3505 let mut stream = se;
3506 while let Some(Ok((_, line))) = stream.next().await {
3507 let _ = tx.send((true, line));
3508 }
3509 }
3510 (None, None) => {}
3511 }
3512 });
3513
3514 tokio::select! {
3515 _ = &mut drain_task => {
3516 let (status, drain_truncated) = match tokio::time::timeout(
3517 std::time::Duration::from_millis(500),
3518 child.wait()
3519 ).await {
3520 Ok(Ok(s)) => (Some(s), false),
3521 Ok(Err(_)) => (None, false),
3522 Err(_) => {
3523 child.start_kill().ok();
3524 let _ = child.wait().await;
3525 (None, true)
3526 }
3527 };
3528 let exit_code = status.and_then(|s| s.code());
3529 let ocerr = if drain_truncated {
3530 Some("post-exit drain timeout: background process held pipes".to_string())
3531 } else {
3532 None
3533 };
3534 (exit_code, false, drain_truncated, ocerr)
3535 }
3536 _ = async {
3537 if let Some(secs) = timeout_secs {
3538 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3539 } else {
3540 std::future::pending::<()>().await;
3541 }
3542 } => {
3543 let _ = child.kill().await;
3544 let _ = child.wait().await;
3545 drain_task.abort();
3546 (None, true, false, None)
3547 }
3548 }
3549}
3550
3551#[allow(clippy::too_many_arguments)]
3555async fn run_exec_impl(
3556 command: String,
3557 working_dir_path: Option<std::path::PathBuf>,
3558 timeout_secs: Option<u64>,
3559 memory_limit_mb: Option<u64>,
3560 cpu_limit_secs: Option<u64>,
3561 stdin: Option<String>,
3562 seq: u32,
3563 resolved_path: Option<&str>,
3564 filter_table: &Arc<Vec<CompiledRule>>,
3565) -> types::ShellOutput {
3566 let command = maybe_inject_no_stat(&command);
3568
3569 let mut cmd = build_exec_command(
3570 &command,
3571 working_dir_path.as_ref(),
3572 memory_limit_mb,
3573 cpu_limit_secs,
3574 stdin.is_some(),
3575 resolved_path,
3576 );
3577
3578 let mut child = match cmd.spawn() {
3579 Ok(c) => c,
3580 Err(e) => {
3581 return types::ShellOutput::new(
3582 String::new(),
3583 format!("failed to spawn command: {e}"),
3584 format!("failed to spawn command: {e}"),
3585 None,
3586 false,
3587 false,
3588 );
3589 }
3590 };
3591
3592 if let Some(stdin_content) = stdin
3593 && let Some(mut stdin_handle) = child.stdin.take()
3594 {
3595 use tokio::io::AsyncWriteExt as _;
3596 match stdin_handle.write_all(stdin_content.as_bytes()).await {
3597 Ok(()) => {
3598 drop(stdin_handle);
3599 }
3600 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
3601 Err(e) => {
3602 warn!("failed to write stdin: {e}");
3603 }
3604 }
3605 }
3606
3607 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, String)>();
3608
3609 let (exit_code, timed_out, mut output_truncated, output_collection_error) =
3610 run_with_timeout(child, timeout_secs, tx).await;
3611
3612 let mut lines: Vec<(bool, String)> = Vec::new();
3613 while let Some(item) = rx.recv().await {
3614 lines.push(item);
3615 }
3616
3617 const MAX_BYTES: usize = 50 * 1024;
3619 let mut stdout_str = String::new();
3620 let mut stderr_str = String::new();
3621 let mut interleaved_str = String::new();
3622 let mut so_bytes = 0usize;
3623 let mut se_bytes = 0usize;
3624 let mut il_bytes = 0usize;
3625 for (is_stderr, line) in &lines {
3626 let entry = format!("{line}\n");
3627 if il_bytes < 2 * MAX_BYTES {
3628 il_bytes += entry.len();
3629 interleaved_str.push_str(&entry);
3630 }
3631 if *is_stderr {
3632 if se_bytes < MAX_BYTES {
3633 se_bytes += entry.len();
3634 stderr_str.push_str(&entry);
3635 }
3636 } else if so_bytes < MAX_BYTES {
3637 so_bytes += entry.len();
3638 stdout_str.push_str(&entry);
3639 }
3640 }
3641
3642 let slot = seq % 8;
3643 let (stdout, stderr, stdout_path, stderr_path, byte_truncated) =
3644 handle_output_persist(stdout_str, stderr_str, slot);
3645 output_truncated = output_truncated || stdout_path.is_some() || byte_truncated;
3646
3647 let mut output = types::ShellOutput::new(
3648 stdout,
3649 stderr,
3650 interleaved_str,
3651 exit_code,
3652 timed_out,
3653 output_truncated,
3654 );
3655 output.output_collection_error = output_collection_error;
3656 output.stdout_path = stdout_path;
3657 output.stderr_path = stderr_path;
3658
3659 if exit_code == Some(0) && !timed_out {
3661 for compiled_rule in filter_table.iter() {
3662 if compiled_rule.pattern.is_match(&command) {
3663 let filtered_stdout = apply_filter(compiled_rule, &output.stdout);
3664 output.stdout = filtered_stdout;
3665 output.filter_applied = compiled_rule
3666 .rule
3667 .description
3668 .clone()
3669 .or_else(|| Some(compiled_rule.rule.match_command.clone()));
3670 break;
3671 }
3672 }
3673 }
3674
3675 output
3676}
3677
3678fn handle_output_persist(
3685 stdout: String,
3686 stderr: String,
3687 slot: u32,
3688) -> (String, String, Option<String>, Option<String>, bool) {
3689 const MAX_OUTPUT_LINES: usize = 2000;
3690 const MAX_STDOUT_BYTES: usize = 30_000;
3694 const MAX_STDERR_BYTES: usize = 10_000;
3695 const OVERFLOW_PREVIEW_LINES: usize = 50;
3696
3697 let stdout_lines: Vec<&str> = stdout.lines().collect();
3698 let stderr_lines: Vec<&str> = stderr.lines().collect();
3699
3700 let mut byte_truncated = false;
3701
3702 let line_overflow =
3704 stdout_lines.len() > MAX_OUTPUT_LINES || stderr_lines.len() > MAX_OUTPUT_LINES;
3705 let stdout_byte_overflow = stdout.len() > MAX_STDOUT_BYTES;
3706 let stderr_byte_overflow = stderr.len() > MAX_STDERR_BYTES;
3707 let byte_overflow = stdout_byte_overflow || stderr_byte_overflow;
3708
3709 if !line_overflow && !byte_overflow {
3711 return (stdout, stderr, None, None, false);
3712 }
3713
3714 let base = std::env::temp_dir()
3716 .join("aptu-coder-overflow")
3717 .join(format!("slot-{slot}"));
3718 let _ = std::fs::create_dir_all(&base);
3719
3720 let stdout_path = base.join("stdout");
3721 let stderr_path = base.join("stderr");
3722
3723 let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3724 let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3725
3726 let stdout_path_str = stdout_path.display().to_string();
3727 let stderr_path_str = stderr_path.display().to_string();
3728
3729 let stdout_preview = if stdout_byte_overflow {
3731 byte_truncated = true;
3732 let tail_start = stdout.len().saturating_sub(MAX_STDOUT_BYTES);
3734 let safe_start = stdout[..tail_start].floor_char_boundary(tail_start);
3735 stdout[safe_start..].to_string()
3736 } else if stdout_lines.len() > MAX_OUTPUT_LINES {
3737 stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3738 } else {
3739 stdout
3740 };
3741
3742 let stderr_preview = if stderr_byte_overflow {
3744 byte_truncated = true;
3745 let tail_start = stderr.len().saturating_sub(MAX_STDERR_BYTES);
3747 let safe_start = stderr[..tail_start].floor_char_boundary(tail_start);
3748 stderr[safe_start..].to_string()
3749 } else if stderr_lines.len() > MAX_OUTPUT_LINES {
3750 stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3751 } else {
3752 stderr
3753 };
3754
3755 (
3756 stdout_preview,
3757 stderr_preview,
3758 Some(stdout_path_str),
3759 Some(stderr_path_str),
3760 byte_truncated,
3761 )
3762}
3763
3764#[derive(Clone)]
3768struct FocusedAnalysisParams {
3769 path: std::path::PathBuf,
3770 symbol: String,
3771 match_mode: SymbolMatchMode,
3772 follow_depth: u32,
3773 max_depth: Option<u32>,
3774 ast_recursion_limit: Option<usize>,
3775 use_summary: bool,
3776 impl_only: Option<bool>,
3777 def_use: bool,
3778 parse_timeout_micros: Option<u64>,
3779}
3780
3781fn disable_routes(router: &mut ToolRouter<CodeAnalyzer>, tools: &[&'static str]) {
3782 for tool in tools {
3783 router.disable_route(*tool);
3784 }
3785}
3786
3787#[tool_handler]
3788impl ServerHandler for CodeAnalyzer {
3789 #[instrument(skip(self, context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
3790 async fn initialize(
3791 &self,
3792 request: InitializeRequestParams,
3793 context: RequestContext<RoleServer>,
3794 ) -> Result<InitializeResult, ErrorData> {
3795 let span = tracing::Span::current();
3796 span.record("service.name", "aptu-coder");
3797 span.record("service.version", env!("CARGO_PKG_VERSION"));
3798
3799 {
3801 let mut client_name_lock = self.client_name.lock().await;
3802 *client_name_lock = Some(request.client_info.name.clone());
3803 }
3804 {
3805 let mut client_version_lock = self.client_version.lock().await;
3806 *client_version_lock = Some(request.client_info.version.clone());
3807 }
3808
3809 if let Some(meta) = context.extensions.get::<Meta>() {
3812 let mut meta_lock = self.profile_meta.lock().await;
3813 *meta_lock = Some(meta.0.clone());
3814 }
3815 Ok(self.get_info())
3816 }
3817
3818 fn get_info(&self) -> InitializeResult {
3819 let excluded = crate::EXCLUDED_DIRS.join(", ");
3820 let instructions = format!(
3821 "Recommended workflow:\n\
3822 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3823 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\
3824 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3825 4. Use analyze_symbol to trace call graphs.\n\
3826 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."
3827 );
3828 let capabilities = ServerCapabilities::builder()
3829 .enable_logging()
3830 .enable_tools()
3831 .enable_tool_list_changed()
3832 .enable_completions()
3833 .build();
3834 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3835 .with_title("Aptu Coder")
3836 .with_description("MCP server for code structure analysis using tree-sitter");
3837 InitializeResult::new(capabilities)
3838 .with_server_info(server_info)
3839 .with_instructions(&instructions)
3840 }
3841
3842 async fn list_tools(
3843 &self,
3844 _request: Option<rmcp::model::PaginatedRequestParams>,
3845 _context: RequestContext<RoleServer>,
3846 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
3847 let router = self.tool_router.read().await;
3848 Ok(rmcp::model::ListToolsResult {
3849 tools: router.list_all(),
3850 meta: None,
3851 next_cursor: None,
3852 })
3853 }
3854
3855 async fn call_tool(
3856 &self,
3857 request: rmcp::model::CallToolRequestParams,
3858 context: RequestContext<RoleServer>,
3859 ) -> Result<CallToolResult, ErrorData> {
3860 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
3861 let router = self.tool_router.read().await;
3862 router.call(tcc).await
3863 }
3864
3865 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
3866 let mut peer_lock = self.peer.lock().await;
3867 *peer_lock = Some(context.peer.clone());
3868 drop(peer_lock);
3869
3870 let millis = std::time::SystemTime::now()
3872 .duration_since(std::time::UNIX_EPOCH)
3873 .unwrap_or_default()
3874 .as_millis()
3875 .try_into()
3876 .unwrap_or(u64::MAX);
3877 let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3878 let sid = format!("{millis}-{counter}");
3879 {
3880 let mut session_id_lock = self.session_id.lock().await;
3881 *session_id_lock = Some(sid);
3882 }
3883 self.session_call_seq
3884 .store(0, std::sync::atomic::Ordering::Relaxed);
3885
3886 let meta_lock = self.profile_meta.lock().await;
3896 let meta_profile = meta_lock
3897 .as_ref()
3898 .and_then(|m| m.get("io.clouatre-labs/profile"))
3899 .and_then(|v| v.as_str())
3900 .map(str::to_owned);
3901 drop(meta_lock);
3902
3903 let active_profile = meta_profile.or(std::env::var("APTU_CODER_PROFILE").ok());
3905
3906 {
3907 let mut router = self.tool_router.write().await;
3908
3909 if let Some(ref profile) = active_profile {
3913 match profile.as_str() {
3914 "edit" => {
3915 disable_routes(
3917 &mut router,
3918 &[
3919 "analyze_directory",
3920 "analyze_file",
3921 "analyze_module",
3922 "analyze_symbol",
3923 ],
3924 );
3925 }
3926 "analyze" => {
3927 disable_routes(&mut router, &["edit_replace", "edit_overwrite"]);
3929 }
3930 _ => {
3931 }
3933 }
3934 }
3935
3936 router.bind_peer_notifier(&context.peer);
3938 }
3939
3940 let peer = self.peer.clone();
3942 let event_rx = self.event_rx.clone();
3943
3944 tokio::spawn(async move {
3945 let rx = {
3946 let mut rx_lock = event_rx.lock().await;
3947 rx_lock.take()
3948 };
3949
3950 if let Some(mut receiver) = rx {
3951 let mut buffer = Vec::with_capacity(64);
3952 loop {
3953 receiver.recv_many(&mut buffer, 64).await;
3955
3956 if buffer.is_empty() {
3957 break;
3959 }
3960
3961 let peer_lock = peer.lock().await;
3963 if let Some(peer) = peer_lock.as_ref() {
3964 for log_event in buffer.drain(..) {
3965 let notification = ServerNotification::LoggingMessageNotification(
3966 Notification::new(LoggingMessageNotificationParam {
3967 level: log_event.level,
3968 logger: Some(log_event.logger),
3969 data: log_event.data,
3970 }),
3971 );
3972 if let Err(e) = peer.send_notification(notification).await {
3973 warn!("Failed to send logging notification: {}", e);
3974 }
3975 }
3976 }
3977 }
3978 }
3979 });
3980 }
3981
3982 #[instrument(skip(self, _context))]
3983 async fn on_cancelled(
3984 &self,
3985 notification: CancelledNotificationParam,
3986 _context: NotificationContext<RoleServer>,
3987 ) {
3988 tracing::info!(
3989 request_id = ?notification.request_id,
3990 reason = ?notification.reason,
3991 "Received cancellation notification"
3992 );
3993 }
3994
3995 #[instrument(skip(self, _context))]
3996 async fn complete(
3997 &self,
3998 request: CompleteRequestParams,
3999 _context: RequestContext<RoleServer>,
4000 ) -> Result<CompleteResult, ErrorData> {
4001 let argument_name = &request.argument.name;
4003 let argument_value = &request.argument.value;
4004
4005 let completions = match argument_name.as_str() {
4006 "path" => {
4007 let root = Path::new(".");
4009 completion::path_completions(root, argument_value)
4010 }
4011 "symbol" => {
4012 let path_arg = request
4014 .context
4015 .as_ref()
4016 .and_then(|ctx| ctx.get_argument("path"));
4017
4018 match path_arg {
4019 Some(path_str) => {
4020 let path = Path::new(path_str);
4021 completion::symbol_completions(&self.cache, path, argument_value)
4022 }
4023 None => Vec::new(),
4024 }
4025 }
4026 _ => Vec::new(),
4027 };
4028
4029 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
4031 let (values, has_more) = if completions.len() > 100 {
4032 (completions.into_iter().take(100).collect(), true)
4033 } else {
4034 (completions, false)
4035 };
4036
4037 let completion_info =
4038 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
4039 Ok(info) => info,
4040 Err(_) => {
4041 CompletionInfo::with_all_values(Vec::new())
4043 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
4044 }
4045 };
4046
4047 Ok(CompleteResult::new(completion_info))
4048 }
4049
4050 async fn set_level(
4051 &self,
4052 params: SetLevelRequestParams,
4053 _context: RequestContext<RoleServer>,
4054 ) -> Result<(), ErrorData> {
4055 let level_filter = match params.level {
4056 LoggingLevel::Debug => LevelFilter::DEBUG,
4057 LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
4058 LoggingLevel::Warning => LevelFilter::WARN,
4059 LoggingLevel::Error
4060 | LoggingLevel::Critical
4061 | LoggingLevel::Alert
4062 | LoggingLevel::Emergency => LevelFilter::ERROR,
4063 };
4064
4065 let mut filter_lock = self
4066 .log_level_filter
4067 .lock()
4068 .unwrap_or_else(|e| e.into_inner());
4069 *filter_lock = level_filter;
4070 Ok(())
4071 }
4072}
4073
4074#[cfg(test)]
4075mod tests {
4076 use super::*;
4077 use regex::Regex;
4078
4079 #[tokio::test]
4080 async fn test_emit_progress_none_peer_is_noop() {
4081 let peer = Arc::new(TokioMutex::new(None));
4082 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4083 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4084 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4085 let analyzer = CodeAnalyzer::new(
4086 peer,
4087 log_level_filter,
4088 rx,
4089 crate::metrics::MetricsSender(metrics_tx),
4090 );
4091 let token = ProgressToken(NumberOrString::String("test".into()));
4092 analyzer
4094 .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
4095 .await;
4096 }
4097
4098 fn make_analyzer() -> CodeAnalyzer {
4099 let peer = Arc::new(TokioMutex::new(None));
4100 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4101 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4102 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4103 CodeAnalyzer::new(
4104 peer,
4105 log_level_filter,
4106 rx,
4107 crate::metrics::MetricsSender(metrics_tx),
4108 )
4109 }
4110
4111 #[test]
4112 fn test_summary_cursor_conflict() {
4113 assert!(summary_cursor_conflict(Some(true), Some("cursor")));
4114 assert!(!summary_cursor_conflict(Some(true), None));
4115 assert!(!summary_cursor_conflict(None, Some("x")));
4116 assert!(!summary_cursor_conflict(None, None));
4117 }
4118
4119 #[tokio::test]
4120 async fn test_validate_impl_only_non_rust_returns_invalid_params() {
4121 use tempfile::TempDir;
4122
4123 let dir = TempDir::new().unwrap();
4124 std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
4125
4126 let analyzer = make_analyzer();
4127 let entries: Vec<traversal::WalkEntry> =
4130 traversal::walk_directory(dir.path(), None).unwrap_or_default();
4131 let result = CodeAnalyzer::validate_impl_only(&entries);
4132 assert!(result.is_err());
4133 let err = result.unwrap_err();
4134 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
4135 drop(analyzer); }
4137
4138 #[tokio::test]
4139 async fn test_no_cache_meta_on_analyze_directory_result() {
4140 use aptu_coder_core::types::{
4141 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4142 };
4143 use tempfile::TempDir;
4144
4145 let dir = TempDir::new().unwrap();
4146 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4147
4148 let analyzer = make_analyzer();
4149 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4150 "path": dir.path().to_str().unwrap(),
4151 }))
4152 .unwrap();
4153 let ct = tokio_util::sync::CancellationToken::new();
4154 let (arc_output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
4155 let meta = no_cache_meta();
4157 assert_eq!(
4158 meta.0.get("cache_hint").and_then(|v| v.as_str()),
4159 Some("no-cache"),
4160 );
4161 drop(arc_output);
4162 }
4163
4164 #[test]
4165 fn test_complete_path_completions_returns_suggestions() {
4166 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4171 let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
4172 let suggestions = completion::path_completions(workspace_root, "aptu-");
4173 assert!(
4174 !suggestions.is_empty(),
4175 "expected completions for prefix 'aptu-' in workspace root"
4176 );
4177 }
4178
4179 #[tokio::test]
4180 async fn test_handle_overview_mode_verbose_no_summary_block() {
4181 use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
4182 use aptu_coder_core::types::{
4183 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4184 };
4185 use tempfile::TempDir;
4186
4187 let tmp = TempDir::new().unwrap();
4188 std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
4189
4190 let peer = Arc::new(TokioMutex::new(None));
4191 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4192 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4193 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4194 let analyzer = CodeAnalyzer::new(
4195 peer,
4196 log_level_filter,
4197 rx,
4198 crate::metrics::MetricsSender(metrics_tx),
4199 );
4200
4201 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4202 "path": tmp.path().to_str().unwrap(),
4203 "verbose": true,
4204 }))
4205 .unwrap();
4206
4207 let ct = tokio_util::sync::CancellationToken::new();
4208 let (output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
4209
4210 let use_summary = output.formatted.len() > SIZE_LIMIT; let paginated =
4213 paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
4214 let verbose = true;
4215 let formatted = if !use_summary {
4216 format_structure_paginated(
4217 &paginated.items,
4218 paginated.total,
4219 params.max_depth,
4220 Some(std::path::Path::new(¶ms.path)),
4221 verbose,
4222 )
4223 } else {
4224 output.formatted.clone()
4225 };
4226
4227 assert!(
4229 !formatted.contains("SUMMARY:"),
4230 "verbose=true must not emit SUMMARY: block; got: {}",
4231 &formatted[..formatted.len().min(300)]
4232 );
4233 assert!(
4234 formatted.contains("PAGINATED:"),
4235 "verbose=true must emit PAGINATED: header"
4236 );
4237 assert!(
4238 formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
4239 "verbose=true must emit FILES section header"
4240 );
4241 }
4242
4243 #[tokio::test]
4246 async fn test_analyze_directory_cache_hit_metrics() {
4247 use aptu_coder_core::types::{
4248 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4249 };
4250 use tempfile::TempDir;
4251
4252 let dir = TempDir::new().unwrap();
4254 std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
4255 let analyzer = make_analyzer();
4256 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4257 "path": dir.path().to_str().unwrap(),
4258 }))
4259 .unwrap();
4260
4261 let ct1 = tokio_util::sync::CancellationToken::new();
4263 let (_out1, hit1) = analyzer.handle_overview_mode(¶ms, ct1).await.unwrap();
4264
4265 let ct2 = tokio_util::sync::CancellationToken::new();
4267 let (_out2, hit2) = analyzer.handle_overview_mode(¶ms, ct2).await.unwrap();
4268
4269 assert_eq!(hit1, CacheTier::Miss, "first call must be a cache miss");
4271 assert_eq!(hit2, CacheTier::L1Memory, "second call must be a cache hit");
4272 }
4273
4274 #[tokio::test]
4275 async fn test_analyze_module_cache_hit_metrics() {
4276 use std::io::Write as _;
4277 use tempfile::NamedTempFile;
4278
4279 let mut f = NamedTempFile::with_suffix(".rs").unwrap();
4281 writeln!(f, "fn bar() {{}}").unwrap();
4282 let path = f.path().to_str().unwrap().to_string();
4283
4284 let analyzer = make_analyzer();
4285
4286 let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
4288 file_params.path = path.clone();
4289 file_params.ast_recursion_limit = None;
4290 file_params.fields = None;
4291 file_params.pagination.cursor = None;
4292 file_params.pagination.page_size = None;
4293 file_params.output_control.summary = None;
4294 file_params.output_control.force = None;
4295 file_params.output_control.verbose = None;
4296 let (_cached, _) = analyzer
4297 .handle_file_details_mode(&file_params)
4298 .await
4299 .unwrap();
4300
4301 let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
4303 module_params.path = path.clone();
4304
4305 let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
4307 meta.modified()
4308 .ok()
4309 .map(|mtime| aptu_coder_core::cache::CacheKey {
4310 path: std::path::PathBuf::from(&path),
4311 modified: mtime,
4312 mode: aptu_coder_core::types::AnalysisMode::FileDetails,
4313 })
4314 });
4315 let cache_hit = module_cache_key
4316 .as_ref()
4317 .and_then(|k| analyzer.cache.get(k))
4318 .is_some();
4319
4320 assert!(
4322 cache_hit,
4323 "analyze_module should find the file in the shared file cache"
4324 );
4325 drop(module_params);
4326 }
4327
4328 #[test]
4331 fn test_analyze_symbol_import_lookup_invalid_params() {
4332 let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
4336
4337 assert!(
4339 result.is_err(),
4340 "import_lookup=true with empty symbol must return Err"
4341 );
4342 let err = result.unwrap_err();
4343 assert_eq!(
4344 err.code,
4345 rmcp::model::ErrorCode::INVALID_PARAMS,
4346 "expected INVALID_PARAMS; got {:?}",
4347 err.code
4348 );
4349 }
4350
4351 #[tokio::test]
4352 async fn test_analyze_symbol_import_lookup_found() {
4353 use tempfile::TempDir;
4354
4355 let dir = TempDir::new().unwrap();
4357 std::fs::write(
4358 dir.path().join("main.rs"),
4359 "use std::collections::HashMap;\nfn main() {}\n",
4360 )
4361 .unwrap();
4362
4363 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4364
4365 let output =
4367 analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
4368
4369 assert!(
4371 output.formatted.contains("MATCHES: 1"),
4372 "expected 1 match; got: {}",
4373 output.formatted
4374 );
4375 assert!(
4376 output.formatted.contains("main.rs"),
4377 "expected main.rs in output; got: {}",
4378 output.formatted
4379 );
4380 }
4381
4382 #[tokio::test]
4383 async fn test_analyze_symbol_import_lookup_empty() {
4384 use tempfile::TempDir;
4385
4386 let dir = TempDir::new().unwrap();
4388 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
4389
4390 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4391
4392 let output =
4394 analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
4395
4396 assert!(
4398 output.formatted.contains("MATCHES: 0"),
4399 "expected 0 matches; got: {}",
4400 output.formatted
4401 );
4402 }
4403
4404 #[tokio::test]
4407 async fn test_analyze_directory_git_ref_non_git_repo() {
4408 use aptu_coder_core::traversal::changed_files_from_git_ref;
4409 use tempfile::TempDir;
4410
4411 let dir = TempDir::new().unwrap();
4413 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4414
4415 let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
4417
4418 assert!(result.is_err(), "non-git dir must return an error");
4420 let err_msg = result.unwrap_err().to_string();
4421 assert!(
4422 err_msg.contains("git"),
4423 "error must mention git; got: {err_msg}"
4424 );
4425 }
4426
4427 #[tokio::test]
4428 async fn test_analyze_directory_git_ref_filters_changed_files() {
4429 use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
4430 use std::collections::HashSet;
4431 use tempfile::TempDir;
4432
4433 let dir = TempDir::new().unwrap();
4435 let changed_file = dir.path().join("changed.rs");
4436 let unchanged_file = dir.path().join("unchanged.rs");
4437 std::fs::write(&changed_file, "fn changed() {}").unwrap();
4438 std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
4439
4440 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4441 let total_files = entries.iter().filter(|e| !e.is_dir).count();
4442 assert_eq!(total_files, 2, "sanity: 2 files before filtering");
4443
4444 let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
4446 changed.insert(changed_file.clone());
4447
4448 let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
4450 let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
4451
4452 assert_eq!(
4454 filtered_files.len(),
4455 1,
4456 "only 1 file must remain after git_ref filter"
4457 );
4458 assert_eq!(
4459 filtered_files[0].path, changed_file,
4460 "the remaining file must be the changed one"
4461 );
4462
4463 let _ = changed_files_from_git_ref;
4465 }
4466
4467 #[tokio::test]
4468 async fn test_handle_overview_mode_git_ref_filters_via_handler() {
4469 use aptu_coder_core::types::{
4470 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4471 };
4472 use std::process::Command;
4473 use tempfile::TempDir;
4474
4475 let dir = TempDir::new().unwrap();
4477 let repo = dir.path();
4478
4479 let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
4482 let mut cmd = std::process::Command::new("git");
4483 cmd.args(["-c", "core.hooksPath=/dev/null"]);
4484 cmd.args(args);
4485 cmd.current_dir(repo_path);
4486 let out = cmd.output().unwrap();
4487 assert!(out.status.success(), "{out:?}");
4488 };
4489 git_no_hook(repo, &["init"]);
4490 git_no_hook(
4491 repo,
4492 &[
4493 "-c",
4494 "user.email=ci@example.com",
4495 "-c",
4496 "user.name=CI",
4497 "commit",
4498 "--allow-empty",
4499 "-m",
4500 "initial",
4501 ],
4502 );
4503
4504 std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
4506 git_no_hook(repo, &["add", "file_a.rs"]);
4507 git_no_hook(
4508 repo,
4509 &[
4510 "-c",
4511 "user.email=ci@example.com",
4512 "-c",
4513 "user.name=CI",
4514 "commit",
4515 "-m",
4516 "add a",
4517 ],
4518 );
4519
4520 std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
4522 git_no_hook(repo, &["add", "file_b.rs"]);
4523 git_no_hook(
4524 repo,
4525 &[
4526 "-c",
4527 "user.email=ci@example.com",
4528 "-c",
4529 "user.name=CI",
4530 "commit",
4531 "-m",
4532 "add b",
4533 ],
4534 );
4535
4536 let canon_repo = std::fs::canonicalize(repo).unwrap();
4542 let analyzer = make_analyzer();
4543 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4544 "path": canon_repo.to_str().unwrap(),
4545 "git_ref": "HEAD~1",
4546 }))
4547 .unwrap();
4548 let ct = tokio_util::sync::CancellationToken::new();
4549 let (arc_output, _cache_hit) = analyzer
4550 .handle_overview_mode(¶ms, ct)
4551 .await
4552 .expect("handle_overview_mode with git_ref must succeed");
4553
4554 let formatted = &arc_output.formatted;
4556 assert!(
4557 formatted.contains("file_b.rs"),
4558 "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
4559 );
4560 assert!(
4561 !formatted.contains("file_a.rs"),
4562 "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
4563 );
4564 }
4565
4566 #[test]
4567 fn test_validate_path_rejects_absolute_path_outside_cwd() {
4568 let result = validate_path("/etc/passwd", true);
4571 assert!(
4572 result.is_err(),
4573 "validate_path should reject /etc/passwd (outside CWD)"
4574 );
4575 let err = result.unwrap_err();
4576 let err_msg = err.message.to_lowercase();
4577 assert!(
4578 err_msg.contains("outside") || err_msg.contains("not found"),
4579 "Error message should mention 'outside' or 'not found': {}",
4580 err.message
4581 );
4582 }
4583
4584 #[test]
4585 fn test_validate_path_accepts_relative_path_in_cwd() {
4586 let result = validate_path("Cargo.toml", true);
4589 assert!(
4590 result.is_ok(),
4591 "validate_path should accept Cargo.toml (exists in CWD)"
4592 );
4593 }
4594
4595 #[test]
4596 fn test_validate_path_creates_parent_for_nonexistent_file() {
4597 let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4600 assert!(
4601 result.is_ok(),
4602 "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4603 );
4604 let path = result.unwrap();
4605 let cwd = std::env::current_dir().expect("should get cwd");
4606 let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4607 assert!(
4608 path.starts_with(&canonical_cwd),
4609 "Resolved path should be within CWD: {:?} should start with {:?}",
4610 path,
4611 canonical_cwd
4612 );
4613 }
4614
4615 #[test]
4616 fn test_edit_overwrite_with_working_dir() {
4617 let cwd = std::env::current_dir().expect("should get cwd");
4619 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4620 let temp_path = temp_dir.path();
4621
4622 let result = validate_path_in_dir("test_file.txt", false, temp_path);
4624
4625 assert!(
4627 result.is_ok(),
4628 "validate_path_in_dir should accept relative path in valid working_dir: {:?}",
4629 result.err()
4630 );
4631 let resolved = result.unwrap();
4632 assert!(
4633 resolved.starts_with(temp_path),
4634 "Resolved path should be within working_dir: {:?} should start with {:?}",
4635 resolved,
4636 temp_path
4637 );
4638 }
4639
4640 #[test]
4641 fn test_validate_path_in_dir_accepts_outside_cwd() {
4642 let temp_dir = std::env::temp_dir();
4644 let canonical_temp_dir =
4645 std::fs::canonicalize(&temp_dir).expect("should canonicalize temp_dir");
4646
4647 let result = validate_path_in_dir("probe.txt", false, &temp_dir);
4649
4650 assert!(
4652 result.is_ok(),
4653 "validate_path_in_dir should accept working_dir outside CWD: {:?}",
4654 result.err()
4655 );
4656 let resolved = result.unwrap();
4657 assert!(
4658 resolved.starts_with(&canonical_temp_dir),
4659 "Resolved path should be within working_dir: {:?} should start with {:?}",
4660 resolved,
4661 canonical_temp_dir
4662 );
4663 }
4664
4665 #[test]
4666 fn test_edit_overwrite_working_dir_traversal() {
4667 let cwd = std::env::current_dir().expect("should get cwd");
4669 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4670 let temp_path = temp_dir.path();
4671
4672 let result = validate_path_in_dir("../../../etc/passwd", false, temp_path);
4674
4675 assert!(
4677 result.is_err(),
4678 "validate_path_in_dir should reject path traversal outside working_dir"
4679 );
4680 let err = result.unwrap_err();
4681 let err_msg = err.message.to_lowercase();
4682 assert!(
4683 err_msg.contains("outside") || err_msg.contains("working"),
4684 "Error message should mention 'outside' or 'working': {}",
4685 err.message
4686 );
4687 }
4688
4689 #[test]
4690 fn test_edit_replace_with_working_dir() {
4691 let cwd = std::env::current_dir().expect("should get cwd");
4693 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4694 let temp_path = temp_dir.path();
4695 let file_path = temp_path.join("test.txt");
4696 std::fs::write(&file_path, "hello world").expect("should write test file");
4697
4698 let result = validate_path_in_dir("test.txt", true, temp_path);
4700
4701 assert!(
4703 result.is_ok(),
4704 "validate_path_in_dir should find existing file in working_dir: {:?}",
4705 result.err()
4706 );
4707 let resolved = result.unwrap();
4708 assert_eq!(
4709 resolved, file_path,
4710 "Resolved path should match the actual file path"
4711 );
4712 }
4713
4714 #[test]
4715 fn test_edit_overwrite_no_working_dir() {
4716 let result = validate_path("Cargo.toml", true);
4721
4722 assert!(
4724 result.is_ok(),
4725 "validate_path should still work without working_dir"
4726 );
4727 }
4728
4729 #[test]
4730 fn test_edit_overwrite_working_dir_is_file() {
4731 let cwd = std::env::current_dir().expect("should get cwd");
4733 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4734 let temp_file = temp_dir.path().join("test_file.txt");
4735 std::fs::write(&temp_file, "test content").expect("should write test file");
4736
4737 let result = validate_path_in_dir("some_file.txt", false, &temp_file);
4739
4740 assert!(
4742 result.is_err(),
4743 "validate_path_in_dir should reject a file as working_dir"
4744 );
4745 let err = result.unwrap_err();
4746 let err_msg = err.message.to_lowercase();
4747 assert!(
4748 err_msg.contains("directory"),
4749 "Error message should mention 'directory': {}",
4750 err.message
4751 );
4752 }
4753
4754 #[test]
4755 fn test_tool_annotations() {
4756 let tools = CodeAnalyzer::list_tools();
4758
4759 let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
4761 let exec_command = tools.iter().find(|t| t.name == "exec_command");
4762
4763 let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
4765 let analyze_dir_annot = analyze_dir_tool
4766 .annotations
4767 .as_ref()
4768 .expect("analyze_directory should have annotations");
4769 assert_eq!(
4770 analyze_dir_annot.read_only_hint,
4771 Some(true),
4772 "analyze_directory read_only_hint should be true"
4773 );
4774 assert_eq!(
4775 analyze_dir_annot.destructive_hint,
4776 Some(false),
4777 "analyze_directory destructive_hint should be false"
4778 );
4779
4780 let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
4782 let exec_cmd_annot = exec_cmd_tool
4783 .annotations
4784 .as_ref()
4785 .expect("exec_command should have annotations");
4786 assert_eq!(
4787 exec_cmd_annot.open_world_hint,
4788 Some(true),
4789 "exec_command open_world_hint should be true"
4790 );
4791 }
4792
4793 #[test]
4794 fn test_exec_stdin_size_cap_validation() {
4795 let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
4798
4799 assert!(
4801 oversized_stdin.len() > STDIN_MAX_BYTES,
4802 "test setup: oversized stdin should exceed 1 MB"
4803 );
4804
4805 let max_stdin = "y".repeat(STDIN_MAX_BYTES);
4807 assert_eq!(
4808 max_stdin.len(),
4809 STDIN_MAX_BYTES,
4810 "test setup: max stdin should be exactly 1 MB"
4811 );
4812 }
4813
4814 #[tokio::test]
4815 async fn test_exec_stdin_cat_roundtrip() {
4816 let stdin_content = "hello world";
4819
4820 let mut child = tokio::process::Command::new("sh")
4822 .arg("-c")
4823 .arg("cat")
4824 .stdin(std::process::Stdio::piped())
4825 .stdout(std::process::Stdio::piped())
4826 .stderr(std::process::Stdio::piped())
4827 .spawn()
4828 .expect("spawn cat");
4829
4830 if let Some(mut stdin_handle) = child.stdin.take() {
4831 use tokio::io::AsyncWriteExt as _;
4832 stdin_handle
4833 .write_all(stdin_content.as_bytes())
4834 .await
4835 .expect("write stdin");
4836 drop(stdin_handle);
4837 }
4838
4839 let output = child.wait_with_output().await.expect("wait for cat");
4840
4841 let stdout_str = String::from_utf8_lossy(&output.stdout);
4843 assert!(
4844 stdout_str.contains(stdin_content),
4845 "stdout should contain stdin content: {}",
4846 stdout_str
4847 );
4848 }
4849
4850 #[tokio::test]
4851 async fn test_exec_stdin_none_no_regression() {
4852 let child = tokio::process::Command::new("sh")
4855 .arg("-c")
4856 .arg("echo hi")
4857 .stdin(std::process::Stdio::null())
4858 .stdout(std::process::Stdio::piped())
4859 .stderr(std::process::Stdio::piped())
4860 .spawn()
4861 .expect("spawn echo");
4862
4863 let output = child.wait_with_output().await.expect("wait for echo");
4864
4865 let stdout_str = String::from_utf8_lossy(&output.stdout);
4867 assert!(
4868 stdout_str.contains("hi"),
4869 "stdout should contain echo output: {}",
4870 stdout_str
4871 );
4872 }
4873
4874 #[test]
4875 fn test_validate_path_in_dir_rejects_sibling_prefix() {
4876 let cwd = std::env::current_dir().expect("should get cwd");
4881 let parent = tempfile::TempDir::new_in(&cwd).expect("should create parent temp dir");
4882 let allowed = parent.path().join("allowed");
4883 let sibling = parent.path().join("allowed_sibling");
4884 std::fs::create_dir_all(&allowed).expect("should create allowed dir");
4885 std::fs::create_dir_all(&sibling).expect("should create sibling dir");
4886
4887 let result = validate_path_in_dir("../allowed_sibling/secret.txt", false, &allowed);
4890
4891 assert!(
4893 result.is_err(),
4894 "validate_path_in_dir must reject a path resolving to a sibling directory \
4895 sharing the working_dir name prefix (CVE-2025-53110 pattern)"
4896 );
4897 let err = result.unwrap_err();
4898 let msg = err.message.to_lowercase();
4899 assert!(
4900 msg.contains("outside") || msg.contains("working"),
4901 "Error should mention 'outside' or 'working', got: {}",
4902 err.message
4903 );
4904 }
4905
4906 #[test]
4907 #[serial_test::serial]
4908 fn test_file_cache_capacity_default() {
4909 unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
4911
4912 let analyzer = make_analyzer();
4914
4915 assert_eq!(analyzer.cache.file_capacity(), 100);
4917 }
4918
4919 #[test]
4920 #[serial_test::serial]
4921 fn test_file_cache_capacity_from_env() {
4922 unsafe { std::env::set_var("APTU_CODER_FILE_CACHE_CAPACITY", "42") };
4924
4925 let analyzer = make_analyzer();
4927
4928 unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
4930
4931 assert_eq!(analyzer.cache.file_capacity(), 42);
4933 }
4934
4935 #[test]
4936 fn test_exec_command_path_injected() {
4937 let resolved_path = Some("/usr/local/bin:/usr/bin:/bin");
4939 let cmd = build_exec_command("echo test", None, None, None, false, resolved_path);
4940
4941 let cmd_str = format!("{:?}", cmd);
4945
4946 assert!(
4948 !cmd_str.is_empty(),
4949 "build_exec_command should return a valid Command"
4950 );
4951 }
4952
4953 #[test]
4954 fn test_exec_command_path_fallback() {
4955 let cmd = build_exec_command("echo test", None, None, None, false, None);
4957
4958 let cmd_str = format!("{:?}", cmd);
4960
4961 assert!(
4963 !cmd_str.is_empty(),
4964 "build_exec_command should handle None resolved_path gracefully"
4965 );
4966 }
4967
4968 #[test]
4969 fn test_analyze_symbol_cache_fields_use_cache_tier_enum() {
4970 assert_eq!(
4974 CacheTier::Miss.as_str(),
4975 "miss",
4976 "CacheTier::Miss.as_str() must stay \"miss\" -- analyze_symbol metrics depend on it"
4977 );
4978 assert!(
4979 !matches!(CacheTier::Miss, CacheTier::L1Memory | CacheTier::L2Disk),
4980 "CacheTier::Miss must not be a hit variant (cache_hit=false for a miss)"
4981 );
4982 }
4983
4984 #[tokio::test]
4985 async fn test_unsupported_extension_returns_invalid_params() {
4986 let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
4989 let unsupported_file = temp_dir.path().join("notes.md");
4990 std::fs::write(&unsupported_file, "# notes").expect("should write file");
4991
4992 let analyzer = make_analyzer();
4993 let mut params = AnalyzeFileParams::default();
4994 params.path = unsupported_file.to_string_lossy().to_string();
4995
4996 let result = analyzer.handle_file_details_mode(¶ms).await;
4997
4998 assert!(result.is_err(), "should error for unsupported extension");
4999 let err = result.unwrap_err();
5000 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
5001 assert!(err.message.to_lowercase().contains("unsupported"));
5002 }
5003
5004 #[test]
5005 fn test_exec_no_truncation_under_limits() {
5006 let stdout = "hello world".to_string();
5008 let stderr = "no errors".to_string();
5009 let slot = 0u32;
5010
5011 let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
5012 handle_output_persist(stdout, stderr, slot);
5013
5014 assert_eq!(out_stdout, "hello world");
5015 assert_eq!(out_stderr, "no errors");
5016 assert!(stdout_path.is_none());
5017 assert!(stderr_path.is_none());
5018 assert!(!byte_truncated);
5019 }
5020
5021 #[test]
5022 fn test_exec_byte_overflow_stdout_exceeds_30k() {
5023 let stdout = "x".repeat(35_000);
5025 let stderr = "small".to_string();
5026 let slot = 0u32;
5027
5028 let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
5029 handle_output_persist(stdout.clone(), stderr.clone(), slot);
5030
5031 assert!(byte_truncated, "byte_truncated should be true");
5033 assert!(stdout_path.is_some(), "stdout_path should be set");
5034 assert!(stderr_path.is_some(), "stderr_path should be set");
5035
5036 assert!(
5038 out_stdout.len() <= 30_000,
5039 "stdout should be truncated to <= 30k"
5040 );
5041 assert_eq!(out_stderr, "small", "stderr should be unchanged");
5042
5043 let base = std::env::temp_dir()
5045 .join("aptu-coder-overflow")
5046 .join(format!("slot-{slot}"));
5047 let stdout_file = base.join("stdout");
5048 assert!(
5049 stdout_file.exists(),
5050 "stdout slot file should exist after byte overflow"
5051 );
5052 }
5053
5054 #[test]
5055 fn test_exec_byte_overflow_stderr_exceeds_10k() {
5056 let stdout = "small".to_string();
5058 let stderr = "y".repeat(15_000);
5059 let slot = 1u32;
5060
5061 let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
5062 handle_output_persist(stdout.clone(), stderr.clone(), slot);
5063
5064 assert!(byte_truncated, "byte_truncated should be true");
5066 assert!(stdout_path.is_some(), "stdout_path should be set");
5067 assert!(stderr_path.is_some(), "stderr_path should be set");
5068
5069 assert_eq!(out_stdout, "small", "stdout should be unchanged");
5071 assert!(
5072 out_stderr.len() <= 10_000,
5073 "stderr should be truncated to <= 10k"
5074 );
5075
5076 let base = std::env::temp_dir()
5078 .join("aptu-coder-overflow")
5079 .join(format!("slot-{slot}"));
5080 let stderr_file = base.join("stderr");
5081 assert!(
5082 stderr_file.exists(),
5083 "stderr slot file should exist after byte overflow"
5084 );
5085 }
5086
5087 #[test]
5088 fn test_exec_byte_overflow_combined_exceeds_50k() {
5089 let large_output = "z".repeat(60_000);
5092 assert!(large_output.len() > SIZE_LIMIT);
5093
5094 let mut combined_truncated = false;
5096 let truncated = if large_output.len() > SIZE_LIMIT {
5097 combined_truncated = true;
5098 let tail_start = large_output.len().saturating_sub(SIZE_LIMIT);
5099 let safe_start = large_output[..tail_start].floor_char_boundary(tail_start);
5100 large_output[safe_start..].to_string()
5101 } else {
5102 large_output.clone()
5103 };
5104
5105 assert!(combined_truncated, "combined_truncated should be true");
5106 assert!(
5107 truncated.len() <= SIZE_LIMIT,
5108 "output should be truncated to <= 50k"
5109 );
5110 }
5111
5112 #[test]
5113 fn test_exec_line_and_byte_interaction() {
5114 let lines: Vec<String> = (0..1500)
5117 .map(|i| {
5118 format!(
5119 "line {} with some padding to make it longer: {}",
5120 i,
5121 "x".repeat(15)
5122 )
5123 })
5124 .collect();
5125 let stdout = lines.join("\n");
5126 assert!(stdout.lines().count() <= 2000, "should have <= 2000 lines");
5127 assert!(stdout.len() > 30_000, "should exceed 30k bytes");
5128
5129 let stderr = "".to_string();
5130 let slot = 2u32;
5131
5132 let (out_stdout, _out_stderr, stdout_path, _stderr_path, byte_truncated) =
5133 handle_output_persist(stdout.clone(), stderr, slot);
5134
5135 assert!(byte_truncated, "byte_truncated should be true");
5137 assert!(stdout_path.is_some(), "stdout_path should be set");
5138 assert!(
5139 out_stdout.len() <= 30_000,
5140 "stdout should be truncated by byte cap"
5141 );
5142 }
5143
5144 #[test]
5145 fn test_exec_utf8_boundary_safety() {
5146 let mut stdout = String::new();
5149 for _ in 0..4000 {
5150 stdout.push_str("hello world ");
5151 }
5152 stdout.push_str("こんにちは"); assert!(stdout.len() > 30_000, "stdout should exceed 30k bytes");
5155
5156 let stderr = "".to_string();
5157 let slot = 5u32;
5158
5159 let (out_stdout, _out_stderr, _stdout_path, _stderr_path, byte_truncated) =
5160 handle_output_persist(stdout, stderr, slot);
5161
5162 assert!(byte_truncated, "byte_truncated should be true");
5164 assert!(
5165 out_stdout.is_char_boundary(0),
5166 "start should be char boundary"
5167 );
5168 assert!(
5169 out_stdout.is_char_boundary(out_stdout.len()),
5170 "end should be char boundary"
5171 );
5172 let _char_count = out_stdout.chars().count();
5174 }
5175
5176 #[test]
5177 fn test_filter_strip_lines_matching() {
5178 let rule = types::FilterRule {
5180 match_command: "^git\\s+pull".to_string(),
5181 description: Some("test filter".to_string()),
5182 strip_ansi: false,
5183 strip_lines_matching: vec!["^\\s*\\|\\s*\\d+\\s*[+-]+".to_string()],
5184 keep_lines_matching: vec![],
5185 max_lines: None,
5186 on_empty: None,
5187 };
5188
5189 let strip_patterns = vec![Regex::new("^\\s*\\|\\s*\\d+\\s*[+-]+").unwrap()];
5190 let compiled = CompiledRule {
5191 pattern: Regex::new("^git\\s+pull").unwrap(),
5192 strip_patterns,
5193 keep_patterns: vec![],
5194 rule,
5195 };
5196
5197 let stdout = "Updating abc123..def456\n | 5 ++++\n | 3 ---\nFast-forward\n";
5198 let filtered = apply_filter(&compiled, stdout);
5199
5200 assert!(!filtered.contains("| 5 ++++"), "should strip stat lines");
5201 assert!(!filtered.contains("| 3 ---"), "should strip stat lines");
5202 assert!(
5203 filtered.contains("Updating"),
5204 "should keep non-matching lines"
5205 );
5206 assert!(
5207 filtered.contains("Fast-forward"),
5208 "should keep non-matching lines"
5209 );
5210 }
5211
5212 #[test]
5213 fn test_filter_on_empty_substitution() {
5214 let rule = types::FilterRule {
5216 match_command: "^git\\s+fetch".to_string(),
5217 description: Some("test fetch".to_string()),
5218 strip_ansi: false,
5219 strip_lines_matching: vec!["^From ".to_string(), "^\\s+[a-f0-9]+\\.\\.".to_string()],
5220 keep_lines_matching: vec![],
5221 max_lines: None,
5222 on_empty: Some("ok fetched".to_string()),
5223 };
5224
5225 let strip_patterns = vec![
5226 Regex::new("^From ").unwrap(),
5227 Regex::new("^\\s+[a-f0-9]+\\.\\.").unwrap(),
5228 ];
5229 let compiled = CompiledRule {
5230 pattern: Regex::new("^git\\s+fetch").unwrap(),
5231 strip_patterns,
5232 keep_patterns: vec![],
5233 rule,
5234 };
5235
5236 let stdout = "From github.com:user/repo\n abc123..def456 main -> origin/main\n";
5237 let filtered = apply_filter(&compiled, stdout);
5238
5239 assert_eq!(
5240 filtered, "ok fetched",
5241 "should return on_empty when all lines stripped"
5242 );
5243 }
5244
5245 #[test]
5246 fn test_filter_passthrough_on_failure() {
5247 let rule = types::FilterRule {
5249 match_command: "^cargo\\s+build".to_string(),
5250 description: Some("cargo build filter".to_string()),
5251 strip_ansi: false,
5252 strip_lines_matching: vec!["^\\s*Compiling ".to_string()],
5253 keep_lines_matching: vec![],
5254 max_lines: None,
5255 on_empty: None,
5256 };
5257
5258 let strip_patterns = vec![Regex::new("^\\s*Compiling ").unwrap()];
5259 let compiled = CompiledRule {
5260 pattern: Regex::new("^cargo\\s+build").unwrap(),
5261 strip_patterns,
5262 keep_patterns: vec![],
5263 rule,
5264 };
5265
5266 let stdout = " Compiling mylib v0.1.0\nerror: failed to compile\n";
5267
5268 let mut output = types::ShellOutput::new(
5271 stdout.to_string(),
5272 "".to_string(),
5273 "".to_string(),
5274 Some(1), false,
5276 false,
5277 );
5278
5279 if output.exit_code == Some(0) && !output.timed_out {
5281 output.stdout = apply_filter(&compiled, &output.stdout);
5282 output.filter_applied = compiled
5283 .rule
5284 .description
5285 .clone()
5286 .or_else(|| Some(compiled.rule.match_command.clone()));
5287 }
5288
5289 assert!(
5290 output.filter_applied.is_none(),
5291 "filter_applied should be None when exit_code != Some(0)"
5292 );
5293 assert!(
5294 output.stdout.contains("Compiling"),
5295 "stdout should be unchanged when exit_code != Some(0)"
5296 );
5297
5298 let mut output2 = types::ShellOutput::new(
5301 stdout.to_string(),
5302 "".to_string(),
5303 "".to_string(),
5304 Some(0), false,
5306 false,
5307 );
5308
5309 if output2.exit_code == Some(0) && !output2.timed_out {
5310 output2.stdout = apply_filter(&compiled, &output2.stdout);
5311 output2.filter_applied = compiled
5312 .rule
5313 .description
5314 .clone()
5315 .or_else(|| Some(compiled.rule.match_command.clone()));
5316 }
5317
5318 assert!(
5319 output2.filter_applied.is_some(),
5320 "filter_applied should be set when exit_code == Some(0)"
5321 );
5322 assert_eq!(
5323 output2.filter_applied.as_ref().unwrap(),
5324 "cargo build filter"
5325 );
5326 assert!(
5327 !output2.stdout.contains("Compiling"),
5328 "stdout should be filtered when exit_code == Some(0)"
5329 );
5330 }
5331
5332 #[test]
5333 fn test_no_stat_injection() {
5334 let command = "git pull origin main";
5336 let result = maybe_inject_no_stat(command);
5337 assert_eq!(
5338 result, "git pull origin main --no-stat",
5339 "should inject --no-stat"
5340 );
5341 }
5342
5343 #[test]
5344 fn test_no_stat_not_injected_when_present() {
5345 let command = "git pull --stat origin main";
5347 let result = maybe_inject_no_stat(command);
5348 assert_eq!(result, command, "should not inject when --stat present");
5349
5350 let command2 = "git pull --no-stat origin main";
5351 let result2 = maybe_inject_no_stat(command2);
5352 assert_eq!(
5353 result2, command2,
5354 "should not inject when --no-stat present"
5355 );
5356
5357 let command3 = "git pull --verbose origin main";
5358 let result3 = maybe_inject_no_stat(command3);
5359 assert_eq!(
5360 result3, command3,
5361 "should not inject when --verbose present"
5362 );
5363 }
5364
5365 #[test]
5366 fn test_filter_applied_field_present() {
5367 let rule = types::FilterRule {
5369 match_command: "^git\\s+status".to_string(),
5370 description: Some("git status filter".to_string()),
5371 strip_ansi: false,
5372 strip_lines_matching: vec!["^On branch".to_string()],
5373 keep_lines_matching: vec![],
5374 max_lines: Some(20),
5375 on_empty: None,
5376 };
5377
5378 let strip_patterns = vec![Regex::new("^On branch").unwrap()];
5379 let compiled = CompiledRule {
5380 pattern: Regex::new("^git\\s+status").unwrap(),
5381 strip_patterns,
5382 keep_patterns: vec![],
5383 rule,
5384 };
5385
5386 let stdout = "On branch main\nnothing to commit\n";
5387
5388 let filtered = apply_filter(&compiled, stdout);
5390 assert!(
5391 !filtered.contains("On branch"),
5392 "apply_filter should strip matching lines"
5393 );
5394 assert!(
5395 filtered.contains("nothing to commit"),
5396 "apply_filter should keep non-matching lines"
5397 );
5398
5399 let mut output = types::ShellOutput::new(
5401 filtered,
5402 "".to_string(),
5403 "".to_string(),
5404 Some(0),
5405 false,
5406 false,
5407 );
5408
5409 output.filter_applied = compiled
5411 .rule
5412 .description
5413 .clone()
5414 .or_else(|| Some(compiled.rule.match_command.clone()));
5415
5416 assert!(
5417 output.filter_applied.is_some(),
5418 "filter_applied should be set when filter matches"
5419 );
5420 assert_eq!(output.filter_applied.as_ref().unwrap(), "git status filter");
5421 }
5422
5423 #[test]
5424 fn test_filter_keep_lines_matching() {
5425 let rule = types::FilterRule {
5427 match_command: "^cargo\\s+test".to_string(),
5428 description: Some("test keep filter".to_string()),
5429 strip_ansi: false,
5430 strip_lines_matching: vec![],
5431 keep_lines_matching: vec!["^test ".to_string(), "^FAILED".to_string()],
5432 max_lines: None,
5433 on_empty: None,
5434 };
5435 let compiled = filters::CompiledRule {
5436 pattern: Regex::new("^cargo\\s+test").unwrap(),
5437 strip_patterns: vec![],
5438 keep_patterns: vec![
5439 Regex::new("^test ").unwrap(),
5440 Regex::new("^FAILED").unwrap(),
5441 ],
5442 rule,
5443 };
5444
5445 let stdout = " Compiling mylib v0.1.0\ntest foo::bar ... ok\ntest foo::baz ... FAILED\ntest result: FAILED\n";
5446 let filtered = filters::apply_filter(&compiled, stdout);
5447
5448 assert!(filtered.contains("test foo::bar"), "should keep test lines");
5449 assert!(
5450 filtered.contains("test foo::baz"),
5451 "should keep FAILED test lines"
5452 );
5453 assert!(!filtered.contains("Compiling"), "should drop compile lines");
5454 }
5455
5456 #[test]
5457 fn test_filter_max_lines_cap() {
5458 let rule = types::FilterRule {
5460 match_command: "^git\\s+log".to_string(),
5461 description: Some("test max lines".to_string()),
5462 strip_ansi: false,
5463 strip_lines_matching: vec![],
5464 keep_lines_matching: vec![],
5465 max_lines: Some(3),
5466 on_empty: None,
5467 };
5468 let compiled = filters::CompiledRule {
5469 pattern: Regex::new("^git\\s+log").unwrap(),
5470 strip_patterns: vec![],
5471 keep_patterns: vec![],
5472 rule,
5473 };
5474
5475 let stdout = "line1\nline2\nline3\nline4\nline5\n";
5476 let filtered = filters::apply_filter(&compiled, stdout);
5477
5478 assert_eq!(filtered.lines().count(), 3, "should cap at 3 lines");
5479 assert!(filtered.contains("line1"));
5480 assert!(filtered.contains("line3"));
5481 assert!(
5482 !filtered.contains("line4"),
5483 "should not include lines beyond max"
5484 );
5485 }
5486
5487 #[test]
5488 fn test_line_cap_fires_before_byte_cap() {
5489 let line = "abcde";
5492 let stdout: String = std::iter::repeat(format!("{}\n", line))
5493 .take(2500)
5494 .collect();
5495 assert_eq!(stdout.lines().count(), 2500, "should have 2500 lines");
5496 assert!(stdout.len() < 30_000, "should be under byte cap");
5497
5498 let stderr = String::new();
5499 let slot = 42u32;
5500
5501 let (out_stdout, _out_stderr, stdout_path, _stderr_path, byte_truncated) =
5502 handle_output_persist(stdout, stderr, slot);
5503
5504 assert!(
5506 !byte_truncated,
5507 "byte cap should NOT fire (under 30k bytes)"
5508 );
5509 assert!(
5510 stdout_path.is_some(),
5511 "stdout_path should be set when line cap fires"
5512 );
5513 let line_count = out_stdout.lines().count();
5515 assert!(
5516 line_count <= 50,
5517 "returned content should have at most 50 lines, got {}",
5518 line_count
5519 );
5520 assert!(line_count > 0, "returned content should not be empty");
5521 }
5522
5523 #[test]
5524 fn test_project_local_overrides_builtin() {
5525 use std::io::Write;
5529
5530 let tmp = std::env::temp_dir().join(format!(
5531 "aptu-test-project-local-{}",
5532 std::time::SystemTime::now()
5533 .duration_since(std::time::UNIX_EPOCH)
5534 .map(|d| d.as_nanos())
5535 .unwrap_or(0)
5536 ));
5537 let aptu_dir = tmp.join(".aptu");
5538 std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
5539
5540 let toml_content = "schema_version = 1\n[[filters]]\nmatch_command = \"^my-custom-tool\"\nkeep_lines_matching = []\non_empty = \"project-local-only-marker\"\n";
5542 let mut f = std::fs::File::create(aptu_dir.join("filters.toml"))
5543 .expect("should create filters.toml");
5544 f.write_all(toml_content.as_bytes())
5545 .expect("should write toml");
5546 drop(f);
5547
5548 let rules = filters::load_filter_table(&tmp);
5549
5550 let first_rule = rules.first().expect("should have at least one rule");
5552 assert!(
5553 first_rule.pattern.is_match("my-custom-tool --flag"),
5554 "project-local rule should be first (index 0)"
5555 );
5556 assert_eq!(
5557 first_rule.rule.on_empty.as_deref(),
5558 Some("project-local-only-marker"),
5559 "project-local rule on_empty should match what was written"
5560 );
5561
5562 let has_git_pull = rules
5564 .iter()
5565 .any(|r| r.pattern.is_match("git pull origin main"));
5566 assert!(
5567 has_git_pull,
5568 "built-in git pull rule should still be present"
5569 );
5570
5571 let _ = std::fs::remove_dir_all(&tmp);
5573 }
5574
5575 #[test]
5576 fn test_invalid_toml_falls_back_gracefully() {
5577 use std::io::Write;
5579
5580 let tmp = std::env::temp_dir().join(format!(
5581 "aptu-test-invalid-toml-{}",
5582 std::time::SystemTime::now()
5583 .duration_since(std::time::UNIX_EPOCH)
5584 .map(|d| d.as_nanos())
5585 .unwrap_or(0)
5586 ));
5587 let aptu_dir = tmp.join(".aptu");
5588 std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
5589
5590 let mut f = std::fs::File::create(aptu_dir.join("filters.toml"))
5591 .expect("should create filters.toml");
5592 f.write_all(b"schema_version = INVALID_VALUE {{{{")
5596 .expect("should write garbage");
5597 drop(f);
5598
5599 let rules = filters::load_filter_table(&tmp);
5601
5602 let has_git_pull = rules
5604 .iter()
5605 .any(|r| r.pattern.is_match("git pull origin main"));
5606 assert!(
5607 has_git_pull,
5608 "should have git pull built-in rule after invalid TOML"
5609 );
5610
5611 let _ = std::fs::remove_dir_all(&tmp);
5613 }
5614
5615 #[test]
5616 fn test_metric_chars_threshold_breach_fires() {
5617 let output_chars: usize = 35_000;
5619 let event = crate::metrics::MetricEvent {
5620 ts: 0,
5621 tool: "exec_command",
5622 duration_ms: 1,
5623 output_chars,
5624 param_path_depth: 0,
5625 max_depth: None,
5626 result: "ok",
5627 error_type: None,
5628 error_subtype: None,
5629 session_id: None,
5630 seq: None,
5631 cache_hit: None,
5632 cache_write_failure: None,
5633 cache_tier: None,
5634 exit_code: None,
5635 timed_out: false,
5636 output_truncated: None,
5637 chars_threshold_breach: output_chars > 30_000,
5638 file_ext: None,
5639 };
5640 assert!(
5641 event.chars_threshold_breach,
5642 "chars_threshold_breach should be true for output_chars=35000"
5643 );
5644 }
5645
5646 #[test]
5647 fn test_metric_chars_threshold_breach_no_fire() {
5648 let output_chars: usize = 5_000;
5650 let event = crate::metrics::MetricEvent {
5651 ts: 0,
5652 tool: "exec_command",
5653 duration_ms: 1,
5654 output_chars,
5655 param_path_depth: 0,
5656 max_depth: None,
5657 result: "ok",
5658 error_type: None,
5659 error_subtype: None,
5660 session_id: None,
5661 seq: None,
5662 cache_hit: None,
5663 cache_write_failure: None,
5664 cache_tier: None,
5665 exit_code: None,
5666 timed_out: false,
5667 output_truncated: None,
5668 chars_threshold_breach: output_chars > 30_000,
5669 file_ext: None,
5670 };
5671 assert!(
5672 !event.chars_threshold_breach,
5673 "chars_threshold_breach should be false for output_chars=5000"
5674 );
5675 }
5676}