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