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