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