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