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