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