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