1#![cfg_attr(test, allow(clippy::unwrap_used))]
31
32pub mod logging;
33pub mod metrics;
34pub mod otel;
35
36pub use aptu_coder_core::analyze;
37use aptu_coder_core::types::STDIN_MAX_BYTES;
38use aptu_coder_core::{cache, completion, graph, traversal, types};
39
40pub(crate) const EXCLUDED_DIRS: &[&str] = &[
41 "node_modules",
42 "vendor",
43 ".git",
44 "__pycache__",
45 "target",
46 "dist",
47 "build",
48 ".venv",
49];
50
51use aptu_coder_core::cache::{AnalysisCache, CacheTier};
52use aptu_coder_core::formatter::{
53 format_file_details_paginated, format_file_details_summary, format_focused_paginated,
54 format_module_info, format_structure_paginated, format_summary,
55};
56use aptu_coder_core::formatter_defuse::format_focused_paginated_defuse;
57use aptu_coder_core::pagination::{
58 CursorData, DEFAULT_PAGE_SIZE, PaginationMode, decode_cursor, encode_cursor, paginate_slice,
59};
60use aptu_coder_core::traversal::{
61 WalkEntry, changed_files_from_git_ref, filter_entries_by_git_ref, walk_directory,
62};
63use aptu_coder_core::types::{
64 AnalysisMode, AnalyzeDirectoryParams, AnalyzeFileParams, AnalyzeModuleParams,
65 AnalyzeSymbolParams, EditOverwriteOutput, EditOverwriteParams, EditReplaceOutput,
66 EditReplaceParams, SymbolMatchMode,
67};
68use logging::LogEvent;
69use rmcp::handler::server::tool::{ToolRouter, schema_for_type};
70use rmcp::handler::server::wrapper::Parameters;
71use rmcp::model::{
72 CallToolResult, CancelledNotificationParam, CompleteRequestParams, CompleteResult,
73 CompletionInfo, Content, ErrorData, Implementation, InitializeRequestParams, InitializeResult,
74 LoggingLevel, LoggingMessageNotificationParam, Meta, Notification, NumberOrString,
75 ProgressNotificationParam, ProgressToken, ServerCapabilities, ServerNotification,
76 SetLevelRequestParams,
77};
78use rmcp::service::{NotificationContext, RequestContext};
79use rmcp::{Peer, RoleServer, ServerHandler, tool, tool_handler, tool_router};
80use serde_json::Value;
81use std::path::{Path, PathBuf};
82use std::sync::{Arc, Mutex};
83use tokio::sync::{Mutex as TokioMutex, RwLock, mpsc};
84use tracing::{instrument, warn};
85use tracing_subscriber::filter::LevelFilter;
86
87#[cfg(unix)]
88use nix::sys::resource::{Resource, setrlimit};
89
90static GLOBAL_SESSION_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
91
92const SIZE_LIMIT: usize = 50_000;
93
94#[must_use]
97pub fn summary_cursor_conflict(summary: Option<bool>, cursor: Option<&str>) -> bool {
98 summary == Some(true) && cursor.is_some()
99}
100
101pub struct ClientMetadata {
103 pub session_id: Option<String>,
104 pub client_name: Option<String>,
105 pub client_version: Option<String>,
106}
107
108pub fn extract_and_set_trace_context(
116 meta: Option<&rmcp::model::Meta>,
117 client_meta: ClientMetadata,
118) {
119 use tracing_opentelemetry::OpenTelemetrySpanExt as _;
120
121 let span = tracing::Span::current();
122
123 if let Some(sid) = client_meta.session_id {
125 span.record("mcp.session.id", &sid);
126 }
127 if let Some(cn) = client_meta.client_name {
128 span.record("client.name", &cn);
129 }
130 if let Some(cv) = client_meta.client_version {
131 span.record("client.version", &cv);
132 }
133
134 if let Some(asi_str) = meta.and_then(|m| m.0.get("agent-session-id").and_then(|v| v.as_str())) {
136 span.record("mcp.client.session.id", asi_str);
137 }
138
139 let Some(meta) = meta else { return };
140
141 let mut propagation_map = std::collections::HashMap::new();
142
143 if let Some(traceparent) = meta.0.get("traceparent")
145 && let Some(tp_str) = traceparent.as_str()
146 {
147 propagation_map.insert("traceparent".to_string(), tp_str.to_string());
148 }
149
150 if let Some(tracestate) = meta.0.get("tracestate")
152 && let Some(ts_str) = tracestate.as_str()
153 {
154 propagation_map.insert("tracestate".to_string(), ts_str.to_string());
155 }
156
157 if propagation_map.is_empty() {
159 return;
160 }
161
162 let parent_cx = opentelemetry::global::get_text_map_propagator(|propagator| {
164 propagator.extract(&ExtractMap(&propagation_map))
165 });
166
167 let _ = span.set_parent(parent_cx);
170}
171
172struct ExtractMap<'a>(&'a std::collections::HashMap<String, String>);
174
175impl<'a> opentelemetry::propagation::Extractor for ExtractMap<'a> {
176 fn get(&self, key: &str) -> Option<&str> {
177 self.0.get(key).map(|s| s.as_str())
178 }
179
180 fn keys(&self) -> Vec<&str> {
181 self.0.keys().map(|k| k.as_str()).collect()
182 }
183}
184
185#[must_use]
186fn error_meta(
187 category: &'static str,
188 is_retryable: bool,
189 suggested_action: &'static str,
190) -> serde_json::Value {
191 serde_json::json!({
192 "errorCategory": category,
193 "isRetryable": is_retryable,
194 "suggestedAction": suggested_action,
195 })
196}
197
198#[must_use]
199fn err_to_tool_result(e: ErrorData) -> CallToolResult {
200 CallToolResult::error(vec![Content::text(e.message)])
201}
202
203fn err_to_tool_result_from_pagination(
204 e: aptu_coder_core::pagination::PaginationError,
205) -> CallToolResult {
206 let msg = format!("Pagination error: {}", e);
207 CallToolResult::error(vec![Content::text(msg)])
208}
209
210fn no_cache_meta() -> Meta {
211 let mut m = serde_json::Map::new();
212 m.insert(
213 "cache_hint".to_string(),
214 serde_json::Value::String("no-cache".to_string()),
215 );
216 Meta(m)
217}
218
219fn validate_path(path: &str, require_exists: bool) -> Result<std::path::PathBuf, ErrorData> {
223 let allowed_root = std::fs::canonicalize(std::env::current_dir().map_err(|_| {
225 ErrorData::new(
226 rmcp::model::ErrorCode::INVALID_PARAMS,
227 "path is outside the allowed root".to_string(),
228 Some(error_meta(
229 "validation",
230 false,
231 "ensure the working directory is accessible",
232 )),
233 )
234 })?)
235 .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
236
237 let canonical_path = if require_exists {
238 std::fs::canonicalize(path).map_err(|e| {
239 let msg = match e.kind() {
240 std::io::ErrorKind::NotFound => format!("path not found: {path}"),
241 std::io::ErrorKind::PermissionDenied => format!("permission denied: {path}"),
242 _ => "path is outside the allowed root".to_string(),
243 };
244 ErrorData::new(
245 rmcp::model::ErrorCode::INVALID_PARAMS,
246 msg,
247 Some(error_meta(
248 "validation",
249 false,
250 "provide a valid path within the working directory",
251 )),
252 )
253 })?
254 } else {
255 let p = std::path::Path::new(path);
257 let mut ancestor = p.to_path_buf();
258 let mut suffix = std::path::PathBuf::new();
259
260 loop {
261 if ancestor.exists() {
262 break;
263 }
264 if let Some(parent) = ancestor.parent() {
265 if let Some(file_name) = ancestor.file_name() {
266 suffix = std::path::PathBuf::from(file_name).join(&suffix);
267 }
268 ancestor = parent.to_path_buf();
269 } else {
270 ancestor = allowed_root.clone();
272 break;
273 }
274 }
275
276 let canonical_base =
277 std::fs::canonicalize(&ancestor).unwrap_or_else(|_| allowed_root.clone());
278 canonical_base.join(&suffix)
279 };
280
281 if !canonical_path.starts_with(&allowed_root) {
282 return Err(ErrorData::new(
283 rmcp::model::ErrorCode::INVALID_PARAMS,
284 "path is outside the allowed root".to_string(),
285 Some(error_meta(
286 "validation",
287 false,
288 "provide a path within the current working directory",
289 )),
290 ));
291 }
292
293 Ok(canonical_path)
294}
295
296fn io_error_to_path_error(
298 err: &std::io::Error,
299 path_context: &str,
300 suggested_action: &'static str,
301) -> ErrorData {
302 let msg = match err.kind() {
303 std::io::ErrorKind::NotFound => format!("{path_context} not found"),
304 std::io::ErrorKind::PermissionDenied => format!("permission denied: {path_context}"),
305 _ => format!("{path_context} is invalid"),
306 };
307 let mut meta = error_meta("validation", false, suggested_action);
308 if let Some(obj) = meta.as_object_mut() {
310 obj.insert(
311 "ioErrorKind".to_string(),
312 serde_json::json!(format!("{:?}", err.kind())),
313 );
314 obj.insert(
315 "ioErrorSource".to_string(),
316 serde_json::json!(err.to_string()),
317 );
318 }
319 ErrorData::new(rmcp::model::ErrorCode::INVALID_PARAMS, msg, Some(meta))
320}
321
322fn validate_path_in_dir(
326 path: &str,
327 require_exists: bool,
328 working_dir: &std::path::Path,
329) -> Result<std::path::PathBuf, ErrorData> {
330 let canonical_working_dir = std::fs::canonicalize(working_dir).map_err(|e| {
332 io_error_to_path_error(&e, "working_dir", "provide a valid working directory")
333 })?;
334
335 if !std::fs::metadata(&canonical_working_dir)
337 .map(|m| m.is_dir())
338 .unwrap_or(false)
339 {
340 return Err(ErrorData::new(
341 rmcp::model::ErrorCode::INVALID_PARAMS,
342 "working_dir must be a directory".to_string(),
343 Some(error_meta(
344 "validation",
345 false,
346 "provide a valid directory path",
347 )),
348 ));
349 }
350
351 let allowed_root = std::fs::canonicalize(std::env::current_dir().map_err(|_| {
353 ErrorData::new(
354 rmcp::model::ErrorCode::INVALID_PARAMS,
355 "path is outside the allowed root".to_string(),
356 Some(error_meta(
357 "validation",
358 false,
359 "ensure the working directory is accessible",
360 )),
361 )
362 })?)
363 .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
364
365 if !canonical_working_dir.starts_with(&allowed_root) {
366 return Err(ErrorData::new(
367 rmcp::model::ErrorCode::INVALID_PARAMS,
368 "working_dir is outside the allowed root".to_string(),
369 Some(error_meta(
370 "validation",
371 false,
372 "provide a working directory within the current working directory",
373 )),
374 ));
375 }
376
377 let canonical_path = if require_exists {
379 let target_path = canonical_working_dir.join(path);
380 std::fs::canonicalize(&target_path).map_err(|e| {
381 io_error_to_path_error(
382 &e,
383 path,
384 "provide a valid path within the working directory",
385 )
386 })?
387 } else {
388 let p = std::path::Path::new(path);
390 let mut ancestor = p.to_path_buf();
391 let mut suffix = std::path::PathBuf::new();
392
393 loop {
394 let full_path = canonical_working_dir.join(&ancestor);
395 if full_path.exists() {
396 break;
397 }
398 if let Some(parent) = ancestor.parent() {
399 if let Some(file_name) = ancestor.file_name() {
400 suffix = std::path::PathBuf::from(file_name).join(&suffix);
401 }
402 ancestor = parent.to_path_buf();
403 } else {
404 ancestor = std::path::PathBuf::new();
406 break;
407 }
408 }
409
410 let canonical_base = canonical_working_dir.join(&ancestor);
411 let canonical_base =
412 std::fs::canonicalize(&canonical_base).unwrap_or(canonical_working_dir.clone());
413 canonical_base.join(&suffix)
414 };
415
416 if !canonical_path.starts_with(&canonical_working_dir) {
424 return Err(ErrorData::new(
425 rmcp::model::ErrorCode::INVALID_PARAMS,
426 "path is outside the working directory".to_string(),
427 Some(error_meta(
428 "validation",
429 false,
430 "provide a path within the working directory",
431 )),
432 ));
433 }
434
435 Ok(canonical_path)
436}
437
438fn paginate_focus_chains(
441 chains: &[graph::InternalCallChain],
442 mode: PaginationMode,
443 offset: usize,
444 page_size: usize,
445) -> Result<(Vec<graph::InternalCallChain>, Option<String>), ErrorData> {
446 let paginated = paginate_slice(chains, offset, page_size, mode).map_err(|e| {
447 ErrorData::new(
448 rmcp::model::ErrorCode::INTERNAL_ERROR,
449 e.to_string(),
450 Some(error_meta("transient", true, "retry the request")),
451 )
452 })?;
453
454 if paginated.next_cursor.is_none() && offset == 0 {
455 return Ok((paginated.items, None));
456 }
457
458 let next = if let Some(raw_cursor) = paginated.next_cursor {
459 let decoded = decode_cursor(&raw_cursor).map_err(|e| {
460 ErrorData::new(
461 rmcp::model::ErrorCode::INVALID_PARAMS,
462 e.to_string(),
463 Some(error_meta("validation", false, "invalid cursor format")),
464 )
465 })?;
466 Some(
467 encode_cursor(&CursorData {
468 mode,
469 offset: decoded.offset,
470 })
471 .map_err(|e| {
472 ErrorData::new(
473 rmcp::model::ErrorCode::INVALID_PARAMS,
474 e.to_string(),
475 Some(error_meta("validation", false, "invalid cursor format")),
476 )
477 })?,
478 )
479 } else {
480 None
481 };
482
483 Ok((paginated.items, next))
484}
485
486fn resolve_shell() -> String {
490 if let Ok(shell) = std::env::var("APTU_SHELL") {
491 return shell;
492 }
493 #[cfg(unix)]
494 {
495 if which::which("bash").is_ok() {
496 return "bash".to_string();
497 }
498 "/bin/sh".to_string()
499 }
500 #[cfg(not(unix))]
501 {
502 "cmd".to_string()
503 }
504}
505
506#[derive(Clone)]
511pub struct CodeAnalyzer {
512 #[allow(dead_code)]
520 pub(crate) tool_router: Arc<RwLock<ToolRouter<Self>>>,
521 cache: AnalysisCache,
522 disk_cache: std::sync::Arc<cache::DiskCache>,
523 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
524 log_level_filter: Arc<Mutex<LevelFilter>>,
525 event_rx: Arc<TokioMutex<Option<mpsc::UnboundedReceiver<LogEvent>>>>,
526 metrics_tx: crate::metrics::MetricsSender,
527 session_call_seq: Arc<std::sync::atomic::AtomicU32>,
528 session_id: Arc<TokioMutex<Option<String>>>,
529 profile_meta: Arc<TokioMutex<Option<serde_json::Map<String, serde_json::Value>>>>,
531 client_name: Arc<TokioMutex<Option<String>>>,
532 client_version: Arc<TokioMutex<Option<String>>>,
533 resolved_path: Arc<Option<String>>,
536}
537
538#[tool_router]
539impl CodeAnalyzer {
540 #[must_use]
541 pub fn list_tools() -> Vec<rmcp::model::Tool> {
542 Self::tool_router().list_all()
543 }
544
545 pub fn new(
546 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
547 log_level_filter: Arc<Mutex<LevelFilter>>,
548 event_rx: mpsc::UnboundedReceiver<LogEvent>,
549 metrics_tx: crate::metrics::MetricsSender,
550 ) -> Self {
551 let file_cap: usize = std::env::var("APTU_CODER_FILE_CACHE_CAPACITY")
552 .ok()
553 .and_then(|v| v.parse().ok())
554 .unwrap_or(100);
555
556 let xdg_data_home = if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
558 && !xdg_data_home.is_empty()
559 {
560 std::path::PathBuf::from(xdg_data_home)
561 } else if let Ok(home) = std::env::var("HOME") {
562 std::path::PathBuf::from(home).join(".local").join("share")
563 } else {
564 std::path::PathBuf::from(".")
565 };
566 let disk_cache_disabled = std::env::var("APTU_CODER_DISK_CACHE_DISABLED")
567 .map(|v| v == "1")
568 .unwrap_or(false);
569 let disk_cache_dir = std::env::var("APTU_CODER_DISK_CACHE_DIR")
570 .map(std::path::PathBuf::from)
571 .unwrap_or_else(|_| xdg_data_home.join("aptu-coder").join("analysis-cache"));
572 let disk_cache =
573 std::sync::Arc::new(cache::DiskCache::new(disk_cache_dir, disk_cache_disabled));
574
575 let resolved_path = {
584 let snapshot_shell = std::env::var("SHELL")
585 .ok()
586 .filter(|s| !s.is_empty())
587 .unwrap_or_else(|| {
588 let s = resolve_shell();
589 if s.is_empty() {
590 "/bin/sh".to_string()
591 } else {
592 s
593 }
594 });
595 let login_path = match std::process::Command::new(&snapshot_shell)
596 .args(["-l", "-c", "echo $PATH"])
597 .output()
598 {
599 Ok(output) => {
600 let path_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
601 if path_str.is_empty() {
602 tracing::warn!(
603 shell = %snapshot_shell,
604 "login shell PATH snapshot returned empty string"
605 );
606 None
607 } else {
608 Some(path_str)
609 }
610 }
611 Err(e) => {
612 tracing::warn!(
613 shell = %snapshot_shell,
614 error = %e,
615 "failed to snapshot login shell PATH"
616 );
617 None
618 }
619 };
620 let path = login_path.or_else(|| std::env::var("PATH").ok());
622 Arc::new(path)
623 };
624
625 CodeAnalyzer {
626 tool_router: Arc::new(RwLock::new(Self::tool_router())),
627 cache: AnalysisCache::new(file_cap),
628 disk_cache,
629 peer,
630 log_level_filter,
631 event_rx: Arc::new(TokioMutex::new(Some(event_rx))),
632 metrics_tx,
633 session_call_seq: Arc::new(std::sync::atomic::AtomicU32::new(0)),
634 session_id: Arc::new(TokioMutex::new(None)),
635 profile_meta: Arc::new(TokioMutex::new(None)),
636 client_name: Arc::new(TokioMutex::new(None)),
637 client_version: Arc::new(TokioMutex::new(None)),
638 resolved_path,
639 }
640 }
641
642 #[instrument(skip(self))]
643 async fn emit_progress(
644 &self,
645 peer: Option<Peer<RoleServer>>,
646 token: &ProgressToken,
647 progress: f64,
648 total: f64,
649 message: String,
650 ) {
651 if let Some(peer) = peer {
652 let notification = ServerNotification::ProgressNotification(Notification::new(
653 ProgressNotificationParam {
654 progress_token: token.clone(),
655 progress,
656 total: Some(total),
657 message: Some(message),
658 },
659 ));
660 if let Err(e) = peer.send_notification(notification).await {
661 warn!("Failed to send progress notification: {}", e);
662 }
663 }
664 }
665
666 #[allow(clippy::too_many_lines)] #[allow(clippy::cast_precision_loss)] #[instrument(skip(self, params, ct))]
672 async fn handle_overview_mode(
673 &self,
674 params: &AnalyzeDirectoryParams,
675 ct: tokio_util::sync::CancellationToken,
676 ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, CacheTier), ErrorData> {
677 let path = Path::new(¶ms.path);
678 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
679 let counter_clone = counter.clone();
680 let path_owned = path.to_path_buf();
681 let max_depth = params.max_depth;
682 let ct_clone = ct.clone();
683
684 let all_entries = walk_directory(path, None).map_err(|e| {
686 ErrorData::new(
687 rmcp::model::ErrorCode::INTERNAL_ERROR,
688 format!("Failed to walk directory: {e}"),
689 Some(error_meta(
690 "resource",
691 false,
692 "check path permissions and availability",
693 )),
694 )
695 })?;
696
697 let canonical_max_depth = max_depth.and_then(|d| if d == 0 { None } else { Some(d) });
699
700 let git_ref_val = params.git_ref.as_deref().filter(|s| !s.is_empty());
703 let cache_key = cache::DirectoryCacheKey::from_entries(
704 &all_entries,
705 canonical_max_depth,
706 AnalysisMode::Overview,
707 git_ref_val,
708 );
709
710 if let Some(cached) = self.cache.get_directory(&cache_key) {
712 tracing::debug!(cache_hit = true, message = "returning cached result");
713 return Ok((cached, CacheTier::L1Memory));
714 }
715
716 let root = std::path::Path::new(¶ms.path);
718 let disk_key = {
719 let mut hasher = blake3::Hasher::new();
720 let mut sorted_entries: Vec<_> = all_entries.iter().collect();
721 sorted_entries.sort_by(|a, b| a.path.cmp(&b.path));
722 for entry in &sorted_entries {
723 let rel = entry.path.strip_prefix(root).unwrap_or(&entry.path);
724 hasher.update(rel.as_os_str().to_string_lossy().as_bytes());
725 let mtime_secs = entry
726 .mtime
727 .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
728 .map(|d| d.as_secs())
729 .unwrap_or(0);
730 hasher.update(&mtime_secs.to_le_bytes());
731 }
732 if let Some(depth) = canonical_max_depth {
733 hasher.update(depth.to_string().as_bytes());
734 }
735 if let Some(ref git_ref) = params.git_ref {
736 hasher.update(git_ref.as_bytes());
737 }
738 hasher.finalize()
739 };
740
741 if let Some(cached) = self
743 .disk_cache
744 .get::<analyze::AnalysisOutput>("analyze_directory", &disk_key)
745 {
746 let arc = std::sync::Arc::new(cached);
747 self.cache.put_directory(cache_key.clone(), arc.clone());
748 return Ok((arc, CacheTier::L2Disk));
749 }
750
751 let all_entries = if let Some(ref git_ref) = params.git_ref
753 && !git_ref.is_empty()
754 {
755 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
756 ErrorData::new(
757 rmcp::model::ErrorCode::INVALID_PARAMS,
758 format!("git_ref filter failed: {e}"),
759 Some(error_meta(
760 "resource",
761 false,
762 "ensure git is installed and path is inside a git repository",
763 )),
764 )
765 })?;
766 filter_entries_by_git_ref(all_entries, &changed, path)
767 } else {
768 all_entries
769 };
770
771 let subtree_counts = if max_depth.is_some_and(|d| d > 0) {
773 Some(traversal::subtree_counts_from_entries(path, &all_entries))
774 } else {
775 None
776 };
777
778 let entries: Vec<traversal::WalkEntry> = if let Some(depth) = max_depth
780 && depth > 0
781 {
782 all_entries
783 .into_iter()
784 .filter(|e| e.depth <= depth as usize)
785 .collect()
786 } else {
787 all_entries
788 };
789
790 let total_files = entries.iter().filter(|e| !e.is_dir).count();
792
793 let handle = tokio::task::spawn_blocking(move || {
795 analyze::analyze_directory_with_progress(&path_owned, entries, counter_clone, ct_clone)
796 });
797
798 let token = ProgressToken(NumberOrString::String(
800 format!(
801 "analyze-overview-{}",
802 std::time::SystemTime::now()
803 .duration_since(std::time::UNIX_EPOCH)
804 .map(|d| d.as_nanos())
805 .unwrap_or(0)
806 )
807 .into(),
808 ));
809 let peer = self.peer.lock().await.clone();
810 let mut last_progress = 0usize;
811 let mut cancelled = false;
812 loop {
813 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
814 if ct.is_cancelled() {
815 cancelled = true;
816 break;
817 }
818 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
819 if current != last_progress && total_files > 0 {
820 self.emit_progress(
821 peer.clone(),
822 &token,
823 current as f64,
824 total_files as f64,
825 format!("Analyzing {current}/{total_files} files"),
826 )
827 .await;
828 last_progress = current;
829 }
830 if handle.is_finished() {
831 break;
832 }
833 }
834
835 if !cancelled && total_files > 0 {
837 self.emit_progress(
838 peer.clone(),
839 &token,
840 total_files as f64,
841 total_files as f64,
842 format!("Completed analyzing {total_files} files"),
843 )
844 .await;
845 }
846
847 match handle.await {
848 Ok(Ok(mut output)) => {
849 output.subtree_counts = subtree_counts;
850 let arc_output = std::sync::Arc::new(output);
851 self.cache.put_directory(cache_key, arc_output.clone());
852 {
854 let dc = self.disk_cache.clone();
855 let k = disk_key;
856 let v = arc_output.as_ref().clone();
857 let handle = tokio::task::spawn_blocking(move || {
858 dc.put("analyze_directory", &k, &v);
859 dc.drain_write_failures()
860 });
861 let metrics_tx = self.metrics_tx.clone();
862 let sid = self.session_id.lock().await.clone();
863 tokio::spawn(async move {
864 if let Ok(failures) = handle.await
865 && failures > 0
866 {
867 tracing::warn!(
868 tool = "analyze_directory",
869 failures,
870 "L2 disk cache write failed"
871 );
872 metrics_tx.send(crate::metrics::MetricEvent {
873 ts: crate::metrics::unix_ms(),
874 tool: "analyze_directory",
875 duration_ms: 0,
876 output_chars: 0,
877 param_path_depth: 0,
878 max_depth: None,
879 result: "ok",
880 error_type: None,
881 session_id: sid,
882 seq: None,
883 cache_hit: None,
884 cache_write_failure: Some(true),
885 cache_tier: None,
886 exit_code: None,
887 timed_out: false,
888 });
889 }
890 });
891 }
892 Ok((arc_output, CacheTier::Miss))
893 }
894 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
895 rmcp::model::ErrorCode::INTERNAL_ERROR,
896 "Analysis cancelled".to_string(),
897 Some(error_meta("transient", true, "analysis was cancelled")),
898 )),
899 Ok(Err(e)) => Err(ErrorData::new(
900 rmcp::model::ErrorCode::INTERNAL_ERROR,
901 format!("Error analyzing directory: {e}"),
902 Some(error_meta(
903 "resource",
904 false,
905 "check path and file permissions",
906 )),
907 )),
908 Err(e) => Err(ErrorData::new(
909 rmcp::model::ErrorCode::INTERNAL_ERROR,
910 format!("Task join error: {e}"),
911 Some(error_meta("transient", true, "retry the request")),
912 )),
913 }
914 }
915
916 #[instrument(skip(self, params))]
919 async fn handle_file_details_mode(
920 &self,
921 params: &AnalyzeFileParams,
922 ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, CacheTier), ErrorData> {
923 let cache_key = std::fs::metadata(¶ms.path).ok().and_then(|meta| {
925 meta.modified().ok().map(|mtime| cache::CacheKey {
926 path: std::path::PathBuf::from(¶ms.path),
927 modified: mtime,
928 mode: AnalysisMode::FileDetails,
929 })
930 });
931
932 if let Some(ref key) = cache_key
934 && let Some(cached) = self.cache.get(key)
935 {
936 tracing::debug!(cache_hit = true, message = "returning cached result");
937 return Ok((cached, CacheTier::L1Memory));
938 }
939
940 let file_bytes = std::fs::read(¶ms.path).unwrap_or_default();
942 let disk_key = blake3::hash(&file_bytes);
943
944 if let Some(cached) = self
946 .disk_cache
947 .get::<analyze::FileAnalysisOutput>("analyze_file", &disk_key)
948 {
949 let arc = std::sync::Arc::new(cached);
950 if let Some(ref key) = cache_key {
951 self.cache.put(key.clone(), arc.clone());
952 }
953 return Ok((arc, CacheTier::L2Disk));
954 }
955
956 match analyze::analyze_file(¶ms.path, params.ast_recursion_limit) {
958 Ok(output) => {
959 let arc_output = std::sync::Arc::new(output);
960 if let Some(key) = cache_key {
961 self.cache.put(key, arc_output.clone());
962 }
963 {
965 let dc = self.disk_cache.clone();
966 let k = disk_key;
967 let v = arc_output.as_ref().clone();
968 let handle = tokio::task::spawn_blocking(move || {
969 dc.put("analyze_file", &k, &v);
970 dc.drain_write_failures()
971 });
972 let metrics_tx = self.metrics_tx.clone();
973 let sid = self.session_id.lock().await.clone();
974 tokio::spawn(async move {
975 if let Ok(failures) = handle.await
976 && failures > 0
977 {
978 tracing::warn!(
979 tool = "analyze_file",
980 failures,
981 "L2 disk cache write failed"
982 );
983 metrics_tx.send(crate::metrics::MetricEvent {
984 ts: crate::metrics::unix_ms(),
985 tool: "analyze_file",
986 duration_ms: 0,
987 output_chars: 0,
988 param_path_depth: 0,
989 max_depth: None,
990 result: "ok",
991 error_type: None,
992 session_id: sid,
993 seq: None,
994 cache_hit: None,
995 cache_write_failure: Some(true),
996 cache_tier: None,
997 exit_code: None,
998 timed_out: false,
999 });
1000 }
1001 });
1002 }
1003 Ok((arc_output, CacheTier::Miss))
1004 }
1005 Err(e) => Err(ErrorData::new(
1006 rmcp::model::ErrorCode::INTERNAL_ERROR,
1007 format!("Error analyzing file: {e}"),
1008 Some(error_meta(
1009 "resource",
1010 false,
1011 "check file path and permissions",
1012 )),
1013 )),
1014 }
1015 }
1016
1017 fn validate_impl_only(entries: &[WalkEntry]) -> Result<(), ErrorData> {
1019 let has_rust = entries.iter().any(|e| {
1020 !e.is_dir
1021 && e.path
1022 .extension()
1023 .and_then(|x: &std::ffi::OsStr| x.to_str())
1024 == Some("rs")
1025 });
1026
1027 if !has_rust {
1028 return Err(ErrorData::new(
1029 rmcp::model::ErrorCode::INVALID_PARAMS,
1030 "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(),
1031 Some(error_meta(
1032 "validation",
1033 false,
1034 "remove impl_only or point to a directory containing .rs files",
1035 )),
1036 ));
1037 }
1038 Ok(())
1039 }
1040
1041 fn validate_import_lookup(import_lookup: Option<bool>, symbol: &str) -> Result<(), ErrorData> {
1043 if import_lookup == Some(true) && symbol.is_empty() {
1044 return Err(ErrorData::new(
1045 rmcp::model::ErrorCode::INVALID_PARAMS,
1046 "import_lookup=true requires symbol to contain the module path to search for"
1047 .to_string(),
1048 Some(error_meta(
1049 "validation",
1050 false,
1051 "set symbol to the module path when using import_lookup=true",
1052 )),
1053 ));
1054 }
1055 Ok(())
1056 }
1057
1058 #[allow(clippy::cast_precision_loss)] async fn poll_progress_until_done(
1061 &self,
1062 analysis_params: &FocusedAnalysisParams,
1063 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1064 ct: tokio_util::sync::CancellationToken,
1065 entries: std::sync::Arc<Vec<WalkEntry>>,
1066 total_files: usize,
1067 symbol_display: &str,
1068 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1069 let counter_clone = counter.clone();
1070 let ct_clone = ct.clone();
1071 let entries_clone = std::sync::Arc::clone(&entries);
1072 let path_owned = analysis_params.path.clone();
1073 let symbol_owned = analysis_params.symbol.clone();
1074 let match_mode_owned = analysis_params.match_mode.clone();
1075 let follow_depth = analysis_params.follow_depth;
1076 let max_depth = analysis_params.max_depth;
1077 let ast_recursion_limit = analysis_params.ast_recursion_limit;
1078 let use_summary = analysis_params.use_summary;
1079 let impl_only = analysis_params.impl_only;
1080 let def_use = analysis_params.def_use;
1081 let parse_timeout_micros = analysis_params.parse_timeout_micros;
1082 let handle = tokio::task::spawn_blocking(move || {
1083 let params = analyze::FocusedAnalysisConfig {
1084 focus: symbol_owned,
1085 match_mode: match_mode_owned,
1086 follow_depth,
1087 max_depth,
1088 ast_recursion_limit,
1089 use_summary,
1090 impl_only,
1091 def_use,
1092 parse_timeout_micros,
1093 };
1094 analyze::analyze_focused_with_progress_with_entries(
1095 &path_owned,
1096 ¶ms,
1097 &counter_clone,
1098 &ct_clone,
1099 &entries_clone,
1100 )
1101 });
1102
1103 let token = ProgressToken(NumberOrString::String(
1104 format!(
1105 "analyze-symbol-{}",
1106 std::time::SystemTime::now()
1107 .duration_since(std::time::UNIX_EPOCH)
1108 .map(|d| d.as_nanos())
1109 .unwrap_or(0)
1110 )
1111 .into(),
1112 ));
1113 let peer = self.peer.lock().await.clone();
1114 let mut last_progress = 0usize;
1115 let mut cancelled = false;
1116
1117 loop {
1118 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1119 if ct.is_cancelled() {
1120 cancelled = true;
1121 break;
1122 }
1123 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
1124 if current != last_progress && total_files > 0 {
1125 self.emit_progress(
1126 peer.clone(),
1127 &token,
1128 current as f64,
1129 total_files as f64,
1130 format!(
1131 "Analyzing {current}/{total_files} files for symbol '{symbol_display}'"
1132 ),
1133 )
1134 .await;
1135 last_progress = current;
1136 }
1137 if handle.is_finished() {
1138 break;
1139 }
1140 }
1141
1142 if !cancelled && total_files > 0 {
1143 self.emit_progress(
1144 peer.clone(),
1145 &token,
1146 total_files as f64,
1147 total_files as f64,
1148 format!("Completed analyzing {total_files} files for symbol '{symbol_display}'"),
1149 )
1150 .await;
1151 }
1152
1153 match handle.await {
1154 Ok(Ok(output)) => Ok(output),
1155 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
1156 rmcp::model::ErrorCode::INTERNAL_ERROR,
1157 "Analysis cancelled".to_string(),
1158 Some(error_meta("transient", true, "analysis was cancelled")),
1159 )),
1160 Ok(Err(e)) => Err(ErrorData::new(
1161 rmcp::model::ErrorCode::INTERNAL_ERROR,
1162 format!("Error analyzing symbol: {e}"),
1163 Some(error_meta("resource", false, "check symbol name and file")),
1164 )),
1165 Err(e) => Err(ErrorData::new(
1166 rmcp::model::ErrorCode::INTERNAL_ERROR,
1167 format!("Task join error: {e}"),
1168 Some(error_meta("transient", true, "retry the request")),
1169 )),
1170 }
1171 }
1172
1173 async fn run_focused_with_auto_summary(
1175 &self,
1176 params: &AnalyzeSymbolParams,
1177 analysis_params: &FocusedAnalysisParams,
1178 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1179 ct: tokio_util::sync::CancellationToken,
1180 entries: std::sync::Arc<Vec<WalkEntry>>,
1181 total_files: usize,
1182 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1183 let use_summary_for_task = params.output_control.force != Some(true)
1184 && params.output_control.summary == Some(true);
1185
1186 let analysis_params_initial = FocusedAnalysisParams {
1187 use_summary: use_summary_for_task,
1188 ..analysis_params.clone()
1189 };
1190
1191 let mut output = self
1192 .poll_progress_until_done(
1193 &analysis_params_initial,
1194 counter.clone(),
1195 ct.clone(),
1196 entries.clone(),
1197 total_files,
1198 ¶ms.symbol,
1199 )
1200 .await?;
1201
1202 if params.output_control.summary.is_none()
1203 && params.output_control.force != Some(true)
1204 && output.formatted.len() > SIZE_LIMIT
1205 {
1206 tracing::debug!(
1207 auto_summary = true,
1208 message = "output exceeded size limit, retrying with summary"
1209 );
1210 let counter2 = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1211 let analysis_params_retry = FocusedAnalysisParams {
1212 use_summary: true,
1213 ..analysis_params.clone()
1214 };
1215 let summary_result = self
1216 .poll_progress_until_done(
1217 &analysis_params_retry,
1218 counter2,
1219 ct,
1220 entries,
1221 total_files,
1222 ¶ms.symbol,
1223 )
1224 .await;
1225
1226 if let Ok(summary_output) = summary_result {
1227 output.formatted = summary_output.formatted;
1228 } else {
1229 let estimated_tokens = output.formatted.len() / 4;
1230 let message = format!(
1231 "Output exceeds 50K chars ({} chars, ~{} tokens). Use summary=true or force=true.",
1232 output.formatted.len(),
1233 estimated_tokens
1234 );
1235 return Err(ErrorData::new(
1236 rmcp::model::ErrorCode::INVALID_PARAMS,
1237 message,
1238 Some(error_meta(
1239 "validation",
1240 false,
1241 "use summary=true or force=true",
1242 )),
1243 ));
1244 }
1245 } else if output.formatted.len() > SIZE_LIMIT
1246 && params.output_control.force != Some(true)
1247 && params.output_control.summary == Some(false)
1248 {
1249 let estimated_tokens = output.formatted.len() / 4;
1250 let message = format!(
1251 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1252 - force=true to return full output\n\
1253 - summary=true to get compact summary\n\
1254 - Narrow your scope (smaller directory, specific file)",
1255 output.formatted.len(),
1256 estimated_tokens
1257 );
1258 return Err(ErrorData::new(
1259 rmcp::model::ErrorCode::INVALID_PARAMS,
1260 message,
1261 Some(error_meta(
1262 "validation",
1263 false,
1264 "use force=true, summary=true, or narrow scope",
1265 )),
1266 ));
1267 }
1268
1269 Ok(output)
1270 }
1271
1272 #[instrument(skip(self, params, ct))]
1276 async fn handle_focused_mode(
1277 &self,
1278 params: &AnalyzeSymbolParams,
1279 ct: tokio_util::sync::CancellationToken,
1280 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1281 let path = Path::new(¶ms.path);
1282 let raw_entries = match walk_directory(path, params.max_depth) {
1283 Ok(e) => e,
1284 Err(e) => {
1285 return Err(ErrorData::new(
1286 rmcp::model::ErrorCode::INTERNAL_ERROR,
1287 format!("Failed to walk directory: {e}"),
1288 Some(error_meta(
1289 "resource",
1290 false,
1291 "check path permissions and availability",
1292 )),
1293 ));
1294 }
1295 };
1296 let filtered_entries = if let Some(ref git_ref) = params.git_ref
1298 && !git_ref.is_empty()
1299 {
1300 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
1301 ErrorData::new(
1302 rmcp::model::ErrorCode::INVALID_PARAMS,
1303 format!("git_ref filter failed: {e}"),
1304 Some(error_meta(
1305 "resource",
1306 false,
1307 "ensure git is installed and path is inside a git repository",
1308 )),
1309 )
1310 })?;
1311 filter_entries_by_git_ref(raw_entries, &changed, path)
1312 } else {
1313 raw_entries
1314 };
1315 let entries = std::sync::Arc::new(filtered_entries);
1316
1317 if params.impl_only == Some(true) {
1318 Self::validate_impl_only(&entries)?;
1319 }
1320
1321 let total_files = entries.iter().filter(|e| !e.is_dir).count();
1322 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1323
1324 let analysis_params = FocusedAnalysisParams {
1325 path: path.to_path_buf(),
1326 symbol: params.symbol.clone(),
1327 match_mode: params.match_mode.clone().unwrap_or_default(),
1328 follow_depth: params.follow_depth.unwrap_or(1),
1329 max_depth: params.max_depth,
1330 ast_recursion_limit: params.ast_recursion_limit,
1331 use_summary: false,
1332 impl_only: params.impl_only,
1333 def_use: params.def_use.unwrap_or(false),
1334 parse_timeout_micros: None,
1335 };
1336
1337 let mut output = self
1338 .run_focused_with_auto_summary(
1339 params,
1340 &analysis_params,
1341 counter,
1342 ct,
1343 entries,
1344 total_files,
1345 )
1346 .await?;
1347
1348 if params.impl_only == Some(true) {
1349 let filter_line = format!(
1350 "FILTER: impl_only=true ({} of {} callers shown)\n",
1351 output.impl_trait_caller_count, output.unfiltered_caller_count
1352 );
1353 output.formatted = format!("{}{}", filter_line, output.formatted);
1354
1355 if output.impl_trait_caller_count == 0 {
1356 output.formatted.push_str(
1357 "\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"
1358 );
1359 }
1360 }
1361
1362 Ok(output)
1363 }
1364
1365 #[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))]
1366 #[tool(
1367 name = "analyze_directory",
1368 title = "Analyze Directory",
1369 description = "Tree-view of directory with LOC, function/class counts, test markers. Respects .gitignore. Returns per-file stats plus next_cursor for pagination. Fails if summary=true and cursor. For 1000+ files, use max_depth=2-3 and summary=true. 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?",
1370 output_schema = schema_for_type::<analyze::AnalysisOutput>(),
1371 annotations(
1372 title = "Analyze Directory",
1373 read_only_hint = true,
1374 destructive_hint = false,
1375 idempotent_hint = true,
1376 open_world_hint = false
1377 )
1378 )]
1379 async fn analyze_directory(
1380 &self,
1381 params: Parameters<AnalyzeDirectoryParams>,
1382 context: RequestContext<RoleServer>,
1383 ) -> Result<CallToolResult, ErrorData> {
1384 let params = params.0;
1385 let session_id = self.session_id.lock().await.clone();
1387 let client_name = self.client_name.lock().await.clone();
1388 let client_version = self.client_version.lock().await.clone();
1389 extract_and_set_trace_context(
1390 Some(&context.meta),
1391 ClientMetadata {
1392 session_id,
1393 client_name,
1394 client_version,
1395 },
1396 );
1397 let span = tracing::Span::current();
1398 span.record("gen_ai.system", "mcp");
1399 span.record("gen_ai.operation.name", "execute_tool");
1400 span.record("gen_ai.tool.name", "analyze_directory");
1401 span.record("path", ¶ms.path);
1402 let _validated_path = match validate_path(¶ms.path, true) {
1403 Ok(p) => p,
1404 Err(e) => {
1405 span.record("error", true);
1406 span.record("error.type", "invalid_params");
1407 return Ok(err_to_tool_result(e));
1408 }
1409 };
1410 let ct = context.ct.clone();
1411 let t_start = std::time::Instant::now();
1412 let param_path = params.path.clone();
1413 let max_depth_val = params.max_depth;
1414 let seq = self
1415 .session_call_seq
1416 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1417 let sid = self.session_id.lock().await.clone();
1418
1419 let (arc_output, dir_cache_hit) = match self.handle_overview_mode(¶ms, ct).await {
1421 Ok(v) => v,
1422 Err(e) => {
1423 span.record("error", true);
1424 span.record("error.type", "internal_error");
1425 return Ok(err_to_tool_result(e));
1426 }
1427 };
1428 let mut output = match std::sync::Arc::try_unwrap(arc_output) {
1431 Ok(owned) => owned,
1432 Err(arc) => (*arc).clone(),
1433 };
1434
1435 if summary_cursor_conflict(
1438 params.output_control.summary,
1439 params.pagination.cursor.as_deref(),
1440 ) {
1441 span.record("error", true);
1442 span.record("error.type", "invalid_params");
1443 return Ok(err_to_tool_result(ErrorData::new(
1444 rmcp::model::ErrorCode::INVALID_PARAMS,
1445 "summary=true is incompatible with a pagination cursor; use one or the other"
1446 .to_string(),
1447 Some(error_meta(
1448 "validation",
1449 false,
1450 "remove cursor or set summary=false",
1451 )),
1452 )));
1453 }
1454
1455 let use_summary = if params.output_control.force == Some(true) {
1457 false
1458 } else if params.output_control.summary == Some(true) {
1459 true
1460 } else if params.output_control.summary == Some(false) {
1461 false
1462 } else {
1463 output.formatted.len() > SIZE_LIMIT
1464 };
1465
1466 if use_summary {
1467 output.formatted = format_summary(
1468 &output.entries,
1469 &output.files,
1470 params.max_depth,
1471 output.subtree_counts.as_deref(),
1472 );
1473 }
1474
1475 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1477 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1478 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1479 ErrorData::new(
1480 rmcp::model::ErrorCode::INVALID_PARAMS,
1481 e.to_string(),
1482 Some(error_meta("validation", false, "invalid cursor format")),
1483 )
1484 }) {
1485 Ok(v) => v,
1486 Err(e) => {
1487 span.record("error", true);
1488 span.record("error.type", "invalid_params");
1489 return Ok(err_to_tool_result(e));
1490 }
1491 };
1492 cursor_data.offset
1493 } else {
1494 0
1495 };
1496
1497 let paginated =
1499 match paginate_slice(&output.files, offset, page_size, PaginationMode::Default) {
1500 Ok(v) => v,
1501 Err(e) => {
1502 span.record("error", true);
1503 span.record("error.type", "internal_error");
1504 return Ok(err_to_tool_result(ErrorData::new(
1505 rmcp::model::ErrorCode::INTERNAL_ERROR,
1506 e.to_string(),
1507 Some(error_meta("transient", true, "retry the request")),
1508 )));
1509 }
1510 };
1511
1512 let verbose = params.output_control.verbose.unwrap_or(false);
1513 if !use_summary {
1514 output.formatted = format_structure_paginated(
1515 &paginated.items,
1516 paginated.total,
1517 params.max_depth,
1518 Some(Path::new(¶ms.path)),
1519 verbose,
1520 );
1521 }
1522
1523 if use_summary {
1525 output.next_cursor = None;
1526 } else {
1527 output.next_cursor.clone_from(&paginated.next_cursor);
1528 }
1529
1530 let mut final_text = output.formatted.clone();
1532 if !use_summary && let Some(cursor) = paginated.next_cursor {
1533 final_text.push('\n');
1534 final_text.push_str("NEXT_CURSOR: ");
1535 final_text.push_str(&cursor);
1536 }
1537
1538 tracing::Span::current().record("cache_tier", dir_cache_hit.as_str());
1540
1541 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1543 let mut meta = no_cache_meta().0;
1544 meta.insert(
1545 "content_hash".to_string(),
1546 serde_json::Value::String(content_hash),
1547 );
1548 let meta = rmcp::model::Meta(meta);
1549
1550 let mut result =
1551 CallToolResult::success(vec![Content::text(final_text.clone())]).with_meta(Some(meta));
1552 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1553 result.structured_content = Some(structured);
1554 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1555 self.metrics_tx.send(crate::metrics::MetricEvent {
1556 ts: crate::metrics::unix_ms(),
1557 tool: "analyze_directory",
1558 duration_ms: dur,
1559 output_chars: final_text.len(),
1560 param_path_depth: crate::metrics::path_component_count(¶m_path),
1561 max_depth: max_depth_val,
1562 result: "ok",
1563 error_type: None,
1564 session_id: sid,
1565 seq: Some(seq),
1566 cache_hit: Some(dir_cache_hit != CacheTier::Miss),
1567 cache_write_failure: None,
1568 cache_tier: Some(dir_cache_hit.as_str()),
1569 exit_code: None,
1570 timed_out: false,
1571 });
1572 Ok(result)
1573 }
1574
1575 #[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))]
1576 #[tool(
1577 name = "analyze_file",
1578 title = "Analyze File",
1579 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: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#. Example queries: What functions are defined in src/lib.rs?; Show me the classes and their methods in src/analyzer.py.",
1580 output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
1581 annotations(
1582 title = "Analyze File",
1583 read_only_hint = true,
1584 destructive_hint = false,
1585 idempotent_hint = true,
1586 open_world_hint = false
1587 )
1588 )]
1589 async fn analyze_file(
1590 &self,
1591 params: Parameters<AnalyzeFileParams>,
1592 context: RequestContext<RoleServer>,
1593 ) -> Result<CallToolResult, ErrorData> {
1594 let params = params.0;
1595 let session_id = self.session_id.lock().await.clone();
1597 let client_name = self.client_name.lock().await.clone();
1598 let client_version = self.client_version.lock().await.clone();
1599 extract_and_set_trace_context(
1600 Some(&context.meta),
1601 ClientMetadata {
1602 session_id,
1603 client_name,
1604 client_version,
1605 },
1606 );
1607 let span = tracing::Span::current();
1608 span.record("gen_ai.system", "mcp");
1609 span.record("gen_ai.operation.name", "execute_tool");
1610 span.record("gen_ai.tool.name", "analyze_file");
1611 span.record("path", ¶ms.path);
1612 let _validated_path = match validate_path(¶ms.path, true) {
1613 Ok(p) => p,
1614 Err(e) => {
1615 span.record("error", true);
1616 span.record("error.type", "invalid_params");
1617 return Ok(err_to_tool_result(e));
1618 }
1619 };
1620 let t_start = std::time::Instant::now();
1621 let param_path = params.path.clone();
1622 let seq = self
1623 .session_call_seq
1624 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1625 let sid = self.session_id.lock().await.clone();
1626
1627 if std::path::Path::new(¶ms.path).is_dir() {
1629 span.record("error", true);
1630 span.record("error.type", "invalid_params");
1631 return Ok(err_to_tool_result(ErrorData::new(
1632 rmcp::model::ErrorCode::INVALID_PARAMS,
1633 format!(
1634 "'{}' is a directory; use analyze_directory instead",
1635 params.path
1636 ),
1637 Some(error_meta(
1638 "validation",
1639 false,
1640 "pass a file path, not a directory",
1641 )),
1642 )));
1643 }
1644
1645 if summary_cursor_conflict(
1647 params.output_control.summary,
1648 params.pagination.cursor.as_deref(),
1649 ) {
1650 span.record("error", true);
1651 span.record("error.type", "invalid_params");
1652 return Ok(err_to_tool_result(ErrorData::new(
1653 rmcp::model::ErrorCode::INVALID_PARAMS,
1654 "summary=true is incompatible with a pagination cursor; use one or the other"
1655 .to_string(),
1656 Some(error_meta(
1657 "validation",
1658 false,
1659 "remove cursor or set summary=false",
1660 )),
1661 )));
1662 }
1663
1664 let (arc_output, file_cache_hit) = match self.handle_file_details_mode(¶ms).await {
1666 Ok(v) => v,
1667 Err(e) => {
1668 span.record("error", true);
1669 span.record("error.type", "internal_error");
1670 return Ok(err_to_tool_result(e));
1671 }
1672 };
1673
1674 let mut formatted = arc_output.formatted.clone();
1678 let line_count = arc_output.line_count;
1679
1680 let use_summary = if params.output_control.force == Some(true) {
1682 false
1683 } else if params.output_control.summary == Some(true) {
1684 true
1685 } else if params.output_control.summary == Some(false) {
1686 false
1687 } else {
1688 formatted.len() > SIZE_LIMIT
1689 };
1690
1691 if use_summary {
1692 formatted = format_file_details_summary(&arc_output.semantic, ¶ms.path, line_count);
1693 } else if formatted.len() > SIZE_LIMIT && params.output_control.force != Some(true) {
1694 span.record("error", true);
1695 span.record("error.type", "invalid_params");
1696 let estimated_tokens = formatted.len() / 4;
1697 let message = format!(
1698 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1699 - force=true to return full output\n\
1700 - Use fields to limit output to specific sections (functions, classes, or imports)\n\
1701 - Use summary=true for a compact overview",
1702 formatted.len(),
1703 estimated_tokens
1704 );
1705 return Ok(err_to_tool_result(ErrorData::new(
1706 rmcp::model::ErrorCode::INVALID_PARAMS,
1707 message,
1708 Some(error_meta(
1709 "validation",
1710 false,
1711 "use force=true, fields, or summary=true",
1712 )),
1713 )));
1714 }
1715
1716 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1718 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1719 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1720 ErrorData::new(
1721 rmcp::model::ErrorCode::INVALID_PARAMS,
1722 e.to_string(),
1723 Some(error_meta("validation", false, "invalid cursor format")),
1724 )
1725 }) {
1726 Ok(v) => v,
1727 Err(e) => {
1728 span.record("error", true);
1729 span.record("error.type", "invalid_params");
1730 return Ok(err_to_tool_result(e));
1731 }
1732 };
1733 cursor_data.offset
1734 } else {
1735 0
1736 };
1737
1738 let top_level_fns: Vec<crate::types::FunctionInfo> = arc_output
1740 .semantic
1741 .functions
1742 .iter()
1743 .filter(|func| {
1744 !arc_output
1745 .semantic
1746 .classes
1747 .iter()
1748 .any(|class| func.line >= class.line && func.end_line <= class.end_line)
1749 })
1750 .cloned()
1751 .collect();
1752
1753 let paginated =
1755 match paginate_slice(&top_level_fns, offset, page_size, PaginationMode::Default) {
1756 Ok(v) => v,
1757 Err(e) => {
1758 return Ok(err_to_tool_result(ErrorData::new(
1759 rmcp::model::ErrorCode::INTERNAL_ERROR,
1760 e.to_string(),
1761 Some(error_meta("transient", true, "retry the request")),
1762 )));
1763 }
1764 };
1765
1766 let verbose = params.output_control.verbose.unwrap_or(false);
1768 if !use_summary {
1769 formatted = format_file_details_paginated(
1771 &paginated.items,
1772 paginated.total,
1773 &arc_output.semantic,
1774 ¶ms.path,
1775 line_count,
1776 offset,
1777 verbose,
1778 params.fields.as_deref(),
1779 );
1780 }
1781
1782 let next_cursor = if use_summary {
1784 None
1785 } else {
1786 paginated.next_cursor.clone()
1787 };
1788
1789 let mut final_text = formatted.clone();
1791 if !use_summary && let Some(ref cursor) = next_cursor {
1792 final_text.push('\n');
1793 final_text.push_str("NEXT_CURSOR: ");
1794 final_text.push_str(cursor);
1795 }
1796
1797 let response_output = analyze::FileAnalysisOutput::new(
1799 formatted,
1800 arc_output.semantic.clone(),
1801 line_count,
1802 next_cursor,
1803 );
1804
1805 tracing::Span::current().record("cache_tier", file_cache_hit.as_str());
1807
1808 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1810 let mut meta = no_cache_meta().0;
1811 meta.insert(
1812 "content_hash".to_string(),
1813 serde_json::Value::String(content_hash),
1814 );
1815 let meta = rmcp::model::Meta(meta);
1816
1817 let mut result =
1818 CallToolResult::success(vec![Content::text(final_text.clone())]).with_meta(Some(meta));
1819 let structured = serde_json::to_value(&response_output).unwrap_or(Value::Null);
1820 result.structured_content = Some(structured);
1821 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1822 self.metrics_tx.send(crate::metrics::MetricEvent {
1823 ts: crate::metrics::unix_ms(),
1824 tool: "analyze_file",
1825 duration_ms: dur,
1826 output_chars: final_text.len(),
1827 param_path_depth: crate::metrics::path_component_count(¶m_path),
1828 max_depth: None,
1829 result: "ok",
1830 error_type: None,
1831 session_id: sid,
1832 seq: Some(seq),
1833 cache_hit: Some(file_cache_hit != CacheTier::Miss),
1834 cache_write_failure: None,
1835 cache_tier: Some(file_cache_hit.as_str()),
1836 exit_code: None,
1837 timed_out: false,
1838 });
1839 Ok(result)
1840 }
1841
1842 #[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))]
1843 #[tool(
1844 name = "analyze_symbol",
1845 title = "Analyze Symbol",
1846 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.",
1847 output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
1848 annotations(
1849 title = "Analyze Symbol",
1850 read_only_hint = true,
1851 destructive_hint = false,
1852 idempotent_hint = true,
1853 open_world_hint = false
1854 )
1855 )]
1856 async fn analyze_symbol(
1857 &self,
1858 params: Parameters<AnalyzeSymbolParams>,
1859 context: RequestContext<RoleServer>,
1860 ) -> Result<CallToolResult, ErrorData> {
1861 let params = params.0;
1862 let session_id = self.session_id.lock().await.clone();
1864 let client_name = self.client_name.lock().await.clone();
1865 let client_version = self.client_version.lock().await.clone();
1866 extract_and_set_trace_context(
1867 Some(&context.meta),
1868 ClientMetadata {
1869 session_id,
1870 client_name,
1871 client_version,
1872 },
1873 );
1874 let span = tracing::Span::current();
1875 span.record("gen_ai.system", "mcp");
1876 span.record("gen_ai.operation.name", "execute_tool");
1877 span.record("gen_ai.tool.name", "analyze_symbol");
1878 span.record("symbol", ¶ms.symbol);
1879 let _validated_path = match validate_path(¶ms.path, true) {
1880 Ok(p) => p,
1881 Err(e) => {
1882 span.record("error", true);
1883 span.record("error.type", "invalid_params");
1884 return Ok(err_to_tool_result(e));
1885 }
1886 };
1887 let ct = context.ct.clone();
1888 let t_start = std::time::Instant::now();
1889 let param_path = params.path.clone();
1890 let max_depth_val = params.follow_depth;
1891 let seq = self
1892 .session_call_seq
1893 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1894 let sid = self.session_id.lock().await.clone();
1895
1896 if std::path::Path::new(¶ms.path).is_file() {
1898 span.record("error", true);
1899 span.record("error.type", "invalid_params");
1900 return Ok(err_to_tool_result(ErrorData::new(
1901 rmcp::model::ErrorCode::INVALID_PARAMS,
1902 format!(
1903 "'{}' is a file; analyze_symbol requires a directory path",
1904 params.path
1905 ),
1906 Some(error_meta(
1907 "validation",
1908 false,
1909 "pass a directory path, not a file",
1910 )),
1911 )));
1912 }
1913
1914 if summary_cursor_conflict(
1916 params.output_control.summary,
1917 params.pagination.cursor.as_deref(),
1918 ) {
1919 span.record("error", true);
1920 span.record("error.type", "invalid_params");
1921 return Ok(err_to_tool_result(ErrorData::new(
1922 rmcp::model::ErrorCode::INVALID_PARAMS,
1923 "summary=true is incompatible with a pagination cursor; use one or the other"
1924 .to_string(),
1925 Some(error_meta(
1926 "validation",
1927 false,
1928 "remove cursor or set summary=false",
1929 )),
1930 )));
1931 }
1932
1933 if let Err(e) = Self::validate_import_lookup(params.import_lookup, ¶ms.symbol) {
1935 span.record("error", true);
1936 span.record("error.type", "invalid_params");
1937 return Ok(err_to_tool_result(e));
1938 }
1939
1940 if params.import_lookup == Some(true) {
1942 let path_owned = PathBuf::from(¶ms.path);
1943 let symbol = params.symbol.clone();
1944 let git_ref = params.git_ref.clone();
1945 let max_depth = params.max_depth;
1946 let ast_recursion_limit = params.ast_recursion_limit;
1947
1948 let handle = tokio::task::spawn_blocking(move || {
1949 let path = path_owned.as_path();
1950 let raw_entries = match walk_directory(path, max_depth) {
1951 Ok(e) => e,
1952 Err(e) => {
1953 return Err(ErrorData::new(
1954 rmcp::model::ErrorCode::INTERNAL_ERROR,
1955 format!("Failed to walk directory: {e}"),
1956 Some(error_meta(
1957 "resource",
1958 false,
1959 "check path permissions and availability",
1960 )),
1961 ));
1962 }
1963 };
1964 let entries = if let Some(ref git_ref_val) = git_ref
1966 && !git_ref_val.is_empty()
1967 {
1968 let changed = match changed_files_from_git_ref(path, git_ref_val) {
1969 Ok(c) => c,
1970 Err(e) => {
1971 return Err(ErrorData::new(
1972 rmcp::model::ErrorCode::INVALID_PARAMS,
1973 format!("git_ref filter failed: {e}"),
1974 Some(error_meta(
1975 "resource",
1976 false,
1977 "ensure git is installed and path is inside a git repository",
1978 )),
1979 ));
1980 }
1981 };
1982 filter_entries_by_git_ref(raw_entries, &changed, path)
1983 } else {
1984 raw_entries
1985 };
1986 let output = match analyze::analyze_import_lookup(
1987 path,
1988 &symbol,
1989 &entries,
1990 ast_recursion_limit,
1991 ) {
1992 Ok(v) => v,
1993 Err(e) => {
1994 return Err(ErrorData::new(
1995 rmcp::model::ErrorCode::INTERNAL_ERROR,
1996 format!("import_lookup failed: {e}"),
1997 Some(error_meta(
1998 "resource",
1999 false,
2000 "check path and file permissions",
2001 )),
2002 ));
2003 }
2004 };
2005 Ok(output)
2006 });
2007
2008 let output = match handle.await {
2009 Ok(Ok(v)) => v,
2010 Ok(Err(e)) => return Ok(err_to_tool_result(e)),
2011 Err(e) => {
2012 return Ok(err_to_tool_result(ErrorData::new(
2013 rmcp::model::ErrorCode::INTERNAL_ERROR,
2014 format!("spawn_blocking failed: {e}"),
2015 Some(error_meta("resource", false, "internal error")),
2016 )));
2017 }
2018 };
2019
2020 let final_text = output.formatted.clone();
2021
2022 tracing::Span::current().record("cache_tier", "Miss");
2024
2025 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
2027 let mut meta = no_cache_meta().0;
2028 meta.insert(
2029 "content_hash".to_string(),
2030 serde_json::Value::String(content_hash),
2031 );
2032
2033 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
2034 .with_meta(Some(Meta(meta)));
2035 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
2036 result.structured_content = Some(structured);
2037 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2038 self.metrics_tx.send(crate::metrics::MetricEvent {
2039 ts: crate::metrics::unix_ms(),
2040 tool: "analyze_symbol",
2041 duration_ms: dur,
2042 output_chars: final_text.len(),
2043 param_path_depth: crate::metrics::path_component_count(¶m_path),
2044 max_depth: max_depth_val,
2045 result: "ok",
2046 error_type: None,
2047 session_id: sid,
2048 seq: Some(seq),
2049 cache_hit: None,
2050 cache_tier: None,
2051 cache_write_failure: None,
2052 exit_code: None,
2053 timed_out: false,
2054 });
2055 return Ok(result);
2056 }
2057
2058 let mut output = match self.handle_focused_mode(¶ms, ct).await {
2060 Ok(v) => v,
2061 Err(e) => return Ok(err_to_tool_result(e)),
2062 };
2063
2064 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
2066 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
2067 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
2068 ErrorData::new(
2069 rmcp::model::ErrorCode::INVALID_PARAMS,
2070 e.to_string(),
2071 Some(error_meta("validation", false, "invalid cursor format")),
2072 )
2073 }) {
2074 Ok(v) => v,
2075 Err(e) => return Ok(err_to_tool_result(e)),
2076 };
2077 cursor_data.offset
2078 } else {
2079 0
2080 };
2081
2082 let cursor_mode = if let Some(ref cursor_str) = params.pagination.cursor {
2084 decode_cursor(cursor_str)
2085 .map(|c| c.mode)
2086 .unwrap_or(PaginationMode::Callers)
2087 } else {
2088 PaginationMode::Callers
2089 };
2090
2091 let mut use_summary = params.output_control.summary == Some(true);
2092 if params.output_control.force == Some(true) {
2093 use_summary = false;
2094 }
2095 let verbose = params.output_control.verbose.unwrap_or(false);
2096
2097 let mut callee_cursor = match cursor_mode {
2098 PaginationMode::Callers => {
2099 let (paginated_items, paginated_next) = match paginate_focus_chains(
2100 &output.prod_chains,
2101 PaginationMode::Callers,
2102 offset,
2103 page_size,
2104 ) {
2105 Ok(v) => v,
2106 Err(e) => return Ok(err_to_tool_result(e)),
2107 };
2108
2109 if !use_summary
2110 && (paginated_next.is_some()
2111 || offset > 0
2112 || !verbose
2113 || !output.outgoing_chains.is_empty())
2114 {
2115 let base_path = Path::new(¶ms.path);
2116 output.formatted = format_focused_paginated(
2117 &paginated_items,
2118 output.prod_chains.len(),
2119 PaginationMode::Callers,
2120 ¶ms.symbol,
2121 &output.prod_chains,
2122 &output.test_chains,
2123 &output.outgoing_chains,
2124 output.def_count,
2125 offset,
2126 Some(base_path),
2127 verbose,
2128 );
2129 paginated_next
2130 } else {
2131 None
2132 }
2133 }
2134 PaginationMode::Callees => {
2135 let (paginated_items, paginated_next) = match paginate_focus_chains(
2136 &output.outgoing_chains,
2137 PaginationMode::Callees,
2138 offset,
2139 page_size,
2140 ) {
2141 Ok(v) => v,
2142 Err(e) => return Ok(err_to_tool_result(e)),
2143 };
2144
2145 if paginated_next.is_some() || offset > 0 || !verbose {
2146 let base_path = Path::new(¶ms.path);
2147 output.formatted = format_focused_paginated(
2148 &paginated_items,
2149 output.outgoing_chains.len(),
2150 PaginationMode::Callees,
2151 ¶ms.symbol,
2152 &output.prod_chains,
2153 &output.test_chains,
2154 &output.outgoing_chains,
2155 output.def_count,
2156 offset,
2157 Some(base_path),
2158 verbose,
2159 );
2160 paginated_next
2161 } else {
2162 None
2163 }
2164 }
2165 PaginationMode::Default => {
2166 return Ok(err_to_tool_result(ErrorData::new(
2167 rmcp::model::ErrorCode::INVALID_PARAMS,
2168 "invalid cursor: unknown pagination mode".to_string(),
2169 Some(error_meta(
2170 "validation",
2171 false,
2172 "use a cursor returned by a previous analyze_symbol call",
2173 )),
2174 )));
2175 }
2176 PaginationMode::DefUse => {
2177 let total_sites = output.def_use_sites.len();
2178 let (paginated_sites, paginated_next) = match paginate_slice(
2179 &output.def_use_sites,
2180 offset,
2181 page_size,
2182 PaginationMode::DefUse,
2183 ) {
2184 Ok(r) => (r.items, r.next_cursor),
2185 Err(e) => return Ok(err_to_tool_result_from_pagination(e)),
2186 };
2187
2188 if !use_summary {
2191 let base_path = Path::new(¶ms.path);
2192 output.formatted = format_focused_paginated_defuse(
2193 &paginated_sites,
2194 total_sites,
2195 ¶ms.symbol,
2196 offset,
2197 Some(base_path),
2198 verbose,
2199 );
2200 }
2201
2202 output.def_use_sites = paginated_sites;
2205
2206 paginated_next
2207 }
2208 };
2209
2210 if callee_cursor.is_none()
2215 && cursor_mode == PaginationMode::Callers
2216 && !output.outgoing_chains.is_empty()
2217 && !use_summary
2218 && let Ok(cursor) = encode_cursor(&CursorData {
2219 mode: PaginationMode::Callees,
2220 offset: 0,
2221 })
2222 {
2223 callee_cursor = Some(cursor);
2224 }
2225
2226 if callee_cursor.is_none()
2233 && matches!(
2234 cursor_mode,
2235 PaginationMode::Callees | PaginationMode::Callers
2236 )
2237 && !output.def_use_sites.is_empty()
2238 && !use_summary
2239 && let Ok(cursor) = encode_cursor(&CursorData {
2240 mode: PaginationMode::DefUse,
2241 offset: 0,
2242 })
2243 {
2244 if cursor_mode == PaginationMode::Callees || output.outgoing_chains.is_empty() {
2247 callee_cursor = Some(cursor);
2248 }
2249 }
2250
2251 output.next_cursor.clone_from(&callee_cursor);
2253
2254 let mut final_text = output.formatted.clone();
2256 if let Some(cursor) = callee_cursor {
2257 final_text.push('\n');
2258 final_text.push_str("NEXT_CURSOR: ");
2259 final_text.push_str(&cursor);
2260 }
2261
2262 tracing::Span::current().record("cache_tier", "Miss");
2264
2265 let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
2267 let mut meta = no_cache_meta().0;
2268 meta.insert(
2269 "content_hash".to_string(),
2270 serde_json::Value::String(content_hash),
2271 );
2272
2273 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
2274 .with_meta(Some(Meta(meta)));
2275 if cursor_mode != PaginationMode::DefUse {
2279 output.def_use_sites = Vec::new();
2280 }
2281 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
2282 result.structured_content = Some(structured);
2283 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2284 self.metrics_tx.send(crate::metrics::MetricEvent {
2285 ts: crate::metrics::unix_ms(),
2286 tool: "analyze_symbol",
2287 duration_ms: dur,
2288 output_chars: final_text.len(),
2289 param_path_depth: crate::metrics::path_component_count(¶m_path),
2290 max_depth: max_depth_val,
2291 result: "ok",
2292 error_type: None,
2293 session_id: sid,
2294 seq: Some(seq),
2295 cache_hit: None,
2296 cache_tier: None,
2297 cache_write_failure: None,
2298 exit_code: None,
2299 timed_out: false,
2300 });
2301 Ok(result)
2302 }
2303
2304 #[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))]
2305 #[tool(
2306 name = "analyze_module",
2307 title = "Analyze Module",
2308 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, summary, force, verbose, git_ref not supported. Use analyze_file when you need signatures, types, or class details. Supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#. Example queries: What functions are defined in src/analyze.rs?",
2309 output_schema = schema_for_type::<types::ModuleInfo>(),
2310 annotations(
2311 title = "Analyze Module",
2312 read_only_hint = true,
2313 destructive_hint = false,
2314 idempotent_hint = true,
2315 open_world_hint = false
2316 )
2317 )]
2318 async fn analyze_module(
2319 &self,
2320 params: Parameters<AnalyzeModuleParams>,
2321 context: RequestContext<RoleServer>,
2322 ) -> Result<CallToolResult, ErrorData> {
2323 let params = params.0;
2324 let session_id = self.session_id.lock().await.clone();
2326 let client_name = self.client_name.lock().await.clone();
2327 let client_version = self.client_version.lock().await.clone();
2328 extract_and_set_trace_context(
2329 Some(&context.meta),
2330 ClientMetadata {
2331 session_id,
2332 client_name,
2333 client_version,
2334 },
2335 );
2336 let span = tracing::Span::current();
2337 span.record("gen_ai.system", "mcp");
2338 span.record("gen_ai.operation.name", "execute_tool");
2339 span.record("gen_ai.tool.name", "analyze_module");
2340 span.record("path", ¶ms.path);
2341 let _validated_path = match validate_path(¶ms.path, true) {
2342 Ok(p) => p,
2343 Err(e) => {
2344 span.record("error", true);
2345 span.record("error.type", "invalid_params");
2346 return Ok(err_to_tool_result(e));
2347 }
2348 };
2349 let t_start = std::time::Instant::now();
2350 let param_path = params.path.clone();
2351 let seq = self
2352 .session_call_seq
2353 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2354 let sid = self.session_id.lock().await.clone();
2355
2356 if std::fs::metadata(¶ms.path)
2358 .map(|m| m.is_dir())
2359 .unwrap_or(false)
2360 {
2361 span.record("error", true);
2362 span.record("error.type", "invalid_params");
2363 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2364 self.metrics_tx.send(crate::metrics::MetricEvent {
2365 ts: crate::metrics::unix_ms(),
2366 tool: "analyze_module",
2367 duration_ms: dur,
2368 output_chars: 0,
2369 param_path_depth: crate::metrics::path_component_count(¶m_path),
2370 max_depth: None,
2371 result: "error",
2372 error_type: Some("invalid_params".to_string()),
2373 session_id: sid.clone(),
2374 seq: Some(seq),
2375 cache_hit: None,
2376 cache_write_failure: None,
2377 cache_tier: None,
2378 exit_code: None,
2379 timed_out: false,
2380 });
2381 return Ok(err_to_tool_result(ErrorData::new(
2382 rmcp::model::ErrorCode::INVALID_PARAMS,
2383 format!(
2384 "'{}' is a directory. Use analyze_directory to analyze a directory, or pass a specific file path to analyze_module.",
2385 params.path
2386 ),
2387 Some(error_meta(
2388 "validation",
2389 false,
2390 "use analyze_directory for directories",
2391 )),
2392 )));
2393 }
2394
2395 let mut analyze_file_params: AnalyzeFileParams = Default::default();
2397 analyze_file_params.path = params.path.clone();
2398 let (arc_output, module_tier) =
2399 match self.handle_file_details_mode(&analyze_file_params).await {
2400 Ok((output, tier)) => (output, tier),
2401 Err(e) => {
2402 let error_data = match e.code {
2403 rmcp::model::ErrorCode::INVALID_PARAMS => e,
2404 _ => ErrorData::new(
2405 rmcp::model::ErrorCode::INTERNAL_ERROR,
2406 format!("Failed to analyze module: {}", e.message),
2407 Some(error_meta("internal", false, "report this as a bug")),
2408 ),
2409 };
2410 return Ok(err_to_tool_result(error_data));
2411 }
2412 };
2413
2414 let file_path = std::path::Path::new(¶ms.path);
2416 let name = file_path
2417 .file_name()
2418 .and_then(|n: &std::ffi::OsStr| n.to_str())
2419 .unwrap_or("unknown")
2420 .to_string();
2421 let language = file_path
2422 .extension()
2423 .and_then(|e| e.to_str())
2424 .and_then(aptu_coder_core::lang::language_for_extension)
2425 .unwrap_or("unknown")
2426 .to_string();
2427 let functions = arc_output
2428 .semantic
2429 .functions
2430 .iter()
2431 .map(|f| {
2432 let mut mfi = types::ModuleFunctionInfo::default();
2433 mfi.name = f.name.clone();
2434 mfi.line = f.line;
2435 mfi
2436 })
2437 .collect();
2438 let imports = arc_output
2439 .semantic
2440 .imports
2441 .iter()
2442 .map(|i| {
2443 let mut mii = types::ModuleImportInfo::default();
2444 mii.module = i.module.clone();
2445 mii.items = i.items.clone();
2446 mii
2447 })
2448 .collect();
2449 let module_info =
2450 types::ModuleInfo::new(name, arc_output.line_count, language, functions, imports);
2451
2452 let text = format_module_info(&module_info);
2453
2454 tracing::Span::current().record("cache_tier", module_tier.as_str());
2456
2457 let content_hash = format!("{}", blake3::hash(text.as_bytes()));
2459 let mut meta = no_cache_meta().0;
2460 meta.insert(
2461 "content_hash".to_string(),
2462 serde_json::Value::String(content_hash),
2463 );
2464
2465 let mut result =
2466 CallToolResult::success(vec![Content::text(text.clone())]).with_meta(Some(Meta(meta)));
2467 let structured = match serde_json::to_value(&module_info).map_err(|e| {
2468 ErrorData::new(
2469 rmcp::model::ErrorCode::INTERNAL_ERROR,
2470 format!("serialization failed: {e}"),
2471 Some(error_meta("internal", false, "report this as a bug")),
2472 )
2473 }) {
2474 Ok(v) => v,
2475 Err(e) => return Ok(err_to_tool_result(e)),
2476 };
2477 result.structured_content = Some(structured);
2478 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2479 self.metrics_tx.send(crate::metrics::MetricEvent {
2480 ts: crate::metrics::unix_ms(),
2481 tool: "analyze_module",
2482 duration_ms: dur,
2483 output_chars: text.len(),
2484 param_path_depth: crate::metrics::path_component_count(¶m_path),
2485 max_depth: None,
2486 result: "ok",
2487 error_type: None,
2488 session_id: sid,
2489 seq: Some(seq),
2490 cache_hit: Some(module_tier != CacheTier::Miss),
2491 cache_tier: Some(module_tier.as_str()),
2492 cache_write_failure: None,
2493 exit_code: None,
2494 timed_out: false,
2495 });
2496 Ok(result)
2497 }
2498
2499 #[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))]
2500 #[tool(
2501 name = "edit_overwrite",
2502 title = "Edit Overwrite",
2503 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.",
2504 output_schema = schema_for_type::<EditOverwriteOutput>(),
2505 annotations(
2506 title = "Edit Overwrite",
2507 read_only_hint = false,
2508 destructive_hint = true,
2509 idempotent_hint = false,
2510 open_world_hint = false
2511 )
2512 )]
2513 async fn edit_overwrite(
2514 &self,
2515 params: Parameters<EditOverwriteParams>,
2516 context: RequestContext<RoleServer>,
2517 ) -> Result<CallToolResult, ErrorData> {
2518 let params = params.0;
2519 let session_id = self.session_id.lock().await.clone();
2521 let client_name = self.client_name.lock().await.clone();
2522 let client_version = self.client_version.lock().await.clone();
2523 extract_and_set_trace_context(
2524 Some(&context.meta),
2525 ClientMetadata {
2526 session_id,
2527 client_name,
2528 client_version,
2529 },
2530 );
2531 let span = tracing::Span::current();
2532 span.record("gen_ai.system", "mcp");
2533 span.record("gen_ai.operation.name", "execute_tool");
2534 span.record("gen_ai.tool.name", "edit_overwrite");
2535 span.record("path", ¶ms.path);
2536 let _validated_path = if let Some(ref wd) = params.working_dir {
2537 match validate_path_in_dir(¶ms.path, false, std::path::Path::new(wd)) {
2538 Ok(p) => p,
2539 Err(e) => {
2540 span.record("error", true);
2541 span.record("error.type", "invalid_params");
2542 return Ok(err_to_tool_result(e));
2543 }
2544 }
2545 } else {
2546 match validate_path(¶ms.path, false) {
2547 Ok(p) => p,
2548 Err(e) => {
2549 span.record("error", true);
2550 span.record("error.type", "invalid_params");
2551 return Ok(err_to_tool_result(e));
2552 }
2553 }
2554 };
2555 let t_start = std::time::Instant::now();
2556 let param_path = params.path.clone();
2557 let seq = self
2558 .session_call_seq
2559 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2560 let sid = self.session_id.lock().await.clone();
2561
2562 if std::fs::metadata(¶ms.path)
2564 .map(|m| m.is_dir())
2565 .unwrap_or(false)
2566 {
2567 span.record("error", true);
2568 span.record("error.type", "invalid_params");
2569 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2570 self.metrics_tx.send(crate::metrics::MetricEvent {
2571 ts: crate::metrics::unix_ms(),
2572 tool: "edit_overwrite",
2573 duration_ms: dur,
2574 output_chars: 0,
2575 param_path_depth: crate::metrics::path_component_count(¶m_path),
2576 max_depth: None,
2577 result: "error",
2578 error_type: Some("invalid_params".to_string()),
2579 session_id: sid.clone(),
2580 seq: Some(seq),
2581 cache_hit: None,
2582 cache_write_failure: None,
2583 cache_tier: None,
2584 exit_code: None,
2585 timed_out: false,
2586 });
2587 return Ok(err_to_tool_result(ErrorData::new(
2588 rmcp::model::ErrorCode::INVALID_PARAMS,
2589 "path is a directory; cannot write to a directory".to_string(),
2590 Some(error_meta(
2591 "validation",
2592 false,
2593 "provide a file path, not a directory",
2594 )),
2595 )));
2596 }
2597
2598 let path = std::path::PathBuf::from(¶ms.path);
2599 let content = params.content.clone();
2600 let handle = tokio::task::spawn_blocking(move || {
2601 aptu_coder_core::edit_overwrite_content(&path, &content)
2602 });
2603
2604 let output = match handle.await {
2605 Ok(Ok(v)) => v,
2606 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2607 span.record("error", true);
2608 span.record("error.type", "invalid_params");
2609 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2610 self.metrics_tx.send(crate::metrics::MetricEvent {
2611 ts: crate::metrics::unix_ms(),
2612 tool: "edit_overwrite",
2613 duration_ms: dur,
2614 output_chars: 0,
2615 param_path_depth: crate::metrics::path_component_count(¶m_path),
2616 max_depth: None,
2617 result: "error",
2618 error_type: Some("invalid_params".to_string()),
2619 session_id: sid.clone(),
2620 seq: Some(seq),
2621 cache_hit: None,
2622 cache_write_failure: None,
2623 cache_tier: None,
2624 exit_code: None,
2625 timed_out: false,
2626 });
2627 return Ok(err_to_tool_result(ErrorData::new(
2628 rmcp::model::ErrorCode::INVALID_PARAMS,
2629 "path is a directory".to_string(),
2630 Some(error_meta(
2631 "validation",
2632 false,
2633 "provide a file path, not a directory",
2634 )),
2635 )));
2636 }
2637 Ok(Err(e)) => {
2638 span.record("error", true);
2639 span.record("error.type", "internal_error");
2640 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2641 self.metrics_tx.send(crate::metrics::MetricEvent {
2642 ts: crate::metrics::unix_ms(),
2643 tool: "edit_overwrite",
2644 duration_ms: dur,
2645 output_chars: 0,
2646 param_path_depth: crate::metrics::path_component_count(¶m_path),
2647 max_depth: None,
2648 result: "error",
2649 error_type: Some("internal_error".to_string()),
2650 session_id: sid.clone(),
2651 seq: Some(seq),
2652 cache_hit: None,
2653 cache_write_failure: None,
2654 cache_tier: None,
2655 exit_code: None,
2656 timed_out: false,
2657 });
2658 return Ok(err_to_tool_result(ErrorData::new(
2659 rmcp::model::ErrorCode::INTERNAL_ERROR,
2660 e.to_string(),
2661 Some(error_meta(
2662 "resource",
2663 false,
2664 "check file path and permissions",
2665 )),
2666 )));
2667 }
2668 Err(e) => {
2669 span.record("error", true);
2670 span.record("error.type", "internal_error");
2671 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2672 self.metrics_tx.send(crate::metrics::MetricEvent {
2673 ts: crate::metrics::unix_ms(),
2674 tool: "edit_overwrite",
2675 duration_ms: dur,
2676 output_chars: 0,
2677 param_path_depth: crate::metrics::path_component_count(¶m_path),
2678 max_depth: None,
2679 result: "error",
2680 error_type: Some("internal_error".to_string()),
2681 session_id: sid.clone(),
2682 seq: Some(seq),
2683 cache_hit: None,
2684 cache_write_failure: None,
2685 cache_tier: None,
2686 exit_code: None,
2687 timed_out: false,
2688 });
2689 return Ok(err_to_tool_result(ErrorData::new(
2690 rmcp::model::ErrorCode::INTERNAL_ERROR,
2691 e.to_string(),
2692 Some(error_meta(
2693 "resource",
2694 false,
2695 "check file path and permissions",
2696 )),
2697 )));
2698 }
2699 };
2700
2701 let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2702 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2703 .with_meta(Some(no_cache_meta()));
2704 let structured = match serde_json::to_value(&output).map_err(|e| {
2705 ErrorData::new(
2706 rmcp::model::ErrorCode::INTERNAL_ERROR,
2707 format!("serialization failed: {e}"),
2708 Some(error_meta("internal", false, "report this as a bug")),
2709 )
2710 }) {
2711 Ok(v) => v,
2712 Err(e) => return Ok(err_to_tool_result(e)),
2713 };
2714 result.structured_content = Some(structured);
2715 self.cache
2716 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2717 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2718 self.metrics_tx.send(crate::metrics::MetricEvent {
2719 ts: crate::metrics::unix_ms(),
2720 tool: "edit_overwrite",
2721 duration_ms: dur,
2722 output_chars: text.len(),
2723 param_path_depth: crate::metrics::path_component_count(¶m_path),
2724 max_depth: None,
2725 result: "ok",
2726 error_type: None,
2727 session_id: sid,
2728 seq: Some(seq),
2729 cache_hit: None,
2730 cache_write_failure: None,
2731 cache_tier: None,
2732 exit_code: None,
2733 timed_out: false,
2734 });
2735 Ok(result)
2736 }
2737
2738 #[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))]
2739 #[tool(
2740 name = "edit_replace",
2741 title = "Edit Replace",
2742 description = "Replaces a unique exact text block; old_text must match character-for-character and appear exactly once. Returns path, bytes_before, bytes_after. Fails if zero matches; fails if multiple matches (extend old_text to be more specific). If invalid_params is returned, re-read the target file with analyze_file or analyze_module before retrying. Whitespace-sensitive exact match. Use edit_overwrite to replace the whole file. working_dir sets the base directory for path resolution (default: server CWD). Example queries: Update the function signature in lib.rs.",
2743 output_schema = schema_for_type::<EditReplaceOutput>(),
2744 annotations(
2745 title = "Edit Replace",
2746 read_only_hint = false,
2747 destructive_hint = true,
2748 idempotent_hint = false,
2749 open_world_hint = false
2750 )
2751 )]
2752 async fn edit_replace(
2753 &self,
2754 params: Parameters<EditReplaceParams>,
2755 context: RequestContext<RoleServer>,
2756 ) -> Result<CallToolResult, ErrorData> {
2757 let params = params.0;
2758 let session_id = self.session_id.lock().await.clone();
2760 let client_name = self.client_name.lock().await.clone();
2761 let client_version = self.client_version.lock().await.clone();
2762 extract_and_set_trace_context(
2763 Some(&context.meta),
2764 ClientMetadata {
2765 session_id,
2766 client_name,
2767 client_version,
2768 },
2769 );
2770 let span = tracing::Span::current();
2771 span.record("gen_ai.system", "mcp");
2772 span.record("gen_ai.operation.name", "execute_tool");
2773 span.record("gen_ai.tool.name", "edit_replace");
2774 span.record("path", ¶ms.path);
2775 let _validated_path = if let Some(ref wd) = params.working_dir {
2776 match validate_path_in_dir(¶ms.path, true, std::path::Path::new(wd)) {
2777 Ok(p) => p,
2778 Err(e) => {
2779 span.record("error", true);
2780 span.record("error.type", "invalid_params");
2781 return Ok(err_to_tool_result(e));
2782 }
2783 }
2784 } else {
2785 match validate_path(¶ms.path, true) {
2786 Ok(p) => p,
2787 Err(e) => {
2788 span.record("error", true);
2789 span.record("error.type", "invalid_params");
2790 return Ok(err_to_tool_result(e));
2791 }
2792 }
2793 };
2794 let t_start = std::time::Instant::now();
2795 let param_path = params.path.clone();
2796 let seq = self
2797 .session_call_seq
2798 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2799 let sid = self.session_id.lock().await.clone();
2800
2801 if std::fs::metadata(¶ms.path)
2803 .map(|m| m.is_dir())
2804 .unwrap_or(false)
2805 {
2806 span.record("error", true);
2807 span.record("error.type", "invalid_params");
2808 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2809 self.metrics_tx.send(crate::metrics::MetricEvent {
2810 ts: crate::metrics::unix_ms(),
2811 tool: "edit_replace",
2812 duration_ms: dur,
2813 output_chars: 0,
2814 param_path_depth: crate::metrics::path_component_count(¶m_path),
2815 max_depth: None,
2816 result: "error",
2817 error_type: Some("invalid_params".to_string()),
2818 session_id: sid.clone(),
2819 seq: Some(seq),
2820 cache_hit: None,
2821 cache_write_failure: None,
2822 cache_tier: None,
2823 exit_code: None,
2824 timed_out: false,
2825 });
2826 return Ok(err_to_tool_result(ErrorData::new(
2827 rmcp::model::ErrorCode::INVALID_PARAMS,
2828 "path is a directory; cannot edit a directory".to_string(),
2829 Some(error_meta(
2830 "validation",
2831 false,
2832 "provide a file path, not a directory",
2833 )),
2834 )));
2835 }
2836
2837 let path = std::path::PathBuf::from(¶ms.path);
2838 let old_text = params.old_text.clone();
2839 let new_text = params.new_text.clone();
2840 let handle = tokio::task::spawn_blocking(move || {
2841 aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2842 });
2843
2844 let output = match handle.await {
2845 Ok(Ok(v)) => v,
2846 Ok(Err(aptu_coder_core::EditError::NotFound {
2847 path: notfound_path,
2848 })) => {
2849 span.record("error", true);
2850 span.record("error.type", "invalid_params");
2851 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2852 self.metrics_tx.send(crate::metrics::MetricEvent {
2853 ts: crate::metrics::unix_ms(),
2854 tool: "edit_replace",
2855 duration_ms: dur,
2856 output_chars: 0,
2857 param_path_depth: crate::metrics::path_component_count(¶m_path),
2858 max_depth: None,
2859 result: "error",
2860 error_type: Some("invalid_params".to_string()),
2861 session_id: sid.clone(),
2862 seq: Some(seq),
2863 cache_hit: None,
2864 cache_write_failure: None,
2865 cache_tier: None,
2866 exit_code: None,
2867 timed_out: false,
2868 });
2869 return Ok(err_to_tool_result(ErrorData::new(
2870 rmcp::model::ErrorCode::INVALID_PARAMS,
2871 format!(
2872 "old_text not found (0 matches) in {notfound_path}. 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."
2873 ),
2874 Some(error_meta(
2875 "validation",
2876 false,
2877 "re-read the file with analyze_file or analyze_module, then derive old_text from the live content",
2878 )),
2879 )));
2880 }
2881 Ok(Err(aptu_coder_core::EditError::Ambiguous {
2882 count,
2883 path: ambiguous_path,
2884 })) => {
2885 span.record("error", true);
2886 span.record("error.type", "invalid_params");
2887 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2888 self.metrics_tx.send(crate::metrics::MetricEvent {
2889 ts: crate::metrics::unix_ms(),
2890 tool: "edit_replace",
2891 duration_ms: dur,
2892 output_chars: 0,
2893 param_path_depth: crate::metrics::path_component_count(¶m_path),
2894 max_depth: None,
2895 result: "error",
2896 error_type: Some("invalid_params".to_string()),
2897 session_id: sid.clone(),
2898 seq: Some(seq),
2899 cache_hit: None,
2900 cache_write_failure: None,
2901 cache_tier: None,
2902 exit_code: None,
2903 timed_out: false,
2904 });
2905 return Ok(err_to_tool_result(ErrorData::new(
2906 rmcp::model::ErrorCode::INVALID_PARAMS,
2907 format!(
2908 "old_text matched {count} locations in {ambiguous_path}. Extend old_text with more surrounding context to make it unique, or re-read with analyze_file to confirm the exact text."
2909 ),
2910 Some(error_meta(
2911 "validation",
2912 false,
2913 "extend old_text with more surrounding context, or re-read with analyze_file to confirm the exact text",
2914 )),
2915 )));
2916 }
2917 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2918 span.record("error", true);
2919 span.record("error.type", "invalid_params");
2920 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2921 self.metrics_tx.send(crate::metrics::MetricEvent {
2922 ts: crate::metrics::unix_ms(),
2923 tool: "edit_replace",
2924 duration_ms: dur,
2925 output_chars: 0,
2926 param_path_depth: crate::metrics::path_component_count(¶m_path),
2927 max_depth: None,
2928 result: "error",
2929 error_type: Some("invalid_params".to_string()),
2930 session_id: sid.clone(),
2931 seq: Some(seq),
2932 cache_hit: None,
2933 cache_write_failure: None,
2934 cache_tier: None,
2935 exit_code: None,
2936 timed_out: false,
2937 });
2938 return Ok(err_to_tool_result(ErrorData::new(
2939 rmcp::model::ErrorCode::INVALID_PARAMS,
2940 "path is a directory".to_string(),
2941 Some(error_meta(
2942 "validation",
2943 false,
2944 "provide a file path, not a directory",
2945 )),
2946 )));
2947 }
2948 Ok(Err(e)) => {
2949 span.record("error", true);
2950 span.record("error.type", "internal_error");
2951 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2952 self.metrics_tx.send(crate::metrics::MetricEvent {
2953 ts: crate::metrics::unix_ms(),
2954 tool: "edit_replace",
2955 duration_ms: dur,
2956 output_chars: 0,
2957 param_path_depth: crate::metrics::path_component_count(¶m_path),
2958 max_depth: None,
2959 result: "error",
2960 error_type: Some("internal_error".to_string()),
2961 session_id: sid.clone(),
2962 seq: Some(seq),
2963 cache_hit: None,
2964 cache_write_failure: None,
2965 cache_tier: None,
2966 exit_code: None,
2967 timed_out: false,
2968 });
2969 return Ok(err_to_tool_result(ErrorData::new(
2970 rmcp::model::ErrorCode::INTERNAL_ERROR,
2971 e.to_string(),
2972 Some(error_meta(
2973 "resource",
2974 false,
2975 "check file path and permissions",
2976 )),
2977 )));
2978 }
2979 Err(e) => {
2980 span.record("error", true);
2981 span.record("error.type", "internal_error");
2982 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2983 self.metrics_tx.send(crate::metrics::MetricEvent {
2984 ts: crate::metrics::unix_ms(),
2985 tool: "edit_replace",
2986 duration_ms: dur,
2987 output_chars: 0,
2988 param_path_depth: crate::metrics::path_component_count(¶m_path),
2989 max_depth: None,
2990 result: "error",
2991 error_type: Some("internal_error".to_string()),
2992 session_id: sid.clone(),
2993 seq: Some(seq),
2994 cache_hit: None,
2995 cache_write_failure: None,
2996 cache_tier: None,
2997 exit_code: None,
2998 timed_out: false,
2999 });
3000 return Ok(err_to_tool_result(ErrorData::new(
3001 rmcp::model::ErrorCode::INTERNAL_ERROR,
3002 e.to_string(),
3003 Some(error_meta(
3004 "resource",
3005 false,
3006 "check file path and permissions",
3007 )),
3008 )));
3009 }
3010 };
3011
3012 let text = format!(
3013 "Edited {}: {} bytes -> {} bytes",
3014 output.path, output.bytes_before, output.bytes_after
3015 );
3016 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
3017 .with_meta(Some(no_cache_meta()));
3018 let structured = match serde_json::to_value(&output).map_err(|e| {
3019 ErrorData::new(
3020 rmcp::model::ErrorCode::INTERNAL_ERROR,
3021 format!("serialization failed: {e}"),
3022 Some(error_meta("internal", false, "report this as a bug")),
3023 )
3024 }) {
3025 Ok(v) => v,
3026 Err(e) => return Ok(err_to_tool_result(e)),
3027 };
3028 result.structured_content = Some(structured);
3029 self.cache
3030 .invalidate_file(&std::path::PathBuf::from(¶m_path));
3031 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3032 self.metrics_tx.send(crate::metrics::MetricEvent {
3033 ts: crate::metrics::unix_ms(),
3034 tool: "edit_replace",
3035 duration_ms: dur,
3036 output_chars: text.len(),
3037 param_path_depth: crate::metrics::path_component_count(¶m_path),
3038 max_depth: None,
3039 result: "ok",
3040 error_type: None,
3041 session_id: sid,
3042 seq: Some(seq),
3043 cache_hit: None,
3044 cache_write_failure: None,
3045 cache_tier: None,
3046 exit_code: None,
3047 timed_out: false,
3048 });
3049 Ok(result)
3050 }
3051
3052 #[tool(
3053 name = "exec_command",
3054 title = "Exec Command",
3055 description = "Execute shell command via sh -c (or $SHELL if set). Returns stdout, stderr, interleaved, exit_code, timed_out, output_truncated. Output capped at 2000 lines and 50 KB per stream; use timeout_secs to limit execution time. working_dir sets initial working directory; cd and absolute paths in command string bypass this restriction. Fails if working_dir does not exist, is not a directory, or is outside CWD. Pass stdin to pipe UTF-8 content into the process (max 1 MB). For file creation and edits, prefer the edit_* tools. Example queries: Run the test suite and capture output.",
3056 output_schema = schema_for_type::<types::ShellOutput>(),
3057 annotations(
3058 title = "Exec Command",
3059 read_only_hint = false,
3060 destructive_hint = true,
3061 idempotent_hint = false,
3062 open_world_hint = true
3063 )
3064 )]
3065 #[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, timed_out = 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))]
3066 pub async fn exec_command(
3067 &self,
3068 params: Parameters<types::ExecCommandParams>,
3069 context: RequestContext<RoleServer>,
3070 ) -> Result<CallToolResult, ErrorData> {
3071 let t_start = std::time::Instant::now();
3072 let params = params.0;
3073 let session_id = self.session_id.lock().await.clone();
3075 let client_name = self.client_name.lock().await.clone();
3076 let client_version = self.client_version.lock().await.clone();
3077 extract_and_set_trace_context(
3078 Some(&context.meta),
3079 ClientMetadata {
3080 session_id,
3081 client_name,
3082 client_version,
3083 },
3084 );
3085 let span = tracing::Span::current();
3086 span.record("gen_ai.system", "mcp");
3087 span.record("gen_ai.operation.name", "execute_tool");
3088 span.record("gen_ai.tool.name", "exec_command");
3089 span.record("command", ¶ms.command);
3090
3091 let working_dir_path = if let Some(ref wd) = params.working_dir {
3093 match validate_path(wd, true) {
3094 Ok(p) => {
3095 if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
3097 span.record("error", true);
3098 span.record("error.type", "invalid_params");
3099 return Ok(err_to_tool_result(ErrorData::new(
3100 rmcp::model::ErrorCode::INVALID_PARAMS,
3101 "working_dir must be a directory".to_string(),
3102 Some(error_meta(
3103 "validation",
3104 false,
3105 "provide a valid directory path",
3106 )),
3107 )));
3108 }
3109 Some(p)
3110 }
3111 Err(e) => {
3112 span.record("error", true);
3113 span.record("error.type", "invalid_params");
3114 return Ok(err_to_tool_result(e));
3115 }
3116 }
3117 } else {
3118 None
3119 };
3120
3121 let param_path = params.working_dir.clone();
3122 let seq = self
3123 .session_call_seq
3124 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3125 let sid = self.session_id.lock().await.clone();
3126
3127 if let Some(ref stdin_content) = params.stdin
3129 && stdin_content.len() > STDIN_MAX_BYTES
3130 {
3131 span.record("error", true);
3132 span.record("error.type", "invalid_params");
3133 return Ok(err_to_tool_result(ErrorData::new(
3134 rmcp::model::ErrorCode::INVALID_PARAMS,
3135 "stdin exceeds 1 MB limit".to_string(),
3136 Some(error_meta("validation", false, "reduce stdin content size")),
3137 )));
3138 }
3139
3140 let command = params.command.clone();
3141 let timeout_secs = params.timeout_secs;
3142
3143 let _cache_key = (
3145 command.clone(),
3146 working_dir_path
3147 .as_ref()
3148 .map(|p| p.display().to_string())
3149 .unwrap_or_default(),
3150 );
3151 let resolved_path_str = self.resolved_path.as_ref().as_deref();
3153 let output = run_exec_impl(
3154 command.clone(),
3155 working_dir_path.clone(),
3156 timeout_secs,
3157 params.memory_limit_mb,
3158 params.cpu_limit_secs,
3159 params.stdin.clone(),
3160 seq,
3161 resolved_path_str,
3162 )
3163 .await;
3164
3165 let exit_code = output.exit_code;
3166 let timed_out = output.timed_out;
3167 let output_truncated = output.output_truncated;
3168
3169 if let Some(code) = exit_code {
3171 span.record("exit_code", code);
3172 }
3173 span.record("timed_out", timed_out);
3174 span.record("output_truncated", output_truncated);
3175
3176 if output_truncated {
3178 tracing::debug!(truncated = true, message = "output truncated");
3179 }
3180
3181 let output_text = if output.interleaved.is_empty() {
3183 format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
3184 } else {
3185 format!("Output:\n{}", output.interleaved)
3186 };
3187
3188 let text = format!(
3189 "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
3190 params.command,
3191 exit_code
3192 .map(|c| c.to_string())
3193 .unwrap_or_else(|| "null".to_string()),
3194 timed_out,
3195 output_truncated,
3196 output_text,
3197 );
3198
3199 let content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
3200
3201 let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
3206
3207 let mut result = if command_failed {
3208 CallToolResult::error(content_blocks)
3209 } else {
3210 CallToolResult::success(content_blocks)
3211 }
3212 .with_meta(Some(no_cache_meta()));
3213
3214 let structured = match serde_json::to_value(&output).map_err(|e| {
3215 ErrorData::new(
3216 rmcp::model::ErrorCode::INTERNAL_ERROR,
3217 format!("serialization failed: {e}"),
3218 Some(error_meta("internal", false, "report this as a bug")),
3219 )
3220 }) {
3221 Ok(v) => v,
3222 Err(e) => {
3223 span.record("error", true);
3224 span.record("error.type", "internal_error");
3225 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3226 self.metrics_tx.send(crate::metrics::MetricEvent {
3227 ts: crate::metrics::unix_ms(),
3228 tool: "exec_command",
3229 duration_ms: dur,
3230 output_chars: 0,
3231 param_path_depth: crate::metrics::path_component_count(
3232 param_path.as_deref().unwrap_or(""),
3233 ),
3234 max_depth: None,
3235 result: "error",
3236 error_type: Some("internal_error".to_string()),
3237 session_id: sid.clone(),
3238 seq: Some(seq),
3239 cache_hit: Some(false),
3240 cache_write_failure: None,
3241 cache_tier: None,
3242 exit_code,
3243 timed_out,
3244 });
3245 return Ok(err_to_tool_result(e));
3246 }
3247 };
3248
3249 result.structured_content = Some(structured);
3250 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3251 self.metrics_tx.send(crate::metrics::MetricEvent {
3252 ts: crate::metrics::unix_ms(),
3253 tool: "exec_command",
3254 duration_ms: dur,
3255 output_chars: text.len(),
3256 param_path_depth: crate::metrics::path_component_count(
3257 param_path.as_deref().unwrap_or(""),
3258 ),
3259 max_depth: None,
3260 result: "ok",
3261 error_type: None,
3262 session_id: sid,
3263 seq: Some(seq),
3264 cache_hit: Some(false),
3265 cache_write_failure: None,
3266 cache_tier: None,
3267 exit_code,
3268 timed_out,
3269 });
3270 Ok(result)
3271 }
3272
3273 #[tool(
3274 name = "remote_tree",
3275 title = "Remote Tree",
3276 description = "For uncloned repositories only. Explore a remote GitLab or GitHub repository directory structure without cloning. Returns a compact summary of files and directories with extension counts and individual entries. Supports gitlab.com and github.com URLs. Requires GITLAB_TOKEN or GITHUB_TOKEN environment variable. Fails if the URL scheme is not https://, the host is unsupported, the token is missing, or the path or ref does not exist. Use remote_file to read a specific file from the same repository. Example queries: List top-level files in https://github.com/org/repo; Show the src/ directory at a specific tag in https://gitlab.com/org/repo.",
3277 output_schema = schema_for_type::<aptu_coder_remote::types::RemoteTreeOutput>(),
3278 annotations(
3279 title = "Remote Tree",
3280 read_only_hint = true,
3281 destructive_hint = false,
3282 idempotent_hint = true,
3283 open_world_hint = true
3284 )
3285 )]
3286 #[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, url = 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))]
3287 pub async fn remote_tree(
3288 &self,
3289 params: Parameters<aptu_coder_remote::types::RemoteTreeParams>,
3290 _context: RequestContext<RoleServer>,
3291 ) -> Result<CallToolResult, ErrorData> {
3292 let params = params.0;
3293 let span = tracing::Span::current();
3294 span.record("gen_ai.system", "mcp");
3295 span.record("gen_ai.operation.name", "execute_tool");
3296 span.record("gen_ai.tool.name", "remote_tree");
3297 span.record("url", ¶ms.url);
3298
3299 let start = std::time::Instant::now();
3300 let sid = self.session_id.lock().await.clone();
3301 let seq = self
3302 .session_call_seq
3303 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3304
3305 let depth = params.depth.unwrap_or(2);
3306 let output = aptu_coder_remote::fetch_tree(
3307 ¶ms.url,
3308 params.path.as_deref(),
3309 params.git_ref.as_deref(),
3310 depth,
3311 )
3312 .await;
3313
3314 match output {
3315 Ok(tree) => {
3316 let text = tree.formatted.clone();
3317 let structured = match serde_json::to_value(&tree) {
3318 Ok(v) => v,
3319 Err(e) => {
3320 span.record("error", true);
3321 span.record("error.type", "internal_error");
3322 let dur = start.elapsed().as_millis() as u64;
3323 self.metrics_tx.send(crate::metrics::MetricEvent {
3324 ts: crate::metrics::unix_ms(),
3325 tool: "remote_tree",
3326 duration_ms: dur,
3327 output_chars: 0,
3328 param_path_depth: 0,
3329 max_depth: None,
3330 result: "error",
3331 error_type: Some("serialization".to_string()),
3332 session_id: sid,
3333 seq: Some(seq),
3334 cache_hit: None,
3335 cache_write_failure: None,
3336 cache_tier: None,
3337 exit_code: None,
3338 timed_out: false,
3339 });
3340 return Ok(err_to_tool_result(ErrorData::new(
3341 rmcp::model::ErrorCode::INTERNAL_ERROR,
3342 format!("serialization failed: {e}"),
3343 Some(error_meta("internal", false, "report this as a bug")),
3344 )));
3345 }
3346 };
3347 let dur = start.elapsed().as_millis() as u64;
3348 self.metrics_tx.send(crate::metrics::MetricEvent {
3349 ts: crate::metrics::unix_ms(),
3350 tool: "remote_tree",
3351 duration_ms: dur,
3352 output_chars: text.len(),
3353 param_path_depth: 0,
3354 max_depth: None,
3355 result: "ok",
3356 error_type: None,
3357 session_id: sid,
3358 seq: Some(seq),
3359 cache_hit: None,
3360 cache_write_failure: None,
3361 cache_tier: None,
3362 exit_code: None,
3363 timed_out: false,
3364 });
3365 let mut result = CallToolResult::success(vec![Content::text(text)])
3366 .with_meta(Some(no_cache_meta()));
3367 result.structured_content = Some(structured);
3368 Ok(result)
3369 }
3370 Err(e) => {
3371 span.record("error", true);
3372 span.record("error.type", "remote_error");
3373 let (code, category, retryable, action) = match &e {
3374 aptu_coder_remote::RemoteError::MissingGitLabToken
3375 | aptu_coder_remote::RemoteError::MissingGitHubToken => (
3376 rmcp::model::ErrorCode::INVALID_PARAMS,
3377 "auth",
3378 false,
3379 "Set GITLAB_TOKEN or GITHUB_TOKEN env var",
3380 ),
3381 aptu_coder_remote::RemoteError::UnsupportedHost(_) => (
3382 rmcp::model::ErrorCode::INVALID_PARAMS,
3383 "params",
3384 false,
3385 "Use gitlab.com or github.com URL",
3386 ),
3387 aptu_coder_remote::RemoteError::NotFound(_) => (
3388 rmcp::model::ErrorCode::INVALID_PARAMS,
3389 "params",
3390 false,
3391 "Check path and ref",
3392 ),
3393 aptu_coder_remote::RemoteError::InvalidLineRange(_) => (
3394 rmcp::model::ErrorCode::INVALID_PARAMS,
3395 "params",
3396 false,
3397 "Use format START-END e.g. 10-50",
3398 ),
3399 _ => (
3400 rmcp::model::ErrorCode::INTERNAL_ERROR,
3401 "api",
3402 true,
3403 "Retry or check token permissions",
3404 ),
3405 };
3406 let dur = start.elapsed().as_millis() as u64;
3407 let error_type = match &e {
3408 aptu_coder_remote::RemoteError::MissingGitLabToken => "missing_gitlab_token",
3409 aptu_coder_remote::RemoteError::MissingGitHubToken => "missing_github_token",
3410 aptu_coder_remote::RemoteError::UnsupportedHost(_) => "unsupported_host",
3411 aptu_coder_remote::RemoteError::NotFound(_) => "not_found",
3412 aptu_coder_remote::RemoteError::InvalidLineRange(_) => "invalid_line_range",
3413 _ => "remote_error",
3414 };
3415 self.metrics_tx.send(crate::metrics::MetricEvent {
3416 ts: crate::metrics::unix_ms(),
3417 tool: "remote_tree",
3418 duration_ms: dur,
3419 output_chars: 0,
3420 param_path_depth: 0,
3421 max_depth: None,
3422 result: "error",
3423 error_type: Some(error_type.to_string()),
3424 session_id: sid,
3425 seq: Some(seq),
3426 cache_hit: None,
3427 cache_write_failure: None,
3428 cache_tier: None,
3429 exit_code: None,
3430 timed_out: false,
3431 });
3432 Ok(err_to_tool_result(ErrorData::new(
3433 code,
3434 e.to_string(),
3435 Some(error_meta(category, retryable, action)),
3436 )))
3437 }
3438 }
3439 }
3440
3441 #[tool(
3442 name = "remote_file",
3443 title = "Remote File",
3444 description = "For uncloned repositories only. Fetch the content of a single file from a remote GitLab or GitHub repository without cloning. Returns file content, size_bytes, resolved_ref, and path. Supports optional line range slicing (START-END format) to keep context cost low. Requires GITLAB_TOKEN or GITHUB_TOKEN environment variable. Fails if the URL scheme is not https://, the host is unsupported, the token is missing, the file or ref does not exist, or line_range format is invalid. Use remote_tree to discover paths in the same repository. Example queries: Read README.md from https://github.com/org/repo; Show lines 10-50 of src/main.rs in a GitLab project.",
3445 output_schema = schema_for_type::<aptu_coder_remote::types::RemoteFileOutput>(),
3446 annotations(
3447 title = "Remote File",
3448 read_only_hint = true,
3449 destructive_hint = false,
3450 idempotent_hint = true,
3451 open_world_hint = true
3452 )
3453 )]
3454 #[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, url = 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))]
3455 pub async fn remote_file(
3456 &self,
3457 params: Parameters<aptu_coder_remote::types::RemoteFileParams>,
3458 _context: RequestContext<RoleServer>,
3459 ) -> Result<CallToolResult, ErrorData> {
3460 let params = params.0;
3461 let span = tracing::Span::current();
3462 span.record("gen_ai.system", "mcp");
3463 span.record("gen_ai.operation.name", "execute_tool");
3464 span.record("gen_ai.tool.name", "remote_file");
3465 span.record("url", ¶ms.url);
3466
3467 let start = std::time::Instant::now();
3468 let sid = self.session_id.lock().await.clone();
3469 let seq = self
3470 .session_call_seq
3471 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3472
3473 let output = aptu_coder_remote::fetch_file(
3474 ¶ms.url,
3475 ¶ms.path,
3476 params.git_ref.as_deref(),
3477 params.line_range.as_deref(),
3478 )
3479 .await;
3480
3481 match output {
3482 Ok(file) => {
3483 let text = file.content.clone();
3484 let structured = match serde_json::to_value(&file) {
3485 Ok(v) => v,
3486 Err(e) => {
3487 span.record("error", true);
3488 span.record("error.type", "internal_error");
3489 let dur = start.elapsed().as_millis() as u64;
3490 self.metrics_tx.send(crate::metrics::MetricEvent {
3491 ts: crate::metrics::unix_ms(),
3492 tool: "remote_file",
3493 duration_ms: dur,
3494 output_chars: 0,
3495 param_path_depth: 0,
3496 max_depth: None,
3497 result: "error",
3498 error_type: Some("serialization".to_string()),
3499 session_id: sid,
3500 seq: Some(seq),
3501 cache_hit: None,
3502 cache_write_failure: None,
3503 cache_tier: None,
3504 exit_code: None,
3505 timed_out: false,
3506 });
3507 return Ok(err_to_tool_result(ErrorData::new(
3508 rmcp::model::ErrorCode::INTERNAL_ERROR,
3509 format!("serialization failed: {e}"),
3510 Some(error_meta("internal", false, "report this as a bug")),
3511 )));
3512 }
3513 };
3514 let dur = start.elapsed().as_millis() as u64;
3515 self.metrics_tx.send(crate::metrics::MetricEvent {
3516 ts: crate::metrics::unix_ms(),
3517 tool: "remote_file",
3518 duration_ms: dur,
3519 output_chars: text.len(),
3520 param_path_depth: 0,
3521 max_depth: None,
3522 result: "ok",
3523 error_type: None,
3524 session_id: sid,
3525 seq: Some(seq),
3526 cache_hit: None,
3527 cache_write_failure: None,
3528 cache_tier: None,
3529 exit_code: None,
3530 timed_out: false,
3531 });
3532 let mut result = CallToolResult::success(vec![Content::text(text)])
3533 .with_meta(Some(no_cache_meta()));
3534 result.structured_content = Some(structured);
3535 Ok(result)
3536 }
3537 Err(e) => {
3538 span.record("error", true);
3539 span.record("error.type", "remote_error");
3540 let (code, category, retryable, action) = match &e {
3541 aptu_coder_remote::RemoteError::MissingGitLabToken
3542 | aptu_coder_remote::RemoteError::MissingGitHubToken => (
3543 rmcp::model::ErrorCode::INVALID_PARAMS,
3544 "auth",
3545 false,
3546 "Set GITLAB_TOKEN or GITHUB_TOKEN env var",
3547 ),
3548 aptu_coder_remote::RemoteError::UnsupportedHost(_) => (
3549 rmcp::model::ErrorCode::INVALID_PARAMS,
3550 "params",
3551 false,
3552 "Use gitlab.com or github.com URL",
3553 ),
3554 aptu_coder_remote::RemoteError::NotFound(_) => (
3555 rmcp::model::ErrorCode::INVALID_PARAMS,
3556 "params",
3557 false,
3558 "Check path and ref",
3559 ),
3560 aptu_coder_remote::RemoteError::InvalidLineRange(_) => (
3561 rmcp::model::ErrorCode::INVALID_PARAMS,
3562 "params",
3563 false,
3564 "Use format START-END e.g. 10-50",
3565 ),
3566 _ => (
3567 rmcp::model::ErrorCode::INTERNAL_ERROR,
3568 "api",
3569 true,
3570 "Retry or check token permissions",
3571 ),
3572 };
3573 let dur = start.elapsed().as_millis() as u64;
3574 let error_type = match &e {
3575 aptu_coder_remote::RemoteError::MissingGitLabToken => "missing_gitlab_token",
3576 aptu_coder_remote::RemoteError::MissingGitHubToken => "missing_github_token",
3577 aptu_coder_remote::RemoteError::UnsupportedHost(_) => "unsupported_host",
3578 aptu_coder_remote::RemoteError::NotFound(_) => "not_found",
3579 aptu_coder_remote::RemoteError::InvalidLineRange(_) => "invalid_line_range",
3580 _ => "remote_error",
3581 };
3582 self.metrics_tx.send(crate::metrics::MetricEvent {
3583 ts: crate::metrics::unix_ms(),
3584 tool: "remote_file",
3585 duration_ms: dur,
3586 output_chars: 0,
3587 param_path_depth: 0,
3588 max_depth: None,
3589 result: "error",
3590 error_type: Some(error_type.to_string()),
3591 session_id: sid,
3592 seq: Some(seq),
3593 cache_hit: None,
3594 cache_write_failure: None,
3595 cache_tier: None,
3596 exit_code: None,
3597 timed_out: false,
3598 });
3599 Ok(err_to_tool_result(ErrorData::new(
3600 code,
3601 e.to_string(),
3602 Some(error_meta(category, retryable, action)),
3603 )))
3604 }
3605 }
3606 }
3607}
3608
3609fn build_exec_command(
3611 command: &str,
3612 working_dir_path: Option<&std::path::PathBuf>,
3613 memory_limit_mb: Option<u64>,
3614 cpu_limit_secs: Option<u64>,
3615 stdin_present: bool,
3616 resolved_path: Option<&str>,
3617) -> tokio::process::Command {
3618 let shell = resolve_shell();
3619 let mut cmd = tokio::process::Command::new(shell);
3620 cmd.arg("-c").arg(command);
3621
3622 if let Some(wd) = working_dir_path {
3623 cmd.current_dir(wd);
3624 }
3625
3626 if let Some(path) = resolved_path {
3628 cmd.env("PATH", path);
3629 }
3630
3631 cmd.stdout(std::process::Stdio::piped())
3632 .stderr(std::process::Stdio::piped());
3633
3634 if stdin_present {
3635 cmd.stdin(std::process::Stdio::piped());
3636 } else {
3637 cmd.stdin(std::process::Stdio::null());
3638 }
3639
3640 #[cfg(unix)]
3641 {
3642 #[cfg(not(target_os = "linux"))]
3643 if memory_limit_mb.is_some() {
3644 warn!("memory_limit_mb is not enforced on this platform (Linux only)");
3645 }
3646 if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
3647 unsafe {
3651 cmd.pre_exec(move || {
3652 #[cfg(target_os = "linux")]
3653 if let Some(mb) = memory_limit_mb {
3654 let bytes = mb.saturating_mul(1024 * 1024);
3655 setrlimit(Resource::RLIMIT_AS, bytes, bytes)
3656 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3657 }
3658 if let Some(cpu) = cpu_limit_secs {
3659 setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
3660 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3661 }
3662 Ok(())
3663 });
3664 }
3665 }
3666 }
3667
3668 cmd
3669}
3670
3671async fn run_with_timeout(
3674 mut child: tokio::process::Child,
3675 timeout_secs: Option<u64>,
3676 tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
3677) -> (Option<i32>, bool, bool, Option<String>) {
3678 use tokio::io::AsyncBufReadExt as _;
3679 use tokio_stream::StreamExt as TokioStreamExt;
3680 use tokio_stream::wrappers::LinesStream;
3681
3682 let stdout_pipe = child.stdout.take();
3683 let stderr_pipe = child.stderr.take();
3684
3685 let mut drain_task = tokio::spawn(async move {
3686 let so_stream = stdout_pipe.map(|p| {
3687 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (false, s)))
3688 });
3689 let se_stream = stderr_pipe.map(|p| {
3690 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3691 });
3692
3693 match (so_stream, se_stream) {
3694 (Some(so), Some(se)) => {
3695 let mut merged = so.merge(se);
3696 while let Some(Ok((is_stderr, line))) = merged.next().await {
3697 let _ = tx.send((is_stderr, line));
3698 }
3699 }
3700 (Some(so), None) => {
3701 let mut stream = so;
3702 while let Some(Ok((_, line))) = stream.next().await {
3703 let _ = tx.send((false, line));
3704 }
3705 }
3706 (None, Some(se)) => {
3707 let mut stream = se;
3708 while let Some(Ok((_, line))) = stream.next().await {
3709 let _ = tx.send((true, line));
3710 }
3711 }
3712 (None, None) => {}
3713 }
3714 });
3715
3716 tokio::select! {
3717 _ = &mut drain_task => {
3718 let (status, drain_truncated) = match tokio::time::timeout(
3719 std::time::Duration::from_millis(500),
3720 child.wait()
3721 ).await {
3722 Ok(Ok(s)) => (Some(s), false),
3723 Ok(Err(_)) => (None, false),
3724 Err(_) => {
3725 child.start_kill().ok();
3726 let _ = child.wait().await;
3727 (None, true)
3728 }
3729 };
3730 let exit_code = status.and_then(|s| s.code());
3731 let ocerr = if drain_truncated {
3732 Some("post-exit drain timeout: background process held pipes".to_string())
3733 } else {
3734 None
3735 };
3736 (exit_code, false, drain_truncated, ocerr)
3737 }
3738 _ = async {
3739 if let Some(secs) = timeout_secs {
3740 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3741 } else {
3742 std::future::pending::<()>().await;
3743 }
3744 } => {
3745 let _ = child.kill().await;
3746 let _ = child.wait().await;
3747 drain_task.abort();
3748 (None, true, false, None)
3749 }
3750 }
3751}
3752
3753#[allow(clippy::too_many_arguments)]
3757async fn run_exec_impl(
3758 command: String,
3759 working_dir_path: Option<std::path::PathBuf>,
3760 timeout_secs: Option<u64>,
3761 memory_limit_mb: Option<u64>,
3762 cpu_limit_secs: Option<u64>,
3763 stdin: Option<String>,
3764 seq: u32,
3765 resolved_path: Option<&str>,
3766) -> types::ShellOutput {
3767 let mut cmd = build_exec_command(
3768 &command,
3769 working_dir_path.as_ref(),
3770 memory_limit_mb,
3771 cpu_limit_secs,
3772 stdin.is_some(),
3773 resolved_path,
3774 );
3775
3776 let mut child = match cmd.spawn() {
3777 Ok(c) => c,
3778 Err(e) => {
3779 return types::ShellOutput::new(
3780 String::new(),
3781 format!("failed to spawn command: {e}"),
3782 format!("failed to spawn command: {e}"),
3783 None,
3784 false,
3785 false,
3786 );
3787 }
3788 };
3789
3790 if let Some(stdin_content) = stdin
3791 && let Some(mut stdin_handle) = child.stdin.take()
3792 {
3793 use tokio::io::AsyncWriteExt as _;
3794 match stdin_handle.write_all(stdin_content.as_bytes()).await {
3795 Ok(()) => {
3796 drop(stdin_handle);
3797 }
3798 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
3799 Err(e) => {
3800 warn!("failed to write stdin: {e}");
3801 }
3802 }
3803 }
3804
3805 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, String)>();
3806
3807 let (exit_code, timed_out, mut output_truncated, output_collection_error) =
3808 run_with_timeout(child, timeout_secs, tx).await;
3809
3810 let mut lines: Vec<(bool, String)> = Vec::new();
3811 while let Some(item) = rx.recv().await {
3812 lines.push(item);
3813 }
3814
3815 const MAX_BYTES: usize = 50 * 1024;
3817 let mut stdout_str = String::new();
3818 let mut stderr_str = String::new();
3819 let mut interleaved_str = String::new();
3820 let mut so_bytes = 0usize;
3821 let mut se_bytes = 0usize;
3822 let mut il_bytes = 0usize;
3823 for (is_stderr, line) in &lines {
3824 let entry = format!("{line}\n");
3825 if il_bytes < 2 * MAX_BYTES {
3826 il_bytes += entry.len();
3827 interleaved_str.push_str(&entry);
3828 }
3829 if *is_stderr {
3830 if se_bytes < MAX_BYTES {
3831 se_bytes += entry.len();
3832 stderr_str.push_str(&entry);
3833 }
3834 } else if so_bytes < MAX_BYTES {
3835 so_bytes += entry.len();
3836 stdout_str.push_str(&entry);
3837 }
3838 }
3839
3840 let slot = seq % 8;
3841 let (stdout, stderr, stdout_path, stderr_path) =
3842 handle_output_persist(stdout_str, stderr_str, slot);
3843 output_truncated = output_truncated || stdout_path.is_some();
3844
3845 let mut output = types::ShellOutput::new(
3846 stdout,
3847 stderr,
3848 interleaved_str,
3849 exit_code,
3850 timed_out,
3851 output_truncated,
3852 );
3853 output.output_collection_error = output_collection_error;
3854 output.stdout_path = stdout_path;
3855 output.stderr_path = stderr_path;
3856
3857 output
3858}
3859
3860fn handle_output_persist(
3867 stdout: String,
3868 stderr: String,
3869 slot: u32,
3870) -> (String, String, Option<String>, Option<String>) {
3871 const MAX_OUTPUT_LINES: usize = 2000;
3872 const OVERFLOW_PREVIEW_LINES: usize = 50;
3873
3874 let stdout_lines: Vec<&str> = stdout.lines().collect();
3875 let stderr_lines: Vec<&str> = stderr.lines().collect();
3876
3877 if stdout_lines.len() <= MAX_OUTPUT_LINES && stderr_lines.len() <= MAX_OUTPUT_LINES {
3879 return (stdout, stderr, None, None);
3880 }
3881
3882 let base = std::env::temp_dir()
3884 .join("aptu-coder-overflow")
3885 .join(format!("slot-{slot}"));
3886 let _ = std::fs::create_dir_all(&base);
3887
3888 let stdout_path = base.join("stdout");
3889 let stderr_path = base.join("stderr");
3890
3891 let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3892 let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3893
3894 let stdout_path_str = stdout_path.display().to_string();
3895 let stderr_path_str = stderr_path.display().to_string();
3896
3897 let stdout_preview = if stdout_lines.len() > MAX_OUTPUT_LINES {
3898 stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3899 } else {
3900 stdout
3901 };
3902 let stderr_preview = if stderr_lines.len() > MAX_OUTPUT_LINES {
3903 stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3904 } else {
3905 stderr
3906 };
3907
3908 (
3909 stdout_preview,
3910 stderr_preview,
3911 Some(stdout_path_str),
3912 Some(stderr_path_str),
3913 )
3914}
3915
3916#[derive(Clone)]
3920struct FocusedAnalysisParams {
3921 path: std::path::PathBuf,
3922 symbol: String,
3923 match_mode: SymbolMatchMode,
3924 follow_depth: u32,
3925 max_depth: Option<u32>,
3926 ast_recursion_limit: Option<usize>,
3927 use_summary: bool,
3928 impl_only: Option<bool>,
3929 def_use: bool,
3930 parse_timeout_micros: Option<u64>,
3931}
3932
3933fn disable_routes(router: &mut ToolRouter<CodeAnalyzer>, tools: &[&'static str]) {
3934 for tool in tools {
3935 router.disable_route(*tool);
3936 }
3937}
3938
3939#[tool_handler]
3940impl ServerHandler for CodeAnalyzer {
3941 #[instrument(skip(self, context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
3942 async fn initialize(
3943 &self,
3944 request: InitializeRequestParams,
3945 context: RequestContext<RoleServer>,
3946 ) -> Result<InitializeResult, ErrorData> {
3947 let span = tracing::Span::current();
3948 span.record("service.name", "aptu-coder");
3949 span.record("service.version", env!("CARGO_PKG_VERSION"));
3950
3951 {
3953 let mut client_name_lock = self.client_name.lock().await;
3954 *client_name_lock = Some(request.client_info.name.clone());
3955 }
3956 {
3957 let mut client_version_lock = self.client_version.lock().await;
3958 *client_version_lock = Some(request.client_info.version.clone());
3959 }
3960
3961 if let Some(meta) = context.extensions.get::<Meta>() {
3964 let mut meta_lock = self.profile_meta.lock().await;
3965 *meta_lock = Some(meta.0.clone());
3966 }
3967 Ok(self.get_info())
3968 }
3969
3970 fn get_info(&self) -> InitializeResult {
3971 let excluded = crate::EXCLUDED_DIRS.join(", ");
3972 let instructions = format!(
3973 "Recommended workflow:\n\
3974 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3975 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\
3976 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3977 4. Use analyze_symbol to trace call graphs.\n\
3978 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."
3979 );
3980 let capabilities = ServerCapabilities::builder()
3981 .enable_logging()
3982 .enable_tools()
3983 .enable_tool_list_changed()
3984 .enable_completions()
3985 .build();
3986 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3987 .with_title("Aptu Coder")
3988 .with_description("MCP server for code structure analysis using tree-sitter");
3989 InitializeResult::new(capabilities)
3990 .with_server_info(server_info)
3991 .with_instructions(&instructions)
3992 }
3993
3994 async fn list_tools(
3995 &self,
3996 _request: Option<rmcp::model::PaginatedRequestParams>,
3997 _context: RequestContext<RoleServer>,
3998 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
3999 let router = self.tool_router.read().await;
4000 Ok(rmcp::model::ListToolsResult {
4001 tools: router.list_all(),
4002 meta: None,
4003 next_cursor: None,
4004 })
4005 }
4006
4007 async fn call_tool(
4008 &self,
4009 request: rmcp::model::CallToolRequestParams,
4010 context: RequestContext<RoleServer>,
4011 ) -> Result<CallToolResult, ErrorData> {
4012 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
4013 let router = self.tool_router.read().await;
4014 router.call(tcc).await
4015 }
4016
4017 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
4018 let mut peer_lock = self.peer.lock().await;
4019 *peer_lock = Some(context.peer.clone());
4020 drop(peer_lock);
4021
4022 let millis = std::time::SystemTime::now()
4024 .duration_since(std::time::UNIX_EPOCH)
4025 .unwrap_or_default()
4026 .as_millis()
4027 .try_into()
4028 .unwrap_or(u64::MAX);
4029 let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
4030 let sid = format!("{millis}-{counter}");
4031 {
4032 let mut session_id_lock = self.session_id.lock().await;
4033 *session_id_lock = Some(sid);
4034 }
4035 self.session_call_seq
4036 .store(0, std::sync::atomic::Ordering::Relaxed);
4037
4038 let meta_lock = self.profile_meta.lock().await;
4048 let meta_profile = meta_lock
4049 .as_ref()
4050 .and_then(|m| m.get("io.clouatre-labs/profile"))
4051 .and_then(|v| v.as_str())
4052 .map(str::to_owned);
4053 drop(meta_lock);
4054
4055 let active_profile = meta_profile.or(std::env::var("APTU_CODER_PROFILE").ok());
4057
4058 {
4059 let mut router = self.tool_router.write().await;
4060
4061 let enable_remote = !matches!(
4064 active_profile.as_deref(),
4065 Some("compact") | Some("edit") | Some("analyze")
4066 );
4067 if !enable_remote {
4069 disable_routes(&mut router, &["remote_tree", "remote_file"]);
4070 }
4071
4072 if let Some(ref profile) = active_profile {
4073 match profile.as_str() {
4074 "edit" => {
4075 disable_routes(
4077 &mut router,
4078 &[
4079 "analyze_directory",
4080 "analyze_file",
4081 "analyze_module",
4082 "analyze_symbol",
4083 ],
4084 );
4085 }
4087 "analyze" => {
4088 disable_routes(&mut router, &["edit_replace", "edit_overwrite"]);
4090 }
4092 "compact" => {
4093 }
4096 "remote" => {
4097 }
4099 _ => {
4100 }
4102 }
4103 }
4104
4105 router.bind_peer_notifier(&context.peer);
4107 }
4108
4109 let peer = self.peer.clone();
4111 let event_rx = self.event_rx.clone();
4112
4113 tokio::spawn(async move {
4114 let rx = {
4115 let mut rx_lock = event_rx.lock().await;
4116 rx_lock.take()
4117 };
4118
4119 if let Some(mut receiver) = rx {
4120 let mut buffer = Vec::with_capacity(64);
4121 loop {
4122 receiver.recv_many(&mut buffer, 64).await;
4124
4125 if buffer.is_empty() {
4126 break;
4128 }
4129
4130 let peer_lock = peer.lock().await;
4132 if let Some(peer) = peer_lock.as_ref() {
4133 for log_event in buffer.drain(..) {
4134 let notification = ServerNotification::LoggingMessageNotification(
4135 Notification::new(LoggingMessageNotificationParam {
4136 level: log_event.level,
4137 logger: Some(log_event.logger),
4138 data: log_event.data,
4139 }),
4140 );
4141 if let Err(e) = peer.send_notification(notification).await {
4142 warn!("Failed to send logging notification: {}", e);
4143 }
4144 }
4145 }
4146 }
4147 }
4148 });
4149 }
4150
4151 #[instrument(skip(self, _context))]
4152 async fn on_cancelled(
4153 &self,
4154 notification: CancelledNotificationParam,
4155 _context: NotificationContext<RoleServer>,
4156 ) {
4157 tracing::info!(
4158 request_id = ?notification.request_id,
4159 reason = ?notification.reason,
4160 "Received cancellation notification"
4161 );
4162 }
4163
4164 #[instrument(skip(self, _context))]
4165 async fn complete(
4166 &self,
4167 request: CompleteRequestParams,
4168 _context: RequestContext<RoleServer>,
4169 ) -> Result<CompleteResult, ErrorData> {
4170 let argument_name = &request.argument.name;
4172 let argument_value = &request.argument.value;
4173
4174 let completions = match argument_name.as_str() {
4175 "path" => {
4176 let root = Path::new(".");
4178 completion::path_completions(root, argument_value)
4179 }
4180 "symbol" => {
4181 let path_arg = request
4183 .context
4184 .as_ref()
4185 .and_then(|ctx| ctx.get_argument("path"));
4186
4187 match path_arg {
4188 Some(path_str) => {
4189 let path = Path::new(path_str);
4190 completion::symbol_completions(&self.cache, path, argument_value)
4191 }
4192 None => Vec::new(),
4193 }
4194 }
4195 _ => Vec::new(),
4196 };
4197
4198 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
4200 let (values, has_more) = if completions.len() > 100 {
4201 (completions.into_iter().take(100).collect(), true)
4202 } else {
4203 (completions, false)
4204 };
4205
4206 let completion_info =
4207 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
4208 Ok(info) => info,
4209 Err(_) => {
4210 CompletionInfo::with_all_values(Vec::new())
4212 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
4213 }
4214 };
4215
4216 Ok(CompleteResult::new(completion_info))
4217 }
4218
4219 async fn set_level(
4220 &self,
4221 params: SetLevelRequestParams,
4222 _context: RequestContext<RoleServer>,
4223 ) -> Result<(), ErrorData> {
4224 let level_filter = match params.level {
4225 LoggingLevel::Debug => LevelFilter::DEBUG,
4226 LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
4227 LoggingLevel::Warning => LevelFilter::WARN,
4228 LoggingLevel::Error
4229 | LoggingLevel::Critical
4230 | LoggingLevel::Alert
4231 | LoggingLevel::Emergency => LevelFilter::ERROR,
4232 };
4233
4234 let mut filter_lock = self
4235 .log_level_filter
4236 .lock()
4237 .unwrap_or_else(|e| e.into_inner());
4238 *filter_lock = level_filter;
4239 Ok(())
4240 }
4241}
4242
4243#[cfg(test)]
4244mod tests {
4245 use super::*;
4246
4247 #[tokio::test]
4248 async fn test_emit_progress_none_peer_is_noop() {
4249 let peer = Arc::new(TokioMutex::new(None));
4250 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4251 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4252 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4253 let analyzer = CodeAnalyzer::new(
4254 peer,
4255 log_level_filter,
4256 rx,
4257 crate::metrics::MetricsSender(metrics_tx),
4258 );
4259 let token = ProgressToken(NumberOrString::String("test".into()));
4260 analyzer
4262 .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
4263 .await;
4264 }
4265
4266 fn make_analyzer() -> CodeAnalyzer {
4267 let peer = Arc::new(TokioMutex::new(None));
4268 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4269 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4270 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4271 CodeAnalyzer::new(
4272 peer,
4273 log_level_filter,
4274 rx,
4275 crate::metrics::MetricsSender(metrics_tx),
4276 )
4277 }
4278
4279 #[test]
4280 fn test_summary_cursor_conflict() {
4281 assert!(summary_cursor_conflict(Some(true), Some("cursor")));
4282 assert!(!summary_cursor_conflict(Some(true), None));
4283 assert!(!summary_cursor_conflict(None, Some("x")));
4284 assert!(!summary_cursor_conflict(None, None));
4285 }
4286
4287 #[tokio::test]
4288 async fn test_validate_impl_only_non_rust_returns_invalid_params() {
4289 use tempfile::TempDir;
4290
4291 let dir = TempDir::new().unwrap();
4292 std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
4293
4294 let analyzer = make_analyzer();
4295 let entries: Vec<traversal::WalkEntry> =
4298 traversal::walk_directory(dir.path(), None).unwrap_or_default();
4299 let result = CodeAnalyzer::validate_impl_only(&entries);
4300 assert!(result.is_err());
4301 let err = result.unwrap_err();
4302 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
4303 drop(analyzer); }
4305
4306 #[tokio::test]
4307 async fn test_no_cache_meta_on_analyze_directory_result() {
4308 use aptu_coder_core::types::{
4309 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4310 };
4311 use tempfile::TempDir;
4312
4313 let dir = TempDir::new().unwrap();
4314 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4315
4316 let analyzer = make_analyzer();
4317 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4318 "path": dir.path().to_str().unwrap(),
4319 }))
4320 .unwrap();
4321 let ct = tokio_util::sync::CancellationToken::new();
4322 let (arc_output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
4323 let meta = no_cache_meta();
4325 assert_eq!(
4326 meta.0.get("cache_hint").and_then(|v| v.as_str()),
4327 Some("no-cache"),
4328 );
4329 drop(arc_output);
4330 }
4331
4332 #[test]
4333 fn test_complete_path_completions_returns_suggestions() {
4334 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4339 let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
4340 let suggestions = completion::path_completions(workspace_root, "aptu-");
4341 assert!(
4342 !suggestions.is_empty(),
4343 "expected completions for prefix 'aptu-' in workspace root"
4344 );
4345 }
4346
4347 #[tokio::test]
4348 async fn test_handle_overview_mode_verbose_no_summary_block() {
4349 use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
4350 use aptu_coder_core::types::{
4351 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4352 };
4353 use tempfile::TempDir;
4354
4355 let tmp = TempDir::new().unwrap();
4356 std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
4357
4358 let peer = Arc::new(TokioMutex::new(None));
4359 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4360 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4361 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4362 let analyzer = CodeAnalyzer::new(
4363 peer,
4364 log_level_filter,
4365 rx,
4366 crate::metrics::MetricsSender(metrics_tx),
4367 );
4368
4369 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4370 "path": tmp.path().to_str().unwrap(),
4371 "verbose": true,
4372 }))
4373 .unwrap();
4374
4375 let ct = tokio_util::sync::CancellationToken::new();
4376 let (output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
4377
4378 let use_summary = output.formatted.len() > SIZE_LIMIT; let paginated =
4381 paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
4382 let verbose = true;
4383 let formatted = if !use_summary {
4384 format_structure_paginated(
4385 &paginated.items,
4386 paginated.total,
4387 params.max_depth,
4388 Some(std::path::Path::new(¶ms.path)),
4389 verbose,
4390 )
4391 } else {
4392 output.formatted.clone()
4393 };
4394
4395 assert!(
4397 !formatted.contains("SUMMARY:"),
4398 "verbose=true must not emit SUMMARY: block; got: {}",
4399 &formatted[..formatted.len().min(300)]
4400 );
4401 assert!(
4402 formatted.contains("PAGINATED:"),
4403 "verbose=true must emit PAGINATED: header"
4404 );
4405 assert!(
4406 formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
4407 "verbose=true must emit FILES section header"
4408 );
4409 }
4410
4411 #[tokio::test]
4414 async fn test_analyze_directory_cache_hit_metrics() {
4415 use aptu_coder_core::types::{
4416 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4417 };
4418 use tempfile::TempDir;
4419
4420 let dir = TempDir::new().unwrap();
4422 std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
4423 let analyzer = make_analyzer();
4424 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4425 "path": dir.path().to_str().unwrap(),
4426 }))
4427 .unwrap();
4428
4429 let ct1 = tokio_util::sync::CancellationToken::new();
4431 let (_out1, hit1) = analyzer.handle_overview_mode(¶ms, ct1).await.unwrap();
4432
4433 let ct2 = tokio_util::sync::CancellationToken::new();
4435 let (_out2, hit2) = analyzer.handle_overview_mode(¶ms, ct2).await.unwrap();
4436
4437 assert_eq!(hit1, CacheTier::Miss, "first call must be a cache miss");
4439 assert_eq!(hit2, CacheTier::L1Memory, "second call must be a cache hit");
4440 }
4441
4442 #[tokio::test]
4443 async fn test_analyze_module_cache_hit_metrics() {
4444 use std::io::Write as _;
4445 use tempfile::NamedTempFile;
4446
4447 let mut f = NamedTempFile::with_suffix(".rs").unwrap();
4449 writeln!(f, "fn bar() {{}}").unwrap();
4450 let path = f.path().to_str().unwrap().to_string();
4451
4452 let analyzer = make_analyzer();
4453
4454 let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
4456 file_params.path = path.clone();
4457 file_params.ast_recursion_limit = None;
4458 file_params.fields = None;
4459 file_params.pagination.cursor = None;
4460 file_params.pagination.page_size = None;
4461 file_params.output_control.summary = None;
4462 file_params.output_control.force = None;
4463 file_params.output_control.verbose = None;
4464 let (_cached, _) = analyzer
4465 .handle_file_details_mode(&file_params)
4466 .await
4467 .unwrap();
4468
4469 let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
4471 module_params.path = path.clone();
4472
4473 let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
4475 meta.modified()
4476 .ok()
4477 .map(|mtime| aptu_coder_core::cache::CacheKey {
4478 path: std::path::PathBuf::from(&path),
4479 modified: mtime,
4480 mode: aptu_coder_core::types::AnalysisMode::FileDetails,
4481 })
4482 });
4483 let cache_hit = module_cache_key
4484 .as_ref()
4485 .and_then(|k| analyzer.cache.get(k))
4486 .is_some();
4487
4488 assert!(
4490 cache_hit,
4491 "analyze_module should find the file in the shared file cache"
4492 );
4493 drop(module_params);
4494 }
4495
4496 #[test]
4499 fn test_analyze_symbol_import_lookup_invalid_params() {
4500 let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
4504
4505 assert!(
4507 result.is_err(),
4508 "import_lookup=true with empty symbol must return Err"
4509 );
4510 let err = result.unwrap_err();
4511 assert_eq!(
4512 err.code,
4513 rmcp::model::ErrorCode::INVALID_PARAMS,
4514 "expected INVALID_PARAMS; got {:?}",
4515 err.code
4516 );
4517 }
4518
4519 #[tokio::test]
4520 async fn test_analyze_symbol_import_lookup_found() {
4521 use tempfile::TempDir;
4522
4523 let dir = TempDir::new().unwrap();
4525 std::fs::write(
4526 dir.path().join("main.rs"),
4527 "use std::collections::HashMap;\nfn main() {}\n",
4528 )
4529 .unwrap();
4530
4531 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4532
4533 let output =
4535 analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
4536
4537 assert!(
4539 output.formatted.contains("MATCHES: 1"),
4540 "expected 1 match; got: {}",
4541 output.formatted
4542 );
4543 assert!(
4544 output.formatted.contains("main.rs"),
4545 "expected main.rs in output; got: {}",
4546 output.formatted
4547 );
4548 }
4549
4550 #[tokio::test]
4551 async fn test_analyze_symbol_import_lookup_empty() {
4552 use tempfile::TempDir;
4553
4554 let dir = TempDir::new().unwrap();
4556 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
4557
4558 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4559
4560 let output =
4562 analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
4563
4564 assert!(
4566 output.formatted.contains("MATCHES: 0"),
4567 "expected 0 matches; got: {}",
4568 output.formatted
4569 );
4570 }
4571
4572 #[tokio::test]
4575 async fn test_analyze_directory_git_ref_non_git_repo() {
4576 use aptu_coder_core::traversal::changed_files_from_git_ref;
4577 use tempfile::TempDir;
4578
4579 let dir = TempDir::new().unwrap();
4581 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4582
4583 let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
4585
4586 assert!(result.is_err(), "non-git dir must return an error");
4588 let err_msg = result.unwrap_err().to_string();
4589 assert!(
4590 err_msg.contains("git"),
4591 "error must mention git; got: {err_msg}"
4592 );
4593 }
4594
4595 #[tokio::test]
4596 async fn test_analyze_directory_git_ref_filters_changed_files() {
4597 use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
4598 use std::collections::HashSet;
4599 use tempfile::TempDir;
4600
4601 let dir = TempDir::new().unwrap();
4603 let changed_file = dir.path().join("changed.rs");
4604 let unchanged_file = dir.path().join("unchanged.rs");
4605 std::fs::write(&changed_file, "fn changed() {}").unwrap();
4606 std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
4607
4608 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4609 let total_files = entries.iter().filter(|e| !e.is_dir).count();
4610 assert_eq!(total_files, 2, "sanity: 2 files before filtering");
4611
4612 let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
4614 changed.insert(changed_file.clone());
4615
4616 let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
4618 let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
4619
4620 assert_eq!(
4622 filtered_files.len(),
4623 1,
4624 "only 1 file must remain after git_ref filter"
4625 );
4626 assert_eq!(
4627 filtered_files[0].path, changed_file,
4628 "the remaining file must be the changed one"
4629 );
4630
4631 let _ = changed_files_from_git_ref;
4633 }
4634
4635 #[tokio::test]
4636 async fn test_handle_overview_mode_git_ref_filters_via_handler() {
4637 use aptu_coder_core::types::{
4638 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4639 };
4640 use std::process::Command;
4641 use tempfile::TempDir;
4642
4643 let dir = TempDir::new().unwrap();
4645 let repo = dir.path();
4646
4647 let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
4650 let mut cmd = std::process::Command::new("git");
4651 cmd.args(["-c", "core.hooksPath=/dev/null"]);
4652 cmd.args(args);
4653 cmd.current_dir(repo_path);
4654 let out = cmd.output().unwrap();
4655 assert!(out.status.success(), "{out:?}");
4656 };
4657 git_no_hook(repo, &["init"]);
4658 git_no_hook(
4659 repo,
4660 &[
4661 "-c",
4662 "user.email=ci@example.com",
4663 "-c",
4664 "user.name=CI",
4665 "commit",
4666 "--allow-empty",
4667 "-m",
4668 "initial",
4669 ],
4670 );
4671
4672 std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
4674 git_no_hook(repo, &["add", "file_a.rs"]);
4675 git_no_hook(
4676 repo,
4677 &[
4678 "-c",
4679 "user.email=ci@example.com",
4680 "-c",
4681 "user.name=CI",
4682 "commit",
4683 "-m",
4684 "add a",
4685 ],
4686 );
4687
4688 std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
4690 git_no_hook(repo, &["add", "file_b.rs"]);
4691 git_no_hook(
4692 repo,
4693 &[
4694 "-c",
4695 "user.email=ci@example.com",
4696 "-c",
4697 "user.name=CI",
4698 "commit",
4699 "-m",
4700 "add b",
4701 ],
4702 );
4703
4704 let canon_repo = std::fs::canonicalize(repo).unwrap();
4710 let analyzer = make_analyzer();
4711 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4712 "path": canon_repo.to_str().unwrap(),
4713 "git_ref": "HEAD~1",
4714 }))
4715 .unwrap();
4716 let ct = tokio_util::sync::CancellationToken::new();
4717 let (arc_output, _cache_hit) = analyzer
4718 .handle_overview_mode(¶ms, ct)
4719 .await
4720 .expect("handle_overview_mode with git_ref must succeed");
4721
4722 let formatted = &arc_output.formatted;
4724 assert!(
4725 formatted.contains("file_b.rs"),
4726 "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
4727 );
4728 assert!(
4729 !formatted.contains("file_a.rs"),
4730 "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
4731 );
4732 }
4733
4734 #[test]
4735 fn test_validate_path_rejects_absolute_path_outside_cwd() {
4736 let result = validate_path("/etc/passwd", true);
4739 assert!(
4740 result.is_err(),
4741 "validate_path should reject /etc/passwd (outside CWD)"
4742 );
4743 let err = result.unwrap_err();
4744 let err_msg = err.message.to_lowercase();
4745 assert!(
4746 err_msg.contains("outside") || err_msg.contains("not found"),
4747 "Error message should mention 'outside' or 'not found': {}",
4748 err.message
4749 );
4750 }
4751
4752 #[test]
4753 fn test_validate_path_accepts_relative_path_in_cwd() {
4754 let result = validate_path("Cargo.toml", true);
4757 assert!(
4758 result.is_ok(),
4759 "validate_path should accept Cargo.toml (exists in CWD)"
4760 );
4761 }
4762
4763 #[test]
4764 fn test_validate_path_creates_parent_for_nonexistent_file() {
4765 let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4768 assert!(
4769 result.is_ok(),
4770 "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4771 );
4772 let path = result.unwrap();
4773 let cwd = std::env::current_dir().expect("should get cwd");
4774 let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4775 assert!(
4776 path.starts_with(&canonical_cwd),
4777 "Resolved path should be within CWD: {:?} should start with {:?}",
4778 path,
4779 canonical_cwd
4780 );
4781 }
4782
4783 #[test]
4784 fn test_edit_overwrite_with_working_dir() {
4785 let cwd = std::env::current_dir().expect("should get cwd");
4787 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4788 let temp_path = temp_dir.path();
4789
4790 let result = validate_path_in_dir("test_file.txt", false, temp_path);
4792
4793 assert!(
4795 result.is_ok(),
4796 "validate_path_in_dir should accept relative path in valid working_dir: {:?}",
4797 result.err()
4798 );
4799 let resolved = result.unwrap();
4800 assert!(
4801 resolved.starts_with(temp_path),
4802 "Resolved path should be within working_dir: {:?} should start with {:?}",
4803 resolved,
4804 temp_path
4805 );
4806 }
4807
4808 #[test]
4809 fn test_edit_overwrite_working_dir_traversal() {
4810 let cwd = std::env::current_dir().expect("should get cwd");
4812 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4813 let temp_path = temp_dir.path();
4814
4815 let result = validate_path_in_dir("../../../etc/passwd", false, temp_path);
4817
4818 assert!(
4820 result.is_err(),
4821 "validate_path_in_dir should reject path traversal outside working_dir"
4822 );
4823 let err = result.unwrap_err();
4824 let err_msg = err.message.to_lowercase();
4825 assert!(
4826 err_msg.contains("outside") || err_msg.contains("working"),
4827 "Error message should mention 'outside' or 'working': {}",
4828 err.message
4829 );
4830 }
4831
4832 #[test]
4833 fn test_edit_replace_with_working_dir() {
4834 let cwd = std::env::current_dir().expect("should get cwd");
4836 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4837 let temp_path = temp_dir.path();
4838 let file_path = temp_path.join("test.txt");
4839 std::fs::write(&file_path, "hello world").expect("should write test file");
4840
4841 let result = validate_path_in_dir("test.txt", true, temp_path);
4843
4844 assert!(
4846 result.is_ok(),
4847 "validate_path_in_dir should find existing file in working_dir: {:?}",
4848 result.err()
4849 );
4850 let resolved = result.unwrap();
4851 assert_eq!(
4852 resolved, file_path,
4853 "Resolved path should match the actual file path"
4854 );
4855 }
4856
4857 #[test]
4858 fn test_edit_overwrite_no_working_dir() {
4859 let result = validate_path("Cargo.toml", true);
4864
4865 assert!(
4867 result.is_ok(),
4868 "validate_path should still work without working_dir"
4869 );
4870 }
4871
4872 #[test]
4873 fn test_edit_overwrite_working_dir_is_file() {
4874 let cwd = std::env::current_dir().expect("should get cwd");
4876 let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4877 let temp_file = temp_dir.path().join("test_file.txt");
4878 std::fs::write(&temp_file, "test content").expect("should write test file");
4879
4880 let result = validate_path_in_dir("some_file.txt", false, &temp_file);
4882
4883 assert!(
4885 result.is_err(),
4886 "validate_path_in_dir should reject a file as working_dir"
4887 );
4888 let err = result.unwrap_err();
4889 let err_msg = err.message.to_lowercase();
4890 assert!(
4891 err_msg.contains("directory"),
4892 "Error message should mention 'directory': {}",
4893 err.message
4894 );
4895 }
4896
4897 #[test]
4898 fn test_tool_annotations() {
4899 let tools = CodeAnalyzer::list_tools();
4901
4902 let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
4904 let exec_command = tools.iter().find(|t| t.name == "exec_command");
4905
4906 let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
4908 let analyze_dir_annot = analyze_dir_tool
4909 .annotations
4910 .as_ref()
4911 .expect("analyze_directory should have annotations");
4912 assert_eq!(
4913 analyze_dir_annot.read_only_hint,
4914 Some(true),
4915 "analyze_directory read_only_hint should be true"
4916 );
4917 assert_eq!(
4918 analyze_dir_annot.destructive_hint,
4919 Some(false),
4920 "analyze_directory destructive_hint should be false"
4921 );
4922
4923 let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
4925 let exec_cmd_annot = exec_cmd_tool
4926 .annotations
4927 .as_ref()
4928 .expect("exec_command should have annotations");
4929 assert_eq!(
4930 exec_cmd_annot.open_world_hint,
4931 Some(true),
4932 "exec_command open_world_hint should be true"
4933 );
4934 }
4935
4936 #[test]
4937 fn test_profile_remote_enables_remote_tools() {
4938 let tools = CodeAnalyzer::list_tools();
4940
4941 let remote_tree = tools.iter().find(|t| t.name == "remote_tree");
4943 let remote_file = tools.iter().find(|t| t.name == "remote_file");
4944
4945 assert!(
4948 remote_tree.is_some(),
4949 "remote_tree should exist in full tool list"
4950 );
4951 assert!(
4952 remote_file.is_some(),
4953 "remote_file should exist in full tool list"
4954 );
4955 }
4956
4957 #[test]
4958 fn test_profile_none_disables_remote_tools() {
4959 let tools = CodeAnalyzer::list_tools();
4961
4962 let tool_count = tools.len();
4964
4965 assert_eq!(
4968 tool_count, 9,
4969 "static tool list should contain all 9 tools; filtering happens at runtime"
4970 );
4971
4972 let remote_tree = tools.iter().find(|t| t.name == "remote_tree");
4974 let remote_file = tools.iter().find(|t| t.name == "remote_file");
4975 assert!(
4976 remote_tree.is_some(),
4977 "remote_tree should exist in static list"
4978 );
4979 assert!(
4980 remote_file.is_some(),
4981 "remote_file should exist in static list"
4982 );
4983 }
4984
4985 #[test]
4986 fn test_exec_stdin_size_cap_validation() {
4987 let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
4990
4991 assert!(
4993 oversized_stdin.len() > STDIN_MAX_BYTES,
4994 "test setup: oversized stdin should exceed 1 MB"
4995 );
4996
4997 let max_stdin = "y".repeat(STDIN_MAX_BYTES);
4999 assert_eq!(
5000 max_stdin.len(),
5001 STDIN_MAX_BYTES,
5002 "test setup: max stdin should be exactly 1 MB"
5003 );
5004 }
5005
5006 #[tokio::test]
5007 async fn test_exec_stdin_cat_roundtrip() {
5008 let stdin_content = "hello world";
5011
5012 let mut child = tokio::process::Command::new("sh")
5014 .arg("-c")
5015 .arg("cat")
5016 .stdin(std::process::Stdio::piped())
5017 .stdout(std::process::Stdio::piped())
5018 .stderr(std::process::Stdio::piped())
5019 .spawn()
5020 .expect("spawn cat");
5021
5022 if let Some(mut stdin_handle) = child.stdin.take() {
5023 use tokio::io::AsyncWriteExt as _;
5024 stdin_handle
5025 .write_all(stdin_content.as_bytes())
5026 .await
5027 .expect("write stdin");
5028 drop(stdin_handle);
5029 }
5030
5031 let output = child.wait_with_output().await.expect("wait for cat");
5032
5033 let stdout_str = String::from_utf8_lossy(&output.stdout);
5035 assert!(
5036 stdout_str.contains(stdin_content),
5037 "stdout should contain stdin content: {}",
5038 stdout_str
5039 );
5040 }
5041
5042 #[tokio::test]
5043 async fn test_exec_stdin_none_no_regression() {
5044 let child = tokio::process::Command::new("sh")
5047 .arg("-c")
5048 .arg("echo hi")
5049 .stdin(std::process::Stdio::null())
5050 .stdout(std::process::Stdio::piped())
5051 .stderr(std::process::Stdio::piped())
5052 .spawn()
5053 .expect("spawn echo");
5054
5055 let output = child.wait_with_output().await.expect("wait for echo");
5056
5057 let stdout_str = String::from_utf8_lossy(&output.stdout);
5059 assert!(
5060 stdout_str.contains("hi"),
5061 "stdout should contain echo output: {}",
5062 stdout_str
5063 );
5064 }
5065
5066 #[test]
5067 fn test_validate_path_in_dir_rejects_sibling_prefix() {
5068 let cwd = std::env::current_dir().expect("should get cwd");
5073 let parent = tempfile::TempDir::new_in(&cwd).expect("should create parent temp dir");
5074 let allowed = parent.path().join("allowed");
5075 let sibling = parent.path().join("allowed_sibling");
5076 std::fs::create_dir_all(&allowed).expect("should create allowed dir");
5077 std::fs::create_dir_all(&sibling).expect("should create sibling dir");
5078
5079 let result = validate_path_in_dir("../allowed_sibling/secret.txt", false, &allowed);
5082
5083 assert!(
5085 result.is_err(),
5086 "validate_path_in_dir must reject a path resolving to a sibling directory \
5087 sharing the working_dir name prefix (CVE-2025-53110 pattern)"
5088 );
5089 let err = result.unwrap_err();
5090 let msg = err.message.to_lowercase();
5091 assert!(
5092 msg.contains("outside") || msg.contains("working"),
5093 "Error should mention 'outside' or 'working', got: {}",
5094 err.message
5095 );
5096 }
5097
5098 #[test]
5099 #[serial_test::serial]
5100 fn test_file_cache_capacity_default() {
5101 unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
5103
5104 let analyzer = make_analyzer();
5106
5107 assert_eq!(analyzer.cache.file_capacity(), 100);
5109 }
5110
5111 #[test]
5112 #[serial_test::serial]
5113 fn test_file_cache_capacity_from_env() {
5114 unsafe { std::env::set_var("APTU_CODER_FILE_CACHE_CAPACITY", "42") };
5116
5117 let analyzer = make_analyzer();
5119
5120 unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
5122
5123 assert_eq!(analyzer.cache.file_capacity(), 42);
5125 }
5126
5127 #[test]
5128 fn test_exec_command_path_injected() {
5129 let resolved_path = Some("/usr/local/bin:/usr/bin:/bin");
5131 let cmd = build_exec_command("echo test", None, None, None, false, resolved_path);
5132
5133 let cmd_str = format!("{:?}", cmd);
5137
5138 assert!(
5140 !cmd_str.is_empty(),
5141 "build_exec_command should return a valid Command"
5142 );
5143 }
5144
5145 #[test]
5146 fn test_exec_command_path_fallback() {
5147 let cmd = build_exec_command("echo test", None, None, None, false, None);
5149
5150 let cmd_str = format!("{:?}", cmd);
5152
5153 assert!(
5155 !cmd_str.is_empty(),
5156 "build_exec_command should handle None resolved_path gracefully"
5157 );
5158 }
5159}