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 if let Some(file_name) = ancestor.file_name() {
265 suffix = std::path::PathBuf::from(file_name).join(&suffix);
266 }
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 if let Some(file_name) = ancestor.file_name() {
399 suffix = std::path::PathBuf::from(file_name).join(&suffix);
400 }
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 return Ok(err_to_tool_result(e));
1698 }
1699 };
1700
1701 let mut formatted = arc_output.formatted.clone();
1705 let line_count = arc_output.line_count;
1706
1707 let use_summary = if params.output_control.force == Some(true) {
1709 false
1710 } else if params.output_control.summary == Some(true) {
1711 true
1712 } else if params.output_control.summary == Some(false) {
1713 false
1714 } else {
1715 formatted.len() > SIZE_LIMIT
1716 };
1717
1718 if use_summary {
1719 formatted = format_file_details_summary(&arc_output.semantic, ¶ms.path, line_count);
1720 } else if formatted.len() > SIZE_LIMIT && params.output_control.force != Some(true) {
1721 span.record("error", true);
1722 span.record("error.type", "invalid_params");
1723 let estimated_tokens = formatted.len() / 4;
1724 let message = format!(
1725 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1726 - force=true to return full output\n\
1727 - Use fields to limit output to specific sections (functions, classes, or imports)\n\
1728 - Use summary=true for a compact overview",
1729 formatted.len(),
1730 estimated_tokens
1731 );
1732 return Ok(err_to_tool_result(ErrorData::new(
1733 rmcp::model::ErrorCode::INVALID_PARAMS,
1734 message,
1735 Some(error_meta(
1736 "validation",
1737 false,
1738 "use force=true, fields, or summary=true",
1739 )),
1740 )));
1741 }
1742
1743 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1745 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1746 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1747 ErrorData::new(
1748 rmcp::model::ErrorCode::INVALID_PARAMS,
1749 e.to_string(),
1750 Some(error_meta("validation", false, "invalid cursor format")),
1751 )
1752 }) {
1753 Ok(v) => v,
1754 Err(e) => {
1755 span.record("error", true);
1756 span.record("error.type", "invalid_params");
1757 return Ok(err_to_tool_result(e));
1758 }
1759 };
1760 cursor_data.offset
1761 } else {
1762 0
1763 };
1764
1765 let top_level_fns: Vec<crate::types::FunctionInfo> = arc_output
1767 .semantic
1768 .functions
1769 .iter()
1770 .filter(|func| {
1771 !arc_output
1772 .semantic
1773 .classes
1774 .iter()
1775 .any(|class| func.line >= class.line && func.end_line <= class.end_line)
1776 })
1777 .cloned()
1778 .collect();
1779
1780 let paginated =
1782 match paginate_slice(&top_level_fns, offset, page_size, PaginationMode::Default) {
1783 Ok(v) => v,
1784 Err(e) => {
1785 return Ok(err_to_tool_result(ErrorData::new(
1786 rmcp::model::ErrorCode::INTERNAL_ERROR,
1787 e.to_string(),
1788 Some(error_meta("transient", true, "retry the request")),
1789 )));
1790 }
1791 };
1792
1793 let verbose = params.output_control.verbose.unwrap_or(false);
1795 if !use_summary {
1796 formatted = format_file_details_paginated(
1798 &paginated.items,
1799 paginated.total,
1800 &arc_output.semantic,
1801 ¶ms.path,
1802 line_count,
1803 offset,
1804 verbose,
1805 params.fields.as_deref(),
1806 );
1807 }
1808
1809 let next_cursor = if use_summary {
1811 None
1812 } else {
1813 paginated.next_cursor.clone()
1814 };
1815
1816 let mut final_text = formatted.clone();
1818 if !use_summary && let Some(ref cursor) = next_cursor {
1819 final_text.push('\n');
1820 final_text.push_str("NEXT_CURSOR: ");
1821 final_text.push_str(cursor);
1822 }
1823
1824 let response_output = analyze::FileAnalysisOutput::new(
1826 formatted,
1827 arc_output.semantic.clone(),
1828 line_count,
1829 next_cursor,
1830 );
1831
1832 tracing::Span::current().record("cache_tier", file_cache_hit.as_str());
1834
1835 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1837 let mut meta = no_cache_meta().0;
1838 meta.insert(
1839 "content_hash".to_string(),
1840 serde_json::Value::String(content_hash),
1841 );
1842 let meta = rmcp::model::Meta(meta);
1843
1844 let mut result =
1845 CallToolResult::success(vec![Content::text(final_text.clone())]).with_meta(Some(meta));
1846 let structured = serde_json::to_value(&response_output).unwrap_or(Value::Null);
1847 result.structured_content = Some(structured);
1848 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1849 self.metrics_tx.send(crate::metrics::MetricEvent {
1850 ts: crate::metrics::unix_ms(),
1851 tool: "analyze_file",
1852 duration_ms: dur,
1853 output_chars: final_text.len(),
1854 param_path_depth: crate::metrics::path_component_count(¶m_path),
1855 max_depth: None,
1856 result: "ok",
1857 error_type: None,
1858 session_id: sid,
1859 seq: Some(seq),
1860 cache_hit: Some(file_cache_hit != CacheTier::Miss),
1861 cache_write_failure: None,
1862 cache_tier: Some(file_cache_hit.as_str()),
1863 exit_code: None,
1864 timed_out: false,
1865 output_truncated: None,
1866 ..Default::default()
1867 });
1868 Ok(result)
1869 }
1870
1871 #[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))]
1872 #[tool(
1873 name = "analyze_symbol",
1874 title = "Analyze Symbol",
1875 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.",
1876 output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
1877 annotations(
1878 title = "Analyze Symbol",
1879 read_only_hint = true,
1880 destructive_hint = false,
1881 idempotent_hint = true,
1882 open_world_hint = false
1883 )
1884 )]
1885 async fn analyze_symbol(
1886 &self,
1887 params: Parameters<AnalyzeSymbolParams>,
1888 context: RequestContext<RoleServer>,
1889 ) -> Result<CallToolResult, ErrorData> {
1890 let params = params.0;
1891 let session_id = self.session_id.lock().await.clone();
1893 let client_name = self.client_name.lock().await.clone();
1894 let client_version = self.client_version.lock().await.clone();
1895 extract_and_set_trace_context(
1896 Some(&context.meta),
1897 ClientMetadata {
1898 session_id,
1899 client_name,
1900 client_version,
1901 },
1902 );
1903 let span = tracing::Span::current();
1904 span.record("gen_ai.system", "mcp");
1905 span.record("gen_ai.operation.name", "execute_tool");
1906 span.record("gen_ai.tool.name", "analyze_symbol");
1907 span.record("symbol", ¶ms.symbol);
1908 let _validated_path = match validate_path(¶ms.path, true) {
1909 Ok(p) => p,
1910 Err(e) => {
1911 span.record("error", true);
1912 span.record("error.type", "invalid_params");
1913 return Ok(err_to_tool_result(e));
1914 }
1915 };
1916 let ct = context.ct.clone();
1917 let t_start = std::time::Instant::now();
1918 let param_path = params.path.clone();
1919 let max_depth_val = params.follow_depth;
1920 let seq = self
1921 .session_call_seq
1922 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1923 let sid = self.session_id.lock().await.clone();
1924
1925 if std::path::Path::new(¶ms.path).is_file() {
1927 span.record("error", true);
1928 span.record("error.type", "invalid_params");
1929 return Ok(err_to_tool_result(ErrorData::new(
1930 rmcp::model::ErrorCode::INVALID_PARAMS,
1931 format!(
1932 "'{}' is a file; analyze_symbol requires a directory path",
1933 params.path
1934 ),
1935 Some(error_meta(
1936 "validation",
1937 false,
1938 "pass a directory path, not a file",
1939 )),
1940 )));
1941 }
1942
1943 if summary_cursor_conflict(
1945 params.output_control.summary,
1946 params.pagination.cursor.as_deref(),
1947 ) {
1948 span.record("error", true);
1949 span.record("error.type", "invalid_params");
1950 return Ok(err_to_tool_result(ErrorData::new(
1951 rmcp::model::ErrorCode::INVALID_PARAMS,
1952 "summary=true is incompatible with a pagination cursor; use one or the other"
1953 .to_string(),
1954 Some(error_meta(
1955 "validation",
1956 false,
1957 "remove cursor or set summary=false",
1958 )),
1959 )));
1960 }
1961
1962 if let Err(e) = Self::validate_import_lookup(params.import_lookup, ¶ms.symbol) {
1964 span.record("error", true);
1965 span.record("error.type", "invalid_params");
1966 return Ok(err_to_tool_result(e));
1967 }
1968
1969 if params.import_lookup == Some(true) {
1971 let path_owned = PathBuf::from(¶ms.path);
1972 let symbol = params.symbol.clone();
1973 let git_ref = params.git_ref.clone();
1974 let max_depth = params.max_depth;
1975 let ast_recursion_limit = params.ast_recursion_limit;
1976
1977 let handle = tokio::task::spawn_blocking(move || {
1978 let path = path_owned.as_path();
1979 let raw_entries = match walk_directory(path, max_depth) {
1980 Ok(e) => e,
1981 Err(e) => {
1982 return Err(ErrorData::new(
1983 rmcp::model::ErrorCode::INTERNAL_ERROR,
1984 format!("Failed to walk directory: {e}"),
1985 Some(error_meta(
1986 "resource",
1987 false,
1988 "check path permissions and availability",
1989 )),
1990 ));
1991 }
1992 };
1993 let entries = if let Some(ref git_ref_val) = git_ref
1995 && !git_ref_val.is_empty()
1996 {
1997 let changed = match changed_files_from_git_ref(path, git_ref_val) {
1998 Ok(c) => c,
1999 Err(e) => {
2000 return Err(ErrorData::new(
2001 rmcp::model::ErrorCode::INVALID_PARAMS,
2002 format!("git_ref filter failed: {e}"),
2003 Some(error_meta(
2004 "resource",
2005 false,
2006 "ensure git is installed and path is inside a git repository",
2007 )),
2008 ));
2009 }
2010 };
2011 filter_entries_by_git_ref(raw_entries, &changed, path)
2012 } else {
2013 raw_entries
2014 };
2015 let output = match analyze::analyze_import_lookup(
2016 path,
2017 &symbol,
2018 &entries,
2019 ast_recursion_limit,
2020 ) {
2021 Ok(v) => v,
2022 Err(e) => {
2023 return Err(ErrorData::new(
2024 rmcp::model::ErrorCode::INTERNAL_ERROR,
2025 format!("import_lookup failed: {e}"),
2026 Some(error_meta(
2027 "resource",
2028 false,
2029 "check path and file permissions",
2030 )),
2031 ));
2032 }
2033 };
2034 Ok(output)
2035 });
2036
2037 let output = match handle.await {
2038 Ok(Ok(v)) => v,
2039 Ok(Err(e)) => return Ok(err_to_tool_result(e)),
2040 Err(e) => {
2041 return Ok(err_to_tool_result(ErrorData::new(
2042 rmcp::model::ErrorCode::INTERNAL_ERROR,
2043 format!("spawn_blocking failed: {e}"),
2044 Some(error_meta("resource", false, "internal error")),
2045 )));
2046 }
2047 };
2048
2049 let final_text = output.formatted.clone();
2050
2051 tracing::Span::current().record("cache_tier", "Miss");
2053
2054 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
2056 let mut meta = no_cache_meta().0;
2057 meta.insert(
2058 "content_hash".to_string(),
2059 serde_json::Value::String(content_hash),
2060 );
2061
2062 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
2063 .with_meta(Some(Meta(meta)));
2064 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
2065 result.structured_content = Some(structured);
2066 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2067 self.metrics_tx.send(crate::metrics::MetricEvent {
2068 ts: crate::metrics::unix_ms(),
2069 tool: "analyze_symbol",
2070 duration_ms: dur,
2071 output_chars: final_text.len(),
2072 param_path_depth: crate::metrics::path_component_count(¶m_path),
2073 max_depth: max_depth_val,
2074 result: "ok",
2075 error_type: None,
2076 session_id: sid,
2077 seq: Some(seq),
2078 cache_hit: Some(false),
2079 cache_tier: Some(CacheTier::Miss.as_str()),
2080 cache_write_failure: None,
2081 exit_code: None,
2082 timed_out: false,
2083 output_truncated: None,
2084 ..Default::default()
2085 });
2086 return Ok(result);
2087 }
2088
2089 let mut output = match self.handle_focused_mode(¶ms, ct).await {
2091 Ok(v) => v,
2092 Err(e) => return Ok(err_to_tool_result(e)),
2093 };
2094
2095 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
2097 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
2098 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
2099 ErrorData::new(
2100 rmcp::model::ErrorCode::INVALID_PARAMS,
2101 e.to_string(),
2102 Some(error_meta("validation", false, "invalid cursor format")),
2103 )
2104 }) {
2105 Ok(v) => v,
2106 Err(e) => return Ok(err_to_tool_result(e)),
2107 };
2108 cursor_data.offset
2109 } else {
2110 0
2111 };
2112
2113 let cursor_mode = if let Some(ref cursor_str) = params.pagination.cursor {
2115 decode_cursor(cursor_str)
2116 .map(|c| c.mode)
2117 .unwrap_or(PaginationMode::Callers)
2118 } else {
2119 PaginationMode::Callers
2120 };
2121
2122 let mut use_summary = params.output_control.summary == Some(true);
2123 if params.output_control.force == Some(true) {
2124 use_summary = false;
2125 }
2126 let verbose = params.output_control.verbose.unwrap_or(false);
2127
2128 let mut callee_cursor = match cursor_mode {
2129 PaginationMode::Callers => {
2130 let (paginated_items, paginated_next) = match paginate_focus_chains(
2131 &output.prod_chains,
2132 PaginationMode::Callers,
2133 offset,
2134 page_size,
2135 ) {
2136 Ok(v) => v,
2137 Err(e) => return Ok(err_to_tool_result(e)),
2138 };
2139
2140 if !use_summary
2141 && (paginated_next.is_some()
2142 || offset > 0
2143 || !verbose
2144 || !output.outgoing_chains.is_empty())
2145 {
2146 let base_path = Path::new(¶ms.path);
2147 output.formatted = format_focused_paginated(
2148 &paginated_items,
2149 output.prod_chains.len(),
2150 PaginationMode::Callers,
2151 ¶ms.symbol,
2152 &output.prod_chains,
2153 &output.test_chains,
2154 &output.outgoing_chains,
2155 output.def_count,
2156 offset,
2157 Some(base_path),
2158 verbose,
2159 );
2160 paginated_next
2161 } else {
2162 None
2163 }
2164 }
2165 PaginationMode::Callees => {
2166 let (paginated_items, paginated_next) = match paginate_focus_chains(
2167 &output.outgoing_chains,
2168 PaginationMode::Callees,
2169 offset,
2170 page_size,
2171 ) {
2172 Ok(v) => v,
2173 Err(e) => return Ok(err_to_tool_result(e)),
2174 };
2175
2176 if paginated_next.is_some() || offset > 0 || !verbose {
2177 let base_path = Path::new(¶ms.path);
2178 output.formatted = format_focused_paginated(
2179 &paginated_items,
2180 output.outgoing_chains.len(),
2181 PaginationMode::Callees,
2182 ¶ms.symbol,
2183 &output.prod_chains,
2184 &output.test_chains,
2185 &output.outgoing_chains,
2186 output.def_count,
2187 offset,
2188 Some(base_path),
2189 verbose,
2190 );
2191 paginated_next
2192 } else {
2193 None
2194 }
2195 }
2196 PaginationMode::Default => {
2197 return Ok(err_to_tool_result(ErrorData::new(
2198 rmcp::model::ErrorCode::INVALID_PARAMS,
2199 "invalid cursor: unknown pagination mode".to_string(),
2200 Some(error_meta(
2201 "validation",
2202 false,
2203 "use a cursor returned by a previous analyze_symbol call",
2204 )),
2205 )));
2206 }
2207 PaginationMode::DefUse => {
2208 let total_sites = output.def_use_sites.len();
2209 let (paginated_sites, paginated_next) = match paginate_slice(
2210 &output.def_use_sites,
2211 offset,
2212 page_size,
2213 PaginationMode::DefUse,
2214 ) {
2215 Ok(r) => (r.items, r.next_cursor),
2216 Err(e) => return Ok(err_to_tool_result_from_pagination(e)),
2217 };
2218
2219 if !use_summary {
2222 let base_path = Path::new(¶ms.path);
2223 output.formatted = format_focused_paginated_defuse(
2224 &paginated_sites,
2225 total_sites,
2226 ¶ms.symbol,
2227 offset,
2228 Some(base_path),
2229 verbose,
2230 );
2231 }
2232
2233 output.def_use_sites = paginated_sites;
2236
2237 paginated_next
2238 }
2239 };
2240
2241 if callee_cursor.is_none()
2246 && cursor_mode == PaginationMode::Callers
2247 && !output.outgoing_chains.is_empty()
2248 && !use_summary
2249 && let Ok(cursor) = encode_cursor(&CursorData {
2250 mode: PaginationMode::Callees,
2251 offset: 0,
2252 })
2253 {
2254 callee_cursor = Some(cursor);
2255 }
2256
2257 if callee_cursor.is_none()
2264 && matches!(
2265 cursor_mode,
2266 PaginationMode::Callees | PaginationMode::Callers
2267 )
2268 && !output.def_use_sites.is_empty()
2269 && !use_summary
2270 && let Ok(cursor) = encode_cursor(&CursorData {
2271 mode: PaginationMode::DefUse,
2272 offset: 0,
2273 })
2274 {
2275 if cursor_mode == PaginationMode::Callees || output.outgoing_chains.is_empty() {
2278 callee_cursor = Some(cursor);
2279 }
2280 }
2281
2282 output.next_cursor.clone_from(&callee_cursor);
2284
2285 let mut final_text = output.formatted.clone();
2287 if let Some(cursor) = callee_cursor {
2288 final_text.push('\n');
2289 final_text.push_str("NEXT_CURSOR: ");
2290 final_text.push_str(&cursor);
2291 }
2292
2293 tracing::Span::current().record("cache_tier", "Miss");
2295
2296 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
2298 let mut meta = no_cache_meta().0;
2299 meta.insert(
2300 "content_hash".to_string(),
2301 serde_json::Value::String(content_hash),
2302 );
2303
2304 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
2305 .with_meta(Some(Meta(meta)));
2306 if cursor_mode != PaginationMode::DefUse {
2310 output.def_use_sites = Vec::new();
2311 }
2312 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
2313 result.structured_content = Some(structured);
2314 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2315 self.metrics_tx.send(crate::metrics::MetricEvent {
2316 ts: crate::metrics::unix_ms(),
2317 tool: "analyze_symbol",
2318 duration_ms: dur,
2319 output_chars: final_text.len(),
2320 param_path_depth: crate::metrics::path_component_count(¶m_path),
2321 max_depth: max_depth_val,
2322 result: "ok",
2323 error_type: None,
2324 session_id: sid,
2325 seq: Some(seq),
2326 cache_hit: Some(false),
2327 cache_tier: Some(CacheTier::Miss.as_str()),
2328 cache_write_failure: None,
2329 exit_code: None,
2330 timed_out: false,
2331 output_truncated: None,
2332 ..Default::default()
2333 });
2334 Ok(result)
2335 }
2336
2337 #[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))]
2338 #[tool(
2339 name = "analyze_module",
2340 title = "Analyze Module",
2341 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?",
2342 output_schema = schema_for_type::<types::ModuleInfo>(),
2343 annotations(
2344 title = "Analyze Module",
2345 read_only_hint = true,
2346 destructive_hint = false,
2347 idempotent_hint = true,
2348 open_world_hint = false
2349 )
2350 )]
2351 async fn analyze_module(
2352 &self,
2353 params: Parameters<AnalyzeModuleParams>,
2354 context: RequestContext<RoleServer>,
2355 ) -> Result<CallToolResult, ErrorData> {
2356 let params = params.0;
2357 let session_id = self.session_id.lock().await.clone();
2359 let client_name = self.client_name.lock().await.clone();
2360 let client_version = self.client_version.lock().await.clone();
2361 extract_and_set_trace_context(
2362 Some(&context.meta),
2363 ClientMetadata {
2364 session_id,
2365 client_name,
2366 client_version,
2367 },
2368 );
2369 let span = tracing::Span::current();
2370 span.record("gen_ai.system", "mcp");
2371 span.record("gen_ai.operation.name", "execute_tool");
2372 span.record("gen_ai.tool.name", "analyze_module");
2373 span.record("path", ¶ms.path);
2374 let _validated_path = match validate_path(¶ms.path, true) {
2375 Ok(p) => p,
2376 Err(e) => {
2377 span.record("error", true);
2378 span.record("error.type", "invalid_params");
2379 return Ok(err_to_tool_result(e));
2380 }
2381 };
2382 let t_start = std::time::Instant::now();
2383 let param_path = params.path.clone();
2384 let seq = self
2385 .session_call_seq
2386 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2387 let sid = self.session_id.lock().await.clone();
2388
2389 if std::fs::metadata(¶ms.path)
2391 .map(|m| m.is_dir())
2392 .unwrap_or(false)
2393 {
2394 span.record("error", true);
2395 span.record("error.type", "invalid_params");
2396 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2397 self.metrics_tx.send(crate::metrics::MetricEvent {
2398 ts: crate::metrics::unix_ms(),
2399 tool: "analyze_module",
2400 duration_ms: dur,
2401 output_chars: 0,
2402 param_path_depth: crate::metrics::path_component_count(¶m_path),
2403 max_depth: None,
2404 result: "error",
2405 error_type: Some("invalid_params".to_string()),
2406 session_id: sid.clone(),
2407 seq: Some(seq),
2408 cache_hit: None,
2409 cache_write_failure: None,
2410 cache_tier: None,
2411 exit_code: None,
2412 timed_out: false,
2413 output_truncated: None,
2414 ..Default::default()
2415 });
2416 return Ok(err_to_tool_result(ErrorData::new(
2417 rmcp::model::ErrorCode::INVALID_PARAMS,
2418 format!(
2419 "'{}' is a directory. Use analyze_directory to analyze a directory, or pass a specific file path to analyze_module.",
2420 params.path
2421 ),
2422 Some(error_meta(
2423 "validation",
2424 false,
2425 "use analyze_directory for directories",
2426 )),
2427 )));
2428 }
2429
2430 let mut analyze_file_params: AnalyzeFileParams = Default::default();
2432 analyze_file_params.path = params.path.clone();
2433 let (arc_output, module_tier) =
2434 match self.handle_file_details_mode(&analyze_file_params).await {
2435 Ok((output, tier)) => (output, tier),
2436 Err(e) => {
2437 let error_data = match e.code {
2438 rmcp::model::ErrorCode::INVALID_PARAMS => e,
2439 _ => ErrorData::new(
2440 rmcp::model::ErrorCode::INTERNAL_ERROR,
2441 format!("Failed to analyze module: {}", e.message),
2442 Some(error_meta("internal", false, "report this as a bug")),
2443 ),
2444 };
2445 return Ok(err_to_tool_result(error_data));
2446 }
2447 };
2448
2449 let file_path = std::path::Path::new(¶ms.path);
2451 let name = file_path
2452 .file_name()
2453 .and_then(|n: &std::ffi::OsStr| n.to_str())
2454 .unwrap_or("unknown")
2455 .to_string();
2456 let language = file_path
2457 .extension()
2458 .and_then(|e| e.to_str())
2459 .and_then(aptu_coder_core::lang::language_for_extension)
2460 .unwrap_or("unknown")
2461 .to_string();
2462 let functions = arc_output
2463 .semantic
2464 .functions
2465 .iter()
2466 .map(|f| {
2467 let mut mfi = types::ModuleFunctionInfo::default();
2468 mfi.name = f.name.clone();
2469 mfi.line = f.line;
2470 mfi
2471 })
2472 .collect();
2473 let imports = arc_output
2474 .semantic
2475 .imports
2476 .iter()
2477 .map(|i| {
2478 let mut mii = types::ModuleImportInfo::default();
2479 mii.module = i.module.clone();
2480 mii.items = i.items.clone();
2481 mii
2482 })
2483 .collect();
2484 let module_info =
2485 types::ModuleInfo::new(name, arc_output.line_count, language, functions, imports);
2486
2487 let text = format_module_info(&module_info);
2488
2489 tracing::Span::current().record("cache_tier", module_tier.as_str());
2491
2492 let content_hash = format!("{}", blake3::hash(text.as_bytes()));
2494 let mut meta = no_cache_meta().0;
2495 meta.insert(
2496 "content_hash".to_string(),
2497 serde_json::Value::String(content_hash),
2498 );
2499
2500 let mut result =
2501 CallToolResult::success(vec![Content::text(text.clone())]).with_meta(Some(Meta(meta)));
2502 let structured = match serde_json::to_value(&module_info).map_err(|e| {
2503 ErrorData::new(
2504 rmcp::model::ErrorCode::INTERNAL_ERROR,
2505 format!("serialization failed: {e}"),
2506 Some(error_meta("internal", false, "report this as a bug")),
2507 )
2508 }) {
2509 Ok(v) => v,
2510 Err(e) => return Ok(err_to_tool_result(e)),
2511 };
2512 result.structured_content = Some(structured);
2513 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2514 self.metrics_tx.send(crate::metrics::MetricEvent {
2515 ts: crate::metrics::unix_ms(),
2516 tool: "analyze_module",
2517 duration_ms: dur,
2518 output_chars: text.len(),
2519 param_path_depth: crate::metrics::path_component_count(¶m_path),
2520 max_depth: None,
2521 result: "ok",
2522 error_type: None,
2523 session_id: sid,
2524 seq: Some(seq),
2525 cache_hit: Some(module_tier != CacheTier::Miss),
2526 cache_tier: Some(module_tier.as_str()),
2527 cache_write_failure: None,
2528 exit_code: None,
2529 timed_out: false,
2530 output_truncated: None,
2531 ..Default::default()
2532 });
2533 Ok(result)
2534 }
2535
2536 #[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))]
2537 #[tool(
2538 name = "edit_overwrite",
2539 title = "Edit Overwrite",
2540 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.",
2541 output_schema = schema_for_type::<EditOverwriteOutput>(),
2542 annotations(
2543 title = "Edit Overwrite",
2544 read_only_hint = false,
2545 destructive_hint = true,
2546 idempotent_hint = false,
2547 open_world_hint = false
2548 )
2549 )]
2550 async fn edit_overwrite(
2551 &self,
2552 params: Parameters<EditOverwriteParams>,
2553 context: RequestContext<RoleServer>,
2554 ) -> Result<CallToolResult, ErrorData> {
2555 let params = params.0;
2556 let session_id = self.session_id.lock().await.clone();
2558 let client_name = self.client_name.lock().await.clone();
2559 let client_version = self.client_version.lock().await.clone();
2560 extract_and_set_trace_context(
2561 Some(&context.meta),
2562 ClientMetadata {
2563 session_id,
2564 client_name,
2565 client_version,
2566 },
2567 );
2568 let span = tracing::Span::current();
2569 span.record("gen_ai.system", "mcp");
2570 span.record("gen_ai.operation.name", "execute_tool");
2571 span.record("gen_ai.tool.name", "edit_overwrite");
2572 span.record("path", ¶ms.path);
2573 let _validated_path = if let Some(ref wd) = params.working_dir {
2574 match validate_path_in_dir(¶ms.path, false, std::path::Path::new(wd)) {
2575 Ok(p) => p,
2576 Err(e) => {
2577 span.record("error", true);
2578 span.record("error.type", "invalid_params");
2579 return Ok(err_to_tool_result(e));
2580 }
2581 }
2582 } else {
2583 match validate_path(¶ms.path, false) {
2584 Ok(p) => p,
2585 Err(e) => {
2586 span.record("error", true);
2587 span.record("error.type", "invalid_params");
2588 return Ok(err_to_tool_result(e));
2589 }
2590 }
2591 };
2592 let t_start = std::time::Instant::now();
2593 let param_path = params.path.clone();
2594 let seq = self
2595 .session_call_seq
2596 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2597 let sid = self.session_id.lock().await.clone();
2598
2599 if std::fs::metadata(¶ms.path)
2601 .map(|m| m.is_dir())
2602 .unwrap_or(false)
2603 {
2604 span.record("error", true);
2605 span.record("error.type", "invalid_params");
2606 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2607 self.metrics_tx.send(crate::metrics::MetricEvent {
2608 ts: crate::metrics::unix_ms(),
2609 tool: "edit_overwrite",
2610 duration_ms: dur,
2611 output_chars: 0,
2612 param_path_depth: crate::metrics::path_component_count(¶m_path),
2613 max_depth: None,
2614 result: "error",
2615 error_type: Some("invalid_params".to_string()),
2616 session_id: sid.clone(),
2617 seq: Some(seq),
2618 cache_hit: None,
2619 cache_write_failure: None,
2620 cache_tier: None,
2621 exit_code: None,
2622 timed_out: false,
2623 output_truncated: None,
2624 ..Default::default()
2625 });
2626 return Ok(err_to_tool_result(ErrorData::new(
2627 rmcp::model::ErrorCode::INVALID_PARAMS,
2628 "path is a directory; cannot write to a directory".to_string(),
2629 Some(error_meta(
2630 "validation",
2631 false,
2632 "provide a file path, not a directory",
2633 )),
2634 )));
2635 }
2636
2637 let path = std::path::PathBuf::from(¶ms.path);
2638 let content = params.content.clone();
2639 let handle = tokio::task::spawn_blocking(move || {
2640 aptu_coder_core::edit_overwrite_content(&path, &content)
2641 });
2642
2643 let output = match handle.await {
2644 Ok(Ok(v)) => v,
2645 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2646 span.record("error", true);
2647 span.record("error.type", "invalid_params");
2648 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2649 self.metrics_tx.send(crate::metrics::MetricEvent {
2650 ts: crate::metrics::unix_ms(),
2651 tool: "edit_overwrite",
2652 duration_ms: dur,
2653 output_chars: 0,
2654 param_path_depth: crate::metrics::path_component_count(¶m_path),
2655 max_depth: None,
2656 result: "error",
2657 error_type: Some("invalid_params".to_string()),
2658 session_id: sid.clone(),
2659 seq: Some(seq),
2660 cache_hit: None,
2661 cache_write_failure: None,
2662 cache_tier: None,
2663 exit_code: None,
2664 timed_out: false,
2665 output_truncated: None,
2666 ..Default::default()
2667 });
2668 return Ok(err_to_tool_result(ErrorData::new(
2669 rmcp::model::ErrorCode::INVALID_PARAMS,
2670 "path is a directory".to_string(),
2671 Some(error_meta(
2672 "validation",
2673 false,
2674 "provide a file path, not a directory",
2675 )),
2676 )));
2677 }
2678 Ok(Err(e)) => {
2679 span.record("error", true);
2680 span.record("error.type", "internal_error");
2681 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2682 self.metrics_tx.send(crate::metrics::MetricEvent {
2683 ts: crate::metrics::unix_ms(),
2684 tool: "edit_overwrite",
2685 duration_ms: dur,
2686 output_chars: 0,
2687 param_path_depth: crate::metrics::path_component_count(¶m_path),
2688 max_depth: None,
2689 result: "error",
2690 error_type: Some("internal_error".to_string()),
2691 session_id: sid.clone(),
2692 seq: Some(seq),
2693 cache_hit: None,
2694 cache_write_failure: None,
2695 cache_tier: None,
2696 exit_code: None,
2697 timed_out: false,
2698 output_truncated: None,
2699 ..Default::default()
2700 });
2701 return Ok(err_to_tool_result(ErrorData::new(
2702 rmcp::model::ErrorCode::INTERNAL_ERROR,
2703 e.to_string(),
2704 Some(error_meta(
2705 "resource",
2706 false,
2707 "check file path and permissions",
2708 )),
2709 )));
2710 }
2711 Err(e) => {
2712 span.record("error", true);
2713 span.record("error.type", "internal_error");
2714 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2715 self.metrics_tx.send(crate::metrics::MetricEvent {
2716 ts: crate::metrics::unix_ms(),
2717 tool: "edit_overwrite",
2718 duration_ms: dur,
2719 output_chars: 0,
2720 param_path_depth: crate::metrics::path_component_count(¶m_path),
2721 max_depth: None,
2722 result: "error",
2723 error_type: Some("internal_error".to_string()),
2724 session_id: sid.clone(),
2725 seq: Some(seq),
2726 cache_hit: None,
2727 cache_write_failure: None,
2728 cache_tier: None,
2729 exit_code: None,
2730 timed_out: false,
2731 output_truncated: None,
2732 ..Default::default()
2733 });
2734 return Ok(err_to_tool_result(ErrorData::new(
2735 rmcp::model::ErrorCode::INTERNAL_ERROR,
2736 e.to_string(),
2737 Some(error_meta(
2738 "resource",
2739 false,
2740 "check file path and permissions",
2741 )),
2742 )));
2743 }
2744 };
2745
2746 let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2747 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2748 .with_meta(Some(no_cache_meta()));
2749 let structured = match serde_json::to_value(&output).map_err(|e| {
2750 ErrorData::new(
2751 rmcp::model::ErrorCode::INTERNAL_ERROR,
2752 format!("serialization failed: {e}"),
2753 Some(error_meta("internal", false, "report this as a bug")),
2754 )
2755 }) {
2756 Ok(v) => v,
2757 Err(e) => return Ok(err_to_tool_result(e)),
2758 };
2759 result.structured_content = Some(structured);
2760 self.cache
2761 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2762 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2763 self.metrics_tx.send(crate::metrics::MetricEvent {
2764 ts: crate::metrics::unix_ms(),
2765 tool: "edit_overwrite",
2766 duration_ms: dur,
2767 output_chars: text.len(),
2768 param_path_depth: crate::metrics::path_component_count(¶m_path),
2769 max_depth: None,
2770 result: "ok",
2771 error_type: None,
2772 session_id: sid,
2773 seq: Some(seq),
2774 cache_hit: None,
2775 cache_write_failure: None,
2776 cache_tier: None,
2777 exit_code: None,
2778 timed_out: false,
2779 output_truncated: None,
2780 ..Default::default()
2781 });
2782 Ok(result)
2783 }
2784
2785 #[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))]
2786 #[tool(
2787 name = "edit_replace",
2788 title = "Edit Replace",
2789 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.",
2790 output_schema = schema_for_type::<EditReplaceOutput>(),
2791 annotations(
2792 title = "Edit Replace",
2793 read_only_hint = false,
2794 destructive_hint = true,
2795 idempotent_hint = false,
2796 open_world_hint = false
2797 )
2798 )]
2799 async fn edit_replace(
2800 &self,
2801 params: Parameters<EditReplaceParams>,
2802 context: RequestContext<RoleServer>,
2803 ) -> Result<CallToolResult, ErrorData> {
2804 let params = params.0;
2805 let session_id = self.session_id.lock().await.clone();
2807 let client_name = self.client_name.lock().await.clone();
2808 let client_version = self.client_version.lock().await.clone();
2809 extract_and_set_trace_context(
2810 Some(&context.meta),
2811 ClientMetadata {
2812 session_id,
2813 client_name,
2814 client_version,
2815 },
2816 );
2817 let span = tracing::Span::current();
2818 span.record("gen_ai.system", "mcp");
2819 span.record("gen_ai.operation.name", "execute_tool");
2820 span.record("gen_ai.tool.name", "edit_replace");
2821 span.record("path", ¶ms.path);
2822 let _validated_path = if let Some(ref wd) = params.working_dir {
2823 match validate_path_in_dir(¶ms.path, true, std::path::Path::new(wd)) {
2824 Ok(p) => p,
2825 Err(e) => {
2826 span.record("error", true);
2827 span.record("error.type", "invalid_params");
2828 return Ok(err_to_tool_result(e));
2829 }
2830 }
2831 } else {
2832 match validate_path(¶ms.path, true) {
2833 Ok(p) => p,
2834 Err(e) => {
2835 span.record("error", true);
2836 span.record("error.type", "invalid_params");
2837 return Ok(err_to_tool_result(e));
2838 }
2839 }
2840 };
2841 let t_start = std::time::Instant::now();
2842 let param_path = params.path.clone();
2843 let seq = self
2844 .session_call_seq
2845 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2846 let sid = self.session_id.lock().await.clone();
2847
2848 if std::fs::metadata(¶ms.path)
2850 .map(|m| m.is_dir())
2851 .unwrap_or(false)
2852 {
2853 span.record("error", true);
2854 span.record("error.type", "invalid_params");
2855 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2856 self.metrics_tx.send(crate::metrics::MetricEvent {
2857 ts: crate::metrics::unix_ms(),
2858 tool: "edit_replace",
2859 duration_ms: dur,
2860 output_chars: 0,
2861 param_path_depth: crate::metrics::path_component_count(¶m_path),
2862 max_depth: None,
2863 result: "error",
2864 error_type: Some("invalid_params".to_string()),
2865 session_id: sid.clone(),
2866 seq: Some(seq),
2867 cache_hit: None,
2868 cache_write_failure: None,
2869 cache_tier: None,
2870 exit_code: None,
2871 timed_out: false,
2872 output_truncated: None,
2873 ..Default::default()
2874 });
2875 return Ok(err_to_tool_result(ErrorData::new(
2876 rmcp::model::ErrorCode::INVALID_PARAMS,
2877 "path is a directory; cannot edit a directory".to_string(),
2878 Some(error_meta(
2879 "validation",
2880 false,
2881 "provide a file path, not a directory",
2882 )),
2883 )));
2884 }
2885
2886 let path = std::path::PathBuf::from(¶ms.path);
2887 let old_text = params.old_text.clone();
2888 let new_text = params.new_text.clone();
2889 let handle = tokio::task::spawn_blocking(move || {
2890 aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2891 });
2892
2893 let output = match handle.await {
2894 Ok(Ok(v)) => v,
2895 Ok(Err(aptu_coder_core::EditError::NotFound {
2896 path: notfound_path,
2897 })) => {
2898 span.record("error", true);
2899 span.record("error.type", "invalid_params");
2900 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2901 self.metrics_tx.send(crate::metrics::MetricEvent {
2902 ts: crate::metrics::unix_ms(),
2903 tool: "edit_replace",
2904 duration_ms: dur,
2905 output_chars: 0,
2906 param_path_depth: crate::metrics::path_component_count(¶m_path),
2907 max_depth: None,
2908 result: "error",
2909 error_type: Some("invalid_params".to_string()),
2910 session_id: sid.clone(),
2911 seq: Some(seq),
2912 cache_hit: None,
2913 cache_write_failure: None,
2914 cache_tier: None,
2915 exit_code: None,
2916 timed_out: false,
2917 output_truncated: None,
2918 ..Default::default()
2919 });
2920 return Ok(err_to_tool_result(ErrorData::new(
2921 rmcp::model::ErrorCode::INVALID_PARAMS,
2922 format!(
2923 "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."
2924 ),
2925 Some(error_meta(
2926 "validation",
2927 false,
2928 "re-read the file with analyze_file or analyze_module, then derive old_text from the live content",
2929 )),
2930 )));
2931 }
2932 Ok(Err(aptu_coder_core::EditError::Ambiguous {
2933 count,
2934 path: ambiguous_path,
2935 })) => {
2936 span.record("error", true);
2937 span.record("error.type", "invalid_params");
2938 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2939 self.metrics_tx.send(crate::metrics::MetricEvent {
2940 ts: crate::metrics::unix_ms(),
2941 tool: "edit_replace",
2942 duration_ms: dur,
2943 output_chars: 0,
2944 param_path_depth: crate::metrics::path_component_count(¶m_path),
2945 max_depth: None,
2946 result: "error",
2947 error_type: Some("invalid_params".to_string()),
2948 session_id: sid.clone(),
2949 seq: Some(seq),
2950 cache_hit: None,
2951 cache_write_failure: None,
2952 cache_tier: None,
2953 exit_code: None,
2954 timed_out: false,
2955 output_truncated: None,
2956 ..Default::default()
2957 });
2958 return Ok(err_to_tool_result(ErrorData::new(
2959 rmcp::model::ErrorCode::INVALID_PARAMS,
2960 format!(
2961 "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."
2962 ),
2963 Some(error_meta(
2964 "validation",
2965 false,
2966 "extend old_text with more surrounding context, or re-read with analyze_file to confirm the exact text",
2967 )),
2968 )));
2969 }
2970 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2971 span.record("error", true);
2972 span.record("error.type", "invalid_params");
2973 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2974 self.metrics_tx.send(crate::metrics::MetricEvent {
2975 ts: crate::metrics::unix_ms(),
2976 tool: "edit_replace",
2977 duration_ms: dur,
2978 output_chars: 0,
2979 param_path_depth: crate::metrics::path_component_count(¶m_path),
2980 max_depth: None,
2981 result: "error",
2982 error_type: Some("invalid_params".to_string()),
2983 session_id: sid.clone(),
2984 seq: Some(seq),
2985 cache_hit: None,
2986 cache_write_failure: None,
2987 cache_tier: None,
2988 exit_code: None,
2989 timed_out: false,
2990 output_truncated: None,
2991 ..Default::default()
2992 });
2993 return Ok(err_to_tool_result(ErrorData::new(
2994 rmcp::model::ErrorCode::INVALID_PARAMS,
2995 "path is a directory".to_string(),
2996 Some(error_meta(
2997 "validation",
2998 false,
2999 "provide a file path, not a directory",
3000 )),
3001 )));
3002 }
3003 Ok(Err(e)) => {
3004 span.record("error", true);
3005 span.record("error.type", "internal_error");
3006 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3007 self.metrics_tx.send(crate::metrics::MetricEvent {
3008 ts: crate::metrics::unix_ms(),
3009 tool: "edit_replace",
3010 duration_ms: dur,
3011 output_chars: 0,
3012 param_path_depth: crate::metrics::path_component_count(¶m_path),
3013 max_depth: None,
3014 result: "error",
3015 error_type: Some("internal_error".to_string()),
3016 session_id: sid.clone(),
3017 seq: Some(seq),
3018 cache_hit: None,
3019 cache_write_failure: None,
3020 cache_tier: None,
3021 exit_code: None,
3022 timed_out: false,
3023 output_truncated: None,
3024 ..Default::default()
3025 });
3026 return Ok(err_to_tool_result(ErrorData::new(
3027 rmcp::model::ErrorCode::INTERNAL_ERROR,
3028 e.to_string(),
3029 Some(error_meta(
3030 "resource",
3031 false,
3032 "check file path and permissions",
3033 )),
3034 )));
3035 }
3036 Err(e) => {
3037 span.record("error", true);
3038 span.record("error.type", "internal_error");
3039 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3040 self.metrics_tx.send(crate::metrics::MetricEvent {
3041 ts: crate::metrics::unix_ms(),
3042 tool: "edit_replace",
3043 duration_ms: dur,
3044 output_chars: 0,
3045 param_path_depth: crate::metrics::path_component_count(¶m_path),
3046 max_depth: None,
3047 result: "error",
3048 error_type: Some("internal_error".to_string()),
3049 session_id: sid.clone(),
3050 seq: Some(seq),
3051 cache_hit: None,
3052 cache_write_failure: None,
3053 cache_tier: None,
3054 exit_code: None,
3055 timed_out: false,
3056 output_truncated: None,
3057 ..Default::default()
3058 });
3059 return Ok(err_to_tool_result(ErrorData::new(
3060 rmcp::model::ErrorCode::INTERNAL_ERROR,
3061 e.to_string(),
3062 Some(error_meta(
3063 "resource",
3064 false,
3065 "check file path and permissions",
3066 )),
3067 )));
3068 }
3069 };
3070
3071 let text = format!(
3072 "Edited {}: {} bytes -> {} bytes",
3073 output.path, output.bytes_before, output.bytes_after
3074 );
3075 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
3076 .with_meta(Some(no_cache_meta()));
3077 let structured = match serde_json::to_value(&output).map_err(|e| {
3078 ErrorData::new(
3079 rmcp::model::ErrorCode::INTERNAL_ERROR,
3080 format!("serialization failed: {e}"),
3081 Some(error_meta("internal", false, "report this as a bug")),
3082 )
3083 }) {
3084 Ok(v) => v,
3085 Err(e) => return Ok(err_to_tool_result(e)),
3086 };
3087 result.structured_content = Some(structured);
3088 self.cache
3089 .invalidate_file(&std::path::PathBuf::from(¶m_path));
3090 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3091 self.metrics_tx.send(crate::metrics::MetricEvent {
3092 ts: crate::metrics::unix_ms(),
3093 tool: "edit_replace",
3094 duration_ms: dur,
3095 output_chars: text.len(),
3096 param_path_depth: crate::metrics::path_component_count(¶m_path),
3097 max_depth: None,
3098 result: "ok",
3099 error_type: None,
3100 session_id: sid,
3101 seq: Some(seq),
3102 cache_hit: None,
3103 cache_write_failure: None,
3104 cache_tier: None,
3105 exit_code: None,
3106 timed_out: false,
3107 output_truncated: None,
3108 ..Default::default()
3109 });
3110 Ok(result)
3111 }
3112
3113 #[tool(
3114 name = "exec_command",
3115 title = "Exec Command",
3116 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.",
3117 output_schema = schema_for_type::<types::ShellOutput>(),
3118 annotations(
3119 title = "Exec Command",
3120 read_only_hint = false,
3121 destructive_hint = true,
3122 idempotent_hint = false,
3123 open_world_hint = true
3124 )
3125 )]
3126 #[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))]
3127 pub async fn exec_command(
3128 &self,
3129 params: Parameters<types::ExecCommandParams>,
3130 context: RequestContext<RoleServer>,
3131 ) -> Result<CallToolResult, ErrorData> {
3132 let t_start = std::time::Instant::now();
3133 let params = params.0;
3134 let session_id = self.session_id.lock().await.clone();
3136 let client_name = self.client_name.lock().await.clone();
3137 let client_version = self.client_version.lock().await.clone();
3138 extract_and_set_trace_context(
3139 Some(&context.meta),
3140 ClientMetadata {
3141 session_id,
3142 client_name,
3143 client_version,
3144 },
3145 );
3146 let span = tracing::Span::current();
3147 span.record("gen_ai.system", "mcp");
3148 span.record("gen_ai.operation.name", "execute_tool");
3149 span.record("gen_ai.tool.name", "exec_command");
3150 span.record("command", ¶ms.command);
3151
3152 let working_dir_path = if let Some(ref wd) = params.working_dir {
3154 match validate_path(wd, true) {
3155 Ok(p) => {
3156 if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
3158 span.record("error", true);
3159 span.record("error.type", "invalid_params");
3160 return Ok(err_to_tool_result(ErrorData::new(
3161 rmcp::model::ErrorCode::INVALID_PARAMS,
3162 "working_dir must be a directory".to_string(),
3163 Some(error_meta(
3164 "validation",
3165 false,
3166 "provide a valid directory path",
3167 )),
3168 )));
3169 }
3170 Some(p)
3171 }
3172 Err(e) => {
3173 span.record("error", true);
3174 span.record("error.type", "invalid_params");
3175 return Ok(err_to_tool_result(e));
3176 }
3177 }
3178 } else {
3179 None
3180 };
3181
3182 let param_path = params.working_dir.clone();
3183 let seq = self
3184 .session_call_seq
3185 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3186 let sid = self.session_id.lock().await.clone();
3187
3188 if let Some(ref stdin_content) = params.stdin
3190 && stdin_content.len() > STDIN_MAX_BYTES
3191 {
3192 span.record("error", true);
3193 span.record("error.type", "invalid_params");
3194 return Ok(err_to_tool_result(ErrorData::new(
3195 rmcp::model::ErrorCode::INVALID_PARAMS,
3196 "stdin exceeds 1 MB limit".to_string(),
3197 Some(error_meta("validation", false, "reduce stdin content size")),
3198 )));
3199 }
3200
3201 let command = params.command.clone();
3202 let timeout_secs = params.timeout_secs;
3203
3204 let _cache_key = (
3206 command.clone(),
3207 working_dir_path
3208 .as_ref()
3209 .map(|p| p.display().to_string())
3210 .unwrap_or_default(),
3211 );
3212 let resolved_path_str = self.resolved_path.as_ref().as_deref();
3214 let output = run_exec_impl(
3215 command.clone(),
3216 working_dir_path.clone(),
3217 timeout_secs,
3218 params.memory_limit_mb,
3219 params.cpu_limit_secs,
3220 params.stdin.clone(),
3221 seq,
3222 resolved_path_str,
3223 &self.filter_table,
3224 )
3225 .await;
3226
3227 let exit_code = output.exit_code;
3228 let timed_out = output.timed_out;
3229 let mut output_truncated = output.output_truncated;
3230
3231 if let Some(code) = exit_code {
3233 span.record("exit_code", code);
3234 }
3235 span.record("timed_out", timed_out);
3236 span.record("output_truncated", output_truncated);
3237
3238 if output_truncated {
3240 tracing::debug!(truncated = true, message = "output truncated");
3241 }
3242
3243 let output_text = if output.interleaved.is_empty() {
3245 format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
3246 } else {
3247 format!("Output:\n{}", output.interleaved)
3248 };
3249
3250 let mut combined_truncated = false;
3255 let truncated_output_text = if output_text.len() > SIZE_LIMIT {
3256 combined_truncated = true;
3257 let tail_start = output_text.len().saturating_sub(SIZE_LIMIT);
3259 let safe_start = output_text[..tail_start].floor_char_boundary(tail_start);
3260 output_text[safe_start..].to_string()
3261 } else {
3262 output_text
3263 };
3264
3265 output_truncated = output_truncated || combined_truncated;
3267
3268 let text = format!(
3269 "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
3270 params.command,
3271 exit_code
3272 .map(|c| c.to_string())
3273 .unwrap_or_else(|| "null".to_string()),
3274 timed_out,
3275 output_truncated,
3276 truncated_output_text,
3277 );
3278
3279 let content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
3280
3281 let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
3286
3287 let mut result = if command_failed {
3288 CallToolResult::error(content_blocks)
3289 } else {
3290 CallToolResult::success(content_blocks)
3291 }
3292 .with_meta(Some(no_cache_meta()));
3293
3294 let structured = match serde_json::to_value(&output).map_err(|e| {
3295 ErrorData::new(
3296 rmcp::model::ErrorCode::INTERNAL_ERROR,
3297 format!("serialization failed: {e}"),
3298 Some(error_meta("internal", false, "report this as a bug")),
3299 )
3300 }) {
3301 Ok(v) => v,
3302 Err(e) => {
3303 span.record("error", true);
3304 span.record("error.type", "internal_error");
3305 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3306 self.metrics_tx.send(crate::metrics::MetricEvent {
3307 ts: crate::metrics::unix_ms(),
3308 tool: "exec_command",
3309 duration_ms: dur,
3310 output_chars: 0,
3311 param_path_depth: crate::metrics::path_component_count(
3312 param_path.as_deref().unwrap_or(""),
3313 ),
3314 max_depth: None,
3315 result: "error",
3316 error_type: Some("internal_error".to_string()),
3317 session_id: sid.clone(),
3318 seq: Some(seq),
3319 cache_hit: Some(false),
3320 cache_write_failure: None,
3321 cache_tier: None,
3322 exit_code,
3323 timed_out,
3324 output_truncated: Some(output_truncated),
3325 ..Default::default()
3326 });
3327 return Ok(err_to_tool_result(e));
3328 }
3329 };
3330
3331 result.structured_content = Some(structured);
3332 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3333 self.metrics_tx.send(crate::metrics::MetricEvent {
3334 ts: crate::metrics::unix_ms(),
3335 tool: "exec_command",
3336 duration_ms: dur,
3337 output_chars: text.len(),
3338 param_path_depth: crate::metrics::path_component_count(
3339 param_path.as_deref().unwrap_or(""),
3340 ),
3341 max_depth: None,
3342 result: "ok",
3343 error_type: None,
3344 session_id: sid,
3345 seq: Some(seq),
3346 cache_hit: Some(false),
3347 cache_write_failure: None,
3348 cache_tier: None,
3349 exit_code,
3350 timed_out,
3351 output_truncated: Some(output_truncated),
3352 chars_threshold_breach: text.len() > 30_000,
3353 });
3354 Ok(result)
3355 }
3356}
3357
3358fn build_exec_command(
3360 command: &str,
3361 working_dir_path: Option<&std::path::PathBuf>,
3362 memory_limit_mb: Option<u64>,
3363 cpu_limit_secs: Option<u64>,
3364 stdin_present: bool,
3365 resolved_path: Option<&str>,
3366) -> tokio::process::Command {
3367 let shell = resolve_shell();
3368 let mut cmd = tokio::process::Command::new(shell);
3369 cmd.arg("-c").arg(command);
3370
3371 if let Some(wd) = working_dir_path {
3372 cmd.current_dir(wd);
3373 }
3374
3375 if let Some(path) = resolved_path {
3377 cmd.env("PATH", path);
3378 }
3379
3380 cmd.stdout(std::process::Stdio::piped())
3381 .stderr(std::process::Stdio::piped());
3382
3383 if stdin_present {
3384 cmd.stdin(std::process::Stdio::piped());
3385 } else {
3386 cmd.stdin(std::process::Stdio::null());
3387 }
3388
3389 #[cfg(unix)]
3390 {
3391 #[cfg(not(target_os = "linux"))]
3392 if memory_limit_mb.is_some() {
3393 warn!("memory_limit_mb is not enforced on this platform (Linux only)");
3394 }
3395 if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
3396 unsafe {
3400 cmd.pre_exec(move || {
3401 #[cfg(target_os = "linux")]
3402 if let Some(mb) = memory_limit_mb {
3403 let bytes = mb.saturating_mul(1024 * 1024);
3404 setrlimit(Resource::RLIMIT_AS, bytes, bytes)
3405 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3406 }
3407 if let Some(cpu) = cpu_limit_secs {
3408 setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
3409 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3410 }
3411 Ok(())
3412 });
3413 }
3414 }
3415 }
3416
3417 cmd
3418}
3419
3420async fn run_with_timeout(
3423 mut child: tokio::process::Child,
3424 timeout_secs: Option<u64>,
3425 tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
3426) -> (Option<i32>, bool, bool, Option<String>) {
3427 use tokio::io::AsyncBufReadExt as _;
3428 use tokio_stream::StreamExt as TokioStreamExt;
3429 use tokio_stream::wrappers::LinesStream;
3430
3431 let stdout_pipe = child.stdout.take();
3432 let stderr_pipe = child.stderr.take();
3433
3434 let mut drain_task = tokio::spawn(async move {
3435 let so_stream = stdout_pipe.map(|p| {
3436 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (false, s)))
3437 });
3438 let se_stream = stderr_pipe.map(|p| {
3439 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3440 });
3441
3442 match (so_stream, se_stream) {
3443 (Some(so), Some(se)) => {
3444 let mut merged = so.merge(se);
3445 while let Some(Ok((is_stderr, line))) = merged.next().await {
3446 let _ = tx.send((is_stderr, line));
3447 }
3448 }
3449 (Some(so), None) => {
3450 let mut stream = so;
3451 while let Some(Ok((_, line))) = stream.next().await {
3452 let _ = tx.send((false, line));
3453 }
3454 }
3455 (None, Some(se)) => {
3456 let mut stream = se;
3457 while let Some(Ok((_, line))) = stream.next().await {
3458 let _ = tx.send((true, line));
3459 }
3460 }
3461 (None, None) => {}
3462 }
3463 });
3464
3465 tokio::select! {
3466 _ = &mut drain_task => {
3467 let (status, drain_truncated) = match tokio::time::timeout(
3468 std::time::Duration::from_millis(500),
3469 child.wait()
3470 ).await {
3471 Ok(Ok(s)) => (Some(s), false),
3472 Ok(Err(_)) => (None, false),
3473 Err(_) => {
3474 child.start_kill().ok();
3475 let _ = child.wait().await;
3476 (None, true)
3477 }
3478 };
3479 let exit_code = status.and_then(|s| s.code());
3480 let ocerr = if drain_truncated {
3481 Some("post-exit drain timeout: background process held pipes".to_string())
3482 } else {
3483 None
3484 };
3485 (exit_code, false, drain_truncated, ocerr)
3486 }
3487 _ = async {
3488 if let Some(secs) = timeout_secs {
3489 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3490 } else {
3491 std::future::pending::<()>().await;
3492 }
3493 } => {
3494 let _ = child.kill().await;
3495 let _ = child.wait().await;
3496 drain_task.abort();
3497 (None, true, false, None)
3498 }
3499 }
3500}
3501
3502#[allow(clippy::too_many_arguments)]
3506async fn run_exec_impl(
3507 command: String,
3508 working_dir_path: Option<std::path::PathBuf>,
3509 timeout_secs: Option<u64>,
3510 memory_limit_mb: Option<u64>,
3511 cpu_limit_secs: Option<u64>,
3512 stdin: Option<String>,
3513 seq: u32,
3514 resolved_path: Option<&str>,
3515 filter_table: &Arc<Vec<CompiledRule>>,
3516) -> types::ShellOutput {
3517 let command = maybe_inject_no_stat(&command);
3519
3520 let mut cmd = build_exec_command(
3521 &command,
3522 working_dir_path.as_ref(),
3523 memory_limit_mb,
3524 cpu_limit_secs,
3525 stdin.is_some(),
3526 resolved_path,
3527 );
3528
3529 let mut child = match cmd.spawn() {
3530 Ok(c) => c,
3531 Err(e) => {
3532 return types::ShellOutput::new(
3533 String::new(),
3534 format!("failed to spawn command: {e}"),
3535 format!("failed to spawn command: {e}"),
3536 None,
3537 false,
3538 false,
3539 );
3540 }
3541 };
3542
3543 if let Some(stdin_content) = stdin
3544 && let Some(mut stdin_handle) = child.stdin.take()
3545 {
3546 use tokio::io::AsyncWriteExt as _;
3547 match stdin_handle.write_all(stdin_content.as_bytes()).await {
3548 Ok(()) => {
3549 drop(stdin_handle);
3550 }
3551 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
3552 Err(e) => {
3553 warn!("failed to write stdin: {e}");
3554 }
3555 }
3556 }
3557
3558 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, String)>();
3559
3560 let (exit_code, timed_out, mut output_truncated, output_collection_error) =
3561 run_with_timeout(child, timeout_secs, tx).await;
3562
3563 let mut lines: Vec<(bool, String)> = Vec::new();
3564 while let Some(item) = rx.recv().await {
3565 lines.push(item);
3566 }
3567
3568 const MAX_BYTES: usize = 50 * 1024;
3570 let mut stdout_str = String::new();
3571 let mut stderr_str = String::new();
3572 let mut interleaved_str = String::new();
3573 let mut so_bytes = 0usize;
3574 let mut se_bytes = 0usize;
3575 let mut il_bytes = 0usize;
3576 for (is_stderr, line) in &lines {
3577 let entry = format!("{line}\n");
3578 if il_bytes < 2 * MAX_BYTES {
3579 il_bytes += entry.len();
3580 interleaved_str.push_str(&entry);
3581 }
3582 if *is_stderr {
3583 if se_bytes < MAX_BYTES {
3584 se_bytes += entry.len();
3585 stderr_str.push_str(&entry);
3586 }
3587 } else if so_bytes < MAX_BYTES {
3588 so_bytes += entry.len();
3589 stdout_str.push_str(&entry);
3590 }
3591 }
3592
3593 let slot = seq % 8;
3594 let (stdout, stderr, stdout_path, stderr_path, byte_truncated) =
3595 handle_output_persist(stdout_str, stderr_str, slot);
3596 output_truncated = output_truncated || stdout_path.is_some() || byte_truncated;
3597
3598 let mut output = types::ShellOutput::new(
3599 stdout,
3600 stderr,
3601 interleaved_str,
3602 exit_code,
3603 timed_out,
3604 output_truncated,
3605 );
3606 output.output_collection_error = output_collection_error;
3607 output.stdout_path = stdout_path;
3608 output.stderr_path = stderr_path;
3609
3610 if exit_code == Some(0) && !timed_out {
3612 for compiled_rule in filter_table.iter() {
3613 if compiled_rule.pattern.is_match(&command) {
3614 let filtered_stdout = apply_filter(compiled_rule, &output.stdout);
3615 output.stdout = filtered_stdout;
3616 output.filter_applied = compiled_rule
3617 .rule
3618 .description
3619 .clone()
3620 .or_else(|| Some(compiled_rule.rule.match_command.clone()));
3621 break;
3622 }
3623 }
3624 }
3625
3626 output
3627}
3628
3629fn handle_output_persist(
3636 stdout: String,
3637 stderr: String,
3638 slot: u32,
3639) -> (String, String, Option<String>, Option<String>, bool) {
3640 const MAX_OUTPUT_LINES: usize = 2000;
3641 const MAX_STDOUT_BYTES: usize = 30_000;
3645 const MAX_STDERR_BYTES: usize = 10_000;
3646 const OVERFLOW_PREVIEW_LINES: usize = 50;
3647
3648 let stdout_lines: Vec<&str> = stdout.lines().collect();
3649 let stderr_lines: Vec<&str> = stderr.lines().collect();
3650
3651 let mut byte_truncated = false;
3652
3653 let line_overflow =
3655 stdout_lines.len() > MAX_OUTPUT_LINES || stderr_lines.len() > MAX_OUTPUT_LINES;
3656 let stdout_byte_overflow = stdout.len() > MAX_STDOUT_BYTES;
3657 let stderr_byte_overflow = stderr.len() > MAX_STDERR_BYTES;
3658 let byte_overflow = stdout_byte_overflow || stderr_byte_overflow;
3659
3660 if !line_overflow && !byte_overflow {
3662 return (stdout, stderr, None, None, false);
3663 }
3664
3665 let base = std::env::temp_dir()
3667 .join("aptu-coder-overflow")
3668 .join(format!("slot-{slot}"));
3669 let _ = std::fs::create_dir_all(&base);
3670
3671 let stdout_path = base.join("stdout");
3672 let stderr_path = base.join("stderr");
3673
3674 let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3675 let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3676
3677 let stdout_path_str = stdout_path.display().to_string();
3678 let stderr_path_str = stderr_path.display().to_string();
3679
3680 let stdout_preview = if stdout_byte_overflow {
3682 byte_truncated = true;
3683 let tail_start = stdout.len().saturating_sub(MAX_STDOUT_BYTES);
3685 let safe_start = stdout[..tail_start].floor_char_boundary(tail_start);
3686 stdout[safe_start..].to_string()
3687 } else if stdout_lines.len() > MAX_OUTPUT_LINES {
3688 stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3689 } else {
3690 stdout
3691 };
3692
3693 let stderr_preview = if stderr_byte_overflow {
3695 byte_truncated = true;
3696 let tail_start = stderr.len().saturating_sub(MAX_STDERR_BYTES);
3698 let safe_start = stderr[..tail_start].floor_char_boundary(tail_start);
3699 stderr[safe_start..].to_string()
3700 } else if stderr_lines.len() > MAX_OUTPUT_LINES {
3701 stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3702 } else {
3703 stderr
3704 };
3705
3706 (
3707 stdout_preview,
3708 stderr_preview,
3709 Some(stdout_path_str),
3710 Some(stderr_path_str),
3711 byte_truncated,
3712 )
3713}
3714
3715#[derive(Clone)]
3719struct FocusedAnalysisParams {
3720 path: std::path::PathBuf,
3721 symbol: String,
3722 match_mode: SymbolMatchMode,
3723 follow_depth: u32,
3724 max_depth: Option<u32>,
3725 ast_recursion_limit: Option<usize>,
3726 use_summary: bool,
3727 impl_only: Option<bool>,
3728 def_use: bool,
3729 parse_timeout_micros: Option<u64>,
3730}
3731
3732fn disable_routes(router: &mut ToolRouter<CodeAnalyzer>, tools: &[&'static str]) {
3733 for tool in tools {
3734 router.disable_route(*tool);
3735 }
3736}
3737
3738#[tool_handler]
3739impl ServerHandler for CodeAnalyzer {
3740 #[instrument(skip(self, context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
3741 async fn initialize(
3742 &self,
3743 request: InitializeRequestParams,
3744 context: RequestContext<RoleServer>,
3745 ) -> Result<InitializeResult, ErrorData> {
3746 let span = tracing::Span::current();
3747 span.record("service.name", "aptu-coder");
3748 span.record("service.version", env!("CARGO_PKG_VERSION"));
3749
3750 {
3752 let mut client_name_lock = self.client_name.lock().await;
3753 *client_name_lock = Some(request.client_info.name.clone());
3754 }
3755 {
3756 let mut client_version_lock = self.client_version.lock().await;
3757 *client_version_lock = Some(request.client_info.version.clone());
3758 }
3759
3760 if let Some(meta) = context.extensions.get::<Meta>() {
3763 let mut meta_lock = self.profile_meta.lock().await;
3764 *meta_lock = Some(meta.0.clone());
3765 }
3766 Ok(self.get_info())
3767 }
3768
3769 fn get_info(&self) -> InitializeResult {
3770 let excluded = crate::EXCLUDED_DIRS.join(", ");
3771 let instructions = format!(
3772 "Recommended workflow:\n\
3773 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3774 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\
3775 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3776 4. Use analyze_symbol to trace call graphs.\n\
3777 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."
3778 );
3779 let capabilities = ServerCapabilities::builder()
3780 .enable_logging()
3781 .enable_tools()
3782 .enable_tool_list_changed()
3783 .enable_completions()
3784 .build();
3785 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3786 .with_title("Aptu Coder")
3787 .with_description("MCP server for code structure analysis using tree-sitter");
3788 InitializeResult::new(capabilities)
3789 .with_server_info(server_info)
3790 .with_instructions(&instructions)
3791 }
3792
3793 async fn list_tools(
3794 &self,
3795 _request: Option<rmcp::model::PaginatedRequestParams>,
3796 _context: RequestContext<RoleServer>,
3797 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
3798 let router = self.tool_router.read().await;
3799 Ok(rmcp::model::ListToolsResult {
3800 tools: router.list_all(),
3801 meta: None,
3802 next_cursor: None,
3803 })
3804 }
3805
3806 async fn call_tool(
3807 &self,
3808 request: rmcp::model::CallToolRequestParams,
3809 context: RequestContext<RoleServer>,
3810 ) -> Result<CallToolResult, ErrorData> {
3811 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
3812 let router = self.tool_router.read().await;
3813 router.call(tcc).await
3814 }
3815
3816 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
3817 let mut peer_lock = self.peer.lock().await;
3818 *peer_lock = Some(context.peer.clone());
3819 drop(peer_lock);
3820
3821 let millis = std::time::SystemTime::now()
3823 .duration_since(std::time::UNIX_EPOCH)
3824 .unwrap_or_default()
3825 .as_millis()
3826 .try_into()
3827 .unwrap_or(u64::MAX);
3828 let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3829 let sid = format!("{millis}-{counter}");
3830 {
3831 let mut session_id_lock = self.session_id.lock().await;
3832 *session_id_lock = Some(sid);
3833 }
3834 self.session_call_seq
3835 .store(0, std::sync::atomic::Ordering::Relaxed);
3836
3837 let meta_lock = self.profile_meta.lock().await;
3847 let meta_profile = meta_lock
3848 .as_ref()
3849 .and_then(|m| m.get("io.clouatre-labs/profile"))
3850 .and_then(|v| v.as_str())
3851 .map(str::to_owned);
3852 drop(meta_lock);
3853
3854 let active_profile = meta_profile.or(std::env::var("APTU_CODER_PROFILE").ok());
3856
3857 {
3858 let mut router = self.tool_router.write().await;
3859
3860 if let Some(ref profile) = active_profile {
3864 match profile.as_str() {
3865 "edit" => {
3866 disable_routes(
3868 &mut router,
3869 &[
3870 "analyze_directory",
3871 "analyze_file",
3872 "analyze_module",
3873 "analyze_symbol",
3874 ],
3875 );
3876 }
3877 "analyze" => {
3878 disable_routes(&mut router, &["edit_replace", "edit_overwrite"]);
3880 }
3881 _ => {
3882 }
3884 }
3885 }
3886
3887 router.bind_peer_notifier(&context.peer);
3889 }
3890
3891 let peer = self.peer.clone();
3893 let event_rx = self.event_rx.clone();
3894
3895 tokio::spawn(async move {
3896 let rx = {
3897 let mut rx_lock = event_rx.lock().await;
3898 rx_lock.take()
3899 };
3900
3901 if let Some(mut receiver) = rx {
3902 let mut buffer = Vec::with_capacity(64);
3903 loop {
3904 receiver.recv_many(&mut buffer, 64).await;
3906
3907 if buffer.is_empty() {
3908 break;
3910 }
3911
3912 let peer_lock = peer.lock().await;
3914 if let Some(peer) = peer_lock.as_ref() {
3915 for log_event in buffer.drain(..) {
3916 let notification = ServerNotification::LoggingMessageNotification(
3917 Notification::new(LoggingMessageNotificationParam {
3918 level: log_event.level,
3919 logger: Some(log_event.logger),
3920 data: log_event.data,
3921 }),
3922 );
3923 if let Err(e) = peer.send_notification(notification).await {
3924 warn!("Failed to send logging notification: {}", e);
3925 }
3926 }
3927 }
3928 }
3929 }
3930 });
3931 }
3932
3933 #[instrument(skip(self, _context))]
3934 async fn on_cancelled(
3935 &self,
3936 notification: CancelledNotificationParam,
3937 _context: NotificationContext<RoleServer>,
3938 ) {
3939 tracing::info!(
3940 request_id = ?notification.request_id,
3941 reason = ?notification.reason,
3942 "Received cancellation notification"
3943 );
3944 }
3945
3946 #[instrument(skip(self, _context))]
3947 async fn complete(
3948 &self,
3949 request: CompleteRequestParams,
3950 _context: RequestContext<RoleServer>,
3951 ) -> Result<CompleteResult, ErrorData> {
3952 let argument_name = &request.argument.name;
3954 let argument_value = &request.argument.value;
3955
3956 let completions = match argument_name.as_str() {
3957 "path" => {
3958 let root = Path::new(".");
3960 completion::path_completions(root, argument_value)
3961 }
3962 "symbol" => {
3963 let path_arg = request
3965 .context
3966 .as_ref()
3967 .and_then(|ctx| ctx.get_argument("path"));
3968
3969 match path_arg {
3970 Some(path_str) => {
3971 let path = Path::new(path_str);
3972 completion::symbol_completions(&self.cache, path, argument_value)
3973 }
3974 None => Vec::new(),
3975 }
3976 }
3977 _ => Vec::new(),
3978 };
3979
3980 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
3982 let (values, has_more) = if completions.len() > 100 {
3983 (completions.into_iter().take(100).collect(), true)
3984 } else {
3985 (completions, false)
3986 };
3987
3988 let completion_info =
3989 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
3990 Ok(info) => info,
3991 Err(_) => {
3992 CompletionInfo::with_all_values(Vec::new())
3994 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
3995 }
3996 };
3997
3998 Ok(CompleteResult::new(completion_info))
3999 }
4000
4001 async fn set_level(
4002 &self,
4003 params: SetLevelRequestParams,
4004 _context: RequestContext<RoleServer>,
4005 ) -> Result<(), ErrorData> {
4006 let level_filter = match params.level {
4007 LoggingLevel::Debug => LevelFilter::DEBUG,
4008 LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
4009 LoggingLevel::Warning => LevelFilter::WARN,
4010 LoggingLevel::Error
4011 | LoggingLevel::Critical
4012 | LoggingLevel::Alert
4013 | LoggingLevel::Emergency => LevelFilter::ERROR,
4014 };
4015
4016 let mut filter_lock = self
4017 .log_level_filter
4018 .lock()
4019 .unwrap_or_else(|e| e.into_inner());
4020 *filter_lock = level_filter;
4021 Ok(())
4022 }
4023}
4024
4025#[cfg(test)]
4026mod tests {
4027 use super::*;
4028 use regex::Regex;
4029
4030 #[tokio::test]
4031 async fn test_emit_progress_none_peer_is_noop() {
4032 let peer = Arc::new(TokioMutex::new(None));
4033 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4034 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4035 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4036 let analyzer = CodeAnalyzer::new(
4037 peer,
4038 log_level_filter,
4039 rx,
4040 crate::metrics::MetricsSender(metrics_tx),
4041 );
4042 let token = ProgressToken(NumberOrString::String("test".into()));
4043 analyzer
4045 .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
4046 .await;
4047 }
4048
4049 fn make_analyzer() -> CodeAnalyzer {
4050 let peer = Arc::new(TokioMutex::new(None));
4051 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4052 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4053 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4054 CodeAnalyzer::new(
4055 peer,
4056 log_level_filter,
4057 rx,
4058 crate::metrics::MetricsSender(metrics_tx),
4059 )
4060 }
4061
4062 #[test]
4063 fn test_summary_cursor_conflict() {
4064 assert!(summary_cursor_conflict(Some(true), Some("cursor")));
4065 assert!(!summary_cursor_conflict(Some(true), None));
4066 assert!(!summary_cursor_conflict(None, Some("x")));
4067 assert!(!summary_cursor_conflict(None, None));
4068 }
4069
4070 #[tokio::test]
4071 async fn test_validate_impl_only_non_rust_returns_invalid_params() {
4072 use tempfile::TempDir;
4073
4074 let dir = TempDir::new().unwrap();
4075 std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
4076
4077 let analyzer = make_analyzer();
4078 let entries: Vec<traversal::WalkEntry> =
4081 traversal::walk_directory(dir.path(), None).unwrap_or_default();
4082 let result = CodeAnalyzer::validate_impl_only(&entries);
4083 assert!(result.is_err());
4084 let err = result.unwrap_err();
4085 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
4086 drop(analyzer); }
4088
4089 #[tokio::test]
4090 async fn test_no_cache_meta_on_analyze_directory_result() {
4091 use aptu_coder_core::types::{
4092 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4093 };
4094 use tempfile::TempDir;
4095
4096 let dir = TempDir::new().unwrap();
4097 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4098
4099 let analyzer = make_analyzer();
4100 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4101 "path": dir.path().to_str().unwrap(),
4102 }))
4103 .unwrap();
4104 let ct = tokio_util::sync::CancellationToken::new();
4105 let (arc_output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
4106 let meta = no_cache_meta();
4108 assert_eq!(
4109 meta.0.get("cache_hint").and_then(|v| v.as_str()),
4110 Some("no-cache"),
4111 );
4112 drop(arc_output);
4113 }
4114
4115 #[test]
4116 fn test_complete_path_completions_returns_suggestions() {
4117 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4122 let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
4123 let suggestions = completion::path_completions(workspace_root, "aptu-");
4124 assert!(
4125 !suggestions.is_empty(),
4126 "expected completions for prefix 'aptu-' in workspace root"
4127 );
4128 }
4129
4130 #[tokio::test]
4131 async fn test_handle_overview_mode_verbose_no_summary_block() {
4132 use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
4133 use aptu_coder_core::types::{
4134 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4135 };
4136 use tempfile::TempDir;
4137
4138 let tmp = TempDir::new().unwrap();
4139 std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
4140
4141 let peer = Arc::new(TokioMutex::new(None));
4142 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4143 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4144 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4145 let analyzer = CodeAnalyzer::new(
4146 peer,
4147 log_level_filter,
4148 rx,
4149 crate::metrics::MetricsSender(metrics_tx),
4150 );
4151
4152 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4153 "path": tmp.path().to_str().unwrap(),
4154 "verbose": true,
4155 }))
4156 .unwrap();
4157
4158 let ct = tokio_util::sync::CancellationToken::new();
4159 let (output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
4160
4161 let use_summary = output.formatted.len() > SIZE_LIMIT; let paginated =
4164 paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
4165 let verbose = true;
4166 let formatted = if !use_summary {
4167 format_structure_paginated(
4168 &paginated.items,
4169 paginated.total,
4170 params.max_depth,
4171 Some(std::path::Path::new(¶ms.path)),
4172 verbose,
4173 )
4174 } else {
4175 output.formatted.clone()
4176 };
4177
4178 assert!(
4180 !formatted.contains("SUMMARY:"),
4181 "verbose=true must not emit SUMMARY: block; got: {}",
4182 &formatted[..formatted.len().min(300)]
4183 );
4184 assert!(
4185 formatted.contains("PAGINATED:"),
4186 "verbose=true must emit PAGINATED: header"
4187 );
4188 assert!(
4189 formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
4190 "verbose=true must emit FILES section header"
4191 );
4192 }
4193
4194 #[tokio::test]
4197 async fn test_analyze_directory_cache_hit_metrics() {
4198 use aptu_coder_core::types::{
4199 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4200 };
4201 use tempfile::TempDir;
4202
4203 let dir = TempDir::new().unwrap();
4205 std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
4206 let analyzer = make_analyzer();
4207 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4208 "path": dir.path().to_str().unwrap(),
4209 }))
4210 .unwrap();
4211
4212 let ct1 = tokio_util::sync::CancellationToken::new();
4214 let (_out1, hit1) = analyzer.handle_overview_mode(¶ms, ct1).await.unwrap();
4215
4216 let ct2 = tokio_util::sync::CancellationToken::new();
4218 let (_out2, hit2) = analyzer.handle_overview_mode(¶ms, ct2).await.unwrap();
4219
4220 assert_eq!(hit1, CacheTier::Miss, "first call must be a cache miss");
4222 assert_eq!(hit2, CacheTier::L1Memory, "second call must be a cache hit");
4223 }
4224
4225 #[tokio::test]
4226 async fn test_analyze_module_cache_hit_metrics() {
4227 use std::io::Write as _;
4228 use tempfile::NamedTempFile;
4229
4230 let mut f = NamedTempFile::with_suffix(".rs").unwrap();
4232 writeln!(f, "fn bar() {{}}").unwrap();
4233 let path = f.path().to_str().unwrap().to_string();
4234
4235 let analyzer = make_analyzer();
4236
4237 let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
4239 file_params.path = path.clone();
4240 file_params.ast_recursion_limit = None;
4241 file_params.fields = None;
4242 file_params.pagination.cursor = None;
4243 file_params.pagination.page_size = None;
4244 file_params.output_control.summary = None;
4245 file_params.output_control.force = None;
4246 file_params.output_control.verbose = None;
4247 let (_cached, _) = analyzer
4248 .handle_file_details_mode(&file_params)
4249 .await
4250 .unwrap();
4251
4252 let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
4254 module_params.path = path.clone();
4255
4256 let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
4258 meta.modified()
4259 .ok()
4260 .map(|mtime| aptu_coder_core::cache::CacheKey {
4261 path: std::path::PathBuf::from(&path),
4262 modified: mtime,
4263 mode: aptu_coder_core::types::AnalysisMode::FileDetails,
4264 })
4265 });
4266 let cache_hit = module_cache_key
4267 .as_ref()
4268 .and_then(|k| analyzer.cache.get(k))
4269 .is_some();
4270
4271 assert!(
4273 cache_hit,
4274 "analyze_module should find the file in the shared file cache"
4275 );
4276 drop(module_params);
4277 }
4278
4279 #[test]
4282 fn test_analyze_symbol_import_lookup_invalid_params() {
4283 let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
4287
4288 assert!(
4290 result.is_err(),
4291 "import_lookup=true with empty symbol must return Err"
4292 );
4293 let err = result.unwrap_err();
4294 assert_eq!(
4295 err.code,
4296 rmcp::model::ErrorCode::INVALID_PARAMS,
4297 "expected INVALID_PARAMS; got {:?}",
4298 err.code
4299 );
4300 }
4301
4302 #[tokio::test]
4303 async fn test_analyze_symbol_import_lookup_found() {
4304 use tempfile::TempDir;
4305
4306 let dir = TempDir::new().unwrap();
4308 std::fs::write(
4309 dir.path().join("main.rs"),
4310 "use std::collections::HashMap;\nfn main() {}\n",
4311 )
4312 .unwrap();
4313
4314 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4315
4316 let output =
4318 analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
4319
4320 assert!(
4322 output.formatted.contains("MATCHES: 1"),
4323 "expected 1 match; got: {}",
4324 output.formatted
4325 );
4326 assert!(
4327 output.formatted.contains("main.rs"),
4328 "expected main.rs in output; got: {}",
4329 output.formatted
4330 );
4331 }
4332
4333 #[tokio::test]
4334 async fn test_analyze_symbol_import_lookup_empty() {
4335 use tempfile::TempDir;
4336
4337 let dir = TempDir::new().unwrap();
4339 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
4340
4341 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4342
4343 let output =
4345 analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
4346
4347 assert!(
4349 output.formatted.contains("MATCHES: 0"),
4350 "expected 0 matches; got: {}",
4351 output.formatted
4352 );
4353 }
4354
4355 #[tokio::test]
4358 async fn test_analyze_directory_git_ref_non_git_repo() {
4359 use aptu_coder_core::traversal::changed_files_from_git_ref;
4360 use tempfile::TempDir;
4361
4362 let dir = TempDir::new().unwrap();
4364 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4365
4366 let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
4368
4369 assert!(result.is_err(), "non-git dir must return an error");
4371 let err_msg = result.unwrap_err().to_string();
4372 assert!(
4373 err_msg.contains("git"),
4374 "error must mention git; got: {err_msg}"
4375 );
4376 }
4377
4378 #[tokio::test]
4379 async fn test_analyze_directory_git_ref_filters_changed_files() {
4380 use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
4381 use std::collections::HashSet;
4382 use tempfile::TempDir;
4383
4384 let dir = TempDir::new().unwrap();
4386 let changed_file = dir.path().join("changed.rs");
4387 let unchanged_file = dir.path().join("unchanged.rs");
4388 std::fs::write(&changed_file, "fn changed() {}").unwrap();
4389 std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
4390
4391 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4392 let total_files = entries.iter().filter(|e| !e.is_dir).count();
4393 assert_eq!(total_files, 2, "sanity: 2 files before filtering");
4394
4395 let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
4397 changed.insert(changed_file.clone());
4398
4399 let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
4401 let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
4402
4403 assert_eq!(
4405 filtered_files.len(),
4406 1,
4407 "only 1 file must remain after git_ref filter"
4408 );
4409 assert_eq!(
4410 filtered_files[0].path, changed_file,
4411 "the remaining file must be the changed one"
4412 );
4413
4414 let _ = changed_files_from_git_ref;
4416 }
4417
4418 #[tokio::test]
4419 async fn test_handle_overview_mode_git_ref_filters_via_handler() {
4420 use aptu_coder_core::types::{
4421 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4422 };
4423 use std::process::Command;
4424 use tempfile::TempDir;
4425
4426 let dir = TempDir::new().unwrap();
4428 let repo = dir.path();
4429
4430 let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
4433 let mut cmd = std::process::Command::new("git");
4434 cmd.args(["-c", "core.hooksPath=/dev/null"]);
4435 cmd.args(args);
4436 cmd.current_dir(repo_path);
4437 let out = cmd.output().unwrap();
4438 assert!(out.status.success(), "{out:?}");
4439 };
4440 git_no_hook(repo, &["init"]);
4441 git_no_hook(
4442 repo,
4443 &[
4444 "-c",
4445 "user.email=ci@example.com",
4446 "-c",
4447 "user.name=CI",
4448 "commit",
4449 "--allow-empty",
4450 "-m",
4451 "initial",
4452 ],
4453 );
4454
4455 std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
4457 git_no_hook(repo, &["add", "file_a.rs"]);
4458 git_no_hook(
4459 repo,
4460 &[
4461 "-c",
4462 "user.email=ci@example.com",
4463 "-c",
4464 "user.name=CI",
4465 "commit",
4466 "-m",
4467 "add a",
4468 ],
4469 );
4470
4471 std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
4473 git_no_hook(repo, &["add", "file_b.rs"]);
4474 git_no_hook(
4475 repo,
4476 &[
4477 "-c",
4478 "user.email=ci@example.com",
4479 "-c",
4480 "user.name=CI",
4481 "commit",
4482 "-m",
4483 "add b",
4484 ],
4485 );
4486
4487 let canon_repo = std::fs::canonicalize(repo).unwrap();
4493 let analyzer = make_analyzer();
4494 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4495 "path": canon_repo.to_str().unwrap(),
4496 "git_ref": "HEAD~1",
4497 }))
4498 .unwrap();
4499 let ct = tokio_util::sync::CancellationToken::new();
4500 let (arc_output, _cache_hit) = analyzer
4501 .handle_overview_mode(¶ms, ct)
4502 .await
4503 .expect("handle_overview_mode with git_ref must succeed");
4504
4505 let formatted = &arc_output.formatted;
4507 assert!(
4508 formatted.contains("file_b.rs"),
4509 "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
4510 );
4511 assert!(
4512 !formatted.contains("file_a.rs"),
4513 "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
4514 );
4515 }
4516
4517 #[test]
4518 fn test_validate_path_rejects_absolute_path_outside_cwd() {
4519 let result = validate_path("/etc/passwd", true);
4522 assert!(
4523 result.is_err(),
4524 "validate_path should reject /etc/passwd (outside CWD)"
4525 );
4526 let err = result.unwrap_err();
4527 let err_msg = err.message.to_lowercase();
4528 assert!(
4529 err_msg.contains("outside") || err_msg.contains("not found"),
4530 "Error message should mention 'outside' or 'not found': {}",
4531 err.message
4532 );
4533 }
4534
4535 #[test]
4536 fn test_validate_path_accepts_relative_path_in_cwd() {
4537 let result = validate_path("Cargo.toml", true);
4540 assert!(
4541 result.is_ok(),
4542 "validate_path should accept Cargo.toml (exists in CWD)"
4543 );
4544 }
4545
4546 #[test]
4547 fn test_validate_path_creates_parent_for_nonexistent_file() {
4548 let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4551 assert!(
4552 result.is_ok(),
4553 "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4554 );
4555 let path = result.unwrap();
4556 let cwd = std::env::current_dir().expect("should get cwd");
4557 let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4558 assert!(
4559 path.starts_with(&canonical_cwd),
4560 "Resolved path should be within CWD: {:?} should start with {:?}",
4561 path,
4562 canonical_cwd
4563 );
4564 }
4565
4566 #[test]
4567 fn test_edit_overwrite_with_working_dir() {
4568 let cwd = std::env::current_dir().expect("should get cwd");
4570 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4571 let temp_path = temp_dir.path();
4572
4573 let result = validate_path_in_dir("test_file.txt", false, temp_path);
4575
4576 assert!(
4578 result.is_ok(),
4579 "validate_path_in_dir should accept relative path in valid working_dir: {:?}",
4580 result.err()
4581 );
4582 let resolved = result.unwrap();
4583 assert!(
4584 resolved.starts_with(temp_path),
4585 "Resolved path should be within working_dir: {:?} should start with {:?}",
4586 resolved,
4587 temp_path
4588 );
4589 }
4590
4591 #[test]
4592 fn test_edit_overwrite_working_dir_traversal() {
4593 let cwd = std::env::current_dir().expect("should get cwd");
4595 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4596 let temp_path = temp_dir.path();
4597
4598 let result = validate_path_in_dir("../../../etc/passwd", false, temp_path);
4600
4601 assert!(
4603 result.is_err(),
4604 "validate_path_in_dir should reject path traversal outside working_dir"
4605 );
4606 let err = result.unwrap_err();
4607 let err_msg = err.message.to_lowercase();
4608 assert!(
4609 err_msg.contains("outside") || err_msg.contains("working"),
4610 "Error message should mention 'outside' or 'working': {}",
4611 err.message
4612 );
4613 }
4614
4615 #[test]
4616 fn test_edit_replace_with_working_dir() {
4617 let cwd = std::env::current_dir().expect("should get cwd");
4619 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4620 let temp_path = temp_dir.path();
4621 let file_path = temp_path.join("test.txt");
4622 std::fs::write(&file_path, "hello world").expect("should write test file");
4623
4624 let result = validate_path_in_dir("test.txt", true, temp_path);
4626
4627 assert!(
4629 result.is_ok(),
4630 "validate_path_in_dir should find existing file in working_dir: {:?}",
4631 result.err()
4632 );
4633 let resolved = result.unwrap();
4634 assert_eq!(
4635 resolved, file_path,
4636 "Resolved path should match the actual file path"
4637 );
4638 }
4639
4640 #[test]
4641 fn test_edit_overwrite_no_working_dir() {
4642 let result = validate_path("Cargo.toml", true);
4647
4648 assert!(
4650 result.is_ok(),
4651 "validate_path should still work without working_dir"
4652 );
4653 }
4654
4655 #[test]
4656 fn test_edit_overwrite_working_dir_is_file() {
4657 let cwd = std::env::current_dir().expect("should get cwd");
4659 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4660 let temp_file = temp_dir.path().join("test_file.txt");
4661 std::fs::write(&temp_file, "test content").expect("should write test file");
4662
4663 let result = validate_path_in_dir("some_file.txt", false, &temp_file);
4665
4666 assert!(
4668 result.is_err(),
4669 "validate_path_in_dir should reject a file as working_dir"
4670 );
4671 let err = result.unwrap_err();
4672 let err_msg = err.message.to_lowercase();
4673 assert!(
4674 err_msg.contains("directory"),
4675 "Error message should mention 'directory': {}",
4676 err.message
4677 );
4678 }
4679
4680 #[test]
4681 fn test_tool_annotations() {
4682 let tools = CodeAnalyzer::list_tools();
4684
4685 let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
4687 let exec_command = tools.iter().find(|t| t.name == "exec_command");
4688
4689 let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
4691 let analyze_dir_annot = analyze_dir_tool
4692 .annotations
4693 .as_ref()
4694 .expect("analyze_directory should have annotations");
4695 assert_eq!(
4696 analyze_dir_annot.read_only_hint,
4697 Some(true),
4698 "analyze_directory read_only_hint should be true"
4699 );
4700 assert_eq!(
4701 analyze_dir_annot.destructive_hint,
4702 Some(false),
4703 "analyze_directory destructive_hint should be false"
4704 );
4705
4706 let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
4708 let exec_cmd_annot = exec_cmd_tool
4709 .annotations
4710 .as_ref()
4711 .expect("exec_command should have annotations");
4712 assert_eq!(
4713 exec_cmd_annot.open_world_hint,
4714 Some(true),
4715 "exec_command open_world_hint should be true"
4716 );
4717 }
4718
4719 #[test]
4720 fn test_exec_stdin_size_cap_validation() {
4721 let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
4724
4725 assert!(
4727 oversized_stdin.len() > STDIN_MAX_BYTES,
4728 "test setup: oversized stdin should exceed 1 MB"
4729 );
4730
4731 let max_stdin = "y".repeat(STDIN_MAX_BYTES);
4733 assert_eq!(
4734 max_stdin.len(),
4735 STDIN_MAX_BYTES,
4736 "test setup: max stdin should be exactly 1 MB"
4737 );
4738 }
4739
4740 #[tokio::test]
4741 async fn test_exec_stdin_cat_roundtrip() {
4742 let stdin_content = "hello world";
4745
4746 let mut child = tokio::process::Command::new("sh")
4748 .arg("-c")
4749 .arg("cat")
4750 .stdin(std::process::Stdio::piped())
4751 .stdout(std::process::Stdio::piped())
4752 .stderr(std::process::Stdio::piped())
4753 .spawn()
4754 .expect("spawn cat");
4755
4756 if let Some(mut stdin_handle) = child.stdin.take() {
4757 use tokio::io::AsyncWriteExt as _;
4758 stdin_handle
4759 .write_all(stdin_content.as_bytes())
4760 .await
4761 .expect("write stdin");
4762 drop(stdin_handle);
4763 }
4764
4765 let output = child.wait_with_output().await.expect("wait for cat");
4766
4767 let stdout_str = String::from_utf8_lossy(&output.stdout);
4769 assert!(
4770 stdout_str.contains(stdin_content),
4771 "stdout should contain stdin content: {}",
4772 stdout_str
4773 );
4774 }
4775
4776 #[tokio::test]
4777 async fn test_exec_stdin_none_no_regression() {
4778 let child = tokio::process::Command::new("sh")
4781 .arg("-c")
4782 .arg("echo hi")
4783 .stdin(std::process::Stdio::null())
4784 .stdout(std::process::Stdio::piped())
4785 .stderr(std::process::Stdio::piped())
4786 .spawn()
4787 .expect("spawn echo");
4788
4789 let output = child.wait_with_output().await.expect("wait for echo");
4790
4791 let stdout_str = String::from_utf8_lossy(&output.stdout);
4793 assert!(
4794 stdout_str.contains("hi"),
4795 "stdout should contain echo output: {}",
4796 stdout_str
4797 );
4798 }
4799
4800 #[test]
4801 fn test_validate_path_in_dir_rejects_sibling_prefix() {
4802 let cwd = std::env::current_dir().expect("should get cwd");
4807 let parent = tempfile::TempDir::new_in(&cwd).expect("should create parent temp dir");
4808 let allowed = parent.path().join("allowed");
4809 let sibling = parent.path().join("allowed_sibling");
4810 std::fs::create_dir_all(&allowed).expect("should create allowed dir");
4811 std::fs::create_dir_all(&sibling).expect("should create sibling dir");
4812
4813 let result = validate_path_in_dir("../allowed_sibling/secret.txt", false, &allowed);
4816
4817 assert!(
4819 result.is_err(),
4820 "validate_path_in_dir must reject a path resolving to a sibling directory \
4821 sharing the working_dir name prefix (CVE-2025-53110 pattern)"
4822 );
4823 let err = result.unwrap_err();
4824 let msg = err.message.to_lowercase();
4825 assert!(
4826 msg.contains("outside") || msg.contains("working"),
4827 "Error should mention 'outside' or 'working', got: {}",
4828 err.message
4829 );
4830 }
4831
4832 #[test]
4833 #[serial_test::serial]
4834 fn test_file_cache_capacity_default() {
4835 unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
4837
4838 let analyzer = make_analyzer();
4840
4841 assert_eq!(analyzer.cache.file_capacity(), 100);
4843 }
4844
4845 #[test]
4846 #[serial_test::serial]
4847 fn test_file_cache_capacity_from_env() {
4848 unsafe { std::env::set_var("APTU_CODER_FILE_CACHE_CAPACITY", "42") };
4850
4851 let analyzer = make_analyzer();
4853
4854 unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
4856
4857 assert_eq!(analyzer.cache.file_capacity(), 42);
4859 }
4860
4861 #[test]
4862 fn test_exec_command_path_injected() {
4863 let resolved_path = Some("/usr/local/bin:/usr/bin:/bin");
4865 let cmd = build_exec_command("echo test", None, None, None, false, resolved_path);
4866
4867 let cmd_str = format!("{:?}", cmd);
4871
4872 assert!(
4874 !cmd_str.is_empty(),
4875 "build_exec_command should return a valid Command"
4876 );
4877 }
4878
4879 #[test]
4880 fn test_exec_command_path_fallback() {
4881 let cmd = build_exec_command("echo test", None, None, None, false, None);
4883
4884 let cmd_str = format!("{:?}", cmd);
4886
4887 assert!(
4889 !cmd_str.is_empty(),
4890 "build_exec_command should handle None resolved_path gracefully"
4891 );
4892 }
4893
4894 #[test]
4895 fn test_analyze_symbol_cache_fields_use_cache_tier_enum() {
4896 assert_eq!(
4900 CacheTier::Miss.as_str(),
4901 "miss",
4902 "CacheTier::Miss.as_str() must stay \"miss\" -- analyze_symbol metrics depend on it"
4903 );
4904 assert!(
4905 !matches!(CacheTier::Miss, CacheTier::L1Memory | CacheTier::L2Disk),
4906 "CacheTier::Miss must not be a hit variant (cache_hit=false for a miss)"
4907 );
4908 }
4909
4910 #[tokio::test]
4911 async fn test_unsupported_extension_returns_invalid_params() {
4912 let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
4915 let unsupported_file = temp_dir.path().join("notes.md");
4916 std::fs::write(&unsupported_file, "# notes").expect("should write file");
4917
4918 let analyzer = make_analyzer();
4919 let mut params = AnalyzeFileParams::default();
4920 params.path = unsupported_file.to_string_lossy().to_string();
4921
4922 let result = analyzer.handle_file_details_mode(¶ms).await;
4923
4924 assert!(result.is_err(), "should error for unsupported extension");
4925 let err = result.unwrap_err();
4926 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
4927 assert!(err.message.to_lowercase().contains("unsupported"));
4928 }
4929
4930 #[test]
4931 fn test_exec_no_truncation_under_limits() {
4932 let stdout = "hello world".to_string();
4934 let stderr = "no errors".to_string();
4935 let slot = 0u32;
4936
4937 let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
4938 handle_output_persist(stdout, stderr, slot);
4939
4940 assert_eq!(out_stdout, "hello world");
4941 assert_eq!(out_stderr, "no errors");
4942 assert!(stdout_path.is_none());
4943 assert!(stderr_path.is_none());
4944 assert!(!byte_truncated);
4945 }
4946
4947 #[test]
4948 fn test_exec_byte_overflow_stdout_exceeds_30k() {
4949 let stdout = "x".repeat(35_000);
4951 let stderr = "small".to_string();
4952 let slot = 0u32;
4953
4954 let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
4955 handle_output_persist(stdout.clone(), stderr.clone(), slot);
4956
4957 assert!(byte_truncated, "byte_truncated should be true");
4959 assert!(stdout_path.is_some(), "stdout_path should be set");
4960 assert!(stderr_path.is_some(), "stderr_path should be set");
4961
4962 assert!(
4964 out_stdout.len() <= 30_000,
4965 "stdout should be truncated to <= 30k"
4966 );
4967 assert_eq!(out_stderr, "small", "stderr should be unchanged");
4968
4969 let base = std::env::temp_dir()
4971 .join("aptu-coder-overflow")
4972 .join(format!("slot-{slot}"));
4973 let stdout_file = base.join("stdout");
4974 assert!(
4975 stdout_file.exists(),
4976 "stdout slot file should exist after byte overflow"
4977 );
4978 }
4979
4980 #[test]
4981 fn test_exec_byte_overflow_stderr_exceeds_10k() {
4982 let stdout = "small".to_string();
4984 let stderr = "y".repeat(15_000);
4985 let slot = 1u32;
4986
4987 let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
4988 handle_output_persist(stdout.clone(), stderr.clone(), slot);
4989
4990 assert!(byte_truncated, "byte_truncated should be true");
4992 assert!(stdout_path.is_some(), "stdout_path should be set");
4993 assert!(stderr_path.is_some(), "stderr_path should be set");
4994
4995 assert_eq!(out_stdout, "small", "stdout should be unchanged");
4997 assert!(
4998 out_stderr.len() <= 10_000,
4999 "stderr should be truncated to <= 10k"
5000 );
5001
5002 let base = std::env::temp_dir()
5004 .join("aptu-coder-overflow")
5005 .join(format!("slot-{slot}"));
5006 let stderr_file = base.join("stderr");
5007 assert!(
5008 stderr_file.exists(),
5009 "stderr slot file should exist after byte overflow"
5010 );
5011 }
5012
5013 #[test]
5014 fn test_exec_byte_overflow_combined_exceeds_50k() {
5015 let large_output = "z".repeat(60_000);
5018 assert!(large_output.len() > SIZE_LIMIT);
5019
5020 let mut combined_truncated = false;
5022 let truncated = if large_output.len() > SIZE_LIMIT {
5023 combined_truncated = true;
5024 let tail_start = large_output.len().saturating_sub(SIZE_LIMIT);
5025 let safe_start = large_output[..tail_start].floor_char_boundary(tail_start);
5026 large_output[safe_start..].to_string()
5027 } else {
5028 large_output.clone()
5029 };
5030
5031 assert!(combined_truncated, "combined_truncated should be true");
5032 assert!(
5033 truncated.len() <= SIZE_LIMIT,
5034 "output should be truncated to <= 50k"
5035 );
5036 }
5037
5038 #[test]
5039 fn test_exec_line_and_byte_interaction() {
5040 let lines: Vec<String> = (0..1500)
5043 .map(|i| {
5044 format!(
5045 "line {} with some padding to make it longer: {}",
5046 i,
5047 "x".repeat(15)
5048 )
5049 })
5050 .collect();
5051 let stdout = lines.join("\n");
5052 assert!(stdout.lines().count() <= 2000, "should have <= 2000 lines");
5053 assert!(stdout.len() > 30_000, "should exceed 30k bytes");
5054
5055 let stderr = "".to_string();
5056 let slot = 2u32;
5057
5058 let (out_stdout, _out_stderr, stdout_path, _stderr_path, byte_truncated) =
5059 handle_output_persist(stdout.clone(), stderr, slot);
5060
5061 assert!(byte_truncated, "byte_truncated should be true");
5063 assert!(stdout_path.is_some(), "stdout_path should be set");
5064 assert!(
5065 out_stdout.len() <= 30_000,
5066 "stdout should be truncated by byte cap"
5067 );
5068 }
5069
5070 #[test]
5071 fn test_exec_utf8_boundary_safety() {
5072 let mut stdout = String::new();
5075 for _ in 0..4000 {
5076 stdout.push_str("hello world ");
5077 }
5078 stdout.push_str("こんにちは"); assert!(stdout.len() > 30_000, "stdout should exceed 30k bytes");
5081
5082 let stderr = "".to_string();
5083 let slot = 5u32;
5084
5085 let (out_stdout, _out_stderr, _stdout_path, _stderr_path, byte_truncated) =
5086 handle_output_persist(stdout, stderr, slot);
5087
5088 assert!(byte_truncated, "byte_truncated should be true");
5090 assert!(
5091 out_stdout.is_char_boundary(0),
5092 "start should be char boundary"
5093 );
5094 assert!(
5095 out_stdout.is_char_boundary(out_stdout.len()),
5096 "end should be char boundary"
5097 );
5098 let _char_count = out_stdout.chars().count();
5100 }
5101
5102 #[test]
5103 fn test_filter_strip_lines_matching() {
5104 let rule = types::FilterRule {
5106 match_command: "^git\\s+pull".to_string(),
5107 description: Some("test filter".to_string()),
5108 strip_ansi: false,
5109 strip_lines_matching: vec!["^\\s*\\|\\s*\\d+\\s*[+-]+".to_string()],
5110 keep_lines_matching: vec![],
5111 max_lines: None,
5112 on_empty: None,
5113 };
5114
5115 let strip_patterns = vec![Regex::new("^\\s*\\|\\s*\\d+\\s*[+-]+").unwrap()];
5116 let compiled = CompiledRule {
5117 pattern: Regex::new("^git\\s+pull").unwrap(),
5118 strip_patterns,
5119 keep_patterns: vec![],
5120 rule,
5121 };
5122
5123 let stdout = "Updating abc123..def456\n | 5 ++++\n | 3 ---\nFast-forward\n";
5124 let filtered = apply_filter(&compiled, stdout);
5125
5126 assert!(!filtered.contains("| 5 ++++"), "should strip stat lines");
5127 assert!(!filtered.contains("| 3 ---"), "should strip stat lines");
5128 assert!(
5129 filtered.contains("Updating"),
5130 "should keep non-matching lines"
5131 );
5132 assert!(
5133 filtered.contains("Fast-forward"),
5134 "should keep non-matching lines"
5135 );
5136 }
5137
5138 #[test]
5139 fn test_filter_on_empty_substitution() {
5140 let rule = types::FilterRule {
5142 match_command: "^git\\s+fetch".to_string(),
5143 description: Some("test fetch".to_string()),
5144 strip_ansi: false,
5145 strip_lines_matching: vec!["^From ".to_string(), "^\\s+[a-f0-9]+\\.\\.".to_string()],
5146 keep_lines_matching: vec![],
5147 max_lines: None,
5148 on_empty: Some("ok fetched".to_string()),
5149 };
5150
5151 let strip_patterns = vec![
5152 Regex::new("^From ").unwrap(),
5153 Regex::new("^\\s+[a-f0-9]+\\.\\.").unwrap(),
5154 ];
5155 let compiled = CompiledRule {
5156 pattern: Regex::new("^git\\s+fetch").unwrap(),
5157 strip_patterns,
5158 keep_patterns: vec![],
5159 rule,
5160 };
5161
5162 let stdout = "From github.com:user/repo\n abc123..def456 main -> origin/main\n";
5163 let filtered = apply_filter(&compiled, stdout);
5164
5165 assert_eq!(
5166 filtered, "ok fetched",
5167 "should return on_empty when all lines stripped"
5168 );
5169 }
5170
5171 #[test]
5172 fn test_filter_passthrough_on_failure() {
5173 let rule = types::FilterRule {
5175 match_command: "^cargo\\s+build".to_string(),
5176 description: Some("cargo build filter".to_string()),
5177 strip_ansi: false,
5178 strip_lines_matching: vec!["^\\s*Compiling ".to_string()],
5179 keep_lines_matching: vec![],
5180 max_lines: None,
5181 on_empty: None,
5182 };
5183
5184 let strip_patterns = vec![Regex::new("^\\s*Compiling ").unwrap()];
5185 let compiled = CompiledRule {
5186 pattern: Regex::new("^cargo\\s+build").unwrap(),
5187 strip_patterns,
5188 keep_patterns: vec![],
5189 rule,
5190 };
5191
5192 let stdout = " Compiling mylib v0.1.0\nerror: failed to compile\n";
5193
5194 let mut output = types::ShellOutput::new(
5197 stdout.to_string(),
5198 "".to_string(),
5199 "".to_string(),
5200 Some(1), false,
5202 false,
5203 );
5204
5205 if output.exit_code == Some(0) && !output.timed_out {
5207 output.stdout = apply_filter(&compiled, &output.stdout);
5208 output.filter_applied = compiled
5209 .rule
5210 .description
5211 .clone()
5212 .or_else(|| Some(compiled.rule.match_command.clone()));
5213 }
5214
5215 assert!(
5216 output.filter_applied.is_none(),
5217 "filter_applied should be None when exit_code != Some(0)"
5218 );
5219 assert!(
5220 output.stdout.contains("Compiling"),
5221 "stdout should be unchanged when exit_code != Some(0)"
5222 );
5223
5224 let mut output2 = types::ShellOutput::new(
5227 stdout.to_string(),
5228 "".to_string(),
5229 "".to_string(),
5230 Some(0), false,
5232 false,
5233 );
5234
5235 if output2.exit_code == Some(0) && !output2.timed_out {
5236 output2.stdout = apply_filter(&compiled, &output2.stdout);
5237 output2.filter_applied = compiled
5238 .rule
5239 .description
5240 .clone()
5241 .or_else(|| Some(compiled.rule.match_command.clone()));
5242 }
5243
5244 assert!(
5245 output2.filter_applied.is_some(),
5246 "filter_applied should be set when exit_code == Some(0)"
5247 );
5248 assert_eq!(
5249 output2.filter_applied.as_ref().unwrap(),
5250 "cargo build filter"
5251 );
5252 assert!(
5253 !output2.stdout.contains("Compiling"),
5254 "stdout should be filtered when exit_code == Some(0)"
5255 );
5256 }
5257
5258 #[test]
5259 fn test_no_stat_injection() {
5260 let command = "git pull origin main";
5262 let result = maybe_inject_no_stat(command);
5263 assert_eq!(
5264 result, "git pull origin main --no-stat",
5265 "should inject --no-stat"
5266 );
5267 }
5268
5269 #[test]
5270 fn test_no_stat_not_injected_when_present() {
5271 let command = "git pull --stat origin main";
5273 let result = maybe_inject_no_stat(command);
5274 assert_eq!(result, command, "should not inject when --stat present");
5275
5276 let command2 = "git pull --no-stat origin main";
5277 let result2 = maybe_inject_no_stat(command2);
5278 assert_eq!(
5279 result2, command2,
5280 "should not inject when --no-stat present"
5281 );
5282
5283 let command3 = "git pull --verbose origin main";
5284 let result3 = maybe_inject_no_stat(command3);
5285 assert_eq!(
5286 result3, command3,
5287 "should not inject when --verbose present"
5288 );
5289 }
5290
5291 #[test]
5292 fn test_filter_applied_field_present() {
5293 let rule = types::FilterRule {
5295 match_command: "^git\\s+status".to_string(),
5296 description: Some("git status filter".to_string()),
5297 strip_ansi: false,
5298 strip_lines_matching: vec!["^On branch".to_string()],
5299 keep_lines_matching: vec![],
5300 max_lines: Some(20),
5301 on_empty: None,
5302 };
5303
5304 let strip_patterns = vec![Regex::new("^On branch").unwrap()];
5305 let compiled = CompiledRule {
5306 pattern: Regex::new("^git\\s+status").unwrap(),
5307 strip_patterns,
5308 keep_patterns: vec![],
5309 rule,
5310 };
5311
5312 let stdout = "On branch main\nnothing to commit\n";
5313
5314 let filtered = apply_filter(&compiled, stdout);
5316 assert!(
5317 !filtered.contains("On branch"),
5318 "apply_filter should strip matching lines"
5319 );
5320 assert!(
5321 filtered.contains("nothing to commit"),
5322 "apply_filter should keep non-matching lines"
5323 );
5324
5325 let mut output = types::ShellOutput::new(
5327 filtered,
5328 "".to_string(),
5329 "".to_string(),
5330 Some(0),
5331 false,
5332 false,
5333 );
5334
5335 output.filter_applied = compiled
5337 .rule
5338 .description
5339 .clone()
5340 .or_else(|| Some(compiled.rule.match_command.clone()));
5341
5342 assert!(
5343 output.filter_applied.is_some(),
5344 "filter_applied should be set when filter matches"
5345 );
5346 assert_eq!(output.filter_applied.as_ref().unwrap(), "git status filter");
5347 }
5348
5349 #[test]
5350 fn test_filter_keep_lines_matching() {
5351 let rule = types::FilterRule {
5353 match_command: "^cargo\\s+test".to_string(),
5354 description: Some("test keep filter".to_string()),
5355 strip_ansi: false,
5356 strip_lines_matching: vec![],
5357 keep_lines_matching: vec!["^test ".to_string(), "^FAILED".to_string()],
5358 max_lines: None,
5359 on_empty: None,
5360 };
5361 let compiled = filters::CompiledRule {
5362 pattern: Regex::new("^cargo\\s+test").unwrap(),
5363 strip_patterns: vec![],
5364 keep_patterns: vec![
5365 Regex::new("^test ").unwrap(),
5366 Regex::new("^FAILED").unwrap(),
5367 ],
5368 rule,
5369 };
5370
5371 let stdout = " Compiling mylib v0.1.0\ntest foo::bar ... ok\ntest foo::baz ... FAILED\ntest result: FAILED\n";
5372 let filtered = filters::apply_filter(&compiled, stdout);
5373
5374 assert!(filtered.contains("test foo::bar"), "should keep test lines");
5375 assert!(
5376 filtered.contains("test foo::baz"),
5377 "should keep FAILED test lines"
5378 );
5379 assert!(!filtered.contains("Compiling"), "should drop compile lines");
5380 }
5381
5382 #[test]
5383 fn test_filter_max_lines_cap() {
5384 let rule = types::FilterRule {
5386 match_command: "^git\\s+log".to_string(),
5387 description: Some("test max lines".to_string()),
5388 strip_ansi: false,
5389 strip_lines_matching: vec![],
5390 keep_lines_matching: vec![],
5391 max_lines: Some(3),
5392 on_empty: None,
5393 };
5394 let compiled = filters::CompiledRule {
5395 pattern: Regex::new("^git\\s+log").unwrap(),
5396 strip_patterns: vec![],
5397 keep_patterns: vec![],
5398 rule,
5399 };
5400
5401 let stdout = "line1\nline2\nline3\nline4\nline5\n";
5402 let filtered = filters::apply_filter(&compiled, stdout);
5403
5404 assert_eq!(filtered.lines().count(), 3, "should cap at 3 lines");
5405 assert!(filtered.contains("line1"));
5406 assert!(filtered.contains("line3"));
5407 assert!(
5408 !filtered.contains("line4"),
5409 "should not include lines beyond max"
5410 );
5411 }
5412
5413 #[test]
5414 fn test_line_cap_fires_before_byte_cap() {
5415 let line = "abcde";
5418 let stdout: String = std::iter::repeat(format!("{}\n", line))
5419 .take(2500)
5420 .collect();
5421 assert_eq!(stdout.lines().count(), 2500, "should have 2500 lines");
5422 assert!(stdout.len() < 30_000, "should be under byte cap");
5423
5424 let stderr = String::new();
5425 let slot = 42u32;
5426
5427 let (out_stdout, _out_stderr, stdout_path, _stderr_path, byte_truncated) =
5428 handle_output_persist(stdout, stderr, slot);
5429
5430 assert!(
5432 !byte_truncated,
5433 "byte cap should NOT fire (under 30k bytes)"
5434 );
5435 assert!(
5436 stdout_path.is_some(),
5437 "stdout_path should be set when line cap fires"
5438 );
5439 let line_count = out_stdout.lines().count();
5441 assert!(
5442 line_count <= 50,
5443 "returned content should have at most 50 lines, got {}",
5444 line_count
5445 );
5446 assert!(line_count > 0, "returned content should not be empty");
5447 }
5448
5449 #[test]
5450 fn test_project_local_overrides_builtin() {
5451 use std::io::Write;
5455
5456 let tmp = std::env::temp_dir().join(format!(
5457 "aptu-test-project-local-{}",
5458 std::time::SystemTime::now()
5459 .duration_since(std::time::UNIX_EPOCH)
5460 .map(|d| d.as_nanos())
5461 .unwrap_or(0)
5462 ));
5463 let aptu_dir = tmp.join(".aptu");
5464 std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
5465
5466 let toml_content = "schema_version = 1\n[[filters]]\nmatch_command = \"^my-custom-tool\"\nkeep_lines_matching = []\non_empty = \"project-local-only-marker\"\n";
5468 let mut f = std::fs::File::create(aptu_dir.join("filters.toml"))
5469 .expect("should create filters.toml");
5470 f.write_all(toml_content.as_bytes())
5471 .expect("should write toml");
5472 drop(f);
5473
5474 let rules = filters::load_filter_table(&tmp);
5475
5476 let first_rule = rules.first().expect("should have at least one rule");
5478 assert!(
5479 first_rule.pattern.is_match("my-custom-tool --flag"),
5480 "project-local rule should be first (index 0)"
5481 );
5482 assert_eq!(
5483 first_rule.rule.on_empty.as_deref(),
5484 Some("project-local-only-marker"),
5485 "project-local rule on_empty should match what was written"
5486 );
5487
5488 let has_git_pull = rules
5490 .iter()
5491 .any(|r| r.pattern.is_match("git pull origin main"));
5492 assert!(
5493 has_git_pull,
5494 "built-in git pull rule should still be present"
5495 );
5496
5497 let _ = std::fs::remove_dir_all(&tmp);
5499 }
5500
5501 #[test]
5502 fn test_invalid_toml_falls_back_gracefully() {
5503 use std::io::Write;
5505
5506 let tmp = std::env::temp_dir().join(format!(
5507 "aptu-test-invalid-toml-{}",
5508 std::time::SystemTime::now()
5509 .duration_since(std::time::UNIX_EPOCH)
5510 .map(|d| d.as_nanos())
5511 .unwrap_or(0)
5512 ));
5513 let aptu_dir = tmp.join(".aptu");
5514 std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
5515
5516 let mut f = std::fs::File::create(aptu_dir.join("filters.toml"))
5517 .expect("should create filters.toml");
5518 f.write_all(b"schema_version = INVALID_VALUE {{{{")
5522 .expect("should write garbage");
5523 drop(f);
5524
5525 let rules = filters::load_filter_table(&tmp);
5527
5528 let has_git_pull = rules
5530 .iter()
5531 .any(|r| r.pattern.is_match("git pull origin main"));
5532 assert!(
5533 has_git_pull,
5534 "should have git pull built-in rule after invalid TOML"
5535 );
5536
5537 let _ = std::fs::remove_dir_all(&tmp);
5539 }
5540
5541 #[test]
5542 fn test_metric_chars_threshold_breach_fires() {
5543 let output_chars: usize = 35_000;
5545 let event = crate::metrics::MetricEvent {
5546 ts: 0,
5547 tool: "exec_command",
5548 duration_ms: 1,
5549 output_chars,
5550 param_path_depth: 0,
5551 max_depth: None,
5552 result: "ok",
5553 error_type: None,
5554 session_id: None,
5555 seq: None,
5556 cache_hit: None,
5557 cache_write_failure: None,
5558 cache_tier: None,
5559 exit_code: None,
5560 timed_out: false,
5561 output_truncated: None,
5562 chars_threshold_breach: output_chars > 30_000,
5563 };
5564 assert!(
5565 event.chars_threshold_breach,
5566 "chars_threshold_breach should be true for output_chars=35000"
5567 );
5568 }
5569
5570 #[test]
5571 fn test_metric_chars_threshold_breach_no_fire() {
5572 let output_chars: usize = 5_000;
5574 let event = crate::metrics::MetricEvent {
5575 ts: 0,
5576 tool: "exec_command",
5577 duration_ms: 1,
5578 output_chars,
5579 param_path_depth: 0,
5580 max_depth: None,
5581 result: "ok",
5582 error_type: None,
5583 session_id: None,
5584 seq: None,
5585 cache_hit: None,
5586 cache_write_failure: None,
5587 cache_tier: None,
5588 exit_code: None,
5589 timed_out: false,
5590 output_truncated: None,
5591 chars_threshold_breach: output_chars > 30_000,
5592 };
5593 assert!(
5594 !event.chars_threshold_breach,
5595 "chars_threshold_breach should be false for output_chars=5000"
5596 );
5597 }
5598}