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