1#![cfg_attr(test, allow(clippy::unwrap_used))]
27
28mod filters;
29pub mod logging;
30pub mod metrics;
31pub mod otel;
32mod shell;
33mod validation;
34
35use aptu_coder_core::analyze;
36use aptu_coder_core::{cache, completion, graph, traversal, types};
37use shell::resolve_shell;
38use validation::{validate_path, validate_path_in_dir};
39
40pub const STDIN_MAX_BYTES: usize = 1_048_576;
41
42#[non_exhaustive]
43#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
44pub struct ExecCommandParams {
45 pub command: String,
47 pub timeout_secs: Option<u64>,
49 pub working_dir: Option<String>,
51 pub memory_limit_mb: Option<u64>,
54 pub cpu_limit_secs: Option<u64>,
57 pub stdin: Option<String>,
59 #[serde(default)]
62 pub cache: Option<bool>,
63}
64
65impl ExecCommandParams {
66 pub fn new(command: String, timeout_secs: Option<u64>, working_dir: Option<String>) -> Self {
68 Self {
69 command,
70 timeout_secs,
71 working_dir,
72 ..Default::default()
73 }
74 }
75}
76
77#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
78pub struct ShellOutput {
79 pub stdout: String,
81 pub stderr: String,
83 pub interleaved: String,
85 pub exit_code: Option<i32>,
87 pub timed_out: bool,
89 pub output_truncated: bool,
93 pub output_collection_error: Option<String>,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub stdout_path: Option<String>,
100 #[serde(skip_serializing_if = "Option::is_none")]
102 pub stderr_path: Option<String>,
103 #[serde(skip_serializing_if = "Option::is_none")]
105 pub filter_applied: Option<String>,
106}
107
108impl ShellOutput {
109 pub fn new(
111 stdout: String,
112 stderr: String,
113 interleaved: String,
114 exit_code: Option<i32>,
115 timed_out: bool,
116 output_truncated: bool,
117 ) -> Self {
118 Self {
119 stdout,
120 stderr,
121 interleaved,
122 exit_code,
123 timed_out,
124 output_truncated,
125 output_collection_error: None,
126 stdout_path: None,
127 stderr_path: None,
128 filter_applied: None,
129 }
130 }
131}
132
133use aptu_coder_core::cache::{AnalysisCache, CacheTier};
134use aptu_coder_core::formatter::{
135 format_file_details_paginated, format_file_details_summary, format_focused_paginated,
136 format_module_info, format_structure_paginated, format_summary,
137};
138use aptu_coder_core::formatter_defuse::format_focused_paginated_defuse;
139use aptu_coder_core::pagination::{
140 CursorData, DEFAULT_PAGE_SIZE, PaginationMode, decode_cursor, encode_cursor, paginate_slice,
141};
142use aptu_coder_core::parser::ParserError;
143use aptu_coder_core::traversal::{
144 WalkEntry, changed_files_from_git_ref, filter_entries_by_git_ref, walk_directory,
145};
146use aptu_coder_core::types::{
147 AnalysisMode, AnalyzeDirectoryParams, AnalyzeFileParams, AnalyzeModuleParams,
148 AnalyzeSymbolParams, EditOverwriteOutput, EditOverwriteParams, EditReplaceOutput,
149 EditReplaceParams, SymbolMatchMode,
150};
151use filters::{CompiledRule, apply_filter, load_filter_table, maybe_inject_no_stat};
152use logging::LogEvent;
153use rmcp::handler::server::tool::{ToolRouter, schema_for_type};
154use rmcp::handler::server::wrapper::Parameters;
155use rmcp::model::{
156 CallToolResult, CancelledNotificationParam, CompleteRequestParams, CompleteResult,
157 CompletionInfo, Content, ErrorData, Implementation, InitializeRequestParams, InitializeResult,
158 LoggingLevel, LoggingMessageNotificationParam, Meta, Notification, NumberOrString,
159 ProgressNotificationParam, ProgressToken, ServerCapabilities, ServerNotification,
160 SetLevelRequestParams,
161};
162use rmcp::service::{NotificationContext, RequestContext};
163use rmcp::{Peer, RoleServer, ServerHandler, tool, tool_handler, tool_router};
164use serde_json::Value;
165use std::path::{Path, PathBuf};
166use std::sync::{Arc, Mutex};
167use tokio::sync::{Mutex as TokioMutex, RwLock, mpsc};
168use tracing::{instrument, warn};
169use tracing_subscriber::filter::LevelFilter;
170
171#[cfg(unix)]
172use nix::sys::resource::{Resource, setrlimit};
173
174static GLOBAL_SESSION_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
175
176const SIZE_LIMIT: usize = 50_000;
177
178#[must_use]
181pub fn summary_cursor_conflict(summary: Option<bool>, cursor: Option<&str>) -> bool {
182 summary == Some(true) && cursor.is_some()
183}
184
185pub struct ClientMetadata {
187 pub session_id: Option<String>,
188 pub client_name: Option<String>,
189 pub client_version: Option<String>,
190}
191
192pub fn extract_and_set_trace_context(
200 meta: Option<&rmcp::model::Meta>,
201 client_meta: ClientMetadata,
202) {
203 use tracing_opentelemetry::OpenTelemetrySpanExt as _;
204
205 let span = tracing::Span::current();
206
207 if let Some(sid) = client_meta.session_id {
209 span.record("mcp.session.id", &sid);
210 }
211 if let Some(cn) = client_meta.client_name {
212 span.record("client.name", &cn);
213 }
214 if let Some(cv) = client_meta.client_version {
215 span.record("client.version", &cv);
216 }
217
218 if let Some(asi_str) = meta.and_then(|m| m.0.get("agent-session-id").and_then(|v| v.as_str())) {
220 span.record("mcp.client.session.id", asi_str);
221 }
222
223 let Some(meta) = meta else { return };
224
225 let mut propagation_map = std::collections::HashMap::new();
226
227 if let Some(traceparent) = meta.0.get("traceparent")
229 && let Some(tp_str) = traceparent.as_str()
230 {
231 propagation_map.insert("traceparent".to_string(), tp_str.to_string());
232 }
233
234 if let Some(tracestate) = meta.0.get("tracestate")
236 && let Some(ts_str) = tracestate.as_str()
237 {
238 propagation_map.insert("tracestate".to_string(), ts_str.to_string());
239 }
240
241 if propagation_map.is_empty() {
243 return;
244 }
245
246 let parent_cx = opentelemetry::global::get_text_map_propagator(|propagator| {
248 propagator.extract(&ExtractMap(&propagation_map))
249 });
250
251 let _ = span.set_parent(parent_cx);
254}
255
256struct ExtractMap<'a>(&'a std::collections::HashMap<String, String>);
258
259impl<'a> opentelemetry::propagation::Extractor for ExtractMap<'a> {
260 fn get(&self, key: &str) -> Option<&str> {
261 self.0.get(key).map(|s| s.as_str())
262 }
263
264 fn keys(&self) -> Vec<&str> {
265 self.0.keys().map(|k| k.as_str()).collect()
266 }
267}
268
269#[derive(Debug, Clone, Copy, serde::Serialize)]
270#[serde(rename_all = "camelCase")]
271struct ErrorMeta {
272 error_category: &'static str,
273 is_retryable: bool,
274 suggested_action: &'static str,
275}
276
277#[must_use]
278fn error_meta(
279 category: &'static str,
280 is_retryable: bool,
281 suggested_action: &'static str,
282) -> serde_json::Value {
283 serde_json::to_value(ErrorMeta {
284 error_category: category,
285 is_retryable,
286 suggested_action,
287 })
288 .unwrap_or_default()
289}
290
291#[must_use]
292fn err_to_tool_result(e: ErrorData) -> CallToolResult {
293 CallToolResult::error(vec![Content::text(e.message)])
294}
295
296fn err_to_tool_result_from_pagination(
297 e: aptu_coder_core::pagination::PaginationError,
298) -> CallToolResult {
299 let msg = format!("Pagination error: {}", e);
300 CallToolResult::error(vec![Content::text(msg)])
301}
302
303fn no_cache_meta() -> Meta {
304 let mut m = serde_json::Map::new();
305 m.insert(
306 "cache_hint".to_string(),
307 serde_json::Value::String("no-cache".to_string()),
308 );
309 Meta(m)
310}
311
312fn paginate_focus_chains(
315 chains: &[graph::InternalCallChain],
316 mode: PaginationMode,
317 offset: usize,
318 page_size: usize,
319) -> Result<(Vec<graph::InternalCallChain>, Option<String>), ErrorData> {
320 let paginated = paginate_slice(chains, offset, page_size, mode).map_err(|e| {
321 ErrorData::new(
322 rmcp::model::ErrorCode::INTERNAL_ERROR,
323 e.to_string(),
324 Some(error_meta("transient", true, "retry the request")),
325 )
326 })?;
327
328 if paginated.next_cursor.is_none() && offset == 0 {
329 return Ok((paginated.items, None));
330 }
331
332 let next = if let Some(raw_cursor) = paginated.next_cursor {
333 let decoded = decode_cursor(&raw_cursor).map_err(|e| {
334 ErrorData::new(
335 rmcp::model::ErrorCode::INVALID_PARAMS,
336 e.to_string(),
337 Some(error_meta("validation", false, "invalid cursor format")),
338 )
339 })?;
340 Some(
341 encode_cursor(&CursorData {
342 mode,
343 offset: decoded.offset,
344 })
345 .map_err(|e| {
346 ErrorData::new(
347 rmcp::model::ErrorCode::INVALID_PARAMS,
348 e.to_string(),
349 Some(error_meta("validation", false, "invalid cursor format")),
350 )
351 })?,
352 )
353 } else {
354 None
355 };
356
357 Ok((paginated.items, next))
358}
359
360#[derive(Clone)]
365pub struct CodeAnalyzer {
366 pub(crate) tool_router: Arc<RwLock<ToolRouter<Self>>>,
374 cache: AnalysisCache,
375 disk_cache: std::sync::Arc<cache::DiskCache>,
376 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
377 log_level_filter: Arc<Mutex<LevelFilter>>,
378 event_rx: Arc<TokioMutex<Option<mpsc::UnboundedReceiver<LogEvent>>>>,
379 metrics_tx: crate::metrics::MetricsSender,
380 session_call_seq: Arc<std::sync::atomic::AtomicU32>,
381 session_id: Arc<TokioMutex<Option<String>>>,
382 session_profile: Arc<std::sync::OnceLock<String>>,
385 client_name: Arc<TokioMutex<Option<String>>>,
386 client_version: Arc<TokioMutex<Option<String>>>,
387 resolved_path: Arc<Option<String>>,
390 filter_table: Arc<Vec<CompiledRule>>,
393}
394
395#[tool_router]
396impl CodeAnalyzer {
397 #[must_use]
398 pub fn list_tools() -> Vec<rmcp::model::Tool> {
399 Self::tool_router().list_all()
400 }
401
402 pub fn new(
403 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
404 log_level_filter: Arc<Mutex<LevelFilter>>,
405 event_rx: mpsc::UnboundedReceiver<LogEvent>,
406 metrics_tx: crate::metrics::MetricsSender,
407 ) -> Self {
408 let file_cap: usize = std::env::var("APTU_CODER_FILE_CACHE_CAPACITY")
409 .ok()
410 .and_then(|v| v.parse().ok())
411 .unwrap_or(100);
412
413 let xdg_data_home = if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
415 && !xdg_data_home.is_empty()
416 {
417 std::path::PathBuf::from(xdg_data_home)
418 } else if let Ok(home) = std::env::var("HOME") {
419 std::path::PathBuf::from(home).join(".local").join("share")
420 } else {
421 std::path::PathBuf::from(".")
422 };
423 let disk_cache_disabled = std::env::var("APTU_CODER_DISK_CACHE_DISABLED")
424 .map(|v| v == "1")
425 .unwrap_or(false);
426 let disk_cache_dir = std::env::var("APTU_CODER_DISK_CACHE_DIR")
427 .map(std::path::PathBuf::from)
428 .unwrap_or_else(|_| xdg_data_home.join("aptu-coder").join("analysis-cache"));
429 let disk_cache =
430 std::sync::Arc::new(cache::DiskCache::new(disk_cache_dir, disk_cache_disabled));
431
432 let resolved_path = {
441 let snapshot_shell = std::env::var("SHELL")
442 .ok()
443 .filter(|s| !s.is_empty())
444 .unwrap_or_else(|| {
445 let s = resolve_shell();
446 if s.is_empty() {
447 "/bin/sh".to_string()
448 } else {
449 s
450 }
451 });
452 let login_path = match std::process::Command::new(&snapshot_shell)
453 .args(["-l", "-c", "echo $PATH"])
454 .output()
455 {
456 Ok(output) => {
457 let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
458 if path_str.is_empty() {
459 tracing::warn!(
460 shell = %snapshot_shell,
461 "login shell PATH snapshot returned empty string"
462 );
463 None
464 } else {
465 Some(path_str)
466 }
467 }
468 Err(e) => {
469 tracing::warn!(
470 shell = %snapshot_shell,
471 error = %e,
472 "failed to snapshot login shell PATH"
473 );
474 None
475 }
476 };
477 let path = login_path.or_else(|| std::env::var("PATH").ok());
479 Arc::new(path)
480 };
481
482 let filter_table = Arc::new(load_filter_table(Path::new(".")));
483
484 CodeAnalyzer {
485 tool_router: Arc::new(RwLock::new(Self::tool_router())),
486 cache: AnalysisCache::new(file_cap),
487 disk_cache,
488 peer,
489 log_level_filter,
490 event_rx: Arc::new(TokioMutex::new(Some(event_rx))),
491 metrics_tx,
492 session_call_seq: Arc::new(std::sync::atomic::AtomicU32::new(0)),
493 session_id: Arc::new(TokioMutex::new(None)),
494 session_profile: Arc::new(std::sync::OnceLock::new()),
495 client_name: Arc::new(TokioMutex::new(None)),
496 client_version: Arc::new(TokioMutex::new(None)),
497 resolved_path,
498 filter_table,
499 }
500 }
501
502 #[instrument(skip(self))]
503 async fn emit_progress(
504 &self,
505 peer: Option<Peer<RoleServer>>,
506 token: &ProgressToken,
507 progress: f64,
508 total: f64,
509 message: String,
510 ) {
511 if let Some(peer) = peer {
512 let notification = ServerNotification::ProgressNotification(Notification::new(
513 ProgressNotificationParam {
514 progress_token: token.clone(),
515 progress,
516 total: Some(total),
517 message: Some(message),
518 },
519 ));
520 if let Err(e) = peer.send_notification(notification).await {
521 warn!("Failed to send progress notification: {}", e);
522 }
523 }
524 }
525
526 #[allow(clippy::too_many_lines)] #[allow(clippy::cast_precision_loss)] #[instrument(skip(self, params, ct))]
532 async fn handle_overview_mode(
533 &self,
534 params: &AnalyzeDirectoryParams,
535 ct: tokio_util::sync::CancellationToken,
536 ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, CacheTier), ErrorData> {
537 let path = Path::new(¶ms.path);
538 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
539 let counter_clone = counter.clone();
540 let path_owned = path.to_path_buf();
541 let max_depth = params.max_depth;
542 let ct_clone = ct.clone();
543
544 let all_entries = walk_directory(path, None).map_err(|e| {
546 ErrorData::new(
547 rmcp::model::ErrorCode::INTERNAL_ERROR,
548 format!("Failed to walk directory: {e}"),
549 Some(error_meta(
550 "resource",
551 false,
552 "check path permissions and availability",
553 )),
554 )
555 })?;
556
557 let canonical_max_depth = max_depth.and_then(|d| if d == 0 { None } else { Some(d) });
559
560 let git_ref_val = params.git_ref.as_deref().filter(|s| !s.is_empty());
563 let cache_key = cache::DirectoryCacheKey::from_entries(
564 &all_entries,
565 canonical_max_depth,
566 AnalysisMode::Overview,
567 git_ref_val,
568 );
569
570 if let Some(cached) = self.cache.get_directory(&cache_key) {
572 tracing::debug!(cache_hit = true, message = "returning cached result");
573 return Ok((cached, CacheTier::L1Memory));
574 }
575
576 let root = std::path::Path::new(¶ms.path);
578 let disk_key = {
579 let mut hasher = blake3::Hasher::new();
580 let mut sorted_entries: Vec<_> = all_entries.iter().collect();
581 sorted_entries.sort_by(|a, b| a.path.cmp(&b.path));
582 for entry in &sorted_entries {
583 let rel = entry.path.strip_prefix(root).unwrap_or(&entry.path);
584 hasher.update(rel.as_os_str().to_string_lossy().as_bytes());
585 let mtime_secs = entry
586 .mtime
587 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
588 .map(|d| d.as_secs())
589 .unwrap_or(0);
590 hasher.update(&mtime_secs.to_le_bytes());
591 }
592 if let Some(depth) = canonical_max_depth {
593 hasher.update(depth.to_string().as_bytes());
594 }
595 if let Some(ref git_ref) = params.git_ref {
596 hasher.update(git_ref.as_bytes());
597 }
598 hasher.finalize()
599 };
600
601 if let Some(cached) = self
603 .disk_cache
604 .get::<analyze::AnalysisOutput>("analyze_directory", &disk_key)
605 {
606 let arc = std::sync::Arc::new(cached);
607 self.cache.put_directory(cache_key.clone(), arc.clone());
608 return Ok((arc, CacheTier::L2Disk));
609 }
610
611 let all_entries = if let Some(ref git_ref) = params.git_ref
613 && !git_ref.is_empty()
614 {
615 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
616 ErrorData::new(
617 rmcp::model::ErrorCode::INVALID_PARAMS,
618 format!("git_ref filter failed: {e}"),
619 Some(error_meta(
620 "resource",
621 false,
622 "ensure git is installed and path is inside a git repository",
623 )),
624 )
625 })?;
626 filter_entries_by_git_ref(all_entries, &changed, path)
627 } else {
628 all_entries
629 };
630
631 let subtree_counts = if max_depth.is_some_and(|d| d > 0) {
633 Some(traversal::subtree_counts_from_entries(path, &all_entries))
634 } else {
635 None
636 };
637
638 let entries: Vec<traversal::WalkEntry> = if let Some(depth) = max_depth
640 && depth > 0
641 {
642 all_entries
643 .into_iter()
644 .filter(|e| e.depth <= depth as usize)
645 .collect()
646 } else {
647 all_entries
648 };
649
650 let total_files = entries.iter().filter(|e| !e.is_dir).count();
652
653 let handle = tokio::task::spawn_blocking(move || {
655 analyze::analyze_directory_with_progress(&path_owned, entries, counter_clone, ct_clone)
656 });
657
658 let token = ProgressToken(NumberOrString::String(
660 format!(
661 "analyze-overview-{}",
662 std::time::SystemTime::now()
663 .duration_since(std::time::UNIX_EPOCH)
664 .map(|d| d.as_nanos())
665 .unwrap_or(0)
666 )
667 .into(),
668 ));
669 let peer = self.peer.lock().await.clone();
670 let mut last_progress = 0usize;
671 let mut cancelled = false;
672 loop {
673 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
674 if ct.is_cancelled() {
675 cancelled = true;
676 break;
677 }
678 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
679 if current != last_progress && total_files > 0 {
680 self.emit_progress(
681 peer.clone(),
682 &token,
683 current as f64,
684 total_files as f64,
685 format!("Analyzing {current}/{total_files} files"),
686 )
687 .await;
688 last_progress = current;
689 }
690 if handle.is_finished() {
691 break;
692 }
693 }
694
695 if !cancelled && total_files > 0 {
697 self.emit_progress(
698 peer.clone(),
699 &token,
700 total_files as f64,
701 total_files as f64,
702 format!("Completed analyzing {total_files} files"),
703 )
704 .await;
705 }
706
707 match handle.await {
708 Ok(Ok(mut output)) => {
709 output.subtree_counts = subtree_counts;
710 let arc_output = std::sync::Arc::new(output);
711 self.cache.put_directory(cache_key, arc_output.clone());
712 {
714 let dc = self.disk_cache.clone();
715 let k = disk_key;
716 let v = arc_output.as_ref().clone();
717 let handle = tokio::task::spawn_blocking(move || {
718 dc.put("analyze_directory", &k, &v);
719 dc.drain_write_failures()
720 });
721 let metrics_tx = self.metrics_tx.clone();
722 let sid = self.session_id.lock().await.clone();
723 tokio::spawn(async move {
724 if let Ok(failures) = handle.await
725 && failures > 0
726 {
727 tracing::warn!(
728 tool = "analyze_directory",
729 failures,
730 "L2 disk cache write failed"
731 );
732 metrics_tx.send(crate::metrics::MetricEvent {
733 ts: crate::metrics::unix_ms(),
734 tool: "analyze_directory",
735 duration_ms: 0,
736 output_chars: 0,
737 param_path_depth: 0,
738 max_depth: None,
739 result: "ok",
740 error_type: None,
741 session_id: sid,
742 seq: None,
743 cache_hit: None,
744 cache_write_failure: Some(true),
745 cache_tier: None,
746 exit_code: None,
747 timed_out: false,
748 output_truncated: None,
749 ..Default::default()
750 });
751 }
752 });
753 }
754 Ok((arc_output, CacheTier::Miss))
755 }
756 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
757 rmcp::model::ErrorCode::INTERNAL_ERROR,
758 "Analysis cancelled".to_string(),
759 Some(error_meta("transient", true, "analysis was cancelled")),
760 )),
761 Ok(Err(e)) => Err(ErrorData::new(
762 rmcp::model::ErrorCode::INTERNAL_ERROR,
763 format!("Error analyzing directory: {e}"),
764 Some(error_meta(
765 "resource",
766 false,
767 "check path and file permissions",
768 )),
769 )),
770 Err(e) => Err(ErrorData::new(
771 rmcp::model::ErrorCode::INTERNAL_ERROR,
772 format!("Task join error: {e}"),
773 Some(error_meta("transient", true, "retry the request")),
774 )),
775 }
776 }
777
778 #[instrument(skip(self, params))]
781 async fn handle_file_details_mode(
782 &self,
783 params: &AnalyzeFileParams,
784 ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, CacheTier), ErrorData> {
785 let cache_key = std::fs::metadata(¶ms.path).ok().and_then(|meta| {
787 meta.modified().ok().map(|mtime| cache::CacheKey {
788 path: std::path::PathBuf::from(¶ms.path),
789 modified: mtime,
790 mode: AnalysisMode::FileDetails,
791 })
792 });
793
794 if let Some(ref key) = cache_key
796 && let Some(cached) = self.cache.get(key)
797 {
798 tracing::debug!(cache_hit = true, message = "returning cached result");
799 return Ok((cached, CacheTier::L1Memory));
800 }
801
802 let file_bytes = std::fs::read(¶ms.path).unwrap_or_default();
804 let disk_key = blake3::hash(&file_bytes);
805
806 if let Some(cached) = self
808 .disk_cache
809 .get::<analyze::FileAnalysisOutput>("analyze_file", &disk_key)
810 {
811 let arc = std::sync::Arc::new(cached);
812 if let Some(ref key) = cache_key {
813 self.cache.put(key.clone(), arc.clone());
814 }
815 return Ok((arc, CacheTier::L2Disk));
816 }
817
818 match analyze::analyze_file(¶ms.path, params.ast_recursion_limit) {
820 Ok(output) => {
821 let arc_output = std::sync::Arc::new(output);
822 if let Some(key) = cache_key {
823 self.cache.put(key, arc_output.clone());
824 }
825 {
827 let dc = self.disk_cache.clone();
828 let k = disk_key;
829 let v = arc_output.as_ref().clone();
830 let handle = tokio::task::spawn_blocking(move || {
831 dc.put("analyze_file", &k, &v);
832 dc.drain_write_failures()
833 });
834 let metrics_tx = self.metrics_tx.clone();
835 let sid = self.session_id.lock().await.clone();
836 tokio::spawn(async move {
837 if let Ok(failures) = handle.await
838 && failures > 0
839 {
840 tracing::warn!(
841 tool = "analyze_file",
842 failures,
843 "L2 disk cache write failed"
844 );
845 metrics_tx.send(crate::metrics::MetricEvent {
846 ts: crate::metrics::unix_ms(),
847 tool: "analyze_file",
848 duration_ms: 0,
849 output_chars: 0,
850 param_path_depth: 0,
851 max_depth: None,
852 result: "ok",
853 error_type: None,
854 session_id: sid,
855 seq: None,
856 cache_hit: None,
857 cache_write_failure: Some(true),
858 cache_tier: None,
859 exit_code: None,
860 timed_out: false,
861 output_truncated: None,
862 ..Default::default()
863 });
864 }
865 });
866 }
867 Ok((arc_output, CacheTier::Miss))
868 }
869 Err(e) => match &e {
870 analyze::AnalyzeError::Parser(ParserError::UnsupportedLanguage(lang)) => {
871 Err(ErrorData::new(
872 rmcp::model::ErrorCode::INVALID_PARAMS,
873 format!(
874 "Unsupported language: {lang}. Supported extensions: {}",
875 aptu_coder_core::lang::supported_extensions().join(", ")
876 ),
877 Some(error_meta(
878 "invalid_request",
879 false,
880 "provide a file with a supported extension",
881 )),
882 ))
883 }
884 _ => Err(ErrorData::new(
885 rmcp::model::ErrorCode::INTERNAL_ERROR,
886 format!("Error analyzing file: {e}"),
887 Some(error_meta(
888 "resource",
889 false,
890 "check file path and permissions",
891 )),
892 )),
893 },
894 }
895 }
896
897 fn validate_impl_only(entries: &[WalkEntry]) -> Result<(), ErrorData> {
899 let has_rust = entries.iter().any(|e| {
900 !e.is_dir
901 && e.path
902 .extension()
903 .and_then(|x: &std::ffi::OsStr| x.to_str())
904 == Some("rs")
905 });
906
907 if !has_rust {
908 return Err(ErrorData::new(
909 rmcp::model::ErrorCode::INVALID_PARAMS,
910 "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(),
911 Some(error_meta(
912 "validation",
913 false,
914 "remove impl_only or point to a directory containing .rs files",
915 )),
916 ));
917 }
918 Ok(())
919 }
920
921 fn validate_import_lookup(import_lookup: Option<bool>, symbol: &str) -> Result<(), ErrorData> {
923 if import_lookup == Some(true) && symbol.is_empty() {
924 return Err(ErrorData::new(
925 rmcp::model::ErrorCode::INVALID_PARAMS,
926 "import_lookup=true requires symbol to contain the module path to search for"
927 .to_string(),
928 Some(error_meta(
929 "validation",
930 false,
931 "set symbol to the module path when using import_lookup=true",
932 )),
933 ));
934 }
935 Ok(())
936 }
937
938 #[allow(clippy::cast_precision_loss)] async fn poll_progress_until_done(
941 &self,
942 analysis_params: &FocusedAnalysisParams,
943 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
944 ct: tokio_util::sync::CancellationToken,
945 entries: std::sync::Arc<Vec<WalkEntry>>,
946 total_files: usize,
947 symbol_display: &str,
948 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
949 let counter_clone = counter.clone();
950 let ct_clone = ct.clone();
951 let entries_clone = std::sync::Arc::clone(&entries);
952 let path_owned = analysis_params.path.clone();
953 let symbol_owned = analysis_params.symbol.clone();
954 let match_mode_owned = analysis_params.match_mode.clone();
955 let follow_depth = analysis_params.follow_depth;
956 let max_depth = analysis_params.max_depth;
957 let ast_recursion_limit = analysis_params.ast_recursion_limit;
958 let use_summary = analysis_params.use_summary;
959 let impl_only = analysis_params.impl_only;
960 let def_use = analysis_params.def_use;
961 let parse_timeout_micros = analysis_params.parse_timeout_micros;
962 let handle = tokio::task::spawn_blocking(move || {
963 let params = analyze::FocusedAnalysisConfig {
964 focus: symbol_owned,
965 match_mode: match_mode_owned,
966 follow_depth,
967 max_depth,
968 ast_recursion_limit,
969 use_summary,
970 impl_only,
971 def_use,
972 parse_timeout_micros,
973 };
974 analyze::analyze_focused_with_progress_with_entries(
975 &path_owned,
976 ¶ms,
977 &counter_clone,
978 &ct_clone,
979 &entries_clone,
980 )
981 });
982
983 let token = ProgressToken(NumberOrString::String(
984 format!(
985 "analyze-symbol-{}",
986 std::time::SystemTime::now()
987 .duration_since(std::time::UNIX_EPOCH)
988 .map(|d| d.as_nanos())
989 .unwrap_or(0)
990 )
991 .into(),
992 ));
993 let peer = self.peer.lock().await.clone();
994 let mut last_progress = 0usize;
995 let mut cancelled = false;
996
997 loop {
998 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
999 if ct.is_cancelled() {
1000 cancelled = true;
1001 break;
1002 }
1003 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
1004 if current != last_progress && total_files > 0 {
1005 self.emit_progress(
1006 peer.clone(),
1007 &token,
1008 current as f64,
1009 total_files as f64,
1010 format!(
1011 "Analyzing {current}/{total_files} files for symbol '{symbol_display}'"
1012 ),
1013 )
1014 .await;
1015 last_progress = current;
1016 }
1017 if handle.is_finished() {
1018 break;
1019 }
1020 }
1021
1022 if !cancelled && total_files > 0 {
1023 self.emit_progress(
1024 peer.clone(),
1025 &token,
1026 total_files as f64,
1027 total_files as f64,
1028 format!("Completed analyzing {total_files} files for symbol '{symbol_display}'"),
1029 )
1030 .await;
1031 }
1032
1033 match handle.await {
1034 Ok(Ok(output)) => Ok(output),
1035 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
1036 rmcp::model::ErrorCode::INTERNAL_ERROR,
1037 "Analysis cancelled".to_string(),
1038 Some(error_meta("transient", true, "analysis was cancelled")),
1039 )),
1040 Ok(Err(e)) => Err(ErrorData::new(
1041 rmcp::model::ErrorCode::INTERNAL_ERROR,
1042 format!("Error analyzing symbol: {e}"),
1043 Some(error_meta("resource", false, "check symbol name and file")),
1044 )),
1045 Err(e) => Err(ErrorData::new(
1046 rmcp::model::ErrorCode::INTERNAL_ERROR,
1047 format!("Task join error: {e}"),
1048 Some(error_meta("transient", true, "retry the request")),
1049 )),
1050 }
1051 }
1052
1053 async fn run_focused_with_auto_summary(
1055 &self,
1056 params: &AnalyzeSymbolParams,
1057 analysis_params: &FocusedAnalysisParams,
1058 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1059 ct: tokio_util::sync::CancellationToken,
1060 entries: std::sync::Arc<Vec<WalkEntry>>,
1061 total_files: usize,
1062 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1063 let use_summary_for_task = params.output_control.force != Some(true)
1064 && params.output_control.summary == Some(true);
1065
1066 let analysis_params_initial = FocusedAnalysisParams {
1067 use_summary: use_summary_for_task,
1068 ..analysis_params.clone()
1069 };
1070
1071 let mut output = self
1072 .poll_progress_until_done(
1073 &analysis_params_initial,
1074 counter.clone(),
1075 ct.clone(),
1076 entries.clone(),
1077 total_files,
1078 ¶ms.symbol,
1079 )
1080 .await?;
1081
1082 if params.output_control.summary.is_none()
1083 && params.output_control.force != Some(true)
1084 && output.formatted.len() > SIZE_LIMIT
1085 {
1086 tracing::debug!(
1087 auto_summary = true,
1088 message = "output exceeded size limit, retrying with summary"
1089 );
1090 let counter2 = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1091 let analysis_params_retry = FocusedAnalysisParams {
1092 use_summary: true,
1093 ..analysis_params.clone()
1094 };
1095 let summary_result = self
1096 .poll_progress_until_done(
1097 &analysis_params_retry,
1098 counter2,
1099 ct,
1100 entries,
1101 total_files,
1102 ¶ms.symbol,
1103 )
1104 .await;
1105
1106 if let Ok(summary_output) = summary_result {
1107 output.formatted = summary_output.formatted;
1108 } else {
1109 let estimated_tokens = output.formatted.len() / 4;
1110 let message = format!(
1111 "Output exceeds 50K chars ({} chars, ~{} tokens). Use summary=true or force=true.",
1112 output.formatted.len(),
1113 estimated_tokens
1114 );
1115 return Err(ErrorData::new(
1116 rmcp::model::ErrorCode::INVALID_PARAMS,
1117 message,
1118 Some(error_meta(
1119 "validation",
1120 false,
1121 "use summary=true or force=true",
1122 )),
1123 ));
1124 }
1125 } else if output.formatted.len() > SIZE_LIMIT
1126 && params.output_control.force != Some(true)
1127 && params.output_control.summary == Some(false)
1128 {
1129 let estimated_tokens = output.formatted.len() / 4;
1130 let message = format!(
1131 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1132 - force=true to return full output\n\
1133 - summary=true to get compact summary\n\
1134 - Narrow your scope (smaller directory, specific file)",
1135 output.formatted.len(),
1136 estimated_tokens
1137 );
1138 return Err(ErrorData::new(
1139 rmcp::model::ErrorCode::INVALID_PARAMS,
1140 message,
1141 Some(error_meta(
1142 "validation",
1143 false,
1144 "use force=true, summary=true, or narrow scope",
1145 )),
1146 ));
1147 }
1148
1149 Ok(output)
1150 }
1151
1152 #[instrument(skip(self, params, ct))]
1156 async fn handle_focused_mode(
1157 &self,
1158 params: &AnalyzeSymbolParams,
1159 ct: tokio_util::sync::CancellationToken,
1160 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1161 let path = Path::new(¶ms.path);
1162 let raw_entries = match walk_directory(path, params.max_depth) {
1163 Ok(e) => e,
1164 Err(e) => {
1165 return Err(ErrorData::new(
1166 rmcp::model::ErrorCode::INTERNAL_ERROR,
1167 format!("Failed to walk directory: {e}"),
1168 Some(error_meta(
1169 "resource",
1170 false,
1171 "check path permissions and availability",
1172 )),
1173 ));
1174 }
1175 };
1176 let filtered_entries = if let Some(ref git_ref) = params.git_ref
1178 && !git_ref.is_empty()
1179 {
1180 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
1181 ErrorData::new(
1182 rmcp::model::ErrorCode::INVALID_PARAMS,
1183 format!("git_ref filter failed: {e}"),
1184 Some(error_meta(
1185 "resource",
1186 false,
1187 "ensure git is installed and path is inside a git repository",
1188 )),
1189 )
1190 })?;
1191 filter_entries_by_git_ref(raw_entries, &changed, path)
1192 } else {
1193 raw_entries
1194 };
1195 let entries = std::sync::Arc::new(filtered_entries);
1196
1197 if params.impl_only == Some(true) {
1198 Self::validate_impl_only(&entries)?;
1199 }
1200
1201 let total_files = entries.iter().filter(|e| !e.is_dir).count();
1202 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1203
1204 let analysis_params = FocusedAnalysisParams {
1205 path: path.to_path_buf(),
1206 symbol: params.symbol.clone(),
1207 match_mode: params.match_mode.clone().unwrap_or_default(),
1208 follow_depth: params.follow_depth.unwrap_or(1),
1209 max_depth: params.max_depth,
1210 ast_recursion_limit: params.ast_recursion_limit,
1211 use_summary: false,
1212 impl_only: params.impl_only,
1213 def_use: params.def_use.unwrap_or(false),
1214 parse_timeout_micros: None,
1215 };
1216
1217 let mut output = self
1218 .run_focused_with_auto_summary(
1219 params,
1220 &analysis_params,
1221 counter,
1222 ct,
1223 entries,
1224 total_files,
1225 )
1226 .await?;
1227
1228 if params.impl_only == Some(true) {
1229 let filter_line = format!(
1230 "FILTER: impl_only=true ({} of {} callers shown)\n",
1231 output.impl_trait_caller_count, output.unfiltered_caller_count
1232 );
1233 output.formatted = format!("{}{}", filter_line, output.formatted);
1234
1235 if output.impl_trait_caller_count == 0 {
1236 output.formatted.push_str(
1237 "\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"
1238 );
1239 }
1240 }
1241
1242 Ok(output)
1243 }
1244
1245 #[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))]
1246 #[tool(
1247 name = "analyze_directory",
1248 title = "Analyze Directory",
1249 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?",
1250 output_schema = schema_for_type::<analyze::AnalysisOutput>(),
1251 annotations(
1252 title = "Analyze Directory",
1253 read_only_hint = true,
1254 destructive_hint = false,
1255 idempotent_hint = true,
1256 open_world_hint = false
1257 )
1258 )]
1259 async fn analyze_directory(
1260 &self,
1261 params: Parameters<AnalyzeDirectoryParams>,
1262 context: RequestContext<RoleServer>,
1263 ) -> Result<CallToolResult, ErrorData> {
1264 let params = params.0;
1265 let session_id = self.session_id.lock().await.clone();
1267 let client_name = self.client_name.lock().await.clone();
1268 let client_version = self.client_version.lock().await.clone();
1269 extract_and_set_trace_context(
1270 Some(&context.meta),
1271 ClientMetadata {
1272 session_id,
1273 client_name,
1274 client_version,
1275 },
1276 );
1277 let span = tracing::Span::current();
1278 span.record("gen_ai.system", "mcp");
1279 span.record("gen_ai.operation.name", "execute_tool");
1280 span.record("gen_ai.tool.name", "analyze_directory");
1281 span.record("path", ¶ms.path);
1282 let _validated_path = match validate_path(¶ms.path, true) {
1283 Ok(p) => p,
1284 Err(e) => {
1285 span.record("error", true);
1286 span.record("error.type", "invalid_params");
1287 return Ok(err_to_tool_result(e));
1288 }
1289 };
1290 let ct = context.ct.clone();
1291 let t_start = std::time::Instant::now();
1292 let param_path = params.path.clone();
1293 let max_depth_val = params.max_depth;
1294 let seq = self
1295 .session_call_seq
1296 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1297 let sid = self.session_id.lock().await.clone();
1298
1299 let (arc_output, dir_cache_hit) = match self.handle_overview_mode(¶ms, ct).await {
1301 Ok(v) => v,
1302 Err(e) => {
1303 span.record("error", true);
1304 span.record("error.type", "internal_error");
1305 return Ok(err_to_tool_result(e));
1306 }
1307 };
1308 let mut output = match std::sync::Arc::try_unwrap(arc_output) {
1311 Ok(owned) => owned,
1312 Err(arc) => (*arc).clone(),
1313 };
1314
1315 if summary_cursor_conflict(
1318 params.output_control.summary,
1319 params.pagination.cursor.as_deref(),
1320 ) {
1321 span.record("error", true);
1322 span.record("error.type", "invalid_params");
1323 return Ok(err_to_tool_result(ErrorData::new(
1324 rmcp::model::ErrorCode::INVALID_PARAMS,
1325 "summary=true is incompatible with a pagination cursor; use one or the other"
1326 .to_string(),
1327 Some(error_meta(
1328 "validation",
1329 false,
1330 "remove cursor or set summary=false",
1331 )),
1332 )));
1333 }
1334
1335 let use_summary = if params.output_control.force == Some(true) {
1337 false
1338 } else if params.output_control.summary == Some(true) {
1339 true
1340 } else if params.output_control.summary == Some(false) {
1341 false
1342 } else {
1343 output.formatted.len() > SIZE_LIMIT
1344 };
1345
1346 if use_summary {
1347 output.formatted = format_summary(
1348 &output.entries,
1349 &output.files,
1350 params.max_depth,
1351 output.subtree_counts.as_deref(),
1352 );
1353 }
1354
1355 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1357 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1358 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1359 ErrorData::new(
1360 rmcp::model::ErrorCode::INVALID_PARAMS,
1361 e.to_string(),
1362 Some(error_meta("validation", false, "invalid cursor format")),
1363 )
1364 }) {
1365 Ok(v) => v,
1366 Err(e) => {
1367 span.record("error", true);
1368 span.record("error.type", "invalid_params");
1369 return Ok(err_to_tool_result(e));
1370 }
1371 };
1372 cursor_data.offset
1373 } else {
1374 0
1375 };
1376
1377 let paginated =
1379 match paginate_slice(&output.files, offset, page_size, PaginationMode::Default) {
1380 Ok(v) => v,
1381 Err(e) => {
1382 span.record("error", true);
1383 span.record("error.type", "internal_error");
1384 return Ok(err_to_tool_result(ErrorData::new(
1385 rmcp::model::ErrorCode::INTERNAL_ERROR,
1386 e.to_string(),
1387 Some(error_meta("transient", true, "retry the request")),
1388 )));
1389 }
1390 };
1391
1392 let verbose = params.output_control.verbose.unwrap_or(false);
1393 if !use_summary {
1394 output.formatted = format_structure_paginated(
1395 &paginated.items,
1396 paginated.total,
1397 params.max_depth,
1398 Some(Path::new(¶ms.path)),
1399 verbose,
1400 );
1401 }
1402
1403 if use_summary {
1405 output.next_cursor = None;
1406 } else {
1407 output.next_cursor.clone_from(&paginated.next_cursor);
1408 }
1409
1410 let mut final_text = output.formatted.clone();
1412 if !use_summary && let Some(cursor) = paginated.next_cursor {
1413 final_text.push('\n');
1414 final_text.push_str("NEXT_CURSOR: ");
1415 final_text.push_str(&cursor);
1416 }
1417
1418 tracing::Span::current().record("cache_tier", dir_cache_hit.as_str());
1420
1421 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1423 let mut meta = no_cache_meta().0;
1424 meta.insert(
1425 "content_hash".to_string(),
1426 serde_json::Value::String(content_hash),
1427 );
1428 let meta = rmcp::model::Meta(meta);
1429
1430 let mut result =
1431 CallToolResult::success(vec![Content::text(final_text.clone())]).with_meta(Some(meta));
1432 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1433 result.structured_content = Some(structured);
1434 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1435 self.metrics_tx.send(crate::metrics::MetricEvent {
1436 ts: crate::metrics::unix_ms(),
1437 tool: "analyze_directory",
1438 duration_ms: dur,
1439 output_chars: final_text.len(),
1440 param_path_depth: crate::metrics::path_component_count(¶m_path),
1441 max_depth: max_depth_val,
1442 result: "ok",
1443 error_type: None,
1444 session_id: sid,
1445 seq: Some(seq),
1446 cache_hit: Some(dir_cache_hit != CacheTier::Miss),
1447 cache_write_failure: None,
1448 cache_tier: Some(dir_cache_hit.as_str()),
1449 exit_code: None,
1450 timed_out: false,
1451 output_truncated: None,
1452 ..Default::default()
1453 });
1454 Ok(result)
1455 }
1456
1457 #[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))]
1458 #[tool(
1459 name = "analyze_file",
1460 title = "Analyze File",
1461 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.",
1462 output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
1463 annotations(
1464 title = "Analyze File",
1465 read_only_hint = true,
1466 destructive_hint = false,
1467 idempotent_hint = true,
1468 open_world_hint = false
1469 )
1470 )]
1471 async fn analyze_file(
1472 &self,
1473 params: Parameters<AnalyzeFileParams>,
1474 context: RequestContext<RoleServer>,
1475 ) -> Result<CallToolResult, ErrorData> {
1476 let params = params.0;
1477 let session_id = self.session_id.lock().await.clone();
1479 let client_name = self.client_name.lock().await.clone();
1480 let client_version = self.client_version.lock().await.clone();
1481 extract_and_set_trace_context(
1482 Some(&context.meta),
1483 ClientMetadata {
1484 session_id,
1485 client_name,
1486 client_version,
1487 },
1488 );
1489 let span = tracing::Span::current();
1490 span.record("gen_ai.system", "mcp");
1491 span.record("gen_ai.operation.name", "execute_tool");
1492 span.record("gen_ai.tool.name", "analyze_file");
1493 span.record("path", ¶ms.path);
1494 let _validated_path = match validate_path(¶ms.path, true) {
1495 Ok(p) => p,
1496 Err(e) => {
1497 span.record("error", true);
1498 span.record("error.type", "invalid_params");
1499 return Ok(err_to_tool_result(e));
1500 }
1501 };
1502 let t_start = std::time::Instant::now();
1503 let param_path = params.path.clone();
1504 let seq = self
1505 .session_call_seq
1506 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1507 let sid = self.session_id.lock().await.clone();
1508
1509 if std::path::Path::new(¶ms.path).is_dir() {
1511 span.record("error", true);
1512 span.record("error.type", "invalid_params");
1513 return Ok(err_to_tool_result(ErrorData::new(
1514 rmcp::model::ErrorCode::INVALID_PARAMS,
1515 format!(
1516 "'{}' is a directory; use analyze_directory instead",
1517 params.path
1518 ),
1519 Some(error_meta(
1520 "validation",
1521 false,
1522 "pass a file path, not a directory",
1523 )),
1524 )));
1525 }
1526
1527 if summary_cursor_conflict(
1529 params.output_control.summary,
1530 params.pagination.cursor.as_deref(),
1531 ) {
1532 span.record("error", true);
1533 span.record("error.type", "invalid_params");
1534 return Ok(err_to_tool_result(ErrorData::new(
1535 rmcp::model::ErrorCode::INVALID_PARAMS,
1536 "summary=true is incompatible with a pagination cursor; use one or the other"
1537 .to_string(),
1538 Some(error_meta(
1539 "validation",
1540 false,
1541 "remove cursor or set summary=false",
1542 )),
1543 )));
1544 }
1545
1546 let (arc_output, file_cache_hit) = match self.handle_file_details_mode(¶ms).await {
1548 Ok(v) => v,
1549 Err(e) => {
1550 span.record("error", true);
1551 span.record("error.type", "internal_error");
1552 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1553 let error_type = match e.code {
1554 rmcp::model::ErrorCode::INVALID_PARAMS => Some("invalid_params".to_string()),
1555 rmcp::model::ErrorCode::INTERNAL_ERROR => Some("internal_error".to_string()),
1556 _ => None,
1557 };
1558 self.metrics_tx.send(crate::metrics::MetricEvent {
1559 ts: crate::metrics::unix_ms(),
1560 tool: "analyze_file",
1561 duration_ms: dur,
1562 output_chars: 0,
1563 param_path_depth: crate::metrics::path_component_count(¶m_path),
1564 max_depth: None,
1565 result: "error",
1566 error_type,
1567 session_id: sid.clone(),
1568 seq: Some(seq),
1569 cache_hit: None,
1570 cache_write_failure: None,
1571 cache_tier: None,
1572 exit_code: None,
1573 timed_out: false,
1574 output_truncated: None,
1575 file_ext: crate::metrics::path_file_ext(¶m_path),
1576 ..Default::default()
1577 });
1578 return Ok(err_to_tool_result(e));
1579 }
1580 };
1581
1582 let mut formatted = arc_output.formatted.clone();
1586 let line_count = arc_output.line_count;
1587
1588 let use_summary = if params.output_control.force == Some(true) {
1590 false
1591 } else if params.output_control.summary == Some(true) {
1592 true
1593 } else if params.output_control.summary == Some(false) {
1594 false
1595 } else {
1596 formatted.len() > SIZE_LIMIT
1597 };
1598
1599 if use_summary {
1600 formatted = format_file_details_summary(&arc_output.semantic, ¶ms.path, line_count);
1601 } else if formatted.len() > SIZE_LIMIT && params.output_control.force != Some(true) {
1602 span.record("error", true);
1603 span.record("error.type", "invalid_params");
1604 let estimated_tokens = formatted.len() / 4;
1605 let message = format!(
1606 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1607 - force=true to return full output\n\
1608 - Use fields to limit output to specific sections (functions, classes, or imports)\n\
1609 - Use summary=true for a compact overview",
1610 formatted.len(),
1611 estimated_tokens
1612 );
1613 return Ok(err_to_tool_result(ErrorData::new(
1614 rmcp::model::ErrorCode::INVALID_PARAMS,
1615 message,
1616 Some(error_meta(
1617 "validation",
1618 false,
1619 "use force=true, fields, or summary=true",
1620 )),
1621 )));
1622 }
1623
1624 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1626 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1627 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1628 ErrorData::new(
1629 rmcp::model::ErrorCode::INVALID_PARAMS,
1630 e.to_string(),
1631 Some(error_meta("validation", false, "invalid cursor format")),
1632 )
1633 }) {
1634 Ok(v) => v,
1635 Err(e) => {
1636 span.record("error", true);
1637 span.record("error.type", "invalid_params");
1638 return Ok(err_to_tool_result(e));
1639 }
1640 };
1641 cursor_data.offset
1642 } else {
1643 0
1644 };
1645
1646 let top_level_fns: Vec<crate::types::FunctionInfo> = arc_output
1648 .semantic
1649 .functions
1650 .iter()
1651 .filter(|func| {
1652 !arc_output
1653 .semantic
1654 .classes
1655 .iter()
1656 .any(|class| func.line >= class.line && func.end_line <= class.end_line)
1657 })
1658 .cloned()
1659 .collect();
1660
1661 let paginated =
1663 match paginate_slice(&top_level_fns, offset, page_size, PaginationMode::Default) {
1664 Ok(v) => v,
1665 Err(e) => {
1666 return Ok(err_to_tool_result(ErrorData::new(
1667 rmcp::model::ErrorCode::INTERNAL_ERROR,
1668 e.to_string(),
1669 Some(error_meta("transient", true, "retry the request")),
1670 )));
1671 }
1672 };
1673
1674 let verbose = params.output_control.verbose.unwrap_or(false);
1676 if !use_summary {
1677 formatted = format_file_details_paginated(
1679 &paginated.items,
1680 paginated.total,
1681 &arc_output.semantic,
1682 ¶ms.path,
1683 line_count,
1684 offset,
1685 verbose,
1686 params.fields.as_deref(),
1687 );
1688 }
1689
1690 let next_cursor = if use_summary {
1692 None
1693 } else {
1694 paginated.next_cursor.clone()
1695 };
1696
1697 let mut final_text = formatted.clone();
1699 if !use_summary && let Some(ref cursor) = next_cursor {
1700 final_text.push('\n');
1701 final_text.push_str("NEXT_CURSOR: ");
1702 final_text.push_str(cursor);
1703 }
1704
1705 let response_output = analyze::FileAnalysisOutput::new(
1707 formatted,
1708 arc_output.semantic.clone(),
1709 line_count,
1710 next_cursor,
1711 );
1712
1713 tracing::Span::current().record("cache_tier", file_cache_hit.as_str());
1715
1716 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1718 let mut meta = no_cache_meta().0;
1719 meta.insert(
1720 "content_hash".to_string(),
1721 serde_json::Value::String(content_hash),
1722 );
1723 let meta = rmcp::model::Meta(meta);
1724
1725 let mut result =
1726 CallToolResult::success(vec![Content::text(final_text.clone())]).with_meta(Some(meta));
1727 let structured = serde_json::to_value(&response_output).unwrap_or(Value::Null);
1728 result.structured_content = Some(structured);
1729 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1730 self.metrics_tx.send(crate::metrics::MetricEvent {
1731 ts: crate::metrics::unix_ms(),
1732 tool: "analyze_file",
1733 duration_ms: dur,
1734 output_chars: final_text.len(),
1735 param_path_depth: crate::metrics::path_component_count(¶m_path),
1736 max_depth: None,
1737 result: "ok",
1738 error_type: None,
1739 session_id: sid,
1740 seq: Some(seq),
1741 cache_hit: Some(file_cache_hit != CacheTier::Miss),
1742 cache_write_failure: None,
1743 cache_tier: Some(file_cache_hit.as_str()),
1744 exit_code: None,
1745 timed_out: false,
1746 output_truncated: None,
1747 file_ext: crate::metrics::path_file_ext(¶m_path),
1748 ..Default::default()
1749 });
1750 Ok(result)
1751 }
1752
1753 #[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))]
1754 #[tool(
1755 name = "analyze_symbol",
1756 title = "Analyze Symbol",
1757 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.",
1758 output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
1759 annotations(
1760 title = "Analyze Symbol",
1761 read_only_hint = true,
1762 destructive_hint = false,
1763 idempotent_hint = true,
1764 open_world_hint = false
1765 )
1766 )]
1767 async fn analyze_symbol(
1768 &self,
1769 params: Parameters<AnalyzeSymbolParams>,
1770 context: RequestContext<RoleServer>,
1771 ) -> Result<CallToolResult, ErrorData> {
1772 let params = params.0;
1773 let session_id = self.session_id.lock().await.clone();
1775 let client_name = self.client_name.lock().await.clone();
1776 let client_version = self.client_version.lock().await.clone();
1777 extract_and_set_trace_context(
1778 Some(&context.meta),
1779 ClientMetadata {
1780 session_id,
1781 client_name,
1782 client_version,
1783 },
1784 );
1785 let span = tracing::Span::current();
1786 span.record("gen_ai.system", "mcp");
1787 span.record("gen_ai.operation.name", "execute_tool");
1788 span.record("gen_ai.tool.name", "analyze_symbol");
1789 span.record("symbol", ¶ms.symbol);
1790 let _validated_path = match validate_path(¶ms.path, true) {
1791 Ok(p) => p,
1792 Err(e) => {
1793 span.record("error", true);
1794 span.record("error.type", "invalid_params");
1795 return Ok(err_to_tool_result(e));
1796 }
1797 };
1798 let ct = context.ct.clone();
1799 let t_start = std::time::Instant::now();
1800 let param_path = params.path.clone();
1801 let max_depth_val = params.follow_depth;
1802 let seq = self
1803 .session_call_seq
1804 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1805 let sid = self.session_id.lock().await.clone();
1806
1807 if std::path::Path::new(¶ms.path).is_file() {
1809 span.record("error", true);
1810 span.record("error.type", "invalid_params");
1811 return Ok(err_to_tool_result(ErrorData::new(
1812 rmcp::model::ErrorCode::INVALID_PARAMS,
1813 format!(
1814 "'{}' is a file; analyze_symbol requires a directory path",
1815 params.path
1816 ),
1817 Some(error_meta(
1818 "validation",
1819 false,
1820 "pass a directory path, not a file",
1821 )),
1822 )));
1823 }
1824
1825 if summary_cursor_conflict(
1827 params.output_control.summary,
1828 params.pagination.cursor.as_deref(),
1829 ) {
1830 span.record("error", true);
1831 span.record("error.type", "invalid_params");
1832 return Ok(err_to_tool_result(ErrorData::new(
1833 rmcp::model::ErrorCode::INVALID_PARAMS,
1834 "summary=true is incompatible with a pagination cursor; use one or the other"
1835 .to_string(),
1836 Some(error_meta(
1837 "validation",
1838 false,
1839 "remove cursor or set summary=false",
1840 )),
1841 )));
1842 }
1843
1844 if let Err(e) = Self::validate_import_lookup(params.import_lookup, ¶ms.symbol) {
1846 span.record("error", true);
1847 span.record("error.type", "invalid_params");
1848 return Ok(err_to_tool_result(e));
1849 }
1850
1851 if params.import_lookup == Some(true) {
1853 let path_owned = PathBuf::from(¶ms.path);
1854 let symbol = params.symbol.clone();
1855 let git_ref = params.git_ref.clone();
1856 let max_depth = params.max_depth;
1857 let ast_recursion_limit = params.ast_recursion_limit;
1858
1859 let handle = tokio::task::spawn_blocking(move || {
1860 let path = path_owned.as_path();
1861 let raw_entries = match walk_directory(path, max_depth) {
1862 Ok(e) => e,
1863 Err(e) => {
1864 return Err(ErrorData::new(
1865 rmcp::model::ErrorCode::INTERNAL_ERROR,
1866 format!("Failed to walk directory: {e}"),
1867 Some(error_meta(
1868 "resource",
1869 false,
1870 "check path permissions and availability",
1871 )),
1872 ));
1873 }
1874 };
1875 let entries = if let Some(ref git_ref_val) = git_ref
1877 && !git_ref_val.is_empty()
1878 {
1879 let changed = match changed_files_from_git_ref(path, git_ref_val) {
1880 Ok(c) => c,
1881 Err(e) => {
1882 return Err(ErrorData::new(
1883 rmcp::model::ErrorCode::INVALID_PARAMS,
1884 format!("git_ref filter failed: {e}"),
1885 Some(error_meta(
1886 "resource",
1887 false,
1888 "ensure git is installed and path is inside a git repository",
1889 )),
1890 ));
1891 }
1892 };
1893 filter_entries_by_git_ref(raw_entries, &changed, path)
1894 } else {
1895 raw_entries
1896 };
1897 let output = match analyze::analyze_import_lookup(
1898 path,
1899 &symbol,
1900 &entries,
1901 ast_recursion_limit,
1902 ) {
1903 Ok(v) => v,
1904 Err(e) => {
1905 return Err(ErrorData::new(
1906 rmcp::model::ErrorCode::INTERNAL_ERROR,
1907 format!("import_lookup failed: {e}"),
1908 Some(error_meta(
1909 "resource",
1910 false,
1911 "check path and file permissions",
1912 )),
1913 ));
1914 }
1915 };
1916 Ok(output)
1917 });
1918
1919 let output = match handle.await {
1920 Ok(Ok(v)) => v,
1921 Ok(Err(e)) => return Ok(err_to_tool_result(e)),
1922 Err(e) => {
1923 return Ok(err_to_tool_result(ErrorData::new(
1924 rmcp::model::ErrorCode::INTERNAL_ERROR,
1925 format!("spawn_blocking failed: {e}"),
1926 Some(error_meta("resource", false, "internal error")),
1927 )));
1928 }
1929 };
1930
1931 let final_text = output.formatted.clone();
1932
1933 tracing::Span::current().record("cache_tier", "Miss");
1935
1936 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1938 let mut meta = no_cache_meta().0;
1939 meta.insert(
1940 "content_hash".to_string(),
1941 serde_json::Value::String(content_hash),
1942 );
1943
1944 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1945 .with_meta(Some(Meta(meta)));
1946 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1947 result.structured_content = Some(structured);
1948 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1949 self.metrics_tx.send(crate::metrics::MetricEvent {
1950 ts: crate::metrics::unix_ms(),
1951 tool: "analyze_symbol",
1952 duration_ms: dur,
1953 output_chars: final_text.len(),
1954 param_path_depth: crate::metrics::path_component_count(¶m_path),
1955 max_depth: max_depth_val,
1956 result: "ok",
1957 error_type: None,
1958 session_id: sid,
1959 seq: Some(seq),
1960 cache_hit: Some(false),
1961 cache_tier: Some(CacheTier::Miss.as_str()),
1962 cache_write_failure: None,
1963 exit_code: None,
1964 timed_out: false,
1965 output_truncated: None,
1966 ..Default::default()
1967 });
1968 return Ok(result);
1969 }
1970
1971 let mut output = match self.handle_focused_mode(¶ms, ct).await {
1973 Ok(v) => v,
1974 Err(e) => return Ok(err_to_tool_result(e)),
1975 };
1976
1977 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1979 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1980 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1981 ErrorData::new(
1982 rmcp::model::ErrorCode::INVALID_PARAMS,
1983 e.to_string(),
1984 Some(error_meta("validation", false, "invalid cursor format")),
1985 )
1986 }) {
1987 Ok(v) => v,
1988 Err(e) => return Ok(err_to_tool_result(e)),
1989 };
1990 cursor_data.offset
1991 } else {
1992 0
1993 };
1994
1995 let cursor_mode = if let Some(ref cursor_str) = params.pagination.cursor {
1997 decode_cursor(cursor_str)
1998 .map(|c| c.mode)
1999 .unwrap_or(PaginationMode::Callers)
2000 } else {
2001 PaginationMode::Callers
2002 };
2003
2004 let mut use_summary = params.output_control.summary == Some(true);
2005 if params.output_control.force == Some(true) {
2006 use_summary = false;
2007 }
2008 let verbose = params.output_control.verbose.unwrap_or(false);
2009
2010 let mut callee_cursor = match cursor_mode {
2011 PaginationMode::Callers => {
2012 let (paginated_items, paginated_next) = match paginate_focus_chains(
2013 &output.prod_chains,
2014 PaginationMode::Callers,
2015 offset,
2016 page_size,
2017 ) {
2018 Ok(v) => v,
2019 Err(e) => return Ok(err_to_tool_result(e)),
2020 };
2021
2022 if !use_summary
2023 && (paginated_next.is_some()
2024 || offset > 0
2025 || !verbose
2026 || !output.outgoing_chains.is_empty())
2027 {
2028 let base_path = Path::new(¶ms.path);
2029 output.formatted = format_focused_paginated(
2030 &paginated_items,
2031 output.prod_chains.len(),
2032 PaginationMode::Callers,
2033 ¶ms.symbol,
2034 &output.prod_chains,
2035 &output.test_chains,
2036 &output.outgoing_chains,
2037 output.def_count,
2038 offset,
2039 Some(base_path),
2040 verbose,
2041 );
2042 paginated_next
2043 } else {
2044 None
2045 }
2046 }
2047 PaginationMode::Callees => {
2048 let (paginated_items, paginated_next) = match paginate_focus_chains(
2049 &output.outgoing_chains,
2050 PaginationMode::Callees,
2051 offset,
2052 page_size,
2053 ) {
2054 Ok(v) => v,
2055 Err(e) => return Ok(err_to_tool_result(e)),
2056 };
2057
2058 if paginated_next.is_some() || offset > 0 || !verbose {
2059 let base_path = Path::new(¶ms.path);
2060 output.formatted = format_focused_paginated(
2061 &paginated_items,
2062 output.outgoing_chains.len(),
2063 PaginationMode::Callees,
2064 ¶ms.symbol,
2065 &output.prod_chains,
2066 &output.test_chains,
2067 &output.outgoing_chains,
2068 output.def_count,
2069 offset,
2070 Some(base_path),
2071 verbose,
2072 );
2073 paginated_next
2074 } else {
2075 None
2076 }
2077 }
2078 PaginationMode::Default => {
2079 return Ok(err_to_tool_result(ErrorData::new(
2080 rmcp::model::ErrorCode::INVALID_PARAMS,
2081 "invalid cursor: unknown pagination mode".to_string(),
2082 Some(error_meta(
2083 "validation",
2084 false,
2085 "use a cursor returned by a previous analyze_symbol call",
2086 )),
2087 )));
2088 }
2089 PaginationMode::DefUse => {
2090 let total_sites = output.def_use_sites.len();
2091 let (paginated_sites, paginated_next) = match paginate_slice(
2092 &output.def_use_sites,
2093 offset,
2094 page_size,
2095 PaginationMode::DefUse,
2096 ) {
2097 Ok(r) => (r.items, r.next_cursor),
2098 Err(e) => return Ok(err_to_tool_result_from_pagination(e)),
2099 };
2100
2101 if !use_summary {
2104 let base_path = Path::new(¶ms.path);
2105 output.formatted = format_focused_paginated_defuse(
2106 &paginated_sites,
2107 total_sites,
2108 ¶ms.symbol,
2109 offset,
2110 Some(base_path),
2111 verbose,
2112 );
2113 }
2114
2115 output.def_use_sites = paginated_sites;
2118
2119 paginated_next
2120 }
2121 };
2122
2123 if callee_cursor.is_none()
2128 && cursor_mode == PaginationMode::Callers
2129 && !output.outgoing_chains.is_empty()
2130 && !use_summary
2131 && let Ok(cursor) = encode_cursor(&CursorData {
2132 mode: PaginationMode::Callees,
2133 offset: 0,
2134 })
2135 {
2136 callee_cursor = Some(cursor);
2137 }
2138
2139 if callee_cursor.is_none()
2146 && matches!(
2147 cursor_mode,
2148 PaginationMode::Callees | PaginationMode::Callers
2149 )
2150 && !output.def_use_sites.is_empty()
2151 && !use_summary
2152 && let Ok(cursor) = encode_cursor(&CursorData {
2153 mode: PaginationMode::DefUse,
2154 offset: 0,
2155 })
2156 {
2157 if cursor_mode == PaginationMode::Callees || output.outgoing_chains.is_empty() {
2160 callee_cursor = Some(cursor);
2161 }
2162 }
2163
2164 output.next_cursor.clone_from(&callee_cursor);
2166
2167 let mut final_text = output.formatted.clone();
2169 if let Some(cursor) = callee_cursor {
2170 final_text.push('\n');
2171 final_text.push_str("NEXT_CURSOR: ");
2172 final_text.push_str(&cursor);
2173 }
2174
2175 tracing::Span::current().record("cache_tier", "Miss");
2177
2178 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
2180 let mut meta = no_cache_meta().0;
2181 meta.insert(
2182 "content_hash".to_string(),
2183 serde_json::Value::String(content_hash),
2184 );
2185
2186 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
2187 .with_meta(Some(Meta(meta)));
2188 if cursor_mode != PaginationMode::DefUse {
2192 output.def_use_sites = Vec::new();
2193 }
2194 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
2195 result.structured_content = Some(structured);
2196 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2197 self.metrics_tx.send(crate::metrics::MetricEvent {
2198 ts: crate::metrics::unix_ms(),
2199 tool: "analyze_symbol",
2200 duration_ms: dur,
2201 output_chars: final_text.len(),
2202 param_path_depth: crate::metrics::path_component_count(¶m_path),
2203 max_depth: max_depth_val,
2204 result: "ok",
2205 error_type: None,
2206 session_id: sid,
2207 seq: Some(seq),
2208 cache_hit: Some(false),
2209 cache_tier: Some(CacheTier::Miss.as_str()),
2210 cache_write_failure: None,
2211 exit_code: None,
2212 timed_out: false,
2213 output_truncated: None,
2214 ..Default::default()
2215 });
2216 Ok(result)
2217 }
2218
2219 #[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))]
2220 #[tool(
2221 name = "analyze_module",
2222 title = "Analyze Module",
2223 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?",
2224 output_schema = schema_for_type::<types::ModuleInfo>(),
2225 annotations(
2226 title = "Analyze Module",
2227 read_only_hint = true,
2228 destructive_hint = false,
2229 idempotent_hint = true,
2230 open_world_hint = false
2231 )
2232 )]
2233 async fn analyze_module(
2234 &self,
2235 params: Parameters<AnalyzeModuleParams>,
2236 context: RequestContext<RoleServer>,
2237 ) -> Result<CallToolResult, ErrorData> {
2238 let params = params.0;
2239 let session_id = self.session_id.lock().await.clone();
2241 let client_name = self.client_name.lock().await.clone();
2242 let client_version = self.client_version.lock().await.clone();
2243 extract_and_set_trace_context(
2244 Some(&context.meta),
2245 ClientMetadata {
2246 session_id,
2247 client_name,
2248 client_version,
2249 },
2250 );
2251 let span = tracing::Span::current();
2252 span.record("gen_ai.system", "mcp");
2253 span.record("gen_ai.operation.name", "execute_tool");
2254 span.record("gen_ai.tool.name", "analyze_module");
2255 span.record("path", ¶ms.path);
2256 let _validated_path = match validate_path(¶ms.path, true) {
2257 Ok(p) => p,
2258 Err(e) => {
2259 span.record("error", true);
2260 span.record("error.type", "invalid_params");
2261 return Ok(err_to_tool_result(e));
2262 }
2263 };
2264 let t_start = std::time::Instant::now();
2265 let param_path = params.path.clone();
2266 let seq = self
2267 .session_call_seq
2268 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2269 let sid = self.session_id.lock().await.clone();
2270
2271 if std::fs::metadata(¶ms.path)
2273 .map(|m| m.is_dir())
2274 .unwrap_or(false)
2275 {
2276 span.record("error", true);
2277 span.record("error.type", "invalid_params");
2278 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2279 self.metrics_tx.send(crate::metrics::MetricEvent {
2280 ts: crate::metrics::unix_ms(),
2281 tool: "analyze_module",
2282 duration_ms: dur,
2283 output_chars: 0,
2284 param_path_depth: crate::metrics::path_component_count(¶m_path),
2285 max_depth: None,
2286 result: "error",
2287 error_type: Some("invalid_params".to_string()),
2288 session_id: sid.clone(),
2289 seq: Some(seq),
2290 cache_hit: None,
2291 cache_write_failure: None,
2292 cache_tier: None,
2293 exit_code: None,
2294 timed_out: false,
2295 output_truncated: None,
2296 ..Default::default()
2297 });
2298 return Ok(err_to_tool_result(ErrorData::new(
2299 rmcp::model::ErrorCode::INVALID_PARAMS,
2300 format!(
2301 "'{}' is a directory. Use analyze_directory to analyze a directory, or pass a specific file path to analyze_module.",
2302 params.path
2303 ),
2304 Some(error_meta(
2305 "validation",
2306 false,
2307 "use analyze_directory for directories",
2308 )),
2309 )));
2310 }
2311
2312 let mut analyze_file_params: AnalyzeFileParams = Default::default();
2314 analyze_file_params.path = params.path.clone();
2315 let (arc_output, module_tier) = match self
2316 .handle_file_details_mode(&analyze_file_params)
2317 .await
2318 {
2319 Ok((output, tier)) => (output, tier),
2320 Err(e) => {
2321 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2322 let error_type = match e.code {
2323 rmcp::model::ErrorCode::INVALID_PARAMS => Some("invalid_params".to_string()),
2324 rmcp::model::ErrorCode::INTERNAL_ERROR => Some("internal_error".to_string()),
2325 _ => None,
2326 };
2327 self.metrics_tx.send(crate::metrics::MetricEvent {
2328 ts: crate::metrics::unix_ms(),
2329 tool: "analyze_module",
2330 duration_ms: dur,
2331 output_chars: 0,
2332 param_path_depth: crate::metrics::path_component_count(¶m_path),
2333 max_depth: None,
2334 result: "error",
2335 error_type,
2336 session_id: sid.clone(),
2337 seq: Some(seq),
2338 cache_hit: None,
2339 cache_write_failure: None,
2340 cache_tier: None,
2341 exit_code: None,
2342 timed_out: false,
2343 output_truncated: None,
2344 file_ext: crate::metrics::path_file_ext(¶m_path),
2345 ..Default::default()
2346 });
2347 let error_data = match e.code {
2348 rmcp::model::ErrorCode::INVALID_PARAMS => e,
2349 _ => ErrorData::new(
2350 rmcp::model::ErrorCode::INTERNAL_ERROR,
2351 format!("Failed to analyze module: {}", e.message),
2352 Some(error_meta("internal", false, "report this as a bug")),
2353 ),
2354 };
2355 return Ok(err_to_tool_result(error_data));
2356 }
2357 };
2358
2359 let file_path = std::path::Path::new(¶ms.path);
2361 let name = file_path
2362 .file_name()
2363 .and_then(|n: &std::ffi::OsStr| n.to_str())
2364 .unwrap_or("unknown")
2365 .to_string();
2366 let language = file_path
2367 .extension()
2368 .and_then(|e| e.to_str())
2369 .and_then(aptu_coder_core::lang::language_for_extension)
2370 .unwrap_or("unknown")
2371 .to_string();
2372 let functions = arc_output
2373 .semantic
2374 .functions
2375 .iter()
2376 .map(|f| {
2377 let mut mfi = types::ModuleFunctionInfo::default();
2378 mfi.name = f.name.clone();
2379 mfi.line = f.line;
2380 mfi
2381 })
2382 .collect();
2383 let imports = arc_output
2384 .semantic
2385 .imports
2386 .iter()
2387 .map(|i| {
2388 let mut mii = types::ModuleImportInfo::default();
2389 mii.module = i.module.clone();
2390 mii.items = i.items.clone();
2391 mii
2392 })
2393 .collect();
2394 let module_info =
2395 types::ModuleInfo::new(name, arc_output.line_count, language, functions, imports);
2396
2397 let text = format_module_info(&module_info);
2398
2399 tracing::Span::current().record("cache_tier", module_tier.as_str());
2401
2402 let content_hash = format!("{}", blake3::hash(text.as_bytes()));
2404 let mut meta = no_cache_meta().0;
2405 meta.insert(
2406 "content_hash".to_string(),
2407 serde_json::Value::String(content_hash),
2408 );
2409
2410 let mut result =
2411 CallToolResult::success(vec![Content::text(text.clone())]).with_meta(Some(Meta(meta)));
2412 let structured = match serde_json::to_value(&module_info).map_err(|e| {
2413 ErrorData::new(
2414 rmcp::model::ErrorCode::INTERNAL_ERROR,
2415 format!("serialization failed: {e}"),
2416 Some(error_meta("internal", false, "report this as a bug")),
2417 )
2418 }) {
2419 Ok(v) => v,
2420 Err(e) => return Ok(err_to_tool_result(e)),
2421 };
2422 result.structured_content = Some(structured);
2423 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2424 self.metrics_tx.send(crate::metrics::MetricEvent {
2425 ts: crate::metrics::unix_ms(),
2426 tool: "analyze_module",
2427 duration_ms: dur,
2428 output_chars: text.len(),
2429 param_path_depth: crate::metrics::path_component_count(¶m_path),
2430 max_depth: None,
2431 result: "ok",
2432 error_type: None,
2433 session_id: sid,
2434 seq: Some(seq),
2435 cache_hit: Some(module_tier != CacheTier::Miss),
2436 cache_tier: Some(module_tier.as_str()),
2437 cache_write_failure: None,
2438 exit_code: None,
2439 timed_out: false,
2440 output_truncated: None,
2441 file_ext: crate::metrics::path_file_ext(¶m_path),
2442 ..Default::default()
2443 });
2444 Ok(result)
2445 }
2446
2447 #[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))]
2448 #[tool(
2449 name = "edit_overwrite",
2450 title = "Edit Overwrite",
2451 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.",
2452 output_schema = schema_for_type::<EditOverwriteOutput>(),
2453 annotations(
2454 title = "Edit Overwrite",
2455 read_only_hint = false,
2456 destructive_hint = true,
2457 idempotent_hint = false,
2458 open_world_hint = false
2459 )
2460 )]
2461 async fn edit_overwrite(
2462 &self,
2463 params: Parameters<EditOverwriteParams>,
2464 context: RequestContext<RoleServer>,
2465 ) -> Result<CallToolResult, ErrorData> {
2466 let params = params.0;
2467 let session_id = self.session_id.lock().await.clone();
2469 let client_name = self.client_name.lock().await.clone();
2470 let client_version = self.client_version.lock().await.clone();
2471 extract_and_set_trace_context(
2472 Some(&context.meta),
2473 ClientMetadata {
2474 session_id,
2475 client_name,
2476 client_version,
2477 },
2478 );
2479 let span = tracing::Span::current();
2480 span.record("gen_ai.system", "mcp");
2481 span.record("gen_ai.operation.name", "execute_tool");
2482 span.record("gen_ai.tool.name", "edit_overwrite");
2483 span.record("path", ¶ms.path);
2484 let _validated_path = if let Some(ref wd) = params.working_dir {
2485 match validate_path_in_dir(¶ms.path, false, std::path::Path::new(wd)) {
2486 Ok(p) => p,
2487 Err(e) => {
2488 span.record("error", true);
2489 span.record("error.type", "invalid_params");
2490 return Ok(err_to_tool_result(e));
2491 }
2492 }
2493 } else {
2494 match validate_path(¶ms.path, false) {
2495 Ok(p) => p,
2496 Err(e) => {
2497 span.record("error", true);
2498 span.record("error.type", "invalid_params");
2499 return Ok(err_to_tool_result(e));
2500 }
2501 }
2502 };
2503 let t_start = std::time::Instant::now();
2504 let param_path = params.path.clone();
2505 let seq = self
2506 .session_call_seq
2507 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2508 let sid = self.session_id.lock().await.clone();
2509
2510 if std::fs::metadata(¶ms.path)
2512 .map(|m| m.is_dir())
2513 .unwrap_or(false)
2514 {
2515 span.record("error", true);
2516 span.record("error.type", "invalid_params");
2517 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2518 self.metrics_tx.send(crate::metrics::MetricEvent {
2519 ts: crate::metrics::unix_ms(),
2520 tool: "edit_overwrite",
2521 duration_ms: dur,
2522 output_chars: 0,
2523 param_path_depth: crate::metrics::path_component_count(¶m_path),
2524 max_depth: None,
2525 result: "error",
2526 error_type: Some("invalid_params".to_string()),
2527 session_id: sid.clone(),
2528 seq: Some(seq),
2529 cache_hit: None,
2530 cache_write_failure: None,
2531 cache_tier: None,
2532 exit_code: None,
2533 timed_out: false,
2534 output_truncated: None,
2535 ..Default::default()
2536 });
2537 return Ok(err_to_tool_result(ErrorData::new(
2538 rmcp::model::ErrorCode::INVALID_PARAMS,
2539 "path is a directory; cannot write to a directory".to_string(),
2540 Some(error_meta(
2541 "validation",
2542 false,
2543 "provide a file path, not a directory",
2544 )),
2545 )));
2546 }
2547
2548 let path = std::path::PathBuf::from(¶ms.path);
2549 let content = params.content.clone();
2550 let handle = tokio::task::spawn_blocking(move || {
2551 aptu_coder_core::edit_overwrite_content(&path, &content)
2552 });
2553
2554 let output = match handle.await {
2555 Ok(Ok(v)) => v,
2556 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2557 span.record("error", true);
2558 span.record("error.type", "invalid_params");
2559 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2560 self.metrics_tx.send(crate::metrics::MetricEvent {
2561 ts: crate::metrics::unix_ms(),
2562 tool: "edit_overwrite",
2563 duration_ms: dur,
2564 output_chars: 0,
2565 param_path_depth: crate::metrics::path_component_count(¶m_path),
2566 max_depth: None,
2567 result: "error",
2568 error_type: Some("invalid_params".to_string()),
2569 session_id: sid.clone(),
2570 seq: Some(seq),
2571 cache_hit: None,
2572 cache_write_failure: None,
2573 cache_tier: None,
2574 exit_code: None,
2575 timed_out: false,
2576 output_truncated: None,
2577 ..Default::default()
2578 });
2579 return Ok(err_to_tool_result(ErrorData::new(
2580 rmcp::model::ErrorCode::INVALID_PARAMS,
2581 "path is a directory".to_string(),
2582 Some(error_meta(
2583 "validation",
2584 false,
2585 "provide a file path, not a directory",
2586 )),
2587 )));
2588 }
2589 Ok(Err(e)) => {
2590 span.record("error", true);
2591 span.record("error.type", "internal_error");
2592 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2593 self.metrics_tx.send(crate::metrics::MetricEvent {
2594 ts: crate::metrics::unix_ms(),
2595 tool: "edit_overwrite",
2596 duration_ms: dur,
2597 output_chars: 0,
2598 param_path_depth: crate::metrics::path_component_count(¶m_path),
2599 max_depth: None,
2600 result: "error",
2601 error_type: Some("internal_error".to_string()),
2602 session_id: sid.clone(),
2603 seq: Some(seq),
2604 cache_hit: None,
2605 cache_write_failure: None,
2606 cache_tier: None,
2607 exit_code: None,
2608 timed_out: false,
2609 output_truncated: None,
2610 ..Default::default()
2611 });
2612 return Ok(err_to_tool_result(ErrorData::new(
2613 rmcp::model::ErrorCode::INTERNAL_ERROR,
2614 e.to_string(),
2615 Some(error_meta(
2616 "resource",
2617 false,
2618 "check file path and permissions",
2619 )),
2620 )));
2621 }
2622 Err(e) => {
2623 span.record("error", true);
2624 span.record("error.type", "internal_error");
2625 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2626 self.metrics_tx.send(crate::metrics::MetricEvent {
2627 ts: crate::metrics::unix_ms(),
2628 tool: "edit_overwrite",
2629 duration_ms: dur,
2630 output_chars: 0,
2631 param_path_depth: crate::metrics::path_component_count(¶m_path),
2632 max_depth: None,
2633 result: "error",
2634 error_type: Some("internal_error".to_string()),
2635 session_id: sid.clone(),
2636 seq: Some(seq),
2637 cache_hit: None,
2638 cache_write_failure: None,
2639 cache_tier: None,
2640 exit_code: None,
2641 timed_out: false,
2642 output_truncated: None,
2643 ..Default::default()
2644 });
2645 return Ok(err_to_tool_result(ErrorData::new(
2646 rmcp::model::ErrorCode::INTERNAL_ERROR,
2647 e.to_string(),
2648 Some(error_meta(
2649 "resource",
2650 false,
2651 "check file path and permissions",
2652 )),
2653 )));
2654 }
2655 };
2656
2657 let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2658 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2659 .with_meta(Some(no_cache_meta()));
2660 let structured = match serde_json::to_value(&output).map_err(|e| {
2661 ErrorData::new(
2662 rmcp::model::ErrorCode::INTERNAL_ERROR,
2663 format!("serialization failed: {e}"),
2664 Some(error_meta("internal", false, "report this as a bug")),
2665 )
2666 }) {
2667 Ok(v) => v,
2668 Err(e) => return Ok(err_to_tool_result(e)),
2669 };
2670 result.structured_content = Some(structured);
2671 self.cache
2672 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2673 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2674 self.metrics_tx.send(crate::metrics::MetricEvent {
2675 ts: crate::metrics::unix_ms(),
2676 tool: "edit_overwrite",
2677 duration_ms: dur,
2678 output_chars: text.len(),
2679 param_path_depth: crate::metrics::path_component_count(¶m_path),
2680 max_depth: None,
2681 result: "ok",
2682 error_type: None,
2683 session_id: sid,
2684 seq: Some(seq),
2685 cache_hit: None,
2686 cache_write_failure: None,
2687 cache_tier: None,
2688 exit_code: None,
2689 timed_out: false,
2690 output_truncated: None,
2691 ..Default::default()
2692 });
2693 Ok(result)
2694 }
2695
2696 #[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))]
2697 #[tool(
2698 name = "edit_replace",
2699 title = "Edit Replace",
2700 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.",
2701 output_schema = schema_for_type::<EditReplaceOutput>(),
2702 annotations(
2703 title = "Edit Replace",
2704 read_only_hint = false,
2705 destructive_hint = true,
2706 idempotent_hint = false,
2707 open_world_hint = false
2708 )
2709 )]
2710 async fn edit_replace(
2711 &self,
2712 params: Parameters<EditReplaceParams>,
2713 context: RequestContext<RoleServer>,
2714 ) -> Result<CallToolResult, ErrorData> {
2715 let params = params.0;
2716 let session_id = self.session_id.lock().await.clone();
2718 let client_name = self.client_name.lock().await.clone();
2719 let client_version = self.client_version.lock().await.clone();
2720 extract_and_set_trace_context(
2721 Some(&context.meta),
2722 ClientMetadata {
2723 session_id,
2724 client_name,
2725 client_version,
2726 },
2727 );
2728 let span = tracing::Span::current();
2729 span.record("gen_ai.system", "mcp");
2730 span.record("gen_ai.operation.name", "execute_tool");
2731 span.record("gen_ai.tool.name", "edit_replace");
2732 span.record("path", ¶ms.path);
2733 let _validated_path = if let Some(ref wd) = params.working_dir {
2734 match validate_path_in_dir(¶ms.path, true, std::path::Path::new(wd)) {
2735 Ok(p) => p,
2736 Err(e) => {
2737 span.record("error", true);
2738 span.record("error.type", "invalid_params");
2739 return Ok(err_to_tool_result(e));
2740 }
2741 }
2742 } else {
2743 match validate_path(¶ms.path, true) {
2744 Ok(p) => p,
2745 Err(e) => {
2746 span.record("error", true);
2747 span.record("error.type", "invalid_params");
2748 return Ok(err_to_tool_result(e));
2749 }
2750 }
2751 };
2752 let t_start = std::time::Instant::now();
2753 let param_path = params.path.clone();
2754 let seq = self
2755 .session_call_seq
2756 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2757 let sid = self.session_id.lock().await.clone();
2758
2759 if std::fs::metadata(¶ms.path)
2761 .map(|m| m.is_dir())
2762 .unwrap_or(false)
2763 {
2764 span.record("error", true);
2765 span.record("error.type", "invalid_params");
2766 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2767 self.metrics_tx.send(crate::metrics::MetricEvent {
2768 ts: crate::metrics::unix_ms(),
2769 tool: "edit_replace",
2770 duration_ms: dur,
2771 output_chars: 0,
2772 param_path_depth: crate::metrics::path_component_count(¶m_path),
2773 max_depth: None,
2774 result: "error",
2775 error_type: Some("invalid_params".to_string()),
2776 session_id: sid.clone(),
2777 seq: Some(seq),
2778 cache_hit: None,
2779 cache_write_failure: None,
2780 cache_tier: None,
2781 exit_code: None,
2782 timed_out: false,
2783 output_truncated: None,
2784 ..Default::default()
2785 });
2786 return Ok(err_to_tool_result(ErrorData::new(
2787 rmcp::model::ErrorCode::INVALID_PARAMS,
2788 "path is a directory; cannot edit a directory".to_string(),
2789 Some(error_meta(
2790 "validation",
2791 false,
2792 "provide a file path, not a directory",
2793 )),
2794 )));
2795 }
2796
2797 let path = std::path::PathBuf::from(¶ms.path);
2798 let old_text = params.old_text.clone();
2799 let new_text = params.new_text.clone();
2800 let handle = tokio::task::spawn_blocking(move || {
2801 aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2802 });
2803
2804 let output = match handle.await {
2805 Ok(Ok(v)) => v,
2806 Ok(Err(aptu_coder_core::EditError::NotFound {
2807 path: notfound_path,
2808 })) => {
2809 span.record("error", true);
2810 span.record("error.type", "invalid_params");
2811 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2812 self.metrics_tx.send(crate::metrics::MetricEvent {
2813 ts: crate::metrics::unix_ms(),
2814 tool: "edit_replace",
2815 duration_ms: dur,
2816 output_chars: 0,
2817 param_path_depth: crate::metrics::path_component_count(¶m_path),
2818 max_depth: None,
2819 result: "error",
2820 error_type: Some("invalid_params".to_string()),
2821 error_subtype: Some("not_found".to_string()),
2822 session_id: sid.clone(),
2823 seq: Some(seq),
2824 cache_hit: None,
2825 cache_write_failure: None,
2826 cache_tier: None,
2827 exit_code: None,
2828 timed_out: false,
2829 output_truncated: None,
2830 ..Default::default()
2831 });
2832 return Ok(err_to_tool_result(ErrorData::new(
2833 rmcp::model::ErrorCode::INVALID_PARAMS,
2834 format!(
2835 "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."
2836 ),
2837 Some(error_meta(
2838 "validation",
2839 false,
2840 "re-read the file with analyze_file or analyze_module, then derive old_text from the live content",
2841 )),
2842 )));
2843 }
2844 Ok(Err(aptu_coder_core::EditError::Ambiguous {
2845 count,
2846 path: ambiguous_path,
2847 })) => {
2848 span.record("error", true);
2849 span.record("error.type", "invalid_params");
2850 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2851 self.metrics_tx.send(crate::metrics::MetricEvent {
2852 ts: crate::metrics::unix_ms(),
2853 tool: "edit_replace",
2854 duration_ms: dur,
2855 output_chars: 0,
2856 param_path_depth: crate::metrics::path_component_count(¶m_path),
2857 max_depth: None,
2858 result: "error",
2859 error_type: Some("invalid_params".to_string()),
2860 error_subtype: Some("ambiguous".to_string()),
2861 session_id: sid.clone(),
2862 seq: Some(seq),
2863 cache_hit: None,
2864 cache_write_failure: None,
2865 cache_tier: None,
2866 exit_code: None,
2867 timed_out: false,
2868 output_truncated: None,
2869 ..Default::default()
2870 });
2871 return Ok(err_to_tool_result(ErrorData::new(
2872 rmcp::model::ErrorCode::INVALID_PARAMS,
2873 format!(
2874 "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."
2875 ),
2876 Some(error_meta(
2877 "validation",
2878 false,
2879 "extend old_text with more surrounding context, or re-read with analyze_file to confirm the exact text",
2880 )),
2881 )));
2882 }
2883 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2884 span.record("error", true);
2885 span.record("error.type", "invalid_params");
2886 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2887 self.metrics_tx.send(crate::metrics::MetricEvent {
2888 ts: crate::metrics::unix_ms(),
2889 tool: "edit_replace",
2890 duration_ms: dur,
2891 output_chars: 0,
2892 param_path_depth: crate::metrics::path_component_count(¶m_path),
2893 max_depth: None,
2894 result: "error",
2895 error_type: Some("invalid_params".to_string()),
2896 session_id: sid.clone(),
2897 seq: Some(seq),
2898 cache_hit: None,
2899 cache_write_failure: None,
2900 cache_tier: None,
2901 exit_code: None,
2902 timed_out: false,
2903 output_truncated: None,
2904 ..Default::default()
2905 });
2906 return Ok(err_to_tool_result(ErrorData::new(
2907 rmcp::model::ErrorCode::INVALID_PARAMS,
2908 "path is a directory".to_string(),
2909 Some(error_meta(
2910 "validation",
2911 false,
2912 "provide a file path, not a directory",
2913 )),
2914 )));
2915 }
2916 Ok(Err(e)) => {
2917 span.record("error", true);
2918 span.record("error.type", "internal_error");
2919 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2920 self.metrics_tx.send(crate::metrics::MetricEvent {
2921 ts: crate::metrics::unix_ms(),
2922 tool: "edit_replace",
2923 duration_ms: dur,
2924 output_chars: 0,
2925 param_path_depth: crate::metrics::path_component_count(¶m_path),
2926 max_depth: None,
2927 result: "error",
2928 error_type: Some("internal_error".to_string()),
2929 session_id: sid.clone(),
2930 seq: Some(seq),
2931 cache_hit: None,
2932 cache_write_failure: None,
2933 cache_tier: None,
2934 exit_code: None,
2935 timed_out: false,
2936 output_truncated: None,
2937 ..Default::default()
2938 });
2939 return Ok(err_to_tool_result(ErrorData::new(
2940 rmcp::model::ErrorCode::INTERNAL_ERROR,
2941 e.to_string(),
2942 Some(error_meta(
2943 "resource",
2944 false,
2945 "check file path and permissions",
2946 )),
2947 )));
2948 }
2949 Err(e) => {
2950 span.record("error", true);
2951 span.record("error.type", "internal_error");
2952 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2953 self.metrics_tx.send(crate::metrics::MetricEvent {
2954 ts: crate::metrics::unix_ms(),
2955 tool: "edit_replace",
2956 duration_ms: dur,
2957 output_chars: 0,
2958 param_path_depth: crate::metrics::path_component_count(¶m_path),
2959 max_depth: None,
2960 result: "error",
2961 error_type: Some("internal_error".to_string()),
2962 session_id: sid.clone(),
2963 seq: Some(seq),
2964 cache_hit: None,
2965 cache_write_failure: None,
2966 cache_tier: None,
2967 exit_code: None,
2968 timed_out: false,
2969 output_truncated: None,
2970 ..Default::default()
2971 });
2972 return Ok(err_to_tool_result(ErrorData::new(
2973 rmcp::model::ErrorCode::INTERNAL_ERROR,
2974 e.to_string(),
2975 Some(error_meta(
2976 "resource",
2977 false,
2978 "check file path and permissions",
2979 )),
2980 )));
2981 }
2982 };
2983
2984 let text = format!(
2985 "Edited {}: {} bytes -> {} bytes",
2986 output.path, output.bytes_before, output.bytes_after
2987 );
2988 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2989 .with_meta(Some(no_cache_meta()));
2990 let structured = match serde_json::to_value(&output).map_err(|e| {
2991 ErrorData::new(
2992 rmcp::model::ErrorCode::INTERNAL_ERROR,
2993 format!("serialization failed: {e}"),
2994 Some(error_meta("internal", false, "report this as a bug")),
2995 )
2996 }) {
2997 Ok(v) => v,
2998 Err(e) => return Ok(err_to_tool_result(e)),
2999 };
3000 result.structured_content = Some(structured);
3001 self.cache
3002 .invalidate_file(&std::path::PathBuf::from(¶m_path));
3003 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3004 self.metrics_tx.send(crate::metrics::MetricEvent {
3005 ts: crate::metrics::unix_ms(),
3006 tool: "edit_replace",
3007 duration_ms: dur,
3008 output_chars: text.len(),
3009 param_path_depth: crate::metrics::path_component_count(¶m_path),
3010 max_depth: None,
3011 result: "ok",
3012 error_type: None,
3013 session_id: sid,
3014 seq: Some(seq),
3015 cache_hit: None,
3016 cache_write_failure: None,
3017 cache_tier: None,
3018 exit_code: None,
3019 timed_out: false,
3020 output_truncated: None,
3021 ..Default::default()
3022 });
3023 Ok(result)
3024 }
3025
3026 #[tool(
3027 name = "exec_command",
3028 title = "Exec Command",
3029 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.",
3030 output_schema = schema_for_type::<ShellOutput>(),
3031 annotations(
3032 title = "Exec Command",
3033 read_only_hint = false,
3034 destructive_hint = true,
3035 idempotent_hint = false,
3036 open_world_hint = true
3037 )
3038 )]
3039 #[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))]
3040 pub async fn exec_command(
3041 &self,
3042 params: Parameters<ExecCommandParams>,
3043 context: RequestContext<RoleServer>,
3044 ) -> Result<CallToolResult, ErrorData> {
3045 let t_start = std::time::Instant::now();
3046 let params = params.0;
3047 let session_id = self.session_id.lock().await.clone();
3049 let client_name = self.client_name.lock().await.clone();
3050 let client_version = self.client_version.lock().await.clone();
3051 extract_and_set_trace_context(
3052 Some(&context.meta),
3053 ClientMetadata {
3054 session_id,
3055 client_name,
3056 client_version,
3057 },
3058 );
3059 let span = tracing::Span::current();
3060 span.record("gen_ai.system", "mcp");
3061 span.record("gen_ai.operation.name", "execute_tool");
3062 span.record("gen_ai.tool.name", "exec_command");
3063 span.record("command", ¶ms.command);
3064
3065 let working_dir_path = if let Some(ref wd) = params.working_dir {
3067 match validate_path(wd, true) {
3068 Ok(p) => {
3069 if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
3071 span.record("error", true);
3072 span.record("error.type", "invalid_params");
3073 return Ok(err_to_tool_result(ErrorData::new(
3074 rmcp::model::ErrorCode::INVALID_PARAMS,
3075 "working_dir must be a directory".to_string(),
3076 Some(error_meta(
3077 "validation",
3078 false,
3079 "provide a valid directory path",
3080 )),
3081 )));
3082 }
3083 Some(p)
3084 }
3085 Err(e) => {
3086 span.record("error", true);
3087 span.record("error.type", "invalid_params");
3088 return Ok(err_to_tool_result(e));
3089 }
3090 }
3091 } else {
3092 None
3093 };
3094
3095 let param_path = params.working_dir.clone();
3096 let seq = self
3097 .session_call_seq
3098 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3099 let sid = self.session_id.lock().await.clone();
3100
3101 if let Some(ref stdin_content) = params.stdin
3103 && stdin_content.len() > STDIN_MAX_BYTES
3104 {
3105 span.record("error", true);
3106 span.record("error.type", "invalid_params");
3107 return Ok(err_to_tool_result(ErrorData::new(
3108 rmcp::model::ErrorCode::INVALID_PARAMS,
3109 "stdin exceeds 1 MB limit".to_string(),
3110 Some(error_meta("validation", false, "reduce stdin content size")),
3111 )));
3112 }
3113
3114 let command = params.command.clone();
3115 let timeout_secs = params.timeout_secs;
3116
3117 let _cache_key = (
3119 command.clone(),
3120 working_dir_path
3121 .as_ref()
3122 .map(|p| p.display().to_string())
3123 .unwrap_or_default(),
3124 );
3125 let resolved_path_str = self.resolved_path.as_ref().as_deref();
3127 let output = run_exec_impl(
3128 command.clone(),
3129 working_dir_path.clone(),
3130 timeout_secs,
3131 params.memory_limit_mb,
3132 params.cpu_limit_secs,
3133 params.stdin.clone(),
3134 seq,
3135 resolved_path_str,
3136 &self.filter_table,
3137 )
3138 .await;
3139
3140 let exit_code = output.exit_code;
3141 let timed_out = output.timed_out;
3142 let mut output_truncated = output.output_truncated;
3143
3144 if let Some(code) = exit_code {
3146 span.record("exit_code", code);
3147 }
3148 span.record("timed_out", timed_out);
3149 span.record("output_truncated", output_truncated);
3150
3151 if output_truncated {
3153 tracing::debug!(truncated = true, message = "output truncated");
3154 }
3155
3156 let output_text = if output.interleaved.is_empty() {
3158 format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
3159 } else {
3160 format!("Output:\n{}", output.interleaved)
3161 };
3162
3163 let mut combined_truncated = false;
3168 let truncated_output_text = if output_text.len() > SIZE_LIMIT {
3169 combined_truncated = true;
3170 let tail_start = output_text.len().saturating_sub(SIZE_LIMIT);
3172 let safe_start = output_text[..tail_start].floor_char_boundary(tail_start);
3173 output_text[safe_start..].to_string()
3174 } else {
3175 output_text
3176 };
3177
3178 output_truncated = output_truncated || combined_truncated;
3180
3181 let text = format!(
3182 "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
3183 params.command,
3184 exit_code
3185 .map(|c| c.to_string())
3186 .unwrap_or_else(|| "null".to_string()),
3187 timed_out,
3188 output_truncated,
3189 truncated_output_text,
3190 );
3191
3192 let content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
3193
3194 let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
3199
3200 let mut result = if command_failed {
3201 CallToolResult::error(content_blocks)
3202 } else {
3203 CallToolResult::success(content_blocks)
3204 }
3205 .with_meta(Some(no_cache_meta()));
3206
3207 let structured = match serde_json::to_value(&output).map_err(|e| {
3208 ErrorData::new(
3209 rmcp::model::ErrorCode::INTERNAL_ERROR,
3210 format!("serialization failed: {e}"),
3211 Some(error_meta("internal", false, "report this as a bug")),
3212 )
3213 }) {
3214 Ok(v) => v,
3215 Err(e) => {
3216 span.record("error", true);
3217 span.record("error.type", "internal_error");
3218 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3219 self.metrics_tx.send(crate::metrics::MetricEvent {
3220 ts: crate::metrics::unix_ms(),
3221 tool: "exec_command",
3222 duration_ms: dur,
3223 output_chars: 0,
3224 param_path_depth: crate::metrics::path_component_count(
3225 param_path.as_deref().unwrap_or(""),
3226 ),
3227 max_depth: None,
3228 result: "error",
3229 error_type: Some("internal_error".to_string()),
3230 session_id: sid.clone(),
3231 seq: Some(seq),
3232 cache_hit: Some(false),
3233 cache_write_failure: None,
3234 cache_tier: None,
3235 exit_code,
3236 timed_out,
3237 output_truncated: Some(output_truncated),
3238 ..Default::default()
3239 });
3240 return Ok(err_to_tool_result(e));
3241 }
3242 };
3243
3244 result.structured_content = Some(structured);
3245 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3246 self.metrics_tx.send(crate::metrics::MetricEvent {
3247 ts: crate::metrics::unix_ms(),
3248 tool: "exec_command",
3249 duration_ms: dur,
3250 output_chars: text.len(),
3251 param_path_depth: crate::metrics::path_component_count(
3252 param_path.as_deref().unwrap_or(""),
3253 ),
3254 max_depth: None,
3255 result: "ok",
3256 error_type: None,
3257 error_subtype: None,
3258 session_id: sid,
3259 seq: Some(seq),
3260 cache_hit: Some(false),
3261 cache_write_failure: None,
3262 cache_tier: None,
3263 exit_code,
3264 timed_out,
3265 output_truncated: Some(output_truncated),
3266 chars_threshold_breach: text.len() > 30_000,
3267 file_ext: None,
3268 });
3269 Ok(result)
3270 }
3271}
3272
3273fn build_exec_command(
3275 command: &str,
3276 working_dir_path: Option<&std::path::PathBuf>,
3277 memory_limit_mb: Option<u64>,
3278 cpu_limit_secs: Option<u64>,
3279 stdin_present: bool,
3280 resolved_path: Option<&str>,
3281) -> tokio::process::Command {
3282 let shell = resolve_shell();
3283 let mut cmd = tokio::process::Command::new(shell);
3284 cmd.arg("-c").arg(command);
3285
3286 if let Some(wd) = working_dir_path {
3287 cmd.current_dir(wd);
3288 }
3289
3290 if let Some(path) = resolved_path {
3292 cmd.env("PATH", path);
3293 }
3294
3295 cmd.stdout(std::process::Stdio::piped())
3296 .stderr(std::process::Stdio::piped());
3297
3298 if stdin_present {
3299 cmd.stdin(std::process::Stdio::piped());
3300 } else {
3301 cmd.stdin(std::process::Stdio::null());
3302 }
3303
3304 #[cfg(unix)]
3305 {
3306 #[cfg(not(target_os = "linux"))]
3307 if memory_limit_mb.is_some() {
3308 warn!("memory_limit_mb is not enforced on this platform (Linux only)");
3309 }
3310 if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
3311 unsafe {
3315 cmd.pre_exec(move || {
3316 #[cfg(target_os = "linux")]
3317 if let Some(mb) = memory_limit_mb {
3318 let bytes = mb.saturating_mul(1024 * 1024);
3319 setrlimit(Resource::RLIMIT_AS, bytes, bytes)
3320 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3321 }
3322 if let Some(cpu) = cpu_limit_secs {
3323 setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
3324 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3325 }
3326 Ok(())
3327 });
3328 }
3329 }
3330 }
3331
3332 cmd
3333}
3334
3335async fn run_with_timeout(
3338 mut child: tokio::process::Child,
3339 timeout_secs: Option<u64>,
3340 tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
3341) -> (Option<i32>, bool, bool, Option<String>) {
3342 use tokio::io::AsyncBufReadExt as _;
3343 use tokio_stream::StreamExt as TokioStreamExt;
3344 use tokio_stream::wrappers::LinesStream;
3345
3346 let stdout_pipe = child.stdout.take();
3347 let stderr_pipe = child.stderr.take();
3348
3349 let mut drain_task = tokio::spawn(async move {
3350 let so_stream = stdout_pipe.map(|p| {
3351 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (false, s)))
3352 });
3353 let se_stream = stderr_pipe.map(|p| {
3354 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3355 });
3356
3357 match (so_stream, se_stream) {
3358 (Some(so), Some(se)) => {
3359 let mut merged = so.merge(se);
3360 while let Some(Ok((is_stderr, line))) = merged.next().await {
3361 let _ = tx.send((is_stderr, line));
3362 }
3363 }
3364 (Some(so), None) => {
3365 let mut stream = so;
3366 while let Some(Ok((_, line))) = stream.next().await {
3367 let _ = tx.send((false, line));
3368 }
3369 }
3370 (None, Some(se)) => {
3371 let mut stream = se;
3372 while let Some(Ok((_, line))) = stream.next().await {
3373 let _ = tx.send((true, line));
3374 }
3375 }
3376 (None, None) => {}
3377 }
3378 });
3379
3380 tokio::select! {
3381 _ = &mut drain_task => {
3382 let (status, drain_truncated) = match tokio::time::timeout(
3383 std::time::Duration::from_millis(500),
3384 child.wait()
3385 ).await {
3386 Ok(Ok(s)) => (Some(s), false),
3387 Ok(Err(_)) => (None, false),
3388 Err(_) => {
3389 child.start_kill().ok();
3390 let _ = child.wait().await;
3391 (None, true)
3392 }
3393 };
3394 let exit_code = status.and_then(|s| s.code());
3395 let ocerr = if drain_truncated {
3396 Some("post-exit drain timeout: background process held pipes".to_string())
3397 } else {
3398 None
3399 };
3400 (exit_code, false, drain_truncated, ocerr)
3401 }
3402 _ = async {
3403 if let Some(secs) = timeout_secs {
3404 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3405 } else {
3406 std::future::pending::<()>().await;
3407 }
3408 } => {
3409 let _ = child.kill().await;
3410 let _ = child.wait().await;
3411 drain_task.abort();
3412 (None, true, false, None)
3413 }
3414 }
3415}
3416
3417#[allow(clippy::too_many_arguments)]
3421async fn run_exec_impl(
3422 command: String,
3423 working_dir_path: Option<std::path::PathBuf>,
3424 timeout_secs: Option<u64>,
3425 memory_limit_mb: Option<u64>,
3426 cpu_limit_secs: Option<u64>,
3427 stdin: Option<String>,
3428 seq: u32,
3429 resolved_path: Option<&str>,
3430 filter_table: &Arc<Vec<CompiledRule>>,
3431) -> ShellOutput {
3432 let command = maybe_inject_no_stat(&command);
3434
3435 let mut cmd = build_exec_command(
3436 &command,
3437 working_dir_path.as_ref(),
3438 memory_limit_mb,
3439 cpu_limit_secs,
3440 stdin.is_some(),
3441 resolved_path,
3442 );
3443
3444 let mut child = match cmd.spawn() {
3445 Ok(c) => c,
3446 Err(e) => {
3447 return ShellOutput::new(
3448 String::new(),
3449 format!("failed to spawn command: {e}"),
3450 format!("failed to spawn command: {e}"),
3451 None,
3452 false,
3453 false,
3454 );
3455 }
3456 };
3457
3458 if let Some(stdin_content) = stdin
3459 && let Some(mut stdin_handle) = child.stdin.take()
3460 {
3461 use tokio::io::AsyncWriteExt as _;
3462 match stdin_handle.write_all(stdin_content.as_bytes()).await {
3463 Ok(()) => {
3464 drop(stdin_handle);
3465 }
3466 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
3467 Err(e) => {
3468 warn!("failed to write stdin: {e}");
3469 }
3470 }
3471 }
3472
3473 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, String)>();
3474
3475 let (exit_code, timed_out, mut output_truncated, output_collection_error) =
3476 run_with_timeout(child, timeout_secs, tx).await;
3477
3478 let mut lines: Vec<(bool, String)> = Vec::new();
3479 while let Some(item) = rx.recv().await {
3480 lines.push(item);
3481 }
3482
3483 const MAX_BYTES: usize = 50 * 1024;
3485 let mut stdout_str = String::new();
3486 let mut stderr_str = String::new();
3487 let mut interleaved_str = String::new();
3488 let mut so_bytes = 0usize;
3489 let mut se_bytes = 0usize;
3490 let mut il_bytes = 0usize;
3491 for (is_stderr, line) in &lines {
3492 let entry = format!("{line}\n");
3493 if il_bytes < 2 * MAX_BYTES {
3494 il_bytes += entry.len();
3495 interleaved_str.push_str(&entry);
3496 }
3497 if *is_stderr {
3498 if se_bytes < MAX_BYTES {
3499 se_bytes += entry.len();
3500 stderr_str.push_str(&entry);
3501 }
3502 } else if so_bytes < MAX_BYTES {
3503 so_bytes += entry.len();
3504 stdout_str.push_str(&entry);
3505 }
3506 }
3507
3508 let slot = seq % 8;
3509 let (stdout, stderr, stdout_path, stderr_path, byte_truncated) =
3510 handle_output_persist(stdout_str, stderr_str, slot);
3511 output_truncated = output_truncated || stdout_path.is_some() || byte_truncated;
3512
3513 let mut output = ShellOutput::new(
3514 stdout,
3515 stderr,
3516 interleaved_str,
3517 exit_code,
3518 timed_out,
3519 output_truncated,
3520 );
3521 output.output_collection_error = output_collection_error;
3522 output.stdout_path = stdout_path;
3523 output.stderr_path = stderr_path;
3524
3525 if exit_code == Some(0) && !timed_out {
3527 for compiled_rule in filter_table.iter() {
3528 if compiled_rule.pattern.is_match(&command) {
3529 let filtered_stdout = apply_filter(compiled_rule, &output.stdout);
3530 output.stdout = filtered_stdout;
3531 output.interleaved = apply_filter(compiled_rule, &output.interleaved);
3538 output.filter_applied = compiled_rule
3539 .rule
3540 .description
3541 .clone()
3542 .or_else(|| Some(compiled_rule.rule.match_command.clone()));
3543 break;
3544 }
3545 }
3546 }
3547
3548 output
3549}
3550
3551fn handle_output_persist(
3558 stdout: String,
3559 stderr: String,
3560 slot: u32,
3561) -> (String, String, Option<String>, Option<String>, bool) {
3562 const MAX_OUTPUT_LINES: usize = 2000;
3563 const MAX_STDOUT_BYTES: usize = 30_000;
3567 const MAX_STDERR_BYTES: usize = 10_000;
3568 const OVERFLOW_PREVIEW_LINES: usize = 50;
3569
3570 let stdout_lines: Vec<&str> = stdout.lines().collect();
3571 let stderr_lines: Vec<&str> = stderr.lines().collect();
3572
3573 let mut byte_truncated = false;
3574
3575 let line_overflow =
3577 stdout_lines.len() > MAX_OUTPUT_LINES || stderr_lines.len() > MAX_OUTPUT_LINES;
3578 let stdout_byte_overflow = stdout.len() > MAX_STDOUT_BYTES;
3579 let stderr_byte_overflow = stderr.len() > MAX_STDERR_BYTES;
3580 let byte_overflow = stdout_byte_overflow || stderr_byte_overflow;
3581
3582 if !line_overflow && !byte_overflow {
3584 return (stdout, stderr, None, None, false);
3585 }
3586
3587 let base = std::env::temp_dir()
3589 .join("aptu-coder-overflow")
3590 .join(format!("slot-{slot}"));
3591 let _ = std::fs::create_dir_all(&base);
3592
3593 let stdout_path = base.join("stdout");
3594 let stderr_path = base.join("stderr");
3595
3596 let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3597 let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3598
3599 let stdout_path_str = stdout_path.display().to_string();
3600 let stderr_path_str = stderr_path.display().to_string();
3601
3602 let stdout_preview = if stdout_byte_overflow {
3604 byte_truncated = true;
3605 let tail_start = stdout.len().saturating_sub(MAX_STDOUT_BYTES);
3607 let safe_start = stdout[..tail_start].floor_char_boundary(tail_start);
3608 stdout[safe_start..].to_string()
3609 } else if stdout_lines.len() > MAX_OUTPUT_LINES {
3610 stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3611 } else {
3612 stdout
3613 };
3614
3615 let stderr_preview = if stderr_byte_overflow {
3617 byte_truncated = true;
3618 let tail_start = stderr.len().saturating_sub(MAX_STDERR_BYTES);
3620 let safe_start = stderr[..tail_start].floor_char_boundary(tail_start);
3621 stderr[safe_start..].to_string()
3622 } else if stderr_lines.len() > MAX_OUTPUT_LINES {
3623 stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3624 } else {
3625 stderr
3626 };
3627
3628 (
3629 stdout_preview,
3630 stderr_preview,
3631 Some(stdout_path_str),
3632 Some(stderr_path_str),
3633 byte_truncated,
3634 )
3635}
3636
3637#[derive(Clone)]
3641struct FocusedAnalysisParams {
3642 path: std::path::PathBuf,
3643 symbol: String,
3644 match_mode: SymbolMatchMode,
3645 follow_depth: u32,
3646 max_depth: Option<u32>,
3647 ast_recursion_limit: Option<usize>,
3648 use_summary: bool,
3649 impl_only: Option<bool>,
3650 def_use: bool,
3651 parse_timeout_micros: Option<u64>,
3652}
3653
3654fn disable_routes(router: &mut ToolRouter<CodeAnalyzer>, tools: &[&'static str]) {
3655 for tool in tools {
3656 router.disable_route(*tool);
3657 }
3658}
3659
3660#[tool_handler]
3661impl ServerHandler for CodeAnalyzer {
3662 #[instrument(skip(self, context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
3663 async fn initialize(
3664 &self,
3665 request: InitializeRequestParams,
3666 context: RequestContext<RoleServer>,
3667 ) -> Result<InitializeResult, ErrorData> {
3668 let span = tracing::Span::current();
3669 span.record("service.name", "aptu-coder");
3670 span.record("service.version", env!("CARGO_PKG_VERSION"));
3671
3672 {
3674 let mut client_name_lock = self.client_name.lock().await;
3675 *client_name_lock = Some(request.client_info.name.clone());
3676 }
3677 {
3678 let mut client_version_lock = self.client_version.lock().await;
3679 *client_version_lock = Some(request.client_info.version.clone());
3680 }
3681
3682 if let Some(meta) = context.extensions.get::<Meta>()
3684 && let Some(profile) = meta
3685 .0
3686 .get("io.clouatre-labs/profile")
3687 .and_then(|v| v.as_str())
3688 {
3689 let _ = self.session_profile.set(profile.to_owned());
3690 }
3691 Ok(self.get_info())
3692 }
3693
3694 fn get_info(&self) -> InitializeResult {
3695 let excluded = aptu_coder_core::EXCLUDED_DIRS.join(", ");
3696 let instructions = format!(
3697 "Recommended workflow:\n\
3698 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3699 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\
3700 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3701 4. Use analyze_symbol to trace call graphs.\n\
3702 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."
3703 );
3704 let capabilities = ServerCapabilities::builder()
3705 .enable_logging()
3706 .enable_tools()
3707 .enable_tool_list_changed()
3708 .enable_completions()
3709 .build();
3710 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3711 .with_title("Aptu Coder")
3712 .with_description("MCP server for code structure analysis using tree-sitter");
3713 InitializeResult::new(capabilities)
3714 .with_server_info(server_info)
3715 .with_instructions(&instructions)
3716 }
3717
3718 async fn list_tools(
3719 &self,
3720 _request: Option<rmcp::model::PaginatedRequestParams>,
3721 _context: RequestContext<RoleServer>,
3722 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
3723 let router = self.tool_router.read().await;
3724 Ok(rmcp::model::ListToolsResult {
3725 tools: router.list_all(),
3726 meta: None,
3727 next_cursor: None,
3728 })
3729 }
3730
3731 async fn call_tool(
3732 &self,
3733 request: rmcp::model::CallToolRequestParams,
3734 context: RequestContext<RoleServer>,
3735 ) -> Result<CallToolResult, ErrorData> {
3736 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
3737 let router = self.tool_router.read().await;
3738 router.call(tcc).await
3739 }
3740
3741 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
3742 let mut peer_lock = self.peer.lock().await;
3743 *peer_lock = Some(context.peer.clone());
3744 drop(peer_lock);
3745
3746 let millis = std::time::SystemTime::now()
3748 .duration_since(std::time::UNIX_EPOCH)
3749 .unwrap_or_default()
3750 .as_millis()
3751 .try_into()
3752 .unwrap_or(u64::MAX);
3753 let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3754 let sid = format!("{millis}-{counter}");
3755 {
3756 let mut session_id_lock = self.session_id.lock().await;
3757 *session_id_lock = Some(sid);
3758 }
3759 self.session_call_seq
3760 .store(0, std::sync::atomic::Ordering::Relaxed);
3761
3762 let active_profile = self
3775 .session_profile
3776 .get()
3777 .cloned()
3778 .or_else(|| std::env::var("APTU_CODER_PROFILE").ok());
3779
3780 {
3781 let mut router = self.tool_router.write().await;
3782
3783 if let Some(ref profile) = active_profile {
3787 match profile.as_str() {
3788 "edit" => {
3789 disable_routes(
3791 &mut router,
3792 &[
3793 "analyze_directory",
3794 "analyze_file",
3795 "analyze_module",
3796 "analyze_symbol",
3797 ],
3798 );
3799 }
3800 "analyze" => {
3801 disable_routes(&mut router, &["edit_replace", "edit_overwrite"]);
3803 }
3804 _ => {
3805 }
3807 }
3808 }
3809
3810 router.bind_peer_notifier(&context.peer);
3812 }
3813
3814 let peer = self.peer.clone();
3816 let event_rx = self.event_rx.clone();
3817
3818 tokio::spawn(async move {
3819 let rx = {
3820 let mut rx_lock = event_rx.lock().await;
3821 rx_lock.take()
3822 };
3823
3824 if let Some(mut receiver) = rx {
3825 let mut buffer = Vec::with_capacity(64);
3826 loop {
3827 receiver.recv_many(&mut buffer, 64).await;
3829
3830 if buffer.is_empty() {
3831 break;
3833 }
3834
3835 let peer_lock = peer.lock().await;
3837 if let Some(peer) = peer_lock.as_ref() {
3838 for log_event in buffer.drain(..) {
3839 let notification = ServerNotification::LoggingMessageNotification(
3840 Notification::new(LoggingMessageNotificationParam {
3841 level: log_event.level,
3842 logger: Some(log_event.logger),
3843 data: log_event.data,
3844 }),
3845 );
3846 if let Err(e) = peer.send_notification(notification).await {
3847 warn!("Failed to send logging notification: {}", e);
3848 }
3849 }
3850 }
3851 }
3852 }
3853 });
3854 }
3855
3856 #[instrument(skip(self, _context))]
3857 async fn on_cancelled(
3858 &self,
3859 notification: CancelledNotificationParam,
3860 _context: NotificationContext<RoleServer>,
3861 ) {
3862 tracing::info!(
3863 request_id = ?notification.request_id,
3864 reason = ?notification.reason,
3865 "Received cancellation notification"
3866 );
3867 }
3868
3869 #[instrument(skip(self, _context))]
3870 async fn complete(
3871 &self,
3872 request: CompleteRequestParams,
3873 _context: RequestContext<RoleServer>,
3874 ) -> Result<CompleteResult, ErrorData> {
3875 let argument_name = &request.argument.name;
3877 let argument_value = &request.argument.value;
3878
3879 let completions = match argument_name.as_str() {
3880 "path" => {
3881 let root = Path::new(".");
3883 completion::path_completions(root, argument_value)
3884 }
3885 "symbol" => {
3886 let path_arg = request
3888 .context
3889 .as_ref()
3890 .and_then(|ctx| ctx.get_argument("path"));
3891
3892 match path_arg {
3893 Some(path_str) => {
3894 let path = Path::new(path_str);
3895 completion::symbol_completions(&self.cache, path, argument_value)
3896 }
3897 None => Vec::new(),
3898 }
3899 }
3900 _ => Vec::new(),
3901 };
3902
3903 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
3905 let (values, has_more) = if completions.len() > 100 {
3906 (completions.into_iter().take(100).collect(), true)
3907 } else {
3908 (completions, false)
3909 };
3910
3911 let completion_info =
3912 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
3913 Ok(info) => info,
3914 Err(_) => {
3915 CompletionInfo::with_all_values(Vec::new())
3917 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
3918 }
3919 };
3920
3921 Ok(CompleteResult::new(completion_info))
3922 }
3923
3924 async fn set_level(
3925 &self,
3926 params: SetLevelRequestParams,
3927 _context: RequestContext<RoleServer>,
3928 ) -> Result<(), ErrorData> {
3929 let level_filter = match params.level {
3930 LoggingLevel::Debug => LevelFilter::DEBUG,
3931 LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
3932 LoggingLevel::Warning => LevelFilter::WARN,
3933 LoggingLevel::Error
3934 | LoggingLevel::Critical
3935 | LoggingLevel::Alert
3936 | LoggingLevel::Emergency => LevelFilter::ERROR,
3937 };
3938
3939 let mut filter_lock = self
3940 .log_level_filter
3941 .lock()
3942 .unwrap_or_else(|e| e.into_inner());
3943 *filter_lock = level_filter;
3944 Ok(())
3945 }
3946}
3947
3948#[cfg(test)]
3949mod tests {
3950 use super::*;
3951 use regex::Regex;
3952
3953 #[tokio::test]
3954 async fn test_emit_progress_none_peer_is_noop() {
3955 let peer = Arc::new(TokioMutex::new(None));
3956 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3957 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3958 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3959 let analyzer = CodeAnalyzer::new(
3960 peer,
3961 log_level_filter,
3962 rx,
3963 crate::metrics::MetricsSender(metrics_tx),
3964 );
3965 let token = ProgressToken(NumberOrString::String("test".into()));
3966 analyzer
3968 .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
3969 .await;
3970 }
3971
3972 fn make_analyzer() -> CodeAnalyzer {
3973 let peer = Arc::new(TokioMutex::new(None));
3974 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3975 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3976 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3977 CodeAnalyzer::new(
3978 peer,
3979 log_level_filter,
3980 rx,
3981 crate::metrics::MetricsSender(metrics_tx),
3982 )
3983 }
3984
3985 #[test]
3986 fn test_summary_cursor_conflict() {
3987 assert!(summary_cursor_conflict(Some(true), Some("cursor")));
3988 assert!(!summary_cursor_conflict(Some(true), None));
3989 assert!(!summary_cursor_conflict(None, Some("x")));
3990 assert!(!summary_cursor_conflict(None, None));
3991 }
3992
3993 #[tokio::test]
3994 async fn test_validate_impl_only_non_rust_returns_invalid_params() {
3995 use tempfile::TempDir;
3996
3997 let dir = TempDir::new().unwrap();
3998 std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
3999
4000 let analyzer = make_analyzer();
4001 let entries: Vec<traversal::WalkEntry> =
4004 traversal::walk_directory(dir.path(), None).unwrap_or_default();
4005 let result = CodeAnalyzer::validate_impl_only(&entries);
4006 assert!(result.is_err());
4007 let err = result.unwrap_err();
4008 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
4009 drop(analyzer); }
4011
4012 #[tokio::test]
4013 async fn test_no_cache_meta_on_analyze_directory_result() {
4014 use aptu_coder_core::types::{
4015 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4016 };
4017 use tempfile::TempDir;
4018
4019 let dir = TempDir::new().unwrap();
4020 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4021
4022 let analyzer = make_analyzer();
4023 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4024 "path": dir.path().to_str().unwrap(),
4025 }))
4026 .unwrap();
4027 let ct = tokio_util::sync::CancellationToken::new();
4028 let (arc_output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
4029 let meta = no_cache_meta();
4031 assert_eq!(
4032 meta.0.get("cache_hint").and_then(|v| v.as_str()),
4033 Some("no-cache"),
4034 );
4035 drop(arc_output);
4036 }
4037
4038 #[test]
4039 fn test_complete_path_completions_returns_suggestions() {
4040 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4045 let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
4046 let suggestions = completion::path_completions(workspace_root, "aptu-");
4047 assert!(
4048 !suggestions.is_empty(),
4049 "expected completions for prefix 'aptu-' in workspace root"
4050 );
4051 }
4052
4053 #[tokio::test]
4054 async fn test_handle_overview_mode_verbose_no_summary_block() {
4055 use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
4056 use aptu_coder_core::types::{
4057 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4058 };
4059 use tempfile::TempDir;
4060
4061 let tmp = TempDir::new().unwrap();
4062 std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
4063
4064 let peer = Arc::new(TokioMutex::new(None));
4065 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4066 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4067 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4068 let analyzer = CodeAnalyzer::new(
4069 peer,
4070 log_level_filter,
4071 rx,
4072 crate::metrics::MetricsSender(metrics_tx),
4073 );
4074
4075 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4076 "path": tmp.path().to_str().unwrap(),
4077 "verbose": true,
4078 }))
4079 .unwrap();
4080
4081 let ct = tokio_util::sync::CancellationToken::new();
4082 let (output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
4083
4084 let use_summary = output.formatted.len() > SIZE_LIMIT; let paginated =
4087 paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
4088 let verbose = true;
4089 let formatted = if !use_summary {
4090 format_structure_paginated(
4091 &paginated.items,
4092 paginated.total,
4093 params.max_depth,
4094 Some(std::path::Path::new(¶ms.path)),
4095 verbose,
4096 )
4097 } else {
4098 output.formatted.clone()
4099 };
4100
4101 assert!(
4103 !formatted.contains("SUMMARY:"),
4104 "verbose=true must not emit SUMMARY: block; got: {}",
4105 &formatted[..formatted.len().min(300)]
4106 );
4107 assert!(
4108 formatted.contains("PAGINATED:"),
4109 "verbose=true must emit PAGINATED: header"
4110 );
4111 assert!(
4112 formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
4113 "verbose=true must emit FILES section header"
4114 );
4115 }
4116
4117 #[tokio::test]
4120 async fn test_analyze_directory_cache_hit_metrics() {
4121 use aptu_coder_core::types::{
4122 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4123 };
4124 use tempfile::TempDir;
4125
4126 let dir = TempDir::new().unwrap();
4128 std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
4129 let analyzer = make_analyzer();
4130 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4131 "path": dir.path().to_str().unwrap(),
4132 }))
4133 .unwrap();
4134
4135 let ct1 = tokio_util::sync::CancellationToken::new();
4137 let (_out1, hit1) = analyzer.handle_overview_mode(¶ms, ct1).await.unwrap();
4138
4139 let ct2 = tokio_util::sync::CancellationToken::new();
4141 let (_out2, hit2) = analyzer.handle_overview_mode(¶ms, ct2).await.unwrap();
4142
4143 assert_eq!(hit1, CacheTier::Miss, "first call must be a cache miss");
4145 assert_eq!(hit2, CacheTier::L1Memory, "second call must be a cache hit");
4146 }
4147
4148 #[tokio::test]
4149 async fn test_analyze_module_cache_hit_metrics() {
4150 use std::io::Write as _;
4151 use tempfile::NamedTempFile;
4152
4153 let mut f = NamedTempFile::with_suffix(".rs").unwrap();
4155 writeln!(f, "fn bar() {{}}").unwrap();
4156 let path = f.path().to_str().unwrap().to_string();
4157
4158 let analyzer = make_analyzer();
4159
4160 let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
4162 file_params.path = path.clone();
4163 file_params.ast_recursion_limit = None;
4164 file_params.fields = None;
4165 file_params.pagination.cursor = None;
4166 file_params.pagination.page_size = None;
4167 file_params.output_control.summary = None;
4168 file_params.output_control.force = None;
4169 file_params.output_control.verbose = None;
4170 let (_cached, _) = analyzer
4171 .handle_file_details_mode(&file_params)
4172 .await
4173 .unwrap();
4174
4175 let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
4177 module_params.path = path.clone();
4178
4179 let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
4181 meta.modified()
4182 .ok()
4183 .map(|mtime| aptu_coder_core::cache::CacheKey {
4184 path: std::path::PathBuf::from(&path),
4185 modified: mtime,
4186 mode: aptu_coder_core::types::AnalysisMode::FileDetails,
4187 })
4188 });
4189 let cache_hit = module_cache_key
4190 .as_ref()
4191 .and_then(|k| analyzer.cache.get(k))
4192 .is_some();
4193
4194 assert!(
4196 cache_hit,
4197 "analyze_module should find the file in the shared file cache"
4198 );
4199 drop(module_params);
4200 }
4201
4202 #[test]
4205 fn test_analyze_symbol_import_lookup_invalid_params() {
4206 let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
4210
4211 assert!(
4213 result.is_err(),
4214 "import_lookup=true with empty symbol must return Err"
4215 );
4216 let err = result.unwrap_err();
4217 assert_eq!(
4218 err.code,
4219 rmcp::model::ErrorCode::INVALID_PARAMS,
4220 "expected INVALID_PARAMS; got {:?}",
4221 err.code
4222 );
4223 }
4224
4225 #[tokio::test]
4226 async fn test_analyze_symbol_import_lookup_found() {
4227 use tempfile::TempDir;
4228
4229 let dir = TempDir::new().unwrap();
4231 std::fs::write(
4232 dir.path().join("main.rs"),
4233 "use std::collections::HashMap;\nfn main() {}\n",
4234 )
4235 .unwrap();
4236
4237 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4238
4239 let output =
4241 analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
4242
4243 assert!(
4245 output.formatted.contains("MATCHES: 1"),
4246 "expected 1 match; got: {}",
4247 output.formatted
4248 );
4249 assert!(
4250 output.formatted.contains("main.rs"),
4251 "expected main.rs in output; got: {}",
4252 output.formatted
4253 );
4254 }
4255
4256 #[tokio::test]
4257 async fn test_analyze_symbol_import_lookup_empty() {
4258 use tempfile::TempDir;
4259
4260 let dir = TempDir::new().unwrap();
4262 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
4263
4264 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4265
4266 let output =
4268 analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
4269
4270 assert!(
4272 output.formatted.contains("MATCHES: 0"),
4273 "expected 0 matches; got: {}",
4274 output.formatted
4275 );
4276 }
4277
4278 #[tokio::test]
4281 async fn test_analyze_directory_git_ref_non_git_repo() {
4282 use aptu_coder_core::traversal::changed_files_from_git_ref;
4283 use tempfile::TempDir;
4284
4285 let dir = TempDir::new().unwrap();
4287 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4288
4289 let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
4291
4292 assert!(result.is_err(), "non-git dir must return an error");
4294 let err_msg = result.unwrap_err().to_string();
4295 assert!(
4296 err_msg.contains("git"),
4297 "error must mention git; got: {err_msg}"
4298 );
4299 }
4300
4301 #[tokio::test]
4302 async fn test_analyze_directory_git_ref_filters_changed_files() {
4303 use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
4304 use std::collections::HashSet;
4305 use tempfile::TempDir;
4306
4307 let dir = TempDir::new().unwrap();
4309 let changed_file = dir.path().join("changed.rs");
4310 let unchanged_file = dir.path().join("unchanged.rs");
4311 std::fs::write(&changed_file, "fn changed() {}").unwrap();
4312 std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
4313
4314 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4315 let total_files = entries.iter().filter(|e| !e.is_dir).count();
4316 assert_eq!(total_files, 2, "sanity: 2 files before filtering");
4317
4318 let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
4320 changed.insert(changed_file.clone());
4321
4322 let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
4324 let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
4325
4326 assert_eq!(
4328 filtered_files.len(),
4329 1,
4330 "only 1 file must remain after git_ref filter"
4331 );
4332 assert_eq!(
4333 filtered_files[0].path, changed_file,
4334 "the remaining file must be the changed one"
4335 );
4336
4337 let _ = changed_files_from_git_ref;
4339 }
4340
4341 #[tokio::test]
4342 async fn test_handle_overview_mode_git_ref_filters_via_handler() {
4343 use aptu_coder_core::types::{
4344 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4345 };
4346 use std::process::Command;
4347 use tempfile::TempDir;
4348
4349 let dir = TempDir::new().unwrap();
4351 let repo = dir.path();
4352
4353 let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
4356 let mut cmd = std::process::Command::new("git");
4357 cmd.args(["-c", "core.hooksPath=/dev/null"]);
4358 cmd.args(args);
4359 cmd.current_dir(repo_path);
4360 let out = cmd.output().unwrap();
4361 assert!(out.status.success(), "{out:?}");
4362 };
4363 git_no_hook(repo, &["init"]);
4364 git_no_hook(
4365 repo,
4366 &[
4367 "-c",
4368 "user.email=ci@example.com",
4369 "-c",
4370 "user.name=CI",
4371 "commit",
4372 "--allow-empty",
4373 "-m",
4374 "initial",
4375 ],
4376 );
4377
4378 std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
4380 git_no_hook(repo, &["add", "file_a.rs"]);
4381 git_no_hook(
4382 repo,
4383 &[
4384 "-c",
4385 "user.email=ci@example.com",
4386 "-c",
4387 "user.name=CI",
4388 "commit",
4389 "-m",
4390 "add a",
4391 ],
4392 );
4393
4394 std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
4396 git_no_hook(repo, &["add", "file_b.rs"]);
4397 git_no_hook(
4398 repo,
4399 &[
4400 "-c",
4401 "user.email=ci@example.com",
4402 "-c",
4403 "user.name=CI",
4404 "commit",
4405 "-m",
4406 "add b",
4407 ],
4408 );
4409
4410 let canon_repo = std::fs::canonicalize(repo).unwrap();
4416 let analyzer = make_analyzer();
4417 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4418 "path": canon_repo.to_str().unwrap(),
4419 "git_ref": "HEAD~1",
4420 }))
4421 .unwrap();
4422 let ct = tokio_util::sync::CancellationToken::new();
4423 let (arc_output, _cache_hit) = analyzer
4424 .handle_overview_mode(¶ms, ct)
4425 .await
4426 .expect("handle_overview_mode with git_ref must succeed");
4427
4428 let formatted = &arc_output.formatted;
4430 assert!(
4431 formatted.contains("file_b.rs"),
4432 "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
4433 );
4434 assert!(
4435 !formatted.contains("file_a.rs"),
4436 "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
4437 );
4438 }
4439
4440 #[test]
4441 fn test_validate_path_rejects_absolute_path_outside_cwd() {
4442 let result = validate_path("/etc/passwd", true);
4445 assert!(
4446 result.is_err(),
4447 "validate_path should reject /etc/passwd (outside CWD)"
4448 );
4449 let err = result.unwrap_err();
4450 let err_msg = err.message.to_lowercase();
4451 assert!(
4452 err_msg.contains("outside") || err_msg.contains("not found"),
4453 "Error message should mention 'outside' or 'not found': {}",
4454 err.message
4455 );
4456 }
4457
4458 #[test]
4459 fn test_validate_path_accepts_relative_path_in_cwd() {
4460 let result = validate_path("Cargo.toml", true);
4463 assert!(
4464 result.is_ok(),
4465 "validate_path should accept Cargo.toml (exists in CWD)"
4466 );
4467 }
4468
4469 #[test]
4470 fn test_validate_path_creates_parent_for_nonexistent_file() {
4471 let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4474 assert!(
4475 result.is_ok(),
4476 "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4477 );
4478 let path = result.unwrap();
4479 let cwd = std::env::current_dir().expect("should get cwd");
4480 let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4481 assert!(
4482 path.starts_with(&canonical_cwd),
4483 "Resolved path should be within CWD: {:?} should start with {:?}",
4484 path,
4485 canonical_cwd
4486 );
4487 }
4488
4489 #[test]
4490 fn test_edit_overwrite_with_working_dir() {
4491 let cwd = std::env::current_dir().expect("should get cwd");
4493 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4494 let temp_path = temp_dir.path();
4495
4496 let result = validate_path_in_dir("test_file.txt", false, temp_path);
4498
4499 assert!(
4501 result.is_ok(),
4502 "validate_path_in_dir should accept relative path in valid working_dir: {:?}",
4503 result.err()
4504 );
4505 let resolved = result.unwrap();
4506 assert!(
4507 resolved.starts_with(temp_path),
4508 "Resolved path should be within working_dir: {:?} should start with {:?}",
4509 resolved,
4510 temp_path
4511 );
4512 }
4513
4514 #[test]
4515 fn test_validate_path_in_dir_accepts_outside_cwd() {
4516 let temp_dir = std::env::temp_dir();
4518 let canonical_temp_dir =
4519 std::fs::canonicalize(&temp_dir).expect("should canonicalize temp_dir");
4520
4521 let result = validate_path_in_dir("probe.txt", false, &temp_dir);
4523
4524 assert!(
4526 result.is_ok(),
4527 "validate_path_in_dir should accept working_dir outside CWD: {:?}",
4528 result.err()
4529 );
4530 let resolved = result.unwrap();
4531 assert!(
4532 resolved.starts_with(&canonical_temp_dir),
4533 "Resolved path should be within working_dir: {:?} should start with {:?}",
4534 resolved,
4535 canonical_temp_dir
4536 );
4537 }
4538
4539 #[test]
4540 fn test_edit_overwrite_working_dir_traversal() {
4541 let cwd = std::env::current_dir().expect("should get cwd");
4543 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4544 let temp_path = temp_dir.path();
4545
4546 let result = validate_path_in_dir("../../../etc/passwd", false, temp_path);
4548
4549 assert!(
4551 result.is_err(),
4552 "validate_path_in_dir should reject path traversal outside working_dir"
4553 );
4554 let err = result.unwrap_err();
4555 let err_msg = err.message.to_lowercase();
4556 assert!(
4557 err_msg.contains("outside") || err_msg.contains("working"),
4558 "Error message should mention 'outside' or 'working': {}",
4559 err.message
4560 );
4561 }
4562
4563 #[test]
4564 fn test_edit_replace_with_working_dir() {
4565 let cwd = std::env::current_dir().expect("should get cwd");
4567 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4568 let temp_path = temp_dir.path();
4569 let file_path = temp_path.join("test.txt");
4570 std::fs::write(&file_path, "hello world").expect("should write test file");
4571
4572 let result = validate_path_in_dir("test.txt", true, temp_path);
4574
4575 assert!(
4577 result.is_ok(),
4578 "validate_path_in_dir should find existing file in working_dir: {:?}",
4579 result.err()
4580 );
4581 let resolved = result.unwrap();
4582 assert_eq!(
4583 resolved, file_path,
4584 "Resolved path should match the actual file path"
4585 );
4586 }
4587
4588 #[test]
4589 fn test_edit_overwrite_no_working_dir() {
4590 let result = validate_path("Cargo.toml", true);
4595
4596 assert!(
4598 result.is_ok(),
4599 "validate_path should still work without working_dir"
4600 );
4601 }
4602
4603 #[test]
4604 fn test_edit_overwrite_working_dir_is_file() {
4605 let cwd = std::env::current_dir().expect("should get cwd");
4607 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4608 let temp_file = temp_dir.path().join("test_file.txt");
4609 std::fs::write(&temp_file, "test content").expect("should write test file");
4610
4611 let result = validate_path_in_dir("some_file.txt", false, &temp_file);
4613
4614 assert!(
4616 result.is_err(),
4617 "validate_path_in_dir should reject a file as working_dir"
4618 );
4619 let err = result.unwrap_err();
4620 let err_msg = err.message.to_lowercase();
4621 assert!(
4622 err_msg.contains("directory"),
4623 "Error message should mention 'directory': {}",
4624 err.message
4625 );
4626 }
4627
4628 #[test]
4629 fn test_tool_annotations() {
4630 let tools = CodeAnalyzer::list_tools();
4632
4633 let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
4635 let exec_command = tools.iter().find(|t| t.name == "exec_command");
4636
4637 let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
4639 let analyze_dir_annot = analyze_dir_tool
4640 .annotations
4641 .as_ref()
4642 .expect("analyze_directory should have annotations");
4643 assert_eq!(
4644 analyze_dir_annot.read_only_hint,
4645 Some(true),
4646 "analyze_directory read_only_hint should be true"
4647 );
4648 assert_eq!(
4649 analyze_dir_annot.destructive_hint,
4650 Some(false),
4651 "analyze_directory destructive_hint should be false"
4652 );
4653
4654 let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
4656 let exec_cmd_annot = exec_cmd_tool
4657 .annotations
4658 .as_ref()
4659 .expect("exec_command should have annotations");
4660 assert_eq!(
4661 exec_cmd_annot.open_world_hint,
4662 Some(true),
4663 "exec_command open_world_hint should be true"
4664 );
4665 }
4666
4667 #[test]
4668 fn test_exec_stdin_size_cap_validation() {
4669 let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
4672
4673 assert!(
4675 oversized_stdin.len() > STDIN_MAX_BYTES,
4676 "test setup: oversized stdin should exceed 1 MB"
4677 );
4678
4679 let max_stdin = "y".repeat(STDIN_MAX_BYTES);
4681 assert_eq!(
4682 max_stdin.len(),
4683 STDIN_MAX_BYTES,
4684 "test setup: max stdin should be exactly 1 MB"
4685 );
4686 }
4687
4688 #[tokio::test]
4689 async fn test_exec_stdin_cat_roundtrip() {
4690 let stdin_content = "hello world";
4693
4694 let mut child = tokio::process::Command::new("sh")
4696 .arg("-c")
4697 .arg("cat")
4698 .stdin(std::process::Stdio::piped())
4699 .stdout(std::process::Stdio::piped())
4700 .stderr(std::process::Stdio::piped())
4701 .spawn()
4702 .expect("spawn cat");
4703
4704 if let Some(mut stdin_handle) = child.stdin.take() {
4705 use tokio::io::AsyncWriteExt as _;
4706 stdin_handle
4707 .write_all(stdin_content.as_bytes())
4708 .await
4709 .expect("write stdin");
4710 drop(stdin_handle);
4711 }
4712
4713 let output = child.wait_with_output().await.expect("wait for cat");
4714
4715 let stdout_str = String::from_utf8_lossy(&output.stdout);
4717 assert!(
4718 stdout_str.contains(stdin_content),
4719 "stdout should contain stdin content: {}",
4720 stdout_str
4721 );
4722 }
4723
4724 #[tokio::test]
4725 async fn test_exec_stdin_none_no_regression() {
4726 let child = tokio::process::Command::new("sh")
4729 .arg("-c")
4730 .arg("echo hi")
4731 .stdin(std::process::Stdio::null())
4732 .stdout(std::process::Stdio::piped())
4733 .stderr(std::process::Stdio::piped())
4734 .spawn()
4735 .expect("spawn echo");
4736
4737 let output = child.wait_with_output().await.expect("wait for echo");
4738
4739 let stdout_str = String::from_utf8_lossy(&output.stdout);
4741 assert!(
4742 stdout_str.contains("hi"),
4743 "stdout should contain echo output: {}",
4744 stdout_str
4745 );
4746 }
4747
4748 #[test]
4749 fn test_validate_path_in_dir_rejects_sibling_prefix() {
4750 let cwd = std::env::current_dir().expect("should get cwd");
4755 let parent = tempfile::TempDir::new_in(&cwd).expect("should create parent temp dir");
4756 let allowed = parent.path().join("allowed");
4757 let sibling = parent.path().join("allowed_sibling");
4758 std::fs::create_dir_all(&allowed).expect("should create allowed dir");
4759 std::fs::create_dir_all(&sibling).expect("should create sibling dir");
4760
4761 let result = validate_path_in_dir("../allowed_sibling/secret.txt", false, &allowed);
4764
4765 assert!(
4767 result.is_err(),
4768 "validate_path_in_dir must reject a path resolving to a sibling directory \
4769 sharing the working_dir name prefix (CVE-2025-53110 pattern)"
4770 );
4771 let err = result.unwrap_err();
4772 let msg = err.message.to_lowercase();
4773 assert!(
4774 msg.contains("outside") || msg.contains("working"),
4775 "Error should mention 'outside' or 'working', got: {}",
4776 err.message
4777 );
4778 }
4779
4780 #[test]
4781 fn test_validate_path_in_dir_nonexistent_deep_path() {
4782 let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
4786 let result = validate_path_in_dir("a/b/c/d/new.txt", false, temp_dir.path());
4787 assert!(
4788 result.is_ok(),
4789 "validate_path_in_dir should accept deeply nested non-existent path: {:?}",
4790 result.err()
4791 );
4792 let resolved = result.unwrap();
4793 let canonical_wd =
4794 std::fs::canonicalize(temp_dir.path()).expect("should canonicalize temp dir");
4795 assert!(
4796 resolved.starts_with(&canonical_wd),
4797 "Resolved path must be within working_dir: {resolved:?}"
4798 );
4799 assert!(
4800 resolved.ends_with("a/b/c/d/new.txt"),
4801 "Full suffix must be preserved: {resolved:?}"
4802 );
4803 }
4804
4805 #[test]
4806 fn test_validate_path_in_dir_nonexistent_with_existing_parent() {
4807 let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
4810 let sub = temp_dir.path().join("sub");
4811 std::fs::create_dir_all(&sub).expect("should create sub dir");
4812
4813 let result = validate_path_in_dir("sub/new.txt", false, temp_dir.path());
4814 assert!(
4815 result.is_ok(),
4816 "validate_path_in_dir should accept file in existing subdir: {:?}",
4817 result.err()
4818 );
4819 let resolved = result.unwrap();
4820 let canonical_sub = std::fs::canonicalize(&sub).expect("should canonicalize sub");
4821 assert!(
4822 resolved.starts_with(&canonical_sub),
4823 "Resolved path should anchor at the existing sub/ dir: {resolved:?}"
4824 );
4825 assert_eq!(
4826 resolved.file_name().and_then(|n| n.to_str()),
4827 Some("new.txt"),
4828 "File name component must be preserved"
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 = 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 = 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 = 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_filter_git_show_strips_patch_hunks() {
5415 let compiled = filters::CompiledRule {
5417 pattern: Regex::new("^git\\s+show").unwrap(),
5418 strip_patterns: vec![
5419 Regex::new("^@@").unwrap(),
5420 Regex::new("^[+-][^+-]").unwrap(),
5421 ],
5422 keep_patterns: vec![],
5423 rule: types::FilterRule {
5424 match_command: "^git\\s+show".to_string(),
5425 description: None,
5426 strip_ansi: true,
5427 strip_lines_matching: vec!["^@@".to_string(), "^[+-][^+-]".to_string()],
5428 keep_lines_matching: vec![],
5429 max_lines: Some(200),
5430 on_empty: None,
5431 },
5432 };
5433
5434 let stdout = "commit abc123\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,3 +1,4 @@\n-old line\n+new line\n context line\n";
5435 let filtered = filters::apply_filter(&compiled, stdout);
5436
5437 assert!(
5438 filtered.contains("--- a/src/lib.rs"),
5439 "should keep --- file header"
5440 );
5441 assert!(
5442 filtered.contains("+++ b/src/lib.rs"),
5443 "should keep +++ file header"
5444 );
5445 assert!(!filtered.contains("@@ -1,3"), "should strip hunk headers");
5446 assert!(
5447 !filtered.contains("-old line"),
5448 "should strip removed lines"
5449 );
5450 assert!(!filtered.contains("+new line"), "should strip added lines");
5451 }
5452
5453 #[test]
5454 fn test_filter_on_empty_from_empty_input() {
5455 let compiled = filters::CompiledRule {
5458 pattern: Regex::new("^git\\s+diff").unwrap(),
5459 strip_patterns: vec![],
5460 keep_patterns: vec![],
5461 rule: types::FilterRule {
5462 match_command: "^git\\s+diff".to_string(),
5463 description: None,
5464 strip_ansi: true,
5465 strip_lines_matching: vec![],
5466 keep_lines_matching: vec![],
5467 max_lines: Some(100),
5468 on_empty: Some("ok (working tree clean)".to_string()),
5469 },
5470 };
5471
5472 assert_eq!(
5473 filters::apply_filter(&compiled, ""),
5474 "ok (working tree clean)",
5475 "on_empty should fire on empty input"
5476 );
5477 }
5478
5479 #[test]
5480 fn test_filter_applied_to_interleaved_with_both_streams() {
5481 let compiled = filters::CompiledRule {
5484 pattern: Regex::new("^git\\s+pull").unwrap(),
5485 strip_patterns: vec![Regex::new("^\\s*\\|\\s*\\d+\\s*[+\\-]+").unwrap()],
5486 keep_patterns: vec![],
5487 rule: types::FilterRule {
5488 match_command: "^git\\s+pull".to_string(),
5489 description: None,
5490 strip_ansi: false,
5491 strip_lines_matching: vec!["^\\s*\\|\\s*\\d+\\s*[+\\-]+".to_string()],
5492 keep_lines_matching: vec![],
5493 max_lines: None,
5494 on_empty: None,
5495 },
5496 };
5497
5498 let interleaved = " | 42 ++++++++++++\nFrom https://github.com/example/repo\n";
5500
5501 let result = filters::apply_filter(&compiled, interleaved);
5503
5504 assert!(
5506 !result.contains("| 42"),
5507 "strip-matched line should be absent from filtered interleaved"
5508 );
5509 assert!(
5510 result.contains("From https://github.com/example/repo"),
5511 "stderr-origin line should be preserved in filtered interleaved"
5512 );
5513 }
5514
5515 #[test]
5516 fn test_on_empty_substitution_in_interleaved() {
5517 let compiled = filters::CompiledRule {
5519 pattern: Regex::new("^git\\s+pull").unwrap(),
5520 strip_patterns: vec![Regex::new(".*").unwrap()],
5521 keep_patterns: vec![],
5522 rule: types::FilterRule {
5523 match_command: "^git\\s+pull".to_string(),
5524 description: None,
5525 strip_ansi: false,
5526 strip_lines_matching: vec![".*".to_string()],
5527 keep_lines_matching: vec![],
5528 max_lines: None,
5529 on_empty: Some("ok (up-to-date)".to_string()),
5530 },
5531 };
5532
5533 let interleaved = "Already up to date.\nFrom https://github.com/example/repo\n";
5535
5536 let result = filters::apply_filter(&compiled, interleaved);
5538
5539 assert_eq!(
5541 result, "ok (up-to-date)",
5542 "on_empty should be returned when filter strips all lines in interleaved"
5543 );
5544 }
5545
5546 #[test]
5547 fn test_line_cap_fires_before_byte_cap() {
5548 let line = "abcde";
5551 let stdout: String = std::iter::repeat(format!("{}\n", line))
5552 .take(2500)
5553 .collect();
5554 assert_eq!(stdout.lines().count(), 2500, "should have 2500 lines");
5555 assert!(stdout.len() < 30_000, "should be under byte cap");
5556
5557 let stderr = String::new();
5558 let slot = 42u32;
5559
5560 let (out_stdout, _out_stderr, stdout_path, _stderr_path, byte_truncated) =
5561 handle_output_persist(stdout, stderr, slot);
5562
5563 assert!(
5565 !byte_truncated,
5566 "byte cap should NOT fire (under 30k bytes)"
5567 );
5568 assert!(
5569 stdout_path.is_some(),
5570 "stdout_path should be set when line cap fires"
5571 );
5572 let line_count = out_stdout.lines().count();
5574 assert!(
5575 line_count <= 50,
5576 "returned content should have at most 50 lines, got {}",
5577 line_count
5578 );
5579 assert!(line_count > 0, "returned content should not be empty");
5580 }
5581
5582 #[test]
5583 fn test_project_local_overrides_builtin() {
5584 use std::io::Write;
5588
5589 let tmp = std::env::temp_dir().join(format!(
5590 "aptu-test-project-local-{}",
5591 std::time::SystemTime::now()
5592 .duration_since(std::time::UNIX_EPOCH)
5593 .map(|d| d.as_nanos())
5594 .unwrap_or(0)
5595 ));
5596 let aptu_dir = tmp.join(".aptu");
5597 std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
5598
5599 let toml_content = "schema_version = 1\n[[filters]]\nmatch_command = \"^my-custom-tool\"\nkeep_lines_matching = []\non_empty = \"project-local-only-marker\"\n";
5601 let mut f = std::fs::File::create(aptu_dir.join("filters.toml"))
5602 .expect("should create filters.toml");
5603 f.write_all(toml_content.as_bytes())
5604 .expect("should write toml");
5605 drop(f);
5606
5607 let rules = filters::load_filter_table(&tmp);
5608
5609 let first_rule = rules.first().expect("should have at least one rule");
5611 assert!(
5612 first_rule.pattern.is_match("my-custom-tool --flag"),
5613 "project-local rule should be first (index 0)"
5614 );
5615 assert_eq!(
5616 first_rule.rule.on_empty.as_deref(),
5617 Some("project-local-only-marker"),
5618 "project-local rule on_empty should match what was written"
5619 );
5620
5621 let has_git_pull = rules
5623 .iter()
5624 .any(|r| r.pattern.is_match("git pull origin main"));
5625 assert!(
5626 has_git_pull,
5627 "built-in git pull rule should still be present"
5628 );
5629
5630 let _ = std::fs::remove_dir_all(&tmp);
5632 }
5633
5634 #[test]
5635 fn test_invalid_toml_falls_back_gracefully() {
5636 use std::io::Write;
5638
5639 let tmp = std::env::temp_dir().join(format!(
5640 "aptu-test-invalid-toml-{}",
5641 std::time::SystemTime::now()
5642 .duration_since(std::time::UNIX_EPOCH)
5643 .map(|d| d.as_nanos())
5644 .unwrap_or(0)
5645 ));
5646 let aptu_dir = tmp.join(".aptu");
5647 std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
5648
5649 let mut f = std::fs::File::create(aptu_dir.join("filters.toml"))
5650 .expect("should create filters.toml");
5651 f.write_all(b"schema_version = INVALID_VALUE {{{{")
5655 .expect("should write garbage");
5656 drop(f);
5657
5658 let rules = filters::load_filter_table(&tmp);
5660
5661 let has_git_pull = rules
5663 .iter()
5664 .any(|r| r.pattern.is_match("git pull origin main"));
5665 assert!(
5666 has_git_pull,
5667 "should have git pull built-in rule after invalid TOML"
5668 );
5669
5670 let _ = std::fs::remove_dir_all(&tmp);
5672 }
5673
5674 #[test]
5675 fn test_invalid_schema_version_falls_back_gracefully() {
5676 use std::io::Write;
5678
5679 let tmp = std::env::temp_dir().join(format!(
5680 "aptu-test-schema-version-{}",
5681 std::time::SystemTime::now()
5682 .duration_since(std::time::UNIX_EPOCH)
5683 .map(|d| d.as_nanos())
5684 .unwrap_or(0)
5685 ));
5686 let aptu_dir = tmp.join(".aptu");
5687 std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
5688
5689 let toml_content = "schema_version = 2\n[[filters]]\nmatch_command = \"^my-v2-tool\"\nkeep_lines_matching = []\n";
5691 let mut f = std::fs::File::create(aptu_dir.join("filters.toml"))
5692 .expect("should create filters.toml");
5693 f.write_all(toml_content.as_bytes())
5694 .expect("should write toml");
5695 drop(f);
5696
5697 let rules = filters::load_filter_table(&tmp);
5699
5700 let has_git_pull = rules
5702 .iter()
5703 .any(|r| r.pattern.is_match("git pull origin main"));
5704 assert!(
5705 has_git_pull,
5706 "should have git pull built-in rule after schema_version=2 rejection"
5707 );
5708
5709 let has_v2_rule = rules
5711 .iter()
5712 .any(|r| r.pattern.is_match("my-v2-tool --flag"));
5713 assert!(
5714 !has_v2_rule,
5715 "schema_version=2 rule should not be loaded; only built-ins expected"
5716 );
5717
5718 let _ = std::fs::remove_dir_all(&tmp);
5720 }
5721
5722 #[test]
5723 fn test_metric_chars_threshold_breach_fires() {
5724 let output_chars: usize = 35_000;
5726 let event = crate::metrics::MetricEvent {
5727 ts: 0,
5728 tool: "exec_command",
5729 duration_ms: 1,
5730 output_chars,
5731 param_path_depth: 0,
5732 max_depth: None,
5733 result: "ok",
5734 error_type: None,
5735 error_subtype: None,
5736 session_id: None,
5737 seq: None,
5738 cache_hit: None,
5739 cache_write_failure: None,
5740 cache_tier: None,
5741 exit_code: None,
5742 timed_out: false,
5743 output_truncated: None,
5744 chars_threshold_breach: output_chars > 30_000,
5745 file_ext: None,
5746 };
5747 assert!(
5748 event.chars_threshold_breach,
5749 "chars_threshold_breach should be true for output_chars=35000"
5750 );
5751 }
5752
5753 #[test]
5754 fn test_metric_chars_threshold_breach_no_fire() {
5755 let output_chars: usize = 5_000;
5757 let event = crate::metrics::MetricEvent {
5758 ts: 0,
5759 tool: "exec_command",
5760 duration_ms: 1,
5761 output_chars,
5762 param_path_depth: 0,
5763 max_depth: None,
5764 result: "ok",
5765 error_type: None,
5766 error_subtype: None,
5767 session_id: None,
5768 seq: None,
5769 cache_hit: None,
5770 cache_write_failure: None,
5771 cache_tier: None,
5772 exit_code: None,
5773 timed_out: false,
5774 output_truncated: None,
5775 chars_threshold_breach: output_chars > 30_000,
5776 file_ext: None,
5777 };
5778 assert!(
5779 !event.chars_threshold_breach,
5780 "chars_threshold_breach should be false for output_chars=5000"
5781 );
5782 }
5783}