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