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