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