1pub mod logging;
19pub mod metrics;
20
21pub use aptu_coder_core::analyze;
22use aptu_coder_core::types::STDIN_MAX_BYTES;
23use aptu_coder_core::{cache, completion, graph, traversal, types};
24
25pub(crate) const EXCLUDED_DIRS: &[&str] = &[
26 "node_modules",
27 "vendor",
28 ".git",
29 "__pycache__",
30 "target",
31 "dist",
32 "build",
33 ".venv",
34];
35
36use aptu_coder_core::cache::AnalysisCache;
37use aptu_coder_core::formatter::{
38 format_file_details_paginated, format_file_details_summary, format_focused_paginated,
39 format_module_info, format_structure_paginated, format_summary,
40};
41use aptu_coder_core::formatter_defuse::format_focused_paginated_defuse;
42use aptu_coder_core::pagination::{
43 CursorData, DEFAULT_PAGE_SIZE, PaginationMode, decode_cursor, encode_cursor, paginate_slice,
44};
45use aptu_coder_core::traversal::{
46 WalkEntry, changed_files_from_git_ref, filter_entries_by_git_ref, walk_directory,
47};
48use aptu_coder_core::types::{
49 AnalysisMode, AnalyzeDirectoryParams, AnalyzeFileParams, AnalyzeModuleParams,
50 AnalyzeSymbolParams, EditInsertOutput, EditInsertParams, EditOverwriteOutput,
51 EditOverwriteParams, EditRenameOutput, EditRenameParams, EditReplaceOutput, EditReplaceParams,
52 SymbolMatchMode,
53};
54use aptu_coder_core::{edit_insert_at_symbol, edit_rename_directory, edit_rename_in_file};
55use logging::LogEvent;
56use rmcp::handler::server::tool::{ToolRouter, schema_for_type};
57use rmcp::handler::server::wrapper::Parameters;
58use rmcp::model::{
59 CallToolResult, CancelledNotificationParam, CompleteRequestParams, CompleteResult,
60 CompletionInfo, Content, ErrorData, Implementation, InitializeRequestParams, InitializeResult,
61 LoggingLevel, LoggingMessageNotificationParam, Meta, Notification, NumberOrString,
62 ProgressNotificationParam, ProgressToken, ServerCapabilities, ServerNotification,
63 SetLevelRequestParams,
64};
65use rmcp::service::{NotificationContext, RequestContext};
66use rmcp::{Peer, RoleServer, ServerHandler, tool, tool_handler, tool_router};
67use serde_json::Value;
68use std::path::{Path, PathBuf};
69use std::sync::{Arc, Mutex};
70use tokio::sync::{Mutex as TokioMutex, RwLock, mpsc};
71use tracing::{instrument, warn};
72use tracing_subscriber::filter::LevelFilter;
73
74#[cfg(unix)]
75use nix::sys::resource::{Resource, setrlimit};
76
77static GLOBAL_SESSION_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
78
79const SIZE_LIMIT: usize = 50_000;
80
81#[must_use]
84pub fn summary_cursor_conflict(summary: Option<bool>, cursor: Option<&str>) -> bool {
85 summary == Some(true) && cursor.is_some()
86}
87
88#[must_use]
89fn error_meta(
90 category: &'static str,
91 is_retryable: bool,
92 suggested_action: &'static str,
93) -> serde_json::Value {
94 serde_json::json!({
95 "errorCategory": category,
96 "isRetryable": is_retryable,
97 "suggestedAction": suggested_action,
98 })
99}
100
101#[must_use]
102fn err_to_tool_result(e: ErrorData) -> CallToolResult {
103 CallToolResult::error(vec![Content::text(e.message)])
104}
105
106fn err_to_tool_result_from_pagination(
107 e: aptu_coder_core::pagination::PaginationError,
108) -> CallToolResult {
109 let msg = format!("Pagination error: {}", e);
110 CallToolResult::error(vec![Content::text(msg)])
111}
112
113fn no_cache_meta() -> Meta {
114 let mut m = serde_json::Map::new();
115 m.insert(
116 "cache_hint".to_string(),
117 serde_json::Value::String("no-cache".to_string()),
118 );
119 Meta(m)
120}
121
122fn validate_path(path: &str, require_exists: bool) -> Result<std::path::PathBuf, ErrorData> {
126 let allowed_root = std::fs::canonicalize(std::env::current_dir().map_err(|_| {
128 ErrorData::new(
129 rmcp::model::ErrorCode::INVALID_PARAMS,
130 "path is outside the allowed root".to_string(),
131 Some(error_meta(
132 "validation",
133 false,
134 "ensure the working directory is accessible",
135 )),
136 )
137 })?)
138 .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
139
140 let canonical_path = if require_exists {
141 std::fs::canonicalize(path).map_err(|e| {
142 let msg = match e.kind() {
143 std::io::ErrorKind::NotFound => format!("path not found: {path}"),
144 std::io::ErrorKind::PermissionDenied => format!("permission denied: {path}"),
145 _ => "path is outside the allowed root".to_string(),
146 };
147 ErrorData::new(
148 rmcp::model::ErrorCode::INVALID_PARAMS,
149 msg,
150 Some(error_meta(
151 "validation",
152 false,
153 "provide a valid path within the working directory",
154 )),
155 )
156 })?
157 } else {
158 let p = std::path::Path::new(path);
160 let mut ancestor = p.to_path_buf();
161 let mut suffix = std::path::PathBuf::new();
162
163 loop {
164 if ancestor.exists() {
165 break;
166 }
167 if let Some(parent) = ancestor.parent() {
168 if let Some(file_name) = ancestor.file_name() {
169 suffix = std::path::PathBuf::from(file_name).join(&suffix);
170 }
171 ancestor = parent.to_path_buf();
172 } else {
173 ancestor = allowed_root.clone();
175 break;
176 }
177 }
178
179 let canonical_base =
180 std::fs::canonicalize(&ancestor).unwrap_or_else(|_| allowed_root.clone());
181 canonical_base.join(&suffix)
182 };
183
184 if !canonical_path.starts_with(&allowed_root) {
185 return Err(ErrorData::new(
186 rmcp::model::ErrorCode::INVALID_PARAMS,
187 "path is outside the allowed root".to_string(),
188 Some(error_meta(
189 "validation",
190 false,
191 "provide a path within the current working directory",
192 )),
193 ));
194 }
195
196 Ok(canonical_path)
197}
198
199fn paginate_focus_chains(
202 chains: &[graph::InternalCallChain],
203 mode: PaginationMode,
204 offset: usize,
205 page_size: usize,
206) -> Result<(Vec<graph::InternalCallChain>, Option<String>), ErrorData> {
207 let paginated = paginate_slice(chains, offset, page_size, mode).map_err(|e| {
208 ErrorData::new(
209 rmcp::model::ErrorCode::INTERNAL_ERROR,
210 e.to_string(),
211 Some(error_meta("transient", true, "retry the request")),
212 )
213 })?;
214
215 if paginated.next_cursor.is_none() && offset == 0 {
216 return Ok((paginated.items, None));
217 }
218
219 let next = if let Some(raw_cursor) = paginated.next_cursor {
220 let decoded = decode_cursor(&raw_cursor).map_err(|e| {
221 ErrorData::new(
222 rmcp::model::ErrorCode::INVALID_PARAMS,
223 e.to_string(),
224 Some(error_meta("validation", false, "invalid cursor format")),
225 )
226 })?;
227 Some(
228 encode_cursor(&CursorData {
229 mode,
230 offset: decoded.offset,
231 })
232 .map_err(|e| {
233 ErrorData::new(
234 rmcp::model::ErrorCode::INVALID_PARAMS,
235 e.to_string(),
236 Some(error_meta("validation", false, "invalid cursor format")),
237 )
238 })?,
239 )
240 } else {
241 None
242 };
243
244 Ok((paginated.items, next))
245}
246
247fn resolve_shell() -> String {
251 if let Ok(shell) = std::env::var("APTU_SHELL") {
252 return shell;
253 }
254 #[cfg(unix)]
255 {
256 if which::which("bash").is_ok() {
257 return "bash".to_string();
258 }
259 "/bin/sh".to_string()
260 }
261 #[cfg(not(unix))]
262 {
263 "cmd".to_string()
264 }
265}
266
267#[derive(Clone)]
272pub struct CodeAnalyzer {
273 #[allow(dead_code)]
281 pub(crate) tool_router: Arc<RwLock<ToolRouter<Self>>>,
282 cache: AnalysisCache,
283 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
284 log_level_filter: Arc<Mutex<LevelFilter>>,
285 event_rx: Arc<TokioMutex<Option<mpsc::UnboundedReceiver<LogEvent>>>>,
286 metrics_tx: crate::metrics::MetricsSender,
287 session_call_seq: Arc<std::sync::atomic::AtomicU32>,
288 session_id: Arc<TokioMutex<Option<String>>>,
289 profile_meta: Arc<TokioMutex<Option<serde_json::Map<String, serde_json::Value>>>>,
291}
292
293#[tool_router]
294impl CodeAnalyzer {
295 #[must_use]
296 pub fn list_tools() -> Vec<rmcp::model::Tool> {
297 Self::tool_router().list_all()
298 }
299
300 pub fn new(
301 peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
302 log_level_filter: Arc<Mutex<LevelFilter>>,
303 event_rx: mpsc::UnboundedReceiver<LogEvent>,
304 metrics_tx: crate::metrics::MetricsSender,
305 ) -> Self {
306 let file_cap: usize = std::env::var("CODE_ANALYZE_FILE_CACHE_CAPACITY")
307 .ok()
308 .and_then(|v| v.parse().ok())
309 .unwrap_or(100);
310 CodeAnalyzer {
311 tool_router: Arc::new(RwLock::new(Self::tool_router())),
312 cache: AnalysisCache::new(file_cap),
313 peer,
314 log_level_filter,
315 event_rx: Arc::new(TokioMutex::new(Some(event_rx))),
316 metrics_tx,
317 session_call_seq: Arc::new(std::sync::atomic::AtomicU32::new(0)),
318 session_id: Arc::new(TokioMutex::new(None)),
319 profile_meta: Arc::new(TokioMutex::new(None)),
320 }
321 }
322
323 #[instrument(skip(self))]
324 async fn emit_progress(
325 &self,
326 peer: Option<Peer<RoleServer>>,
327 token: &ProgressToken,
328 progress: f64,
329 total: f64,
330 message: String,
331 ) {
332 if let Some(peer) = peer {
333 let notification = ServerNotification::ProgressNotification(Notification::new(
334 ProgressNotificationParam {
335 progress_token: token.clone(),
336 progress,
337 total: Some(total),
338 message: Some(message),
339 },
340 ));
341 if let Err(e) = peer.send_notification(notification).await {
342 warn!("Failed to send progress notification: {}", e);
343 }
344 }
345 }
346
347 #[allow(clippy::too_many_lines)] #[allow(clippy::cast_precision_loss)] #[instrument(skip(self, params, ct))]
353 async fn handle_overview_mode(
354 &self,
355 params: &AnalyzeDirectoryParams,
356 ct: tokio_util::sync::CancellationToken,
357 ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, bool), ErrorData> {
358 let path = Path::new(¶ms.path);
359 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
360 let counter_clone = counter.clone();
361 let path_owned = path.to_path_buf();
362 let max_depth = params.max_depth;
363 let ct_clone = ct.clone();
364
365 let all_entries = walk_directory(path, None).map_err(|e| {
367 ErrorData::new(
368 rmcp::model::ErrorCode::INTERNAL_ERROR,
369 format!("Failed to walk directory: {e}"),
370 Some(error_meta(
371 "resource",
372 false,
373 "check path permissions and availability",
374 )),
375 )
376 })?;
377
378 let canonical_max_depth = max_depth.and_then(|d| if d == 0 { None } else { Some(d) });
380
381 let git_ref_val = params.git_ref.as_deref().filter(|s| !s.is_empty());
384 let cache_key = cache::DirectoryCacheKey::from_entries(
385 &all_entries,
386 canonical_max_depth,
387 AnalysisMode::Overview,
388 git_ref_val,
389 );
390
391 if let Some(cached) = self.cache.get_directory(&cache_key) {
393 return Ok((cached, true));
394 }
395
396 let all_entries = if let Some(ref git_ref) = params.git_ref
398 && !git_ref.is_empty()
399 {
400 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
401 ErrorData::new(
402 rmcp::model::ErrorCode::INVALID_PARAMS,
403 format!("git_ref filter failed: {e}"),
404 Some(error_meta(
405 "resource",
406 false,
407 "ensure git is installed and path is inside a git repository",
408 )),
409 )
410 })?;
411 filter_entries_by_git_ref(all_entries, &changed, path)
412 } else {
413 all_entries
414 };
415
416 let subtree_counts = if max_depth.is_some_and(|d| d > 0) {
418 Some(traversal::subtree_counts_from_entries(path, &all_entries))
419 } else {
420 None
421 };
422
423 let entries: Vec<traversal::WalkEntry> = if let Some(depth) = max_depth
425 && depth > 0
426 {
427 all_entries
428 .into_iter()
429 .filter(|e| e.depth <= depth as usize)
430 .collect()
431 } else {
432 all_entries
433 };
434
435 let total_files = entries.iter().filter(|e| !e.is_dir).count();
437
438 let handle = tokio::task::spawn_blocking(move || {
440 analyze::analyze_directory_with_progress(&path_owned, entries, counter_clone, ct_clone)
441 });
442
443 let token = ProgressToken(NumberOrString::String(
445 format!(
446 "analyze-overview-{}",
447 std::time::SystemTime::now()
448 .duration_since(std::time::UNIX_EPOCH)
449 .map(|d| d.as_nanos())
450 .unwrap_or(0)
451 )
452 .into(),
453 ));
454 let peer = self.peer.lock().await.clone();
455 let mut last_progress = 0usize;
456 let mut cancelled = false;
457 loop {
458 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
459 if ct.is_cancelled() {
460 cancelled = true;
461 break;
462 }
463 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
464 if current != last_progress && total_files > 0 {
465 self.emit_progress(
466 peer.clone(),
467 &token,
468 current as f64,
469 total_files as f64,
470 format!("Analyzing {current}/{total_files} files"),
471 )
472 .await;
473 last_progress = current;
474 }
475 if handle.is_finished() {
476 break;
477 }
478 }
479
480 if !cancelled && total_files > 0 {
482 self.emit_progress(
483 peer.clone(),
484 &token,
485 total_files as f64,
486 total_files as f64,
487 format!("Completed analyzing {total_files} files"),
488 )
489 .await;
490 }
491
492 match handle.await {
493 Ok(Ok(mut output)) => {
494 output.subtree_counts = subtree_counts;
495 let arc_output = std::sync::Arc::new(output);
496 self.cache.put_directory(cache_key, arc_output.clone());
497 Ok((arc_output, false))
498 }
499 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
500 rmcp::model::ErrorCode::INTERNAL_ERROR,
501 "Analysis cancelled".to_string(),
502 Some(error_meta("transient", true, "analysis was cancelled")),
503 )),
504 Ok(Err(e)) => Err(ErrorData::new(
505 rmcp::model::ErrorCode::INTERNAL_ERROR,
506 format!("Error analyzing directory: {e}"),
507 Some(error_meta(
508 "resource",
509 false,
510 "check path and file permissions",
511 )),
512 )),
513 Err(e) => Err(ErrorData::new(
514 rmcp::model::ErrorCode::INTERNAL_ERROR,
515 format!("Task join error: {e}"),
516 Some(error_meta("transient", true, "retry the request")),
517 )),
518 }
519 }
520
521 #[instrument(skip(self, params))]
524 async fn handle_file_details_mode(
525 &self,
526 params: &AnalyzeFileParams,
527 ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, bool), ErrorData> {
528 let cache_key = std::fs::metadata(¶ms.path).ok().and_then(|meta| {
530 meta.modified().ok().map(|mtime| cache::CacheKey {
531 path: std::path::PathBuf::from(¶ms.path),
532 modified: mtime,
533 mode: AnalysisMode::FileDetails,
534 })
535 });
536
537 if let Some(ref key) = cache_key
539 && let Some(cached) = self.cache.get(key)
540 {
541 return Ok((cached, true));
542 }
543
544 match analyze::analyze_file(¶ms.path, params.ast_recursion_limit) {
546 Ok(output) => {
547 let arc_output = std::sync::Arc::new(output);
548 if let Some(key) = cache_key {
549 self.cache.put(key, arc_output.clone());
550 }
551 Ok((arc_output, false))
552 }
553 Err(e) => Err(ErrorData::new(
554 rmcp::model::ErrorCode::INTERNAL_ERROR,
555 format!("Error analyzing file: {e}"),
556 Some(error_meta(
557 "resource",
558 false,
559 "check file path and permissions",
560 )),
561 )),
562 }
563 }
564
565 fn validate_impl_only(entries: &[WalkEntry]) -> Result<(), ErrorData> {
567 let has_rust = entries.iter().any(|e| {
568 !e.is_dir
569 && e.path
570 .extension()
571 .and_then(|x: &std::ffi::OsStr| x.to_str())
572 == Some("rs")
573 });
574
575 if !has_rust {
576 return Err(ErrorData::new(
577 rmcp::model::ErrorCode::INVALID_PARAMS,
578 "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(),
579 Some(error_meta(
580 "validation",
581 false,
582 "remove impl_only or point to a directory containing .rs files",
583 )),
584 ));
585 }
586 Ok(())
587 }
588
589 fn validate_import_lookup(import_lookup: Option<bool>, symbol: &str) -> Result<(), ErrorData> {
591 if import_lookup == Some(true) && symbol.is_empty() {
592 return Err(ErrorData::new(
593 rmcp::model::ErrorCode::INVALID_PARAMS,
594 "import_lookup=true requires symbol to contain the module path to search for"
595 .to_string(),
596 Some(error_meta(
597 "validation",
598 false,
599 "set symbol to the module path when using import_lookup=true",
600 )),
601 ));
602 }
603 Ok(())
604 }
605
606 #[allow(clippy::cast_precision_loss)] async fn poll_progress_until_done(
609 &self,
610 analysis_params: &FocusedAnalysisParams,
611 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
612 ct: tokio_util::sync::CancellationToken,
613 entries: std::sync::Arc<Vec<WalkEntry>>,
614 total_files: usize,
615 symbol_display: &str,
616 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
617 let counter_clone = counter.clone();
618 let ct_clone = ct.clone();
619 let entries_clone = std::sync::Arc::clone(&entries);
620 let path_owned = analysis_params.path.clone();
621 let symbol_owned = analysis_params.symbol.clone();
622 let match_mode_owned = analysis_params.match_mode.clone();
623 let follow_depth = analysis_params.follow_depth;
624 let max_depth = analysis_params.max_depth;
625 let ast_recursion_limit = analysis_params.ast_recursion_limit;
626 let use_summary = analysis_params.use_summary;
627 let impl_only = analysis_params.impl_only;
628 let def_use = analysis_params.def_use;
629 let handle = tokio::task::spawn_blocking(move || {
630 let params = analyze::FocusedAnalysisConfig {
631 focus: symbol_owned,
632 match_mode: match_mode_owned,
633 follow_depth,
634 max_depth,
635 ast_recursion_limit,
636 use_summary,
637 impl_only,
638 def_use,
639 };
640 analyze::analyze_focused_with_progress_with_entries(
641 &path_owned,
642 ¶ms,
643 &counter_clone,
644 &ct_clone,
645 &entries_clone,
646 )
647 });
648
649 let token = ProgressToken(NumberOrString::String(
650 format!(
651 "analyze-symbol-{}",
652 std::time::SystemTime::now()
653 .duration_since(std::time::UNIX_EPOCH)
654 .map(|d| d.as_nanos())
655 .unwrap_or(0)
656 )
657 .into(),
658 ));
659 let peer = self.peer.lock().await.clone();
660 let mut last_progress = 0usize;
661 let mut cancelled = false;
662
663 loop {
664 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
665 if ct.is_cancelled() {
666 cancelled = true;
667 break;
668 }
669 let current = counter.load(std::sync::atomic::Ordering::Relaxed);
670 if current != last_progress && total_files > 0 {
671 self.emit_progress(
672 peer.clone(),
673 &token,
674 current as f64,
675 total_files as f64,
676 format!(
677 "Analyzing {current}/{total_files} files for symbol '{symbol_display}'"
678 ),
679 )
680 .await;
681 last_progress = current;
682 }
683 if handle.is_finished() {
684 break;
685 }
686 }
687
688 if !cancelled && total_files > 0 {
689 self.emit_progress(
690 peer.clone(),
691 &token,
692 total_files as f64,
693 total_files as f64,
694 format!("Completed analyzing {total_files} files for symbol '{symbol_display}'"),
695 )
696 .await;
697 }
698
699 match handle.await {
700 Ok(Ok(output)) => Ok(output),
701 Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
702 rmcp::model::ErrorCode::INTERNAL_ERROR,
703 "Analysis cancelled".to_string(),
704 Some(error_meta("transient", true, "analysis was cancelled")),
705 )),
706 Ok(Err(e)) => Err(ErrorData::new(
707 rmcp::model::ErrorCode::INTERNAL_ERROR,
708 format!("Error analyzing symbol: {e}"),
709 Some(error_meta("resource", false, "check symbol name and file")),
710 )),
711 Err(e) => Err(ErrorData::new(
712 rmcp::model::ErrorCode::INTERNAL_ERROR,
713 format!("Task join error: {e}"),
714 Some(error_meta("transient", true, "retry the request")),
715 )),
716 }
717 }
718
719 async fn run_focused_with_auto_summary(
721 &self,
722 params: &AnalyzeSymbolParams,
723 analysis_params: &FocusedAnalysisParams,
724 counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
725 ct: tokio_util::sync::CancellationToken,
726 entries: std::sync::Arc<Vec<WalkEntry>>,
727 total_files: usize,
728 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
729 let use_summary_for_task = params.output_control.force != Some(true)
730 && params.output_control.summary == Some(true);
731
732 let analysis_params_initial = FocusedAnalysisParams {
733 use_summary: use_summary_for_task,
734 ..analysis_params.clone()
735 };
736
737 let mut output = self
738 .poll_progress_until_done(
739 &analysis_params_initial,
740 counter.clone(),
741 ct.clone(),
742 entries.clone(),
743 total_files,
744 ¶ms.symbol,
745 )
746 .await?;
747
748 if params.output_control.summary.is_none()
749 && params.output_control.force != Some(true)
750 && output.formatted.len() > SIZE_LIMIT
751 {
752 let counter2 = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
753 let analysis_params_retry = FocusedAnalysisParams {
754 use_summary: true,
755 ..analysis_params.clone()
756 };
757 let summary_result = self
758 .poll_progress_until_done(
759 &analysis_params_retry,
760 counter2,
761 ct,
762 entries,
763 total_files,
764 ¶ms.symbol,
765 )
766 .await;
767
768 if let Ok(summary_output) = summary_result {
769 output.formatted = summary_output.formatted;
770 } else {
771 let estimated_tokens = output.formatted.len() / 4;
772 let message = format!(
773 "Output exceeds 50K chars ({} chars, ~{} tokens). Use summary=true or force=true.",
774 output.formatted.len(),
775 estimated_tokens
776 );
777 return Err(ErrorData::new(
778 rmcp::model::ErrorCode::INVALID_PARAMS,
779 message,
780 Some(error_meta(
781 "validation",
782 false,
783 "use summary=true or force=true",
784 )),
785 ));
786 }
787 } else if output.formatted.len() > SIZE_LIMIT
788 && params.output_control.force != Some(true)
789 && params.output_control.summary == Some(false)
790 {
791 let estimated_tokens = output.formatted.len() / 4;
792 let message = format!(
793 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
794 - force=true to return full output\n\
795 - summary=true to get compact summary\n\
796 - Narrow your scope (smaller directory, specific file)",
797 output.formatted.len(),
798 estimated_tokens
799 );
800 return Err(ErrorData::new(
801 rmcp::model::ErrorCode::INVALID_PARAMS,
802 message,
803 Some(error_meta(
804 "validation",
805 false,
806 "use force=true, summary=true, or narrow scope",
807 )),
808 ));
809 }
810
811 Ok(output)
812 }
813
814 #[instrument(skip(self, params, ct))]
818 async fn handle_focused_mode(
819 &self,
820 params: &AnalyzeSymbolParams,
821 ct: tokio_util::sync::CancellationToken,
822 ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
823 let path = Path::new(¶ms.path);
824 let raw_entries = match walk_directory(path, params.max_depth) {
825 Ok(e) => e,
826 Err(e) => {
827 return Err(ErrorData::new(
828 rmcp::model::ErrorCode::INTERNAL_ERROR,
829 format!("Failed to walk directory: {e}"),
830 Some(error_meta(
831 "resource",
832 false,
833 "check path permissions and availability",
834 )),
835 ));
836 }
837 };
838 let filtered_entries = if let Some(ref git_ref) = params.git_ref
840 && !git_ref.is_empty()
841 {
842 let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
843 ErrorData::new(
844 rmcp::model::ErrorCode::INVALID_PARAMS,
845 format!("git_ref filter failed: {e}"),
846 Some(error_meta(
847 "resource",
848 false,
849 "ensure git is installed and path is inside a git repository",
850 )),
851 )
852 })?;
853 filter_entries_by_git_ref(raw_entries, &changed, path)
854 } else {
855 raw_entries
856 };
857 let entries = std::sync::Arc::new(filtered_entries);
858
859 if params.impl_only == Some(true) {
860 Self::validate_impl_only(&entries)?;
861 }
862
863 let total_files = entries.iter().filter(|e| !e.is_dir).count();
864 let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
865
866 let analysis_params = FocusedAnalysisParams {
867 path: path.to_path_buf(),
868 symbol: params.symbol.clone(),
869 match_mode: params.match_mode.clone().unwrap_or_default(),
870 follow_depth: params.follow_depth.unwrap_or(1),
871 max_depth: params.max_depth,
872 ast_recursion_limit: params.ast_recursion_limit,
873 use_summary: false,
874 impl_only: params.impl_only,
875 def_use: params.def_use.unwrap_or(false),
876 };
877
878 let mut output = self
879 .run_focused_with_auto_summary(
880 params,
881 &analysis_params,
882 counter,
883 ct,
884 entries,
885 total_files,
886 )
887 .await?;
888
889 if params.impl_only == Some(true) {
890 let filter_line = format!(
891 "FILTER: impl_only=true ({} of {} callers shown)\n",
892 output.impl_trait_caller_count, output.unfiltered_caller_count
893 );
894 output.formatted = format!("{}{}", filter_line, output.formatted);
895
896 if output.impl_trait_caller_count == 0 {
897 output.formatted.push_str(
898 "\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"
899 );
900 }
901 }
902
903 Ok(output)
904 }
905
906 #[instrument(skip(self, context))]
907 #[tool(
908 name = "analyze_directory",
909 title = "Analyze Directory",
910 description = "Tree-view of directory with LOC, function/class counts, test markers. Respects .gitignore. Returns per-file stats plus next_cursor for pagination. Fails if summary=true and cursor. For 1000+ files, use max_depth=2-3 and summary=true. git_ref restricts to files changed since a branch/tag/commit. Empty directories return zero counts. Example queries: Analyze the src/ directory to understand module structure; What files are in the tests/ directory and how large are they?",
911 output_schema = schema_for_type::<analyze::AnalysisOutput>(),
912 annotations(
913 title = "Analyze Directory",
914 read_only_hint = true,
915 destructive_hint = false,
916 idempotent_hint = true,
917 open_world_hint = false
918 )
919 )]
920 async fn analyze_directory(
921 &self,
922 params: Parameters<AnalyzeDirectoryParams>,
923 context: RequestContext<RoleServer>,
924 ) -> Result<CallToolResult, ErrorData> {
925 let params = params.0;
926 let _validated_path = match validate_path(¶ms.path, true) {
927 Ok(p) => p,
928 Err(e) => return Ok(err_to_tool_result(e)),
929 };
930 let ct = context.ct.clone();
931 let t_start = std::time::Instant::now();
932 let param_path = params.path.clone();
933 let max_depth_val = params.max_depth;
934 let seq = self
935 .session_call_seq
936 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
937 let sid = self.session_id.lock().await.clone();
938
939 let (arc_output, dir_cache_hit) = match self.handle_overview_mode(¶ms, ct).await {
941 Ok(v) => v,
942 Err(e) => return Ok(err_to_tool_result(e)),
943 };
944 let mut output = match std::sync::Arc::try_unwrap(arc_output) {
947 Ok(owned) => owned,
948 Err(arc) => (*arc).clone(),
949 };
950
951 if summary_cursor_conflict(
954 params.output_control.summary,
955 params.pagination.cursor.as_deref(),
956 ) {
957 return Ok(err_to_tool_result(ErrorData::new(
958 rmcp::model::ErrorCode::INVALID_PARAMS,
959 "summary=true is incompatible with a pagination cursor; use one or the other"
960 .to_string(),
961 Some(error_meta(
962 "validation",
963 false,
964 "remove cursor or set summary=false",
965 )),
966 )));
967 }
968
969 let use_summary = if params.output_control.force == Some(true) {
971 false
972 } else if params.output_control.summary == Some(true) {
973 true
974 } else if params.output_control.summary == Some(false) {
975 false
976 } else {
977 output.formatted.len() > SIZE_LIMIT
978 };
979
980 if use_summary {
981 output.formatted = format_summary(
982 &output.entries,
983 &output.files,
984 params.max_depth,
985 output.subtree_counts.as_deref(),
986 );
987 }
988
989 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
991 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
992 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
993 ErrorData::new(
994 rmcp::model::ErrorCode::INVALID_PARAMS,
995 e.to_string(),
996 Some(error_meta("validation", false, "invalid cursor format")),
997 )
998 }) {
999 Ok(v) => v,
1000 Err(e) => return Ok(err_to_tool_result(e)),
1001 };
1002 cursor_data.offset
1003 } else {
1004 0
1005 };
1006
1007 let paginated =
1009 match paginate_slice(&output.files, offset, page_size, PaginationMode::Default) {
1010 Ok(v) => v,
1011 Err(e) => {
1012 return Ok(err_to_tool_result(ErrorData::new(
1013 rmcp::model::ErrorCode::INTERNAL_ERROR,
1014 e.to_string(),
1015 Some(error_meta("transient", true, "retry the request")),
1016 )));
1017 }
1018 };
1019
1020 let verbose = params.output_control.verbose.unwrap_or(false);
1021 if !use_summary {
1022 output.formatted = format_structure_paginated(
1023 &paginated.items,
1024 paginated.total,
1025 params.max_depth,
1026 Some(Path::new(¶ms.path)),
1027 verbose,
1028 );
1029 }
1030
1031 if use_summary {
1033 output.next_cursor = None;
1034 } else {
1035 output.next_cursor.clone_from(&paginated.next_cursor);
1036 }
1037
1038 let mut final_text = output.formatted.clone();
1040 if !use_summary && let Some(cursor) = paginated.next_cursor {
1041 final_text.push('\n');
1042 final_text.push_str("NEXT_CURSOR: ");
1043 final_text.push_str(&cursor);
1044 }
1045
1046 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1047 .with_meta(Some(no_cache_meta()));
1048 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1049 result.structured_content = Some(structured);
1050 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1051 self.metrics_tx.send(crate::metrics::MetricEvent {
1052 ts: crate::metrics::unix_ms(),
1053 tool: "analyze_directory",
1054 duration_ms: dur,
1055 output_chars: final_text.len(),
1056 param_path_depth: crate::metrics::path_component_count(¶m_path),
1057 max_depth: max_depth_val,
1058 result: "ok",
1059 error_type: None,
1060 session_id: sid,
1061 seq: Some(seq),
1062 cache_hit: Some(dir_cache_hit),
1063 });
1064 Ok(result)
1065 }
1066
1067 #[instrument(skip(self, _context))]
1068 #[tool(
1069 name = "analyze_file",
1070 title = "Analyze File",
1071 description = "Functions, types, classes, and imports from a single source file. Returns functions (name, signature, line range), classes (methods, fields, inheritance), imports; paginate with cursor/page_size. Use fields=[\"functions\",\"classes\",\"imports\"] to limit output sections. Fails if directory path supplied; use analyze_directory instead. Fails if summary=true and cursor. git_ref not supported for single-file analysis. Use analyze_module for lightweight function/import index (~75% smaller). Supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#. Example queries: What functions are defined in src/lib.rs?; Show me the classes and their methods in src/analyzer.py.",
1072 output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
1073 annotations(
1074 title = "Analyze File",
1075 read_only_hint = true,
1076 destructive_hint = false,
1077 idempotent_hint = true,
1078 open_world_hint = false
1079 )
1080 )]
1081 async fn analyze_file(
1082 &self,
1083 params: Parameters<AnalyzeFileParams>,
1084 _context: RequestContext<RoleServer>,
1085 ) -> Result<CallToolResult, ErrorData> {
1086 let params = params.0;
1087 let _validated_path = match validate_path(¶ms.path, true) {
1088 Ok(p) => p,
1089 Err(e) => return Ok(err_to_tool_result(e)),
1090 };
1091 let t_start = std::time::Instant::now();
1092 let param_path = params.path.clone();
1093 let seq = self
1094 .session_call_seq
1095 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1096 let sid = self.session_id.lock().await.clone();
1097
1098 if std::path::Path::new(¶ms.path).is_dir() {
1100 return Ok(err_to_tool_result(ErrorData::new(
1101 rmcp::model::ErrorCode::INVALID_PARAMS,
1102 format!(
1103 "'{}' is a directory; use analyze_directory instead",
1104 params.path
1105 ),
1106 Some(error_meta(
1107 "validation",
1108 false,
1109 "pass a file path, not a directory",
1110 )),
1111 )));
1112 }
1113
1114 if summary_cursor_conflict(
1116 params.output_control.summary,
1117 params.pagination.cursor.as_deref(),
1118 ) {
1119 return Ok(err_to_tool_result(ErrorData::new(
1120 rmcp::model::ErrorCode::INVALID_PARAMS,
1121 "summary=true is incompatible with a pagination cursor; use one or the other"
1122 .to_string(),
1123 Some(error_meta(
1124 "validation",
1125 false,
1126 "remove cursor or set summary=false",
1127 )),
1128 )));
1129 }
1130
1131 let (arc_output, file_cache_hit) = match self.handle_file_details_mode(¶ms).await {
1133 Ok(v) => v,
1134 Err(e) => return Ok(err_to_tool_result(e)),
1135 };
1136
1137 let mut formatted = arc_output.formatted.clone();
1141 let line_count = arc_output.line_count;
1142
1143 let use_summary = if params.output_control.force == Some(true) {
1145 false
1146 } else if params.output_control.summary == Some(true) {
1147 true
1148 } else if params.output_control.summary == Some(false) {
1149 false
1150 } else {
1151 formatted.len() > SIZE_LIMIT
1152 };
1153
1154 if use_summary {
1155 formatted = format_file_details_summary(&arc_output.semantic, ¶ms.path, line_count);
1156 } else if formatted.len() > SIZE_LIMIT && params.output_control.force != Some(true) {
1157 let estimated_tokens = formatted.len() / 4;
1158 let message = format!(
1159 "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1160 - force=true to return full output\n\
1161 - Use fields to limit output to specific sections (functions, classes, or imports)\n\
1162 - Use summary=true for a compact overview",
1163 formatted.len(),
1164 estimated_tokens
1165 );
1166 return Ok(err_to_tool_result(ErrorData::new(
1167 rmcp::model::ErrorCode::INVALID_PARAMS,
1168 message,
1169 Some(error_meta(
1170 "validation",
1171 false,
1172 "use force=true, fields, or summary=true",
1173 )),
1174 )));
1175 }
1176
1177 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1179 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1180 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1181 ErrorData::new(
1182 rmcp::model::ErrorCode::INVALID_PARAMS,
1183 e.to_string(),
1184 Some(error_meta("validation", false, "invalid cursor format")),
1185 )
1186 }) {
1187 Ok(v) => v,
1188 Err(e) => return Ok(err_to_tool_result(e)),
1189 };
1190 cursor_data.offset
1191 } else {
1192 0
1193 };
1194
1195 let top_level_fns: Vec<crate::types::FunctionInfo> = arc_output
1197 .semantic
1198 .functions
1199 .iter()
1200 .filter(|func| {
1201 !arc_output
1202 .semantic
1203 .classes
1204 .iter()
1205 .any(|class| func.line >= class.line && func.end_line <= class.end_line)
1206 })
1207 .cloned()
1208 .collect();
1209
1210 let paginated =
1212 match paginate_slice(&top_level_fns, offset, page_size, PaginationMode::Default) {
1213 Ok(v) => v,
1214 Err(e) => {
1215 return Ok(err_to_tool_result(ErrorData::new(
1216 rmcp::model::ErrorCode::INTERNAL_ERROR,
1217 e.to_string(),
1218 Some(error_meta("transient", true, "retry the request")),
1219 )));
1220 }
1221 };
1222
1223 let verbose = params.output_control.verbose.unwrap_or(false);
1225 if !use_summary {
1226 formatted = format_file_details_paginated(
1228 &paginated.items,
1229 paginated.total,
1230 &arc_output.semantic,
1231 ¶ms.path,
1232 line_count,
1233 offset,
1234 verbose,
1235 params.fields.as_deref(),
1236 );
1237 }
1238
1239 let next_cursor = if use_summary {
1241 None
1242 } else {
1243 paginated.next_cursor.clone()
1244 };
1245
1246 let mut final_text = formatted.clone();
1248 if !use_summary && let Some(ref cursor) = next_cursor {
1249 final_text.push('\n');
1250 final_text.push_str("NEXT_CURSOR: ");
1251 final_text.push_str(cursor);
1252 }
1253
1254 let response_output = analyze::FileAnalysisOutput::new(
1256 formatted,
1257 arc_output.semantic.clone(),
1258 line_count,
1259 next_cursor,
1260 );
1261
1262 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1263 .with_meta(Some(no_cache_meta()));
1264 let structured = serde_json::to_value(&response_output).unwrap_or(Value::Null);
1265 result.structured_content = Some(structured);
1266 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1267 self.metrics_tx.send(crate::metrics::MetricEvent {
1268 ts: crate::metrics::unix_ms(),
1269 tool: "analyze_file",
1270 duration_ms: dur,
1271 output_chars: final_text.len(),
1272 param_path_depth: crate::metrics::path_component_count(¶m_path),
1273 max_depth: None,
1274 result: "ok",
1275 error_type: None,
1276 session_id: sid,
1277 seq: Some(seq),
1278 cache_hit: Some(file_cache_hit),
1279 });
1280 Ok(result)
1281 }
1282
1283 #[instrument(skip(self, context))]
1284 #[tool(
1285 name = "analyze_symbol",
1286 title = "Analyze Symbol",
1287 description = "Call 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.",
1288 output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
1289 annotations(
1290 title = "Analyze Symbol",
1291 read_only_hint = true,
1292 destructive_hint = false,
1293 idempotent_hint = true,
1294 open_world_hint = false
1295 )
1296 )]
1297 async fn analyze_symbol(
1298 &self,
1299 params: Parameters<AnalyzeSymbolParams>,
1300 context: RequestContext<RoleServer>,
1301 ) -> Result<CallToolResult, ErrorData> {
1302 let params = params.0;
1303 let _validated_path = match validate_path(¶ms.path, true) {
1304 Ok(p) => p,
1305 Err(e) => return Ok(err_to_tool_result(e)),
1306 };
1307 let ct = context.ct.clone();
1308 let t_start = std::time::Instant::now();
1309 let param_path = params.path.clone();
1310 let max_depth_val = params.follow_depth;
1311 let seq = self
1312 .session_call_seq
1313 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1314 let sid = self.session_id.lock().await.clone();
1315
1316 if std::path::Path::new(¶ms.path).is_file() {
1318 return Ok(err_to_tool_result(ErrorData::new(
1319 rmcp::model::ErrorCode::INVALID_PARAMS,
1320 format!(
1321 "'{}' is a file; analyze_symbol requires a directory path",
1322 params.path
1323 ),
1324 Some(error_meta(
1325 "validation",
1326 false,
1327 "pass a directory path, not a file",
1328 )),
1329 )));
1330 }
1331
1332 if summary_cursor_conflict(
1334 params.output_control.summary,
1335 params.pagination.cursor.as_deref(),
1336 ) {
1337 return Ok(err_to_tool_result(ErrorData::new(
1338 rmcp::model::ErrorCode::INVALID_PARAMS,
1339 "summary=true is incompatible with a pagination cursor; use one or the other"
1340 .to_string(),
1341 Some(error_meta(
1342 "validation",
1343 false,
1344 "remove cursor or set summary=false",
1345 )),
1346 )));
1347 }
1348
1349 if let Err(e) = Self::validate_import_lookup(params.import_lookup, ¶ms.symbol) {
1351 return Ok(err_to_tool_result(e));
1352 }
1353
1354 if params.import_lookup == Some(true) {
1356 let path_owned = PathBuf::from(¶ms.path);
1357 let symbol = params.symbol.clone();
1358 let git_ref = params.git_ref.clone();
1359 let max_depth = params.max_depth;
1360 let ast_recursion_limit = params.ast_recursion_limit;
1361
1362 let handle = tokio::task::spawn_blocking(move || {
1363 let path = path_owned.as_path();
1364 let raw_entries = match walk_directory(path, max_depth) {
1365 Ok(e) => e,
1366 Err(e) => {
1367 return Err(ErrorData::new(
1368 rmcp::model::ErrorCode::INTERNAL_ERROR,
1369 format!("Failed to walk directory: {e}"),
1370 Some(error_meta(
1371 "resource",
1372 false,
1373 "check path permissions and availability",
1374 )),
1375 ));
1376 }
1377 };
1378 let entries = if let Some(ref git_ref_val) = git_ref
1380 && !git_ref_val.is_empty()
1381 {
1382 let changed = match changed_files_from_git_ref(path, git_ref_val) {
1383 Ok(c) => c,
1384 Err(e) => {
1385 return Err(ErrorData::new(
1386 rmcp::model::ErrorCode::INVALID_PARAMS,
1387 format!("git_ref filter failed: {e}"),
1388 Some(error_meta(
1389 "resource",
1390 false,
1391 "ensure git is installed and path is inside a git repository",
1392 )),
1393 ));
1394 }
1395 };
1396 filter_entries_by_git_ref(raw_entries, &changed, path)
1397 } else {
1398 raw_entries
1399 };
1400 let output = match analyze::analyze_import_lookup(
1401 path,
1402 &symbol,
1403 &entries,
1404 ast_recursion_limit,
1405 ) {
1406 Ok(v) => v,
1407 Err(e) => {
1408 return Err(ErrorData::new(
1409 rmcp::model::ErrorCode::INTERNAL_ERROR,
1410 format!("import_lookup failed: {e}"),
1411 Some(error_meta(
1412 "resource",
1413 false,
1414 "check path and file permissions",
1415 )),
1416 ));
1417 }
1418 };
1419 Ok(output)
1420 });
1421
1422 let output = match handle.await {
1423 Ok(Ok(v)) => v,
1424 Ok(Err(e)) => return Ok(err_to_tool_result(e)),
1425 Err(e) => {
1426 return Ok(err_to_tool_result(ErrorData::new(
1427 rmcp::model::ErrorCode::INTERNAL_ERROR,
1428 format!("spawn_blocking failed: {e}"),
1429 Some(error_meta("resource", false, "internal error")),
1430 )));
1431 }
1432 };
1433
1434 let final_text = output.formatted.clone();
1435 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1436 .with_meta(Some(no_cache_meta()));
1437 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1438 result.structured_content = Some(structured);
1439 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1440 self.metrics_tx.send(crate::metrics::MetricEvent {
1441 ts: crate::metrics::unix_ms(),
1442 tool: "analyze_symbol",
1443 duration_ms: dur,
1444 output_chars: final_text.len(),
1445 param_path_depth: crate::metrics::path_component_count(¶m_path),
1446 max_depth: max_depth_val,
1447 result: "ok",
1448 error_type: None,
1449 session_id: sid,
1450 seq: Some(seq),
1451 cache_hit: Some(false),
1452 });
1453 return Ok(result);
1454 }
1455
1456 let mut output = match self.handle_focused_mode(¶ms, ct).await {
1458 Ok(v) => v,
1459 Err(e) => return Ok(err_to_tool_result(e)),
1460 };
1461
1462 let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1464 let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1465 let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1466 ErrorData::new(
1467 rmcp::model::ErrorCode::INVALID_PARAMS,
1468 e.to_string(),
1469 Some(error_meta("validation", false, "invalid cursor format")),
1470 )
1471 }) {
1472 Ok(v) => v,
1473 Err(e) => return Ok(err_to_tool_result(e)),
1474 };
1475 cursor_data.offset
1476 } else {
1477 0
1478 };
1479
1480 let cursor_mode = if let Some(ref cursor_str) = params.pagination.cursor {
1482 decode_cursor(cursor_str)
1483 .map(|c| c.mode)
1484 .unwrap_or(PaginationMode::Callers)
1485 } else {
1486 PaginationMode::Callers
1487 };
1488
1489 let mut use_summary = params.output_control.summary == Some(true);
1490 if params.output_control.force == Some(true) {
1491 use_summary = false;
1492 }
1493 let verbose = params.output_control.verbose.unwrap_or(false);
1494
1495 let mut callee_cursor = match cursor_mode {
1496 PaginationMode::Callers => {
1497 let (paginated_items, paginated_next) = match paginate_focus_chains(
1498 &output.prod_chains,
1499 PaginationMode::Callers,
1500 offset,
1501 page_size,
1502 ) {
1503 Ok(v) => v,
1504 Err(e) => return Ok(err_to_tool_result(e)),
1505 };
1506
1507 if !use_summary
1508 && (paginated_next.is_some()
1509 || offset > 0
1510 || !verbose
1511 || !output.outgoing_chains.is_empty())
1512 {
1513 let base_path = Path::new(¶ms.path);
1514 output.formatted = format_focused_paginated(
1515 &paginated_items,
1516 output.prod_chains.len(),
1517 PaginationMode::Callers,
1518 ¶ms.symbol,
1519 &output.prod_chains,
1520 &output.test_chains,
1521 &output.outgoing_chains,
1522 output.def_count,
1523 offset,
1524 Some(base_path),
1525 verbose,
1526 );
1527 paginated_next
1528 } else {
1529 None
1530 }
1531 }
1532 PaginationMode::Callees => {
1533 let (paginated_items, paginated_next) = match paginate_focus_chains(
1534 &output.outgoing_chains,
1535 PaginationMode::Callees,
1536 offset,
1537 page_size,
1538 ) {
1539 Ok(v) => v,
1540 Err(e) => return Ok(err_to_tool_result(e)),
1541 };
1542
1543 if paginated_next.is_some() || offset > 0 || !verbose {
1544 let base_path = Path::new(¶ms.path);
1545 output.formatted = format_focused_paginated(
1546 &paginated_items,
1547 output.outgoing_chains.len(),
1548 PaginationMode::Callees,
1549 ¶ms.symbol,
1550 &output.prod_chains,
1551 &output.test_chains,
1552 &output.outgoing_chains,
1553 output.def_count,
1554 offset,
1555 Some(base_path),
1556 verbose,
1557 );
1558 paginated_next
1559 } else {
1560 None
1561 }
1562 }
1563 PaginationMode::Default => {
1564 return Ok(err_to_tool_result(ErrorData::new(
1565 rmcp::model::ErrorCode::INVALID_PARAMS,
1566 "invalid cursor: unknown pagination mode".to_string(),
1567 Some(error_meta(
1568 "validation",
1569 false,
1570 "use a cursor returned by a previous analyze_symbol call",
1571 )),
1572 )));
1573 }
1574 PaginationMode::DefUse => {
1575 let total_sites = output.def_use_sites.len();
1576 let (paginated_sites, paginated_next) = match paginate_slice(
1577 &output.def_use_sites,
1578 offset,
1579 page_size,
1580 PaginationMode::DefUse,
1581 ) {
1582 Ok(r) => (r.items, r.next_cursor),
1583 Err(e) => return Ok(err_to_tool_result_from_pagination(e)),
1584 };
1585
1586 if !use_summary {
1589 let base_path = Path::new(¶ms.path);
1590 output.formatted = format_focused_paginated_defuse(
1591 &paginated_sites,
1592 total_sites,
1593 ¶ms.symbol,
1594 offset,
1595 Some(base_path),
1596 verbose,
1597 );
1598 }
1599
1600 output.def_use_sites = paginated_sites;
1603
1604 paginated_next
1605 }
1606 };
1607
1608 if callee_cursor.is_none()
1613 && cursor_mode == PaginationMode::Callers
1614 && !output.outgoing_chains.is_empty()
1615 && !use_summary
1616 && let Ok(cursor) = encode_cursor(&CursorData {
1617 mode: PaginationMode::Callees,
1618 offset: 0,
1619 })
1620 {
1621 callee_cursor = Some(cursor);
1622 }
1623
1624 if callee_cursor.is_none()
1631 && matches!(
1632 cursor_mode,
1633 PaginationMode::Callees | PaginationMode::Callers
1634 )
1635 && !output.def_use_sites.is_empty()
1636 && !use_summary
1637 && let Ok(cursor) = encode_cursor(&CursorData {
1638 mode: PaginationMode::DefUse,
1639 offset: 0,
1640 })
1641 {
1642 if cursor_mode == PaginationMode::Callees || output.outgoing_chains.is_empty() {
1645 callee_cursor = Some(cursor);
1646 }
1647 }
1648
1649 output.next_cursor.clone_from(&callee_cursor);
1651
1652 let mut final_text = output.formatted.clone();
1654 if let Some(cursor) = callee_cursor {
1655 final_text.push('\n');
1656 final_text.push_str("NEXT_CURSOR: ");
1657 final_text.push_str(&cursor);
1658 }
1659
1660 let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1661 .with_meta(Some(no_cache_meta()));
1662 if cursor_mode != PaginationMode::DefUse {
1666 output.def_use_sites = Vec::new();
1667 }
1668 let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1669 result.structured_content = Some(structured);
1670 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1671 self.metrics_tx.send(crate::metrics::MetricEvent {
1672 ts: crate::metrics::unix_ms(),
1673 tool: "analyze_symbol",
1674 duration_ms: dur,
1675 output_chars: final_text.len(),
1676 param_path_depth: crate::metrics::path_component_count(¶m_path),
1677 max_depth: max_depth_val,
1678 result: "ok",
1679 error_type: None,
1680 session_id: sid,
1681 seq: Some(seq),
1682 cache_hit: Some(false),
1683 });
1684 Ok(result)
1685 }
1686
1687 #[instrument(skip(self, _context))]
1688 #[tool(
1689 name = "analyze_module",
1690 title = "Analyze Module",
1691 description = "Function and import index for a single source file with minimal token cost: name, line_count, language, function names with line numbers, import list only (~75% smaller than analyze_file). Fails if directory path supplied. Pagination, summary, force, verbose, git_ref not supported. Use analyze_file when you need signatures, types, or class details. Supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#. Example queries: What functions are defined in src/analyze.rs?",
1692 output_schema = schema_for_type::<types::ModuleInfo>(),
1693 annotations(
1694 title = "Analyze Module",
1695 read_only_hint = true,
1696 destructive_hint = false,
1697 idempotent_hint = true,
1698 open_world_hint = false
1699 )
1700 )]
1701 async fn analyze_module(
1702 &self,
1703 params: Parameters<AnalyzeModuleParams>,
1704 _context: RequestContext<RoleServer>,
1705 ) -> Result<CallToolResult, ErrorData> {
1706 let params = params.0;
1707 let _validated_path = match validate_path(¶ms.path, true) {
1708 Ok(p) => p,
1709 Err(e) => return Ok(err_to_tool_result(e)),
1710 };
1711 let t_start = std::time::Instant::now();
1712 let param_path = params.path.clone();
1713 let seq = self
1714 .session_call_seq
1715 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1716 let sid = self.session_id.lock().await.clone();
1717
1718 if std::fs::metadata(¶ms.path)
1720 .map(|m| m.is_dir())
1721 .unwrap_or(false)
1722 {
1723 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1724 self.metrics_tx.send(crate::metrics::MetricEvent {
1725 ts: crate::metrics::unix_ms(),
1726 tool: "analyze_module",
1727 duration_ms: dur,
1728 output_chars: 0,
1729 param_path_depth: crate::metrics::path_component_count(¶m_path),
1730 max_depth: None,
1731 result: "error",
1732 error_type: Some("invalid_params".to_string()),
1733 session_id: sid.clone(),
1734 seq: Some(seq),
1735 cache_hit: None,
1736 });
1737 return Ok(err_to_tool_result(ErrorData::new(
1738 rmcp::model::ErrorCode::INVALID_PARAMS,
1739 format!(
1740 "'{}' is a directory. Use analyze_directory to analyze a directory, or pass a specific file path to analyze_module.",
1741 params.path
1742 ),
1743 Some(error_meta(
1744 "validation",
1745 false,
1746 "use analyze_directory for directories",
1747 )),
1748 )));
1749 }
1750
1751 let module_cache_key = std::fs::metadata(¶ms.path).ok().and_then(|meta| {
1753 meta.modified().ok().map(|mtime| cache::CacheKey {
1754 path: std::path::PathBuf::from(¶ms.path),
1755 modified: mtime,
1756 mode: AnalysisMode::FileDetails,
1757 })
1758 });
1759 let (module_info, module_cache_hit) = if let Some(ref key) = module_cache_key
1760 && let Some(cached_file) = self.cache.get(key)
1761 {
1762 let file_path = std::path::Path::new(¶ms.path);
1766 let name = file_path
1767 .file_name()
1768 .and_then(|n: &std::ffi::OsStr| n.to_str())
1769 .unwrap_or("unknown")
1770 .to_string();
1771 let language = file_path
1772 .extension()
1773 .and_then(|e| e.to_str())
1774 .and_then(aptu_coder_core::lang::language_for_extension)
1775 .unwrap_or("unknown")
1776 .to_string();
1777 let mut mi = types::ModuleInfo::default();
1778 mi.name = name;
1779 mi.line_count = cached_file.line_count;
1780 mi.language = language;
1781 mi.functions = cached_file
1782 .semantic
1783 .functions
1784 .iter()
1785 .map(|f| {
1786 let mut mfi = types::ModuleFunctionInfo::default();
1787 mfi.name = f.name.clone();
1788 mfi.line = f.line;
1789 mfi
1790 })
1791 .collect();
1792 mi.imports = cached_file
1793 .semantic
1794 .imports
1795 .iter()
1796 .map(|i| {
1797 let mut mii = types::ModuleImportInfo::default();
1798 mii.module = i.module.clone();
1799 mii.items = i.items.clone();
1800 mii
1801 })
1802 .collect();
1803 (mi, true)
1804 } else {
1805 let file_output = match analyze::analyze_file(¶ms.path, None) {
1809 Ok(v) => v,
1810 Err(e) => {
1811 let error_data = match &e {
1812 analyze::AnalyzeError::Io(io_err) => match io_err.kind() {
1813 std::io::ErrorKind::NotFound | std::io::ErrorKind::PermissionDenied => {
1814 ErrorData::new(
1815 rmcp::model::ErrorCode::INVALID_PARAMS,
1816 format!("Failed to analyze module: {e}"),
1817 Some(error_meta(
1818 "validation",
1819 false,
1820 "ensure file exists, is readable, and has a supported extension",
1821 )),
1822 )
1823 }
1824 _ => ErrorData::new(
1825 rmcp::model::ErrorCode::INTERNAL_ERROR,
1826 format!("Failed to analyze module: {e}"),
1827 Some(error_meta("internal", false, "report this as a bug")),
1828 ),
1829 },
1830 analyze::AnalyzeError::UnsupportedLanguage(_)
1831 | analyze::AnalyzeError::InvalidRange { .. }
1832 | analyze::AnalyzeError::NotAFile(_) => ErrorData::new(
1833 rmcp::model::ErrorCode::INVALID_PARAMS,
1834 format!("Failed to analyze module: {e}"),
1835 Some(error_meta(
1836 "validation",
1837 false,
1838 "ensure the path is a supported source file",
1839 )),
1840 ),
1841 _ => ErrorData::new(
1842 rmcp::model::ErrorCode::INTERNAL_ERROR,
1843 format!("Failed to analyze module: {e}"),
1844 Some(error_meta("internal", false, "report this as a bug")),
1845 ),
1846 };
1847 return Ok(err_to_tool_result(error_data));
1848 }
1849 };
1850 let arc_output = std::sync::Arc::new(file_output);
1851 if let Some(key) = module_cache_key.clone() {
1852 self.cache.put(key, arc_output.clone());
1853 }
1854 let file_path = std::path::Path::new(¶ms.path);
1855 let name = file_path
1856 .file_name()
1857 .and_then(|n: &std::ffi::OsStr| n.to_str())
1858 .unwrap_or("unknown")
1859 .to_string();
1860 let language = file_path
1861 .extension()
1862 .and_then(|e| e.to_str())
1863 .and_then(aptu_coder_core::lang::language_for_extension)
1864 .unwrap_or("unknown")
1865 .to_string();
1866 let mut mi = types::ModuleInfo::default();
1867 mi.name = name;
1868 mi.line_count = arc_output.line_count;
1869 mi.language = language;
1870 mi.functions = arc_output
1871 .semantic
1872 .functions
1873 .iter()
1874 .map(|f| {
1875 let mut mfi = types::ModuleFunctionInfo::default();
1876 mfi.name = f.name.clone();
1877 mfi.line = f.line;
1878 mfi
1879 })
1880 .collect();
1881 mi.imports = arc_output
1882 .semantic
1883 .imports
1884 .iter()
1885 .map(|i| {
1886 let mut mii = types::ModuleImportInfo::default();
1887 mii.module = i.module.clone();
1888 mii.items = i.items.clone();
1889 mii
1890 })
1891 .collect();
1892 (mi, false)
1893 };
1894
1895 let text = format_module_info(&module_info);
1896 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
1897 .with_meta(Some(no_cache_meta()));
1898 let structured = match serde_json::to_value(&module_info).map_err(|e| {
1899 ErrorData::new(
1900 rmcp::model::ErrorCode::INTERNAL_ERROR,
1901 format!("serialization failed: {e}"),
1902 Some(error_meta("internal", false, "report this as a bug")),
1903 )
1904 }) {
1905 Ok(v) => v,
1906 Err(e) => return Ok(err_to_tool_result(e)),
1907 };
1908 result.structured_content = Some(structured);
1909 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1910 self.metrics_tx.send(crate::metrics::MetricEvent {
1911 ts: crate::metrics::unix_ms(),
1912 tool: "analyze_module",
1913 duration_ms: dur,
1914 output_chars: text.len(),
1915 param_path_depth: crate::metrics::path_component_count(¶m_path),
1916 max_depth: None,
1917 result: "ok",
1918 error_type: None,
1919 session_id: sid,
1920 seq: Some(seq),
1921 cache_hit: Some(module_cache_hit),
1922 });
1923 Ok(result)
1924 }
1925
1926 #[instrument(skip(self, _context))]
1927 #[tool(
1928 name = "analyze_raw",
1929 title = "Analyze Raw",
1930 description = "Raw UTF-8 file content with line numbers; no AST parsing. Returns path, total_lines, start_line, end_line, content, next_start_line (null at EOF; pass as start_line to continue pagination). Omitting start_line/end_line returns the full file; for files over 100 lines use analyze_module first to locate the range. Fails if directory path supplied; fails on binary or non-UTF-8 files. Use analyze_file or analyze_module for AST-structured output. Example queries: Show lines 100-150 of src/lib.rs.",
1931 output_schema = schema_for_type::<types::AnalyzeRawOutput>(),
1932 annotations(
1933 title = "Analyze Raw",
1934 read_only_hint = true,
1935 destructive_hint = false,
1936 idempotent_hint = true,
1937 open_world_hint = false
1938 )
1939 )]
1940 async fn analyze_raw(
1941 &self,
1942 params: Parameters<types::AnalyzeRawParams>,
1943 _context: RequestContext<RoleServer>,
1944 ) -> Result<CallToolResult, ErrorData> {
1945 let params = params.0;
1946 let _validated_path = match validate_path(¶ms.path, true) {
1947 Ok(p) => p,
1948 Err(e) => return Ok(err_to_tool_result(e)),
1949 };
1950 let t_start = std::time::Instant::now();
1951 let param_path = params.path.clone();
1952 let seq = self
1953 .session_call_seq
1954 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1955 let sid = self.session_id.lock().await.clone();
1956
1957 if std::fs::metadata(¶ms.path)
1959 .map(|m| m.is_dir())
1960 .unwrap_or(false)
1961 {
1962 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1963 self.metrics_tx.send(crate::metrics::MetricEvent {
1964 ts: crate::metrics::unix_ms(),
1965 tool: "analyze_raw",
1966 duration_ms: dur,
1967 output_chars: 0,
1968 param_path_depth: crate::metrics::path_component_count(¶m_path),
1969 max_depth: None,
1970 result: "error",
1971 error_type: Some("invalid_params".to_string()),
1972 session_id: sid.clone(),
1973 seq: Some(seq),
1974 cache_hit: None,
1975 });
1976 return Ok(err_to_tool_result(ErrorData::new(
1977 rmcp::model::ErrorCode::INVALID_PARAMS,
1978 "path is a directory; use analyze_directory instead".to_string(),
1979 Some(error_meta(
1980 "validation",
1981 false,
1982 "pass a file path, not a directory",
1983 )),
1984 )));
1985 }
1986
1987 let path = std::path::PathBuf::from(¶ms.path);
1988 let handle = tokio::task::spawn_blocking(move || {
1989 aptu_coder_core::analyze_raw_range(&path, params.start_line, params.end_line)
1990 });
1991
1992 let output = match handle.await {
1993 Ok(Ok(v)) => v,
1994 Ok(Err(aptu_coder_core::AnalyzeError::InvalidRange { start, end, total })) => {
1995 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1996 self.metrics_tx.send(crate::metrics::MetricEvent {
1997 ts: crate::metrics::unix_ms(),
1998 tool: "analyze_raw",
1999 duration_ms: dur,
2000 output_chars: 0,
2001 param_path_depth: crate::metrics::path_component_count(¶m_path),
2002 max_depth: None,
2003 result: "error",
2004 error_type: Some("invalid_params".to_string()),
2005 session_id: sid.clone(),
2006 seq: Some(seq),
2007 cache_hit: None,
2008 });
2009 return Ok(err_to_tool_result(ErrorData::new(
2010 rmcp::model::ErrorCode::INVALID_PARAMS,
2011 format!("invalid range: start ({start}) > end ({end}); file has {total} lines"),
2012 Some(error_meta(
2013 "validation",
2014 false,
2015 "ensure start_line <= end_line",
2016 )),
2017 )));
2018 }
2019 Ok(Err(aptu_coder_core::AnalyzeError::NotAFile(_))) => {
2020 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2021 self.metrics_tx.send(crate::metrics::MetricEvent {
2022 ts: crate::metrics::unix_ms(),
2023 tool: "analyze_raw",
2024 duration_ms: dur,
2025 output_chars: 0,
2026 param_path_depth: crate::metrics::path_component_count(¶m_path),
2027 max_depth: None,
2028 result: "error",
2029 error_type: Some("invalid_params".to_string()),
2030 session_id: sid.clone(),
2031 seq: Some(seq),
2032 cache_hit: None,
2033 });
2034 return Ok(err_to_tool_result(ErrorData::new(
2035 rmcp::model::ErrorCode::INVALID_PARAMS,
2036 "path is not a file".to_string(),
2037 Some(error_meta(
2038 "validation",
2039 false,
2040 "provide a file path, not a directory",
2041 )),
2042 )));
2043 }
2044 Ok(Err(aptu_coder_core::AnalyzeError::RangelessLargeFile { total_lines })) => {
2045 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2046 self.metrics_tx.send(crate::metrics::MetricEvent {
2047 ts: crate::metrics::unix_ms(),
2048 tool: "analyze_raw",
2049 duration_ms: dur,
2050 output_chars: 0,
2051 param_path_depth: crate::metrics::path_component_count(¶m_path),
2052 max_depth: None,
2053 result: "error",
2054 error_type: Some("invalid_params".to_string()),
2055 session_id: sid.clone(),
2056 seq: Some(seq),
2057 cache_hit: None,
2058 });
2059 return Ok(err_to_tool_result(ErrorData::new(
2060 rmcp::model::ErrorCode::INVALID_PARAMS,
2061 format!(
2062 "file has {total_lines} lines; provide start_line and end_line, or call analyze_module first to locate the range"
2063 ),
2064 Some(error_meta(
2065 "validation",
2066 false,
2067 "call analyze_module to get function line numbers, then retry with start_line and end_line",
2068 )),
2069 )));
2070 }
2071 Ok(Err(e)) => {
2072 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2073 self.metrics_tx.send(crate::metrics::MetricEvent {
2074 ts: crate::metrics::unix_ms(),
2075 tool: "analyze_raw",
2076 duration_ms: dur,
2077 output_chars: 0,
2078 param_path_depth: crate::metrics::path_component_count(¶m_path),
2079 max_depth: None,
2080 result: "error",
2081 error_type: Some("internal_error".to_string()),
2082 session_id: sid.clone(),
2083 seq: Some(seq),
2084 cache_hit: None,
2085 });
2086 return Ok(err_to_tool_result(ErrorData::new(
2087 rmcp::model::ErrorCode::INTERNAL_ERROR,
2088 e.to_string(),
2089 Some(error_meta(
2090 "resource",
2091 false,
2092 "check file path and permissions",
2093 )),
2094 )));
2095 }
2096 Err(e) => {
2097 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2098 self.metrics_tx.send(crate::metrics::MetricEvent {
2099 ts: crate::metrics::unix_ms(),
2100 tool: "analyze_raw",
2101 duration_ms: dur,
2102 output_chars: 0,
2103 param_path_depth: crate::metrics::path_component_count(¶m_path),
2104 max_depth: None,
2105 result: "error",
2106 error_type: Some("internal_error".to_string()),
2107 session_id: sid.clone(),
2108 seq: Some(seq),
2109 cache_hit: None,
2110 });
2111 return Ok(err_to_tool_result(ErrorData::new(
2112 rmcp::model::ErrorCode::INTERNAL_ERROR,
2113 e.to_string(),
2114 Some(error_meta(
2115 "resource",
2116 false,
2117 "check file path and permissions",
2118 )),
2119 )));
2120 }
2121 };
2122
2123 let text = format!(
2124 "File: {} (total: {} lines, showing: {}-{})\n\n{}",
2125 output.path, output.total_lines, output.start_line, output.end_line, output.content
2126 );
2127 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2128 .with_meta(Some(no_cache_meta()));
2129 let structured = match serde_json::to_value(&output).map_err(|e| {
2130 ErrorData::new(
2131 rmcp::model::ErrorCode::INTERNAL_ERROR,
2132 format!("serialization failed: {e}"),
2133 Some(error_meta("internal", false, "report this as a bug")),
2134 )
2135 }) {
2136 Ok(v) => v,
2137 Err(e) => return Ok(err_to_tool_result(e)),
2138 };
2139 result.structured_content = Some(structured);
2140 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2141 self.metrics_tx.send(crate::metrics::MetricEvent {
2142 ts: crate::metrics::unix_ms(),
2143 tool: "analyze_raw",
2144 duration_ms: dur,
2145 output_chars: text.len(),
2146 param_path_depth: crate::metrics::path_component_count(¶m_path),
2147 max_depth: None,
2148 result: "ok",
2149 error_type: None,
2150 session_id: sid,
2151 seq: Some(seq),
2152 cache_hit: None,
2153 });
2154 Ok(result)
2155 }
2156
2157 #[instrument(skip(self, _context))]
2158 #[tool(
2159 name = "edit_overwrite",
2160 title = "Edit Overwrite",
2161 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; edit_rename/edit_insert for AST-targeted changes. Example queries: Overwrite src/config.rs with updated content.",
2162 output_schema = schema_for_type::<EditOverwriteOutput>(),
2163 annotations(
2164 title = "Edit Overwrite",
2165 read_only_hint = false,
2166 destructive_hint = true,
2167 idempotent_hint = false,
2168 open_world_hint = false
2169 )
2170 )]
2171 async fn edit_overwrite(
2172 &self,
2173 params: Parameters<EditOverwriteParams>,
2174 _context: RequestContext<RoleServer>,
2175 ) -> Result<CallToolResult, ErrorData> {
2176 let params = params.0;
2177 let _validated_path = match validate_path(¶ms.path, false) {
2178 Ok(p) => p,
2179 Err(e) => return Ok(err_to_tool_result(e)),
2180 };
2181 let t_start = std::time::Instant::now();
2182 let param_path = params.path.clone();
2183 let seq = self
2184 .session_call_seq
2185 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2186 let sid = self.session_id.lock().await.clone();
2187
2188 if std::fs::metadata(¶ms.path)
2190 .map(|m| m.is_dir())
2191 .unwrap_or(false)
2192 {
2193 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2194 self.metrics_tx.send(crate::metrics::MetricEvent {
2195 ts: crate::metrics::unix_ms(),
2196 tool: "edit_overwrite",
2197 duration_ms: dur,
2198 output_chars: 0,
2199 param_path_depth: crate::metrics::path_component_count(¶m_path),
2200 max_depth: None,
2201 result: "error",
2202 error_type: Some("invalid_params".to_string()),
2203 session_id: sid.clone(),
2204 seq: Some(seq),
2205 cache_hit: None,
2206 });
2207 return Ok(err_to_tool_result(ErrorData::new(
2208 rmcp::model::ErrorCode::INVALID_PARAMS,
2209 "path is a directory; cannot write to a directory".to_string(),
2210 Some(error_meta(
2211 "validation",
2212 false,
2213 "provide a file path, not a directory",
2214 )),
2215 )));
2216 }
2217
2218 let path = std::path::PathBuf::from(¶ms.path);
2219 let content = params.content.clone();
2220 let handle = tokio::task::spawn_blocking(move || {
2221 aptu_coder_core::edit_overwrite_content(&path, &content)
2222 });
2223
2224 let output = match handle.await {
2225 Ok(Ok(v)) => v,
2226 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2227 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2228 self.metrics_tx.send(crate::metrics::MetricEvent {
2229 ts: crate::metrics::unix_ms(),
2230 tool: "edit_overwrite",
2231 duration_ms: dur,
2232 output_chars: 0,
2233 param_path_depth: crate::metrics::path_component_count(¶m_path),
2234 max_depth: None,
2235 result: "error",
2236 error_type: Some("invalid_params".to_string()),
2237 session_id: sid.clone(),
2238 seq: Some(seq),
2239 cache_hit: None,
2240 });
2241 return Ok(err_to_tool_result(ErrorData::new(
2242 rmcp::model::ErrorCode::INVALID_PARAMS,
2243 "path is a directory".to_string(),
2244 Some(error_meta(
2245 "validation",
2246 false,
2247 "provide a file path, not a directory",
2248 )),
2249 )));
2250 }
2251 Ok(Err(e)) => {
2252 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2253 self.metrics_tx.send(crate::metrics::MetricEvent {
2254 ts: crate::metrics::unix_ms(),
2255 tool: "edit_overwrite",
2256 duration_ms: dur,
2257 output_chars: 0,
2258 param_path_depth: crate::metrics::path_component_count(¶m_path),
2259 max_depth: None,
2260 result: "error",
2261 error_type: Some("internal_error".to_string()),
2262 session_id: sid.clone(),
2263 seq: Some(seq),
2264 cache_hit: None,
2265 });
2266 return Ok(err_to_tool_result(ErrorData::new(
2267 rmcp::model::ErrorCode::INTERNAL_ERROR,
2268 e.to_string(),
2269 Some(error_meta(
2270 "resource",
2271 false,
2272 "check file path and permissions",
2273 )),
2274 )));
2275 }
2276 Err(e) => {
2277 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2278 self.metrics_tx.send(crate::metrics::MetricEvent {
2279 ts: crate::metrics::unix_ms(),
2280 tool: "edit_overwrite",
2281 duration_ms: dur,
2282 output_chars: 0,
2283 param_path_depth: crate::metrics::path_component_count(¶m_path),
2284 max_depth: None,
2285 result: "error",
2286 error_type: Some("internal_error".to_string()),
2287 session_id: sid.clone(),
2288 seq: Some(seq),
2289 cache_hit: None,
2290 });
2291 return Ok(err_to_tool_result(ErrorData::new(
2292 rmcp::model::ErrorCode::INTERNAL_ERROR,
2293 e.to_string(),
2294 Some(error_meta(
2295 "resource",
2296 false,
2297 "check file path and permissions",
2298 )),
2299 )));
2300 }
2301 };
2302
2303 let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2304 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2305 .with_meta(Some(no_cache_meta()));
2306 let structured = match serde_json::to_value(&output).map_err(|e| {
2307 ErrorData::new(
2308 rmcp::model::ErrorCode::INTERNAL_ERROR,
2309 format!("serialization failed: {e}"),
2310 Some(error_meta("internal", false, "report this as a bug")),
2311 )
2312 }) {
2313 Ok(v) => v,
2314 Err(e) => return Ok(err_to_tool_result(e)),
2315 };
2316 result.structured_content = Some(structured);
2317 self.cache
2318 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2319 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2320 self.metrics_tx.send(crate::metrics::MetricEvent {
2321 ts: crate::metrics::unix_ms(),
2322 tool: "edit_overwrite",
2323 duration_ms: dur,
2324 output_chars: text.len(),
2325 param_path_depth: crate::metrics::path_component_count(¶m_path),
2326 max_depth: None,
2327 result: "ok",
2328 error_type: None,
2329 session_id: sid,
2330 seq: Some(seq),
2331 cache_hit: None,
2332 });
2333 Ok(result)
2334 }
2335
2336 #[instrument(skip(self, _context))]
2337 #[tool(
2338 name = "edit_replace",
2339 title = "Edit Replace",
2340 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). Whitespace-sensitive exact match. Use edit_overwrite to replace the whole file; edit_rename/edit_insert for AST-targeted changes. Example queries: Update the function signature in lib.rs.",
2341 output_schema = schema_for_type::<EditReplaceOutput>(),
2342 annotations(
2343 title = "Edit Replace",
2344 read_only_hint = false,
2345 destructive_hint = true,
2346 idempotent_hint = false,
2347 open_world_hint = false
2348 )
2349 )]
2350 async fn edit_replace(
2351 &self,
2352 params: Parameters<EditReplaceParams>,
2353 _context: RequestContext<RoleServer>,
2354 ) -> Result<CallToolResult, ErrorData> {
2355 let params = params.0;
2356 let _validated_path = match validate_path(¶ms.path, true) {
2357 Ok(p) => p,
2358 Err(e) => return Ok(err_to_tool_result(e)),
2359 };
2360 let t_start = std::time::Instant::now();
2361 let param_path = params.path.clone();
2362 let seq = self
2363 .session_call_seq
2364 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2365 let sid = self.session_id.lock().await.clone();
2366
2367 if std::fs::metadata(¶ms.path)
2369 .map(|m| m.is_dir())
2370 .unwrap_or(false)
2371 {
2372 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2373 self.metrics_tx.send(crate::metrics::MetricEvent {
2374 ts: crate::metrics::unix_ms(),
2375 tool: "edit_replace",
2376 duration_ms: dur,
2377 output_chars: 0,
2378 param_path_depth: crate::metrics::path_component_count(¶m_path),
2379 max_depth: None,
2380 result: "error",
2381 error_type: Some("invalid_params".to_string()),
2382 session_id: sid.clone(),
2383 seq: Some(seq),
2384 cache_hit: None,
2385 });
2386 return Ok(err_to_tool_result(ErrorData::new(
2387 rmcp::model::ErrorCode::INVALID_PARAMS,
2388 "path is a directory; cannot edit a directory".to_string(),
2389 Some(error_meta(
2390 "validation",
2391 false,
2392 "provide a file path, not a directory",
2393 )),
2394 )));
2395 }
2396
2397 let path = std::path::PathBuf::from(¶ms.path);
2398 let old_text = params.old_text.clone();
2399 let new_text = params.new_text.clone();
2400 let handle = tokio::task::spawn_blocking(move || {
2401 aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2402 });
2403
2404 let output = match handle.await {
2405 Ok(Ok(v)) => v,
2406 Ok(Err(aptu_coder_core::EditError::NotFound { path: _ })) => {
2407 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2408 self.metrics_tx.send(crate::metrics::MetricEvent {
2409 ts: crate::metrics::unix_ms(),
2410 tool: "edit_replace",
2411 duration_ms: dur,
2412 output_chars: 0,
2413 param_path_depth: crate::metrics::path_component_count(¶m_path),
2414 max_depth: None,
2415 result: "error",
2416 error_type: Some("invalid_params".to_string()),
2417 session_id: sid.clone(),
2418 seq: Some(seq),
2419 cache_hit: None,
2420 });
2421 return Ok(err_to_tool_result(ErrorData::new(
2422 rmcp::model::ErrorCode::INVALID_PARAMS,
2423 "old_text not found in file — verify the text matches exactly, including whitespace and newlines".to_string(),
2424 Some(error_meta(
2425 "validation",
2426 false,
2427 "check that old_text appears in the file",
2428 )),
2429 )));
2430 }
2431 Ok(Err(aptu_coder_core::EditError::Ambiguous { count, path: _ })) => {
2432 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2433 self.metrics_tx.send(crate::metrics::MetricEvent {
2434 ts: crate::metrics::unix_ms(),
2435 tool: "edit_replace",
2436 duration_ms: dur,
2437 output_chars: 0,
2438 param_path_depth: crate::metrics::path_component_count(¶m_path),
2439 max_depth: None,
2440 result: "error",
2441 error_type: Some("invalid_params".to_string()),
2442 session_id: sid.clone(),
2443 seq: Some(seq),
2444 cache_hit: None,
2445 });
2446 return Ok(err_to_tool_result(ErrorData::new(
2447 rmcp::model::ErrorCode::INVALID_PARAMS,
2448 format!(
2449 "old_text appears {count} times in file — make old_text longer and more specific to uniquely identify the block"
2450 ),
2451 Some(error_meta(
2452 "validation",
2453 false,
2454 "include more context in old_text to make it unique",
2455 )),
2456 )));
2457 }
2458 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2459 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2460 self.metrics_tx.send(crate::metrics::MetricEvent {
2461 ts: crate::metrics::unix_ms(),
2462 tool: "edit_replace",
2463 duration_ms: dur,
2464 output_chars: 0,
2465 param_path_depth: crate::metrics::path_component_count(¶m_path),
2466 max_depth: None,
2467 result: "error",
2468 error_type: Some("invalid_params".to_string()),
2469 session_id: sid.clone(),
2470 seq: Some(seq),
2471 cache_hit: None,
2472 });
2473 return Ok(err_to_tool_result(ErrorData::new(
2474 rmcp::model::ErrorCode::INVALID_PARAMS,
2475 "path is a directory".to_string(),
2476 Some(error_meta(
2477 "validation",
2478 false,
2479 "provide a file path, not a directory",
2480 )),
2481 )));
2482 }
2483 Ok(Err(e)) => {
2484 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2485 self.metrics_tx.send(crate::metrics::MetricEvent {
2486 ts: crate::metrics::unix_ms(),
2487 tool: "edit_replace",
2488 duration_ms: dur,
2489 output_chars: 0,
2490 param_path_depth: crate::metrics::path_component_count(¶m_path),
2491 max_depth: None,
2492 result: "error",
2493 error_type: Some("internal_error".to_string()),
2494 session_id: sid.clone(),
2495 seq: Some(seq),
2496 cache_hit: None,
2497 });
2498 return Ok(err_to_tool_result(ErrorData::new(
2499 rmcp::model::ErrorCode::INTERNAL_ERROR,
2500 e.to_string(),
2501 Some(error_meta(
2502 "resource",
2503 false,
2504 "check file path and permissions",
2505 )),
2506 )));
2507 }
2508 Err(e) => {
2509 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2510 self.metrics_tx.send(crate::metrics::MetricEvent {
2511 ts: crate::metrics::unix_ms(),
2512 tool: "edit_replace",
2513 duration_ms: dur,
2514 output_chars: 0,
2515 param_path_depth: crate::metrics::path_component_count(¶m_path),
2516 max_depth: None,
2517 result: "error",
2518 error_type: Some("internal_error".to_string()),
2519 session_id: sid.clone(),
2520 seq: Some(seq),
2521 cache_hit: None,
2522 });
2523 return Ok(err_to_tool_result(ErrorData::new(
2524 rmcp::model::ErrorCode::INTERNAL_ERROR,
2525 e.to_string(),
2526 Some(error_meta(
2527 "resource",
2528 false,
2529 "check file path and permissions",
2530 )),
2531 )));
2532 }
2533 };
2534
2535 let text = format!(
2536 "Edited {}: {} bytes -> {} bytes",
2537 output.path, output.bytes_before, output.bytes_after
2538 );
2539 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2540 .with_meta(Some(no_cache_meta()));
2541 let structured = match serde_json::to_value(&output).map_err(|e| {
2542 ErrorData::new(
2543 rmcp::model::ErrorCode::INTERNAL_ERROR,
2544 format!("serialization failed: {e}"),
2545 Some(error_meta("internal", false, "report this as a bug")),
2546 )
2547 }) {
2548 Ok(v) => v,
2549 Err(e) => return Ok(err_to_tool_result(e)),
2550 };
2551 result.structured_content = Some(structured);
2552 self.cache
2553 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2554 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2555 self.metrics_tx.send(crate::metrics::MetricEvent {
2556 ts: crate::metrics::unix_ms(),
2557 tool: "edit_replace",
2558 duration_ms: dur,
2559 output_chars: text.len(),
2560 param_path_depth: crate::metrics::path_component_count(¶m_path),
2561 max_depth: None,
2562 result: "ok",
2563 error_type: None,
2564 session_id: sid,
2565 seq: Some(seq),
2566 cache_hit: None,
2567 });
2568 Ok(result)
2569 }
2570
2571 #[instrument(skip(self, _context))]
2572 #[tool(
2573 name = "edit_rename",
2574 title = "Edit Rename",
2575 description = "AST-aware rename within a single file or across a directory tree. Matches syntactic identifiers only; occurrences in string literals and comments are excluded. When path is a file: returns path, old_name, new_name, occurrences_renamed. When path is a directory: renames old_name across all files in the directory tree where it appears as a syntactic identifier; returns files_changed (per-file results) and errors (per-file failures). Supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#. Example queries: Rename function parse_config to load_config in src/config.rs; or rename across all files in src/.",
2576 output_schema = schema_for_type::<EditRenameOutput>(),
2577 annotations(
2578 title = "Edit Rename",
2579 read_only_hint = false,
2580 destructive_hint = false,
2581 idempotent_hint = true,
2582 open_world_hint = false
2583 )
2584 )]
2585 async fn edit_rename(
2586 &self,
2587 params: Parameters<EditRenameParams>,
2588 _context: RequestContext<RoleServer>,
2589 ) -> Result<CallToolResult, ErrorData> {
2590 let params = params.0;
2591 let _validated_path = match validate_path(¶ms.path, true) {
2592 Ok(p) => p,
2593 Err(e) => return Ok(err_to_tool_result(e)),
2594 };
2595 let t_start = std::time::Instant::now();
2596 let param_path = params.path.clone();
2597 let seq = self
2598 .session_call_seq
2599 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2600 let sid = self.session_id.lock().await.clone();
2601
2602 let is_dir = std::fs::metadata(¶ms.path)
2603 .map(|m| m.is_dir())
2604 .unwrap_or(false);
2605
2606 let path = std::path::PathBuf::from(¶ms.path);
2607 let old_name = params.old_name.clone();
2608 let new_name = params.new_name.clone();
2609
2610 let handle = if is_dir {
2611 tokio::task::spawn_blocking(move || {
2612 edit_rename_directory(&path, &old_name, &new_name, None).map(|(results, errors)| {
2613 let total_occurrences: usize =
2614 results.iter().map(|r| r.occurrences_renamed).sum();
2615 aptu_coder_core::types::EditRenameOutput {
2616 path: path.display().to_string(),
2617 old_name,
2618 new_name,
2619 occurrences_renamed: total_occurrences,
2620 files_changed: Some(results),
2621 errors: Some(errors),
2622 }
2623 })
2624 })
2625 } else {
2626 tokio::task::spawn_blocking(move || {
2627 edit_rename_in_file(&path, &old_name, &new_name, None)
2628 })
2629 };
2630
2631 let output = match handle.await {
2632 Ok(Ok(v)) => v,
2633 Ok(Err(aptu_coder_core::EditError::SymbolNotFound { .. })) => {
2634 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2635 self.metrics_tx.send(crate::metrics::MetricEvent {
2636 ts: crate::metrics::unix_ms(),
2637 tool: "edit_rename",
2638 duration_ms: dur,
2639 output_chars: 0,
2640 param_path_depth: crate::metrics::path_component_count(¶m_path),
2641 max_depth: None,
2642 result: "error",
2643 error_type: Some("invalid_params".to_string()),
2644 session_id: sid.clone(),
2645 seq: Some(seq),
2646 cache_hit: None,
2647 });
2648 return Ok(err_to_tool_result(ErrorData::new(
2649 rmcp::model::ErrorCode::INVALID_PARAMS,
2650 "symbol not found in file".to_string(),
2651 Some(error_meta(
2652 "validation",
2653 false,
2654 "verify the symbol name and file path",
2655 )),
2656 )));
2657 }
2658 Ok(Err(aptu_coder_core::EditError::AmbiguousKind { .. })) => {
2659 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2660 self.metrics_tx.send(crate::metrics::MetricEvent {
2661 ts: crate::metrics::unix_ms(),
2662 tool: "edit_rename",
2663 duration_ms: dur,
2664 output_chars: 0,
2665 param_path_depth: crate::metrics::path_component_count(¶m_path),
2666 max_depth: None,
2667 result: "error",
2668 error_type: Some("invalid_params".to_string()),
2669 session_id: sid.clone(),
2670 seq: Some(seq),
2671 cache_hit: None,
2672 });
2673 return Ok(err_to_tool_result(ErrorData::new(
2674 rmcp::model::ErrorCode::INVALID_PARAMS,
2675 "symbol name is ambiguous".to_string(),
2676 Some(error_meta(
2677 "validation",
2678 false,
2679 "verify the symbol name is unique",
2680 )),
2681 )));
2682 }
2683 Ok(Err(aptu_coder_core::EditError::UnsupportedLanguage(_))) => {
2684 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2685 self.metrics_tx.send(crate::metrics::MetricEvent {
2686 ts: crate::metrics::unix_ms(),
2687 tool: "edit_rename",
2688 duration_ms: dur,
2689 output_chars: 0,
2690 param_path_depth: crate::metrics::path_component_count(¶m_path),
2691 max_depth: None,
2692 result: "error",
2693 error_type: Some("invalid_params".to_string()),
2694 session_id: sid.clone(),
2695 seq: Some(seq),
2696 cache_hit: None,
2697 });
2698 return Ok(err_to_tool_result(ErrorData::new(
2699 rmcp::model::ErrorCode::INVALID_PARAMS,
2700 "file language is not supported".to_string(),
2701 Some(error_meta(
2702 "validation",
2703 false,
2704 "check that the file has a supported language extension",
2705 )),
2706 )));
2707 }
2708 Ok(Err(aptu_coder_core::EditError::KindFilterUnsupported)) => {
2709 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2710 self.metrics_tx.send(crate::metrics::MetricEvent {
2711 ts: crate::metrics::unix_ms(),
2712 tool: "edit_rename",
2713 duration_ms: dur,
2714 output_chars: 0,
2715 param_path_depth: crate::metrics::path_component_count(¶m_path),
2716 max_depth: None,
2717 result: "error",
2718 error_type: Some("invalid_params".to_string()),
2719 session_id: sid.clone(),
2720 seq: Some(seq),
2721 cache_hit: None,
2722 });
2723 return Ok(err_to_tool_result(ErrorData::new(
2724 rmcp::model::ErrorCode::INVALID_PARAMS,
2725 "kind filtering is not supported with the current identifier query infrastructure"
2726 .to_string(),
2727 Some(error_meta(
2728 "validation",
2729 false,
2730 "omit the kind parameter",
2731 )),
2732 )));
2733 }
2734 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2735 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2736 self.metrics_tx.send(crate::metrics::MetricEvent {
2737 ts: crate::metrics::unix_ms(),
2738 tool: "edit_rename",
2739 duration_ms: dur,
2740 output_chars: 0,
2741 param_path_depth: crate::metrics::path_component_count(¶m_path),
2742 max_depth: None,
2743 result: "error",
2744 error_type: Some("invalid_params".to_string()),
2745 session_id: sid.clone(),
2746 seq: Some(seq),
2747 cache_hit: None,
2748 });
2749 return Ok(err_to_tool_result(ErrorData::new(
2750 rmcp::model::ErrorCode::INVALID_PARAMS,
2751 "path is a directory".to_string(),
2752 Some(error_meta(
2753 "validation",
2754 false,
2755 "provide a file path, not a directory",
2756 )),
2757 )));
2758 }
2759 Ok(Err(e)) => {
2760 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2761 self.metrics_tx.send(crate::metrics::MetricEvent {
2762 ts: crate::metrics::unix_ms(),
2763 tool: "edit_rename",
2764 duration_ms: dur,
2765 output_chars: 0,
2766 param_path_depth: crate::metrics::path_component_count(¶m_path),
2767 max_depth: None,
2768 result: "error",
2769 error_type: Some("internal_error".to_string()),
2770 session_id: sid.clone(),
2771 seq: Some(seq),
2772 cache_hit: None,
2773 });
2774 return Ok(err_to_tool_result(ErrorData::new(
2775 rmcp::model::ErrorCode::INTERNAL_ERROR,
2776 e.to_string(),
2777 Some(error_meta(
2778 "resource",
2779 false,
2780 "check file path and permissions",
2781 )),
2782 )));
2783 }
2784 Err(e) => {
2785 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2786 self.metrics_tx.send(crate::metrics::MetricEvent {
2787 ts: crate::metrics::unix_ms(),
2788 tool: "edit_rename",
2789 duration_ms: dur,
2790 output_chars: 0,
2791 param_path_depth: crate::metrics::path_component_count(¶m_path),
2792 max_depth: None,
2793 result: "error",
2794 error_type: Some("internal_error".to_string()),
2795 session_id: sid.clone(),
2796 seq: Some(seq),
2797 cache_hit: None,
2798 });
2799 return Ok(err_to_tool_result(ErrorData::new(
2800 rmcp::model::ErrorCode::INTERNAL_ERROR,
2801 e.to_string(),
2802 Some(error_meta(
2803 "resource",
2804 false,
2805 "check file path and permissions",
2806 )),
2807 )));
2808 }
2809 };
2810
2811 let text = format!(
2812 "Renamed '{}' to '{}' in {} ({} occurrence(s))",
2813 output.old_name, output.new_name, output.path, output.occurrences_renamed
2814 );
2815 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2816 .with_meta(Some(no_cache_meta()));
2817 let structured = match serde_json::to_value(&output).map_err(|e| {
2818 ErrorData::new(
2819 rmcp::model::ErrorCode::INTERNAL_ERROR,
2820 format!("serialization failed: {e}"),
2821 Some(error_meta("internal", false, "report this as a bug")),
2822 )
2823 }) {
2824 Ok(v) => v,
2825 Err(e) => return Ok(err_to_tool_result(e)),
2826 };
2827 result.structured_content = Some(structured);
2828 self.cache
2829 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2830 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2831 self.metrics_tx.send(crate::metrics::MetricEvent {
2832 ts: crate::metrics::unix_ms(),
2833 tool: "edit_rename",
2834 duration_ms: dur,
2835 output_chars: text.len(),
2836 param_path_depth: crate::metrics::path_component_count(¶m_path),
2837 max_depth: None,
2838 result: "ok",
2839 error_type: None,
2840 session_id: sid,
2841 seq: Some(seq),
2842 cache_hit: None,
2843 });
2844 Ok(result)
2845 }
2846
2847 #[instrument(skip(self, _context))]
2848 #[tool(
2849 name = "edit_insert",
2850 title = "Edit Insert",
2851 description = "Insert content immediately before or after a named identifier in a source file. Returns path, symbol_name, position, byte_offset. position is \"before\" or \"after\"; symbol_name must be an identifier (not a keyword or punctuation). Inserts content verbatim at that token's byte boundary; include leading/trailing newlines as needed. Uses first occurrence if symbol_name appears multiple times. Fails if symbol not found; fails if directory path supplied. Supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#. Example queries: Insert a #[instrument] attribute before the handle_request function.",
2852 output_schema = schema_for_type::<EditInsertOutput>(),
2853 annotations(
2854 title = "Edit Insert",
2855 read_only_hint = false,
2856 destructive_hint = true,
2857 idempotent_hint = false,
2858 open_world_hint = false
2859 )
2860 )]
2861 async fn edit_insert(
2862 &self,
2863 params: Parameters<EditInsertParams>,
2864 _context: RequestContext<RoleServer>,
2865 ) -> Result<CallToolResult, ErrorData> {
2866 let params = params.0;
2867 let _validated_path = match validate_path(¶ms.path, true) {
2868 Ok(p) => p,
2869 Err(e) => return Ok(err_to_tool_result(e)),
2870 };
2871 let t_start = std::time::Instant::now();
2872 let param_path = params.path.clone();
2873 let seq = self
2874 .session_call_seq
2875 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2876 let sid = self.session_id.lock().await.clone();
2877
2878 if std::fs::metadata(¶ms.path)
2880 .map(|m| m.is_dir())
2881 .unwrap_or(false)
2882 {
2883 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2884 self.metrics_tx.send(crate::metrics::MetricEvent {
2885 ts: crate::metrics::unix_ms(),
2886 tool: "edit_insert",
2887 duration_ms: dur,
2888 output_chars: 0,
2889 param_path_depth: crate::metrics::path_component_count(¶m_path),
2890 max_depth: None,
2891 result: "error",
2892 error_type: Some("invalid_params".to_string()),
2893 session_id: sid.clone(),
2894 seq: Some(seq),
2895 cache_hit: None,
2896 });
2897 return Ok(err_to_tool_result(ErrorData::new(
2898 rmcp::model::ErrorCode::INVALID_PARAMS,
2899 "edit_insert operates on a single file — provide a file path, not a directory"
2900 .to_string(),
2901 Some(error_meta(
2902 "validation",
2903 false,
2904 "provide a file path, not a directory",
2905 )),
2906 )));
2907 }
2908
2909 let path = std::path::PathBuf::from(¶ms.path);
2910 let symbol_name = params.symbol_name.clone();
2911 let position = params.position;
2912 let content = params.content.clone();
2913 let handle = tokio::task::spawn_blocking(move || {
2914 edit_insert_at_symbol(&path, &symbol_name, position, &content)
2915 });
2916
2917 let output = match handle.await {
2918 Ok(Ok(v)) => v,
2919 Ok(Err(aptu_coder_core::EditError::SymbolNotFound { .. })) => {
2920 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2921 self.metrics_tx.send(crate::metrics::MetricEvent {
2922 ts: crate::metrics::unix_ms(),
2923 tool: "edit_insert",
2924 duration_ms: dur,
2925 output_chars: 0,
2926 param_path_depth: crate::metrics::path_component_count(¶m_path),
2927 max_depth: None,
2928 result: "error",
2929 error_type: Some("invalid_params".to_string()),
2930 session_id: sid.clone(),
2931 seq: Some(seq),
2932 cache_hit: None,
2933 });
2934 return Ok(err_to_tool_result(ErrorData::new(
2935 rmcp::model::ErrorCode::INVALID_PARAMS,
2936 "symbol not found in file".to_string(),
2937 Some(error_meta(
2938 "validation",
2939 false,
2940 "verify the symbol name and file path",
2941 )),
2942 )));
2943 }
2944 Ok(Err(aptu_coder_core::EditError::UnsupportedLanguage(_))) => {
2945 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2946 self.metrics_tx.send(crate::metrics::MetricEvent {
2947 ts: crate::metrics::unix_ms(),
2948 tool: "edit_insert",
2949 duration_ms: dur,
2950 output_chars: 0,
2951 param_path_depth: crate::metrics::path_component_count(¶m_path),
2952 max_depth: None,
2953 result: "error",
2954 error_type: Some("invalid_params".to_string()),
2955 session_id: sid.clone(),
2956 seq: Some(seq),
2957 cache_hit: None,
2958 });
2959 return Ok(err_to_tool_result(ErrorData::new(
2960 rmcp::model::ErrorCode::INVALID_PARAMS,
2961 "file language is not supported".to_string(),
2962 Some(error_meta(
2963 "validation",
2964 false,
2965 "check that the file has a supported language extension",
2966 )),
2967 )));
2968 }
2969 Ok(Err(e)) => {
2970 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2971 self.metrics_tx.send(crate::metrics::MetricEvent {
2972 ts: crate::metrics::unix_ms(),
2973 tool: "edit_insert",
2974 duration_ms: dur,
2975 output_chars: 0,
2976 param_path_depth: crate::metrics::path_component_count(¶m_path),
2977 max_depth: None,
2978 result: "error",
2979 error_type: Some("internal_error".to_string()),
2980 session_id: sid.clone(),
2981 seq: Some(seq),
2982 cache_hit: None,
2983 });
2984 return Ok(err_to_tool_result(ErrorData::new(
2985 rmcp::model::ErrorCode::INTERNAL_ERROR,
2986 e.to_string(),
2987 Some(error_meta(
2988 "resource",
2989 false,
2990 "check file path and permissions",
2991 )),
2992 )));
2993 }
2994 Err(e) => {
2995 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2996 self.metrics_tx.send(crate::metrics::MetricEvent {
2997 ts: crate::metrics::unix_ms(),
2998 tool: "edit_insert",
2999 duration_ms: dur,
3000 output_chars: 0,
3001 param_path_depth: crate::metrics::path_component_count(¶m_path),
3002 max_depth: None,
3003 result: "error",
3004 error_type: Some("internal_error".to_string()),
3005 session_id: sid.clone(),
3006 seq: Some(seq),
3007 cache_hit: None,
3008 });
3009 return Ok(err_to_tool_result(ErrorData::new(
3010 rmcp::model::ErrorCode::INTERNAL_ERROR,
3011 e.to_string(),
3012 Some(error_meta(
3013 "resource",
3014 false,
3015 "check file path and permissions",
3016 )),
3017 )));
3018 }
3019 };
3020
3021 let position_str = match output.position {
3022 aptu_coder_core::types::InsertPosition::Before => "before",
3023 aptu_coder_core::types::InsertPosition::After => "after",
3024 };
3025 let text = format!(
3026 "Inserted content {} '{}' in {} (at byte offset {})",
3027 position_str, output.symbol_name, output.path, output.byte_offset
3028 );
3029 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
3030 .with_meta(Some(no_cache_meta()));
3031 let structured = match serde_json::to_value(&output).map_err(|e| {
3032 ErrorData::new(
3033 rmcp::model::ErrorCode::INTERNAL_ERROR,
3034 format!("serialization failed: {e}"),
3035 Some(error_meta("internal", false, "report this as a bug")),
3036 )
3037 }) {
3038 Ok(v) => v,
3039 Err(e) => return Ok(err_to_tool_result(e)),
3040 };
3041 result.structured_content = Some(structured);
3042 self.cache
3043 .invalidate_file(&std::path::PathBuf::from(¶m_path));
3044 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3045 self.metrics_tx.send(crate::metrics::MetricEvent {
3046 ts: crate::metrics::unix_ms(),
3047 tool: "edit_insert",
3048 duration_ms: dur,
3049 output_chars: text.len(),
3050 param_path_depth: crate::metrics::path_component_count(¶m_path),
3051 max_depth: None,
3052 result: "ok",
3053 error_type: None,
3054 session_id: sid,
3055 seq: Some(seq),
3056 cache_hit: None,
3057 });
3058 Ok(result)
3059 }
3060
3061 #[tool(
3062 name = "exec_command",
3063 title = "Exec Command",
3064 description = "Execute shell command via sh -c (or $SHELL if set). Returns stdout, stderr, interleaved, exit_code, timed_out, output_truncated. Output capped at 2000 lines and 50 KB per stream; use timeout_secs to limit execution time. working_dir sets initial working directory; cd and absolute paths in command string bypass this restriction. Fails if working_dir does not exist, is not a directory, or is outside CWD. Pass stdin to pipe UTF-8 content into the process (max 1 MB). For file creation and edits, prefer the edit_* tools. Example queries: Run the test suite and capture output.",
3065 output_schema = schema_for_type::<types::ShellOutput>(),
3066 annotations(
3067 title = "Exec Command",
3068 read_only_hint = false,
3069 destructive_hint = true,
3070 idempotent_hint = false,
3071 open_world_hint = true
3072 )
3073 )]
3074 #[instrument(skip(self, context))]
3075 pub async fn exec_command(
3076 &self,
3077 params: Parameters<types::ExecCommandParams>,
3078 context: RequestContext<RoleServer>,
3079 ) -> Result<CallToolResult, ErrorData> {
3080 let t_start = std::time::Instant::now();
3081 let params = params.0;
3082
3083 let working_dir_path = if let Some(ref wd) = params.working_dir {
3085 match validate_path(wd, true) {
3086 Ok(p) => {
3087 if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
3089 return Ok(err_to_tool_result(ErrorData::new(
3090 rmcp::model::ErrorCode::INVALID_PARAMS,
3091 "working_dir must be a directory".to_string(),
3092 Some(error_meta(
3093 "validation",
3094 false,
3095 "provide a valid directory path",
3096 )),
3097 )));
3098 }
3099 Some(p)
3100 }
3101 Err(e) => {
3102 return Ok(err_to_tool_result(e));
3103 }
3104 }
3105 } else {
3106 None
3107 };
3108
3109 let param_path = params.working_dir.clone();
3110 let seq = self
3111 .session_call_seq
3112 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3113 let sid = self.session_id.lock().await.clone();
3114
3115 if let Some(ref stdin_content) = params.stdin
3117 && stdin_content.len() > STDIN_MAX_BYTES
3118 {
3119 return Ok(err_to_tool_result(ErrorData::new(
3120 rmcp::model::ErrorCode::INVALID_PARAMS,
3121 "stdin exceeds 1 MB limit".to_string(),
3122 Some(error_meta("validation", false, "reduce stdin content size")),
3123 )));
3124 }
3125
3126 let command = params.command.clone();
3127 let timeout_secs = params.timeout_secs;
3128
3129 let peer = self.peer.lock().await.clone();
3131 let progress_token = context.meta.get_progress_token();
3132
3133 let progress_handle: Option<tokio::task::JoinHandle<()>> =
3135 if timeout_secs.is_none_or(|t| t > 10) {
3136 if let (Some(token), Some(peer_conn)) = (progress_token.clone(), peer.clone()) {
3137 let self_clone = self.clone();
3138 Some(tokio::spawn(async move {
3139 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
3140 interval.tick().await; let mut tick = 0u64;
3142 loop {
3143 interval.tick().await;
3144 tick += 1;
3145 let progress = match timeout_secs {
3146 Some(secs) => ((tick * 5) as f64 / secs as f64).min(0.99),
3147 None => 0.0,
3148 };
3149 self_clone
3150 .emit_progress(
3151 Some(peer_conn.clone()),
3152 &token,
3153 progress,
3154 1.0,
3155 "command running".to_string(),
3156 )
3157 .await;
3158 }
3159 }))
3160 } else {
3161 None
3162 }
3163 } else {
3164 None
3165 };
3166
3167 let shell = resolve_shell();
3169
3170 let mut cmd = tokio::process::Command::new(shell);
3171 cmd.arg("-c").arg(&command);
3172
3173 if let Some(ref wd) = working_dir_path {
3174 cmd.current_dir(wd);
3175 }
3176
3177 cmd.stdout(std::process::Stdio::piped())
3178 .stderr(std::process::Stdio::piped());
3179
3180 if params.stdin.is_some() {
3182 cmd.stdin(std::process::Stdio::piped());
3183 } else {
3184 cmd.stdin(std::process::Stdio::null());
3185 }
3186
3187 #[cfg(unix)]
3188 {
3189 let memory_limit_mb = params.memory_limit_mb;
3190 let cpu_limit_secs = params.cpu_limit_secs;
3191 #[cfg(not(target_os = "linux"))]
3192 if memory_limit_mb.is_some() {
3193 warn!("memory_limit_mb is not enforced on this platform (Linux only)");
3194 }
3195 if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
3196 unsafe {
3200 cmd.pre_exec(move || {
3201 #[cfg(target_os = "linux")]
3202 if let Some(mb) = memory_limit_mb {
3203 let bytes = mb.saturating_mul(1024 * 1024);
3204 setrlimit(Resource::RLIMIT_AS, bytes, bytes)
3205 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3206 }
3207 if let Some(cpu) = cpu_limit_secs {
3208 setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
3209 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3210 }
3211 Ok(())
3212 });
3213 }
3214 }
3215 }
3216
3217 let mut child = match cmd.spawn() {
3218 Ok(c) => c,
3219 Err(e) => {
3220 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3221 self.metrics_tx.send(crate::metrics::MetricEvent {
3222 ts: crate::metrics::unix_ms(),
3223 tool: "exec_command",
3224 duration_ms: dur,
3225 output_chars: 0,
3226 param_path_depth: crate::metrics::path_component_count(
3227 param_path.as_deref().unwrap_or(""),
3228 ),
3229 max_depth: None,
3230 result: "error",
3231 error_type: Some("internal_error".to_string()),
3232 session_id: sid.clone(),
3233 seq: Some(seq),
3234 cache_hit: None,
3235 });
3236 return Ok(err_to_tool_result(ErrorData::new(
3237 rmcp::model::ErrorCode::INTERNAL_ERROR,
3238 format!("failed to spawn command: {e}"),
3239 Some(error_meta(
3240 "resource",
3241 false,
3242 "check command syntax and permissions",
3243 )),
3244 )));
3245 }
3246 };
3247
3248 const MAX_BYTES: usize = 50 * 1024;
3250
3251 let stdout_pipe = child.stdout.take();
3252 let stderr_pipe = child.stderr.take();
3253
3254 if let Some(stdin_content) = params.stdin
3256 && let Some(mut stdin_handle) = child.stdin.take()
3257 {
3258 use tokio::io::AsyncWriteExt as _;
3259 match stdin_handle.write_all(stdin_content.as_bytes()).await {
3260 Ok(()) => {
3261 drop(stdin_handle); }
3263 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
3264 }
3266 Err(e) => {
3267 warn!("failed to write stdin: {e}");
3268 }
3269 }
3270 }
3271
3272 use std::sync::Arc;
3273 use tokio::io::AsyncBufReadExt as _;
3274 use tokio::sync::Mutex as TokioMutex;
3275 use tokio_stream::StreamExt as TokioStreamExt;
3276 use tokio_stream::wrappers::LinesStream;
3277
3278 let stdout_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
3281 let stderr_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
3282 let interleaved_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
3283
3284 let so_acc = Arc::clone(&stdout_shared);
3285 let se_acc = Arc::clone(&stderr_shared);
3286 let il_acc = Arc::clone(&interleaved_shared);
3287
3288 let mut drain_task = tokio::spawn(async move {
3291 let mut so_bytes = 0usize;
3292 let mut se_bytes = 0usize;
3293 let mut il_bytes = 0usize;
3294
3295 let so_stream = stdout_pipe.map(|p| {
3296 LinesStream::new(tokio::io::BufReader::new(p).lines())
3297 .map(|l| l.map(|s| (false, s)))
3298 });
3299 let se_stream = stderr_pipe.map(|p| {
3300 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3301 });
3302
3303 match (so_stream, se_stream) {
3304 (Some(so), Some(se)) => {
3305 let mut merged = so.merge(se);
3306 while let Some(item) = merged.next().await {
3307 if let Ok((is_stderr, line)) = item {
3308 let entry = format!("{line}\n");
3309 if is_stderr {
3310 if se_bytes < MAX_BYTES {
3311 se_bytes += entry.len();
3312 se_acc.lock().await.push_str(&entry);
3313 if il_bytes < 2 * MAX_BYTES {
3314 il_bytes += entry.len();
3315 il_acc.lock().await.push_str(&entry);
3316 }
3317 }
3318 } else if so_bytes < MAX_BYTES {
3319 so_bytes += entry.len();
3320 so_acc.lock().await.push_str(&entry);
3321 if il_bytes < 2 * MAX_BYTES {
3322 il_bytes += entry.len();
3323 il_acc.lock().await.push_str(&entry);
3324 }
3325 }
3326 }
3327 }
3328 }
3329 (Some(so), None) => {
3330 let mut stream = so;
3331 while let Some(item) = stream.next().await {
3332 if let Ok((_, line)) = item
3333 && so_bytes < MAX_BYTES
3334 {
3335 let entry = format!("{line}\n");
3336 so_bytes += entry.len();
3337 so_acc.lock().await.push_str(&entry);
3338 if il_bytes < 2 * MAX_BYTES {
3339 il_bytes += entry.len();
3340 il_acc.lock().await.push_str(&entry);
3341 }
3342 }
3343 }
3344 }
3345 (None, Some(se)) => {
3346 let mut stream = se;
3347 while let Some(item) = stream.next().await {
3348 if let Ok((_, line)) = item
3349 && se_bytes < MAX_BYTES
3350 {
3351 let entry = format!("{line}\n");
3352 se_bytes += entry.len();
3353 se_acc.lock().await.push_str(&entry);
3354 if il_bytes < 2 * MAX_BYTES {
3355 il_bytes += entry.len();
3356 il_acc.lock().await.push_str(&entry);
3357 }
3358 }
3359 }
3360 }
3361 (None, None) => {}
3362 }
3363 });
3364
3365 let (exit_code, timed_out, mut output_truncated, output_collection_error) = tokio::select! {
3366 _ = &mut drain_task => {
3367 let (status, drain_truncated) = match tokio::time::timeout(
3370 std::time::Duration::from_millis(500),
3371 child.wait()
3372 ).await {
3373 Ok(Ok(s)) => (Some(s), false),
3374 Ok(Err(_)) => (None, false),
3375 Err(_) => {
3376 child.start_kill().ok();
3377 let _ = child.wait().await;
3378 (None, true)
3379 }
3380 };
3381 let exit_code = status.and_then(|s| s.code());
3382 let ocerr = if drain_truncated {
3383 Some("post-exit drain timeout: background process held pipes".to_string())
3384 } else {
3385 None
3386 };
3387 (exit_code, false, drain_truncated, ocerr)
3388 }
3389 _ = async {
3390 if let Some(secs) = timeout_secs {
3391 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3392 } else {
3393 std::future::pending::<()>().await;
3394 }
3395 } => {
3396 let _ = child.kill().await;
3398 let _ = child.wait().await;
3399 drain_task.abort();
3400 (None, true, false, None)
3402 }
3403 };
3404
3405 if let Some(handle) = progress_handle {
3407 handle.abort();
3408 }
3409
3410 let stdout_str = std::mem::take(&mut *stdout_shared.lock().await);
3412 let stderr_str = std::mem::take(&mut *stderr_shared.lock().await);
3413 let interleaved_str = std::mem::take(&mut *interleaved_shared.lock().await);
3414
3415 let slot = seq % 8;
3417 let (stdout, stderr, overflow_notice) =
3418 handle_output_overflow(stdout_str, stderr_str, slot);
3419 output_truncated = output_truncated || overflow_notice.is_some();
3420
3421 let mut output = types::ShellOutput::new(
3422 stdout,
3423 stderr,
3424 interleaved_str,
3425 exit_code,
3426 timed_out,
3427 output_truncated,
3428 );
3429 output.output_collection_error = output_collection_error;
3430
3431 let output_text = if output.interleaved.is_empty() {
3433 format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
3434 } else {
3435 format!("Output:\n{}", output.interleaved)
3436 };
3437
3438 let text = format!(
3439 "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
3440 params.command,
3441 exit_code
3442 .map(|c| c.to_string())
3443 .unwrap_or_else(|| "null".to_string()),
3444 timed_out,
3445 output_truncated,
3446 output_text,
3447 );
3448
3449 let mut content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
3450 if let Some(notice) = overflow_notice {
3451 content_blocks.push(Content::text(notice).with_priority(0.0));
3452 }
3453
3454 let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
3459
3460 let mut result = if command_failed {
3461 CallToolResult::error(content_blocks)
3462 } else {
3463 CallToolResult::success(content_blocks)
3464 }
3465 .with_meta(Some(no_cache_meta()));
3466
3467 let structured = match serde_json::to_value(&output).map_err(|e| {
3468 ErrorData::new(
3469 rmcp::model::ErrorCode::INTERNAL_ERROR,
3470 format!("serialization failed: {e}"),
3471 Some(error_meta("internal", false, "report this as a bug")),
3472 )
3473 }) {
3474 Ok(v) => v,
3475 Err(e) => {
3476 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3477 self.metrics_tx.send(crate::metrics::MetricEvent {
3478 ts: crate::metrics::unix_ms(),
3479 tool: "exec_command",
3480 duration_ms: dur,
3481 output_chars: 0,
3482 param_path_depth: crate::metrics::path_component_count(
3483 param_path.as_deref().unwrap_or(""),
3484 ),
3485 max_depth: None,
3486 result: "error",
3487 error_type: Some("internal_error".to_string()),
3488 session_id: sid.clone(),
3489 seq: Some(seq),
3490 cache_hit: None,
3491 });
3492 return Ok(err_to_tool_result(e));
3493 }
3494 };
3495
3496 result.structured_content = Some(structured);
3497 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3498 self.metrics_tx.send(crate::metrics::MetricEvent {
3499 ts: crate::metrics::unix_ms(),
3500 tool: "exec_command",
3501 duration_ms: dur,
3502 output_chars: text.len(),
3503 param_path_depth: crate::metrics::path_component_count(
3504 param_path.as_deref().unwrap_or(""),
3505 ),
3506 max_depth: None,
3507 result: "ok",
3508 error_type: None,
3509 session_id: sid,
3510 seq: Some(seq),
3511 cache_hit: None,
3512 });
3513 Ok(result)
3514 }
3515}
3516
3517fn handle_output_overflow(
3523 stdout: String,
3524 stderr: String,
3525 slot: u32,
3526) -> (String, String, Option<String>) {
3527 const MAX_OUTPUT_LINES: usize = 2000;
3528 const OVERFLOW_PREVIEW_LINES: usize = 50;
3529
3530 let stdout_lines: Vec<&str> = stdout.lines().collect();
3531 let stderr_lines: Vec<&str> = stderr.lines().collect();
3532
3533 if stdout_lines.len() <= MAX_OUTPUT_LINES && stderr_lines.len() <= MAX_OUTPUT_LINES {
3534 return (stdout, stderr, None);
3535 }
3536
3537 let base = std::env::temp_dir()
3539 .join("aptu-coder-overflow")
3540 .join(format!("slot-{slot}"));
3541 let _ = std::fs::create_dir_all(&base);
3542
3543 let stdout_path = base.join("stdout");
3544 let stderr_path = base.join("stderr");
3545
3546 let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3547 let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3548
3549 let stdout_preview = if stdout_lines.len() > MAX_OUTPUT_LINES {
3551 stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3552 } else {
3553 stdout
3554 };
3555 let stderr_preview = if stderr_lines.len() > MAX_OUTPUT_LINES {
3556 stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3557 } else {
3558 stderr
3559 };
3560
3561 let notice = format!(
3562 "Output exceeded {MAX_OUTPUT_LINES} lines and was saved to:\n stdout: {}\n stderr: {}\nThe last {OVERFLOW_PREVIEW_LINES} lines are included above. To read the full output:\n cat {}",
3563 stdout_path.display(),
3564 stderr_path.display(),
3565 stdout_path.display(),
3566 );
3567
3568 (stdout_preview, stderr_preview, Some(notice))
3569}
3570
3571#[derive(Clone)]
3575struct FocusedAnalysisParams {
3576 path: std::path::PathBuf,
3577 symbol: String,
3578 match_mode: SymbolMatchMode,
3579 follow_depth: u32,
3580 max_depth: Option<u32>,
3581 ast_recursion_limit: Option<usize>,
3582 use_summary: bool,
3583 impl_only: Option<bool>,
3584 def_use: bool,
3585}
3586
3587#[tool_handler]
3588impl ServerHandler for CodeAnalyzer {
3589 async fn initialize(
3590 &self,
3591 _request: InitializeRequestParams,
3592 context: RequestContext<RoleServer>,
3593 ) -> Result<InitializeResult, ErrorData> {
3594 if let Some(meta) = context.extensions.get::<Meta>() {
3597 let mut meta_lock = self.profile_meta.lock().await;
3598 *meta_lock = Some(meta.0.clone());
3599 }
3600 Ok(self.get_info())
3601 }
3602
3603 fn get_info(&self) -> InitializeResult {
3604 let excluded = crate::EXCLUDED_DIRS.join(", ");
3605 let instructions = format!(
3606 "Recommended workflow:\n\
3607 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3608 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\
3609 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3610 4. Use analyze_symbol to trace call graphs.\n\
3611 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."
3612 );
3613 let capabilities = ServerCapabilities::builder()
3614 .enable_logging()
3615 .enable_tools()
3616 .enable_tool_list_changed()
3617 .enable_completions()
3618 .build();
3619 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3620 .with_title("Aptu Coder")
3621 .with_description("MCP server for code structure analysis using tree-sitter");
3622 InitializeResult::new(capabilities)
3623 .with_server_info(server_info)
3624 .with_instructions(&instructions)
3625 }
3626
3627 async fn list_tools(
3628 &self,
3629 _request: Option<rmcp::model::PaginatedRequestParams>,
3630 _context: RequestContext<RoleServer>,
3631 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
3632 let router = self.tool_router.read().await;
3633 Ok(rmcp::model::ListToolsResult {
3634 tools: router.list_all(),
3635 meta: None,
3636 next_cursor: None,
3637 })
3638 }
3639
3640 async fn call_tool(
3641 &self,
3642 request: rmcp::model::CallToolRequestParams,
3643 context: RequestContext<RoleServer>,
3644 ) -> Result<CallToolResult, ErrorData> {
3645 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
3646 let router = self.tool_router.read().await;
3647 router.call(tcc).await
3648 }
3649
3650 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
3651 let mut peer_lock = self.peer.lock().await;
3652 *peer_lock = Some(context.peer.clone());
3653 drop(peer_lock);
3654
3655 let millis = std::time::SystemTime::now()
3657 .duration_since(std::time::UNIX_EPOCH)
3658 .unwrap_or_default()
3659 .as_millis()
3660 .try_into()
3661 .unwrap_or(u64::MAX);
3662 let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3663 let sid = format!("{millis}-{counter}");
3664 {
3665 let mut session_id_lock = self.session_id.lock().await;
3666 *session_id_lock = Some(sid);
3667 }
3668 self.session_call_seq
3669 .store(0, std::sync::atomic::Ordering::Relaxed);
3670
3671 let meta_lock = self.profile_meta.lock().await;
3674 if let Some(meta) = meta_lock.as_ref()
3675 && let Some(profile_val) = meta.get("io.clouatre-labs/profile")
3676 && let Some(profile) = profile_val.as_str()
3677 {
3678 let mut router = self.tool_router.write().await;
3679 match profile {
3680 "edit" => {
3681 router.disable_route("analyze_directory");
3683 router.disable_route("analyze_file");
3684 router.disable_route("analyze_module");
3685 router.disable_route("analyze_symbol");
3686 router.disable_route("edit_rename");
3687 router.disable_route("edit_insert");
3688 }
3689 "analyze" => {
3690 router.disable_route("edit_replace");
3692 router.disable_route("edit_overwrite");
3693 router.disable_route("edit_rename");
3694 router.disable_route("edit_insert");
3695 }
3696 _ => {
3697 }
3699 }
3700 router.bind_peer_notifier(&context.peer);
3702 }
3703 drop(meta_lock);
3704
3705 let peer = self.peer.clone();
3707 let event_rx = self.event_rx.clone();
3708
3709 tokio::spawn(async move {
3710 let rx = {
3711 let mut rx_lock = event_rx.lock().await;
3712 rx_lock.take()
3713 };
3714
3715 if let Some(mut receiver) = rx {
3716 let mut buffer = Vec::with_capacity(64);
3717 loop {
3718 receiver.recv_many(&mut buffer, 64).await;
3720
3721 if buffer.is_empty() {
3722 break;
3724 }
3725
3726 let peer_lock = peer.lock().await;
3728 if let Some(peer) = peer_lock.as_ref() {
3729 for log_event in buffer.drain(..) {
3730 let notification = ServerNotification::LoggingMessageNotification(
3731 Notification::new(LoggingMessageNotificationParam {
3732 level: log_event.level,
3733 logger: Some(log_event.logger),
3734 data: log_event.data,
3735 }),
3736 );
3737 if let Err(e) = peer.send_notification(notification).await {
3738 warn!("Failed to send logging notification: {}", e);
3739 }
3740 }
3741 }
3742 }
3743 }
3744 });
3745 }
3746
3747 #[instrument(skip(self, _context))]
3748 async fn on_cancelled(
3749 &self,
3750 notification: CancelledNotificationParam,
3751 _context: NotificationContext<RoleServer>,
3752 ) {
3753 tracing::info!(
3754 request_id = ?notification.request_id,
3755 reason = ?notification.reason,
3756 "Received cancellation notification"
3757 );
3758 }
3759
3760 #[instrument(skip(self, _context))]
3761 async fn complete(
3762 &self,
3763 request: CompleteRequestParams,
3764 _context: RequestContext<RoleServer>,
3765 ) -> Result<CompleteResult, ErrorData> {
3766 let argument_name = &request.argument.name;
3768 let argument_value = &request.argument.value;
3769
3770 let completions = match argument_name.as_str() {
3771 "path" => {
3772 let root = Path::new(".");
3774 completion::path_completions(root, argument_value)
3775 }
3776 "symbol" => {
3777 let path_arg = request
3779 .context
3780 .as_ref()
3781 .and_then(|ctx| ctx.get_argument("path"));
3782
3783 match path_arg {
3784 Some(path_str) => {
3785 let path = Path::new(path_str);
3786 completion::symbol_completions(&self.cache, path, argument_value)
3787 }
3788 None => Vec::new(),
3789 }
3790 }
3791 _ => Vec::new(),
3792 };
3793
3794 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
3796 let (values, has_more) = if completions.len() > 100 {
3797 (completions.into_iter().take(100).collect(), true)
3798 } else {
3799 (completions, false)
3800 };
3801
3802 let completion_info =
3803 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
3804 Ok(info) => info,
3805 Err(_) => {
3806 CompletionInfo::with_all_values(Vec::new())
3808 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
3809 }
3810 };
3811
3812 Ok(CompleteResult::new(completion_info))
3813 }
3814
3815 async fn set_level(
3816 &self,
3817 params: SetLevelRequestParams,
3818 _context: RequestContext<RoleServer>,
3819 ) -> Result<(), ErrorData> {
3820 let level_filter = match params.level {
3821 LoggingLevel::Debug => LevelFilter::DEBUG,
3822 LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
3823 LoggingLevel::Warning => LevelFilter::WARN,
3824 LoggingLevel::Error
3825 | LoggingLevel::Critical
3826 | LoggingLevel::Alert
3827 | LoggingLevel::Emergency => LevelFilter::ERROR,
3828 };
3829
3830 let mut filter_lock = self
3831 .log_level_filter
3832 .lock()
3833 .unwrap_or_else(|e| e.into_inner());
3834 *filter_lock = level_filter;
3835 Ok(())
3836 }
3837}
3838
3839#[cfg(test)]
3840mod tests {
3841 use super::*;
3842
3843 #[tokio::test]
3844 async fn test_emit_progress_none_peer_is_noop() {
3845 let peer = Arc::new(TokioMutex::new(None));
3846 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3847 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3848 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3849 let analyzer = CodeAnalyzer::new(
3850 peer,
3851 log_level_filter,
3852 rx,
3853 crate::metrics::MetricsSender(metrics_tx),
3854 );
3855 let token = ProgressToken(NumberOrString::String("test".into()));
3856 analyzer
3858 .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
3859 .await;
3860 }
3861
3862 fn make_analyzer() -> CodeAnalyzer {
3863 let peer = Arc::new(TokioMutex::new(None));
3864 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3865 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3866 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3867 CodeAnalyzer::new(
3868 peer,
3869 log_level_filter,
3870 rx,
3871 crate::metrics::MetricsSender(metrics_tx),
3872 )
3873 }
3874
3875 #[test]
3876 fn test_summary_cursor_conflict() {
3877 assert!(summary_cursor_conflict(Some(true), Some("cursor")));
3878 assert!(!summary_cursor_conflict(Some(true), None));
3879 assert!(!summary_cursor_conflict(None, Some("x")));
3880 assert!(!summary_cursor_conflict(None, None));
3881 }
3882
3883 #[tokio::test]
3884 async fn test_validate_impl_only_non_rust_returns_invalid_params() {
3885 use tempfile::TempDir;
3886
3887 let dir = TempDir::new().unwrap();
3888 std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
3889
3890 let analyzer = make_analyzer();
3891 let entries: Vec<traversal::WalkEntry> =
3894 traversal::walk_directory(dir.path(), None).unwrap_or_default();
3895 let result = CodeAnalyzer::validate_impl_only(&entries);
3896 assert!(result.is_err());
3897 let err = result.unwrap_err();
3898 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
3899 drop(analyzer); }
3901
3902 #[tokio::test]
3903 async fn test_no_cache_meta_on_analyze_directory_result() {
3904 use aptu_coder_core::types::{
3905 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3906 };
3907 use tempfile::TempDir;
3908
3909 let dir = TempDir::new().unwrap();
3910 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
3911
3912 let analyzer = make_analyzer();
3913 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3914 "path": dir.path().to_str().unwrap(),
3915 }))
3916 .unwrap();
3917 let ct = tokio_util::sync::CancellationToken::new();
3918 let (arc_output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
3919 let meta = no_cache_meta();
3921 assert_eq!(
3922 meta.0.get("cache_hint").and_then(|v| v.as_str()),
3923 Some("no-cache"),
3924 );
3925 drop(arc_output);
3926 }
3927
3928 #[test]
3929 fn test_complete_path_completions_returns_suggestions() {
3930 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
3935 let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
3936 let suggestions = completion::path_completions(workspace_root, "aptu-");
3937 assert!(
3938 !suggestions.is_empty(),
3939 "expected completions for prefix 'aptu-' in workspace root"
3940 );
3941 }
3942
3943 #[tokio::test]
3944 async fn test_handle_overview_mode_verbose_no_summary_block() {
3945 use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
3946 use aptu_coder_core::types::{
3947 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3948 };
3949 use tempfile::TempDir;
3950
3951 let tmp = TempDir::new().unwrap();
3952 std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
3953
3954 let peer = Arc::new(TokioMutex::new(None));
3955 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3956 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3957 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3958 let analyzer = CodeAnalyzer::new(
3959 peer,
3960 log_level_filter,
3961 rx,
3962 crate::metrics::MetricsSender(metrics_tx),
3963 );
3964
3965 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3966 "path": tmp.path().to_str().unwrap(),
3967 "verbose": true,
3968 }))
3969 .unwrap();
3970
3971 let ct = tokio_util::sync::CancellationToken::new();
3972 let (output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
3973
3974 let use_summary = output.formatted.len() > SIZE_LIMIT; let paginated =
3977 paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
3978 let verbose = true;
3979 let formatted = if !use_summary {
3980 format_structure_paginated(
3981 &paginated.items,
3982 paginated.total,
3983 params.max_depth,
3984 Some(std::path::Path::new(¶ms.path)),
3985 verbose,
3986 )
3987 } else {
3988 output.formatted.clone()
3989 };
3990
3991 assert!(
3993 !formatted.contains("SUMMARY:"),
3994 "verbose=true must not emit SUMMARY: block; got: {}",
3995 &formatted[..formatted.len().min(300)]
3996 );
3997 assert!(
3998 formatted.contains("PAGINATED:"),
3999 "verbose=true must emit PAGINATED: header"
4000 );
4001 assert!(
4002 formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
4003 "verbose=true must emit FILES section header"
4004 );
4005 }
4006
4007 #[tokio::test]
4010 async fn test_analyze_directory_cache_hit_metrics() {
4011 use aptu_coder_core::types::{
4012 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4013 };
4014 use tempfile::TempDir;
4015
4016 let dir = TempDir::new().unwrap();
4018 std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
4019 let analyzer = make_analyzer();
4020 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4021 "path": dir.path().to_str().unwrap(),
4022 }))
4023 .unwrap();
4024
4025 let ct1 = tokio_util::sync::CancellationToken::new();
4027 let (_out1, hit1) = analyzer.handle_overview_mode(¶ms, ct1).await.unwrap();
4028
4029 let ct2 = tokio_util::sync::CancellationToken::new();
4031 let (_out2, hit2) = analyzer.handle_overview_mode(¶ms, ct2).await.unwrap();
4032
4033 assert!(!hit1, "first call must be a cache miss");
4035 assert!(hit2, "second call must be a cache hit");
4036 }
4037
4038 #[tokio::test]
4039 async fn test_analyze_module_cache_hit_metrics() {
4040 use std::io::Write as _;
4041 use tempfile::NamedTempFile;
4042
4043 let mut f = NamedTempFile::with_suffix(".rs").unwrap();
4045 writeln!(f, "fn bar() {{}}").unwrap();
4046 let path = f.path().to_str().unwrap().to_string();
4047
4048 let analyzer = make_analyzer();
4049
4050 let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
4052 file_params.path = path.clone();
4053 file_params.ast_recursion_limit = None;
4054 file_params.fields = None;
4055 file_params.pagination.cursor = None;
4056 file_params.pagination.page_size = None;
4057 file_params.output_control.summary = None;
4058 file_params.output_control.force = None;
4059 file_params.output_control.verbose = None;
4060 let (_cached, _) = analyzer
4061 .handle_file_details_mode(&file_params)
4062 .await
4063 .unwrap();
4064
4065 let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
4067 module_params.path = path.clone();
4068
4069 let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
4071 meta.modified()
4072 .ok()
4073 .map(|mtime| aptu_coder_core::cache::CacheKey {
4074 path: std::path::PathBuf::from(&path),
4075 modified: mtime,
4076 mode: aptu_coder_core::types::AnalysisMode::FileDetails,
4077 })
4078 });
4079 let cache_hit = module_cache_key
4080 .as_ref()
4081 .and_then(|k| analyzer.cache.get(k))
4082 .is_some();
4083
4084 assert!(
4086 cache_hit,
4087 "analyze_module should find the file in the shared file cache"
4088 );
4089 drop(module_params);
4090 }
4091
4092 #[test]
4095 fn test_analyze_symbol_import_lookup_invalid_params() {
4096 let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
4100
4101 assert!(
4103 result.is_err(),
4104 "import_lookup=true with empty symbol must return Err"
4105 );
4106 let err = result.unwrap_err();
4107 assert_eq!(
4108 err.code,
4109 rmcp::model::ErrorCode::INVALID_PARAMS,
4110 "expected INVALID_PARAMS; got {:?}",
4111 err.code
4112 );
4113 }
4114
4115 #[tokio::test]
4116 async fn test_analyze_symbol_import_lookup_found() {
4117 use tempfile::TempDir;
4118
4119 let dir = TempDir::new().unwrap();
4121 std::fs::write(
4122 dir.path().join("main.rs"),
4123 "use std::collections::HashMap;\nfn main() {}\n",
4124 )
4125 .unwrap();
4126
4127 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4128
4129 let output =
4131 analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
4132
4133 assert!(
4135 output.formatted.contains("MATCHES: 1"),
4136 "expected 1 match; got: {}",
4137 output.formatted
4138 );
4139 assert!(
4140 output.formatted.contains("main.rs"),
4141 "expected main.rs in output; got: {}",
4142 output.formatted
4143 );
4144 }
4145
4146 #[tokio::test]
4147 async fn test_analyze_symbol_import_lookup_empty() {
4148 use tempfile::TempDir;
4149
4150 let dir = TempDir::new().unwrap();
4152 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
4153
4154 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4155
4156 let output =
4158 analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
4159
4160 assert!(
4162 output.formatted.contains("MATCHES: 0"),
4163 "expected 0 matches; got: {}",
4164 output.formatted
4165 );
4166 }
4167
4168 #[tokio::test]
4171 async fn test_analyze_directory_git_ref_non_git_repo() {
4172 use aptu_coder_core::traversal::changed_files_from_git_ref;
4173 use tempfile::TempDir;
4174
4175 let dir = TempDir::new().unwrap();
4177 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4178
4179 let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
4181
4182 assert!(result.is_err(), "non-git dir must return an error");
4184 let err_msg = result.unwrap_err().to_string();
4185 assert!(
4186 err_msg.contains("git"),
4187 "error must mention git; got: {err_msg}"
4188 );
4189 }
4190
4191 #[tokio::test]
4192 async fn test_analyze_directory_git_ref_filters_changed_files() {
4193 use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
4194 use std::collections::HashSet;
4195 use tempfile::TempDir;
4196
4197 let dir = TempDir::new().unwrap();
4199 let changed_file = dir.path().join("changed.rs");
4200 let unchanged_file = dir.path().join("unchanged.rs");
4201 std::fs::write(&changed_file, "fn changed() {}").unwrap();
4202 std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
4203
4204 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4205 let total_files = entries.iter().filter(|e| !e.is_dir).count();
4206 assert_eq!(total_files, 2, "sanity: 2 files before filtering");
4207
4208 let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
4210 changed.insert(changed_file.clone());
4211
4212 let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
4214 let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
4215
4216 assert_eq!(
4218 filtered_files.len(),
4219 1,
4220 "only 1 file must remain after git_ref filter"
4221 );
4222 assert_eq!(
4223 filtered_files[0].path, changed_file,
4224 "the remaining file must be the changed one"
4225 );
4226
4227 let _ = changed_files_from_git_ref;
4229 }
4230
4231 #[tokio::test]
4232 async fn test_handle_overview_mode_git_ref_filters_via_handler() {
4233 use aptu_coder_core::types::{
4234 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4235 };
4236 use std::process::Command;
4237 use tempfile::TempDir;
4238
4239 let dir = TempDir::new().unwrap();
4241 let repo = dir.path();
4242
4243 let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
4246 let mut cmd = std::process::Command::new("git");
4247 cmd.args(["-c", "core.hooksPath=/dev/null"]);
4248 cmd.args(args);
4249 cmd.current_dir(repo_path);
4250 let out = cmd.output().unwrap();
4251 assert!(out.status.success(), "{out:?}");
4252 };
4253 git_no_hook(repo, &["init"]);
4254 git_no_hook(
4255 repo,
4256 &[
4257 "-c",
4258 "user.email=ci@example.com",
4259 "-c",
4260 "user.name=CI",
4261 "commit",
4262 "--allow-empty",
4263 "-m",
4264 "initial",
4265 ],
4266 );
4267
4268 std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
4270 git_no_hook(repo, &["add", "file_a.rs"]);
4271 git_no_hook(
4272 repo,
4273 &[
4274 "-c",
4275 "user.email=ci@example.com",
4276 "-c",
4277 "user.name=CI",
4278 "commit",
4279 "-m",
4280 "add a",
4281 ],
4282 );
4283
4284 std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
4286 git_no_hook(repo, &["add", "file_b.rs"]);
4287 git_no_hook(
4288 repo,
4289 &[
4290 "-c",
4291 "user.email=ci@example.com",
4292 "-c",
4293 "user.name=CI",
4294 "commit",
4295 "-m",
4296 "add b",
4297 ],
4298 );
4299
4300 let canon_repo = std::fs::canonicalize(repo).unwrap();
4306 let analyzer = make_analyzer();
4307 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4308 "path": canon_repo.to_str().unwrap(),
4309 "git_ref": "HEAD~1",
4310 }))
4311 .unwrap();
4312 let ct = tokio_util::sync::CancellationToken::new();
4313 let (arc_output, _cache_hit) = analyzer
4314 .handle_overview_mode(¶ms, ct)
4315 .await
4316 .expect("handle_overview_mode with git_ref must succeed");
4317
4318 let formatted = &arc_output.formatted;
4320 assert!(
4321 formatted.contains("file_b.rs"),
4322 "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
4323 );
4324 assert!(
4325 !formatted.contains("file_a.rs"),
4326 "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
4327 );
4328 }
4329
4330 #[test]
4331 fn test_validate_path_rejects_absolute_path_outside_cwd() {
4332 let result = validate_path("/etc/passwd", true);
4335 assert!(
4336 result.is_err(),
4337 "validate_path should reject /etc/passwd (outside CWD)"
4338 );
4339 let err = result.unwrap_err();
4340 let err_msg = err.message.to_lowercase();
4341 assert!(
4342 err_msg.contains("outside") || err_msg.contains("not found"),
4343 "Error message should mention 'outside' or 'not found': {}",
4344 err.message
4345 );
4346 }
4347
4348 #[test]
4349 fn test_validate_path_accepts_relative_path_in_cwd() {
4350 let result = validate_path("Cargo.toml", true);
4353 assert!(
4354 result.is_ok(),
4355 "validate_path should accept Cargo.toml (exists in CWD)"
4356 );
4357 }
4358
4359 #[test]
4360 fn test_validate_path_creates_parent_for_nonexistent_file() {
4361 let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4364 assert!(
4365 result.is_ok(),
4366 "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4367 );
4368 let path = result.unwrap();
4369 let cwd = std::env::current_dir().expect("should get cwd");
4370 let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4371 assert!(
4372 path.starts_with(&canonical_cwd),
4373 "Resolved path should be within CWD: {:?} should start with {:?}",
4374 path,
4375 canonical_cwd
4376 );
4377 }
4378
4379 #[test]
4380 fn test_tool_annotations() {
4381 let tools = CodeAnalyzer::list_tools();
4383
4384 let edit_rename = tools.iter().find(|t| t.name == "edit_rename");
4386 let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
4387 let exec_command = tools.iter().find(|t| t.name == "exec_command");
4388
4389 let edit_rename_tool = edit_rename.expect("edit_rename tool should exist");
4391 let edit_rename_annot = edit_rename_tool
4392 .annotations
4393 .as_ref()
4394 .expect("edit_rename should have annotations");
4395 assert_eq!(
4396 edit_rename_annot.destructive_hint,
4397 Some(false),
4398 "edit_rename destructive_hint should be false"
4399 );
4400 assert_eq!(
4401 edit_rename_annot.idempotent_hint,
4402 Some(true),
4403 "edit_rename idempotent_hint should be true"
4404 );
4405
4406 let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
4408 let analyze_dir_annot = analyze_dir_tool
4409 .annotations
4410 .as_ref()
4411 .expect("analyze_directory should have annotations");
4412 assert_eq!(
4413 analyze_dir_annot.read_only_hint,
4414 Some(true),
4415 "analyze_directory read_only_hint should be true"
4416 );
4417 assert_eq!(
4418 analyze_dir_annot.destructive_hint,
4419 Some(false),
4420 "analyze_directory destructive_hint should be false"
4421 );
4422
4423 let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
4425 let exec_cmd_annot = exec_cmd_tool
4426 .annotations
4427 .as_ref()
4428 .expect("exec_command should have annotations");
4429 assert_eq!(
4430 exec_cmd_annot.open_world_hint,
4431 Some(true),
4432 "exec_command open_world_hint should be true"
4433 );
4434 }
4435
4436 #[test]
4437 fn test_exec_stdin_size_cap_validation() {
4438 let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
4441
4442 assert!(
4444 oversized_stdin.len() > STDIN_MAX_BYTES,
4445 "test setup: oversized stdin should exceed 1 MB"
4446 );
4447
4448 let max_stdin = "y".repeat(STDIN_MAX_BYTES);
4450 assert_eq!(
4451 max_stdin.len(),
4452 STDIN_MAX_BYTES,
4453 "test setup: max stdin should be exactly 1 MB"
4454 );
4455 }
4456
4457 #[tokio::test]
4458 async fn test_exec_stdin_cat_roundtrip() {
4459 let stdin_content = "hello world";
4462
4463 let mut child = tokio::process::Command::new("sh")
4465 .arg("-c")
4466 .arg("cat")
4467 .stdin(std::process::Stdio::piped())
4468 .stdout(std::process::Stdio::piped())
4469 .stderr(std::process::Stdio::piped())
4470 .spawn()
4471 .expect("spawn cat");
4472
4473 if let Some(mut stdin_handle) = child.stdin.take() {
4474 use tokio::io::AsyncWriteExt as _;
4475 stdin_handle
4476 .write_all(stdin_content.as_bytes())
4477 .await
4478 .expect("write stdin");
4479 drop(stdin_handle);
4480 }
4481
4482 let output = child.wait_with_output().await.expect("wait for cat");
4483
4484 let stdout_str = String::from_utf8_lossy(&output.stdout);
4486 assert!(
4487 stdout_str.contains(stdin_content),
4488 "stdout should contain stdin content: {}",
4489 stdout_str
4490 );
4491 }
4492
4493 #[tokio::test]
4494 async fn test_exec_stdin_none_no_regression() {
4495 let child = tokio::process::Command::new("sh")
4498 .arg("-c")
4499 .arg("echo hi")
4500 .stdin(std::process::Stdio::null())
4501 .stdout(std::process::Stdio::piped())
4502 .stderr(std::process::Stdio::piped())
4503 .spawn()
4504 .expect("spawn echo");
4505
4506 let output = child.wait_with_output().await.expect("wait for echo");
4507
4508 let stdout_str = String::from_utf8_lossy(&output.stdout);
4510 assert!(
4511 stdout_str.contains("hi"),
4512 "stdout should contain echo output: {}",
4513 stdout_str
4514 );
4515 }
4516}