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