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