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