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