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