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