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 handle = tokio::task::spawn_blocking(move || {
3032 aptu_coder_core::edit_replace_block(&resolved_path, &old_text, &new_text)
3033 });
3034
3035 let output = match handle.await {
3036 Ok(Ok(v)) => v,
3037 Ok(Err(aptu_coder_core::EditError::NotFound {
3038 path: notfound_path,
3039 })) => {
3040 span.record("error", true);
3041 span.record("error.type", "invalid_params");
3042 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3043 self.metrics_tx.send(crate::metrics::MetricEvent {
3044 ts: crate::metrics::unix_ms(),
3045 tool: "edit_replace",
3046 duration_ms: dur,
3047 output_chars: 0,
3048 param_path_depth: crate::metrics::path_component_count(¶m_path),
3049 max_depth: None,
3050 result: "error",
3051 error_type: Some("invalid_params".to_string()),
3052 error_subtype: Some("not_found".to_string()),
3053 session_id: sid.clone(),
3054 seq: Some(seq),
3055 cache_hit: None,
3056 cache_write_failure: None,
3057 cache_tier: None,
3058 exit_code: None,
3059 timed_out: false,
3060 output_truncated: None,
3061 ..Default::default()
3062 });
3063 return Ok(err_to_tool_result(ErrorData::new(
3064 rmcp::model::ErrorCode::INVALID_PARAMS,
3065 format!(
3066 "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."
3067 ),
3068 Some(error_meta(
3069 "validation",
3070 false,
3071 "re-read the file with analyze_file or analyze_module, then derive old_text from the live content",
3072 )),
3073 )));
3074 }
3075 Ok(Err(aptu_coder_core::EditError::Ambiguous {
3076 count,
3077 path: ambiguous_path,
3078 })) => {
3079 span.record("error", true);
3080 span.record("error.type", "invalid_params");
3081 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3082 self.metrics_tx.send(crate::metrics::MetricEvent {
3083 ts: crate::metrics::unix_ms(),
3084 tool: "edit_replace",
3085 duration_ms: dur,
3086 output_chars: 0,
3087 param_path_depth: crate::metrics::path_component_count(¶m_path),
3088 max_depth: None,
3089 result: "error",
3090 error_type: Some("invalid_params".to_string()),
3091 error_subtype: Some("ambiguous".to_string()),
3092 session_id: sid.clone(),
3093 seq: Some(seq),
3094 cache_hit: None,
3095 cache_write_failure: None,
3096 cache_tier: None,
3097 exit_code: None,
3098 timed_out: false,
3099 output_truncated: None,
3100 ..Default::default()
3101 });
3102 return Ok(err_to_tool_result(ErrorData::new(
3103 rmcp::model::ErrorCode::INVALID_PARAMS,
3104 format!(
3105 "old_text matched {count} locations in {ambiguous_path}. Extend old_text with more surrounding context to make it unique, or re-read with analyze_file to confirm the exact text."
3106 ),
3107 Some(error_meta(
3108 "validation",
3109 false,
3110 "extend old_text with more surrounding context, or re-read with analyze_file to confirm the exact text",
3111 )),
3112 )));
3113 }
3114 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
3115 span.record("error", true);
3116 span.record("error.type", "invalid_params");
3117 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3118 self.metrics_tx.send(crate::metrics::MetricEvent {
3119 ts: crate::metrics::unix_ms(),
3120 tool: "edit_replace",
3121 duration_ms: dur,
3122 output_chars: 0,
3123 param_path_depth: crate::metrics::path_component_count(¶m_path),
3124 max_depth: None,
3125 result: "error",
3126 error_type: Some("invalid_params".to_string()),
3127 session_id: sid.clone(),
3128 seq: Some(seq),
3129 cache_hit: None,
3130 cache_write_failure: None,
3131 cache_tier: None,
3132 exit_code: None,
3133 timed_out: false,
3134 output_truncated: None,
3135 ..Default::default()
3136 });
3137 return Ok(err_to_tool_result(ErrorData::new(
3138 rmcp::model::ErrorCode::INVALID_PARAMS,
3139 "path is a directory".to_string(),
3140 Some(error_meta(
3141 "validation",
3142 false,
3143 "provide a file path, not a directory",
3144 )),
3145 )));
3146 }
3147 Ok(Err(e)) => {
3148 span.record("error", true);
3149 span.record("error.type", "internal_error");
3150 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3151 self.metrics_tx.send(crate::metrics::MetricEvent {
3152 ts: crate::metrics::unix_ms(),
3153 tool: "edit_replace",
3154 duration_ms: dur,
3155 output_chars: 0,
3156 param_path_depth: crate::metrics::path_component_count(¶m_path),
3157 max_depth: None,
3158 result: "error",
3159 error_type: Some("internal_error".to_string()),
3160 session_id: sid.clone(),
3161 seq: Some(seq),
3162 cache_hit: None,
3163 cache_write_failure: None,
3164 cache_tier: None,
3165 exit_code: None,
3166 timed_out: false,
3167 output_truncated: None,
3168 ..Default::default()
3169 });
3170 return Ok(err_to_tool_result(ErrorData::new(
3171 rmcp::model::ErrorCode::INTERNAL_ERROR,
3172 e.to_string(),
3173 Some(error_meta(
3174 "resource",
3175 false,
3176 "check file path and permissions",
3177 )),
3178 )));
3179 }
3180 Err(e) => {
3181 span.record("error", true);
3182 span.record("error.type", "internal_error");
3183 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3184 self.metrics_tx.send(crate::metrics::MetricEvent {
3185 ts: crate::metrics::unix_ms(),
3186 tool: "edit_replace",
3187 duration_ms: dur,
3188 output_chars: 0,
3189 param_path_depth: crate::metrics::path_component_count(¶m_path),
3190 max_depth: None,
3191 result: "error",
3192 error_type: Some("internal_error".to_string()),
3193 session_id: sid.clone(),
3194 seq: Some(seq),
3195 cache_hit: None,
3196 cache_write_failure: None,
3197 cache_tier: None,
3198 exit_code: None,
3199 timed_out: false,
3200 output_truncated: None,
3201 ..Default::default()
3202 });
3203 return Ok(err_to_tool_result(ErrorData::new(
3204 rmcp::model::ErrorCode::INTERNAL_ERROR,
3205 e.to_string(),
3206 Some(error_meta(
3207 "resource",
3208 false,
3209 "check file path and permissions",
3210 )),
3211 )));
3212 }
3213 };
3214
3215 let text = format!(
3216 "Edited {}: {} bytes -> {} bytes",
3217 output.path, output.bytes_before, output.bytes_after
3218 );
3219 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
3220 .with_meta(Some(no_cache_meta()));
3221 let structured = match serde_json::to_value(&output).map_err(|e| {
3222 ErrorData::new(
3223 rmcp::model::ErrorCode::INTERNAL_ERROR,
3224 format!("serialization failed: {e}"),
3225 Some(error_meta("internal", false, "report this as a bug")),
3226 )
3227 }) {
3228 Ok(v) => v,
3229 Err(e) => return Ok(err_to_tool_result(e)),
3230 };
3231 result.structured_content = Some(structured);
3232 self.cache
3233 .invalidate_file(&std::path::PathBuf::from(¶m_path));
3234 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3235 self.metrics_tx.send(crate::metrics::MetricEvent {
3236 ts: crate::metrics::unix_ms(),
3237 tool: "edit_replace",
3238 duration_ms: dur,
3239 output_chars: text.len(),
3240 param_path_depth: crate::metrics::path_component_count(¶m_path),
3241 max_depth: None,
3242 result: "ok",
3243 error_type: None,
3244 session_id: sid,
3245 seq: Some(seq),
3246 cache_hit: None,
3247 cache_write_failure: None,
3248 cache_tier: None,
3249 exit_code: None,
3250 timed_out: false,
3251 output_truncated: None,
3252 ..Default::default()
3253 });
3254 Ok(result)
3255 }
3256
3257 #[tool(
3258 name = "exec_command",
3259 title = "Exec Command",
3260 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.",
3261 output_schema = schema_for_type::<ShellOutput>(),
3262 annotations(
3263 title = "Exec Command",
3264 read_only_hint = false,
3265 destructive_hint = true,
3266 idempotent_hint = false,
3267 open_world_hint = true
3268 )
3269 )]
3270 #[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))]
3271 pub async fn exec_command(
3272 &self,
3273 params: Parameters<ExecCommandParams>,
3274 context: RequestContext<RoleServer>,
3275 ) -> Result<CallToolResult, ErrorData> {
3276 let t_start = std::time::Instant::now();
3277 let params = params.0;
3278 let session_id = self.session_id.lock().await.clone();
3280 let client_name = self.client_name.lock().await.clone();
3281 let client_version = self.client_version.lock().await.clone();
3282 extract_and_set_trace_context(
3283 Some(&context.meta),
3284 ClientMetadata {
3285 session_id,
3286 client_name,
3287 client_version,
3288 },
3289 );
3290 let span = tracing::Span::current();
3291 span.record("gen_ai.system", "mcp");
3292 span.record("gen_ai.operation.name", "execute_tool");
3293 span.record("gen_ai.tool.name", "exec_command");
3294 span.record("command", ¶ms.command);
3295
3296 let working_dir_path = if let Some(ref wd) = params.working_dir {
3298 match validate_path(wd, true) {
3299 Ok(p) => {
3300 if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
3302 span.record("error", true);
3303 span.record("error.type", "invalid_params");
3304 return Ok(tool_error(format!(
3305 "working_dir {:?} is not a directory. Provide an existing directory path.",
3306 wd
3307 )));
3308 }
3309 Some(p)
3310 }
3311 Err(e) => {
3312 span.record("error", true);
3313 span.record("error.type", "invalid_params");
3314 return Ok(tool_error(format!(
3315 "working_dir {:?} is not valid: {}. Provide an existing directory path.",
3316 wd, e.message
3317 )));
3318 }
3319 }
3320 } else {
3321 None
3322 };
3323
3324 let (effective_command, cd_extracted_path) = strip_cd_prefix(¶ms.command);
3330 let (command, working_dir_path) = if let Some(cd_path) = cd_extracted_path {
3331 if working_dir_path.is_none() {
3332 let is_plain_absolute = cd_path.starts_with('/')
3337 && !cd_path.contains('$')
3338 && !cd_path.contains('~')
3339 && cd_path != "-";
3340 if !is_plain_absolute {
3341 (params.command.clone(), working_dir_path)
3343 } else {
3344 match validate_path(cd_path, true) {
3346 Ok(p) if std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) => {
3347 tracing::debug!(
3348 "exec_command: promoting cd prefix path as working_dir: {}",
3349 cd_path
3350 );
3351 (effective_command.to_owned(), Some(p))
3352 }
3353 Ok(_) => {
3354 span.record("error", true);
3355 span.record("error.type", "invalid_params");
3356 return Ok(tool_error(format!(
3357 "cd prefix path {:?} is not a directory. Set working_dir explicitly or use a valid directory path.",
3358 cd_path
3359 )));
3360 }
3361 Err(_) => {
3362 span.record("error", true);
3363 span.record("error.type", "invalid_params");
3364 return Ok(tool_error(format!(
3365 "cd prefix path {:?} does not exist or is outside CWD. Set working_dir explicitly.",
3366 cd_path
3367 )));
3368 }
3369 }
3370 }
3371 } else {
3372 let cd_resolves_to_same = validate_path(cd_path, true)
3375 .ok()
3376 .map(|p| Some(&p) == working_dir_path.as_ref())
3377 .unwrap_or(false);
3378 if cd_resolves_to_same {
3379 tracing::debug!(
3380 "exec_command: stripped redundant cd prefix; matches explicit working_dir"
3381 );
3382 (effective_command.to_owned(), working_dir_path)
3383 } else {
3384 (params.command.clone(), working_dir_path)
3386 }
3387 }
3388 } else {
3389 (params.command.clone(), working_dir_path)
3390 };
3391
3392 let param_path = params.working_dir.clone();
3393 let seq = self
3394 .session_call_seq
3395 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3396 let sid = self.session_id.lock().await.clone();
3397
3398 if let Some(ref stdin_content) = params.stdin
3400 && stdin_content.len() > STDIN_MAX_BYTES
3401 {
3402 span.record("error", true);
3403 span.record("error.type", "invalid_params");
3404 return Ok(err_to_tool_result(ErrorData::new(
3405 rmcp::model::ErrorCode::INVALID_PARAMS,
3406 "stdin exceeds 1 MB limit".to_string(),
3407 Some(error_meta("validation", false, "reduce stdin content size")),
3408 )));
3409 }
3410
3411 let timeout_secs = params.timeout_secs;
3412
3413 let resolved_path_str = self.resolved_path.as_ref().as_deref();
3415 let output = run_exec_impl(
3416 command.clone(),
3417 working_dir_path.clone(),
3418 timeout_secs,
3419 params.memory_limit_mb,
3420 params.cpu_limit_secs,
3421 params.stdin.clone(),
3422 seq,
3423 resolved_path_str,
3424 &self.filter_table,
3425 )
3426 .await;
3427
3428 let exit_code = output.exit_code;
3429 let timed_out = output.timed_out;
3430 let mut output_truncated = output.output_truncated;
3431
3432 if let Some(code) = exit_code {
3434 span.record("exit_code", code);
3435 }
3436 span.record("timed_out", timed_out);
3437 span.record("output_truncated", output_truncated);
3438
3439 if output_truncated {
3441 tracing::debug!(truncated = true, message = "output truncated");
3442 }
3443
3444 let output_text = if output.interleaved.is_empty() {
3446 format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
3447 } else {
3448 format!("Output:\n{}", output.interleaved)
3449 };
3450
3451 let mut combined_truncated = false;
3456 let truncated_output_text = if output_text.len() > SIZE_LIMIT {
3457 combined_truncated = true;
3458 let tail_start = output_text.len().saturating_sub(SIZE_LIMIT);
3460 let safe_start = output_text[..tail_start].floor_char_boundary(tail_start);
3461 output_text[safe_start..].to_string()
3462 } else {
3463 output_text
3464 };
3465
3466 output_truncated = output_truncated || combined_truncated;
3468
3469 let text = format!(
3470 "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
3471 params.command,
3472 exit_code
3473 .map(|c| c.to_string())
3474 .unwrap_or_else(|| "null".to_string()),
3475 timed_out,
3476 output_truncated,
3477 truncated_output_text,
3478 );
3479
3480 let content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
3481
3482 let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
3487
3488 let mut result = if command_failed {
3489 CallToolResult::error(content_blocks)
3490 } else {
3491 CallToolResult::success(content_blocks)
3492 }
3493 .with_meta(Some(no_cache_meta()));
3494
3495 let structured = match serde_json::to_value(&output).map_err(|e| {
3496 ErrorData::new(
3497 rmcp::model::ErrorCode::INTERNAL_ERROR,
3498 format!("serialization failed: {e}"),
3499 Some(error_meta("internal", false, "report this as a bug")),
3500 )
3501 }) {
3502 Ok(v) => v,
3503 Err(e) => {
3504 span.record("error", true);
3505 span.record("error.type", "internal_error");
3506 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3507 self.metrics_tx.send(crate::metrics::MetricEvent {
3508 ts: crate::metrics::unix_ms(),
3509 tool: "exec_command",
3510 duration_ms: dur,
3511 output_chars: 0,
3512 param_path_depth: crate::metrics::path_component_count(
3513 param_path.as_deref().unwrap_or(""),
3514 ),
3515 max_depth: None,
3516 result: "error",
3517 error_type: Some("internal_error".to_string()),
3518 session_id: sid.clone(),
3519 seq: Some(seq),
3520 cache_hit: None,
3521 cache_write_failure: None,
3522 cache_tier: None,
3523 exit_code,
3524 timed_out,
3525 output_truncated: Some(output_truncated),
3526 ..Default::default()
3527 });
3528 return Ok(err_to_tool_result(e));
3529 }
3530 };
3531
3532 result.structured_content = Some(structured);
3533 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3534 self.metrics_tx.send(crate::metrics::MetricEvent {
3535 ts: crate::metrics::unix_ms(),
3536 tool: "exec_command",
3537 duration_ms: dur,
3538 output_chars: text.len(),
3539 param_path_depth: crate::metrics::path_component_count(
3540 param_path.as_deref().unwrap_or(""),
3541 ),
3542 max_depth: None,
3543 result: "ok",
3544 error_type: None,
3545 error_subtype: None,
3546 session_id: sid,
3547 seq: Some(seq),
3548 cache_hit: None,
3549 cache_write_failure: None,
3550 cache_tier: None,
3551 exit_code,
3552 timed_out,
3553 output_truncated: Some(output_truncated),
3554 chars_threshold_breach: text.len() > 30_000,
3555 file_ext: None,
3556 filter_applied: output.filter_applied.clone(),
3557 });
3558 Ok(result)
3559 }
3560}
3561
3562fn build_exec_command(
3564 command: &str,
3565 working_dir_path: Option<&std::path::PathBuf>,
3566 memory_limit_mb: Option<u64>,
3567 cpu_limit_secs: Option<u64>,
3568 stdin_present: bool,
3569 resolved_path: Option<&str>,
3570) -> tokio::process::Command {
3571 let shell = resolve_shell();
3572 let mut cmd = tokio::process::Command::new(shell);
3573 cmd.arg("-c").arg(command);
3574
3575 if let Some(wd) = working_dir_path {
3576 cmd.current_dir(wd);
3577 }
3578
3579 if let Some(path) = resolved_path {
3581 cmd.env("PATH", path);
3582 }
3583
3584 cmd.stdout(std::process::Stdio::piped())
3585 .stderr(std::process::Stdio::piped());
3586
3587 if stdin_present {
3588 cmd.stdin(std::process::Stdio::piped());
3589 } else {
3590 cmd.stdin(std::process::Stdio::null());
3591 }
3592
3593 #[cfg(unix)]
3594 {
3595 #[cfg(not(target_os = "linux"))]
3596 if memory_limit_mb.is_some() {
3597 warn!("memory_limit_mb is not enforced on this platform (Linux only)");
3598 }
3599 if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
3600 unsafe {
3604 cmd.pre_exec(move || {
3605 #[cfg(target_os = "linux")]
3606 if let Some(mb) = memory_limit_mb {
3607 let bytes = mb.saturating_mul(1024 * 1024);
3608 setrlimit(Resource::RLIMIT_AS, bytes, bytes)
3609 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3610 }
3611 if let Some(cpu) = cpu_limit_secs {
3612 setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
3613 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3614 }
3615 Ok(())
3616 });
3617 }
3618 }
3619 }
3620
3621 cmd
3622}
3623
3624fn strip_cd_prefix(cmd: &str) -> (&str, Option<&str>) {
3631 let trimmed = cmd.trim_start();
3632 let Some(rest) = trimmed.strip_prefix("cd ") else {
3633 return (cmd, None);
3634 };
3635 let Some((path_part, rest_part)) = rest.split_once("&&") else {
3637 return (cmd, None);
3638 };
3639 let path = path_part.trim();
3640 let stripped = rest_part.trim();
3641 (stripped, Some(path))
3642}
3643
3644async fn run_with_timeout(
3647 mut child: tokio::process::Child,
3648 timeout_secs: Option<u64>,
3649 tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
3650) -> (Option<i32>, bool, bool, Option<String>) {
3651 use tokio::io::AsyncBufReadExt as _;
3652 use tokio_stream::StreamExt as TokioStreamExt;
3653 use tokio_stream::wrappers::LinesStream;
3654
3655 let stdout_pipe = child.stdout.take();
3656 let stderr_pipe = child.stderr.take();
3657
3658 let mut drain_task = tokio::spawn(async move {
3659 let so_stream = stdout_pipe.map(|p| {
3660 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (false, s)))
3661 });
3662 let se_stream = stderr_pipe.map(|p| {
3663 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3664 });
3665
3666 match (so_stream, se_stream) {
3667 (Some(so), Some(se)) => {
3668 let mut merged = so.merge(se);
3669 while let Some(Ok((is_stderr, line))) = merged.next().await {
3670 let _ = tx.send((is_stderr, line));
3671 }
3672 }
3673 (Some(so), None) => {
3674 let mut stream = so;
3675 while let Some(Ok((_, line))) = stream.next().await {
3676 let _ = tx.send((false, line));
3677 }
3678 }
3679 (None, Some(se)) => {
3680 let mut stream = se;
3681 while let Some(Ok((_, line))) = stream.next().await {
3682 let _ = tx.send((true, line));
3683 }
3684 }
3685 (None, None) => {}
3686 }
3687 });
3688
3689 tokio::select! {
3690 _ = &mut drain_task => {
3691 let (status, drain_truncated) = match tokio::time::timeout(
3692 std::time::Duration::from_millis(500),
3693 child.wait()
3694 ).await {
3695 Ok(Ok(s)) => (Some(s), false),
3696 Ok(Err(_)) => (None, false),
3697 Err(_) => {
3698 child.start_kill().ok();
3699 let _ = child.wait().await;
3700 (None, true)
3701 }
3702 };
3703 let exit_code = status.and_then(|s| s.code());
3704 let ocerr = if drain_truncated {
3705 Some("post-exit drain timeout: background process held pipes".to_string())
3706 } else {
3707 None
3708 };
3709 (exit_code, false, drain_truncated, ocerr)
3710 }
3711 _ = async {
3712 if let Some(secs) = timeout_secs {
3713 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3714 } else {
3715 std::future::pending::<()>().await;
3716 }
3717 } => {
3718 let _ = child.kill().await;
3719 let _ = child.wait().await;
3720 drain_task.abort();
3721 (None, true, false, None)
3722 }
3723 }
3724}
3725
3726#[allow(clippy::too_many_arguments)]
3730async fn run_exec_impl(
3731 command: String,
3732 working_dir_path: Option<std::path::PathBuf>,
3733 timeout_secs: Option<u64>,
3734 memory_limit_mb: Option<u64>,
3735 cpu_limit_secs: Option<u64>,
3736 stdin: Option<String>,
3737 seq: u32,
3738 resolved_path: Option<&str>,
3739 filter_table: &Arc<Vec<CompiledRule>>,
3740) -> ShellOutput {
3741 let command = maybe_inject_no_stat(&command);
3743
3744 let mut cmd = build_exec_command(
3745 &command,
3746 working_dir_path.as_ref(),
3747 memory_limit_mb,
3748 cpu_limit_secs,
3749 stdin.is_some(),
3750 resolved_path,
3751 );
3752
3753 let mut child = match cmd.spawn() {
3754 Ok(c) => c,
3755 Err(e) => {
3756 return ShellOutput::new(
3757 String::new(),
3758 format!("failed to spawn command: {e}"),
3759 format!("failed to spawn command: {e}"),
3760 None,
3761 false,
3762 false,
3763 );
3764 }
3765 };
3766
3767 if let Some(stdin_content) = stdin
3768 && let Some(mut stdin_handle) = child.stdin.take()
3769 {
3770 use tokio::io::AsyncWriteExt as _;
3771 match stdin_handle.write_all(stdin_content.as_bytes()).await {
3772 Ok(()) => {
3773 drop(stdin_handle);
3774 }
3775 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
3776 Err(e) => {
3777 warn!("failed to write stdin: {e}");
3778 }
3779 }
3780 }
3781
3782 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, String)>();
3783
3784 let (exit_code, timed_out, mut output_truncated, output_collection_error) =
3785 run_with_timeout(child, timeout_secs, tx).await;
3786
3787 let mut lines: Vec<(bool, String)> = Vec::new();
3788 while let Some(item) = rx.recv().await {
3789 lines.push(item);
3790 }
3791
3792 const MAX_BYTES: usize = 50 * 1024;
3794 let mut stdout_str = String::new();
3795 let mut stderr_str = String::new();
3796 let mut interleaved_str = String::new();
3797 let mut so_bytes = 0usize;
3798 let mut se_bytes = 0usize;
3799 let mut il_bytes = 0usize;
3800 for (is_stderr, line) in &lines {
3801 let entry = format!("{line}\n");
3802 if il_bytes < 2 * MAX_BYTES {
3803 il_bytes += entry.len();
3804 interleaved_str.push_str(&entry);
3805 }
3806 if *is_stderr {
3807 if se_bytes < MAX_BYTES {
3808 se_bytes += entry.len();
3809 stderr_str.push_str(&entry);
3810 }
3811 } else if so_bytes < MAX_BYTES {
3812 so_bytes += entry.len();
3813 stdout_str.push_str(&entry);
3814 }
3815 }
3816
3817 let slot = seq % 8;
3818 let (stdout, stderr, stdout_path, stderr_path, byte_truncated) =
3819 handle_output_persist(stdout_str, stderr_str, slot);
3820 output_truncated = output_truncated || stdout_path.is_some() || byte_truncated;
3821
3822 let mut output = ShellOutput::new(
3823 stdout,
3824 stderr,
3825 interleaved_str,
3826 exit_code,
3827 timed_out,
3828 output_truncated,
3829 );
3830 output.output_collection_error = output_collection_error;
3831 output.stdout_path = stdout_path;
3832 output.stderr_path = stderr_path;
3833
3834 if exit_code == Some(0) && !timed_out {
3836 for compiled_rule in filter_table.iter() {
3837 if compiled_rule.pattern.is_match(&command) {
3838 let filtered_stdout = apply_filter(compiled_rule, &output.stdout);
3839 output.stdout = filtered_stdout;
3840 output.interleaved = apply_filter(compiled_rule, &output.interleaved);
3847 output.filter_applied = compiled_rule
3848 .rule
3849 .description
3850 .clone()
3851 .or_else(|| Some(compiled_rule.rule.match_command.clone()));
3852 break;
3853 }
3854 }
3855 }
3856
3857 output
3858}
3859
3860fn handle_output_persist(
3867 stdout: String,
3868 stderr: String,
3869 slot: u32,
3870) -> (String, String, Option<String>, Option<String>, bool) {
3871 const MAX_OUTPUT_LINES: usize = 2000;
3872 const MAX_STDOUT_BYTES: usize = 30_000;
3876 const MAX_STDERR_BYTES: usize = 10_000;
3877 const OVERFLOW_PREVIEW_LINES: usize = 50;
3878
3879 let stdout_lines: Vec<&str> = stdout.lines().collect();
3880 let stderr_lines: Vec<&str> = stderr.lines().collect();
3881
3882 let mut byte_truncated = false;
3883
3884 let line_overflow =
3886 stdout_lines.len() > MAX_OUTPUT_LINES || stderr_lines.len() > MAX_OUTPUT_LINES;
3887 let stdout_byte_overflow = stdout.len() > MAX_STDOUT_BYTES;
3888 let stderr_byte_overflow = stderr.len() > MAX_STDERR_BYTES;
3889 let byte_overflow = stdout_byte_overflow || stderr_byte_overflow;
3890
3891 if !line_overflow && !byte_overflow {
3893 return (stdout, stderr, None, None, false);
3894 }
3895
3896 let base = std::env::temp_dir()
3898 .join("aptu-coder-overflow")
3899 .join(format!("slot-{slot}"));
3900 let _ = std::fs::create_dir_all(&base);
3901
3902 let stdout_path = base.join("stdout");
3903 let stderr_path = base.join("stderr");
3904
3905 let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3906 let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3907
3908 let stdout_path_str = stdout_path.display().to_string();
3909 let stderr_path_str = stderr_path.display().to_string();
3910
3911 let stdout_preview = if stdout_byte_overflow {
3913 byte_truncated = true;
3914 let tail_start = stdout.len().saturating_sub(MAX_STDOUT_BYTES);
3916 let safe_start = stdout[..tail_start].floor_char_boundary(tail_start);
3917 stdout[safe_start..].to_string()
3918 } else if stdout_lines.len() > MAX_OUTPUT_LINES {
3919 stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3920 } else {
3921 stdout
3922 };
3923
3924 let stderr_preview = if stderr_byte_overflow {
3926 byte_truncated = true;
3927 let tail_start = stderr.len().saturating_sub(MAX_STDERR_BYTES);
3929 let safe_start = stderr[..tail_start].floor_char_boundary(tail_start);
3930 stderr[safe_start..].to_string()
3931 } else if stderr_lines.len() > MAX_OUTPUT_LINES {
3932 stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3933 } else {
3934 stderr
3935 };
3936
3937 (
3938 stdout_preview,
3939 stderr_preview,
3940 Some(stdout_path_str),
3941 Some(stderr_path_str),
3942 byte_truncated,
3943 )
3944}
3945
3946#[derive(Clone)]
3950struct FocusedAnalysisParams {
3951 path: std::path::PathBuf,
3952 symbol: String,
3953 match_mode: SymbolMatchMode,
3954 follow_depth: u32,
3955 max_depth: Option<u32>,
3956 ast_recursion_limit: Option<usize>,
3957 use_summary: bool,
3958 impl_only: Option<bool>,
3959 def_use: bool,
3960 parse_timeout_micros: Option<u64>,
3961}
3962
3963fn disable_routes(router: &mut ToolRouter<CodeAnalyzer>, tools: &[&'static str]) {
3964 for tool in tools {
3965 router.disable_route(*tool);
3966 }
3967}
3968
3969#[tool_handler]
3970impl ServerHandler for CodeAnalyzer {
3971 #[instrument(skip(self, context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
3972 async fn initialize(
3973 &self,
3974 request: InitializeRequestParams,
3975 context: RequestContext<RoleServer>,
3976 ) -> Result<InitializeResult, ErrorData> {
3977 let span = tracing::Span::current();
3978 span.record("service.name", "aptu-coder");
3979 span.record("service.version", env!("CARGO_PKG_VERSION"));
3980
3981 {
3983 let mut client_name_lock = self.client_name.lock().await;
3984 *client_name_lock = Some(request.client_info.name.clone());
3985 }
3986 {
3987 let mut client_version_lock = self.client_version.lock().await;
3988 *client_version_lock = Some(request.client_info.version.clone());
3989 }
3990
3991 if let Some(meta) = context.extensions.get::<Meta>()
3993 && let Some(profile) = meta
3994 .0
3995 .get("io.clouatre-labs/profile")
3996 .and_then(|v| v.as_str())
3997 {
3998 let _ = self.session_profile.set(profile.to_owned());
3999 }
4000 Ok(self.get_info())
4001 }
4002
4003 fn get_info(&self) -> InitializeResult {
4004 let excluded = aptu_coder_core::EXCLUDED_DIRS.join(", ");
4005 let instructions = format!(
4006 "Recommended workflow:\n\
4007 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
4008 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\
4009 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
4010 4. Use analyze_symbol to trace call graphs.\n\
4011 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\
4012 JSONL metrics at $HOME/.local/share/aptu-coder/ (or $XDG_DATA_HOME/aptu-coder/). Always cd there before jq glob queries."
4013 );
4014 let capabilities = ServerCapabilities::builder()
4015 .enable_logging()
4016 .enable_tools()
4017 .enable_tool_list_changed()
4018 .enable_completions()
4019 .build();
4020 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
4021 .with_title("Aptu Coder")
4022 .with_description("MCP server for code structure analysis using tree-sitter");
4023 InitializeResult::new(capabilities)
4024 .with_server_info(server_info)
4025 .with_instructions(&instructions)
4026 }
4027
4028 async fn list_tools(
4029 &self,
4030 _request: Option<rmcp::model::PaginatedRequestParams>,
4031 _context: RequestContext<RoleServer>,
4032 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
4033 let router = self.tool_router.read().await;
4034 Ok(rmcp::model::ListToolsResult {
4035 tools: router.list_all(),
4036 meta: None,
4037 next_cursor: None,
4038 })
4039 }
4040
4041 async fn call_tool(
4042 &self,
4043 request: rmcp::model::CallToolRequestParams,
4044 context: RequestContext<RoleServer>,
4045 ) -> Result<CallToolResult, ErrorData> {
4046 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
4047 let router = self.tool_router.read().await;
4048 router.call(tcc).await
4049 }
4050
4051 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
4052 let mut peer_lock = self.peer.lock().await;
4053 *peer_lock = Some(context.peer.clone());
4054 drop(peer_lock);
4055
4056 let millis = std::time::SystemTime::now()
4058 .duration_since(std::time::UNIX_EPOCH)
4059 .unwrap_or_default()
4060 .as_millis()
4061 .try_into()
4062 .unwrap_or(u64::MAX);
4063 let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4064 let sid = format!("{millis}-{counter}");
4065 {
4066 let mut session_id_lock = self.session_id.lock().await;
4067 *session_id_lock = Some(sid);
4068 }
4069 self.session_call_seq
4070 .store(0, std::sync::atomic::Ordering::Relaxed);
4071
4072 let active_profile = self
4085 .session_profile
4086 .get()
4087 .cloned()
4088 .or_else(|| std::env::var("APTU_CODER_PROFILE").ok());
4089
4090 {
4091 let mut router = self.tool_router.write().await;
4092
4093 if let Some(ref profile) = active_profile {
4097 match profile.as_str() {
4098 "edit" => {
4099 disable_routes(
4101 &mut router,
4102 &[
4103 "analyze_directory",
4104 "analyze_file",
4105 "analyze_module",
4106 "analyze_symbol",
4107 ],
4108 );
4109 }
4110 "analyze" => {
4111 disable_routes(&mut router, &["edit_replace", "edit_overwrite"]);
4113 }
4114 _ => {
4115 }
4117 }
4118 }
4119
4120 router.bind_peer_notifier(&context.peer);
4122 }
4123
4124 let peer = self.peer.clone();
4126 let event_rx = self.event_rx.clone();
4127
4128 tokio::spawn(async move {
4129 let rx = {
4130 let mut rx_lock = event_rx.lock().await;
4131 rx_lock.take()
4132 };
4133
4134 if let Some(mut receiver) = rx {
4135 let mut buffer = Vec::with_capacity(64);
4136 loop {
4137 receiver.recv_many(&mut buffer, 64).await;
4139
4140 if buffer.is_empty() {
4141 break;
4143 }
4144
4145 let peer_lock = peer.lock().await;
4147 if let Some(peer) = peer_lock.as_ref() {
4148 for log_event in buffer.drain(..) {
4149 let notification = ServerNotification::LoggingMessageNotification(
4150 Notification::new(LoggingMessageNotificationParam {
4151 level: log_event.level,
4152 logger: Some(log_event.logger),
4153 data: log_event.data,
4154 }),
4155 );
4156 if let Err(e) = peer.send_notification(notification).await {
4157 warn!("Failed to send logging notification: {}", e);
4158 }
4159 }
4160 }
4161 }
4162 }
4163 });
4164 }
4165
4166 #[instrument(skip(self, _context))]
4167 async fn on_cancelled(
4168 &self,
4169 notification: CancelledNotificationParam,
4170 _context: NotificationContext<RoleServer>,
4171 ) {
4172 tracing::info!(
4173 request_id = ?notification.request_id,
4174 reason = ?notification.reason,
4175 "Received cancellation notification"
4176 );
4177 }
4178
4179 #[instrument(skip(self, _context))]
4180 async fn complete(
4181 &self,
4182 request: CompleteRequestParams,
4183 _context: RequestContext<RoleServer>,
4184 ) -> Result<CompleteResult, ErrorData> {
4185 let argument_name = &request.argument.name;
4187 let argument_value = &request.argument.value;
4188
4189 let completions = match argument_name.as_str() {
4190 "path" => {
4191 let root = Path::new(".");
4193 completion::path_completions(root, argument_value)
4194 }
4195 "symbol" => {
4196 let path_arg = request
4198 .context
4199 .as_ref()
4200 .and_then(|ctx| ctx.get_argument("path"));
4201
4202 match path_arg {
4203 Some(path_str) => {
4204 let path = Path::new(path_str);
4205 completion::symbol_completions(&self.cache, path, argument_value)
4206 }
4207 None => Vec::new(),
4208 }
4209 }
4210 _ => Vec::new(),
4211 };
4212
4213 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
4215 let (values, has_more) = if completions.len() > 100 {
4216 (completions.into_iter().take(100).collect(), true)
4217 } else {
4218 (completions, false)
4219 };
4220
4221 let completion_info =
4222 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
4223 Ok(info) => info,
4224 Err(_) => {
4225 CompletionInfo::with_all_values(Vec::new())
4227 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
4228 }
4229 };
4230
4231 Ok(CompleteResult::new(completion_info))
4232 }
4233
4234 async fn set_level(
4235 &self,
4236 params: SetLevelRequestParams,
4237 _context: RequestContext<RoleServer>,
4238 ) -> Result<(), ErrorData> {
4239 let level_filter = match params.level {
4240 LoggingLevel::Debug => LevelFilter::DEBUG,
4241 LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
4242 LoggingLevel::Warning => LevelFilter::WARN,
4243 LoggingLevel::Error
4244 | LoggingLevel::Critical
4245 | LoggingLevel::Alert
4246 | LoggingLevel::Emergency => LevelFilter::ERROR,
4247 };
4248
4249 let mut filter_lock = self
4250 .log_level_filter
4251 .lock()
4252 .unwrap_or_else(|e| e.into_inner());
4253 *filter_lock = level_filter;
4254 Ok(())
4255 }
4256}
4257
4258#[cfg(test)]
4259mod tests {
4260 use super::*;
4261 use regex::Regex;
4262 use rmcp::model::NumberOrString;
4263
4264 #[tokio::test]
4265 async fn test_emit_progress_none_peer_is_noop() {
4266 let peer = Arc::new(TokioMutex::new(None));
4267 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4268 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4269 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4270 let analyzer = CodeAnalyzer::new(
4271 peer,
4272 log_level_filter,
4273 rx,
4274 crate::metrics::MetricsSender(metrics_tx),
4275 );
4276 let token = ProgressToken(NumberOrString::String("test".into()));
4277 analyzer
4279 .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
4280 .await;
4281 }
4282
4283 fn make_analyzer() -> CodeAnalyzer {
4284 let peer = Arc::new(TokioMutex::new(None));
4285 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4286 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4287 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4288 CodeAnalyzer::new(
4289 peer,
4290 log_level_filter,
4291 rx,
4292 crate::metrics::MetricsSender(metrics_tx),
4293 )
4294 }
4295
4296 #[test]
4297 fn test_summary_cursor_conflict() {
4298 assert!(summary_cursor_conflict(Some(true), Some("cursor")));
4299 assert!(!summary_cursor_conflict(Some(true), None));
4300 assert!(!summary_cursor_conflict(None, Some("x")));
4301 assert!(!summary_cursor_conflict(None, None));
4302 }
4303
4304 #[tokio::test]
4305 async fn test_validate_impl_only_non_rust_returns_invalid_params() {
4306 use tempfile::TempDir;
4307
4308 let dir = TempDir::new().unwrap();
4309 std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
4310
4311 let analyzer = make_analyzer();
4312 let entries: Vec<traversal::WalkEntry> =
4315 traversal::walk_directory(dir.path(), None).unwrap_or_default();
4316 let result = CodeAnalyzer::validate_impl_only(&entries);
4317 assert!(result.is_err());
4318 let err = result.unwrap_err();
4319 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
4320 drop(analyzer); }
4322
4323 #[tokio::test]
4324 async fn test_no_cache_meta_on_analyze_directory_result() {
4325 use aptu_coder_core::types::{
4326 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4327 };
4328 use tempfile::TempDir;
4329
4330 let dir = TempDir::new().unwrap();
4331 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4332
4333 let analyzer = make_analyzer();
4334 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4335 "path": dir.path().to_str().unwrap(),
4336 }))
4337 .unwrap();
4338 let ct = tokio_util::sync::CancellationToken::new();
4339 let (arc_output, _cache_hit) = analyzer
4340 .handle_overview_mode(¶ms, ct, None)
4341 .await
4342 .unwrap();
4343 let meta = no_cache_meta();
4345 assert_eq!(
4346 meta.0.get("cache_hint").and_then(|v| v.as_str()),
4347 Some("no-cache"),
4348 );
4349 drop(arc_output);
4350 }
4351
4352 #[test]
4353 fn test_complete_path_completions_returns_suggestions() {
4354 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4359 let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
4360 let suggestions = completion::path_completions(workspace_root, "aptu-");
4361 assert!(
4362 !suggestions.is_empty(),
4363 "expected completions for prefix 'aptu-' in workspace root"
4364 );
4365 }
4366
4367 #[tokio::test]
4368 async fn test_handle_overview_mode_verbose_no_summary_block() {
4369 use aptu_coder_core::types::AnalyzeDirectoryParams;
4370 use tempfile::TempDir;
4371
4372 let tmp = TempDir::new().unwrap();
4373 std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
4374
4375 let peer = Arc::new(TokioMutex::new(None));
4376 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4377 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4378 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4379 let analyzer = CodeAnalyzer::new(
4380 peer,
4381 log_level_filter,
4382 rx,
4383 crate::metrics::MetricsSender(metrics_tx),
4384 );
4385
4386 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4387 "path": tmp.path().to_str().unwrap(),
4388 "verbose": true,
4389 }))
4390 .unwrap();
4391
4392 let ct = tokio_util::sync::CancellationToken::new();
4393 let (output, _cache_hit) = analyzer
4394 .handle_overview_mode(¶ms, ct, None)
4395 .await
4396 .unwrap();
4397
4398 let formatted = &output.formatted;
4402
4403 assert!(
4404 formatted.contains("SUMMARY:"),
4405 "summary=None with small output must emit SUMMARY: block (tree output); got: {}",
4406 &formatted[..formatted.len().min(300)]
4407 );
4408 assert!(
4409 formatted.contains("PATH [LOC, FUNCTIONS, CLASSES]"),
4410 "summary=None with small output must emit PATH section header (tree output); got: {}",
4411 &formatted[..formatted.len().min(300)]
4412 );
4413 assert!(
4414 !formatted.contains("PAGINATED:"),
4415 "summary=None must NOT emit PAGINATED: header; got: {}",
4416 &formatted[..formatted.len().min(300)]
4417 );
4418 }
4419
4420 #[tokio::test]
4421 async fn test_analyze_directory_summary_false_forces_pagination() {
4422 use aptu_coder_core::types::AnalyzeDirectoryParams;
4425 use tempfile::TempDir;
4426
4427 let tmp = TempDir::new().unwrap();
4429 std::fs::write(tmp.path().join("lib.rs"), "fn foo() {}").unwrap();
4430
4431 let peer = Arc::new(TokioMutex::new(None));
4432 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4433 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4434 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4435 let analyzer = CodeAnalyzer::new(
4436 peer,
4437 log_level_filter,
4438 rx,
4439 crate::metrics::MetricsSender(metrics_tx),
4440 );
4441
4442 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4443 "path": tmp.path().to_str().unwrap(),
4444 "summary": false,
4445 }))
4446 .unwrap();
4447
4448 let ct = tokio_util::sync::CancellationToken::new();
4450 let (output, _cache_hit) = analyzer
4451 .handle_overview_mode(¶ms, ct, None)
4452 .await
4453 .unwrap();
4454
4455 assert!(
4457 output.formatted.len() <= SIZE_LIMIT,
4458 "test precondition: output must be small; got {} chars",
4459 output.formatted.len()
4460 );
4461
4462 let use_paginated = params.output_control.summary == Some(false);
4467 assert!(use_paginated, "summary=false must set use_paginated=true");
4468
4469 assert!(
4471 !output.formatted.contains("PAGINATED:"),
4472 "handle_overview_mode returns format_structure (tree); PAGINATED: must not appear"
4473 );
4474 assert!(
4476 output.formatted.contains("SUMMARY:"),
4477 "handle_overview_mode returns format_structure (tree); SUMMARY: must appear"
4478 );
4479 }
4480
4481 #[tokio::test]
4484 async fn test_analyze_directory_cache_hit_metrics() {
4485 use aptu_coder_core::types::{
4486 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4487 };
4488 use tempfile::TempDir;
4489
4490 let dir = TempDir::new().unwrap();
4492 std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
4493 let analyzer = make_analyzer();
4494 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4495 "path": dir.path().to_str().unwrap(),
4496 }))
4497 .unwrap();
4498
4499 let ct1 = tokio_util::sync::CancellationToken::new();
4501 let (_out1, hit1) = analyzer
4502 .handle_overview_mode(¶ms, ct1, None)
4503 .await
4504 .unwrap();
4505
4506 let ct2 = tokio_util::sync::CancellationToken::new();
4508 let (_out2, hit2) = analyzer
4509 .handle_overview_mode(¶ms, ct2, None)
4510 .await
4511 .unwrap();
4512
4513 assert_eq!(hit1, CacheTier::Miss, "first call must be a cache miss");
4515 assert_eq!(hit2, CacheTier::L1Memory, "second call must be a cache hit");
4516 }
4517
4518 #[test]
4519 fn test_analyze_module_cache_hit_metrics() {
4520 use std::io::Write as _;
4521 use tempfile::NamedTempFile;
4522
4523 let cwd = std::env::current_dir().unwrap();
4525 let mut f = NamedTempFile::with_suffix_in(".rs", &cwd).unwrap();
4526 write!(f, "use std::io;\nfn bar() {{}}\n").unwrap();
4527 f.flush().unwrap();
4528
4529 let result = analyze::analyze_module_file(f.path().to_str().unwrap());
4531
4532 let module_info = result.expect("analyze_module_file must succeed");
4534 assert_eq!(
4535 module_info.functions.len(),
4536 1,
4537 "expected exactly one function"
4538 );
4539 assert_eq!(module_info.functions[0].name, "bar");
4540 assert_eq!(module_info.imports.len(), 1, "expected exactly one import");
4541 assert!(
4542 module_info.imports[0].module.contains("std"),
4543 "import module must contain 'std', got: {}",
4544 module_info.imports[0].module
4545 );
4546 }
4547
4548 #[test]
4551 fn test_analyze_symbol_import_lookup_invalid_params() {
4552 let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
4556
4557 assert!(
4559 result.is_err(),
4560 "import_lookup=true with empty symbol must return Err"
4561 );
4562 let err = result.unwrap_err();
4563 assert_eq!(
4564 err.code,
4565 rmcp::model::ErrorCode::INVALID_PARAMS,
4566 "expected INVALID_PARAMS; got {:?}",
4567 err.code
4568 );
4569 }
4570
4571 #[tokio::test]
4572 async fn test_analyze_symbol_import_lookup_found() {
4573 use tempfile::TempDir;
4574
4575 let dir = TempDir::new().unwrap();
4577 std::fs::write(
4578 dir.path().join("main.rs"),
4579 "use std::collections::HashMap;\nfn main() {}\n",
4580 )
4581 .unwrap();
4582
4583 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4584
4585 let output =
4587 analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
4588
4589 assert!(
4591 output.formatted.contains("MATCHES: 1"),
4592 "expected 1 match; got: {}",
4593 output.formatted
4594 );
4595 assert!(
4596 output.formatted.contains("main.rs"),
4597 "expected main.rs in output; got: {}",
4598 output.formatted
4599 );
4600 }
4601
4602 #[tokio::test]
4603 async fn test_analyze_symbol_import_lookup_empty() {
4604 use tempfile::TempDir;
4605
4606 let dir = TempDir::new().unwrap();
4608 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
4609
4610 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4611
4612 let output =
4614 analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
4615
4616 assert!(
4618 output.formatted.contains("MATCHES: 0"),
4619 "expected 0 matches; got: {}",
4620 output.formatted
4621 );
4622 }
4623
4624 #[tokio::test]
4627 async fn test_analyze_directory_git_ref_non_git_repo() {
4628 use aptu_coder_core::traversal::changed_files_from_git_ref;
4629 use tempfile::TempDir;
4630
4631 let dir = TempDir::new().unwrap();
4633 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4634
4635 let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
4637
4638 assert!(result.is_err(), "non-git dir must return an error");
4640 let err_msg = result.unwrap_err().to_string();
4641 assert!(
4642 err_msg.contains("git"),
4643 "error must mention git; got: {err_msg}"
4644 );
4645 }
4646
4647 #[tokio::test]
4648 async fn test_analyze_directory_git_ref_filters_changed_files() {
4649 use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
4650 use std::collections::HashSet;
4651 use tempfile::TempDir;
4652
4653 let dir = TempDir::new().unwrap();
4655 let changed_file = dir.path().join("changed.rs");
4656 let unchanged_file = dir.path().join("unchanged.rs");
4657 std::fs::write(&changed_file, "fn changed() {}").unwrap();
4658 std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
4659
4660 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4661 let total_files = entries.iter().filter(|e| !e.is_dir).count();
4662 assert_eq!(total_files, 2, "sanity: 2 files before filtering");
4663
4664 let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
4666 changed.insert(changed_file.clone());
4667
4668 let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
4670 let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
4671
4672 assert_eq!(
4674 filtered_files.len(),
4675 1,
4676 "only 1 file must remain after git_ref filter"
4677 );
4678 assert_eq!(
4679 filtered_files[0].path, changed_file,
4680 "the remaining file must be the changed one"
4681 );
4682
4683 let _ = changed_files_from_git_ref;
4685 }
4686
4687 #[tokio::test]
4688 async fn test_handle_overview_mode_git_ref_filters_via_handler() {
4689 use aptu_coder_core::types::{
4690 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4691 };
4692 use std::process::Command;
4693 use tempfile::TempDir;
4694
4695 let dir = TempDir::new().unwrap();
4697 let repo = dir.path();
4698
4699 let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
4702 let mut cmd = std::process::Command::new("git");
4703 cmd.args(["-c", "core.hooksPath=/dev/null"]);
4704 cmd.args(args);
4705 cmd.current_dir(repo_path);
4706 let out = cmd.output().unwrap();
4707 assert!(out.status.success(), "{out:?}");
4708 };
4709 git_no_hook(repo, &["init"]);
4710 git_no_hook(
4711 repo,
4712 &[
4713 "-c",
4714 "user.email=ci@example.com",
4715 "-c",
4716 "user.name=CI",
4717 "commit",
4718 "--allow-empty",
4719 "-m",
4720 "initial",
4721 ],
4722 );
4723
4724 std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
4726 git_no_hook(repo, &["add", "file_a.rs"]);
4727 git_no_hook(
4728 repo,
4729 &[
4730 "-c",
4731 "user.email=ci@example.com",
4732 "-c",
4733 "user.name=CI",
4734 "commit",
4735 "-m",
4736 "add a",
4737 ],
4738 );
4739
4740 std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
4742 git_no_hook(repo, &["add", "file_b.rs"]);
4743 git_no_hook(
4744 repo,
4745 &[
4746 "-c",
4747 "user.email=ci@example.com",
4748 "-c",
4749 "user.name=CI",
4750 "commit",
4751 "-m",
4752 "add b",
4753 ],
4754 );
4755
4756 let canon_repo = std::fs::canonicalize(repo).unwrap();
4762 let analyzer = make_analyzer();
4763 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4764 "path": canon_repo.to_str().unwrap(),
4765 "git_ref": "HEAD~1",
4766 }))
4767 .unwrap();
4768 let ct = tokio_util::sync::CancellationToken::new();
4769 let (arc_output, _cache_hit) = analyzer
4770 .handle_overview_mode(¶ms, ct, None)
4771 .await
4772 .expect("handle_overview_mode with git_ref must succeed");
4773
4774 let formatted = &arc_output.formatted;
4776 assert!(
4777 formatted.contains("file_b.rs"),
4778 "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
4779 );
4780 assert!(
4781 !formatted.contains("file_a.rs"),
4782 "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
4783 );
4784 }
4785
4786 #[test]
4787 fn test_validate_path_rejects_absolute_path_outside_cwd() {
4788 let result = validate_path("/etc/passwd", true);
4791 assert!(
4792 result.is_err(),
4793 "validate_path should reject /etc/passwd (outside CWD)"
4794 );
4795 let err = result.unwrap_err();
4796 let err_msg = err.message.to_lowercase();
4797 assert!(
4798 err_msg.contains("outside") || err_msg.contains("not found"),
4799 "Error message should mention 'outside' or 'not found': {}",
4800 err.message
4801 );
4802 }
4803
4804 #[test]
4805 fn test_validate_path_accepts_relative_path_in_cwd() {
4806 let result = validate_path("Cargo.toml", true);
4809 assert!(
4810 result.is_ok(),
4811 "validate_path should accept Cargo.toml (exists in CWD)"
4812 );
4813 }
4814
4815 #[test]
4816 fn test_validate_path_creates_parent_for_nonexistent_file() {
4817 let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4820 assert!(
4821 result.is_ok(),
4822 "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4823 );
4824 let path = result.unwrap();
4825 let cwd = std::env::current_dir().expect("should get cwd");
4826 let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4827 assert!(
4828 path.starts_with(&canonical_cwd),
4829 "Resolved path should be within CWD: {:?} should start with {:?}",
4830 path,
4831 canonical_cwd
4832 );
4833 }
4834
4835 #[test]
4836 fn test_edit_overwrite_with_working_dir() {
4837 let cwd = std::env::current_dir().expect("should get cwd");
4839 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4840 let temp_path = temp_dir.path();
4841
4842 let result = validate_path_in_dir("test_file.txt", false, temp_path);
4844
4845 assert!(
4847 result.is_ok(),
4848 "validate_path_in_dir should accept relative path in valid working_dir: {:?}",
4849 result.err()
4850 );
4851 let resolved = result.unwrap();
4852 assert!(
4853 resolved.starts_with(temp_path),
4854 "Resolved path should be within working_dir: {:?} should start with {:?}",
4855 resolved,
4856 temp_path
4857 );
4858 }
4859
4860 #[test]
4861 fn test_validate_path_in_dir_accepts_outside_cwd() {
4862 let temp_dir = std::env::temp_dir();
4864 let canonical_temp_dir =
4865 std::fs::canonicalize(&temp_dir).expect("should canonicalize temp_dir");
4866
4867 let result = validate_path_in_dir("probe.txt", false, &temp_dir);
4869
4870 assert!(
4872 result.is_ok(),
4873 "validate_path_in_dir should accept working_dir outside CWD: {:?}",
4874 result.err()
4875 );
4876 let resolved = result.unwrap();
4877 assert!(
4878 resolved.starts_with(&canonical_temp_dir),
4879 "Resolved path should be within working_dir: {:?} should start with {:?}",
4880 resolved,
4881 canonical_temp_dir
4882 );
4883 }
4884
4885 #[test]
4886 fn test_edit_overwrite_working_dir_traversal() {
4887 let cwd = std::env::current_dir().expect("should get cwd");
4889 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4890 let temp_path = temp_dir.path();
4891
4892 let result = validate_path_in_dir("../../../etc/passwd", false, temp_path);
4894
4895 assert!(
4897 result.is_err(),
4898 "validate_path_in_dir should reject path traversal outside working_dir"
4899 );
4900 let err = result.unwrap_err();
4901 let err_msg = err.message.to_lowercase();
4902 assert!(
4903 err_msg.contains("outside") || err_msg.contains("working"),
4904 "Error message should mention 'outside' or 'working': {}",
4905 err.message
4906 );
4907 }
4908
4909 #[test]
4910 fn test_edit_replace_with_working_dir() {
4911 let cwd = std::env::current_dir().expect("should get cwd");
4913 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4914 let temp_path = temp_dir.path();
4915 let file_path = temp_path.join("test.txt");
4916 std::fs::write(&file_path, "hello world").expect("should write test file");
4917
4918 let result = validate_path_in_dir("test.txt", true, temp_path);
4920
4921 assert!(
4923 result.is_ok(),
4924 "validate_path_in_dir should find existing file in working_dir: {:?}",
4925 result.err()
4926 );
4927 let resolved = result.unwrap();
4928 assert_eq!(
4929 resolved, file_path,
4930 "Resolved path should match the actual file path"
4931 );
4932 }
4933
4934 #[test]
4935 fn test_edit_overwrite_no_working_dir() {
4936 let result = validate_path("Cargo.toml", true);
4941
4942 assert!(
4944 result.is_ok(),
4945 "validate_path should still work without working_dir"
4946 );
4947 }
4948
4949 #[test]
4950 fn test_edit_overwrite_working_dir_is_file() {
4951 let cwd = std::env::current_dir().expect("should get cwd");
4953 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4954 let temp_file = temp_dir.path().join("test_file.txt");
4955 std::fs::write(&temp_file, "test content").expect("should write test file");
4956
4957 let result = validate_path_in_dir("some_file.txt", false, &temp_file);
4959
4960 assert!(
4962 result.is_err(),
4963 "validate_path_in_dir should reject a file as working_dir"
4964 );
4965 let err = result.unwrap_err();
4966 let err_msg = err.message.to_lowercase();
4967 assert!(
4968 err_msg.contains("directory"),
4969 "Error message should mention 'directory': {}",
4970 err.message
4971 );
4972 }
4973
4974 #[test]
4975 fn test_tool_annotations() {
4976 let tools = CodeAnalyzer::list_tools();
4978
4979 let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
4981 let exec_command = tools.iter().find(|t| t.name == "exec_command");
4982
4983 let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
4985 let analyze_dir_annot = analyze_dir_tool
4986 .annotations
4987 .as_ref()
4988 .expect("analyze_directory should have annotations");
4989 assert_eq!(
4990 analyze_dir_annot.read_only_hint,
4991 Some(true),
4992 "analyze_directory read_only_hint should be true"
4993 );
4994 assert_eq!(
4995 analyze_dir_annot.destructive_hint,
4996 Some(false),
4997 "analyze_directory destructive_hint should be false"
4998 );
4999
5000 let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
5002 let exec_cmd_annot = exec_cmd_tool
5003 .annotations
5004 .as_ref()
5005 .expect("exec_command should have annotations");
5006 assert_eq!(
5007 exec_cmd_annot.open_world_hint,
5008 Some(true),
5009 "exec_command open_world_hint should be true"
5010 );
5011 }
5012
5013 #[test]
5014 fn test_exec_stdin_size_cap_validation() {
5015 let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
5018
5019 assert!(
5021 oversized_stdin.len() > STDIN_MAX_BYTES,
5022 "test setup: oversized stdin should exceed 1 MB"
5023 );
5024
5025 let max_stdin = "y".repeat(STDIN_MAX_BYTES);
5027 assert_eq!(
5028 max_stdin.len(),
5029 STDIN_MAX_BYTES,
5030 "test setup: max stdin should be exactly 1 MB"
5031 );
5032 }
5033
5034 #[tokio::test]
5035 async fn test_exec_stdin_cat_roundtrip() {
5036 let stdin_content = "hello world";
5039
5040 let mut child = tokio::process::Command::new("sh")
5042 .arg("-c")
5043 .arg("cat")
5044 .stdin(std::process::Stdio::piped())
5045 .stdout(std::process::Stdio::piped())
5046 .stderr(std::process::Stdio::piped())
5047 .spawn()
5048 .expect("spawn cat");
5049
5050 if let Some(mut stdin_handle) = child.stdin.take() {
5051 use tokio::io::AsyncWriteExt as _;
5052 stdin_handle
5053 .write_all(stdin_content.as_bytes())
5054 .await
5055 .expect("write stdin");
5056 drop(stdin_handle);
5057 }
5058
5059 let output = child.wait_with_output().await.expect("wait for cat");
5060
5061 let stdout_str = String::from_utf8_lossy(&output.stdout);
5063 assert!(
5064 stdout_str.contains(stdin_content),
5065 "stdout should contain stdin content: {}",
5066 stdout_str
5067 );
5068 }
5069
5070 #[tokio::test]
5071 async fn test_exec_stdin_none_no_regression() {
5072 let child = tokio::process::Command::new("sh")
5075 .arg("-c")
5076 .arg("echo hi")
5077 .stdin(std::process::Stdio::null())
5078 .stdout(std::process::Stdio::piped())
5079 .stderr(std::process::Stdio::piped())
5080 .spawn()
5081 .expect("spawn echo");
5082
5083 let output = child.wait_with_output().await.expect("wait for echo");
5084
5085 let stdout_str = String::from_utf8_lossy(&output.stdout);
5087 assert!(
5088 stdout_str.contains("hi"),
5089 "stdout should contain echo output: {}",
5090 stdout_str
5091 );
5092 }
5093
5094 #[test]
5095 fn test_validate_path_in_dir_rejects_sibling_prefix() {
5096 let cwd = std::env::current_dir().expect("should get cwd");
5101 let parent = tempfile::TempDir::new_in(&cwd).expect("should create parent temp dir");
5102 let allowed = parent.path().join("allowed");
5103 let sibling = parent.path().join("allowed_sibling");
5104 std::fs::create_dir_all(&allowed).expect("should create allowed dir");
5105 std::fs::create_dir_all(&sibling).expect("should create sibling dir");
5106
5107 let result = validate_path_in_dir("../allowed_sibling/secret.txt", false, &allowed);
5110
5111 assert!(
5113 result.is_err(),
5114 "validate_path_in_dir must reject a path resolving to a sibling directory \
5115 sharing the working_dir name prefix (CVE-2025-53110 pattern)"
5116 );
5117 let err = result.unwrap_err();
5118 let msg = err.message.to_lowercase();
5119 assert!(
5120 msg.contains("outside") || msg.contains("working"),
5121 "Error should mention 'outside' or 'working', got: {}",
5122 err.message
5123 );
5124 }
5125
5126 #[test]
5127 fn test_validate_path_in_dir_nonexistent_deep_path() {
5128 let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
5132 let result = validate_path_in_dir("a/b/c/d/new.txt", false, temp_dir.path());
5133 assert!(
5134 result.is_ok(),
5135 "validate_path_in_dir should accept deeply nested non-existent path: {:?}",
5136 result.err()
5137 );
5138 let resolved = result.unwrap();
5139 let canonical_wd =
5140 std::fs::canonicalize(temp_dir.path()).expect("should canonicalize temp dir");
5141 assert!(
5142 resolved.starts_with(&canonical_wd),
5143 "Resolved path must be within working_dir: {resolved:?}"
5144 );
5145 assert!(
5146 resolved.ends_with("a/b/c/d/new.txt"),
5147 "Full suffix must be preserved: {resolved:?}"
5148 );
5149 }
5150
5151 #[test]
5152 fn test_validate_path_in_dir_nonexistent_with_existing_parent() {
5153 let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
5156 let sub = temp_dir.path().join("sub");
5157 std::fs::create_dir_all(&sub).expect("should create sub dir");
5158
5159 let result = validate_path_in_dir("sub/new.txt", false, temp_dir.path());
5160 assert!(
5161 result.is_ok(),
5162 "validate_path_in_dir should accept file in existing subdir: {:?}",
5163 result.err()
5164 );
5165 let resolved = result.unwrap();
5166 let canonical_sub = std::fs::canonicalize(&sub).expect("should canonicalize sub");
5167 assert!(
5168 resolved.starts_with(&canonical_sub),
5169 "Resolved path should anchor at the existing sub/ dir: {resolved:?}"
5170 );
5171 assert_eq!(
5172 resolved.file_name().and_then(|n| n.to_str()),
5173 Some("new.txt"),
5174 "File name component must be preserved"
5175 );
5176 }
5177
5178 #[test]
5179 #[serial_test::serial]
5180 fn test_file_cache_capacity_default() {
5181 unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
5183
5184 let analyzer = make_analyzer();
5186
5187 assert_eq!(analyzer.cache.file_capacity(), 100);
5189 }
5190
5191 #[test]
5192 #[serial_test::serial]
5193 fn test_file_cache_capacity_from_env() {
5194 unsafe { std::env::set_var("APTU_CODER_FILE_CACHE_CAPACITY", "42") };
5196
5197 let analyzer = make_analyzer();
5199
5200 unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
5202
5203 assert_eq!(analyzer.cache.file_capacity(), 42);
5205 }
5206
5207 #[test]
5208 fn test_exec_command_path_injected() {
5209 let resolved_path = Some("/usr/local/bin:/usr/bin:/bin");
5211 let cmd = build_exec_command("echo test", None, None, None, false, resolved_path);
5212
5213 let cmd_str = format!("{:?}", cmd);
5217
5218 assert!(
5220 !cmd_str.is_empty(),
5221 "build_exec_command should return a valid Command"
5222 );
5223 }
5224
5225 #[test]
5226 fn test_exec_command_path_fallback() {
5227 let cmd = build_exec_command("echo test", None, None, None, false, None);
5229
5230 let cmd_str = format!("{:?}", cmd);
5232
5233 assert!(
5235 !cmd_str.is_empty(),
5236 "build_exec_command should handle None resolved_path gracefully"
5237 );
5238 }
5239
5240 #[test]
5241 fn test_analyze_symbol_cache_fields_use_cache_tier_enum() {
5242 assert_eq!(
5246 CacheTier::Miss.as_str(),
5247 "miss",
5248 "CacheTier::Miss.as_str() must stay \"miss\" -- analyze_symbol metrics depend on it"
5249 );
5250 assert!(
5251 !matches!(CacheTier::Miss, CacheTier::L1Memory | CacheTier::L2Disk),
5252 "CacheTier::Miss must not be a hit variant (cache_hit=false for a miss)"
5253 );
5254 }
5255
5256 #[tokio::test]
5257 async fn test_unsupported_extension_returns_success() {
5258 let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
5261 let unsupported_file = temp_dir.path().join("notes.txt");
5262 std::fs::write(&unsupported_file, "line one\nline two\nline three")
5263 .expect("should write file");
5264
5265 let analyzer = make_analyzer();
5266 let mut params = AnalyzeFileParams::default();
5267 params.path = unsupported_file.to_string_lossy().to_string();
5268
5269 let result = analyzer.handle_file_details_mode(¶ms).await;
5270
5271 assert!(
5272 result.is_ok(),
5273 "should succeed for unsupported extension; got: {:?}",
5274 result
5275 );
5276 let (output, _tier) = result.unwrap();
5277 assert_eq!(output.line_count, 3, "line_count must be 3");
5278 assert!(
5279 output.semantic.functions.is_empty(),
5280 "functions must be empty"
5281 );
5282 assert!(output.semantic.classes.is_empty(), "classes must be empty");
5283 assert!(output.semantic.imports.is_empty(), "imports must be empty");
5284 }
5285
5286 #[tokio::test]
5287 async fn test_unsupported_extension_fallback_note_in_formatted() {
5288 let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
5290 let unsupported_file = temp_dir.path().join("readme.txt");
5291 std::fs::write(
5292 &unsupported_file,
5293 "This is a plain text file.\nSecond line.",
5294 )
5295 .expect("should write file");
5296
5297 let analyzer = make_analyzer();
5298 let mut params = AnalyzeFileParams::default();
5299 params.path = unsupported_file.to_string_lossy().to_string();
5300
5301 let (output, _tier) = analyzer
5302 .handle_file_details_mode(¶ms)
5303 .await
5304 .expect("must succeed");
5305 let lower = output.formatted.to_lowercase();
5306 assert!(
5307 lower.contains("unsupported"),
5308 "formatted must contain 'unsupported' note; got: {}",
5309 output.formatted
5310 );
5311 }
5312
5313 #[test]
5314 fn test_exec_no_truncation_under_limits() {
5315 let stdout = "hello world".to_string();
5317 let stderr = "no errors".to_string();
5318 let slot = 0u32;
5319
5320 let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
5321 handle_output_persist(stdout, stderr, slot);
5322
5323 assert_eq!(out_stdout, "hello world");
5324 assert_eq!(out_stderr, "no errors");
5325 assert!(stdout_path.is_none());
5326 assert!(stderr_path.is_none());
5327 assert!(!byte_truncated);
5328 }
5329
5330 #[test]
5331 fn test_exec_byte_overflow_stdout_exceeds_30k() {
5332 let stdout = "x".repeat(35_000);
5334 let stderr = "small".to_string();
5335 let slot = 0u32;
5336
5337 let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
5338 handle_output_persist(stdout.clone(), stderr.clone(), slot);
5339
5340 assert!(byte_truncated, "byte_truncated should be true");
5342 assert!(stdout_path.is_some(), "stdout_path should be set");
5343 assert!(stderr_path.is_some(), "stderr_path should be set");
5344
5345 assert!(
5347 out_stdout.len() <= 30_000,
5348 "stdout should be truncated to <= 30k"
5349 );
5350 assert_eq!(out_stderr, "small", "stderr should be unchanged");
5351
5352 let base = std::env::temp_dir()
5354 .join("aptu-coder-overflow")
5355 .join(format!("slot-{slot}"));
5356 let stdout_file = base.join("stdout");
5357 assert!(
5358 stdout_file.exists(),
5359 "stdout slot file should exist after byte overflow"
5360 );
5361 }
5362
5363 #[test]
5364 fn test_exec_byte_overflow_stderr_exceeds_10k() {
5365 let stdout = "small".to_string();
5367 let stderr = "y".repeat(15_000);
5368 let slot = 1u32;
5369
5370 let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
5371 handle_output_persist(stdout.clone(), stderr.clone(), slot);
5372
5373 assert!(byte_truncated, "byte_truncated should be true");
5375 assert!(stdout_path.is_some(), "stdout_path should be set");
5376 assert!(stderr_path.is_some(), "stderr_path should be set");
5377
5378 assert_eq!(out_stdout, "small", "stdout should be unchanged");
5380 assert!(
5381 out_stderr.len() <= 10_000,
5382 "stderr should be truncated to <= 10k"
5383 );
5384
5385 let base = std::env::temp_dir()
5387 .join("aptu-coder-overflow")
5388 .join(format!("slot-{slot}"));
5389 let stderr_file = base.join("stderr");
5390 assert!(
5391 stderr_file.exists(),
5392 "stderr slot file should exist after byte overflow"
5393 );
5394 }
5395
5396 #[test]
5397 fn test_exec_byte_overflow_combined_exceeds_50k() {
5398 let large_output = "z".repeat(60_000);
5401 assert!(large_output.len() > SIZE_LIMIT);
5402
5403 let mut combined_truncated = false;
5405 let truncated = if large_output.len() > SIZE_LIMIT {
5406 combined_truncated = true;
5407 let tail_start = large_output.len().saturating_sub(SIZE_LIMIT);
5408 let safe_start = large_output[..tail_start].floor_char_boundary(tail_start);
5409 large_output[safe_start..].to_string()
5410 } else {
5411 large_output.clone()
5412 };
5413
5414 assert!(combined_truncated, "combined_truncated should be true");
5415 assert!(
5416 truncated.len() <= SIZE_LIMIT,
5417 "output should be truncated to <= 50k"
5418 );
5419 }
5420
5421 #[test]
5422 fn test_exec_line_and_byte_interaction() {
5423 let lines: Vec<String> = (0..1500)
5426 .map(|i| {
5427 format!(
5428 "line {} with some padding to make it longer: {}",
5429 i,
5430 "x".repeat(15)
5431 )
5432 })
5433 .collect();
5434 let stdout = lines.join("\n");
5435 assert!(stdout.lines().count() <= 2000, "should have <= 2000 lines");
5436 assert!(stdout.len() > 30_000, "should exceed 30k bytes");
5437
5438 let stderr = "".to_string();
5439 let slot = 2u32;
5440
5441 let (out_stdout, _out_stderr, stdout_path, _stderr_path, byte_truncated) =
5442 handle_output_persist(stdout.clone(), stderr, slot);
5443
5444 assert!(byte_truncated, "byte_truncated should be true");
5446 assert!(stdout_path.is_some(), "stdout_path should be set");
5447 assert!(
5448 out_stdout.len() <= 30_000,
5449 "stdout should be truncated by byte cap"
5450 );
5451 }
5452
5453 #[test]
5454 fn test_exec_utf8_boundary_safety() {
5455 let mut stdout = String::new();
5458 for _ in 0..4000 {
5459 stdout.push_str("hello world ");
5460 }
5461 stdout.push_str("こんにちは"); assert!(stdout.len() > 30_000, "stdout should exceed 30k bytes");
5464
5465 let stderr = "".to_string();
5466 let slot = 5u32;
5467
5468 let (out_stdout, _out_stderr, _stdout_path, _stderr_path, byte_truncated) =
5469 handle_output_persist(stdout, stderr, slot);
5470
5471 assert!(byte_truncated, "byte_truncated should be true");
5473 assert!(
5474 out_stdout.is_char_boundary(0),
5475 "start should be char boundary"
5476 );
5477 assert!(
5478 out_stdout.is_char_boundary(out_stdout.len()),
5479 "end should be char boundary"
5480 );
5481 let _char_count = out_stdout.chars().count();
5483 }
5484
5485 #[test]
5486 fn test_filter_strip_lines_matching() {
5487 let rule = types::FilterRule {
5489 match_command: "^git\\s+pull".to_string(),
5490 description: Some("test filter".to_string()),
5491 strip_ansi: false,
5492 strip_lines_matching: vec!["^\\s*\\|\\s*\\d+\\s*[+-]+".to_string()],
5493 keep_lines_matching: vec![],
5494 max_lines: None,
5495 on_empty: None,
5496 };
5497
5498 let strip_patterns = vec![Regex::new("^\\s*\\|\\s*\\d+\\s*[+-]+").unwrap()];
5499 let compiled = CompiledRule {
5500 pattern: Regex::new("^git\\s+pull").unwrap(),
5501 strip_patterns,
5502 keep_patterns: vec![],
5503 rule,
5504 };
5505
5506 let stdout = "Updating abc123..def456\n | 5 ++++\n | 3 ---\nFast-forward\n";
5507 let filtered = apply_filter(&compiled, stdout);
5508
5509 assert!(!filtered.contains("| 5 ++++"), "should strip stat lines");
5510 assert!(!filtered.contains("| 3 ---"), "should strip stat lines");
5511 assert!(
5512 filtered.contains("Updating"),
5513 "should keep non-matching lines"
5514 );
5515 assert!(
5516 filtered.contains("Fast-forward"),
5517 "should keep non-matching lines"
5518 );
5519 }
5520
5521 #[test]
5522 fn test_filter_on_empty_substitution() {
5523 let rule = types::FilterRule {
5525 match_command: "^git\\s+fetch".to_string(),
5526 description: Some("test fetch".to_string()),
5527 strip_ansi: false,
5528 strip_lines_matching: vec!["^From ".to_string(), "^\\s+[a-f0-9]+\\.\\.".to_string()],
5529 keep_lines_matching: vec![],
5530 max_lines: None,
5531 on_empty: Some("ok fetched".to_string()),
5532 };
5533
5534 let strip_patterns = vec![
5535 Regex::new("^From ").unwrap(),
5536 Regex::new("^\\s+[a-f0-9]+\\.\\.").unwrap(),
5537 ];
5538 let compiled = CompiledRule {
5539 pattern: Regex::new("^git\\s+fetch").unwrap(),
5540 strip_patterns,
5541 keep_patterns: vec![],
5542 rule,
5543 };
5544
5545 let stdout = "From github.com:user/repo\n abc123..def456 main -> origin/main\n";
5546 let filtered = apply_filter(&compiled, stdout);
5547
5548 assert_eq!(
5549 filtered, "ok fetched",
5550 "should return on_empty when all lines stripped"
5551 );
5552 }
5553
5554 #[test]
5555 fn test_filter_passthrough_on_failure() {
5556 let rule = types::FilterRule {
5558 match_command: "^cargo\\s+build".to_string(),
5559 description: Some("cargo build filter".to_string()),
5560 strip_ansi: false,
5561 strip_lines_matching: vec!["^\\s*Compiling ".to_string()],
5562 keep_lines_matching: vec![],
5563 max_lines: None,
5564 on_empty: None,
5565 };
5566
5567 let strip_patterns = vec![Regex::new("^\\s*Compiling ").unwrap()];
5568 let compiled = CompiledRule {
5569 pattern: Regex::new("^cargo\\s+build").unwrap(),
5570 strip_patterns,
5571 keep_patterns: vec![],
5572 rule,
5573 };
5574
5575 let stdout = " Compiling mylib v0.1.0\nerror: failed to compile\n";
5576
5577 let mut output = ShellOutput::new(
5580 stdout.to_string(),
5581 "".to_string(),
5582 "".to_string(),
5583 Some(1), false,
5585 false,
5586 );
5587
5588 if output.exit_code == Some(0) && !output.timed_out {
5590 output.stdout = apply_filter(&compiled, &output.stdout);
5591 output.filter_applied = compiled
5592 .rule
5593 .description
5594 .clone()
5595 .or_else(|| Some(compiled.rule.match_command.clone()));
5596 }
5597
5598 assert!(
5599 output.filter_applied.is_none(),
5600 "filter_applied should be None when exit_code != Some(0)"
5601 );
5602 assert!(
5603 output.stdout.contains("Compiling"),
5604 "stdout should be unchanged when exit_code != Some(0)"
5605 );
5606
5607 let mut output2 = ShellOutput::new(
5610 stdout.to_string(),
5611 "".to_string(),
5612 "".to_string(),
5613 Some(0), false,
5615 false,
5616 );
5617
5618 if output2.exit_code == Some(0) && !output2.timed_out {
5619 output2.stdout = apply_filter(&compiled, &output2.stdout);
5620 output2.filter_applied = compiled
5621 .rule
5622 .description
5623 .clone()
5624 .or_else(|| Some(compiled.rule.match_command.clone()));
5625 }
5626
5627 assert!(
5628 output2.filter_applied.is_some(),
5629 "filter_applied should be set when exit_code == Some(0)"
5630 );
5631 assert_eq!(
5632 output2.filter_applied.as_ref().unwrap(),
5633 "cargo build filter"
5634 );
5635 assert!(
5636 !output2.stdout.contains("Compiling"),
5637 "stdout should be filtered when exit_code == Some(0)"
5638 );
5639 }
5640
5641 #[test]
5642 fn test_no_stat_injection() {
5643 let command = "git pull origin main";
5645 let result = maybe_inject_no_stat(command);
5646 assert_eq!(
5647 result, "git pull origin main --no-stat",
5648 "should inject --no-stat"
5649 );
5650 }
5651
5652 #[test]
5653 fn test_no_stat_not_injected_when_present() {
5654 let command = "git pull --stat origin main";
5656 let result = maybe_inject_no_stat(command);
5657 assert_eq!(result, command, "should not inject when --stat present");
5658
5659 let command2 = "git pull --no-stat origin main";
5660 let result2 = maybe_inject_no_stat(command2);
5661 assert_eq!(
5662 result2, command2,
5663 "should not inject when --no-stat present"
5664 );
5665
5666 let command3 = "git pull --verbose origin main";
5667 let result3 = maybe_inject_no_stat(command3);
5668 assert_eq!(
5669 result3, command3,
5670 "should not inject when --verbose present"
5671 );
5672 }
5673
5674 #[test]
5675 fn test_filter_applied_field_present() {
5676 let rule = types::FilterRule {
5678 match_command: "^git\\s+status".to_string(),
5679 description: Some("git status filter".to_string()),
5680 strip_ansi: false,
5681 strip_lines_matching: vec!["^On branch".to_string()],
5682 keep_lines_matching: vec![],
5683 max_lines: Some(20),
5684 on_empty: None,
5685 };
5686
5687 let strip_patterns = vec![Regex::new("^On branch").unwrap()];
5688 let compiled = CompiledRule {
5689 pattern: Regex::new("^git\\s+status").unwrap(),
5690 strip_patterns,
5691 keep_patterns: vec![],
5692 rule,
5693 };
5694
5695 let stdout = "On branch main\nnothing to commit\n";
5696
5697 let filtered = apply_filter(&compiled, stdout);
5699 assert!(
5700 !filtered.contains("On branch"),
5701 "apply_filter should strip matching lines"
5702 );
5703 assert!(
5704 filtered.contains("nothing to commit"),
5705 "apply_filter should keep non-matching lines"
5706 );
5707
5708 let mut output = ShellOutput::new(
5710 filtered,
5711 "".to_string(),
5712 "".to_string(),
5713 Some(0),
5714 false,
5715 false,
5716 );
5717
5718 output.filter_applied = compiled
5720 .rule
5721 .description
5722 .clone()
5723 .or_else(|| Some(compiled.rule.match_command.clone()));
5724
5725 assert!(
5726 output.filter_applied.is_some(),
5727 "filter_applied should be set when filter matches"
5728 );
5729 assert_eq!(output.filter_applied.as_ref().unwrap(), "git status filter");
5730 }
5731
5732 #[test]
5733 fn test_filter_keep_lines_matching() {
5734 let rule = types::FilterRule {
5736 match_command: "^cargo\\s+test".to_string(),
5737 description: Some("test keep filter".to_string()),
5738 strip_ansi: false,
5739 strip_lines_matching: vec![],
5740 keep_lines_matching: vec!["^test ".to_string(), "^FAILED".to_string()],
5741 max_lines: None,
5742 on_empty: None,
5743 };
5744 let compiled = filters::CompiledRule {
5745 pattern: Regex::new("^cargo\\s+test").unwrap(),
5746 strip_patterns: vec![],
5747 keep_patterns: vec![
5748 Regex::new("^test ").unwrap(),
5749 Regex::new("^FAILED").unwrap(),
5750 ],
5751 rule,
5752 };
5753
5754 let stdout = " Compiling mylib v0.1.0\ntest foo::bar ... ok\ntest foo::baz ... FAILED\ntest result: FAILED\n";
5755 let filtered = filters::apply_filter(&compiled, stdout);
5756
5757 assert!(filtered.contains("test foo::bar"), "should keep test lines");
5758 assert!(
5759 filtered.contains("test foo::baz"),
5760 "should keep FAILED test lines"
5761 );
5762 assert!(!filtered.contains("Compiling"), "should drop compile lines");
5763 }
5764
5765 #[test]
5766 fn test_filter_max_lines_cap() {
5767 let rule = types::FilterRule {
5769 match_command: "^git\\s+log".to_string(),
5770 description: Some("test max lines".to_string()),
5771 strip_ansi: false,
5772 strip_lines_matching: vec![],
5773 keep_lines_matching: vec![],
5774 max_lines: Some(3),
5775 on_empty: None,
5776 };
5777 let compiled = filters::CompiledRule {
5778 pattern: Regex::new("^git\\s+log").unwrap(),
5779 strip_patterns: vec![],
5780 keep_patterns: vec![],
5781 rule,
5782 };
5783
5784 let stdout = "line1\nline2\nline3\nline4\nline5\n";
5785 let filtered = filters::apply_filter(&compiled, stdout);
5786
5787 assert_eq!(filtered.lines().count(), 3, "should cap at 3 lines");
5788 assert!(filtered.contains("line1"));
5789 assert!(filtered.contains("line3"));
5790 assert!(
5791 !filtered.contains("line4"),
5792 "should not include lines beyond max"
5793 );
5794 }
5795
5796 #[test]
5797 fn test_filter_git_show_strips_patch_hunks() {
5798 let compiled = filters::CompiledRule {
5800 pattern: Regex::new("^git\\s+show").unwrap(),
5801 strip_patterns: vec![
5802 Regex::new("^@@").unwrap(),
5803 Regex::new("^[+-][^+-]").unwrap(),
5804 ],
5805 keep_patterns: vec![],
5806 rule: types::FilterRule {
5807 match_command: "^git\\s+show".to_string(),
5808 description: None,
5809 strip_ansi: true,
5810 strip_lines_matching: vec!["^@@".to_string(), "^[+-][^+-]".to_string()],
5811 keep_lines_matching: vec![],
5812 max_lines: Some(200),
5813 on_empty: None,
5814 },
5815 };
5816
5817 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";
5818 let filtered = filters::apply_filter(&compiled, stdout);
5819
5820 assert!(
5821 filtered.contains("--- a/src/lib.rs"),
5822 "should keep --- file header"
5823 );
5824 assert!(
5825 filtered.contains("+++ b/src/lib.rs"),
5826 "should keep +++ file header"
5827 );
5828 assert!(!filtered.contains("@@ -1,3"), "should strip hunk headers");
5829 assert!(
5830 !filtered.contains("-old line"),
5831 "should strip removed lines"
5832 );
5833 assert!(!filtered.contains("+new line"), "should strip added lines");
5834 }
5835
5836 #[test]
5837 fn test_filter_on_empty_from_empty_input() {
5838 let compiled = filters::CompiledRule {
5841 pattern: Regex::new("^git\\s+diff").unwrap(),
5842 strip_patterns: vec![],
5843 keep_patterns: vec![],
5844 rule: types::FilterRule {
5845 match_command: "^git\\s+diff".to_string(),
5846 description: None,
5847 strip_ansi: true,
5848 strip_lines_matching: vec![],
5849 keep_lines_matching: vec![],
5850 max_lines: Some(100),
5851 on_empty: Some("ok (working tree clean)".to_string()),
5852 },
5853 };
5854
5855 assert_eq!(
5856 filters::apply_filter(&compiled, ""),
5857 "ok (working tree clean)",
5858 "on_empty should fire on empty input"
5859 );
5860 }
5861
5862 #[test]
5863 fn test_filter_applied_to_interleaved_with_both_streams() {
5864 let compiled = filters::CompiledRule {
5867 pattern: Regex::new("^git\\s+pull").unwrap(),
5868 strip_patterns: vec![Regex::new("^\\s*\\|\\s*\\d+\\s*[+\\-]+").unwrap()],
5869 keep_patterns: vec![],
5870 rule: types::FilterRule {
5871 match_command: "^git\\s+pull".to_string(),
5872 description: None,
5873 strip_ansi: false,
5874 strip_lines_matching: vec!["^\\s*\\|\\s*\\d+\\s*[+\\-]+".to_string()],
5875 keep_lines_matching: vec![],
5876 max_lines: None,
5877 on_empty: None,
5878 },
5879 };
5880
5881 let interleaved = " | 42 ++++++++++++\nFrom https://github.com/example/repo\n";
5883
5884 let result = filters::apply_filter(&compiled, interleaved);
5886
5887 assert!(
5889 !result.contains("| 42"),
5890 "strip-matched line should be absent from filtered interleaved"
5891 );
5892 assert!(
5893 result.contains("From https://github.com/example/repo"),
5894 "stderr-origin line should be preserved in filtered interleaved"
5895 );
5896 }
5897
5898 #[test]
5899 fn test_on_empty_substitution_in_interleaved() {
5900 let compiled = filters::CompiledRule {
5902 pattern: Regex::new("^git\\s+pull").unwrap(),
5903 strip_patterns: vec![Regex::new(".*").unwrap()],
5904 keep_patterns: vec![],
5905 rule: types::FilterRule {
5906 match_command: "^git\\s+pull".to_string(),
5907 description: None,
5908 strip_ansi: false,
5909 strip_lines_matching: vec![".*".to_string()],
5910 keep_lines_matching: vec![],
5911 max_lines: None,
5912 on_empty: Some("ok (up-to-date)".to_string()),
5913 },
5914 };
5915
5916 let interleaved = "Already up to date.\nFrom https://github.com/example/repo\n";
5918
5919 let result = filters::apply_filter(&compiled, interleaved);
5921
5922 assert_eq!(
5924 result, "ok (up-to-date)",
5925 "on_empty should be returned when filter strips all lines in interleaved"
5926 );
5927 }
5928
5929 #[test]
5930 fn test_line_cap_fires_before_byte_cap() {
5931 let line = "abcde";
5934 let stdout: String = std::iter::repeat(format!("{}\n", line))
5935 .take(2500)
5936 .collect();
5937 assert_eq!(stdout.lines().count(), 2500, "should have 2500 lines");
5938 assert!(stdout.len() < 30_000, "should be under byte cap");
5939
5940 let stderr = String::new();
5941 let slot = 42u32;
5942
5943 let (out_stdout, _out_stderr, stdout_path, _stderr_path, byte_truncated) =
5944 handle_output_persist(stdout, stderr, slot);
5945
5946 assert!(
5948 !byte_truncated,
5949 "byte cap should NOT fire (under 30k bytes)"
5950 );
5951 assert!(
5952 stdout_path.is_some(),
5953 "stdout_path should be set when line cap fires"
5954 );
5955 let line_count = out_stdout.lines().count();
5957 assert!(
5958 line_count <= 50,
5959 "returned content should have at most 50 lines, got {}",
5960 line_count
5961 );
5962 assert!(line_count > 0, "returned content should not be empty");
5963 }
5964
5965 #[test]
5966 fn test_project_local_overrides_builtin() {
5967 use std::io::Write;
5971
5972 let tmp = std::env::temp_dir().join(format!(
5973 "aptu-test-project-local-{}",
5974 std::time::SystemTime::now()
5975 .duration_since(std::time::UNIX_EPOCH)
5976 .map(|d| d.as_nanos())
5977 .unwrap_or(0)
5978 ));
5979 let aptu_dir = tmp.join(".aptu");
5980 std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
5981
5982 let toml_content = "schema_version = 1\n[[filters]]\nmatch_command = \"^my-custom-tool\"\nkeep_lines_matching = []\non_empty = \"project-local-only-marker\"\n";
5984 let mut f = std::fs::File::create(aptu_dir.join("filters.toml"))
5985 .expect("should create filters.toml");
5986 f.write_all(toml_content.as_bytes())
5987 .expect("should write toml");
5988 drop(f);
5989
5990 let rules = filters::load_filter_table(&tmp);
5991
5992 let first_rule = rules.first().expect("should have at least one rule");
5994 assert!(
5995 first_rule.pattern.is_match("my-custom-tool --flag"),
5996 "project-local rule should be first (index 0)"
5997 );
5998 assert_eq!(
5999 first_rule.rule.on_empty.as_deref(),
6000 Some("project-local-only-marker"),
6001 "project-local rule on_empty should match what was written"
6002 );
6003
6004 let has_git_pull = rules
6006 .iter()
6007 .any(|r| r.pattern.is_match("git pull origin main"));
6008 assert!(
6009 has_git_pull,
6010 "built-in git pull rule should still be present"
6011 );
6012
6013 let _ = std::fs::remove_dir_all(&tmp);
6015 }
6016
6017 #[test]
6018 fn test_invalid_toml_falls_back_gracefully() {
6019 use std::io::Write;
6021
6022 let tmp = std::env::temp_dir().join(format!(
6023 "aptu-test-invalid-toml-{}",
6024 std::time::SystemTime::now()
6025 .duration_since(std::time::UNIX_EPOCH)
6026 .map(|d| d.as_nanos())
6027 .unwrap_or(0)
6028 ));
6029 let aptu_dir = tmp.join(".aptu");
6030 std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
6031
6032 let mut f = std::fs::File::create(aptu_dir.join("filters.toml"))
6033 .expect("should create filters.toml");
6034 f.write_all(b"schema_version = INVALID_VALUE {{{{")
6038 .expect("should write garbage");
6039 drop(f);
6040
6041 let rules = filters::load_filter_table(&tmp);
6043
6044 let has_git_pull = rules
6046 .iter()
6047 .any(|r| r.pattern.is_match("git pull origin main"));
6048 assert!(
6049 has_git_pull,
6050 "should have git pull built-in rule after invalid TOML"
6051 );
6052
6053 let _ = std::fs::remove_dir_all(&tmp);
6055 }
6056
6057 #[test]
6058 fn test_invalid_schema_version_falls_back_gracefully() {
6059 use std::io::Write;
6061
6062 let tmp = std::env::temp_dir().join(format!(
6063 "aptu-test-schema-version-{}",
6064 std::time::SystemTime::now()
6065 .duration_since(std::time::UNIX_EPOCH)
6066 .map(|d| d.as_nanos())
6067 .unwrap_or(0)
6068 ));
6069 let aptu_dir = tmp.join(".aptu");
6070 std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
6071
6072 let toml_content = "schema_version = 2\n[[filters]]\nmatch_command = \"^my-v2-tool\"\nkeep_lines_matching = []\n";
6074 let mut f = std::fs::File::create(aptu_dir.join("filters.toml"))
6075 .expect("should create filters.toml");
6076 f.write_all(toml_content.as_bytes())
6077 .expect("should write toml");
6078 drop(f);
6079
6080 let rules = filters::load_filter_table(&tmp);
6082
6083 let has_git_pull = rules
6085 .iter()
6086 .any(|r| r.pattern.is_match("git pull origin main"));
6087 assert!(
6088 has_git_pull,
6089 "should have git pull built-in rule after schema_version=2 rejection"
6090 );
6091
6092 let has_v2_rule = rules
6094 .iter()
6095 .any(|r| r.pattern.is_match("my-v2-tool --flag"));
6096 assert!(
6097 !has_v2_rule,
6098 "schema_version=2 rule should not be loaded; only built-ins expected"
6099 );
6100
6101 let _ = std::fs::remove_dir_all(&tmp);
6103 }
6104
6105 #[test]
6106 fn test_metric_chars_threshold_breach_fires() {
6107 let output_chars: usize = 35_000;
6109 let event = crate::metrics::MetricEvent {
6110 ts: 0,
6111 tool: "exec_command",
6112 duration_ms: 1,
6113 output_chars,
6114 param_path_depth: 0,
6115 max_depth: None,
6116 result: "ok",
6117 error_type: None,
6118 error_subtype: None,
6119 session_id: None,
6120 seq: None,
6121 cache_hit: None,
6122 cache_write_failure: None,
6123 cache_tier: None,
6124 exit_code: None,
6125 timed_out: false,
6126 output_truncated: None,
6127 chars_threshold_breach: output_chars > 30_000,
6128 file_ext: None,
6129 filter_applied: None,
6130 };
6131 assert!(
6132 event.chars_threshold_breach,
6133 "chars_threshold_breach should be true for output_chars=35000"
6134 );
6135 }
6136
6137 #[test]
6138 fn test_metric_chars_threshold_breach_no_fire() {
6139 let output_chars: usize = 5_000;
6141 let event = crate::metrics::MetricEvent {
6142 ts: 0,
6143 tool: "exec_command",
6144 duration_ms: 1,
6145 output_chars,
6146 param_path_depth: 0,
6147 max_depth: None,
6148 result: "ok",
6149 error_type: None,
6150 error_subtype: None,
6151 session_id: None,
6152 seq: None,
6153 cache_hit: None,
6154 cache_write_failure: None,
6155 cache_tier: None,
6156 exit_code: None,
6157 timed_out: false,
6158 output_truncated: None,
6159 chars_threshold_breach: output_chars > 30_000,
6160 file_ext: None,
6161 filter_applied: None,
6162 };
6163 assert!(
6164 !event.chars_threshold_breach,
6165 "chars_threshold_breach should be false for output_chars=5000"
6166 );
6167 }
6168
6169 #[tokio::test]
6175 async fn test_progress_bypassed_when_no_token() {
6176 use tempfile::TempDir;
6177
6178 let dir = TempDir::new().unwrap();
6179 std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
6180 let analyzer = make_analyzer();
6181 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
6182 "path": dir.path().to_str().unwrap(),
6183 }))
6184 .unwrap();
6185 let ct = tokio_util::sync::CancellationToken::new();
6186
6187 let result = analyzer.handle_overview_mode(¶ms, ct, None).await;
6189 assert!(
6190 result.is_ok(),
6191 "handle_overview_mode with None token must succeed"
6192 );
6193 }
6194
6195 #[test]
6198 fn test_strip_cd_prefix_basic() {
6199 let (cmd, path) = strip_cd_prefix("cd /tmp && echo hello");
6200 assert_eq!(cmd, "echo hello");
6201 assert_eq!(path, Some("/tmp"));
6202 }
6203
6204 #[test]
6205 fn test_strip_cd_prefix_no_ampersand() {
6206 let (cmd, path) = strip_cd_prefix("cd /tmp");
6208 assert_eq!(cmd, "cd /tmp");
6209 assert_eq!(path, None);
6210 }
6211
6212 #[test]
6213 fn test_strip_cd_prefix_with_extra_spaces() {
6214 let (cmd, path) = strip_cd_prefix("cd /tmp && echo hello");
6216 assert_eq!(path, Some("/tmp"));
6217 assert_eq!(cmd, "echo hello");
6218 }
6219
6220 #[test]
6221 fn test_strip_cd_prefix_splits_on_first_ampersand_only() {
6222 let (cmd, path) = strip_cd_prefix("cd /a && cmd1 && cd /b && cmd2");
6224 assert_eq!(path, Some("/a"));
6225 assert_eq!(cmd, "cmd1 && cd /b && cmd2");
6226 }
6227}