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