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