Skip to main content

aptu_coder/
lib.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Rust MCP server for code structure analysis using tree-sitter.
4//!
5//! This crate exposes four MCP tools for multiple programming languages:
6//!
7//! - **`analyze_directory`**: Directory tree with file counts and structure
8//! - **`analyze_file`**: Semantic extraction (functions, classes, imports)
9//! - **`analyze_symbol`**: Call graph analysis (callers and callees)
10//! - **`analyze_module`**: Lightweight function and import index
11//!
12//! Key entry points:
13//! - [`analyze::analyze_directory`]: Analyze entire directory tree
14//! - [`analyze::analyze_file`]: Analyze single file
15//!
16//! Languages supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#.
17
18pub 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/// Returns `true` when `summary=true` and a `cursor` are both provided, which is an invalid
77/// combination since summary mode and pagination are mutually exclusive.
78#[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
117/// Validates that a path is within the current working directory.
118/// For `require_exists=true`, the path must exist and be canonicalizable.
119/// For `require_exists=false`, the parent directory must exist and be canonicalizable.
120fn validate_path(path: &str, require_exists: bool) -> Result<std::path::PathBuf, ErrorData> {
121    // Canonicalize the allowed root (CWD) to resolve symlinks
122    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        // For non-existent files (edit_overwrite), walk up the path until we find an existing ancestor
154        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                // No existing ancestor found — use allowed_root as anchor
169                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
194/// Helper function for paginating focus chains (callers or callees).
195/// Returns (items, re-encoded_cursor_option).
196fn 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/// MCP server handler that wires the four analysis tools to the rmcp transport.
243///
244/// Holds shared state: tool router, analysis cache, peer connection, log-level filter,
245/// log event channel, metrics sender, and per-session sequence tracking.
246#[derive(Clone)]
247pub struct CodeAnalyzer {
248    // Accessed by rmcp macro-generated tool dispatch, but this field still triggers
249    // `dead_code` in this crate, so keep the targeted suppression.
250    #[allow(dead_code)]
251    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    /// Private helper: Extract analysis logic for overview mode (`analyze_directory`).
315    /// Returns the complete analysis output and a cache_hit bool after spawning and monitoring progress.
316    /// Cancels the blocking task when `ct` is triggered; returns an error on cancellation.
317    #[allow(clippy::too_many_lines)] // long but cohesive analysis loop; extracting sub-functions would obscure the control flow
318    #[allow(clippy::cast_precision_loss)] // progress percentage display; precision loss acceptable for usize counts
319    #[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(&params.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        // Single unbounded walk; filter in-memory to respect max_depth for analysis.
333        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        // Canonicalize max_depth: Some(0) is semantically identical to None (unlimited).
346        let canonical_max_depth = max_depth.and_then(|d| if d == 0 { None } else { Some(d) });
347
348        // Build cache key from all_entries (before depth filtering).
349        // git_ref is included in the key so filtered and unfiltered results have distinct entries.
350        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        // Check cache
359        if let Some(cached) = self.cache.get_directory(&cache_key) {
360            return Ok((cached, true));
361        }
362
363        // Apply git_ref filter when requested (non-empty string only).
364        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        // Compute subtree counts from the full entry set before filtering.
384        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        // Filter to depth-bounded subset for analysis.
391        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        // Get total file count for progress reporting
403        let total_files = entries.iter().filter(|e| !e.is_dir).count();
404
405        // Spawn blocking analysis with progress tracking
406        let handle = tokio::task::spawn_blocking(move || {
407            analyze::analyze_directory_with_progress(&path_owned, entries, counter_clone, ct_clone)
408        });
409
410        // Poll and emit progress every 100ms
411        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        // Emit final 100% progress only if not cancelled
448        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    /// Private helper: Extract analysis logic for file details mode (`analyze_file`).
489    /// Returns the cached or newly analyzed file output along with a cache_hit bool.
490    #[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        // Build cache key from file metadata
496        let cache_key = std::fs::metadata(&params.path).ok().and_then(|meta| {
497            meta.modified().ok().map(|mtime| cache::CacheKey {
498                path: std::path::PathBuf::from(&params.path),
499                modified: mtime,
500                mode: AnalysisMode::FileDetails,
501            })
502        });
503
504        // Check cache first
505        if let Some(ref key) = cache_key
506            && let Some(cached) = self.cache.get(key)
507        {
508            return Ok((cached, true));
509        }
510
511        // Cache miss or no cache key, analyze and optionally store
512        match analyze::analyze_file(&params.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    // Validate impl_only: only valid for directories that contain Rust source files.
533    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    /// Validate that `import_lookup=true` is accompanied by a non-empty symbol (the module path).
557    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    // Poll progress until analysis task completes.
574    #[allow(clippy::cast_precision_loss)] // progress percentage display; precision loss acceptable for usize counts
575    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                &params,
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    // Run focused analysis with auto-summary retry on SIZE_LIMIT overflow.
687    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                &params.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                    &params.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    /// Private helper: Extract analysis logic for focused mode (`analyze_symbol`).
782    /// Returns the complete focused analysis output after spawning and monitoring progress.
783    /// Cancels the blocking task when `ct` is triggered; returns an error on cancellation.
784    #[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(&params.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        // Apply git_ref filter when requested (non-empty string only).
806        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(&params.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        // Call handler for analysis and progress tracking
906        let (arc_output, dir_cache_hit) = match self.handle_overview_mode(&params, ct).await {
907            Ok(v) => v,
908            Err(e) => return Ok(err_to_tool_result(e)),
909        };
910        // Extract the value from Arc for modification. On a cache hit the Arc is shared,
911        // so try_unwrap may fail; fall back to cloning the underlying value in that case.
912        let mut output = match std::sync::Arc::try_unwrap(arc_output) {
913            Ok(owned) => owned,
914            Err(arc) => (*arc).clone(),
915        };
916
917        // summary=true (explicit) and cursor are mutually exclusive.
918        // Auto-summarization (summary=None + large output) must NOT block cursor pagination.
919        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        // Apply summary/output size limiting logic
936        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        // Decode pagination cursor if provided
956        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        // Apply pagination to files
974        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(&params.path)),
993                verbose,
994            );
995        }
996
997        // Update next_cursor in output after pagination (unless using summary mode)
998        if use_summary {
999            output.next_cursor = None;
1000        } else {
1001            output.next_cursor.clone_from(&paginated.next_cursor);
1002        }
1003
1004        // Build final text output with pagination cursor if present (unless using summary mode)
1005        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(&param_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(&params.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        // Check if path is a directory (not allowed for analyze_file)
1064        if std::path::Path::new(&params.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        // summary=true and cursor are mutually exclusive
1080        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        // Call handler for analysis and caching
1097        let (arc_output, file_cache_hit) = match self.handle_file_details_mode(&params).await {
1098            Ok(v) => v,
1099            Err(e) => return Ok(err_to_tool_result(e)),
1100        };
1101
1102        // Clone only the two fields that may be mutated per-request (formatted and
1103        // next_cursor). The heavy SemanticAnalysis data is shared via Arc and never
1104        // modified, so we borrow it directly from the cached pointer.
1105        let mut formatted = arc_output.formatted.clone();
1106        let line_count = arc_output.line_count;
1107
1108        // Apply summary/output size limiting logic
1109        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, &params.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        // Decode pagination cursor if provided (analyze_file)
1143        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        // Filter to top-level functions only (exclude methods) before pagination
1161        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        // Paginate top-level functions only
1176        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        // Regenerate formatted output using the paginated formatter (handles verbose and pagination correctly)
1189        let verbose = params.output_control.verbose.unwrap_or(false);
1190        if !use_summary {
1191            // fields: serde rejects unknown enum variants at deserialization; no runtime validation required
1192            formatted = format_file_details_paginated(
1193                &paginated.items,
1194                paginated.total,
1195                &arc_output.semantic,
1196                &params.path,
1197                line_count,
1198                offset,
1199                verbose,
1200                params.fields.as_deref(),
1201            );
1202        }
1203
1204        // Capture next_cursor from pagination result (unless using summary mode)
1205        let next_cursor = if use_summary {
1206            None
1207        } else {
1208            paginated.next_cursor.clone()
1209        };
1210
1211        // Build final text output with pagination cursor if present (unless using summary mode)
1212        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        // Build the response output, sharing SemanticAnalysis from the Arc to avoid cloning it.
1220        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(&param_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(&params.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        // Check if path is a file (not allowed for analyze_symbol)
1281        if std::path::Path::new(&params.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        // summary=true and cursor are mutually exclusive
1297        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        // import_lookup=true is mutually exclusive with a non-empty symbol.
1314        if let Err(e) = Self::validate_import_lookup(params.import_lookup, &params.symbol) {
1315            return Ok(err_to_tool_result(e));
1316        }
1317
1318        // import_lookup mode: scan for files importing `params.symbol` as a module path.
1319        if params.import_lookup == Some(true) {
1320            let path_owned = PathBuf::from(&params.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                // Apply git_ref filter when requested (non-empty string only).
1343                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(&param_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        // Call handler for analysis and progress tracking
1421        let mut output = match self.handle_focused_mode(&params, ct).await {
1422            Ok(v) => v,
1423            Err(e) => return Ok(err_to_tool_result(e)),
1424        };
1425
1426        // Decode pagination cursor if provided (analyze_symbol)
1427        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        // SymbolFocus pagination: decode cursor mode to determine callers vs callees
1445        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(&params.path);
1478                    output.formatted = format_focused_paginated(
1479                        &paginated_items,
1480                        output.prod_chains.len(),
1481                        PaginationMode::Callers,
1482                        &params.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(&params.path);
1509                    output.formatted = format_focused_paginated(
1510                        &paginated_items,
1511                        output.outgoing_chains.len(),
1512                        PaginationMode::Callees,
1513                        &params.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                // Always regenerate formatted output for DefUse mode so the
1551                // first page (offset=0, verbose=true) is not skipped.
1552                if !use_summary {
1553                    let base_path = Path::new(&params.path);
1554                    output.formatted = format_focused_paginated_defuse(
1555                        &paginated_sites,
1556                        total_sites,
1557                        &params.symbol,
1558                        offset,
1559                        Some(base_path),
1560                        verbose,
1561                    );
1562                }
1563
1564                // Slice output.def_use_sites to the current page window so
1565                // structuredContent only contains the paginated subset.
1566                output.def_use_sites = paginated_sites;
1567
1568                paginated_next
1569            }
1570        };
1571
1572        // When callers are exhausted and callees exist, bootstrap callee pagination
1573        // by emitting a {mode:callees, offset:0} cursor. This makes PaginationMode::Callees
1574        // reachable; without it the branch was dead code. Suppressed in summary mode
1575        // because summary and pagination are mutually exclusive.
1576        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        // When callees are exhausted and def_use_sites exist, bootstrap defuse cursor
1589        // by emitting a {mode:defuse, offset:0} cursor. This makes PaginationMode::DefUse
1590        // reachable. Suppressed in summary mode because summary and pagination are mutually exclusive.
1591        // Also bootstrap directly from Callers mode when there are no outgoing chains
1592        // (e.g. SymbolNotFound path or symbols with no callees) so def-use pagination
1593        // is reachable even without a Callees phase.
1594        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            // Only bootstrap from Callers when callees are empty (otherwise
1607            // the Callees bootstrap above takes priority).
1608            if cursor_mode == PaginationMode::Callees || output.outgoing_chains.is_empty() {
1609                callee_cursor = Some(cursor);
1610            }
1611        }
1612
1613        // Update next_cursor in output
1614        output.next_cursor.clone_from(&callee_cursor);
1615
1616        // Build final text output with pagination cursor if present
1617        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        // Only include def_use_sites in structuredContent when in DefUse mode.
1627        // In Callers/Callees modes, clearing the vec prevents large def-use
1628        // payloads from leaking into paginated non-def-use responses.
1629        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(&param_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(&params.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        // Issue 340: Guard against directory paths
1682        if std::fs::metadata(&params.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(&param_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        // Check file cache using mtime-keyed CacheKey (same pattern as handle_file_details_mode).
1715        let module_cache_key = std::fs::metadata(&params.path).ok().and_then(|meta| {
1716            meta.modified().ok().map(|mtime| cache::CacheKey {
1717                path: std::path::PathBuf::from(&params.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            // Reconstruct ModuleInfo from the cached FileAnalysisOutput.
1726            // Path and language are derived from params.path since FileAnalysisOutput
1727            // does not store them.
1728            let file_path = std::path::Path::new(&params.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            // Cache miss: call analyze_file (returns FileAnalysisOutput) so we can populate
1769            // the file cache for future calls. Then reconstruct ModuleInfo from the result,
1770            // mirroring the cache-hit path above.
1771            let file_output = match analyze::analyze_file(&params.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(&params.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(&param_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 = "No AST parsing performed; returns raw UTF-8 file content with line numbers. Output fields: path, total_lines, start_line, end_line, content. Accepts any file extension; errors on non-UTF-8 or binary files. Use analyze_file or analyze_module for AST-structured output; use analyze_raw when you need exact text content without semantic overhead. 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 the full content of 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(&params.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        // Guard against directory paths
1920        if std::fs::metadata(&params.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(&param_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(&params.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(&param_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(&param_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(&param_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(&param_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(&param_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(&params.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        // Guard against directory paths
2123        if std::fs::metadata(&params.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(&param_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(&params.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(&param_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(&param_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(&param_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(&param_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(&param_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(&params.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        // Guard against directory paths
2301        if std::fs::metadata(&params.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(&param_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(&params.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(&param_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(&param_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(&param_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(&param_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(&param_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(&param_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(&param_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(&params.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        // Guard against directory paths
2535        if std::fs::metadata(&params.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(&param_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(&params.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(&param_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(&param_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(&param_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(&param_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(&param_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(&param_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(&param_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(&param_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(&param_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(&params.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        // Guard against directory paths
2820        if std::fs::metadata(&params.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(&param_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(&params.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(&param_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(&param_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(&param_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(&param_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(&param_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(&param_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
2999// Parameters for focused analysis task.
3000#[derive(Clone)]
3001struct FocusedAnalysisParams {
3002    path: std::path::PathBuf,
3003    symbol: String,
3004    match_mode: SymbolMatchMode,
3005    follow_depth: u32,
3006    max_depth: Option<u32>,
3007    ast_recursion_limit: Option<usize>,
3008    use_summary: bool,
3009    impl_only: Option<bool>,
3010    def_use: bool,
3011}
3012
3013#[tool_handler]
3014impl ServerHandler for CodeAnalyzer {
3015    fn get_info(&self) -> InitializeResult {
3016        let excluded = crate::EXCLUDED_DIRS.join(", ");
3017        let instructions = format!(
3018            "Recommended workflow:\n\
3019            1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3020            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\
3021            3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3022            4. Use analyze_symbol to trace call graphs.\n\
3023            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."
3024        );
3025        let capabilities = ServerCapabilities::builder()
3026            .enable_logging()
3027            .enable_tools()
3028            .enable_tool_list_changed()
3029            .enable_completions()
3030            .build();
3031        let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3032            .with_title("Aptu Coder")
3033            .with_description("MCP server for code structure analysis using tree-sitter");
3034        InitializeResult::new(capabilities)
3035            .with_server_info(server_info)
3036            .with_instructions(&instructions)
3037    }
3038
3039    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
3040        let mut peer_lock = self.peer.lock().await;
3041        *peer_lock = Some(context.peer.clone());
3042        drop(peer_lock);
3043
3044        // Generate session_id in MILLIS-N format
3045        let millis = std::time::SystemTime::now()
3046            .duration_since(std::time::UNIX_EPOCH)
3047            .unwrap_or_default()
3048            .as_millis()
3049            .try_into()
3050            .unwrap_or(u64::MAX);
3051        let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3052        let sid = format!("{millis}-{counter}");
3053        {
3054            let mut session_id_lock = self.session_id.lock().await;
3055            *session_id_lock = Some(sid);
3056        }
3057        self.session_call_seq
3058            .store(0, std::sync::atomic::Ordering::Relaxed);
3059
3060        // Spawn consumer task to drain log events from channel with batching.
3061        let peer = self.peer.clone();
3062        let event_rx = self.event_rx.clone();
3063
3064        tokio::spawn(async move {
3065            let rx = {
3066                let mut rx_lock = event_rx.lock().await;
3067                rx_lock.take()
3068            };
3069
3070            if let Some(mut receiver) = rx {
3071                let mut buffer = Vec::with_capacity(64);
3072                loop {
3073                    // Drain up to 64 events from channel
3074                    receiver.recv_many(&mut buffer, 64).await;
3075
3076                    if buffer.is_empty() {
3077                        // Channel closed, exit consumer task
3078                        break;
3079                    }
3080
3081                    // Acquire peer lock once per batch
3082                    let peer_lock = peer.lock().await;
3083                    if let Some(peer) = peer_lock.as_ref() {
3084                        for log_event in buffer.drain(..) {
3085                            let notification = ServerNotification::LoggingMessageNotification(
3086                                Notification::new(LoggingMessageNotificationParam {
3087                                    level: log_event.level,
3088                                    logger: Some(log_event.logger),
3089                                    data: log_event.data,
3090                                }),
3091                            );
3092                            if let Err(e) = peer.send_notification(notification).await {
3093                                warn!("Failed to send logging notification: {}", e);
3094                            }
3095                        }
3096                    }
3097                }
3098            }
3099        });
3100    }
3101
3102    #[instrument(skip(self, _context))]
3103    async fn on_cancelled(
3104        &self,
3105        notification: CancelledNotificationParam,
3106        _context: NotificationContext<RoleServer>,
3107    ) {
3108        tracing::info!(
3109            request_id = ?notification.request_id,
3110            reason = ?notification.reason,
3111            "Received cancellation notification"
3112        );
3113    }
3114
3115    #[instrument(skip(self, _context))]
3116    async fn complete(
3117        &self,
3118        request: CompleteRequestParams,
3119        _context: RequestContext<RoleServer>,
3120    ) -> Result<CompleteResult, ErrorData> {
3121        // Dispatch on argument name: "path" or "symbol"
3122        let argument_name = &request.argument.name;
3123        let argument_value = &request.argument.value;
3124
3125        let completions = match argument_name.as_str() {
3126            "path" => {
3127                // Path completions: use current directory as root
3128                let root = Path::new(".");
3129                completion::path_completions(root, argument_value)
3130            }
3131            "symbol" => {
3132                // Symbol completions: need the path argument from context
3133                let path_arg = request
3134                    .context
3135                    .as_ref()
3136                    .and_then(|ctx| ctx.get_argument("path"));
3137
3138                match path_arg {
3139                    Some(path_str) => {
3140                        let path = Path::new(path_str);
3141                        completion::symbol_completions(&self.cache, path, argument_value)
3142                    }
3143                    None => Vec::new(),
3144                }
3145            }
3146            _ => Vec::new(),
3147        };
3148
3149        // Create CompletionInfo with has_more flag if >100 results
3150        let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
3151        let (values, has_more) = if completions.len() > 100 {
3152            (completions.into_iter().take(100).collect(), true)
3153        } else {
3154            (completions, false)
3155        };
3156
3157        let completion_info =
3158            match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
3159                Ok(info) => info,
3160                Err(_) => {
3161                    // Graceful degradation: return empty on error
3162                    CompletionInfo::with_all_values(Vec::new())
3163                        .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
3164                }
3165            };
3166
3167        Ok(CompleteResult::new(completion_info))
3168    }
3169
3170    async fn set_level(
3171        &self,
3172        params: SetLevelRequestParams,
3173        _context: RequestContext<RoleServer>,
3174    ) -> Result<(), ErrorData> {
3175        let level_filter = match params.level {
3176            LoggingLevel::Debug => LevelFilter::DEBUG,
3177            LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
3178            LoggingLevel::Warning => LevelFilter::WARN,
3179            LoggingLevel::Error
3180            | LoggingLevel::Critical
3181            | LoggingLevel::Alert
3182            | LoggingLevel::Emergency => LevelFilter::ERROR,
3183        };
3184
3185        let mut filter_lock = self
3186            .log_level_filter
3187            .lock()
3188            .unwrap_or_else(|e| e.into_inner());
3189        *filter_lock = level_filter;
3190        Ok(())
3191    }
3192}
3193
3194#[cfg(test)]
3195mod tests {
3196    use super::*;
3197
3198    #[tokio::test]
3199    async fn test_emit_progress_none_peer_is_noop() {
3200        let peer = Arc::new(TokioMutex::new(None));
3201        let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3202        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3203        let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3204        let analyzer = CodeAnalyzer::new(
3205            peer,
3206            log_level_filter,
3207            rx,
3208            crate::metrics::MetricsSender(metrics_tx),
3209        );
3210        let token = ProgressToken(NumberOrString::String("test".into()));
3211        // Should complete without panic
3212        analyzer
3213            .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
3214            .await;
3215    }
3216
3217    fn make_analyzer() -> CodeAnalyzer {
3218        let peer = Arc::new(TokioMutex::new(None));
3219        let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3220        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3221        let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3222        CodeAnalyzer::new(
3223            peer,
3224            log_level_filter,
3225            rx,
3226            crate::metrics::MetricsSender(metrics_tx),
3227        )
3228    }
3229
3230    #[test]
3231    fn test_summary_cursor_conflict() {
3232        assert!(summary_cursor_conflict(Some(true), Some("cursor")));
3233        assert!(!summary_cursor_conflict(Some(true), None));
3234        assert!(!summary_cursor_conflict(None, Some("x")));
3235        assert!(!summary_cursor_conflict(None, None));
3236    }
3237
3238    #[tokio::test]
3239    async fn test_validate_impl_only_non_rust_returns_invalid_params() {
3240        use tempfile::TempDir;
3241
3242        let dir = TempDir::new().unwrap();
3243        std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
3244
3245        let analyzer = make_analyzer();
3246        // Call analyze_symbol with impl_only=true on a Python-only directory via the tool API.
3247        // We use handle_focused_mode which calls validate_impl_only internally.
3248        let entries: Vec<traversal::WalkEntry> =
3249            traversal::walk_directory(dir.path(), None).unwrap_or_default();
3250        let result = CodeAnalyzer::validate_impl_only(&entries);
3251        assert!(result.is_err());
3252        let err = result.unwrap_err();
3253        assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
3254        drop(analyzer); // ensure it compiles with analyzer in scope
3255    }
3256
3257    #[tokio::test]
3258    async fn test_no_cache_meta_on_analyze_directory_result() {
3259        use aptu_coder_core::types::{
3260            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3261        };
3262        use tempfile::TempDir;
3263
3264        let dir = TempDir::new().unwrap();
3265        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
3266
3267        let analyzer = make_analyzer();
3268        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3269            "path": dir.path().to_str().unwrap(),
3270        }))
3271        .unwrap();
3272        let ct = tokio_util::sync::CancellationToken::new();
3273        let (arc_output, _cache_hit) = analyzer.handle_overview_mode(&params, ct).await.unwrap();
3274        // Verify the no_cache_meta shape by constructing it directly and checking the shape
3275        let meta = no_cache_meta();
3276        assert_eq!(
3277            meta.0.get("cache_hint").and_then(|v| v.as_str()),
3278            Some("no-cache"),
3279        );
3280        drop(arc_output);
3281    }
3282
3283    #[test]
3284    fn test_complete_path_completions_returns_suggestions() {
3285        // Test the underlying completion function (same code path as complete()) directly
3286        // to avoid needing a constructed RequestContext<RoleServer>.
3287        // CARGO_MANIFEST_DIR is <workspace>/aptu-coder; parent is the workspace root,
3288        // which contains aptu-coder-core/ and aptu-coder/ matching the "aptu-" prefix.
3289        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
3290        let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
3291        let suggestions = completion::path_completions(workspace_root, "aptu-");
3292        assert!(
3293            !suggestions.is_empty(),
3294            "expected completions for prefix 'aptu-' in workspace root"
3295        );
3296    }
3297
3298    #[tokio::test]
3299    async fn test_handle_overview_mode_verbose_no_summary_block() {
3300        use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
3301        use aptu_coder_core::types::{
3302            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3303        };
3304        use tempfile::TempDir;
3305
3306        let tmp = TempDir::new().unwrap();
3307        std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
3308
3309        let peer = Arc::new(TokioMutex::new(None));
3310        let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3311        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3312        let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3313        let analyzer = CodeAnalyzer::new(
3314            peer,
3315            log_level_filter,
3316            rx,
3317            crate::metrics::MetricsSender(metrics_tx),
3318        );
3319
3320        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3321            "path": tmp.path().to_str().unwrap(),
3322            "verbose": true,
3323        }))
3324        .unwrap();
3325
3326        let ct = tokio_util::sync::CancellationToken::new();
3327        let (output, _cache_hit) = analyzer.handle_overview_mode(&params, ct).await.unwrap();
3328
3329        // Replicate the handler's formatting path (the fix site)
3330        let use_summary = output.formatted.len() > SIZE_LIMIT; // summary=None, force=None, small output
3331        let paginated =
3332            paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
3333        let verbose = true;
3334        let formatted = if !use_summary {
3335            format_structure_paginated(
3336                &paginated.items,
3337                paginated.total,
3338                params.max_depth,
3339                Some(std::path::Path::new(&params.path)),
3340                verbose,
3341            )
3342        } else {
3343            output.formatted.clone()
3344        };
3345
3346        // After the fix: verbose=true must not emit the SUMMARY: block
3347        assert!(
3348            !formatted.contains("SUMMARY:"),
3349            "verbose=true must not emit SUMMARY: block; got: {}",
3350            &formatted[..formatted.len().min(300)]
3351        );
3352        assert!(
3353            formatted.contains("PAGINATED:"),
3354            "verbose=true must emit PAGINATED: header"
3355        );
3356        assert!(
3357            formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
3358            "verbose=true must emit FILES section header"
3359        );
3360    }
3361
3362    // --- cache_hit integration tests ---
3363
3364    #[tokio::test]
3365    async fn test_analyze_directory_cache_hit_metrics() {
3366        use aptu_coder_core::types::{
3367            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3368        };
3369        use tempfile::TempDir;
3370
3371        // Arrange: a temp dir with one file
3372        let dir = TempDir::new().unwrap();
3373        std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
3374        let analyzer = make_analyzer();
3375        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3376            "path": dir.path().to_str().unwrap(),
3377        }))
3378        .unwrap();
3379
3380        // Act: first call (cache miss)
3381        let ct1 = tokio_util::sync::CancellationToken::new();
3382        let (_out1, hit1) = analyzer.handle_overview_mode(&params, ct1).await.unwrap();
3383
3384        // Act: second call (cache hit)
3385        let ct2 = tokio_util::sync::CancellationToken::new();
3386        let (_out2, hit2) = analyzer.handle_overview_mode(&params, ct2).await.unwrap();
3387
3388        // Assert
3389        assert!(!hit1, "first call must be a cache miss");
3390        assert!(hit2, "second call must be a cache hit");
3391    }
3392
3393    #[tokio::test]
3394    async fn test_analyze_module_cache_hit_metrics() {
3395        use std::io::Write as _;
3396        use tempfile::NamedTempFile;
3397
3398        // Arrange: create a temp Rust file; prime the file cache via analyze_file handler
3399        let mut f = NamedTempFile::with_suffix(".rs").unwrap();
3400        writeln!(f, "fn bar() {{}}").unwrap();
3401        let path = f.path().to_str().unwrap().to_string();
3402
3403        let analyzer = make_analyzer();
3404
3405        // Prime the file cache by calling handle_file_details_mode once
3406        let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
3407        file_params.path = path.clone();
3408        file_params.ast_recursion_limit = None;
3409        file_params.fields = None;
3410        file_params.pagination.cursor = None;
3411        file_params.pagination.page_size = None;
3412        file_params.output_control.summary = None;
3413        file_params.output_control.force = None;
3414        file_params.output_control.verbose = None;
3415        let (_cached, _) = analyzer
3416            .handle_file_details_mode(&file_params)
3417            .await
3418            .unwrap();
3419
3420        // Act: now call analyze_module; the cache key is mtime-based so same file = hit
3421        let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
3422        module_params.path = path.clone();
3423
3424        // Replicate the cache lookup the handler does (no public method; test via build path)
3425        let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
3426            meta.modified()
3427                .ok()
3428                .map(|mtime| aptu_coder_core::cache::CacheKey {
3429                    path: std::path::PathBuf::from(&path),
3430                    modified: mtime,
3431                    mode: aptu_coder_core::types::AnalysisMode::FileDetails,
3432                })
3433        });
3434        let cache_hit = module_cache_key
3435            .as_ref()
3436            .and_then(|k| analyzer.cache.get(k))
3437            .is_some();
3438
3439        // Assert: the file cache must have been populated by the earlier handle_file_details_mode call
3440        assert!(
3441            cache_hit,
3442            "analyze_module should find the file in the shared file cache"
3443        );
3444        drop(module_params);
3445    }
3446
3447    // --- import_lookup tests ---
3448
3449    #[test]
3450    fn test_analyze_symbol_import_lookup_invalid_params() {
3451        // Arrange: empty symbol with import_lookup=true (violates the guard:
3452        // symbol must hold the module path when import_lookup=true).
3453        // Act: call the validate helper directly (same pattern as validate_impl_only).
3454        let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
3455
3456        // Assert: INVALID_PARAMS is returned.
3457        assert!(
3458            result.is_err(),
3459            "import_lookup=true with empty symbol must return Err"
3460        );
3461        let err = result.unwrap_err();
3462        assert_eq!(
3463            err.code,
3464            rmcp::model::ErrorCode::INVALID_PARAMS,
3465            "expected INVALID_PARAMS; got {:?}",
3466            err.code
3467        );
3468    }
3469
3470    #[tokio::test]
3471    async fn test_analyze_symbol_import_lookup_found() {
3472        use tempfile::TempDir;
3473
3474        // Arrange: a Rust file that imports "std::collections"
3475        let dir = TempDir::new().unwrap();
3476        std::fs::write(
3477            dir.path().join("main.rs"),
3478            "use std::collections::HashMap;\nfn main() {}\n",
3479        )
3480        .unwrap();
3481
3482        let entries = traversal::walk_directory(dir.path(), None).unwrap();
3483
3484        // Act: search for the module "std::collections"
3485        let output =
3486            analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
3487
3488        // Assert: one match found
3489        assert!(
3490            output.formatted.contains("MATCHES: 1"),
3491            "expected 1 match; got: {}",
3492            output.formatted
3493        );
3494        assert!(
3495            output.formatted.contains("main.rs"),
3496            "expected main.rs in output; got: {}",
3497            output.formatted
3498        );
3499    }
3500
3501    #[tokio::test]
3502    async fn test_analyze_symbol_import_lookup_empty() {
3503        use tempfile::TempDir;
3504
3505        // Arrange: a Rust file that does NOT import "no_such_module"
3506        let dir = TempDir::new().unwrap();
3507        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
3508
3509        let entries = traversal::walk_directory(dir.path(), None).unwrap();
3510
3511        // Act
3512        let output =
3513            analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
3514
3515        // Assert: zero matches
3516        assert!(
3517            output.formatted.contains("MATCHES: 0"),
3518            "expected 0 matches; got: {}",
3519            output.formatted
3520        );
3521    }
3522
3523    // --- git_ref tests ---
3524
3525    #[tokio::test]
3526    async fn test_analyze_directory_git_ref_non_git_repo() {
3527        use aptu_coder_core::traversal::changed_files_from_git_ref;
3528        use tempfile::TempDir;
3529
3530        // Arrange: a temp dir that is NOT a git repository
3531        let dir = TempDir::new().unwrap();
3532        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
3533
3534        // Act: attempt git_ref resolution in a non-git dir
3535        let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
3536
3537        // Assert: must return a GitError
3538        assert!(result.is_err(), "non-git dir must return an error");
3539        let err_msg = result.unwrap_err().to_string();
3540        assert!(
3541            err_msg.contains("git"),
3542            "error must mention git; got: {err_msg}"
3543        );
3544    }
3545
3546    #[tokio::test]
3547    async fn test_analyze_directory_git_ref_filters_changed_files() {
3548        use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
3549        use std::collections::HashSet;
3550        use tempfile::TempDir;
3551
3552        // Arrange: build a set of fake "changed" paths and a walk entry list
3553        let dir = TempDir::new().unwrap();
3554        let changed_file = dir.path().join("changed.rs");
3555        let unchanged_file = dir.path().join("unchanged.rs");
3556        std::fs::write(&changed_file, "fn changed() {}").unwrap();
3557        std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
3558
3559        let entries = traversal::walk_directory(dir.path(), None).unwrap();
3560        let total_files = entries.iter().filter(|e| !e.is_dir).count();
3561        assert_eq!(total_files, 2, "sanity: 2 files before filtering");
3562
3563        // Simulate: only changed.rs is in the changed set
3564        let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
3565        changed.insert(changed_file.clone());
3566
3567        // Act: filter entries
3568        let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
3569        let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
3570
3571        // Assert: only changed.rs remains
3572        assert_eq!(
3573            filtered_files.len(),
3574            1,
3575            "only 1 file must remain after git_ref filter"
3576        );
3577        assert_eq!(
3578            filtered_files[0].path, changed_file,
3579            "the remaining file must be the changed one"
3580        );
3581
3582        // Verify changed_files_from_git_ref is at least callable (tested separately for non-git error)
3583        let _ = changed_files_from_git_ref;
3584    }
3585
3586    #[tokio::test]
3587    async fn test_handle_overview_mode_git_ref_filters_via_handler() {
3588        use aptu_coder_core::types::{
3589            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3590        };
3591        use std::process::Command;
3592        use tempfile::TempDir;
3593
3594        // Arrange: create a real git repo with two commits.
3595        let dir = TempDir::new().unwrap();
3596        let repo = dir.path();
3597
3598        // Init repo and configure minimal identity so git commit works.
3599        // Use no-hooks to avoid project-local commit hooks that enforce email allowlists.
3600        let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
3601            let mut cmd = std::process::Command::new("git");
3602            cmd.args(["-c", "core.hooksPath=/dev/null"]);
3603            cmd.args(args);
3604            cmd.current_dir(repo_path);
3605            let out = cmd.output().unwrap();
3606            assert!(out.status.success(), "{out:?}");
3607        };
3608        git_no_hook(repo, &["init"]);
3609        git_no_hook(
3610            repo,
3611            &[
3612                "-c",
3613                "user.email=ci@example.com",
3614                "-c",
3615                "user.name=CI",
3616                "commit",
3617                "--allow-empty",
3618                "-m",
3619                "initial",
3620            ],
3621        );
3622
3623        // Commit file_a.rs in the first commit.
3624        std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
3625        git_no_hook(repo, &["add", "file_a.rs"]);
3626        git_no_hook(
3627            repo,
3628            &[
3629                "-c",
3630                "user.email=ci@example.com",
3631                "-c",
3632                "user.name=CI",
3633                "commit",
3634                "-m",
3635                "add a",
3636            ],
3637        );
3638
3639        // Add file_b.rs in a second commit (this is what HEAD changes relative to HEAD~1).
3640        std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
3641        git_no_hook(repo, &["add", "file_b.rs"]);
3642        git_no_hook(
3643            repo,
3644            &[
3645                "-c",
3646                "user.email=ci@example.com",
3647                "-c",
3648                "user.name=CI",
3649                "commit",
3650                "-m",
3651                "add b",
3652            ],
3653        );
3654
3655        // Act: call handle_overview_mode with git_ref=HEAD~1.
3656        // `git diff --name-only HEAD~1` compares working tree against HEAD~1, returning
3657        // only file_b.rs (added in the last commit, so present in working tree but not in HEAD~1).
3658        // Use the canonical path so walk entries match what `git rev-parse --show-toplevel` returns
3659        // (macOS /tmp is a symlink to /private/tmp; without canonicalization paths would differ).
3660        let canon_repo = std::fs::canonicalize(repo).unwrap();
3661        let analyzer = make_analyzer();
3662        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3663            "path": canon_repo.to_str().unwrap(),
3664            "git_ref": "HEAD~1",
3665        }))
3666        .unwrap();
3667        let ct = tokio_util::sync::CancellationToken::new();
3668        let (arc_output, _cache_hit) = analyzer
3669            .handle_overview_mode(&params, ct)
3670            .await
3671            .expect("handle_overview_mode with git_ref must succeed");
3672
3673        // Assert: only file_b.rs (changed since HEAD~1) appears; file_a.rs must be absent.
3674        let formatted = &arc_output.formatted;
3675        assert!(
3676            formatted.contains("file_b.rs"),
3677            "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
3678        );
3679        assert!(
3680            !formatted.contains("file_a.rs"),
3681            "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
3682        );
3683    }
3684
3685    #[test]
3686    fn test_validate_path_rejects_absolute_path_outside_cwd() {
3687        // S4: Verify that absolute paths outside the current working directory are rejected.
3688        // This test directly calls validate_path with /etc/passwd, which should fail.
3689        let result = validate_path("/etc/passwd", true);
3690        assert!(
3691            result.is_err(),
3692            "validate_path should reject /etc/passwd (outside CWD)"
3693        );
3694        let err = result.unwrap_err();
3695        let err_msg = err.message.to_lowercase();
3696        assert!(
3697            err_msg.contains("outside") || err_msg.contains("not found"),
3698            "Error message should mention 'outside' or 'not found': {}",
3699            err.message
3700        );
3701    }
3702
3703    #[test]
3704    fn test_validate_path_accepts_relative_path_in_cwd() {
3705        // Happy path: relative path within CWD should be accepted.
3706        // Use Cargo.toml which exists in the crate root.
3707        let result = validate_path("Cargo.toml", true);
3708        assert!(
3709            result.is_ok(),
3710            "validate_path should accept Cargo.toml (exists in CWD)"
3711        );
3712    }
3713
3714    #[test]
3715    fn test_validate_path_creates_parent_for_nonexistent_file() {
3716        // Edge case: non-existent file with non-existent parent should still be accepted
3717        // if the ancestor chain leads back to CWD.
3718        let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
3719        assert!(
3720            result.is_ok(),
3721            "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
3722        );
3723        let path = result.unwrap();
3724        let cwd = std::env::current_dir().expect("should get cwd");
3725        let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
3726        assert!(
3727            path.starts_with(&canonical_cwd),
3728            "Resolved path should be within CWD: {:?} should start with {:?}",
3729            path,
3730            canonical_cwd
3731        );
3732    }
3733}