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). Use start_line/end_line (1-indexed, inclusive) to read a range; defaults to full file. 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(e)) => {
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("internal_error".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::INTERNAL_ERROR,
2061 e.to_string(),
2062 Some(error_meta(
2063 "resource",
2064 false,
2065 "check file path and permissions",
2066 )),
2067 )));
2068 }
2069 Err(e) => {
2070 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2071 self.metrics_tx.send(crate::metrics::MetricEvent {
2072 ts: crate::metrics::unix_ms(),
2073 tool: "analyze_raw",
2074 duration_ms: dur,
2075 output_chars: 0,
2076 param_path_depth: crate::metrics::path_component_count(¶m_path),
2077 max_depth: None,
2078 result: "error",
2079 error_type: Some("internal_error".to_string()),
2080 session_id: sid.clone(),
2081 seq: Some(seq),
2082 cache_hit: None,
2083 });
2084 return Ok(err_to_tool_result(ErrorData::new(
2085 rmcp::model::ErrorCode::INTERNAL_ERROR,
2086 e.to_string(),
2087 Some(error_meta(
2088 "resource",
2089 false,
2090 "check file path and permissions",
2091 )),
2092 )));
2093 }
2094 };
2095
2096 let text = format!(
2097 "File: {} (total: {} lines, showing: {}-{})\n\n{}",
2098 output.path, output.total_lines, output.start_line, output.end_line, output.content
2099 );
2100 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2101 .with_meta(Some(no_cache_meta()));
2102 let structured = match serde_json::to_value(&output).map_err(|e| {
2103 ErrorData::new(
2104 rmcp::model::ErrorCode::INTERNAL_ERROR,
2105 format!("serialization failed: {e}"),
2106 Some(error_meta("internal", false, "report this as a bug")),
2107 )
2108 }) {
2109 Ok(v) => v,
2110 Err(e) => return Ok(err_to_tool_result(e)),
2111 };
2112 result.structured_content = Some(structured);
2113 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2114 self.metrics_tx.send(crate::metrics::MetricEvent {
2115 ts: crate::metrics::unix_ms(),
2116 tool: "analyze_raw",
2117 duration_ms: dur,
2118 output_chars: text.len(),
2119 param_path_depth: crate::metrics::path_component_count(¶m_path),
2120 max_depth: None,
2121 result: "ok",
2122 error_type: None,
2123 session_id: sid,
2124 seq: Some(seq),
2125 cache_hit: None,
2126 });
2127 Ok(result)
2128 }
2129
2130 #[instrument(skip(self, _context))]
2131 #[tool(
2132 name = "edit_overwrite",
2133 title = "Edit Overwrite",
2134 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.",
2135 output_schema = schema_for_type::<EditOverwriteOutput>(),
2136 annotations(
2137 title = "Edit Overwrite",
2138 read_only_hint = false,
2139 destructive_hint = true,
2140 idempotent_hint = false,
2141 open_world_hint = false
2142 )
2143 )]
2144 async fn edit_overwrite(
2145 &self,
2146 params: Parameters<EditOverwriteParams>,
2147 _context: RequestContext<RoleServer>,
2148 ) -> Result<CallToolResult, ErrorData> {
2149 let params = params.0;
2150 let _validated_path = match validate_path(¶ms.path, false) {
2151 Ok(p) => p,
2152 Err(e) => return Ok(err_to_tool_result(e)),
2153 };
2154 let t_start = std::time::Instant::now();
2155 let param_path = params.path.clone();
2156 let seq = self
2157 .session_call_seq
2158 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2159 let sid = self.session_id.lock().await.clone();
2160
2161 if std::fs::metadata(¶ms.path)
2163 .map(|m| m.is_dir())
2164 .unwrap_or(false)
2165 {
2166 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2167 self.metrics_tx.send(crate::metrics::MetricEvent {
2168 ts: crate::metrics::unix_ms(),
2169 tool: "edit_overwrite",
2170 duration_ms: dur,
2171 output_chars: 0,
2172 param_path_depth: crate::metrics::path_component_count(¶m_path),
2173 max_depth: None,
2174 result: "error",
2175 error_type: Some("invalid_params".to_string()),
2176 session_id: sid.clone(),
2177 seq: Some(seq),
2178 cache_hit: None,
2179 });
2180 return Ok(err_to_tool_result(ErrorData::new(
2181 rmcp::model::ErrorCode::INVALID_PARAMS,
2182 "path is a directory; cannot write to a directory".to_string(),
2183 Some(error_meta(
2184 "validation",
2185 false,
2186 "provide a file path, not a directory",
2187 )),
2188 )));
2189 }
2190
2191 let path = std::path::PathBuf::from(¶ms.path);
2192 let content = params.content.clone();
2193 let handle = tokio::task::spawn_blocking(move || {
2194 aptu_coder_core::edit_overwrite_content(&path, &content)
2195 });
2196
2197 let output = match handle.await {
2198 Ok(Ok(v)) => v,
2199 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2200 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2201 self.metrics_tx.send(crate::metrics::MetricEvent {
2202 ts: crate::metrics::unix_ms(),
2203 tool: "edit_overwrite",
2204 duration_ms: dur,
2205 output_chars: 0,
2206 param_path_depth: crate::metrics::path_component_count(¶m_path),
2207 max_depth: None,
2208 result: "error",
2209 error_type: Some("invalid_params".to_string()),
2210 session_id: sid.clone(),
2211 seq: Some(seq),
2212 cache_hit: None,
2213 });
2214 return Ok(err_to_tool_result(ErrorData::new(
2215 rmcp::model::ErrorCode::INVALID_PARAMS,
2216 "path is a directory".to_string(),
2217 Some(error_meta(
2218 "validation",
2219 false,
2220 "provide a file path, not a directory",
2221 )),
2222 )));
2223 }
2224 Ok(Err(e)) => {
2225 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2226 self.metrics_tx.send(crate::metrics::MetricEvent {
2227 ts: crate::metrics::unix_ms(),
2228 tool: "edit_overwrite",
2229 duration_ms: dur,
2230 output_chars: 0,
2231 param_path_depth: crate::metrics::path_component_count(¶m_path),
2232 max_depth: None,
2233 result: "error",
2234 error_type: Some("internal_error".to_string()),
2235 session_id: sid.clone(),
2236 seq: Some(seq),
2237 cache_hit: None,
2238 });
2239 return Ok(err_to_tool_result(ErrorData::new(
2240 rmcp::model::ErrorCode::INTERNAL_ERROR,
2241 e.to_string(),
2242 Some(error_meta(
2243 "resource",
2244 false,
2245 "check file path and permissions",
2246 )),
2247 )));
2248 }
2249 Err(e) => {
2250 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2251 self.metrics_tx.send(crate::metrics::MetricEvent {
2252 ts: crate::metrics::unix_ms(),
2253 tool: "edit_overwrite",
2254 duration_ms: dur,
2255 output_chars: 0,
2256 param_path_depth: crate::metrics::path_component_count(¶m_path),
2257 max_depth: None,
2258 result: "error",
2259 error_type: Some("internal_error".to_string()),
2260 session_id: sid.clone(),
2261 seq: Some(seq),
2262 cache_hit: None,
2263 });
2264 return Ok(err_to_tool_result(ErrorData::new(
2265 rmcp::model::ErrorCode::INTERNAL_ERROR,
2266 e.to_string(),
2267 Some(error_meta(
2268 "resource",
2269 false,
2270 "check file path and permissions",
2271 )),
2272 )));
2273 }
2274 };
2275
2276 let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2277 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2278 .with_meta(Some(no_cache_meta()));
2279 let structured = match serde_json::to_value(&output).map_err(|e| {
2280 ErrorData::new(
2281 rmcp::model::ErrorCode::INTERNAL_ERROR,
2282 format!("serialization failed: {e}"),
2283 Some(error_meta("internal", false, "report this as a bug")),
2284 )
2285 }) {
2286 Ok(v) => v,
2287 Err(e) => return Ok(err_to_tool_result(e)),
2288 };
2289 result.structured_content = Some(structured);
2290 self.cache
2291 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2292 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2293 self.metrics_tx.send(crate::metrics::MetricEvent {
2294 ts: crate::metrics::unix_ms(),
2295 tool: "edit_overwrite",
2296 duration_ms: dur,
2297 output_chars: text.len(),
2298 param_path_depth: crate::metrics::path_component_count(¶m_path),
2299 max_depth: None,
2300 result: "ok",
2301 error_type: None,
2302 session_id: sid,
2303 seq: Some(seq),
2304 cache_hit: None,
2305 });
2306 Ok(result)
2307 }
2308
2309 #[instrument(skip(self, _context))]
2310 #[tool(
2311 name = "edit_replace",
2312 title = "Edit Replace",
2313 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.",
2314 output_schema = schema_for_type::<EditReplaceOutput>(),
2315 annotations(
2316 title = "Edit Replace",
2317 read_only_hint = false,
2318 destructive_hint = true,
2319 idempotent_hint = false,
2320 open_world_hint = false
2321 )
2322 )]
2323 async fn edit_replace(
2324 &self,
2325 params: Parameters<EditReplaceParams>,
2326 _context: RequestContext<RoleServer>,
2327 ) -> Result<CallToolResult, ErrorData> {
2328 let params = params.0;
2329 let _validated_path = match validate_path(¶ms.path, true) {
2330 Ok(p) => p,
2331 Err(e) => return Ok(err_to_tool_result(e)),
2332 };
2333 let t_start = std::time::Instant::now();
2334 let param_path = params.path.clone();
2335 let seq = self
2336 .session_call_seq
2337 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2338 let sid = self.session_id.lock().await.clone();
2339
2340 if std::fs::metadata(¶ms.path)
2342 .map(|m| m.is_dir())
2343 .unwrap_or(false)
2344 {
2345 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2346 self.metrics_tx.send(crate::metrics::MetricEvent {
2347 ts: crate::metrics::unix_ms(),
2348 tool: "edit_replace",
2349 duration_ms: dur,
2350 output_chars: 0,
2351 param_path_depth: crate::metrics::path_component_count(¶m_path),
2352 max_depth: None,
2353 result: "error",
2354 error_type: Some("invalid_params".to_string()),
2355 session_id: sid.clone(),
2356 seq: Some(seq),
2357 cache_hit: None,
2358 });
2359 return Ok(err_to_tool_result(ErrorData::new(
2360 rmcp::model::ErrorCode::INVALID_PARAMS,
2361 "path is a directory; cannot edit a directory".to_string(),
2362 Some(error_meta(
2363 "validation",
2364 false,
2365 "provide a file path, not a directory",
2366 )),
2367 )));
2368 }
2369
2370 let path = std::path::PathBuf::from(¶ms.path);
2371 let old_text = params.old_text.clone();
2372 let new_text = params.new_text.clone();
2373 let handle = tokio::task::spawn_blocking(move || {
2374 aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2375 });
2376
2377 let output = match handle.await {
2378 Ok(Ok(v)) => v,
2379 Ok(Err(aptu_coder_core::EditError::NotFound { path: _ })) => {
2380 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2381 self.metrics_tx.send(crate::metrics::MetricEvent {
2382 ts: crate::metrics::unix_ms(),
2383 tool: "edit_replace",
2384 duration_ms: dur,
2385 output_chars: 0,
2386 param_path_depth: crate::metrics::path_component_count(¶m_path),
2387 max_depth: None,
2388 result: "error",
2389 error_type: Some("invalid_params".to_string()),
2390 session_id: sid.clone(),
2391 seq: Some(seq),
2392 cache_hit: None,
2393 });
2394 return Ok(err_to_tool_result(ErrorData::new(
2395 rmcp::model::ErrorCode::INVALID_PARAMS,
2396 "old_text not found in file — verify the text matches exactly, including whitespace and newlines".to_string(),
2397 Some(error_meta(
2398 "validation",
2399 false,
2400 "check that old_text appears in the file",
2401 )),
2402 )));
2403 }
2404 Ok(Err(aptu_coder_core::EditError::Ambiguous { count, path: _ })) => {
2405 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2406 self.metrics_tx.send(crate::metrics::MetricEvent {
2407 ts: crate::metrics::unix_ms(),
2408 tool: "edit_replace",
2409 duration_ms: dur,
2410 output_chars: 0,
2411 param_path_depth: crate::metrics::path_component_count(¶m_path),
2412 max_depth: None,
2413 result: "error",
2414 error_type: Some("invalid_params".to_string()),
2415 session_id: sid.clone(),
2416 seq: Some(seq),
2417 cache_hit: None,
2418 });
2419 return Ok(err_to_tool_result(ErrorData::new(
2420 rmcp::model::ErrorCode::INVALID_PARAMS,
2421 format!(
2422 "old_text appears {count} times in file — make old_text longer and more specific to uniquely identify the block"
2423 ),
2424 Some(error_meta(
2425 "validation",
2426 false,
2427 "include more context in old_text to make it unique",
2428 )),
2429 )));
2430 }
2431 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
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 "path is a directory".to_string(),
2449 Some(error_meta(
2450 "validation",
2451 false,
2452 "provide a file path, not a directory",
2453 )),
2454 )));
2455 }
2456 Ok(Err(e)) => {
2457 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2458 self.metrics_tx.send(crate::metrics::MetricEvent {
2459 ts: crate::metrics::unix_ms(),
2460 tool: "edit_replace",
2461 duration_ms: dur,
2462 output_chars: 0,
2463 param_path_depth: crate::metrics::path_component_count(¶m_path),
2464 max_depth: None,
2465 result: "error",
2466 error_type: Some("internal_error".to_string()),
2467 session_id: sid.clone(),
2468 seq: Some(seq),
2469 cache_hit: None,
2470 });
2471 return Ok(err_to_tool_result(ErrorData::new(
2472 rmcp::model::ErrorCode::INTERNAL_ERROR,
2473 e.to_string(),
2474 Some(error_meta(
2475 "resource",
2476 false,
2477 "check file path and permissions",
2478 )),
2479 )));
2480 }
2481 Err(e) => {
2482 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2483 self.metrics_tx.send(crate::metrics::MetricEvent {
2484 ts: crate::metrics::unix_ms(),
2485 tool: "edit_replace",
2486 duration_ms: dur,
2487 output_chars: 0,
2488 param_path_depth: crate::metrics::path_component_count(¶m_path),
2489 max_depth: None,
2490 result: "error",
2491 error_type: Some("internal_error".to_string()),
2492 session_id: sid.clone(),
2493 seq: Some(seq),
2494 cache_hit: None,
2495 });
2496 return Ok(err_to_tool_result(ErrorData::new(
2497 rmcp::model::ErrorCode::INTERNAL_ERROR,
2498 e.to_string(),
2499 Some(error_meta(
2500 "resource",
2501 false,
2502 "check file path and permissions",
2503 )),
2504 )));
2505 }
2506 };
2507
2508 let text = format!(
2509 "Edited {}: {} bytes -> {} bytes",
2510 output.path, output.bytes_before, output.bytes_after
2511 );
2512 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2513 .with_meta(Some(no_cache_meta()));
2514 let structured = match serde_json::to_value(&output).map_err(|e| {
2515 ErrorData::new(
2516 rmcp::model::ErrorCode::INTERNAL_ERROR,
2517 format!("serialization failed: {e}"),
2518 Some(error_meta("internal", false, "report this as a bug")),
2519 )
2520 }) {
2521 Ok(v) => v,
2522 Err(e) => return Ok(err_to_tool_result(e)),
2523 };
2524 result.structured_content = Some(structured);
2525 self.cache
2526 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2527 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2528 self.metrics_tx.send(crate::metrics::MetricEvent {
2529 ts: crate::metrics::unix_ms(),
2530 tool: "edit_replace",
2531 duration_ms: dur,
2532 output_chars: text.len(),
2533 param_path_depth: crate::metrics::path_component_count(¶m_path),
2534 max_depth: None,
2535 result: "ok",
2536 error_type: None,
2537 session_id: sid,
2538 seq: Some(seq),
2539 cache_hit: None,
2540 });
2541 Ok(result)
2542 }
2543
2544 #[instrument(skip(self, _context))]
2545 #[tool(
2546 name = "edit_rename",
2547 title = "Edit Rename",
2548 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/.",
2549 output_schema = schema_for_type::<EditRenameOutput>(),
2550 annotations(
2551 title = "Edit Rename",
2552 read_only_hint = false,
2553 destructive_hint = false,
2554 idempotent_hint = true,
2555 open_world_hint = false
2556 )
2557 )]
2558 async fn edit_rename(
2559 &self,
2560 params: Parameters<EditRenameParams>,
2561 _context: RequestContext<RoleServer>,
2562 ) -> Result<CallToolResult, ErrorData> {
2563 let params = params.0;
2564 let _validated_path = match validate_path(¶ms.path, true) {
2565 Ok(p) => p,
2566 Err(e) => return Ok(err_to_tool_result(e)),
2567 };
2568 let t_start = std::time::Instant::now();
2569 let param_path = params.path.clone();
2570 let seq = self
2571 .session_call_seq
2572 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2573 let sid = self.session_id.lock().await.clone();
2574
2575 let is_dir = std::fs::metadata(¶ms.path)
2576 .map(|m| m.is_dir())
2577 .unwrap_or(false);
2578
2579 let path = std::path::PathBuf::from(¶ms.path);
2580 let old_name = params.old_name.clone();
2581 let new_name = params.new_name.clone();
2582 let kind = params.kind.clone();
2583
2584 let handle = if is_dir {
2585 tokio::task::spawn_blocking(move || {
2586 edit_rename_directory(&path, &old_name, &new_name, kind.as_deref()).map(
2587 |(results, errors)| {
2588 let total_occurrences: usize =
2589 results.iter().map(|r| r.occurrences_renamed).sum();
2590 aptu_coder_core::types::EditRenameOutput {
2591 path: path.display().to_string(),
2592 old_name,
2593 new_name,
2594 occurrences_renamed: total_occurrences,
2595 files_changed: Some(results),
2596 errors: Some(errors),
2597 }
2598 },
2599 )
2600 })
2601 } else {
2602 tokio::task::spawn_blocking(move || {
2603 edit_rename_in_file(&path, &old_name, &new_name, kind.as_deref())
2604 })
2605 };
2606
2607 let output = match handle.await {
2608 Ok(Ok(v)) => v,
2609 Ok(Err(aptu_coder_core::EditError::SymbolNotFound { .. })) => {
2610 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2611 self.metrics_tx.send(crate::metrics::MetricEvent {
2612 ts: crate::metrics::unix_ms(),
2613 tool: "edit_rename",
2614 duration_ms: dur,
2615 output_chars: 0,
2616 param_path_depth: crate::metrics::path_component_count(¶m_path),
2617 max_depth: None,
2618 result: "error",
2619 error_type: Some("invalid_params".to_string()),
2620 session_id: sid.clone(),
2621 seq: Some(seq),
2622 cache_hit: None,
2623 });
2624 return Ok(err_to_tool_result(ErrorData::new(
2625 rmcp::model::ErrorCode::INVALID_PARAMS,
2626 "symbol not found in file".to_string(),
2627 Some(error_meta(
2628 "validation",
2629 false,
2630 "verify the symbol name and file path",
2631 )),
2632 )));
2633 }
2634 Ok(Err(aptu_coder_core::EditError::AmbiguousKind { .. })) => {
2635 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2636 self.metrics_tx.send(crate::metrics::MetricEvent {
2637 ts: crate::metrics::unix_ms(),
2638 tool: "edit_rename",
2639 duration_ms: dur,
2640 output_chars: 0,
2641 param_path_depth: crate::metrics::path_component_count(¶m_path),
2642 max_depth: None,
2643 result: "error",
2644 error_type: Some("invalid_params".to_string()),
2645 session_id: sid.clone(),
2646 seq: Some(seq),
2647 cache_hit: None,
2648 });
2649 return Ok(err_to_tool_result(ErrorData::new(
2650 rmcp::model::ErrorCode::INVALID_PARAMS,
2651 "symbol name is ambiguous".to_string(),
2652 Some(error_meta(
2653 "validation",
2654 false,
2655 "verify the symbol name is unique",
2656 )),
2657 )));
2658 }
2659 Ok(Err(aptu_coder_core::EditError::UnsupportedLanguage(_))) => {
2660 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2661 self.metrics_tx.send(crate::metrics::MetricEvent {
2662 ts: crate::metrics::unix_ms(),
2663 tool: "edit_rename",
2664 duration_ms: dur,
2665 output_chars: 0,
2666 param_path_depth: crate::metrics::path_component_count(¶m_path),
2667 max_depth: None,
2668 result: "error",
2669 error_type: Some("invalid_params".to_string()),
2670 session_id: sid.clone(),
2671 seq: Some(seq),
2672 cache_hit: None,
2673 });
2674 return Ok(err_to_tool_result(ErrorData::new(
2675 rmcp::model::ErrorCode::INVALID_PARAMS,
2676 "file language is not supported".to_string(),
2677 Some(error_meta(
2678 "validation",
2679 false,
2680 "check that the file has a supported language extension",
2681 )),
2682 )));
2683 }
2684 Ok(Err(aptu_coder_core::EditError::KindFilterUnsupported)) => {
2685 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2686 self.metrics_tx.send(crate::metrics::MetricEvent {
2687 ts: crate::metrics::unix_ms(),
2688 tool: "edit_rename",
2689 duration_ms: dur,
2690 output_chars: 0,
2691 param_path_depth: crate::metrics::path_component_count(¶m_path),
2692 max_depth: None,
2693 result: "error",
2694 error_type: Some("invalid_params".to_string()),
2695 session_id: sid.clone(),
2696 seq: Some(seq),
2697 cache_hit: None,
2698 });
2699 return Ok(err_to_tool_result(ErrorData::new(
2700 rmcp::model::ErrorCode::INVALID_PARAMS,
2701 "kind filtering is not supported with the current identifier query infrastructure"
2702 .to_string(),
2703 Some(error_meta(
2704 "validation",
2705 false,
2706 "omit the kind parameter",
2707 )),
2708 )));
2709 }
2710 Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2711 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2712 self.metrics_tx.send(crate::metrics::MetricEvent {
2713 ts: crate::metrics::unix_ms(),
2714 tool: "edit_rename",
2715 duration_ms: dur,
2716 output_chars: 0,
2717 param_path_depth: crate::metrics::path_component_count(¶m_path),
2718 max_depth: None,
2719 result: "error",
2720 error_type: Some("invalid_params".to_string()),
2721 session_id: sid.clone(),
2722 seq: Some(seq),
2723 cache_hit: None,
2724 });
2725 return Ok(err_to_tool_result(ErrorData::new(
2726 rmcp::model::ErrorCode::INVALID_PARAMS,
2727 "path is a directory".to_string(),
2728 Some(error_meta(
2729 "validation",
2730 false,
2731 "provide a file path, not a directory",
2732 )),
2733 )));
2734 }
2735 Ok(Err(e)) => {
2736 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2737 self.metrics_tx.send(crate::metrics::MetricEvent {
2738 ts: crate::metrics::unix_ms(),
2739 tool: "edit_rename",
2740 duration_ms: dur,
2741 output_chars: 0,
2742 param_path_depth: crate::metrics::path_component_count(¶m_path),
2743 max_depth: None,
2744 result: "error",
2745 error_type: Some("internal_error".to_string()),
2746 session_id: sid.clone(),
2747 seq: Some(seq),
2748 cache_hit: None,
2749 });
2750 return Ok(err_to_tool_result(ErrorData::new(
2751 rmcp::model::ErrorCode::INTERNAL_ERROR,
2752 e.to_string(),
2753 Some(error_meta(
2754 "resource",
2755 false,
2756 "check file path and permissions",
2757 )),
2758 )));
2759 }
2760 Err(e) => {
2761 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2762 self.metrics_tx.send(crate::metrics::MetricEvent {
2763 ts: crate::metrics::unix_ms(),
2764 tool: "edit_rename",
2765 duration_ms: dur,
2766 output_chars: 0,
2767 param_path_depth: crate::metrics::path_component_count(¶m_path),
2768 max_depth: None,
2769 result: "error",
2770 error_type: Some("internal_error".to_string()),
2771 session_id: sid.clone(),
2772 seq: Some(seq),
2773 cache_hit: None,
2774 });
2775 return Ok(err_to_tool_result(ErrorData::new(
2776 rmcp::model::ErrorCode::INTERNAL_ERROR,
2777 e.to_string(),
2778 Some(error_meta(
2779 "resource",
2780 false,
2781 "check file path and permissions",
2782 )),
2783 )));
2784 }
2785 };
2786
2787 let text = format!(
2788 "Renamed '{}' to '{}' in {} ({} occurrence(s))",
2789 output.old_name, output.new_name, output.path, output.occurrences_renamed
2790 );
2791 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2792 .with_meta(Some(no_cache_meta()));
2793 let structured = match serde_json::to_value(&output).map_err(|e| {
2794 ErrorData::new(
2795 rmcp::model::ErrorCode::INTERNAL_ERROR,
2796 format!("serialization failed: {e}"),
2797 Some(error_meta("internal", false, "report this as a bug")),
2798 )
2799 }) {
2800 Ok(v) => v,
2801 Err(e) => return Ok(err_to_tool_result(e)),
2802 };
2803 result.structured_content = Some(structured);
2804 self.cache
2805 .invalidate_file(&std::path::PathBuf::from(¶m_path));
2806 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2807 self.metrics_tx.send(crate::metrics::MetricEvent {
2808 ts: crate::metrics::unix_ms(),
2809 tool: "edit_rename",
2810 duration_ms: dur,
2811 output_chars: text.len(),
2812 param_path_depth: crate::metrics::path_component_count(¶m_path),
2813 max_depth: None,
2814 result: "ok",
2815 error_type: None,
2816 session_id: sid,
2817 seq: Some(seq),
2818 cache_hit: None,
2819 });
2820 Ok(result)
2821 }
2822
2823 #[instrument(skip(self, _context))]
2824 #[tool(
2825 name = "edit_insert",
2826 title = "Edit Insert",
2827 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.",
2828 output_schema = schema_for_type::<EditInsertOutput>(),
2829 annotations(
2830 title = "Edit Insert",
2831 read_only_hint = false,
2832 destructive_hint = true,
2833 idempotent_hint = false,
2834 open_world_hint = false
2835 )
2836 )]
2837 async fn edit_insert(
2838 &self,
2839 params: Parameters<EditInsertParams>,
2840 _context: RequestContext<RoleServer>,
2841 ) -> Result<CallToolResult, ErrorData> {
2842 let params = params.0;
2843 let _validated_path = match validate_path(¶ms.path, true) {
2844 Ok(p) => p,
2845 Err(e) => return Ok(err_to_tool_result(e)),
2846 };
2847 let t_start = std::time::Instant::now();
2848 let param_path = params.path.clone();
2849 let seq = self
2850 .session_call_seq
2851 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2852 let sid = self.session_id.lock().await.clone();
2853
2854 if std::fs::metadata(¶ms.path)
2856 .map(|m| m.is_dir())
2857 .unwrap_or(false)
2858 {
2859 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2860 self.metrics_tx.send(crate::metrics::MetricEvent {
2861 ts: crate::metrics::unix_ms(),
2862 tool: "edit_insert",
2863 duration_ms: dur,
2864 output_chars: 0,
2865 param_path_depth: crate::metrics::path_component_count(¶m_path),
2866 max_depth: None,
2867 result: "error",
2868 error_type: Some("invalid_params".to_string()),
2869 session_id: sid.clone(),
2870 seq: Some(seq),
2871 cache_hit: None,
2872 });
2873 return Ok(err_to_tool_result(ErrorData::new(
2874 rmcp::model::ErrorCode::INVALID_PARAMS,
2875 "edit_insert operates on a single file — provide a file path, not a directory"
2876 .to_string(),
2877 Some(error_meta(
2878 "validation",
2879 false,
2880 "provide a file path, not a directory",
2881 )),
2882 )));
2883 }
2884
2885 let path = std::path::PathBuf::from(¶ms.path);
2886 let symbol_name = params.symbol_name.clone();
2887 let position = params.position;
2888 let content = params.content.clone();
2889 let handle = tokio::task::spawn_blocking(move || {
2890 edit_insert_at_symbol(&path, &symbol_name, position, &content)
2891 });
2892
2893 let output = match handle.await {
2894 Ok(Ok(v)) => v,
2895 Ok(Err(aptu_coder_core::EditError::SymbolNotFound { .. })) => {
2896 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2897 self.metrics_tx.send(crate::metrics::MetricEvent {
2898 ts: crate::metrics::unix_ms(),
2899 tool: "edit_insert",
2900 duration_ms: dur,
2901 output_chars: 0,
2902 param_path_depth: crate::metrics::path_component_count(¶m_path),
2903 max_depth: None,
2904 result: "error",
2905 error_type: Some("invalid_params".to_string()),
2906 session_id: sid.clone(),
2907 seq: Some(seq),
2908 cache_hit: None,
2909 });
2910 return Ok(err_to_tool_result(ErrorData::new(
2911 rmcp::model::ErrorCode::INVALID_PARAMS,
2912 "symbol not found in file".to_string(),
2913 Some(error_meta(
2914 "validation",
2915 false,
2916 "verify the symbol name and file path",
2917 )),
2918 )));
2919 }
2920 Ok(Err(aptu_coder_core::EditError::UnsupportedLanguage(_))) => {
2921 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2922 self.metrics_tx.send(crate::metrics::MetricEvent {
2923 ts: crate::metrics::unix_ms(),
2924 tool: "edit_insert",
2925 duration_ms: dur,
2926 output_chars: 0,
2927 param_path_depth: crate::metrics::path_component_count(¶m_path),
2928 max_depth: None,
2929 result: "error",
2930 error_type: Some("invalid_params".to_string()),
2931 session_id: sid.clone(),
2932 seq: Some(seq),
2933 cache_hit: None,
2934 });
2935 return Ok(err_to_tool_result(ErrorData::new(
2936 rmcp::model::ErrorCode::INVALID_PARAMS,
2937 "file language is not supported".to_string(),
2938 Some(error_meta(
2939 "validation",
2940 false,
2941 "check that the file has a supported language extension",
2942 )),
2943 )));
2944 }
2945 Ok(Err(e)) => {
2946 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2947 self.metrics_tx.send(crate::metrics::MetricEvent {
2948 ts: crate::metrics::unix_ms(),
2949 tool: "edit_insert",
2950 duration_ms: dur,
2951 output_chars: 0,
2952 param_path_depth: crate::metrics::path_component_count(¶m_path),
2953 max_depth: None,
2954 result: "error",
2955 error_type: Some("internal_error".to_string()),
2956 session_id: sid.clone(),
2957 seq: Some(seq),
2958 cache_hit: None,
2959 });
2960 return Ok(err_to_tool_result(ErrorData::new(
2961 rmcp::model::ErrorCode::INTERNAL_ERROR,
2962 e.to_string(),
2963 Some(error_meta(
2964 "resource",
2965 false,
2966 "check file path and permissions",
2967 )),
2968 )));
2969 }
2970 Err(e) => {
2971 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2972 self.metrics_tx.send(crate::metrics::MetricEvent {
2973 ts: crate::metrics::unix_ms(),
2974 tool: "edit_insert",
2975 duration_ms: dur,
2976 output_chars: 0,
2977 param_path_depth: crate::metrics::path_component_count(¶m_path),
2978 max_depth: None,
2979 result: "error",
2980 error_type: Some("internal_error".to_string()),
2981 session_id: sid.clone(),
2982 seq: Some(seq),
2983 cache_hit: None,
2984 });
2985 return Ok(err_to_tool_result(ErrorData::new(
2986 rmcp::model::ErrorCode::INTERNAL_ERROR,
2987 e.to_string(),
2988 Some(error_meta(
2989 "resource",
2990 false,
2991 "check file path and permissions",
2992 )),
2993 )));
2994 }
2995 };
2996
2997 let text = format!(
2998 "Inserted content {} '{}' in {} (at byte offset {})",
2999 output.position, output.symbol_name, output.path, output.byte_offset
3000 );
3001 let mut result = CallToolResult::success(vec![Content::text(text.clone())])
3002 .with_meta(Some(no_cache_meta()));
3003 let structured = match serde_json::to_value(&output).map_err(|e| {
3004 ErrorData::new(
3005 rmcp::model::ErrorCode::INTERNAL_ERROR,
3006 format!("serialization failed: {e}"),
3007 Some(error_meta("internal", false, "report this as a bug")),
3008 )
3009 }) {
3010 Ok(v) => v,
3011 Err(e) => return Ok(err_to_tool_result(e)),
3012 };
3013 result.structured_content = Some(structured);
3014 self.cache
3015 .invalidate_file(&std::path::PathBuf::from(¶m_path));
3016 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3017 self.metrics_tx.send(crate::metrics::MetricEvent {
3018 ts: crate::metrics::unix_ms(),
3019 tool: "edit_insert",
3020 duration_ms: dur,
3021 output_chars: text.len(),
3022 param_path_depth: crate::metrics::path_component_count(¶m_path),
3023 max_depth: None,
3024 result: "ok",
3025 error_type: None,
3026 session_id: sid,
3027 seq: Some(seq),
3028 cache_hit: None,
3029 });
3030 Ok(result)
3031 }
3032
3033 #[tool(
3034 name = "exec_command",
3035 title = "Exec Command",
3036 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.",
3037 output_schema = schema_for_type::<types::ShellOutput>(),
3038 annotations(
3039 title = "Exec Command",
3040 read_only_hint = false,
3041 destructive_hint = true,
3042 idempotent_hint = false,
3043 open_world_hint = true
3044 )
3045 )]
3046 #[instrument(skip(self, context))]
3047 pub async fn exec_command(
3048 &self,
3049 params: Parameters<types::ExecCommandParams>,
3050 context: RequestContext<RoleServer>,
3051 ) -> Result<CallToolResult, ErrorData> {
3052 let t_start = std::time::Instant::now();
3053 let params = params.0;
3054
3055 let working_dir_path = if let Some(ref wd) = params.working_dir {
3057 match validate_path(wd, true) {
3058 Ok(p) => {
3059 if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
3061 return Ok(err_to_tool_result(ErrorData::new(
3062 rmcp::model::ErrorCode::INVALID_PARAMS,
3063 "working_dir must be a directory".to_string(),
3064 Some(error_meta(
3065 "validation",
3066 false,
3067 "provide a valid directory path",
3068 )),
3069 )));
3070 }
3071 Some(p)
3072 }
3073 Err(e) => {
3074 return Ok(err_to_tool_result(e));
3075 }
3076 }
3077 } else {
3078 None
3079 };
3080
3081 let param_path = params.working_dir.clone();
3082 let seq = self
3083 .session_call_seq
3084 .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3085 let sid = self.session_id.lock().await.clone();
3086
3087 if let Some(ref stdin_content) = params.stdin
3089 && stdin_content.len() > STDIN_MAX_BYTES
3090 {
3091 return Ok(err_to_tool_result(ErrorData::new(
3092 rmcp::model::ErrorCode::INVALID_PARAMS,
3093 "stdin exceeds 1 MB limit".to_string(),
3094 Some(error_meta("validation", false, "reduce stdin content size")),
3095 )));
3096 }
3097
3098 let command = params.command.clone();
3099 let timeout_secs = params.timeout_secs;
3100
3101 let peer = self.peer.lock().await.clone();
3103 let progress_token = context.meta.get_progress_token();
3104
3105 let progress_handle: Option<tokio::task::JoinHandle<()>> =
3107 if timeout_secs.is_none_or(|t| t > 10) {
3108 if let (Some(token), Some(peer_conn)) = (progress_token.clone(), peer.clone()) {
3109 let self_clone = self.clone();
3110 Some(tokio::spawn(async move {
3111 let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
3112 interval.tick().await; let mut tick = 0u64;
3114 loop {
3115 interval.tick().await;
3116 tick += 1;
3117 let progress = match timeout_secs {
3118 Some(secs) => ((tick * 5) as f64 / secs as f64).min(0.99),
3119 None => 0.0,
3120 };
3121 self_clone
3122 .emit_progress(
3123 Some(peer_conn.clone()),
3124 &token,
3125 progress,
3126 1.0,
3127 "command running".to_string(),
3128 )
3129 .await;
3130 }
3131 }))
3132 } else {
3133 None
3134 }
3135 } else {
3136 None
3137 };
3138
3139 let shell = resolve_shell();
3141
3142 let mut cmd = tokio::process::Command::new(shell);
3143 cmd.arg("-c").arg(&command);
3144
3145 if let Some(ref wd) = working_dir_path {
3146 cmd.current_dir(wd);
3147 }
3148
3149 cmd.stdout(std::process::Stdio::piped())
3150 .stderr(std::process::Stdio::piped());
3151
3152 if params.stdin.is_some() {
3154 cmd.stdin(std::process::Stdio::piped());
3155 } else {
3156 cmd.stdin(std::process::Stdio::null());
3157 }
3158
3159 #[cfg(unix)]
3160 {
3161 let memory_limit_mb = params.memory_limit_mb;
3162 let cpu_limit_secs = params.cpu_limit_secs;
3163 #[cfg(not(target_os = "linux"))]
3164 if memory_limit_mb.is_some() {
3165 warn!("memory_limit_mb is not enforced on this platform (Linux only)");
3166 }
3167 if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
3168 unsafe {
3172 cmd.pre_exec(move || {
3173 #[cfg(target_os = "linux")]
3174 if let Some(mb) = memory_limit_mb {
3175 let bytes = mb.saturating_mul(1024 * 1024);
3176 setrlimit(Resource::RLIMIT_AS, bytes, bytes)
3177 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3178 }
3179 if let Some(cpu) = cpu_limit_secs {
3180 setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
3181 .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3182 }
3183 Ok(())
3184 });
3185 }
3186 }
3187 }
3188
3189 let mut child = match cmd.spawn() {
3190 Ok(c) => c,
3191 Err(e) => {
3192 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3193 self.metrics_tx.send(crate::metrics::MetricEvent {
3194 ts: crate::metrics::unix_ms(),
3195 tool: "exec_command",
3196 duration_ms: dur,
3197 output_chars: 0,
3198 param_path_depth: crate::metrics::path_component_count(
3199 param_path.as_deref().unwrap_or(""),
3200 ),
3201 max_depth: None,
3202 result: "error",
3203 error_type: Some("internal_error".to_string()),
3204 session_id: sid.clone(),
3205 seq: Some(seq),
3206 cache_hit: None,
3207 });
3208 return Ok(err_to_tool_result(ErrorData::new(
3209 rmcp::model::ErrorCode::INTERNAL_ERROR,
3210 format!("failed to spawn command: {e}"),
3211 Some(error_meta(
3212 "resource",
3213 false,
3214 "check command syntax and permissions",
3215 )),
3216 )));
3217 }
3218 };
3219
3220 const MAX_BYTES: usize = 50 * 1024;
3222
3223 let stdout_pipe = child.stdout.take();
3224 let stderr_pipe = child.stderr.take();
3225
3226 if let Some(stdin_content) = params.stdin
3228 && let Some(mut stdin_handle) = child.stdin.take()
3229 {
3230 use tokio::io::AsyncWriteExt as _;
3231 match stdin_handle.write_all(stdin_content.as_bytes()).await {
3232 Ok(()) => {
3233 drop(stdin_handle); }
3235 Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
3236 }
3238 Err(e) => {
3239 warn!("failed to write stdin: {e}");
3240 }
3241 }
3242 }
3243
3244 use std::sync::Arc;
3245 use tokio::io::AsyncBufReadExt as _;
3246 use tokio::sync::Mutex as TokioMutex;
3247 use tokio_stream::StreamExt as TokioStreamExt;
3248 use tokio_stream::wrappers::LinesStream;
3249
3250 let stdout_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
3253 let stderr_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
3254 let interleaved_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
3255
3256 let so_acc = Arc::clone(&stdout_shared);
3257 let se_acc = Arc::clone(&stderr_shared);
3258 let il_acc = Arc::clone(&interleaved_shared);
3259
3260 let mut drain_task = tokio::spawn(async move {
3263 let mut so_bytes = 0usize;
3264 let mut se_bytes = 0usize;
3265 let mut il_bytes = 0usize;
3266
3267 let so_stream = stdout_pipe.map(|p| {
3268 LinesStream::new(tokio::io::BufReader::new(p).lines())
3269 .map(|l| l.map(|s| (false, s)))
3270 });
3271 let se_stream = stderr_pipe.map(|p| {
3272 LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3273 });
3274
3275 match (so_stream, se_stream) {
3276 (Some(so), Some(se)) => {
3277 let mut merged = so.merge(se);
3278 while let Some(item) = merged.next().await {
3279 if let Ok((is_stderr, line)) = item {
3280 let entry = format!("{line}\n");
3281 if is_stderr {
3282 if se_bytes < MAX_BYTES {
3283 se_bytes += entry.len();
3284 se_acc.lock().await.push_str(&entry);
3285 if il_bytes < 2 * MAX_BYTES {
3286 il_bytes += entry.len();
3287 il_acc.lock().await.push_str(&entry);
3288 }
3289 }
3290 } else if so_bytes < MAX_BYTES {
3291 so_bytes += entry.len();
3292 so_acc.lock().await.push_str(&entry);
3293 if il_bytes < 2 * MAX_BYTES {
3294 il_bytes += entry.len();
3295 il_acc.lock().await.push_str(&entry);
3296 }
3297 }
3298 }
3299 }
3300 }
3301 (Some(so), None) => {
3302 let mut stream = so;
3303 while let Some(item) = stream.next().await {
3304 if let Ok((_, line)) = item
3305 && so_bytes < MAX_BYTES
3306 {
3307 let entry = format!("{line}\n");
3308 so_bytes += entry.len();
3309 so_acc.lock().await.push_str(&entry);
3310 if il_bytes < 2 * MAX_BYTES {
3311 il_bytes += entry.len();
3312 il_acc.lock().await.push_str(&entry);
3313 }
3314 }
3315 }
3316 }
3317 (None, Some(se)) => {
3318 let mut stream = se;
3319 while let Some(item) = stream.next().await {
3320 if let Ok((_, line)) = item
3321 && se_bytes < MAX_BYTES
3322 {
3323 let entry = format!("{line}\n");
3324 se_bytes += entry.len();
3325 se_acc.lock().await.push_str(&entry);
3326 if il_bytes < 2 * MAX_BYTES {
3327 il_bytes += entry.len();
3328 il_acc.lock().await.push_str(&entry);
3329 }
3330 }
3331 }
3332 }
3333 (None, None) => {}
3334 }
3335 });
3336
3337 let (exit_code, timed_out, mut output_truncated, output_collection_error) = tokio::select! {
3338 _ = &mut drain_task => {
3339 let (status, drain_truncated) = match tokio::time::timeout(
3342 std::time::Duration::from_millis(500),
3343 child.wait()
3344 ).await {
3345 Ok(Ok(s)) => (Some(s), false),
3346 Ok(Err(_)) => (None, false),
3347 Err(_) => {
3348 child.start_kill().ok();
3349 let _ = child.wait().await;
3350 (None, true)
3351 }
3352 };
3353 let exit_code = status.and_then(|s| s.code());
3354 let ocerr = if drain_truncated {
3355 Some("post-exit drain timeout: background process held pipes".to_string())
3356 } else {
3357 None
3358 };
3359 (exit_code, false, drain_truncated, ocerr)
3360 }
3361 _ = async {
3362 if let Some(secs) = timeout_secs {
3363 tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3364 } else {
3365 std::future::pending::<()>().await;
3366 }
3367 } => {
3368 let _ = child.kill().await;
3370 let _ = child.wait().await;
3371 drain_task.abort();
3372 (None, true, false, None)
3374 }
3375 };
3376
3377 if let Some(handle) = progress_handle {
3379 handle.abort();
3380 }
3381
3382 let stdout_str = std::mem::take(&mut *stdout_shared.lock().await);
3384 let stderr_str = std::mem::take(&mut *stderr_shared.lock().await);
3385 let interleaved_str = std::mem::take(&mut *interleaved_shared.lock().await);
3386
3387 let slot = seq % 8;
3389 let (stdout, stderr, overflow_notice) =
3390 handle_output_overflow(stdout_str, stderr_str, slot);
3391 output_truncated = output_truncated || overflow_notice.is_some();
3392
3393 let mut output = types::ShellOutput::new(
3394 stdout,
3395 stderr,
3396 interleaved_str,
3397 exit_code,
3398 timed_out,
3399 output_truncated,
3400 );
3401 output.output_collection_error = output_collection_error;
3402
3403 let output_text = if output.interleaved.is_empty() {
3405 format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
3406 } else {
3407 format!("Output:\n{}", output.interleaved)
3408 };
3409
3410 let text = format!(
3411 "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
3412 params.command,
3413 exit_code
3414 .map(|c| c.to_string())
3415 .unwrap_or_else(|| "null".to_string()),
3416 timed_out,
3417 output_truncated,
3418 output_text,
3419 );
3420
3421 let mut content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
3422 if let Some(notice) = overflow_notice {
3423 content_blocks.push(Content::text(notice).with_priority(0.0));
3424 }
3425
3426 let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
3431
3432 let mut result = if command_failed {
3433 CallToolResult::error(content_blocks)
3434 } else {
3435 CallToolResult::success(content_blocks)
3436 }
3437 .with_meta(Some(no_cache_meta()));
3438
3439 let structured = match serde_json::to_value(&output).map_err(|e| {
3440 ErrorData::new(
3441 rmcp::model::ErrorCode::INTERNAL_ERROR,
3442 format!("serialization failed: {e}"),
3443 Some(error_meta("internal", false, "report this as a bug")),
3444 )
3445 }) {
3446 Ok(v) => v,
3447 Err(e) => {
3448 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3449 self.metrics_tx.send(crate::metrics::MetricEvent {
3450 ts: crate::metrics::unix_ms(),
3451 tool: "exec_command",
3452 duration_ms: dur,
3453 output_chars: 0,
3454 param_path_depth: crate::metrics::path_component_count(
3455 param_path.as_deref().unwrap_or(""),
3456 ),
3457 max_depth: None,
3458 result: "error",
3459 error_type: Some("internal_error".to_string()),
3460 session_id: sid.clone(),
3461 seq: Some(seq),
3462 cache_hit: None,
3463 });
3464 return Ok(err_to_tool_result(e));
3465 }
3466 };
3467
3468 result.structured_content = Some(structured);
3469 let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3470 self.metrics_tx.send(crate::metrics::MetricEvent {
3471 ts: crate::metrics::unix_ms(),
3472 tool: "exec_command",
3473 duration_ms: dur,
3474 output_chars: text.len(),
3475 param_path_depth: crate::metrics::path_component_count(
3476 param_path.as_deref().unwrap_or(""),
3477 ),
3478 max_depth: None,
3479 result: "ok",
3480 error_type: None,
3481 session_id: sid,
3482 seq: Some(seq),
3483 cache_hit: None,
3484 });
3485 Ok(result)
3486 }
3487}
3488
3489fn handle_output_overflow(
3495 stdout: String,
3496 stderr: String,
3497 slot: u32,
3498) -> (String, String, Option<String>) {
3499 const MAX_OUTPUT_LINES: usize = 2000;
3500 const OVERFLOW_PREVIEW_LINES: usize = 50;
3501
3502 let stdout_lines: Vec<&str> = stdout.lines().collect();
3503 let stderr_lines: Vec<&str> = stderr.lines().collect();
3504
3505 if stdout_lines.len() <= MAX_OUTPUT_LINES && stderr_lines.len() <= MAX_OUTPUT_LINES {
3506 return (stdout, stderr, None);
3507 }
3508
3509 let base = std::env::temp_dir()
3511 .join("aptu-coder-overflow")
3512 .join(format!("slot-{slot}"));
3513 let _ = std::fs::create_dir_all(&base);
3514
3515 let stdout_path = base.join("stdout");
3516 let stderr_path = base.join("stderr");
3517
3518 let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3519 let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3520
3521 let stdout_preview = if stdout_lines.len() > MAX_OUTPUT_LINES {
3523 stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3524 } else {
3525 stdout
3526 };
3527 let stderr_preview = if stderr_lines.len() > MAX_OUTPUT_LINES {
3528 stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3529 } else {
3530 stderr
3531 };
3532
3533 let notice = format!(
3534 "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 {}",
3535 stdout_path.display(),
3536 stderr_path.display(),
3537 stdout_path.display(),
3538 );
3539
3540 (stdout_preview, stderr_preview, Some(notice))
3541}
3542
3543#[derive(Clone)]
3547struct FocusedAnalysisParams {
3548 path: std::path::PathBuf,
3549 symbol: String,
3550 match_mode: SymbolMatchMode,
3551 follow_depth: u32,
3552 max_depth: Option<u32>,
3553 ast_recursion_limit: Option<usize>,
3554 use_summary: bool,
3555 impl_only: Option<bool>,
3556 def_use: bool,
3557}
3558
3559#[tool_handler]
3560impl ServerHandler for CodeAnalyzer {
3561 async fn initialize(
3562 &self,
3563 _request: InitializeRequestParams,
3564 context: RequestContext<RoleServer>,
3565 ) -> Result<InitializeResult, ErrorData> {
3566 if let Some(meta) = context.extensions.get::<Meta>() {
3569 let mut meta_lock = self.profile_meta.lock().await;
3570 *meta_lock = Some(meta.0.clone());
3571 }
3572 Ok(self.get_info())
3573 }
3574
3575 fn get_info(&self) -> InitializeResult {
3576 let excluded = crate::EXCLUDED_DIRS.join(", ");
3577 let instructions = format!(
3578 "Recommended workflow:\n\
3579 1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3580 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\
3581 3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3582 4. Use analyze_symbol to trace call graphs.\n\
3583 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."
3584 );
3585 let capabilities = ServerCapabilities::builder()
3586 .enable_logging()
3587 .enable_tools()
3588 .enable_tool_list_changed()
3589 .enable_completions()
3590 .build();
3591 let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3592 .with_title("Aptu Coder")
3593 .with_description("MCP server for code structure analysis using tree-sitter");
3594 InitializeResult::new(capabilities)
3595 .with_server_info(server_info)
3596 .with_instructions(&instructions)
3597 }
3598
3599 async fn list_tools(
3600 &self,
3601 _request: Option<rmcp::model::PaginatedRequestParams>,
3602 _context: RequestContext<RoleServer>,
3603 ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
3604 let router = self.tool_router.read().await;
3605 Ok(rmcp::model::ListToolsResult {
3606 tools: router.list_all(),
3607 meta: None,
3608 next_cursor: None,
3609 })
3610 }
3611
3612 async fn call_tool(
3613 &self,
3614 request: rmcp::model::CallToolRequestParams,
3615 context: RequestContext<RoleServer>,
3616 ) -> Result<CallToolResult, ErrorData> {
3617 let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
3618 let router = self.tool_router.read().await;
3619 router.call(tcc).await
3620 }
3621
3622 async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
3623 let mut peer_lock = self.peer.lock().await;
3624 *peer_lock = Some(context.peer.clone());
3625 drop(peer_lock);
3626
3627 let millis = std::time::SystemTime::now()
3629 .duration_since(std::time::UNIX_EPOCH)
3630 .unwrap_or_default()
3631 .as_millis()
3632 .try_into()
3633 .unwrap_or(u64::MAX);
3634 let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3635 let sid = format!("{millis}-{counter}");
3636 {
3637 let mut session_id_lock = self.session_id.lock().await;
3638 *session_id_lock = Some(sid);
3639 }
3640 self.session_call_seq
3641 .store(0, std::sync::atomic::Ordering::Relaxed);
3642
3643 let meta_lock = self.profile_meta.lock().await;
3646 if let Some(meta) = meta_lock.as_ref()
3647 && let Some(profile_val) = meta.get("io.clouatre-labs/profile")
3648 && let Some(profile) = profile_val.as_str()
3649 {
3650 let mut router = self.tool_router.write().await;
3651 match profile {
3652 "edit" => {
3653 router.disable_route("analyze_directory");
3655 router.disable_route("analyze_file");
3656 router.disable_route("analyze_module");
3657 router.disable_route("analyze_symbol");
3658 router.disable_route("edit_rename");
3659 router.disable_route("edit_insert");
3660 }
3661 "analyze" => {
3662 router.disable_route("edit_replace");
3664 router.disable_route("edit_overwrite");
3665 router.disable_route("edit_rename");
3666 router.disable_route("edit_insert");
3667 }
3668 _ => {
3669 }
3671 }
3672 router.bind_peer_notifier(&context.peer);
3674 }
3675 drop(meta_lock);
3676
3677 let peer = self.peer.clone();
3679 let event_rx = self.event_rx.clone();
3680
3681 tokio::spawn(async move {
3682 let rx = {
3683 let mut rx_lock = event_rx.lock().await;
3684 rx_lock.take()
3685 };
3686
3687 if let Some(mut receiver) = rx {
3688 let mut buffer = Vec::with_capacity(64);
3689 loop {
3690 receiver.recv_many(&mut buffer, 64).await;
3692
3693 if buffer.is_empty() {
3694 break;
3696 }
3697
3698 let peer_lock = peer.lock().await;
3700 if let Some(peer) = peer_lock.as_ref() {
3701 for log_event in buffer.drain(..) {
3702 let notification = ServerNotification::LoggingMessageNotification(
3703 Notification::new(LoggingMessageNotificationParam {
3704 level: log_event.level,
3705 logger: Some(log_event.logger),
3706 data: log_event.data,
3707 }),
3708 );
3709 if let Err(e) = peer.send_notification(notification).await {
3710 warn!("Failed to send logging notification: {}", e);
3711 }
3712 }
3713 }
3714 }
3715 }
3716 });
3717 }
3718
3719 #[instrument(skip(self, _context))]
3720 async fn on_cancelled(
3721 &self,
3722 notification: CancelledNotificationParam,
3723 _context: NotificationContext<RoleServer>,
3724 ) {
3725 tracing::info!(
3726 request_id = ?notification.request_id,
3727 reason = ?notification.reason,
3728 "Received cancellation notification"
3729 );
3730 }
3731
3732 #[instrument(skip(self, _context))]
3733 async fn complete(
3734 &self,
3735 request: CompleteRequestParams,
3736 _context: RequestContext<RoleServer>,
3737 ) -> Result<CompleteResult, ErrorData> {
3738 let argument_name = &request.argument.name;
3740 let argument_value = &request.argument.value;
3741
3742 let completions = match argument_name.as_str() {
3743 "path" => {
3744 let root = Path::new(".");
3746 completion::path_completions(root, argument_value)
3747 }
3748 "symbol" => {
3749 let path_arg = request
3751 .context
3752 .as_ref()
3753 .and_then(|ctx| ctx.get_argument("path"));
3754
3755 match path_arg {
3756 Some(path_str) => {
3757 let path = Path::new(path_str);
3758 completion::symbol_completions(&self.cache, path, argument_value)
3759 }
3760 None => Vec::new(),
3761 }
3762 }
3763 _ => Vec::new(),
3764 };
3765
3766 let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
3768 let (values, has_more) = if completions.len() > 100 {
3769 (completions.into_iter().take(100).collect(), true)
3770 } else {
3771 (completions, false)
3772 };
3773
3774 let completion_info =
3775 match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
3776 Ok(info) => info,
3777 Err(_) => {
3778 CompletionInfo::with_all_values(Vec::new())
3780 .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
3781 }
3782 };
3783
3784 Ok(CompleteResult::new(completion_info))
3785 }
3786
3787 async fn set_level(
3788 &self,
3789 params: SetLevelRequestParams,
3790 _context: RequestContext<RoleServer>,
3791 ) -> Result<(), ErrorData> {
3792 let level_filter = match params.level {
3793 LoggingLevel::Debug => LevelFilter::DEBUG,
3794 LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
3795 LoggingLevel::Warning => LevelFilter::WARN,
3796 LoggingLevel::Error
3797 | LoggingLevel::Critical
3798 | LoggingLevel::Alert
3799 | LoggingLevel::Emergency => LevelFilter::ERROR,
3800 };
3801
3802 let mut filter_lock = self
3803 .log_level_filter
3804 .lock()
3805 .unwrap_or_else(|e| e.into_inner());
3806 *filter_lock = level_filter;
3807 Ok(())
3808 }
3809}
3810
3811#[cfg(test)]
3812mod tests {
3813 use super::*;
3814
3815 #[tokio::test]
3816 async fn test_emit_progress_none_peer_is_noop() {
3817 let peer = Arc::new(TokioMutex::new(None));
3818 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3819 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3820 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3821 let analyzer = CodeAnalyzer::new(
3822 peer,
3823 log_level_filter,
3824 rx,
3825 crate::metrics::MetricsSender(metrics_tx),
3826 );
3827 let token = ProgressToken(NumberOrString::String("test".into()));
3828 analyzer
3830 .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
3831 .await;
3832 }
3833
3834 fn make_analyzer() -> CodeAnalyzer {
3835 let peer = Arc::new(TokioMutex::new(None));
3836 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3837 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3838 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3839 CodeAnalyzer::new(
3840 peer,
3841 log_level_filter,
3842 rx,
3843 crate::metrics::MetricsSender(metrics_tx),
3844 )
3845 }
3846
3847 #[test]
3848 fn test_summary_cursor_conflict() {
3849 assert!(summary_cursor_conflict(Some(true), Some("cursor")));
3850 assert!(!summary_cursor_conflict(Some(true), None));
3851 assert!(!summary_cursor_conflict(None, Some("x")));
3852 assert!(!summary_cursor_conflict(None, None));
3853 }
3854
3855 #[tokio::test]
3856 async fn test_validate_impl_only_non_rust_returns_invalid_params() {
3857 use tempfile::TempDir;
3858
3859 let dir = TempDir::new().unwrap();
3860 std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
3861
3862 let analyzer = make_analyzer();
3863 let entries: Vec<traversal::WalkEntry> =
3866 traversal::walk_directory(dir.path(), None).unwrap_or_default();
3867 let result = CodeAnalyzer::validate_impl_only(&entries);
3868 assert!(result.is_err());
3869 let err = result.unwrap_err();
3870 assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
3871 drop(analyzer); }
3873
3874 #[tokio::test]
3875 async fn test_no_cache_meta_on_analyze_directory_result() {
3876 use aptu_coder_core::types::{
3877 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3878 };
3879 use tempfile::TempDir;
3880
3881 let dir = TempDir::new().unwrap();
3882 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
3883
3884 let analyzer = make_analyzer();
3885 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3886 "path": dir.path().to_str().unwrap(),
3887 }))
3888 .unwrap();
3889 let ct = tokio_util::sync::CancellationToken::new();
3890 let (arc_output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
3891 let meta = no_cache_meta();
3893 assert_eq!(
3894 meta.0.get("cache_hint").and_then(|v| v.as_str()),
3895 Some("no-cache"),
3896 );
3897 drop(arc_output);
3898 }
3899
3900 #[test]
3901 fn test_complete_path_completions_returns_suggestions() {
3902 let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
3907 let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
3908 let suggestions = completion::path_completions(workspace_root, "aptu-");
3909 assert!(
3910 !suggestions.is_empty(),
3911 "expected completions for prefix 'aptu-' in workspace root"
3912 );
3913 }
3914
3915 #[tokio::test]
3916 async fn test_handle_overview_mode_verbose_no_summary_block() {
3917 use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
3918 use aptu_coder_core::types::{
3919 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3920 };
3921 use tempfile::TempDir;
3922
3923 let tmp = TempDir::new().unwrap();
3924 std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
3925
3926 let peer = Arc::new(TokioMutex::new(None));
3927 let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3928 let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3929 let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3930 let analyzer = CodeAnalyzer::new(
3931 peer,
3932 log_level_filter,
3933 rx,
3934 crate::metrics::MetricsSender(metrics_tx),
3935 );
3936
3937 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3938 "path": tmp.path().to_str().unwrap(),
3939 "verbose": true,
3940 }))
3941 .unwrap();
3942
3943 let ct = tokio_util::sync::CancellationToken::new();
3944 let (output, _cache_hit) = analyzer.handle_overview_mode(¶ms, ct).await.unwrap();
3945
3946 let use_summary = output.formatted.len() > SIZE_LIMIT; let paginated =
3949 paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
3950 let verbose = true;
3951 let formatted = if !use_summary {
3952 format_structure_paginated(
3953 &paginated.items,
3954 paginated.total,
3955 params.max_depth,
3956 Some(std::path::Path::new(¶ms.path)),
3957 verbose,
3958 )
3959 } else {
3960 output.formatted.clone()
3961 };
3962
3963 assert!(
3965 !formatted.contains("SUMMARY:"),
3966 "verbose=true must not emit SUMMARY: block; got: {}",
3967 &formatted[..formatted.len().min(300)]
3968 );
3969 assert!(
3970 formatted.contains("PAGINATED:"),
3971 "verbose=true must emit PAGINATED: header"
3972 );
3973 assert!(
3974 formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
3975 "verbose=true must emit FILES section header"
3976 );
3977 }
3978
3979 #[tokio::test]
3982 async fn test_analyze_directory_cache_hit_metrics() {
3983 use aptu_coder_core::types::{
3984 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3985 };
3986 use tempfile::TempDir;
3987
3988 let dir = TempDir::new().unwrap();
3990 std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
3991 let analyzer = make_analyzer();
3992 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3993 "path": dir.path().to_str().unwrap(),
3994 }))
3995 .unwrap();
3996
3997 let ct1 = tokio_util::sync::CancellationToken::new();
3999 let (_out1, hit1) = analyzer.handle_overview_mode(¶ms, ct1).await.unwrap();
4000
4001 let ct2 = tokio_util::sync::CancellationToken::new();
4003 let (_out2, hit2) = analyzer.handle_overview_mode(¶ms, ct2).await.unwrap();
4004
4005 assert!(!hit1, "first call must be a cache miss");
4007 assert!(hit2, "second call must be a cache hit");
4008 }
4009
4010 #[tokio::test]
4011 async fn test_analyze_module_cache_hit_metrics() {
4012 use std::io::Write as _;
4013 use tempfile::NamedTempFile;
4014
4015 let mut f = NamedTempFile::with_suffix(".rs").unwrap();
4017 writeln!(f, "fn bar() {{}}").unwrap();
4018 let path = f.path().to_str().unwrap().to_string();
4019
4020 let analyzer = make_analyzer();
4021
4022 let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
4024 file_params.path = path.clone();
4025 file_params.ast_recursion_limit = None;
4026 file_params.fields = None;
4027 file_params.pagination.cursor = None;
4028 file_params.pagination.page_size = None;
4029 file_params.output_control.summary = None;
4030 file_params.output_control.force = None;
4031 file_params.output_control.verbose = None;
4032 let (_cached, _) = analyzer
4033 .handle_file_details_mode(&file_params)
4034 .await
4035 .unwrap();
4036
4037 let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
4039 module_params.path = path.clone();
4040
4041 let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
4043 meta.modified()
4044 .ok()
4045 .map(|mtime| aptu_coder_core::cache::CacheKey {
4046 path: std::path::PathBuf::from(&path),
4047 modified: mtime,
4048 mode: aptu_coder_core::types::AnalysisMode::FileDetails,
4049 })
4050 });
4051 let cache_hit = module_cache_key
4052 .as_ref()
4053 .and_then(|k| analyzer.cache.get(k))
4054 .is_some();
4055
4056 assert!(
4058 cache_hit,
4059 "analyze_module should find the file in the shared file cache"
4060 );
4061 drop(module_params);
4062 }
4063
4064 #[test]
4067 fn test_analyze_symbol_import_lookup_invalid_params() {
4068 let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
4072
4073 assert!(
4075 result.is_err(),
4076 "import_lookup=true with empty symbol must return Err"
4077 );
4078 let err = result.unwrap_err();
4079 assert_eq!(
4080 err.code,
4081 rmcp::model::ErrorCode::INVALID_PARAMS,
4082 "expected INVALID_PARAMS; got {:?}",
4083 err.code
4084 );
4085 }
4086
4087 #[tokio::test]
4088 async fn test_analyze_symbol_import_lookup_found() {
4089 use tempfile::TempDir;
4090
4091 let dir = TempDir::new().unwrap();
4093 std::fs::write(
4094 dir.path().join("main.rs"),
4095 "use std::collections::HashMap;\nfn main() {}\n",
4096 )
4097 .unwrap();
4098
4099 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4100
4101 let output =
4103 analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
4104
4105 assert!(
4107 output.formatted.contains("MATCHES: 1"),
4108 "expected 1 match; got: {}",
4109 output.formatted
4110 );
4111 assert!(
4112 output.formatted.contains("main.rs"),
4113 "expected main.rs in output; got: {}",
4114 output.formatted
4115 );
4116 }
4117
4118 #[tokio::test]
4119 async fn test_analyze_symbol_import_lookup_empty() {
4120 use tempfile::TempDir;
4121
4122 let dir = TempDir::new().unwrap();
4124 std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
4125
4126 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4127
4128 let output =
4130 analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
4131
4132 assert!(
4134 output.formatted.contains("MATCHES: 0"),
4135 "expected 0 matches; got: {}",
4136 output.formatted
4137 );
4138 }
4139
4140 #[tokio::test]
4143 async fn test_analyze_directory_git_ref_non_git_repo() {
4144 use aptu_coder_core::traversal::changed_files_from_git_ref;
4145 use tempfile::TempDir;
4146
4147 let dir = TempDir::new().unwrap();
4149 std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4150
4151 let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
4153
4154 assert!(result.is_err(), "non-git dir must return an error");
4156 let err_msg = result.unwrap_err().to_string();
4157 assert!(
4158 err_msg.contains("git"),
4159 "error must mention git; got: {err_msg}"
4160 );
4161 }
4162
4163 #[tokio::test]
4164 async fn test_analyze_directory_git_ref_filters_changed_files() {
4165 use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
4166 use std::collections::HashSet;
4167 use tempfile::TempDir;
4168
4169 let dir = TempDir::new().unwrap();
4171 let changed_file = dir.path().join("changed.rs");
4172 let unchanged_file = dir.path().join("unchanged.rs");
4173 std::fs::write(&changed_file, "fn changed() {}").unwrap();
4174 std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
4175
4176 let entries = traversal::walk_directory(dir.path(), None).unwrap();
4177 let total_files = entries.iter().filter(|e| !e.is_dir).count();
4178 assert_eq!(total_files, 2, "sanity: 2 files before filtering");
4179
4180 let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
4182 changed.insert(changed_file.clone());
4183
4184 let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
4186 let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
4187
4188 assert_eq!(
4190 filtered_files.len(),
4191 1,
4192 "only 1 file must remain after git_ref filter"
4193 );
4194 assert_eq!(
4195 filtered_files[0].path, changed_file,
4196 "the remaining file must be the changed one"
4197 );
4198
4199 let _ = changed_files_from_git_ref;
4201 }
4202
4203 #[tokio::test]
4204 async fn test_handle_overview_mode_git_ref_filters_via_handler() {
4205 use aptu_coder_core::types::{
4206 AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4207 };
4208 use std::process::Command;
4209 use tempfile::TempDir;
4210
4211 let dir = TempDir::new().unwrap();
4213 let repo = dir.path();
4214
4215 let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
4218 let mut cmd = std::process::Command::new("git");
4219 cmd.args(["-c", "core.hooksPath=/dev/null"]);
4220 cmd.args(args);
4221 cmd.current_dir(repo_path);
4222 let out = cmd.output().unwrap();
4223 assert!(out.status.success(), "{out:?}");
4224 };
4225 git_no_hook(repo, &["init"]);
4226 git_no_hook(
4227 repo,
4228 &[
4229 "-c",
4230 "user.email=ci@example.com",
4231 "-c",
4232 "user.name=CI",
4233 "commit",
4234 "--allow-empty",
4235 "-m",
4236 "initial",
4237 ],
4238 );
4239
4240 std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
4242 git_no_hook(repo, &["add", "file_a.rs"]);
4243 git_no_hook(
4244 repo,
4245 &[
4246 "-c",
4247 "user.email=ci@example.com",
4248 "-c",
4249 "user.name=CI",
4250 "commit",
4251 "-m",
4252 "add a",
4253 ],
4254 );
4255
4256 std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
4258 git_no_hook(repo, &["add", "file_b.rs"]);
4259 git_no_hook(
4260 repo,
4261 &[
4262 "-c",
4263 "user.email=ci@example.com",
4264 "-c",
4265 "user.name=CI",
4266 "commit",
4267 "-m",
4268 "add b",
4269 ],
4270 );
4271
4272 let canon_repo = std::fs::canonicalize(repo).unwrap();
4278 let analyzer = make_analyzer();
4279 let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4280 "path": canon_repo.to_str().unwrap(),
4281 "git_ref": "HEAD~1",
4282 }))
4283 .unwrap();
4284 let ct = tokio_util::sync::CancellationToken::new();
4285 let (arc_output, _cache_hit) = analyzer
4286 .handle_overview_mode(¶ms, ct)
4287 .await
4288 .expect("handle_overview_mode with git_ref must succeed");
4289
4290 let formatted = &arc_output.formatted;
4292 assert!(
4293 formatted.contains("file_b.rs"),
4294 "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
4295 );
4296 assert!(
4297 !formatted.contains("file_a.rs"),
4298 "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
4299 );
4300 }
4301
4302 #[test]
4303 fn test_validate_path_rejects_absolute_path_outside_cwd() {
4304 let result = validate_path("/etc/passwd", true);
4307 assert!(
4308 result.is_err(),
4309 "validate_path should reject /etc/passwd (outside CWD)"
4310 );
4311 let err = result.unwrap_err();
4312 let err_msg = err.message.to_lowercase();
4313 assert!(
4314 err_msg.contains("outside") || err_msg.contains("not found"),
4315 "Error message should mention 'outside' or 'not found': {}",
4316 err.message
4317 );
4318 }
4319
4320 #[test]
4321 fn test_validate_path_accepts_relative_path_in_cwd() {
4322 let result = validate_path("Cargo.toml", true);
4325 assert!(
4326 result.is_ok(),
4327 "validate_path should accept Cargo.toml (exists in CWD)"
4328 );
4329 }
4330
4331 #[test]
4332 fn test_validate_path_creates_parent_for_nonexistent_file() {
4333 let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4336 assert!(
4337 result.is_ok(),
4338 "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4339 );
4340 let path = result.unwrap();
4341 let cwd = std::env::current_dir().expect("should get cwd");
4342 let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4343 assert!(
4344 path.starts_with(&canonical_cwd),
4345 "Resolved path should be within CWD: {:?} should start with {:?}",
4346 path,
4347 canonical_cwd
4348 );
4349 }
4350
4351 #[test]
4352 fn test_tool_annotations() {
4353 let tools = CodeAnalyzer::list_tools();
4355
4356 let edit_rename = tools.iter().find(|t| t.name == "edit_rename");
4358 let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
4359 let exec_command = tools.iter().find(|t| t.name == "exec_command");
4360
4361 let edit_rename_tool = edit_rename.expect("edit_rename tool should exist");
4363 let edit_rename_annot = edit_rename_tool
4364 .annotations
4365 .as_ref()
4366 .expect("edit_rename should have annotations");
4367 assert_eq!(
4368 edit_rename_annot.destructive_hint,
4369 Some(false),
4370 "edit_rename destructive_hint should be false"
4371 );
4372 assert_eq!(
4373 edit_rename_annot.idempotent_hint,
4374 Some(true),
4375 "edit_rename idempotent_hint should be true"
4376 );
4377
4378 let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
4380 let analyze_dir_annot = analyze_dir_tool
4381 .annotations
4382 .as_ref()
4383 .expect("analyze_directory should have annotations");
4384 assert_eq!(
4385 analyze_dir_annot.read_only_hint,
4386 Some(true),
4387 "analyze_directory read_only_hint should be true"
4388 );
4389 assert_eq!(
4390 analyze_dir_annot.destructive_hint,
4391 Some(false),
4392 "analyze_directory destructive_hint should be false"
4393 );
4394
4395 let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
4397 let exec_cmd_annot = exec_cmd_tool
4398 .annotations
4399 .as_ref()
4400 .expect("exec_command should have annotations");
4401 assert_eq!(
4402 exec_cmd_annot.open_world_hint,
4403 Some(true),
4404 "exec_command open_world_hint should be true"
4405 );
4406 }
4407
4408 #[test]
4409 fn test_exec_stdin_size_cap_validation() {
4410 let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
4413
4414 assert!(
4416 oversized_stdin.len() > STDIN_MAX_BYTES,
4417 "test setup: oversized stdin should exceed 1 MB"
4418 );
4419
4420 let max_stdin = "y".repeat(STDIN_MAX_BYTES);
4422 assert_eq!(
4423 max_stdin.len(),
4424 STDIN_MAX_BYTES,
4425 "test setup: max stdin should be exactly 1 MB"
4426 );
4427 }
4428
4429 #[tokio::test]
4430 async fn test_exec_stdin_cat_roundtrip() {
4431 let stdin_content = "hello world";
4434
4435 let mut child = tokio::process::Command::new("sh")
4437 .arg("-c")
4438 .arg("cat")
4439 .stdin(std::process::Stdio::piped())
4440 .stdout(std::process::Stdio::piped())
4441 .stderr(std::process::Stdio::piped())
4442 .spawn()
4443 .expect("spawn cat");
4444
4445 if let Some(mut stdin_handle) = child.stdin.take() {
4446 use tokio::io::AsyncWriteExt as _;
4447 stdin_handle
4448 .write_all(stdin_content.as_bytes())
4449 .await
4450 .expect("write stdin");
4451 drop(stdin_handle);
4452 }
4453
4454 let output = child.wait_with_output().await.expect("wait for cat");
4455
4456 let stdout_str = String::from_utf8_lossy(&output.stdout);
4458 assert!(
4459 stdout_str.contains(stdin_content),
4460 "stdout should contain stdin content: {}",
4461 stdout_str
4462 );
4463 }
4464
4465 #[tokio::test]
4466 async fn test_exec_stdin_none_no_regression() {
4467 let child = tokio::process::Command::new("sh")
4470 .arg("-c")
4471 .arg("echo hi")
4472 .stdin(std::process::Stdio::null())
4473 .stdout(std::process::Stdio::piped())
4474 .stderr(std::process::Stdio::piped())
4475 .spawn()
4476 .expect("spawn echo");
4477
4478 let output = child.wait_with_output().await.expect("wait for echo");
4479
4480 let stdout_str = String::from_utf8_lossy(&output.stdout);
4482 assert!(
4483 stdout_str.contains("hi"),
4484 "stdout should contain echo output: {}",
4485 stdout_str
4486 );
4487 }
4488}