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 nine MCP tools for multiple programming languages:
6//!
7//! **Analyze family:**
8//! - **`analyze_directory`**: Directory tree with file counts and structure
9//! - **`analyze_file`**: Semantic extraction (functions, classes, imports)
10//! - **`analyze_symbol`**: Call graph analysis (callers and callees)
11//! - **`analyze_module`**: Lightweight function and import index
12//!
13//! **Edit family:**
14//! - **`edit_overwrite`**: Create or overwrite files
15//! - **`edit_replace`**: Replace text blocks in files
16//!
17//! **Exec family:**
18//! - **`exec_command`**: Run shell commands with progress notifications
19//!
20//! **Remote family (from aptu-coder-remote):**
21//! - **`remote_tree`**: Explore remote GitHub/GitLab repositories without cloning
22//! - **`remote_file`**: Fetch files from remote repositories with optional line ranges
23//!
24//! Key entry points:
25//! - [`analyze::analyze_directory`]: Analyze entire directory tree
26//! - [`analyze::analyze_file`]: Analyze single file
27//!
28//! Languages supported: Rust, Go, Java, Python, TypeScript, TSX, Fortran, JavaScript, C/C++, C#.
29
30pub mod logging;
31pub mod metrics;
32pub mod otel;
33
34pub use aptu_coder_core::analyze;
35use aptu_coder_core::types::STDIN_MAX_BYTES;
36use aptu_coder_core::{cache, completion, graph, traversal, types};
37
38pub(crate) const EXCLUDED_DIRS: &[&str] = &[
39    "node_modules",
40    "vendor",
41    ".git",
42    "__pycache__",
43    "target",
44    "dist",
45    "build",
46    ".venv",
47];
48
49use aptu_coder_core::cache::{AnalysisCache, CacheTier};
50use aptu_coder_core::formatter::{
51    format_file_details_paginated, format_file_details_summary, format_focused_paginated,
52    format_module_info, format_structure_paginated, format_summary,
53};
54use aptu_coder_core::formatter_defuse::format_focused_paginated_defuse;
55use aptu_coder_core::pagination::{
56    CursorData, DEFAULT_PAGE_SIZE, PaginationMode, decode_cursor, encode_cursor, paginate_slice,
57};
58use aptu_coder_core::traversal::{
59    WalkEntry, changed_files_from_git_ref, filter_entries_by_git_ref, walk_directory,
60};
61use aptu_coder_core::types::{
62    AnalysisMode, AnalyzeDirectoryParams, AnalyzeFileParams, AnalyzeModuleParams,
63    AnalyzeSymbolParams, EditOverwriteOutput, EditOverwriteParams, EditReplaceOutput,
64    EditReplaceParams, SymbolMatchMode,
65};
66use logging::LogEvent;
67use rmcp::handler::server::tool::{ToolRouter, schema_for_type};
68use rmcp::handler::server::wrapper::Parameters;
69use rmcp::model::{
70    CallToolResult, CancelledNotificationParam, CompleteRequestParams, CompleteResult,
71    CompletionInfo, Content, ErrorData, Implementation, InitializeRequestParams, InitializeResult,
72    LoggingLevel, LoggingMessageNotificationParam, Meta, Notification, NumberOrString,
73    ProgressNotificationParam, ProgressToken, ServerCapabilities, ServerNotification,
74    SetLevelRequestParams,
75};
76use rmcp::service::{NotificationContext, RequestContext};
77use rmcp::{Peer, RoleServer, ServerHandler, tool, tool_handler, tool_router};
78use serde_json::Value;
79use std::path::{Path, PathBuf};
80use std::sync::{Arc, Mutex};
81use tokio::sync::{Mutex as TokioMutex, RwLock, mpsc};
82use tracing::{instrument, warn};
83use tracing_subscriber::filter::LevelFilter;
84
85#[cfg(unix)]
86use nix::sys::resource::{Resource, setrlimit};
87
88static GLOBAL_SESSION_COUNTER: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
89
90const SIZE_LIMIT: usize = 50_000;
91
92/// Returns `true` when `summary=true` and a `cursor` are both provided, which is an invalid
93/// combination since summary mode and pagination are mutually exclusive.
94#[must_use]
95pub fn summary_cursor_conflict(summary: Option<bool>, cursor: Option<&str>) -> bool {
96    summary == Some(true) && cursor.is_some()
97}
98
99/// Session and client metadata recorded as span attributes on every tool call.
100pub struct ClientMetadata {
101    pub session_id: Option<String>,
102    pub client_name: Option<String>,
103    pub client_version: Option<String>,
104}
105
106/// Extract W3C Trace Context from MCP request _meta field and set as parent span context.
107///
108/// Attempts to extract traceparent and tracestate from the request's _meta field.
109/// If successful, calls `set_parent` on the current tracing span so the OTel layer
110/// re-parents it to the caller's trace. This must be called after the `#[instrument]`
111/// span has been entered (i.e., inside the function body) for `set_parent` to take effect.
112/// If extraction fails or _meta is absent, silently proceeds with root context (no panic).
113pub fn extract_and_set_trace_context(
114    meta: Option<&rmcp::model::Meta>,
115    client_meta: ClientMetadata,
116) {
117    use tracing_opentelemetry::OpenTelemetrySpanExt as _;
118
119    let span = tracing::Span::current();
120
121    // Record session and client attributes
122    if let Some(sid) = client_meta.session_id {
123        span.record("mcp.session.id", &sid);
124    }
125    if let Some(cn) = client_meta.client_name {
126        span.record("client.name", &cn);
127    }
128    if let Some(cv) = client_meta.client_version {
129        span.record("client.version", &cv);
130    }
131
132    // Extract agent-session-id from _meta if present (opportunistic; silent no-op if absent)
133    if let Some(asi_str) = meta.and_then(|m| m.0.get("agent-session-id").and_then(|v| v.as_str())) {
134        span.record("mcp.client.session.id", asi_str);
135    }
136
137    let Some(meta) = meta else { return };
138
139    let mut propagation_map = std::collections::HashMap::new();
140
141    // Extract traceparent if present
142    if let Some(traceparent) = meta.0.get("traceparent")
143        && let Some(tp_str) = traceparent.as_str()
144    {
145        propagation_map.insert("traceparent".to_string(), tp_str.to_string());
146    }
147
148    // Extract tracestate if present
149    if let Some(tracestate) = meta.0.get("tracestate")
150        && let Some(ts_str) = tracestate.as_str()
151    {
152        propagation_map.insert("tracestate".to_string(), ts_str.to_string());
153    }
154
155    // Only attempt extraction if we have at least traceparent
156    if propagation_map.is_empty() {
157        return;
158    }
159
160    // Extract context via the globally registered propagator (TraceContextPropagator by default)
161    let parent_cx = opentelemetry::global::get_text_map_propagator(|propagator| {
162        propagator.extract(&ExtractMap(&propagation_map))
163    });
164
165    // Re-parent the current tracing span (already entered via #[instrument]) to the
166    // extracted OTel context. set_parent is a no-op if the OTel layer is not installed.
167    let _ = span.set_parent(parent_cx);
168}
169
170/// Helper struct for W3C Trace Context extraction from HashMap
171struct ExtractMap<'a>(&'a std::collections::HashMap<String, String>);
172
173impl<'a> opentelemetry::propagation::Extractor for ExtractMap<'a> {
174    fn get(&self, key: &str) -> Option<&str> {
175        self.0.get(key).map(|s| s.as_str())
176    }
177
178    fn keys(&self) -> Vec<&str> {
179        self.0.keys().map(|k| k.as_str()).collect()
180    }
181}
182
183#[must_use]
184fn error_meta(
185    category: &'static str,
186    is_retryable: bool,
187    suggested_action: &'static str,
188) -> serde_json::Value {
189    serde_json::json!({
190        "errorCategory": category,
191        "isRetryable": is_retryable,
192        "suggestedAction": suggested_action,
193    })
194}
195
196#[must_use]
197fn err_to_tool_result(e: ErrorData) -> CallToolResult {
198    CallToolResult::error(vec![Content::text(e.message)])
199}
200
201fn err_to_tool_result_from_pagination(
202    e: aptu_coder_core::pagination::PaginationError,
203) -> CallToolResult {
204    let msg = format!("Pagination error: {}", e);
205    CallToolResult::error(vec![Content::text(msg)])
206}
207
208fn no_cache_meta() -> Meta {
209    let mut m = serde_json::Map::new();
210    m.insert(
211        "cache_hint".to_string(),
212        serde_json::Value::String("no-cache".to_string()),
213    );
214    Meta(m)
215}
216
217/// Validates that a path is within the current working directory.
218/// For `require_exists=true`, the path must exist and be canonicalizable.
219/// For `require_exists=false`, the parent directory must exist and be canonicalizable.
220fn validate_path(path: &str, require_exists: bool) -> Result<std::path::PathBuf, ErrorData> {
221    // Canonicalize the allowed root (CWD) to resolve symlinks
222    let allowed_root = std::fs::canonicalize(std::env::current_dir().map_err(|_| {
223        ErrorData::new(
224            rmcp::model::ErrorCode::INVALID_PARAMS,
225            "path is outside the allowed root".to_string(),
226            Some(error_meta(
227                "validation",
228                false,
229                "ensure the working directory is accessible",
230            )),
231        )
232    })?)
233    .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
234
235    let canonical_path = if require_exists {
236        std::fs::canonicalize(path).map_err(|e| {
237            let msg = match e.kind() {
238                std::io::ErrorKind::NotFound => format!("path not found: {path}"),
239                std::io::ErrorKind::PermissionDenied => format!("permission denied: {path}"),
240                _ => "path is outside the allowed root".to_string(),
241            };
242            ErrorData::new(
243                rmcp::model::ErrorCode::INVALID_PARAMS,
244                msg,
245                Some(error_meta(
246                    "validation",
247                    false,
248                    "provide a valid path within the working directory",
249                )),
250            )
251        })?
252    } else {
253        // For non-existent files (edit_overwrite), walk up the path until we find an existing ancestor
254        let p = std::path::Path::new(path);
255        let mut ancestor = p.to_path_buf();
256        let mut suffix = std::path::PathBuf::new();
257
258        loop {
259            if ancestor.exists() {
260                break;
261            }
262            if let Some(parent) = ancestor.parent() {
263                if let Some(file_name) = ancestor.file_name() {
264                    suffix = std::path::PathBuf::from(file_name).join(&suffix);
265                }
266                ancestor = parent.to_path_buf();
267            } else {
268                // No existing ancestor found — use allowed_root as anchor
269                ancestor = allowed_root.clone();
270                break;
271            }
272        }
273
274        let canonical_base =
275            std::fs::canonicalize(&ancestor).unwrap_or_else(|_| allowed_root.clone());
276        canonical_base.join(&suffix)
277    };
278
279    if !canonical_path.starts_with(&allowed_root) {
280        return Err(ErrorData::new(
281            rmcp::model::ErrorCode::INVALID_PARAMS,
282            "path is outside the allowed root".to_string(),
283            Some(error_meta(
284                "validation",
285                false,
286                "provide a path within the current working directory",
287            )),
288        ));
289    }
290
291    Ok(canonical_path)
292}
293
294/// Maps an io::Error to an ErrorData with kind-specific message and preserved context.
295fn io_error_to_path_error(
296    err: &std::io::Error,
297    path_context: &str,
298    suggested_action: &'static str,
299) -> ErrorData {
300    let msg = match err.kind() {
301        std::io::ErrorKind::NotFound => format!("{path_context} not found"),
302        std::io::ErrorKind::PermissionDenied => format!("permission denied: {path_context}"),
303        _ => format!("{path_context} is invalid"),
304    };
305    let mut meta = error_meta("validation", false, suggested_action);
306    // Preserve io::Error context in data field
307    if let Some(obj) = meta.as_object_mut() {
308        obj.insert(
309            "ioErrorKind".to_string(),
310            serde_json::json!(format!("{:?}", err.kind())),
311        );
312        obj.insert(
313            "ioErrorSource".to_string(),
314            serde_json::json!(err.to_string()),
315        );
316    }
317    ErrorData::new(rmcp::model::ErrorCode::INVALID_PARAMS, msg, Some(meta))
318}
319
320/// Validates a path relative to a working directory.
321/// The working_dir itself must be within the server CWD.
322/// The resolved path must also be within the working_dir.
323fn validate_path_in_dir(
324    path: &str,
325    require_exists: bool,
326    working_dir: &std::path::Path,
327) -> Result<std::path::PathBuf, ErrorData> {
328    // Canonicalize the working_dir to resolve symlinks
329    let canonical_working_dir = std::fs::canonicalize(working_dir).map_err(|e| {
330        io_error_to_path_error(&e, "working_dir", "provide a valid working directory")
331    })?;
332
333    // Verify working_dir is actually a directory
334    if !std::fs::metadata(&canonical_working_dir)
335        .map(|m| m.is_dir())
336        .unwrap_or(false)
337    {
338        return Err(ErrorData::new(
339            rmcp::model::ErrorCode::INVALID_PARAMS,
340            "working_dir must be a directory".to_string(),
341            Some(error_meta(
342                "validation",
343                false,
344                "provide a valid directory path",
345            )),
346        ));
347    }
348
349    // Verify working_dir is within the server CWD (same bounds check as validate_path)
350    let allowed_root = std::fs::canonicalize(std::env::current_dir().map_err(|_| {
351        ErrorData::new(
352            rmcp::model::ErrorCode::INVALID_PARAMS,
353            "path is outside the allowed root".to_string(),
354            Some(error_meta(
355                "validation",
356                false,
357                "ensure the working directory is accessible",
358            )),
359        )
360    })?)
361    .unwrap_or_else(|_| std::env::current_dir().unwrap_or_default());
362
363    if !canonical_working_dir.starts_with(&allowed_root) {
364        return Err(ErrorData::new(
365            rmcp::model::ErrorCode::INVALID_PARAMS,
366            "working_dir is outside the allowed root".to_string(),
367            Some(error_meta(
368                "validation",
369                false,
370                "provide a working directory within the current working directory",
371            )),
372        ));
373    }
374
375    // Now resolve the target path relative to working_dir
376    let canonical_path = if require_exists {
377        let target_path = canonical_working_dir.join(path);
378        std::fs::canonicalize(&target_path).map_err(|e| {
379            io_error_to_path_error(
380                &e,
381                path,
382                "provide a valid path within the working directory",
383            )
384        })?
385    } else {
386        // For non-existent files, walk up the path until we find an existing ancestor
387        let p = std::path::Path::new(path);
388        let mut ancestor = p.to_path_buf();
389        let mut suffix = std::path::PathBuf::new();
390
391        loop {
392            let full_path = canonical_working_dir.join(&ancestor);
393            if full_path.exists() {
394                break;
395            }
396            if let Some(parent) = ancestor.parent() {
397                if let Some(file_name) = ancestor.file_name() {
398                    suffix = std::path::PathBuf::from(file_name).join(&suffix);
399                }
400                ancestor = parent.to_path_buf();
401            } else {
402                // No existing ancestor found — use working_dir as anchor
403                ancestor = std::path::PathBuf::new();
404                break;
405            }
406        }
407
408        let canonical_base = canonical_working_dir.join(&ancestor);
409        let canonical_base =
410            std::fs::canonicalize(&canonical_base).unwrap_or(canonical_working_dir.clone());
411        canonical_base.join(&suffix)
412    };
413
414    // Verify the resolved path is within working_dir.
415    // PathBuf::starts_with compares path *components*, not raw bytes, so
416    // a sibling directory whose name shares our prefix (e.g. "/work_evil"
417    // when the allowed root is "/work") is correctly rejected -- this is
418    // the exact prefix-confusion vector exploited in CVE-2025-53110 against
419    // @modelcontextprotocol/server-filesystem.  Do not replace this check
420    // with a string-level prefix comparison.
421    if !canonical_path.starts_with(&canonical_working_dir) {
422        return Err(ErrorData::new(
423            rmcp::model::ErrorCode::INVALID_PARAMS,
424            "path is outside the working directory".to_string(),
425            Some(error_meta(
426                "validation",
427                false,
428                "provide a path within the working directory",
429            )),
430        ));
431    }
432
433    Ok(canonical_path)
434}
435
436/// Helper function for paginating focus chains (callers or callees).
437/// Returns (items, re-encoded_cursor_option).
438fn paginate_focus_chains(
439    chains: &[graph::InternalCallChain],
440    mode: PaginationMode,
441    offset: usize,
442    page_size: usize,
443) -> Result<(Vec<graph::InternalCallChain>, Option<String>), ErrorData> {
444    let paginated = paginate_slice(chains, offset, page_size, mode).map_err(|e| {
445        ErrorData::new(
446            rmcp::model::ErrorCode::INTERNAL_ERROR,
447            e.to_string(),
448            Some(error_meta("transient", true, "retry the request")),
449        )
450    })?;
451
452    if paginated.next_cursor.is_none() && offset == 0 {
453        return Ok((paginated.items, None));
454    }
455
456    let next = if let Some(raw_cursor) = paginated.next_cursor {
457        let decoded = decode_cursor(&raw_cursor).map_err(|e| {
458            ErrorData::new(
459                rmcp::model::ErrorCode::INVALID_PARAMS,
460                e.to_string(),
461                Some(error_meta("validation", false, "invalid cursor format")),
462            )
463        })?;
464        Some(
465            encode_cursor(&CursorData {
466                mode,
467                offset: decoded.offset,
468            })
469            .map_err(|e| {
470                ErrorData::new(
471                    rmcp::model::ErrorCode::INVALID_PARAMS,
472                    e.to_string(),
473                    Some(error_meta("validation", false, "invalid cursor format")),
474                )
475            })?,
476        )
477    } else {
478        None
479    };
480
481    Ok((paginated.items, next))
482}
483
484/// Resolve the preferred shell for command execution.
485/// Priority: APTU_SHELL env var > bash (PATH search) > /bin/sh (unix) / cmd (windows).
486/// APTU_SHELL is honored on all platforms so callers can override the shell uniformly.
487fn resolve_shell() -> String {
488    if let Ok(shell) = std::env::var("APTU_SHELL") {
489        return shell;
490    }
491    #[cfg(unix)]
492    {
493        if which::which("bash").is_ok() {
494            return "bash".to_string();
495        }
496        "/bin/sh".to_string()
497    }
498    #[cfg(not(unix))]
499    {
500        "cmd".to_string()
501    }
502}
503
504/// MCP server handler that wires the four analysis tools to the rmcp transport.
505///
506/// Holds shared state: tool router, analysis cache, peer connection, log-level filter,
507/// log event channel, metrics sender, and per-session sequence tracking.
508#[derive(Clone)]
509pub struct CodeAnalyzer {
510    // Wrapped in Arc<RwLock> to enable interior mutability for profile-based tool routing.
511    // All clones share the same router instance (per-session state).
512    // Read lock acquired by list_tools/call_tool; write lock acquired during on_initialized
513    // to disable tools based on client profile.
514    // IMPORTANT: Do not perform long-running I/O while holding the write lock in
515    // on_initialized. The write lock blocks all concurrent list_tools/call_tool calls
516    // for the duration. Keep the critical section to disable_route() calls only.
517    #[allow(dead_code)]
518    pub(crate) tool_router: Arc<RwLock<ToolRouter<Self>>>,
519    cache: AnalysisCache,
520    disk_cache: std::sync::Arc<cache::DiskCache>,
521    peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
522    log_level_filter: Arc<Mutex<LevelFilter>>,
523    event_rx: Arc<TokioMutex<Option<mpsc::UnboundedReceiver<LogEvent>>>>,
524    metrics_tx: crate::metrics::MetricsSender,
525    session_call_seq: Arc<std::sync::atomic::AtomicU32>,
526    session_id: Arc<TokioMutex<Option<String>>>,
527    // Store profile metadata from initialize request for use in on_initialized
528    profile_meta: Arc<TokioMutex<Option<serde_json::Map<String, serde_json::Value>>>>,
529    client_name: Arc<TokioMutex<Option<String>>>,
530    client_version: Arc<TokioMutex<Option<String>>>,
531}
532
533#[tool_router]
534impl CodeAnalyzer {
535    #[must_use]
536    pub fn list_tools() -> Vec<rmcp::model::Tool> {
537        Self::tool_router().list_all()
538    }
539
540    pub fn new(
541        peer: Arc<TokioMutex<Option<Peer<RoleServer>>>>,
542        log_level_filter: Arc<Mutex<LevelFilter>>,
543        event_rx: mpsc::UnboundedReceiver<LogEvent>,
544        metrics_tx: crate::metrics::MetricsSender,
545    ) -> Self {
546        let file_cap: usize = std::env::var("APTU_CODER_FILE_CACHE_CAPACITY")
547            .ok()
548            .and_then(|v| v.parse().ok())
549            .unwrap_or(100);
550
551        // Initialize disk cache
552        let xdg_data_home = if let Ok(xdg_data_home) = std::env::var("XDG_DATA_HOME")
553            && !xdg_data_home.is_empty()
554        {
555            std::path::PathBuf::from(xdg_data_home)
556        } else if let Ok(home) = std::env::var("HOME") {
557            std::path::PathBuf::from(home).join(".local").join("share")
558        } else {
559            std::path::PathBuf::from(".")
560        };
561        let disk_cache_disabled = std::env::var("APTU_CODER_DISK_CACHE_DISABLED")
562            .map(|v| v == "1")
563            .unwrap_or(false);
564        let disk_cache_dir = std::env::var("APTU_CODER_DISK_CACHE_DIR")
565            .map(std::path::PathBuf::from)
566            .unwrap_or_else(|_| xdg_data_home.join("aptu-coder").join("analysis-cache"));
567        let disk_cache =
568            std::sync::Arc::new(cache::DiskCache::new(disk_cache_dir, disk_cache_disabled));
569
570        CodeAnalyzer {
571            tool_router: Arc::new(RwLock::new(Self::tool_router())),
572            cache: AnalysisCache::new(file_cap),
573            disk_cache,
574            peer,
575            log_level_filter,
576            event_rx: Arc::new(TokioMutex::new(Some(event_rx))),
577            metrics_tx,
578            session_call_seq: Arc::new(std::sync::atomic::AtomicU32::new(0)),
579            session_id: Arc::new(TokioMutex::new(None)),
580            profile_meta: Arc::new(TokioMutex::new(None)),
581            client_name: Arc::new(TokioMutex::new(None)),
582            client_version: Arc::new(TokioMutex::new(None)),
583        }
584    }
585
586    #[instrument(skip(self))]
587    async fn emit_progress(
588        &self,
589        peer: Option<Peer<RoleServer>>,
590        token: &ProgressToken,
591        progress: f64,
592        total: f64,
593        message: String,
594    ) {
595        if let Some(peer) = peer {
596            let notification = ServerNotification::ProgressNotification(Notification::new(
597                ProgressNotificationParam {
598                    progress_token: token.clone(),
599                    progress,
600                    total: Some(total),
601                    message: Some(message),
602                },
603            ));
604            if let Err(e) = peer.send_notification(notification).await {
605                warn!("Failed to send progress notification: {}", e);
606            }
607        }
608    }
609
610    /// Private helper: Extract analysis logic for overview mode (`analyze_directory`).
611    /// Returns the complete analysis output and a cache_hit bool after spawning and monitoring progress.
612    /// Cancels the blocking task when `ct` is triggered; returns an error on cancellation.
613    #[allow(clippy::too_many_lines)] // long but cohesive analysis loop; extracting sub-functions would obscure the control flow
614    #[allow(clippy::cast_precision_loss)] // progress percentage display; precision loss acceptable for usize counts
615    #[instrument(skip(self, params, ct))]
616    async fn handle_overview_mode(
617        &self,
618        params: &AnalyzeDirectoryParams,
619        ct: tokio_util::sync::CancellationToken,
620    ) -> Result<(std::sync::Arc<analyze::AnalysisOutput>, CacheTier), ErrorData> {
621        let path = Path::new(&params.path);
622        let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
623        let counter_clone = counter.clone();
624        let path_owned = path.to_path_buf();
625        let max_depth = params.max_depth;
626        let ct_clone = ct.clone();
627
628        // Single unbounded walk; filter in-memory to respect max_depth for analysis.
629        let all_entries = walk_directory(path, None).map_err(|e| {
630            ErrorData::new(
631                rmcp::model::ErrorCode::INTERNAL_ERROR,
632                format!("Failed to walk directory: {e}"),
633                Some(error_meta(
634                    "resource",
635                    false,
636                    "check path permissions and availability",
637                )),
638            )
639        })?;
640
641        // Canonicalize max_depth: Some(0) is semantically identical to None (unlimited).
642        let canonical_max_depth = max_depth.and_then(|d| if d == 0 { None } else { Some(d) });
643
644        // Build cache key from all_entries (before depth filtering).
645        // git_ref is included in the key so filtered and unfiltered results have distinct entries.
646        let git_ref_val = params.git_ref.as_deref().filter(|s| !s.is_empty());
647        let cache_key = cache::DirectoryCacheKey::from_entries(
648            &all_entries,
649            canonical_max_depth,
650            AnalysisMode::Overview,
651            git_ref_val,
652        );
653
654        // Check L1 cache
655        if let Some(cached) = self.cache.get_directory(&cache_key) {
656            tracing::debug!(cache_hit = true, message = "returning cached result");
657            return Ok((cached, CacheTier::L1Memory));
658        }
659
660        // Compute disk cache key from canonical relative paths + mtime + params
661        let root = std::path::Path::new(&params.path);
662        let disk_key = {
663            let mut hasher = blake3::Hasher::new();
664            let mut sorted_entries: Vec<_> = all_entries.iter().collect();
665            sorted_entries.sort_by(|a, b| a.path.cmp(&b.path));
666            for entry in &sorted_entries {
667                let rel = entry.path.strip_prefix(root).unwrap_or(&entry.path);
668                hasher.update(rel.as_os_str().to_string_lossy().as_bytes());
669                let mtime_secs = entry
670                    .mtime
671                    .and_then(|m| m.duration_since(std::time::UNIX_EPOCH).ok())
672                    .map(|d| d.as_secs())
673                    .unwrap_or(0);
674                hasher.update(&mtime_secs.to_le_bytes());
675            }
676            if let Some(depth) = canonical_max_depth {
677                hasher.update(depth.to_string().as_bytes());
678            }
679            if let Some(ref git_ref) = params.git_ref {
680                hasher.update(git_ref.as_bytes());
681            }
682            hasher.finalize()
683        };
684
685        // Check L2 cache
686        if let Some(cached) = self
687            .disk_cache
688            .get::<analyze::AnalysisOutput>("analyze_directory", &disk_key)
689        {
690            let arc = std::sync::Arc::new(cached);
691            self.cache.put_directory(cache_key.clone(), arc.clone());
692            return Ok((arc, CacheTier::L2Disk));
693        }
694
695        // Apply git_ref filter when requested (non-empty string only).
696        let all_entries = if let Some(ref git_ref) = params.git_ref
697            && !git_ref.is_empty()
698        {
699            let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
700                ErrorData::new(
701                    rmcp::model::ErrorCode::INVALID_PARAMS,
702                    format!("git_ref filter failed: {e}"),
703                    Some(error_meta(
704                        "resource",
705                        false,
706                        "ensure git is installed and path is inside a git repository",
707                    )),
708                )
709            })?;
710            filter_entries_by_git_ref(all_entries, &changed, path)
711        } else {
712            all_entries
713        };
714
715        // Compute subtree counts from the full entry set before filtering.
716        let subtree_counts = if max_depth.is_some_and(|d| d > 0) {
717            Some(traversal::subtree_counts_from_entries(path, &all_entries))
718        } else {
719            None
720        };
721
722        // Filter to depth-bounded subset for analysis.
723        let entries: Vec<traversal::WalkEntry> = if let Some(depth) = max_depth
724            && depth > 0
725        {
726            all_entries
727                .into_iter()
728                .filter(|e| e.depth <= depth as usize)
729                .collect()
730        } else {
731            all_entries
732        };
733
734        // Get total file count for progress reporting
735        let total_files = entries.iter().filter(|e| !e.is_dir).count();
736
737        // Spawn blocking analysis with progress tracking
738        let handle = tokio::task::spawn_blocking(move || {
739            analyze::analyze_directory_with_progress(&path_owned, entries, counter_clone, ct_clone)
740        });
741
742        // Poll and emit progress every 100ms
743        let token = ProgressToken(NumberOrString::String(
744            format!(
745                "analyze-overview-{}",
746                std::time::SystemTime::now()
747                    .duration_since(std::time::UNIX_EPOCH)
748                    .map(|d| d.as_nanos())
749                    .unwrap_or(0)
750            )
751            .into(),
752        ));
753        let peer = self.peer.lock().await.clone();
754        let mut last_progress = 0usize;
755        let mut cancelled = false;
756        loop {
757            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
758            if ct.is_cancelled() {
759                cancelled = true;
760                break;
761            }
762            let current = counter.load(std::sync::atomic::Ordering::Relaxed);
763            if current != last_progress && total_files > 0 {
764                self.emit_progress(
765                    peer.clone(),
766                    &token,
767                    current as f64,
768                    total_files as f64,
769                    format!("Analyzing {current}/{total_files} files"),
770                )
771                .await;
772                last_progress = current;
773            }
774            if handle.is_finished() {
775                break;
776            }
777        }
778
779        // Emit final 100% progress only if not cancelled
780        if !cancelled && total_files > 0 {
781            self.emit_progress(
782                peer.clone(),
783                &token,
784                total_files as f64,
785                total_files as f64,
786                format!("Completed analyzing {total_files} files"),
787            )
788            .await;
789        }
790
791        match handle.await {
792            Ok(Ok(mut output)) => {
793                output.subtree_counts = subtree_counts;
794                let arc_output = std::sync::Arc::new(output);
795                self.cache.put_directory(cache_key, arc_output.clone());
796                // Spawn L2 write-behind; drain failure counter after write completes.
797                {
798                    let dc = self.disk_cache.clone();
799                    let k = disk_key;
800                    let v = arc_output.as_ref().clone();
801                    let handle = tokio::task::spawn_blocking(move || {
802                        dc.put("analyze_directory", &k, &v);
803                        dc.drain_write_failures()
804                    });
805                    let metrics_tx = self.metrics_tx.clone();
806                    let sid = self.session_id.lock().await.clone();
807                    tokio::spawn(async move {
808                        if let Ok(failures) = handle.await
809                            && failures > 0
810                        {
811                            tracing::warn!(
812                                tool = "analyze_directory",
813                                failures,
814                                "L2 disk cache write failed"
815                            );
816                            metrics_tx.send(crate::metrics::MetricEvent {
817                                ts: crate::metrics::unix_ms(),
818                                tool: "analyze_directory",
819                                duration_ms: 0,
820                                output_chars: 0,
821                                param_path_depth: 0,
822                                max_depth: None,
823                                result: "ok",
824                                error_type: None,
825                                session_id: sid,
826                                seq: None,
827                                cache_hit: None,
828                                cache_write_failure: Some(true),
829                                cache_tier: None,
830                                exit_code: None,
831                                timed_out: false,
832                            });
833                        }
834                    });
835                }
836                Ok((arc_output, CacheTier::Miss))
837            }
838            Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
839                rmcp::model::ErrorCode::INTERNAL_ERROR,
840                "Analysis cancelled".to_string(),
841                Some(error_meta("transient", true, "analysis was cancelled")),
842            )),
843            Ok(Err(e)) => Err(ErrorData::new(
844                rmcp::model::ErrorCode::INTERNAL_ERROR,
845                format!("Error analyzing directory: {e}"),
846                Some(error_meta(
847                    "resource",
848                    false,
849                    "check path and file permissions",
850                )),
851            )),
852            Err(e) => Err(ErrorData::new(
853                rmcp::model::ErrorCode::INTERNAL_ERROR,
854                format!("Task join error: {e}"),
855                Some(error_meta("transient", true, "retry the request")),
856            )),
857        }
858    }
859
860    /// Private helper: Extract analysis logic for file details mode (`analyze_file`).
861    /// Returns the cached or newly analyzed file output along with a CacheTier.
862    #[instrument(skip(self, params))]
863    async fn handle_file_details_mode(
864        &self,
865        params: &AnalyzeFileParams,
866    ) -> Result<(std::sync::Arc<analyze::FileAnalysisOutput>, CacheTier), ErrorData> {
867        // Build cache key from file metadata
868        let cache_key = std::fs::metadata(&params.path).ok().and_then(|meta| {
869            meta.modified().ok().map(|mtime| cache::CacheKey {
870                path: std::path::PathBuf::from(&params.path),
871                modified: mtime,
872                mode: AnalysisMode::FileDetails,
873            })
874        });
875
876        // Check L1 cache first
877        if let Some(ref key) = cache_key
878            && let Some(cached) = self.cache.get(key)
879        {
880            tracing::debug!(cache_hit = true, message = "returning cached result");
881            return Ok((cached, CacheTier::L1Memory));
882        }
883
884        // Compute disk cache key from file content
885        let file_bytes = std::fs::read(&params.path).unwrap_or_default();
886        let disk_key = blake3::hash(&file_bytes);
887
888        // Check L2 cache
889        if let Some(cached) = self
890            .disk_cache
891            .get::<analyze::FileAnalysisOutput>("analyze_file", &disk_key)
892        {
893            let arc = std::sync::Arc::new(cached);
894            if let Some(ref key) = cache_key {
895                self.cache.put(key.clone(), arc.clone());
896            }
897            return Ok((arc, CacheTier::L2Disk));
898        }
899
900        // Cache miss or no cache key, analyze and optionally store
901        match analyze::analyze_file(&params.path, params.ast_recursion_limit) {
902            Ok(output) => {
903                let arc_output = std::sync::Arc::new(output);
904                if let Some(key) = cache_key {
905                    self.cache.put(key, arc_output.clone());
906                }
907                // Spawn L2 write-behind; drain failure counter after write completes.
908                {
909                    let dc = self.disk_cache.clone();
910                    let k = disk_key;
911                    let v = arc_output.as_ref().clone();
912                    let handle = tokio::task::spawn_blocking(move || {
913                        dc.put("analyze_file", &k, &v);
914                        dc.drain_write_failures()
915                    });
916                    let metrics_tx = self.metrics_tx.clone();
917                    let sid = self.session_id.lock().await.clone();
918                    tokio::spawn(async move {
919                        if let Ok(failures) = handle.await
920                            && failures > 0
921                        {
922                            tracing::warn!(
923                                tool = "analyze_file",
924                                failures,
925                                "L2 disk cache write failed"
926                            );
927                            metrics_tx.send(crate::metrics::MetricEvent {
928                                ts: crate::metrics::unix_ms(),
929                                tool: "analyze_file",
930                                duration_ms: 0,
931                                output_chars: 0,
932                                param_path_depth: 0,
933                                max_depth: None,
934                                result: "ok",
935                                error_type: None,
936                                session_id: sid,
937                                seq: None,
938                                cache_hit: None,
939                                cache_write_failure: Some(true),
940                                cache_tier: None,
941                                exit_code: None,
942                                timed_out: false,
943                            });
944                        }
945                    });
946                }
947                Ok((arc_output, CacheTier::Miss))
948            }
949            Err(e) => Err(ErrorData::new(
950                rmcp::model::ErrorCode::INTERNAL_ERROR,
951                format!("Error analyzing file: {e}"),
952                Some(error_meta(
953                    "resource",
954                    false,
955                    "check file path and permissions",
956                )),
957            )),
958        }
959    }
960
961    // Validate impl_only: only valid for directories that contain Rust source files.
962    fn validate_impl_only(entries: &[WalkEntry]) -> Result<(), ErrorData> {
963        let has_rust = entries.iter().any(|e| {
964            !e.is_dir
965                && e.path
966                    .extension()
967                    .and_then(|x: &std::ffi::OsStr| x.to_str())
968                    == Some("rs")
969        });
970
971        if !has_rust {
972            return Err(ErrorData::new(
973                rmcp::model::ErrorCode::INVALID_PARAMS,
974                "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(),
975                Some(error_meta(
976                    "validation",
977                    false,
978                    "remove impl_only or point to a directory containing .rs files",
979                )),
980            ));
981        }
982        Ok(())
983    }
984
985    /// Validate that `import_lookup=true` is accompanied by a non-empty symbol (the module path).
986    fn validate_import_lookup(import_lookup: Option<bool>, symbol: &str) -> Result<(), ErrorData> {
987        if import_lookup == Some(true) && symbol.is_empty() {
988            return Err(ErrorData::new(
989                rmcp::model::ErrorCode::INVALID_PARAMS,
990                "import_lookup=true requires symbol to contain the module path to search for"
991                    .to_string(),
992                Some(error_meta(
993                    "validation",
994                    false,
995                    "set symbol to the module path when using import_lookup=true",
996                )),
997            ));
998        }
999        Ok(())
1000    }
1001
1002    // Poll progress until analysis task completes.
1003    #[allow(clippy::cast_precision_loss)] // progress percentage display; precision loss acceptable for usize counts
1004    async fn poll_progress_until_done(
1005        &self,
1006        analysis_params: &FocusedAnalysisParams,
1007        counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1008        ct: tokio_util::sync::CancellationToken,
1009        entries: std::sync::Arc<Vec<WalkEntry>>,
1010        total_files: usize,
1011        symbol_display: &str,
1012    ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1013        let counter_clone = counter.clone();
1014        let ct_clone = ct.clone();
1015        let entries_clone = std::sync::Arc::clone(&entries);
1016        let path_owned = analysis_params.path.clone();
1017        let symbol_owned = analysis_params.symbol.clone();
1018        let match_mode_owned = analysis_params.match_mode.clone();
1019        let follow_depth = analysis_params.follow_depth;
1020        let max_depth = analysis_params.max_depth;
1021        let ast_recursion_limit = analysis_params.ast_recursion_limit;
1022        let use_summary = analysis_params.use_summary;
1023        let impl_only = analysis_params.impl_only;
1024        let def_use = analysis_params.def_use;
1025        let parse_timeout_micros = analysis_params.parse_timeout_micros;
1026        let handle = tokio::task::spawn_blocking(move || {
1027            let params = analyze::FocusedAnalysisConfig {
1028                focus: symbol_owned,
1029                match_mode: match_mode_owned,
1030                follow_depth,
1031                max_depth,
1032                ast_recursion_limit,
1033                use_summary,
1034                impl_only,
1035                def_use,
1036                parse_timeout_micros,
1037            };
1038            analyze::analyze_focused_with_progress_with_entries(
1039                &path_owned,
1040                &params,
1041                &counter_clone,
1042                &ct_clone,
1043                &entries_clone,
1044            )
1045        });
1046
1047        let token = ProgressToken(NumberOrString::String(
1048            format!(
1049                "analyze-symbol-{}",
1050                std::time::SystemTime::now()
1051                    .duration_since(std::time::UNIX_EPOCH)
1052                    .map(|d| d.as_nanos())
1053                    .unwrap_or(0)
1054            )
1055            .into(),
1056        ));
1057        let peer = self.peer.lock().await.clone();
1058        let mut last_progress = 0usize;
1059        let mut cancelled = false;
1060
1061        loop {
1062            tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1063            if ct.is_cancelled() {
1064                cancelled = true;
1065                break;
1066            }
1067            let current = counter.load(std::sync::atomic::Ordering::Relaxed);
1068            if current != last_progress && total_files > 0 {
1069                self.emit_progress(
1070                    peer.clone(),
1071                    &token,
1072                    current as f64,
1073                    total_files as f64,
1074                    format!(
1075                        "Analyzing {current}/{total_files} files for symbol '{symbol_display}'"
1076                    ),
1077                )
1078                .await;
1079                last_progress = current;
1080            }
1081            if handle.is_finished() {
1082                break;
1083            }
1084        }
1085
1086        if !cancelled && total_files > 0 {
1087            self.emit_progress(
1088                peer.clone(),
1089                &token,
1090                total_files as f64,
1091                total_files as f64,
1092                format!("Completed analyzing {total_files} files for symbol '{symbol_display}'"),
1093            )
1094            .await;
1095        }
1096
1097        match handle.await {
1098            Ok(Ok(output)) => Ok(output),
1099            Ok(Err(analyze::AnalyzeError::Cancelled)) => Err(ErrorData::new(
1100                rmcp::model::ErrorCode::INTERNAL_ERROR,
1101                "Analysis cancelled".to_string(),
1102                Some(error_meta("transient", true, "analysis was cancelled")),
1103            )),
1104            Ok(Err(e)) => Err(ErrorData::new(
1105                rmcp::model::ErrorCode::INTERNAL_ERROR,
1106                format!("Error analyzing symbol: {e}"),
1107                Some(error_meta("resource", false, "check symbol name and file")),
1108            )),
1109            Err(e) => Err(ErrorData::new(
1110                rmcp::model::ErrorCode::INTERNAL_ERROR,
1111                format!("Task join error: {e}"),
1112                Some(error_meta("transient", true, "retry the request")),
1113            )),
1114        }
1115    }
1116
1117    // Run focused analysis with auto-summary retry on SIZE_LIMIT overflow.
1118    async fn run_focused_with_auto_summary(
1119        &self,
1120        params: &AnalyzeSymbolParams,
1121        analysis_params: &FocusedAnalysisParams,
1122        counter: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1123        ct: tokio_util::sync::CancellationToken,
1124        entries: std::sync::Arc<Vec<WalkEntry>>,
1125        total_files: usize,
1126    ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1127        let use_summary_for_task = params.output_control.force != Some(true)
1128            && params.output_control.summary == Some(true);
1129
1130        let analysis_params_initial = FocusedAnalysisParams {
1131            use_summary: use_summary_for_task,
1132            ..analysis_params.clone()
1133        };
1134
1135        let mut output = self
1136            .poll_progress_until_done(
1137                &analysis_params_initial,
1138                counter.clone(),
1139                ct.clone(),
1140                entries.clone(),
1141                total_files,
1142                &params.symbol,
1143            )
1144            .await?;
1145
1146        if params.output_control.summary.is_none()
1147            && params.output_control.force != Some(true)
1148            && output.formatted.len() > SIZE_LIMIT
1149        {
1150            tracing::debug!(
1151                auto_summary = true,
1152                message = "output exceeded size limit, retrying with summary"
1153            );
1154            let counter2 = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1155            let analysis_params_retry = FocusedAnalysisParams {
1156                use_summary: true,
1157                ..analysis_params.clone()
1158            };
1159            let summary_result = self
1160                .poll_progress_until_done(
1161                    &analysis_params_retry,
1162                    counter2,
1163                    ct,
1164                    entries,
1165                    total_files,
1166                    &params.symbol,
1167                )
1168                .await;
1169
1170            if let Ok(summary_output) = summary_result {
1171                output.formatted = summary_output.formatted;
1172            } else {
1173                let estimated_tokens = output.formatted.len() / 4;
1174                let message = format!(
1175                    "Output exceeds 50K chars ({} chars, ~{} tokens). Use summary=true or force=true.",
1176                    output.formatted.len(),
1177                    estimated_tokens
1178                );
1179                return Err(ErrorData::new(
1180                    rmcp::model::ErrorCode::INVALID_PARAMS,
1181                    message,
1182                    Some(error_meta(
1183                        "validation",
1184                        false,
1185                        "use summary=true or force=true",
1186                    )),
1187                ));
1188            }
1189        } else if output.formatted.len() > SIZE_LIMIT
1190            && params.output_control.force != Some(true)
1191            && params.output_control.summary == Some(false)
1192        {
1193            let estimated_tokens = output.formatted.len() / 4;
1194            let message = format!(
1195                "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1196                 - force=true to return full output\n\
1197                 - summary=true to get compact summary\n\
1198                 - Narrow your scope (smaller directory, specific file)",
1199                output.formatted.len(),
1200                estimated_tokens
1201            );
1202            return Err(ErrorData::new(
1203                rmcp::model::ErrorCode::INVALID_PARAMS,
1204                message,
1205                Some(error_meta(
1206                    "validation",
1207                    false,
1208                    "use force=true, summary=true, or narrow scope",
1209                )),
1210            ));
1211        }
1212
1213        Ok(output)
1214    }
1215
1216    /// Private helper: Extract analysis logic for focused mode (`analyze_symbol`).
1217    /// Returns the complete focused analysis output after spawning and monitoring progress.
1218    /// Cancels the blocking task when `ct` is triggered; returns an error on cancellation.
1219    #[instrument(skip(self, params, ct))]
1220    async fn handle_focused_mode(
1221        &self,
1222        params: &AnalyzeSymbolParams,
1223        ct: tokio_util::sync::CancellationToken,
1224    ) -> Result<analyze::FocusedAnalysisOutput, ErrorData> {
1225        let path = Path::new(&params.path);
1226        let raw_entries = match walk_directory(path, params.max_depth) {
1227            Ok(e) => e,
1228            Err(e) => {
1229                return Err(ErrorData::new(
1230                    rmcp::model::ErrorCode::INTERNAL_ERROR,
1231                    format!("Failed to walk directory: {e}"),
1232                    Some(error_meta(
1233                        "resource",
1234                        false,
1235                        "check path permissions and availability",
1236                    )),
1237                ));
1238            }
1239        };
1240        // Apply git_ref filter when requested (non-empty string only).
1241        let filtered_entries = if let Some(ref git_ref) = params.git_ref
1242            && !git_ref.is_empty()
1243        {
1244            let changed = changed_files_from_git_ref(path, git_ref).map_err(|e| {
1245                ErrorData::new(
1246                    rmcp::model::ErrorCode::INVALID_PARAMS,
1247                    format!("git_ref filter failed: {e}"),
1248                    Some(error_meta(
1249                        "resource",
1250                        false,
1251                        "ensure git is installed and path is inside a git repository",
1252                    )),
1253                )
1254            })?;
1255            filter_entries_by_git_ref(raw_entries, &changed, path)
1256        } else {
1257            raw_entries
1258        };
1259        let entries = std::sync::Arc::new(filtered_entries);
1260
1261        if params.impl_only == Some(true) {
1262            Self::validate_impl_only(&entries)?;
1263        }
1264
1265        let total_files = entries.iter().filter(|e| !e.is_dir).count();
1266        let counter = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1267
1268        let analysis_params = FocusedAnalysisParams {
1269            path: path.to_path_buf(),
1270            symbol: params.symbol.clone(),
1271            match_mode: params.match_mode.clone().unwrap_or_default(),
1272            follow_depth: params.follow_depth.unwrap_or(1),
1273            max_depth: params.max_depth,
1274            ast_recursion_limit: params.ast_recursion_limit,
1275            use_summary: false,
1276            impl_only: params.impl_only,
1277            def_use: params.def_use.unwrap_or(false),
1278            parse_timeout_micros: None,
1279        };
1280
1281        let mut output = self
1282            .run_focused_with_auto_summary(
1283                params,
1284                &analysis_params,
1285                counter,
1286                ct,
1287                entries,
1288                total_files,
1289            )
1290            .await?;
1291
1292        if params.impl_only == Some(true) {
1293            let filter_line = format!(
1294                "FILTER: impl_only=true ({} of {} callers shown)\n",
1295                output.impl_trait_caller_count, output.unfiltered_caller_count
1296            );
1297            output.formatted = format!("{}{}", filter_line, output.formatted);
1298
1299            if output.impl_trait_caller_count == 0 {
1300                output.formatted.push_str(
1301                    "\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"
1302                );
1303            }
1304        }
1305
1306        Ok(output)
1307    }
1308
1309    #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
1310    #[tool(
1311        name = "analyze_directory",
1312        title = "Analyze Directory",
1313        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?",
1314        output_schema = schema_for_type::<analyze::AnalysisOutput>(),
1315        annotations(
1316            title = "Analyze Directory",
1317            read_only_hint = true,
1318            destructive_hint = false,
1319            idempotent_hint = true,
1320            open_world_hint = false
1321        )
1322    )]
1323    async fn analyze_directory(
1324        &self,
1325        params: Parameters<AnalyzeDirectoryParams>,
1326        context: RequestContext<RoleServer>,
1327    ) -> Result<CallToolResult, ErrorData> {
1328        let params = params.0;
1329        // Extract W3C Trace Context from request _meta if present
1330        let session_id = self.session_id.lock().await.clone();
1331        let client_name = self.client_name.lock().await.clone();
1332        let client_version = self.client_version.lock().await.clone();
1333        extract_and_set_trace_context(
1334            Some(&context.meta),
1335            ClientMetadata {
1336                session_id,
1337                client_name,
1338                client_version,
1339            },
1340        );
1341        let span = tracing::Span::current();
1342        span.record("gen_ai.system", "mcp");
1343        span.record("gen_ai.operation.name", "execute_tool");
1344        span.record("gen_ai.tool.name", "analyze_directory");
1345        span.record("path", &params.path);
1346        let _validated_path = match validate_path(&params.path, true) {
1347            Ok(p) => p,
1348            Err(e) => {
1349                span.record("error", true);
1350                span.record("error.type", "invalid_params");
1351                return Ok(err_to_tool_result(e));
1352            }
1353        };
1354        let ct = context.ct.clone();
1355        let t_start = std::time::Instant::now();
1356        let param_path = params.path.clone();
1357        let max_depth_val = params.max_depth;
1358        let seq = self
1359            .session_call_seq
1360            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1361        let sid = self.session_id.lock().await.clone();
1362
1363        // Call handler for analysis and progress tracking
1364        let (arc_output, dir_cache_hit) = match self.handle_overview_mode(&params, ct).await {
1365            Ok(v) => v,
1366            Err(e) => {
1367                span.record("error", true);
1368                span.record("error.type", "internal_error");
1369                return Ok(err_to_tool_result(e));
1370            }
1371        };
1372        // Extract the value from Arc for modification. On a cache hit the Arc is shared,
1373        // so try_unwrap may fail; fall back to cloning the underlying value in that case.
1374        let mut output = match std::sync::Arc::try_unwrap(arc_output) {
1375            Ok(owned) => owned,
1376            Err(arc) => (*arc).clone(),
1377        };
1378
1379        // summary=true (explicit) and cursor are mutually exclusive.
1380        // Auto-summarization (summary=None + large output) must NOT block cursor pagination.
1381        if summary_cursor_conflict(
1382            params.output_control.summary,
1383            params.pagination.cursor.as_deref(),
1384        ) {
1385            span.record("error", true);
1386            span.record("error.type", "invalid_params");
1387            return Ok(err_to_tool_result(ErrorData::new(
1388                rmcp::model::ErrorCode::INVALID_PARAMS,
1389                "summary=true is incompatible with a pagination cursor; use one or the other"
1390                    .to_string(),
1391                Some(error_meta(
1392                    "validation",
1393                    false,
1394                    "remove cursor or set summary=false",
1395                )),
1396            )));
1397        }
1398
1399        // Apply summary/output size limiting logic
1400        let use_summary = if params.output_control.force == Some(true) {
1401            false
1402        } else if params.output_control.summary == Some(true) {
1403            true
1404        } else if params.output_control.summary == Some(false) {
1405            false
1406        } else {
1407            output.formatted.len() > SIZE_LIMIT
1408        };
1409
1410        if use_summary {
1411            output.formatted = format_summary(
1412                &output.entries,
1413                &output.files,
1414                params.max_depth,
1415                output.subtree_counts.as_deref(),
1416            );
1417        }
1418
1419        // Decode pagination cursor if provided
1420        let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1421        let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1422            let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1423                ErrorData::new(
1424                    rmcp::model::ErrorCode::INVALID_PARAMS,
1425                    e.to_string(),
1426                    Some(error_meta("validation", false, "invalid cursor format")),
1427                )
1428            }) {
1429                Ok(v) => v,
1430                Err(e) => {
1431                    span.record("error", true);
1432                    span.record("error.type", "invalid_params");
1433                    return Ok(err_to_tool_result(e));
1434                }
1435            };
1436            cursor_data.offset
1437        } else {
1438            0
1439        };
1440
1441        // Apply pagination to files
1442        let paginated =
1443            match paginate_slice(&output.files, offset, page_size, PaginationMode::Default) {
1444                Ok(v) => v,
1445                Err(e) => {
1446                    span.record("error", true);
1447                    span.record("error.type", "internal_error");
1448                    return Ok(err_to_tool_result(ErrorData::new(
1449                        rmcp::model::ErrorCode::INTERNAL_ERROR,
1450                        e.to_string(),
1451                        Some(error_meta("transient", true, "retry the request")),
1452                    )));
1453                }
1454            };
1455
1456        let verbose = params.output_control.verbose.unwrap_or(false);
1457        if !use_summary {
1458            output.formatted = format_structure_paginated(
1459                &paginated.items,
1460                paginated.total,
1461                params.max_depth,
1462                Some(Path::new(&params.path)),
1463                verbose,
1464            );
1465        }
1466
1467        // Update next_cursor in output after pagination (unless using summary mode)
1468        if use_summary {
1469            output.next_cursor = None;
1470        } else {
1471            output.next_cursor.clone_from(&paginated.next_cursor);
1472        }
1473
1474        // Build final text output with pagination cursor if present (unless using summary mode)
1475        let mut final_text = output.formatted.clone();
1476        if !use_summary && let Some(cursor) = paginated.next_cursor {
1477            final_text.push('\n');
1478            final_text.push_str("NEXT_CURSOR: ");
1479            final_text.push_str(&cursor);
1480        }
1481
1482        // Record cache tier in span
1483        tracing::Span::current().record("cache_tier", dir_cache_hit.as_str());
1484
1485        // Add content_hash to _meta
1486        let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1487        let mut meta = no_cache_meta().0;
1488        meta.insert(
1489            "content_hash".to_string(),
1490            serde_json::Value::String(content_hash),
1491        );
1492        let meta = rmcp::model::Meta(meta);
1493
1494        let mut result =
1495            CallToolResult::success(vec![Content::text(final_text.clone())]).with_meta(Some(meta));
1496        let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1497        result.structured_content = Some(structured);
1498        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1499        self.metrics_tx.send(crate::metrics::MetricEvent {
1500            ts: crate::metrics::unix_ms(),
1501            tool: "analyze_directory",
1502            duration_ms: dur,
1503            output_chars: final_text.len(),
1504            param_path_depth: crate::metrics::path_component_count(&param_path),
1505            max_depth: max_depth_val,
1506            result: "ok",
1507            error_type: None,
1508            session_id: sid,
1509            seq: Some(seq),
1510            cache_hit: Some(dir_cache_hit != CacheTier::Miss),
1511            cache_write_failure: None,
1512            cache_tier: Some(dir_cache_hit.as_str()),
1513            exit_code: None,
1514            timed_out: false,
1515        });
1516        Ok(result)
1517    }
1518
1519    #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
1520    #[tool(
1521        name = "analyze_file",
1522        title = "Analyze File",
1523        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.",
1524        output_schema = schema_for_type::<analyze::FileAnalysisOutput>(),
1525        annotations(
1526            title = "Analyze File",
1527            read_only_hint = true,
1528            destructive_hint = false,
1529            idempotent_hint = true,
1530            open_world_hint = false
1531        )
1532    )]
1533    async fn analyze_file(
1534        &self,
1535        params: Parameters<AnalyzeFileParams>,
1536        context: RequestContext<RoleServer>,
1537    ) -> Result<CallToolResult, ErrorData> {
1538        let params = params.0;
1539        // Extract W3C Trace Context from request _meta if present
1540        let session_id = self.session_id.lock().await.clone();
1541        let client_name = self.client_name.lock().await.clone();
1542        let client_version = self.client_version.lock().await.clone();
1543        extract_and_set_trace_context(
1544            Some(&context.meta),
1545            ClientMetadata {
1546                session_id,
1547                client_name,
1548                client_version,
1549            },
1550        );
1551        let span = tracing::Span::current();
1552        span.record("gen_ai.system", "mcp");
1553        span.record("gen_ai.operation.name", "execute_tool");
1554        span.record("gen_ai.tool.name", "analyze_file");
1555        span.record("path", &params.path);
1556        let _validated_path = match validate_path(&params.path, true) {
1557            Ok(p) => p,
1558            Err(e) => {
1559                span.record("error", true);
1560                span.record("error.type", "invalid_params");
1561                return Ok(err_to_tool_result(e));
1562            }
1563        };
1564        let t_start = std::time::Instant::now();
1565        let param_path = params.path.clone();
1566        let seq = self
1567            .session_call_seq
1568            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1569        let sid = self.session_id.lock().await.clone();
1570
1571        // Check if path is a directory (not allowed for analyze_file)
1572        if std::path::Path::new(&params.path).is_dir() {
1573            span.record("error", true);
1574            span.record("error.type", "invalid_params");
1575            return Ok(err_to_tool_result(ErrorData::new(
1576                rmcp::model::ErrorCode::INVALID_PARAMS,
1577                format!(
1578                    "'{}' is a directory; use analyze_directory instead",
1579                    params.path
1580                ),
1581                Some(error_meta(
1582                    "validation",
1583                    false,
1584                    "pass a file path, not a directory",
1585                )),
1586            )));
1587        }
1588
1589        // summary=true and cursor are mutually exclusive
1590        if summary_cursor_conflict(
1591            params.output_control.summary,
1592            params.pagination.cursor.as_deref(),
1593        ) {
1594            span.record("error", true);
1595            span.record("error.type", "invalid_params");
1596            return Ok(err_to_tool_result(ErrorData::new(
1597                rmcp::model::ErrorCode::INVALID_PARAMS,
1598                "summary=true is incompatible with a pagination cursor; use one or the other"
1599                    .to_string(),
1600                Some(error_meta(
1601                    "validation",
1602                    false,
1603                    "remove cursor or set summary=false",
1604                )),
1605            )));
1606        }
1607
1608        // Call handler for analysis and caching
1609        let (arc_output, file_cache_hit) = match self.handle_file_details_mode(&params).await {
1610            Ok(v) => v,
1611            Err(e) => {
1612                span.record("error", true);
1613                span.record("error.type", "internal_error");
1614                return Ok(err_to_tool_result(e));
1615            }
1616        };
1617
1618        // Clone only the two fields that may be mutated per-request (formatted and
1619        // next_cursor). The heavy SemanticAnalysis data is shared via Arc and never
1620        // modified, so we borrow it directly from the cached pointer.
1621        let mut formatted = arc_output.formatted.clone();
1622        let line_count = arc_output.line_count;
1623
1624        // Apply summary/output size limiting logic
1625        let use_summary = if params.output_control.force == Some(true) {
1626            false
1627        } else if params.output_control.summary == Some(true) {
1628            true
1629        } else if params.output_control.summary == Some(false) {
1630            false
1631        } else {
1632            formatted.len() > SIZE_LIMIT
1633        };
1634
1635        if use_summary {
1636            formatted = format_file_details_summary(&arc_output.semantic, &params.path, line_count);
1637        } else if formatted.len() > SIZE_LIMIT && params.output_control.force != Some(true) {
1638            span.record("error", true);
1639            span.record("error.type", "invalid_params");
1640            let estimated_tokens = formatted.len() / 4;
1641            let message = format!(
1642                "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1643                 - force=true to return full output\n\
1644                 - Use fields to limit output to specific sections (functions, classes, or imports)\n\
1645                 - Use summary=true for a compact overview",
1646                formatted.len(),
1647                estimated_tokens
1648            );
1649            return Ok(err_to_tool_result(ErrorData::new(
1650                rmcp::model::ErrorCode::INVALID_PARAMS,
1651                message,
1652                Some(error_meta(
1653                    "validation",
1654                    false,
1655                    "use force=true, fields, or summary=true",
1656                )),
1657            )));
1658        }
1659
1660        // Decode pagination cursor if provided (analyze_file)
1661        let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1662        let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1663            let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1664                ErrorData::new(
1665                    rmcp::model::ErrorCode::INVALID_PARAMS,
1666                    e.to_string(),
1667                    Some(error_meta("validation", false, "invalid cursor format")),
1668                )
1669            }) {
1670                Ok(v) => v,
1671                Err(e) => {
1672                    span.record("error", true);
1673                    span.record("error.type", "invalid_params");
1674                    return Ok(err_to_tool_result(e));
1675                }
1676            };
1677            cursor_data.offset
1678        } else {
1679            0
1680        };
1681
1682        // Filter to top-level functions only (exclude methods) before pagination
1683        let top_level_fns: Vec<crate::types::FunctionInfo> = arc_output
1684            .semantic
1685            .functions
1686            .iter()
1687            .filter(|func| {
1688                !arc_output
1689                    .semantic
1690                    .classes
1691                    .iter()
1692                    .any(|class| func.line >= class.line && func.end_line <= class.end_line)
1693            })
1694            .cloned()
1695            .collect();
1696
1697        // Paginate top-level functions only
1698        let paginated =
1699            match paginate_slice(&top_level_fns, offset, page_size, PaginationMode::Default) {
1700                Ok(v) => v,
1701                Err(e) => {
1702                    return Ok(err_to_tool_result(ErrorData::new(
1703                        rmcp::model::ErrorCode::INTERNAL_ERROR,
1704                        e.to_string(),
1705                        Some(error_meta("transient", true, "retry the request")),
1706                    )));
1707                }
1708            };
1709
1710        // Regenerate formatted output using the paginated formatter (handles verbose and pagination correctly)
1711        let verbose = params.output_control.verbose.unwrap_or(false);
1712        if !use_summary {
1713            // fields: serde rejects unknown enum variants at deserialization; no runtime validation required
1714            formatted = format_file_details_paginated(
1715                &paginated.items,
1716                paginated.total,
1717                &arc_output.semantic,
1718                &params.path,
1719                line_count,
1720                offset,
1721                verbose,
1722                params.fields.as_deref(),
1723            );
1724        }
1725
1726        // Capture next_cursor from pagination result (unless using summary mode)
1727        let next_cursor = if use_summary {
1728            None
1729        } else {
1730            paginated.next_cursor.clone()
1731        };
1732
1733        // Build final text output with pagination cursor if present (unless using summary mode)
1734        let mut final_text = formatted.clone();
1735        if !use_summary && let Some(ref cursor) = next_cursor {
1736            final_text.push('\n');
1737            final_text.push_str("NEXT_CURSOR: ");
1738            final_text.push_str(cursor);
1739        }
1740
1741        // Build the response output, sharing SemanticAnalysis from the Arc to avoid cloning it.
1742        let response_output = analyze::FileAnalysisOutput::new(
1743            formatted,
1744            arc_output.semantic.clone(),
1745            line_count,
1746            next_cursor,
1747        );
1748
1749        // Record cache tier in span
1750        tracing::Span::current().record("cache_tier", file_cache_hit.as_str());
1751
1752        // Add content_hash to _meta
1753        let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1754        let mut meta = no_cache_meta().0;
1755        meta.insert(
1756            "content_hash".to_string(),
1757            serde_json::Value::String(content_hash),
1758        );
1759        let meta = rmcp::model::Meta(meta);
1760
1761        let mut result =
1762            CallToolResult::success(vec![Content::text(final_text.clone())]).with_meta(Some(meta));
1763        let structured = serde_json::to_value(&response_output).unwrap_or(Value::Null);
1764        result.structured_content = Some(structured);
1765        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1766        self.metrics_tx.send(crate::metrics::MetricEvent {
1767            ts: crate::metrics::unix_ms(),
1768            tool: "analyze_file",
1769            duration_ms: dur,
1770            output_chars: final_text.len(),
1771            param_path_depth: crate::metrics::path_component_count(&param_path),
1772            max_depth: None,
1773            result: "ok",
1774            error_type: None,
1775            session_id: sid,
1776            seq: Some(seq),
1777            cache_hit: Some(file_cache_hit != CacheTier::Miss),
1778            cache_write_failure: None,
1779            cache_tier: Some(file_cache_hit.as_str()),
1780            exit_code: None,
1781            timed_out: false,
1782        });
1783        Ok(result)
1784    }
1785
1786    #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, symbol = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
1787    #[tool(
1788        name = "analyze_symbol",
1789        title = "Analyze Symbol",
1790        description = "Use when you need to: find all callers of a function across the codebase, trace transitive call chains, or locate all files importing a module path. Prefer over analyze_file when the question is \"who calls X\" or \"what does X call\" rather than \"what is in this file\".\n\nCall 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.",
1791        output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
1792        annotations(
1793            title = "Analyze Symbol",
1794            read_only_hint = true,
1795            destructive_hint = false,
1796            idempotent_hint = true,
1797            open_world_hint = false
1798        )
1799    )]
1800    async fn analyze_symbol(
1801        &self,
1802        params: Parameters<AnalyzeSymbolParams>,
1803        context: RequestContext<RoleServer>,
1804    ) -> Result<CallToolResult, ErrorData> {
1805        let params = params.0;
1806        // Extract W3C Trace Context from request _meta if present
1807        let session_id = self.session_id.lock().await.clone();
1808        let client_name = self.client_name.lock().await.clone();
1809        let client_version = self.client_version.lock().await.clone();
1810        extract_and_set_trace_context(
1811            Some(&context.meta),
1812            ClientMetadata {
1813                session_id,
1814                client_name,
1815                client_version,
1816            },
1817        );
1818        let span = tracing::Span::current();
1819        span.record("gen_ai.system", "mcp");
1820        span.record("gen_ai.operation.name", "execute_tool");
1821        span.record("gen_ai.tool.name", "analyze_symbol");
1822        span.record("symbol", &params.symbol);
1823        let _validated_path = match validate_path(&params.path, true) {
1824            Ok(p) => p,
1825            Err(e) => {
1826                span.record("error", true);
1827                span.record("error.type", "invalid_params");
1828                return Ok(err_to_tool_result(e));
1829            }
1830        };
1831        let ct = context.ct.clone();
1832        let t_start = std::time::Instant::now();
1833        let param_path = params.path.clone();
1834        let max_depth_val = params.follow_depth;
1835        let seq = self
1836            .session_call_seq
1837            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1838        let sid = self.session_id.lock().await.clone();
1839
1840        // Check if path is a file (not allowed for analyze_symbol)
1841        if std::path::Path::new(&params.path).is_file() {
1842            span.record("error", true);
1843            span.record("error.type", "invalid_params");
1844            return Ok(err_to_tool_result(ErrorData::new(
1845                rmcp::model::ErrorCode::INVALID_PARAMS,
1846                format!(
1847                    "'{}' is a file; analyze_symbol requires a directory path",
1848                    params.path
1849                ),
1850                Some(error_meta(
1851                    "validation",
1852                    false,
1853                    "pass a directory path, not a file",
1854                )),
1855            )));
1856        }
1857
1858        // summary=true and cursor are mutually exclusive
1859        if summary_cursor_conflict(
1860            params.output_control.summary,
1861            params.pagination.cursor.as_deref(),
1862        ) {
1863            span.record("error", true);
1864            span.record("error.type", "invalid_params");
1865            return Ok(err_to_tool_result(ErrorData::new(
1866                rmcp::model::ErrorCode::INVALID_PARAMS,
1867                "summary=true is incompatible with a pagination cursor; use one or the other"
1868                    .to_string(),
1869                Some(error_meta(
1870                    "validation",
1871                    false,
1872                    "remove cursor or set summary=false",
1873                )),
1874            )));
1875        }
1876
1877        // import_lookup=true is mutually exclusive with a non-empty symbol.
1878        if let Err(e) = Self::validate_import_lookup(params.import_lookup, &params.symbol) {
1879            span.record("error", true);
1880            span.record("error.type", "invalid_params");
1881            return Ok(err_to_tool_result(e));
1882        }
1883
1884        // import_lookup mode: scan for files importing `params.symbol` as a module path.
1885        if params.import_lookup == Some(true) {
1886            let path_owned = PathBuf::from(&params.path);
1887            let symbol = params.symbol.clone();
1888            let git_ref = params.git_ref.clone();
1889            let max_depth = params.max_depth;
1890            let ast_recursion_limit = params.ast_recursion_limit;
1891
1892            let handle = tokio::task::spawn_blocking(move || {
1893                let path = path_owned.as_path();
1894                let raw_entries = match walk_directory(path, max_depth) {
1895                    Ok(e) => e,
1896                    Err(e) => {
1897                        return Err(ErrorData::new(
1898                            rmcp::model::ErrorCode::INTERNAL_ERROR,
1899                            format!("Failed to walk directory: {e}"),
1900                            Some(error_meta(
1901                                "resource",
1902                                false,
1903                                "check path permissions and availability",
1904                            )),
1905                        ));
1906                    }
1907                };
1908                // Apply git_ref filter when requested (non-empty string only).
1909                let entries = if let Some(ref git_ref_val) = git_ref
1910                    && !git_ref_val.is_empty()
1911                {
1912                    let changed = match changed_files_from_git_ref(path, git_ref_val) {
1913                        Ok(c) => c,
1914                        Err(e) => {
1915                            return Err(ErrorData::new(
1916                                rmcp::model::ErrorCode::INVALID_PARAMS,
1917                                format!("git_ref filter failed: {e}"),
1918                                Some(error_meta(
1919                                    "resource",
1920                                    false,
1921                                    "ensure git is installed and path is inside a git repository",
1922                                )),
1923                            ));
1924                        }
1925                    };
1926                    filter_entries_by_git_ref(raw_entries, &changed, path)
1927                } else {
1928                    raw_entries
1929                };
1930                let output = match analyze::analyze_import_lookup(
1931                    path,
1932                    &symbol,
1933                    &entries,
1934                    ast_recursion_limit,
1935                ) {
1936                    Ok(v) => v,
1937                    Err(e) => {
1938                        return Err(ErrorData::new(
1939                            rmcp::model::ErrorCode::INTERNAL_ERROR,
1940                            format!("import_lookup failed: {e}"),
1941                            Some(error_meta(
1942                                "resource",
1943                                false,
1944                                "check path and file permissions",
1945                            )),
1946                        ));
1947                    }
1948                };
1949                Ok(output)
1950            });
1951
1952            let output = match handle.await {
1953                Ok(Ok(v)) => v,
1954                Ok(Err(e)) => return Ok(err_to_tool_result(e)),
1955                Err(e) => {
1956                    return Ok(err_to_tool_result(ErrorData::new(
1957                        rmcp::model::ErrorCode::INTERNAL_ERROR,
1958                        format!("spawn_blocking failed: {e}"),
1959                        Some(error_meta("resource", false, "internal error")),
1960                    )));
1961                }
1962            };
1963
1964            let final_text = output.formatted.clone();
1965
1966            // Record cache tier in span
1967            tracing::Span::current().record("cache_tier", "Miss");
1968
1969            // Add content_hash to _meta
1970            let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1971            let mut meta = no_cache_meta().0;
1972            meta.insert(
1973                "content_hash".to_string(),
1974                serde_json::Value::String(content_hash),
1975            );
1976
1977            let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
1978                .with_meta(Some(Meta(meta)));
1979            let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
1980            result.structured_content = Some(structured);
1981            let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1982            self.metrics_tx.send(crate::metrics::MetricEvent {
1983                ts: crate::metrics::unix_ms(),
1984                tool: "analyze_symbol",
1985                duration_ms: dur,
1986                output_chars: final_text.len(),
1987                param_path_depth: crate::metrics::path_component_count(&param_path),
1988                max_depth: max_depth_val,
1989                result: "ok",
1990                error_type: None,
1991                session_id: sid,
1992                seq: Some(seq),
1993                cache_hit: None,
1994                cache_tier: None,
1995                cache_write_failure: None,
1996                exit_code: None,
1997                timed_out: false,
1998            });
1999            return Ok(result);
2000        }
2001
2002        // Call handler for analysis and progress tracking
2003        let mut output = match self.handle_focused_mode(&params, ct).await {
2004            Ok(v) => v,
2005            Err(e) => return Ok(err_to_tool_result(e)),
2006        };
2007
2008        // Decode pagination cursor if provided (analyze_symbol)
2009        let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
2010        let offset = if let Some(ref cursor_str) = params.pagination.cursor {
2011            let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
2012                ErrorData::new(
2013                    rmcp::model::ErrorCode::INVALID_PARAMS,
2014                    e.to_string(),
2015                    Some(error_meta("validation", false, "invalid cursor format")),
2016                )
2017            }) {
2018                Ok(v) => v,
2019                Err(e) => return Ok(err_to_tool_result(e)),
2020            };
2021            cursor_data.offset
2022        } else {
2023            0
2024        };
2025
2026        // SymbolFocus pagination: decode cursor mode to determine callers vs callees
2027        let cursor_mode = if let Some(ref cursor_str) = params.pagination.cursor {
2028            decode_cursor(cursor_str)
2029                .map(|c| c.mode)
2030                .unwrap_or(PaginationMode::Callers)
2031        } else {
2032            PaginationMode::Callers
2033        };
2034
2035        let mut use_summary = params.output_control.summary == Some(true);
2036        if params.output_control.force == Some(true) {
2037            use_summary = false;
2038        }
2039        let verbose = params.output_control.verbose.unwrap_or(false);
2040
2041        let mut callee_cursor = match cursor_mode {
2042            PaginationMode::Callers => {
2043                let (paginated_items, paginated_next) = match paginate_focus_chains(
2044                    &output.prod_chains,
2045                    PaginationMode::Callers,
2046                    offset,
2047                    page_size,
2048                ) {
2049                    Ok(v) => v,
2050                    Err(e) => return Ok(err_to_tool_result(e)),
2051                };
2052
2053                if !use_summary
2054                    && (paginated_next.is_some()
2055                        || offset > 0
2056                        || !verbose
2057                        || !output.outgoing_chains.is_empty())
2058                {
2059                    let base_path = Path::new(&params.path);
2060                    output.formatted = format_focused_paginated(
2061                        &paginated_items,
2062                        output.prod_chains.len(),
2063                        PaginationMode::Callers,
2064                        &params.symbol,
2065                        &output.prod_chains,
2066                        &output.test_chains,
2067                        &output.outgoing_chains,
2068                        output.def_count,
2069                        offset,
2070                        Some(base_path),
2071                        verbose,
2072                    );
2073                    paginated_next
2074                } else {
2075                    None
2076                }
2077            }
2078            PaginationMode::Callees => {
2079                let (paginated_items, paginated_next) = match paginate_focus_chains(
2080                    &output.outgoing_chains,
2081                    PaginationMode::Callees,
2082                    offset,
2083                    page_size,
2084                ) {
2085                    Ok(v) => v,
2086                    Err(e) => return Ok(err_to_tool_result(e)),
2087                };
2088
2089                if paginated_next.is_some() || offset > 0 || !verbose {
2090                    let base_path = Path::new(&params.path);
2091                    output.formatted = format_focused_paginated(
2092                        &paginated_items,
2093                        output.outgoing_chains.len(),
2094                        PaginationMode::Callees,
2095                        &params.symbol,
2096                        &output.prod_chains,
2097                        &output.test_chains,
2098                        &output.outgoing_chains,
2099                        output.def_count,
2100                        offset,
2101                        Some(base_path),
2102                        verbose,
2103                    );
2104                    paginated_next
2105                } else {
2106                    None
2107                }
2108            }
2109            PaginationMode::Default => {
2110                return Ok(err_to_tool_result(ErrorData::new(
2111                    rmcp::model::ErrorCode::INVALID_PARAMS,
2112                    "invalid cursor: unknown pagination mode".to_string(),
2113                    Some(error_meta(
2114                        "validation",
2115                        false,
2116                        "use a cursor returned by a previous analyze_symbol call",
2117                    )),
2118                )));
2119            }
2120            PaginationMode::DefUse => {
2121                let total_sites = output.def_use_sites.len();
2122                let (paginated_sites, paginated_next) = match paginate_slice(
2123                    &output.def_use_sites,
2124                    offset,
2125                    page_size,
2126                    PaginationMode::DefUse,
2127                ) {
2128                    Ok(r) => (r.items, r.next_cursor),
2129                    Err(e) => return Ok(err_to_tool_result_from_pagination(e)),
2130                };
2131
2132                // Always regenerate formatted output for DefUse mode so the
2133                // first page (offset=0, verbose=true) is not skipped.
2134                if !use_summary {
2135                    let base_path = Path::new(&params.path);
2136                    output.formatted = format_focused_paginated_defuse(
2137                        &paginated_sites,
2138                        total_sites,
2139                        &params.symbol,
2140                        offset,
2141                        Some(base_path),
2142                        verbose,
2143                    );
2144                }
2145
2146                // Slice output.def_use_sites to the current page window so
2147                // structuredContent only contains the paginated subset.
2148                output.def_use_sites = paginated_sites;
2149
2150                paginated_next
2151            }
2152        };
2153
2154        // When callers are exhausted and callees exist, bootstrap callee pagination
2155        // by emitting a {mode:callees, offset:0} cursor. This makes PaginationMode::Callees
2156        // reachable; without it the branch was dead code. Suppressed in summary mode
2157        // because summary and pagination are mutually exclusive.
2158        if callee_cursor.is_none()
2159            && cursor_mode == PaginationMode::Callers
2160            && !output.outgoing_chains.is_empty()
2161            && !use_summary
2162            && let Ok(cursor) = encode_cursor(&CursorData {
2163                mode: PaginationMode::Callees,
2164                offset: 0,
2165            })
2166        {
2167            callee_cursor = Some(cursor);
2168        }
2169
2170        // When callees are exhausted and def_use_sites exist, bootstrap defuse cursor
2171        // by emitting a {mode:defuse, offset:0} cursor. This makes PaginationMode::DefUse
2172        // reachable. Suppressed in summary mode because summary and pagination are mutually exclusive.
2173        // Also bootstrap directly from Callers mode when there are no outgoing chains
2174        // (e.g. SymbolNotFound path or symbols with no callees) so def-use pagination
2175        // is reachable even without a Callees phase.
2176        if callee_cursor.is_none()
2177            && matches!(
2178                cursor_mode,
2179                PaginationMode::Callees | PaginationMode::Callers
2180            )
2181            && !output.def_use_sites.is_empty()
2182            && !use_summary
2183            && let Ok(cursor) = encode_cursor(&CursorData {
2184                mode: PaginationMode::DefUse,
2185                offset: 0,
2186            })
2187        {
2188            // Only bootstrap from Callers when callees are empty (otherwise
2189            // the Callees bootstrap above takes priority).
2190            if cursor_mode == PaginationMode::Callees || output.outgoing_chains.is_empty() {
2191                callee_cursor = Some(cursor);
2192            }
2193        }
2194
2195        // Update next_cursor in output
2196        output.next_cursor.clone_from(&callee_cursor);
2197
2198        // Build final text output with pagination cursor if present
2199        let mut final_text = output.formatted.clone();
2200        if let Some(cursor) = callee_cursor {
2201            final_text.push('\n');
2202            final_text.push_str("NEXT_CURSOR: ");
2203            final_text.push_str(&cursor);
2204        }
2205
2206        // Record cache tier in span
2207        tracing::Span::current().record("cache_tier", "Miss");
2208
2209        // Add content_hash to _meta
2210        let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
2211        let mut meta = no_cache_meta().0;
2212        meta.insert(
2213            "content_hash".to_string(),
2214            serde_json::Value::String(content_hash),
2215        );
2216
2217        let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
2218            .with_meta(Some(Meta(meta)));
2219        // Only include def_use_sites in structuredContent when in DefUse mode.
2220        // In Callers/Callees modes, clearing the vec prevents large def-use
2221        // payloads from leaking into paginated non-def-use responses.
2222        if cursor_mode != PaginationMode::DefUse {
2223            output.def_use_sites = Vec::new();
2224        }
2225        let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
2226        result.structured_content = Some(structured);
2227        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2228        self.metrics_tx.send(crate::metrics::MetricEvent {
2229            ts: crate::metrics::unix_ms(),
2230            tool: "analyze_symbol",
2231            duration_ms: dur,
2232            output_chars: final_text.len(),
2233            param_path_depth: crate::metrics::path_component_count(&param_path),
2234            max_depth: max_depth_val,
2235            result: "ok",
2236            error_type: None,
2237            session_id: sid,
2238            seq: Some(seq),
2239            cache_hit: None,
2240            cache_tier: None,
2241            cache_write_failure: None,
2242            exit_code: None,
2243            timed_out: false,
2244        });
2245        Ok(result)
2246    }
2247
2248    #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty, cache_tier = tracing::field::Empty))]
2249    #[tool(
2250        name = "analyze_module",
2251        title = "Analyze Module",
2252        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?",
2253        output_schema = schema_for_type::<types::ModuleInfo>(),
2254        annotations(
2255            title = "Analyze Module",
2256            read_only_hint = true,
2257            destructive_hint = false,
2258            idempotent_hint = true,
2259            open_world_hint = false
2260        )
2261    )]
2262    async fn analyze_module(
2263        &self,
2264        params: Parameters<AnalyzeModuleParams>,
2265        context: RequestContext<RoleServer>,
2266    ) -> Result<CallToolResult, ErrorData> {
2267        let params = params.0;
2268        // Extract W3C Trace Context from request _meta if present
2269        let session_id = self.session_id.lock().await.clone();
2270        let client_name = self.client_name.lock().await.clone();
2271        let client_version = self.client_version.lock().await.clone();
2272        extract_and_set_trace_context(
2273            Some(&context.meta),
2274            ClientMetadata {
2275                session_id,
2276                client_name,
2277                client_version,
2278            },
2279        );
2280        let span = tracing::Span::current();
2281        span.record("gen_ai.system", "mcp");
2282        span.record("gen_ai.operation.name", "execute_tool");
2283        span.record("gen_ai.tool.name", "analyze_module");
2284        span.record("path", &params.path);
2285        let _validated_path = match validate_path(&params.path, true) {
2286            Ok(p) => p,
2287            Err(e) => {
2288                span.record("error", true);
2289                span.record("error.type", "invalid_params");
2290                return Ok(err_to_tool_result(e));
2291            }
2292        };
2293        let t_start = std::time::Instant::now();
2294        let param_path = params.path.clone();
2295        let seq = self
2296            .session_call_seq
2297            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2298        let sid = self.session_id.lock().await.clone();
2299
2300        // Issue 340: Guard against directory paths
2301        if std::fs::metadata(&params.path)
2302            .map(|m| m.is_dir())
2303            .unwrap_or(false)
2304        {
2305            span.record("error", true);
2306            span.record("error.type", "invalid_params");
2307            let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2308            self.metrics_tx.send(crate::metrics::MetricEvent {
2309                ts: crate::metrics::unix_ms(),
2310                tool: "analyze_module",
2311                duration_ms: dur,
2312                output_chars: 0,
2313                param_path_depth: crate::metrics::path_component_count(&param_path),
2314                max_depth: None,
2315                result: "error",
2316                error_type: Some("invalid_params".to_string()),
2317                session_id: sid.clone(),
2318                seq: Some(seq),
2319                cache_hit: None,
2320                cache_write_failure: None,
2321                cache_tier: None,
2322                exit_code: None,
2323                timed_out: false,
2324            });
2325            return Ok(err_to_tool_result(ErrorData::new(
2326                rmcp::model::ErrorCode::INVALID_PARAMS,
2327                format!(
2328                    "'{}' is a directory. Use analyze_directory to analyze a directory, or pass a specific file path to analyze_module.",
2329                    params.path
2330                ),
2331                Some(error_meta(
2332                    "validation",
2333                    false,
2334                    "use analyze_directory for directories",
2335                )),
2336            )));
2337        }
2338
2339        // Route through handle_file_details_mode to inherit L1+L2 caching
2340        let mut analyze_file_params: AnalyzeFileParams = Default::default();
2341        analyze_file_params.path = params.path.clone();
2342        let (arc_output, module_tier) =
2343            match self.handle_file_details_mode(&analyze_file_params).await {
2344                Ok((output, tier)) => (output, tier),
2345                Err(e) => {
2346                    let error_data = match e.code {
2347                        rmcp::model::ErrorCode::INVALID_PARAMS => e,
2348                        _ => ErrorData::new(
2349                            rmcp::model::ErrorCode::INTERNAL_ERROR,
2350                            format!("Failed to analyze module: {}", e.message),
2351                            Some(error_meta("internal", false, "report this as a bug")),
2352                        ),
2353                    };
2354                    return Ok(err_to_tool_result(error_data));
2355                }
2356            };
2357
2358        // Reconstruct ModuleInfo from FileAnalysisOutput
2359        let file_path = std::path::Path::new(&params.path);
2360        let name = file_path
2361            .file_name()
2362            .and_then(|n: &std::ffi::OsStr| n.to_str())
2363            .unwrap_or("unknown")
2364            .to_string();
2365        let language = file_path
2366            .extension()
2367            .and_then(|e| e.to_str())
2368            .and_then(aptu_coder_core::lang::language_for_extension)
2369            .unwrap_or("unknown")
2370            .to_string();
2371        let functions = arc_output
2372            .semantic
2373            .functions
2374            .iter()
2375            .map(|f| {
2376                let mut mfi = types::ModuleFunctionInfo::default();
2377                mfi.name = f.name.clone();
2378                mfi.line = f.line;
2379                mfi
2380            })
2381            .collect();
2382        let imports = arc_output
2383            .semantic
2384            .imports
2385            .iter()
2386            .map(|i| {
2387                let mut mii = types::ModuleImportInfo::default();
2388                mii.module = i.module.clone();
2389                mii.items = i.items.clone();
2390                mii
2391            })
2392            .collect();
2393        let module_info =
2394            types::ModuleInfo::new(name, arc_output.line_count, language, functions, imports);
2395
2396        let text = format_module_info(&module_info);
2397
2398        // Record cache tier in span
2399        tracing::Span::current().record("cache_tier", module_tier.as_str());
2400
2401        // Add content_hash to _meta
2402        let content_hash = format!("{}", blake3::hash(text.as_bytes()));
2403        let mut meta = no_cache_meta().0;
2404        meta.insert(
2405            "content_hash".to_string(),
2406            serde_json::Value::String(content_hash),
2407        );
2408
2409        let mut result =
2410            CallToolResult::success(vec![Content::text(text.clone())]).with_meta(Some(Meta(meta)));
2411        let structured = match serde_json::to_value(&module_info).map_err(|e| {
2412            ErrorData::new(
2413                rmcp::model::ErrorCode::INTERNAL_ERROR,
2414                format!("serialization failed: {e}"),
2415                Some(error_meta("internal", false, "report this as a bug")),
2416            )
2417        }) {
2418            Ok(v) => v,
2419            Err(e) => return Ok(err_to_tool_result(e)),
2420        };
2421        result.structured_content = Some(structured);
2422        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2423        self.metrics_tx.send(crate::metrics::MetricEvent {
2424            ts: crate::metrics::unix_ms(),
2425            tool: "analyze_module",
2426            duration_ms: dur,
2427            output_chars: text.len(),
2428            param_path_depth: crate::metrics::path_component_count(&param_path),
2429            max_depth: None,
2430            result: "ok",
2431            error_type: None,
2432            session_id: sid,
2433            seq: Some(seq),
2434            cache_hit: Some(module_tier != CacheTier::Miss),
2435            cache_tier: Some(module_tier.as_str()),
2436            cache_write_failure: None,
2437            exit_code: None,
2438            timed_out: false,
2439        });
2440        Ok(result)
2441    }
2442
2443    #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
2444    #[tool(
2445        name = "edit_overwrite",
2446        title = "Edit Overwrite",
2447        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.",
2448        output_schema = schema_for_type::<EditOverwriteOutput>(),
2449        annotations(
2450            title = "Edit Overwrite",
2451            read_only_hint = false,
2452            destructive_hint = true,
2453            idempotent_hint = false,
2454            open_world_hint = false
2455        )
2456    )]
2457    async fn edit_overwrite(
2458        &self,
2459        params: Parameters<EditOverwriteParams>,
2460        context: RequestContext<RoleServer>,
2461    ) -> Result<CallToolResult, ErrorData> {
2462        let params = params.0;
2463        // Extract W3C Trace Context from request _meta if present
2464        let session_id = self.session_id.lock().await.clone();
2465        let client_name = self.client_name.lock().await.clone();
2466        let client_version = self.client_version.lock().await.clone();
2467        extract_and_set_trace_context(
2468            Some(&context.meta),
2469            ClientMetadata {
2470                session_id,
2471                client_name,
2472                client_version,
2473            },
2474        );
2475        let span = tracing::Span::current();
2476        span.record("gen_ai.system", "mcp");
2477        span.record("gen_ai.operation.name", "execute_tool");
2478        span.record("gen_ai.tool.name", "edit_overwrite");
2479        span.record("path", &params.path);
2480        let _validated_path = if let Some(ref wd) = params.working_dir {
2481            match validate_path_in_dir(&params.path, false, std::path::Path::new(wd)) {
2482                Ok(p) => p,
2483                Err(e) => {
2484                    span.record("error", true);
2485                    span.record("error.type", "invalid_params");
2486                    return Ok(err_to_tool_result(e));
2487                }
2488            }
2489        } else {
2490            match validate_path(&params.path, false) {
2491                Ok(p) => p,
2492                Err(e) => {
2493                    span.record("error", true);
2494                    span.record("error.type", "invalid_params");
2495                    return Ok(err_to_tool_result(e));
2496                }
2497            }
2498        };
2499        let t_start = std::time::Instant::now();
2500        let param_path = params.path.clone();
2501        let seq = self
2502            .session_call_seq
2503            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2504        let sid = self.session_id.lock().await.clone();
2505
2506        // Guard against directory paths
2507        if std::fs::metadata(&params.path)
2508            .map(|m| m.is_dir())
2509            .unwrap_or(false)
2510        {
2511            span.record("error", true);
2512            span.record("error.type", "invalid_params");
2513            let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2514            self.metrics_tx.send(crate::metrics::MetricEvent {
2515                ts: crate::metrics::unix_ms(),
2516                tool: "edit_overwrite",
2517                duration_ms: dur,
2518                output_chars: 0,
2519                param_path_depth: crate::metrics::path_component_count(&param_path),
2520                max_depth: None,
2521                result: "error",
2522                error_type: Some("invalid_params".to_string()),
2523                session_id: sid.clone(),
2524                seq: Some(seq),
2525                cache_hit: None,
2526                cache_write_failure: None,
2527                cache_tier: None,
2528                exit_code: None,
2529                timed_out: false,
2530            });
2531            return Ok(err_to_tool_result(ErrorData::new(
2532                rmcp::model::ErrorCode::INVALID_PARAMS,
2533                "path is a directory; cannot write to a directory".to_string(),
2534                Some(error_meta(
2535                    "validation",
2536                    false,
2537                    "provide a file path, not a directory",
2538                )),
2539            )));
2540        }
2541
2542        let path = std::path::PathBuf::from(&params.path);
2543        let content = params.content.clone();
2544        let handle = tokio::task::spawn_blocking(move || {
2545            aptu_coder_core::edit_overwrite_content(&path, &content)
2546        });
2547
2548        let output = match handle.await {
2549            Ok(Ok(v)) => v,
2550            Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2551                span.record("error", true);
2552                span.record("error.type", "invalid_params");
2553                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2554                self.metrics_tx.send(crate::metrics::MetricEvent {
2555                    ts: crate::metrics::unix_ms(),
2556                    tool: "edit_overwrite",
2557                    duration_ms: dur,
2558                    output_chars: 0,
2559                    param_path_depth: crate::metrics::path_component_count(&param_path),
2560                    max_depth: None,
2561                    result: "error",
2562                    error_type: Some("invalid_params".to_string()),
2563                    session_id: sid.clone(),
2564                    seq: Some(seq),
2565                    cache_hit: None,
2566                    cache_write_failure: None,
2567                    cache_tier: None,
2568                    exit_code: None,
2569                    timed_out: false,
2570                });
2571                return Ok(err_to_tool_result(ErrorData::new(
2572                    rmcp::model::ErrorCode::INVALID_PARAMS,
2573                    "path is a directory".to_string(),
2574                    Some(error_meta(
2575                        "validation",
2576                        false,
2577                        "provide a file path, not a directory",
2578                    )),
2579                )));
2580            }
2581            Ok(Err(e)) => {
2582                span.record("error", true);
2583                span.record("error.type", "internal_error");
2584                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2585                self.metrics_tx.send(crate::metrics::MetricEvent {
2586                    ts: crate::metrics::unix_ms(),
2587                    tool: "edit_overwrite",
2588                    duration_ms: dur,
2589                    output_chars: 0,
2590                    param_path_depth: crate::metrics::path_component_count(&param_path),
2591                    max_depth: None,
2592                    result: "error",
2593                    error_type: Some("internal_error".to_string()),
2594                    session_id: sid.clone(),
2595                    seq: Some(seq),
2596                    cache_hit: None,
2597                    cache_write_failure: None,
2598                    cache_tier: None,
2599                    exit_code: None,
2600                    timed_out: false,
2601                });
2602                return Ok(err_to_tool_result(ErrorData::new(
2603                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2604                    e.to_string(),
2605                    Some(error_meta(
2606                        "resource",
2607                        false,
2608                        "check file path and permissions",
2609                    )),
2610                )));
2611            }
2612            Err(e) => {
2613                span.record("error", true);
2614                span.record("error.type", "internal_error");
2615                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2616                self.metrics_tx.send(crate::metrics::MetricEvent {
2617                    ts: crate::metrics::unix_ms(),
2618                    tool: "edit_overwrite",
2619                    duration_ms: dur,
2620                    output_chars: 0,
2621                    param_path_depth: crate::metrics::path_component_count(&param_path),
2622                    max_depth: None,
2623                    result: "error",
2624                    error_type: Some("internal_error".to_string()),
2625                    session_id: sid.clone(),
2626                    seq: Some(seq),
2627                    cache_hit: None,
2628                    cache_write_failure: None,
2629                    cache_tier: None,
2630                    exit_code: None,
2631                    timed_out: false,
2632                });
2633                return Ok(err_to_tool_result(ErrorData::new(
2634                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2635                    e.to_string(),
2636                    Some(error_meta(
2637                        "resource",
2638                        false,
2639                        "check file path and permissions",
2640                    )),
2641                )));
2642            }
2643        };
2644
2645        let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2646        let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2647            .with_meta(Some(no_cache_meta()));
2648        let structured = match serde_json::to_value(&output).map_err(|e| {
2649            ErrorData::new(
2650                rmcp::model::ErrorCode::INTERNAL_ERROR,
2651                format!("serialization failed: {e}"),
2652                Some(error_meta("internal", false, "report this as a bug")),
2653            )
2654        }) {
2655            Ok(v) => v,
2656            Err(e) => return Ok(err_to_tool_result(e)),
2657        };
2658        result.structured_content = Some(structured);
2659        self.cache
2660            .invalidate_file(&std::path::PathBuf::from(&param_path));
2661        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2662        self.metrics_tx.send(crate::metrics::MetricEvent {
2663            ts: crate::metrics::unix_ms(),
2664            tool: "edit_overwrite",
2665            duration_ms: dur,
2666            output_chars: text.len(),
2667            param_path_depth: crate::metrics::path_component_count(&param_path),
2668            max_depth: None,
2669            result: "ok",
2670            error_type: None,
2671            session_id: sid,
2672            seq: Some(seq),
2673            cache_hit: None,
2674            cache_write_failure: None,
2675            cache_tier: None,
2676            exit_code: None,
2677            timed_out: false,
2678        });
2679        Ok(result)
2680    }
2681
2682    #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, path = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
2683    #[tool(
2684        name = "edit_replace",
2685        title = "Edit Replace",
2686        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). If invalid_params is returned, re-read the target file with analyze_file or analyze_module before retrying. 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.",
2687        output_schema = schema_for_type::<EditReplaceOutput>(),
2688        annotations(
2689            title = "Edit Replace",
2690            read_only_hint = false,
2691            destructive_hint = true,
2692            idempotent_hint = false,
2693            open_world_hint = false
2694        )
2695    )]
2696    async fn edit_replace(
2697        &self,
2698        params: Parameters<EditReplaceParams>,
2699        context: RequestContext<RoleServer>,
2700    ) -> Result<CallToolResult, ErrorData> {
2701        let params = params.0;
2702        // Extract W3C Trace Context from request _meta if present
2703        let session_id = self.session_id.lock().await.clone();
2704        let client_name = self.client_name.lock().await.clone();
2705        let client_version = self.client_version.lock().await.clone();
2706        extract_and_set_trace_context(
2707            Some(&context.meta),
2708            ClientMetadata {
2709                session_id,
2710                client_name,
2711                client_version,
2712            },
2713        );
2714        let span = tracing::Span::current();
2715        span.record("gen_ai.system", "mcp");
2716        span.record("gen_ai.operation.name", "execute_tool");
2717        span.record("gen_ai.tool.name", "edit_replace");
2718        span.record("path", &params.path);
2719        let _validated_path = if let Some(ref wd) = params.working_dir {
2720            match validate_path_in_dir(&params.path, true, std::path::Path::new(wd)) {
2721                Ok(p) => p,
2722                Err(e) => {
2723                    span.record("error", true);
2724                    span.record("error.type", "invalid_params");
2725                    return Ok(err_to_tool_result(e));
2726                }
2727            }
2728        } else {
2729            match validate_path(&params.path, true) {
2730                Ok(p) => p,
2731                Err(e) => {
2732                    span.record("error", true);
2733                    span.record("error.type", "invalid_params");
2734                    return Ok(err_to_tool_result(e));
2735                }
2736            }
2737        };
2738        let t_start = std::time::Instant::now();
2739        let param_path = params.path.clone();
2740        let seq = self
2741            .session_call_seq
2742            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2743        let sid = self.session_id.lock().await.clone();
2744
2745        // Guard against directory paths
2746        if std::fs::metadata(&params.path)
2747            .map(|m| m.is_dir())
2748            .unwrap_or(false)
2749        {
2750            span.record("error", true);
2751            span.record("error.type", "invalid_params");
2752            let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2753            self.metrics_tx.send(crate::metrics::MetricEvent {
2754                ts: crate::metrics::unix_ms(),
2755                tool: "edit_replace",
2756                duration_ms: dur,
2757                output_chars: 0,
2758                param_path_depth: crate::metrics::path_component_count(&param_path),
2759                max_depth: None,
2760                result: "error",
2761                error_type: Some("invalid_params".to_string()),
2762                session_id: sid.clone(),
2763                seq: Some(seq),
2764                cache_hit: None,
2765                cache_write_failure: None,
2766                cache_tier: None,
2767                exit_code: None,
2768                timed_out: false,
2769            });
2770            return Ok(err_to_tool_result(ErrorData::new(
2771                rmcp::model::ErrorCode::INVALID_PARAMS,
2772                "path is a directory; cannot edit a directory".to_string(),
2773                Some(error_meta(
2774                    "validation",
2775                    false,
2776                    "provide a file path, not a directory",
2777                )),
2778            )));
2779        }
2780
2781        let path = std::path::PathBuf::from(&params.path);
2782        let old_text = params.old_text.clone();
2783        let new_text = params.new_text.clone();
2784        let handle = tokio::task::spawn_blocking(move || {
2785            aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2786        });
2787
2788        let output = match handle.await {
2789            Ok(Ok(v)) => v,
2790            Ok(Err(aptu_coder_core::EditError::NotFound {
2791                path: notfound_path,
2792            })) => {
2793                span.record("error", true);
2794                span.record("error.type", "invalid_params");
2795                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2796                self.metrics_tx.send(crate::metrics::MetricEvent {
2797                    ts: crate::metrics::unix_ms(),
2798                    tool: "edit_replace",
2799                    duration_ms: dur,
2800                    output_chars: 0,
2801                    param_path_depth: crate::metrics::path_component_count(&param_path),
2802                    max_depth: None,
2803                    result: "error",
2804                    error_type: Some("invalid_params".to_string()),
2805                    session_id: sid.clone(),
2806                    seq: Some(seq),
2807                    cache_hit: None,
2808                    cache_write_failure: None,
2809                    cache_tier: None,
2810                    exit_code: None,
2811                    timed_out: false,
2812                });
2813                return Ok(err_to_tool_result(ErrorData::new(
2814                    rmcp::model::ErrorCode::INVALID_PARAMS,
2815                    format!(
2816                        "old_text not found (0 matches) in {notfound_path}. Re-read the file with analyze_file or analyze_module to obtain the current content, then derive old_text from the live file before retrying."
2817                    ),
2818                    Some(error_meta(
2819                        "validation",
2820                        false,
2821                        "re-read the file with analyze_file or analyze_module, then derive old_text from the live content",
2822                    )),
2823                )));
2824            }
2825            Ok(Err(aptu_coder_core::EditError::Ambiguous {
2826                count,
2827                path: ambiguous_path,
2828            })) => {
2829                span.record("error", true);
2830                span.record("error.type", "invalid_params");
2831                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2832                self.metrics_tx.send(crate::metrics::MetricEvent {
2833                    ts: crate::metrics::unix_ms(),
2834                    tool: "edit_replace",
2835                    duration_ms: dur,
2836                    output_chars: 0,
2837                    param_path_depth: crate::metrics::path_component_count(&param_path),
2838                    max_depth: None,
2839                    result: "error",
2840                    error_type: Some("invalid_params".to_string()),
2841                    session_id: sid.clone(),
2842                    seq: Some(seq),
2843                    cache_hit: None,
2844                    cache_write_failure: None,
2845                    cache_tier: None,
2846                    exit_code: None,
2847                    timed_out: false,
2848                });
2849                return Ok(err_to_tool_result(ErrorData::new(
2850                    rmcp::model::ErrorCode::INVALID_PARAMS,
2851                    format!(
2852                        "old_text matched {count} locations in {ambiguous_path}. Extend old_text with more surrounding context to make it unique, or re-read with analyze_file to confirm the exact text."
2853                    ),
2854                    Some(error_meta(
2855                        "validation",
2856                        false,
2857                        "extend old_text with more surrounding context, or re-read with analyze_file to confirm the exact text",
2858                    )),
2859                )));
2860            }
2861            Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2862                span.record("error", true);
2863                span.record("error.type", "invalid_params");
2864                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2865                self.metrics_tx.send(crate::metrics::MetricEvent {
2866                    ts: crate::metrics::unix_ms(),
2867                    tool: "edit_replace",
2868                    duration_ms: dur,
2869                    output_chars: 0,
2870                    param_path_depth: crate::metrics::path_component_count(&param_path),
2871                    max_depth: None,
2872                    result: "error",
2873                    error_type: Some("invalid_params".to_string()),
2874                    session_id: sid.clone(),
2875                    seq: Some(seq),
2876                    cache_hit: None,
2877                    cache_write_failure: None,
2878                    cache_tier: None,
2879                    exit_code: None,
2880                    timed_out: false,
2881                });
2882                return Ok(err_to_tool_result(ErrorData::new(
2883                    rmcp::model::ErrorCode::INVALID_PARAMS,
2884                    "path is a directory".to_string(),
2885                    Some(error_meta(
2886                        "validation",
2887                        false,
2888                        "provide a file path, not a directory",
2889                    )),
2890                )));
2891            }
2892            Ok(Err(e)) => {
2893                span.record("error", true);
2894                span.record("error.type", "internal_error");
2895                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2896                self.metrics_tx.send(crate::metrics::MetricEvent {
2897                    ts: crate::metrics::unix_ms(),
2898                    tool: "edit_replace",
2899                    duration_ms: dur,
2900                    output_chars: 0,
2901                    param_path_depth: crate::metrics::path_component_count(&param_path),
2902                    max_depth: None,
2903                    result: "error",
2904                    error_type: Some("internal_error".to_string()),
2905                    session_id: sid.clone(),
2906                    seq: Some(seq),
2907                    cache_hit: None,
2908                    cache_write_failure: None,
2909                    cache_tier: None,
2910                    exit_code: None,
2911                    timed_out: false,
2912                });
2913                return Ok(err_to_tool_result(ErrorData::new(
2914                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2915                    e.to_string(),
2916                    Some(error_meta(
2917                        "resource",
2918                        false,
2919                        "check file path and permissions",
2920                    )),
2921                )));
2922            }
2923            Err(e) => {
2924                span.record("error", true);
2925                span.record("error.type", "internal_error");
2926                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2927                self.metrics_tx.send(crate::metrics::MetricEvent {
2928                    ts: crate::metrics::unix_ms(),
2929                    tool: "edit_replace",
2930                    duration_ms: dur,
2931                    output_chars: 0,
2932                    param_path_depth: crate::metrics::path_component_count(&param_path),
2933                    max_depth: None,
2934                    result: "error",
2935                    error_type: Some("internal_error".to_string()),
2936                    session_id: sid.clone(),
2937                    seq: Some(seq),
2938                    cache_hit: None,
2939                    cache_write_failure: None,
2940                    cache_tier: None,
2941                    exit_code: None,
2942                    timed_out: false,
2943                });
2944                return Ok(err_to_tool_result(ErrorData::new(
2945                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2946                    e.to_string(),
2947                    Some(error_meta(
2948                        "resource",
2949                        false,
2950                        "check file path and permissions",
2951                    )),
2952                )));
2953            }
2954        };
2955
2956        let text = format!(
2957            "Edited {}: {} bytes -> {} bytes",
2958            output.path, output.bytes_before, output.bytes_after
2959        );
2960        let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2961            .with_meta(Some(no_cache_meta()));
2962        let structured = match serde_json::to_value(&output).map_err(|e| {
2963            ErrorData::new(
2964                rmcp::model::ErrorCode::INTERNAL_ERROR,
2965                format!("serialization failed: {e}"),
2966                Some(error_meta("internal", false, "report this as a bug")),
2967            )
2968        }) {
2969            Ok(v) => v,
2970            Err(e) => return Ok(err_to_tool_result(e)),
2971        };
2972        result.structured_content = Some(structured);
2973        self.cache
2974            .invalidate_file(&std::path::PathBuf::from(&param_path));
2975        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2976        self.metrics_tx.send(crate::metrics::MetricEvent {
2977            ts: crate::metrics::unix_ms(),
2978            tool: "edit_replace",
2979            duration_ms: dur,
2980            output_chars: text.len(),
2981            param_path_depth: crate::metrics::path_component_count(&param_path),
2982            max_depth: None,
2983            result: "ok",
2984            error_type: None,
2985            session_id: sid,
2986            seq: Some(seq),
2987            cache_hit: None,
2988            cache_write_failure: None,
2989            cache_tier: None,
2990            exit_code: None,
2991            timed_out: false,
2992        });
2993        Ok(result)
2994    }
2995
2996    #[tool(
2997        name = "exec_command",
2998        title = "Exec Command",
2999        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.",
3000        output_schema = schema_for_type::<types::ShellOutput>(),
3001        annotations(
3002            title = "Exec Command",
3003            read_only_hint = false,
3004            destructive_hint = true,
3005            idempotent_hint = false,
3006            open_world_hint = true
3007        )
3008    )]
3009    #[instrument(skip(self, context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, command = tracing::field::Empty, exit_code = tracing::field::Empty, timed_out = tracing::field::Empty, output_truncated = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
3010    pub async fn exec_command(
3011        &self,
3012        params: Parameters<types::ExecCommandParams>,
3013        context: RequestContext<RoleServer>,
3014    ) -> Result<CallToolResult, ErrorData> {
3015        let t_start = std::time::Instant::now();
3016        let params = params.0;
3017        // Extract W3C Trace Context from request _meta if present
3018        let session_id = self.session_id.lock().await.clone();
3019        let client_name = self.client_name.lock().await.clone();
3020        let client_version = self.client_version.lock().await.clone();
3021        extract_and_set_trace_context(
3022            Some(&context.meta),
3023            ClientMetadata {
3024                session_id,
3025                client_name,
3026                client_version,
3027            },
3028        );
3029        let span = tracing::Span::current();
3030        span.record("gen_ai.system", "mcp");
3031        span.record("gen_ai.operation.name", "execute_tool");
3032        span.record("gen_ai.tool.name", "exec_command");
3033        span.record("command", &params.command);
3034
3035        // Validate working_dir if provided
3036        let working_dir_path = if let Some(ref wd) = params.working_dir {
3037            match validate_path(wd, true) {
3038                Ok(p) => {
3039                    // Verify it's a directory
3040                    if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
3041                        span.record("error", true);
3042                        span.record("error.type", "invalid_params");
3043                        return Ok(err_to_tool_result(ErrorData::new(
3044                            rmcp::model::ErrorCode::INVALID_PARAMS,
3045                            "working_dir must be a directory".to_string(),
3046                            Some(error_meta(
3047                                "validation",
3048                                false,
3049                                "provide a valid directory path",
3050                            )),
3051                        )));
3052                    }
3053                    Some(p)
3054                }
3055                Err(e) => {
3056                    span.record("error", true);
3057                    span.record("error.type", "invalid_params");
3058                    return Ok(err_to_tool_result(e));
3059                }
3060            }
3061        } else {
3062            None
3063        };
3064
3065        let param_path = params.working_dir.clone();
3066        let seq = self
3067            .session_call_seq
3068            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3069        let sid = self.session_id.lock().await.clone();
3070
3071        // Validate stdin size cap (1 MB)
3072        if let Some(ref stdin_content) = params.stdin
3073            && stdin_content.len() > STDIN_MAX_BYTES
3074        {
3075            span.record("error", true);
3076            span.record("error.type", "invalid_params");
3077            return Ok(err_to_tool_result(ErrorData::new(
3078                rmcp::model::ErrorCode::INVALID_PARAMS,
3079                "stdin exceeds 1 MB limit".to_string(),
3080                Some(error_meta("validation", false, "reduce stdin content size")),
3081            )));
3082        }
3083
3084        let command = params.command.clone();
3085        let timeout_secs = params.timeout_secs;
3086
3087        // Determine cache key and whether to use cache
3088        let _cache_key = (
3089            command.clone(),
3090            working_dir_path
3091                .as_ref()
3092                .map(|p| p.display().to_string())
3093                .unwrap_or_default(),
3094        );
3095        // Execute command (caching disabled; explicit opt-in via cache=true not implemented)
3096        let output = run_exec_impl(
3097            command.clone(),
3098            working_dir_path.clone(),
3099            timeout_secs,
3100            params.memory_limit_mb,
3101            params.cpu_limit_secs,
3102            params.stdin.clone(),
3103            seq,
3104        )
3105        .await;
3106
3107        let exit_code = output.exit_code;
3108        let timed_out = output.timed_out;
3109        let output_truncated = output.output_truncated;
3110
3111        // Record execution results on span
3112        if let Some(code) = exit_code {
3113            span.record("exit_code", code);
3114        }
3115        span.record("timed_out", timed_out);
3116        span.record("output_truncated", output_truncated);
3117
3118        // Emit debug event for truncation
3119        if output_truncated {
3120            tracing::debug!(truncated = true, message = "output truncated");
3121        }
3122
3123        // Use interleaved if non-empty; fall back to separated stdout/stderr for empty-output commands
3124        let output_text = if output.interleaved.is_empty() {
3125            format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
3126        } else {
3127            format!("Output:\n{}", output.interleaved)
3128        };
3129
3130        let text = format!(
3131            "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
3132            params.command,
3133            exit_code
3134                .map(|c| c.to_string())
3135                .unwrap_or_else(|| "null".to_string()),
3136            timed_out,
3137            output_truncated,
3138            output_text,
3139        );
3140
3141        let content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
3142
3143        // Determine if command failed: timeout or non-zero exit code.
3144        // exit_code is None when: (a) process killed by O1 post-exit drain timeout (background child
3145        // holding pipes -- command work was done, treat as success) or (b) externally killed; both
3146        // cases use unwrap_or(false) to avoid false negatives.
3147        let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
3148
3149        let mut result = if command_failed {
3150            CallToolResult::error(content_blocks)
3151        } else {
3152            CallToolResult::success(content_blocks)
3153        }
3154        .with_meta(Some(no_cache_meta()));
3155
3156        let structured = match serde_json::to_value(&output).map_err(|e| {
3157            ErrorData::new(
3158                rmcp::model::ErrorCode::INTERNAL_ERROR,
3159                format!("serialization failed: {e}"),
3160                Some(error_meta("internal", false, "report this as a bug")),
3161            )
3162        }) {
3163            Ok(v) => v,
3164            Err(e) => {
3165                span.record("error", true);
3166                span.record("error.type", "internal_error");
3167                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3168                self.metrics_tx.send(crate::metrics::MetricEvent {
3169                    ts: crate::metrics::unix_ms(),
3170                    tool: "exec_command",
3171                    duration_ms: dur,
3172                    output_chars: 0,
3173                    param_path_depth: crate::metrics::path_component_count(
3174                        param_path.as_deref().unwrap_or(""),
3175                    ),
3176                    max_depth: None,
3177                    result: "error",
3178                    error_type: Some("internal_error".to_string()),
3179                    session_id: sid.clone(),
3180                    seq: Some(seq),
3181                    cache_hit: Some(false),
3182                    cache_write_failure: None,
3183                    cache_tier: None,
3184                    exit_code,
3185                    timed_out,
3186                });
3187                return Ok(err_to_tool_result(e));
3188            }
3189        };
3190
3191        result.structured_content = Some(structured);
3192        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3193        self.metrics_tx.send(crate::metrics::MetricEvent {
3194            ts: crate::metrics::unix_ms(),
3195            tool: "exec_command",
3196            duration_ms: dur,
3197            output_chars: text.len(),
3198            param_path_depth: crate::metrics::path_component_count(
3199                param_path.as_deref().unwrap_or(""),
3200            ),
3201            max_depth: None,
3202            result: "ok",
3203            error_type: None,
3204            session_id: sid,
3205            seq: Some(seq),
3206            cache_hit: Some(false),
3207            cache_write_failure: None,
3208            cache_tier: None,
3209            exit_code,
3210            timed_out,
3211        });
3212        Ok(result)
3213    }
3214
3215    #[tool(
3216        name = "remote_tree",
3217        title = "Remote Tree",
3218        description = "For uncloned repositories only. Explore a remote GitLab or GitHub repository directory structure without cloning. Returns a compact summary of files and directories with extension counts and individual entries. Supports gitlab.com and github.com URLs. Requires GITLAB_TOKEN or GITHUB_TOKEN environment variable. Fails if the URL scheme is not https://, the host is unsupported, the token is missing, or the path or ref does not exist. Use remote_file to read a specific file from the same repository. Example queries: List top-level files in https://github.com/org/repo; Show the src/ directory at a specific tag in https://gitlab.com/org/repo.",
3219        output_schema = schema_for_type::<aptu_coder_remote::types::RemoteTreeOutput>(),
3220        annotations(
3221            title = "Remote Tree",
3222            read_only_hint = true,
3223            destructive_hint = false,
3224            idempotent_hint = true,
3225            open_world_hint = true
3226        )
3227    )]
3228    #[instrument(skip(self, _context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, url = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
3229    pub async fn remote_tree(
3230        &self,
3231        params: Parameters<aptu_coder_remote::types::RemoteTreeParams>,
3232        _context: RequestContext<RoleServer>,
3233    ) -> Result<CallToolResult, ErrorData> {
3234        let params = params.0;
3235        let span = tracing::Span::current();
3236        span.record("gen_ai.system", "mcp");
3237        span.record("gen_ai.operation.name", "execute_tool");
3238        span.record("gen_ai.tool.name", "remote_tree");
3239        span.record("url", &params.url);
3240
3241        let start = std::time::Instant::now();
3242        let sid = self.session_id.lock().await.clone();
3243        let seq = self
3244            .session_call_seq
3245            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3246
3247        let depth = params.depth.unwrap_or(2);
3248        let output = aptu_coder_remote::fetch_tree(
3249            &params.url,
3250            params.path.as_deref(),
3251            params.git_ref.as_deref(),
3252            depth,
3253        )
3254        .await;
3255
3256        match output {
3257            Ok(tree) => {
3258                let text = tree.formatted.clone();
3259                let structured = match serde_json::to_value(&tree) {
3260                    Ok(v) => v,
3261                    Err(e) => {
3262                        span.record("error", true);
3263                        span.record("error.type", "internal_error");
3264                        let dur = start.elapsed().as_millis() as u64;
3265                        self.metrics_tx.send(crate::metrics::MetricEvent {
3266                            ts: crate::metrics::unix_ms(),
3267                            tool: "remote_tree",
3268                            duration_ms: dur,
3269                            output_chars: 0,
3270                            param_path_depth: 0,
3271                            max_depth: None,
3272                            result: "error",
3273                            error_type: Some("serialization".to_string()),
3274                            session_id: sid,
3275                            seq: Some(seq),
3276                            cache_hit: None,
3277                            cache_write_failure: None,
3278                            cache_tier: None,
3279                            exit_code: None,
3280                            timed_out: false,
3281                        });
3282                        return Ok(err_to_tool_result(ErrorData::new(
3283                            rmcp::model::ErrorCode::INTERNAL_ERROR,
3284                            format!("serialization failed: {e}"),
3285                            Some(error_meta("internal", false, "report this as a bug")),
3286                        )));
3287                    }
3288                };
3289                let dur = start.elapsed().as_millis() as u64;
3290                self.metrics_tx.send(crate::metrics::MetricEvent {
3291                    ts: crate::metrics::unix_ms(),
3292                    tool: "remote_tree",
3293                    duration_ms: dur,
3294                    output_chars: text.len(),
3295                    param_path_depth: 0,
3296                    max_depth: None,
3297                    result: "ok",
3298                    error_type: None,
3299                    session_id: sid,
3300                    seq: Some(seq),
3301                    cache_hit: None,
3302                    cache_write_failure: None,
3303                    cache_tier: None,
3304                    exit_code: None,
3305                    timed_out: false,
3306                });
3307                let mut result = CallToolResult::success(vec![Content::text(text)])
3308                    .with_meta(Some(no_cache_meta()));
3309                result.structured_content = Some(structured);
3310                Ok(result)
3311            }
3312            Err(e) => {
3313                span.record("error", true);
3314                span.record("error.type", "remote_error");
3315                let (code, category, retryable, action) = match &e {
3316                    aptu_coder_remote::RemoteError::MissingGitLabToken
3317                    | aptu_coder_remote::RemoteError::MissingGitHubToken => (
3318                        rmcp::model::ErrorCode::INVALID_PARAMS,
3319                        "auth",
3320                        false,
3321                        "Set GITLAB_TOKEN or GITHUB_TOKEN env var",
3322                    ),
3323                    aptu_coder_remote::RemoteError::UnsupportedHost(_) => (
3324                        rmcp::model::ErrorCode::INVALID_PARAMS,
3325                        "params",
3326                        false,
3327                        "Use gitlab.com or github.com URL",
3328                    ),
3329                    aptu_coder_remote::RemoteError::NotFound(_) => (
3330                        rmcp::model::ErrorCode::INVALID_PARAMS,
3331                        "params",
3332                        false,
3333                        "Check path and ref",
3334                    ),
3335                    aptu_coder_remote::RemoteError::InvalidLineRange(_) => (
3336                        rmcp::model::ErrorCode::INVALID_PARAMS,
3337                        "params",
3338                        false,
3339                        "Use format START-END e.g. 10-50",
3340                    ),
3341                    _ => (
3342                        rmcp::model::ErrorCode::INTERNAL_ERROR,
3343                        "api",
3344                        true,
3345                        "Retry or check token permissions",
3346                    ),
3347                };
3348                let dur = start.elapsed().as_millis() as u64;
3349                let error_type = match &e {
3350                    aptu_coder_remote::RemoteError::MissingGitLabToken => "missing_gitlab_token",
3351                    aptu_coder_remote::RemoteError::MissingGitHubToken => "missing_github_token",
3352                    aptu_coder_remote::RemoteError::UnsupportedHost(_) => "unsupported_host",
3353                    aptu_coder_remote::RemoteError::NotFound(_) => "not_found",
3354                    aptu_coder_remote::RemoteError::InvalidLineRange(_) => "invalid_line_range",
3355                    _ => "remote_error",
3356                };
3357                self.metrics_tx.send(crate::metrics::MetricEvent {
3358                    ts: crate::metrics::unix_ms(),
3359                    tool: "remote_tree",
3360                    duration_ms: dur,
3361                    output_chars: 0,
3362                    param_path_depth: 0,
3363                    max_depth: None,
3364                    result: "error",
3365                    error_type: Some(error_type.to_string()),
3366                    session_id: sid,
3367                    seq: Some(seq),
3368                    cache_hit: None,
3369                    cache_write_failure: None,
3370                    cache_tier: None,
3371                    exit_code: None,
3372                    timed_out: false,
3373                });
3374                Ok(err_to_tool_result(ErrorData::new(
3375                    code,
3376                    e.to_string(),
3377                    Some(error_meta(category, retryable, action)),
3378                )))
3379            }
3380        }
3381    }
3382
3383    #[tool(
3384        name = "remote_file",
3385        title = "Remote File",
3386        description = "For uncloned repositories only. Fetch the content of a single file from a remote GitLab or GitHub repository without cloning. Returns file content, size_bytes, resolved_ref, and path. Supports optional line range slicing (START-END format) to keep context cost low. Requires GITLAB_TOKEN or GITHUB_TOKEN environment variable. Fails if the URL scheme is not https://, the host is unsupported, the token is missing, the file or ref does not exist, or line_range format is invalid. Use remote_tree to discover paths in the same repository. Example queries: Read README.md from https://github.com/org/repo; Show lines 10-50 of src/main.rs in a GitLab project.",
3387        output_schema = schema_for_type::<aptu_coder_remote::types::RemoteFileOutput>(),
3388        annotations(
3389            title = "Remote File",
3390            read_only_hint = true,
3391            destructive_hint = false,
3392            idempotent_hint = true,
3393            open_world_hint = true
3394        )
3395    )]
3396    #[instrument(skip(self, _context), fields(gen_ai.system = tracing::field::Empty, gen_ai.operation.name = tracing::field::Empty, gen_ai.tool.name = tracing::field::Empty, error = tracing::field::Empty, error.type = tracing::field::Empty, url = tracing::field::Empty, mcp.session.id = tracing::field::Empty, client.name = tracing::field::Empty, client.version = tracing::field::Empty, mcp.client.session.id = tracing::field::Empty))]
3397    pub async fn remote_file(
3398        &self,
3399        params: Parameters<aptu_coder_remote::types::RemoteFileParams>,
3400        _context: RequestContext<RoleServer>,
3401    ) -> Result<CallToolResult, ErrorData> {
3402        let params = params.0;
3403        let span = tracing::Span::current();
3404        span.record("gen_ai.system", "mcp");
3405        span.record("gen_ai.operation.name", "execute_tool");
3406        span.record("gen_ai.tool.name", "remote_file");
3407        span.record("url", &params.url);
3408
3409        let start = std::time::Instant::now();
3410        let sid = self.session_id.lock().await.clone();
3411        let seq = self
3412            .session_call_seq
3413            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3414
3415        let output = aptu_coder_remote::fetch_file(
3416            &params.url,
3417            &params.path,
3418            params.git_ref.as_deref(),
3419            params.line_range.as_deref(),
3420        )
3421        .await;
3422
3423        match output {
3424            Ok(file) => {
3425                let text = file.content.clone();
3426                let structured = match serde_json::to_value(&file) {
3427                    Ok(v) => v,
3428                    Err(e) => {
3429                        span.record("error", true);
3430                        span.record("error.type", "internal_error");
3431                        let dur = start.elapsed().as_millis() as u64;
3432                        self.metrics_tx.send(crate::metrics::MetricEvent {
3433                            ts: crate::metrics::unix_ms(),
3434                            tool: "remote_file",
3435                            duration_ms: dur,
3436                            output_chars: 0,
3437                            param_path_depth: 0,
3438                            max_depth: None,
3439                            result: "error",
3440                            error_type: Some("serialization".to_string()),
3441                            session_id: sid,
3442                            seq: Some(seq),
3443                            cache_hit: None,
3444                            cache_write_failure: None,
3445                            cache_tier: None,
3446                            exit_code: None,
3447                            timed_out: false,
3448                        });
3449                        return Ok(err_to_tool_result(ErrorData::new(
3450                            rmcp::model::ErrorCode::INTERNAL_ERROR,
3451                            format!("serialization failed: {e}"),
3452                            Some(error_meta("internal", false, "report this as a bug")),
3453                        )));
3454                    }
3455                };
3456                let dur = start.elapsed().as_millis() as u64;
3457                self.metrics_tx.send(crate::metrics::MetricEvent {
3458                    ts: crate::metrics::unix_ms(),
3459                    tool: "remote_file",
3460                    duration_ms: dur,
3461                    output_chars: text.len(),
3462                    param_path_depth: 0,
3463                    max_depth: None,
3464                    result: "ok",
3465                    error_type: None,
3466                    session_id: sid,
3467                    seq: Some(seq),
3468                    cache_hit: None,
3469                    cache_write_failure: None,
3470                    cache_tier: None,
3471                    exit_code: None,
3472                    timed_out: false,
3473                });
3474                let mut result = CallToolResult::success(vec![Content::text(text)])
3475                    .with_meta(Some(no_cache_meta()));
3476                result.structured_content = Some(structured);
3477                Ok(result)
3478            }
3479            Err(e) => {
3480                span.record("error", true);
3481                span.record("error.type", "remote_error");
3482                let (code, category, retryable, action) = match &e {
3483                    aptu_coder_remote::RemoteError::MissingGitLabToken
3484                    | aptu_coder_remote::RemoteError::MissingGitHubToken => (
3485                        rmcp::model::ErrorCode::INVALID_PARAMS,
3486                        "auth",
3487                        false,
3488                        "Set GITLAB_TOKEN or GITHUB_TOKEN env var",
3489                    ),
3490                    aptu_coder_remote::RemoteError::UnsupportedHost(_) => (
3491                        rmcp::model::ErrorCode::INVALID_PARAMS,
3492                        "params",
3493                        false,
3494                        "Use gitlab.com or github.com URL",
3495                    ),
3496                    aptu_coder_remote::RemoteError::NotFound(_) => (
3497                        rmcp::model::ErrorCode::INVALID_PARAMS,
3498                        "params",
3499                        false,
3500                        "Check path and ref",
3501                    ),
3502                    aptu_coder_remote::RemoteError::InvalidLineRange(_) => (
3503                        rmcp::model::ErrorCode::INVALID_PARAMS,
3504                        "params",
3505                        false,
3506                        "Use format START-END e.g. 10-50",
3507                    ),
3508                    _ => (
3509                        rmcp::model::ErrorCode::INTERNAL_ERROR,
3510                        "api",
3511                        true,
3512                        "Retry or check token permissions",
3513                    ),
3514                };
3515                let dur = start.elapsed().as_millis() as u64;
3516                let error_type = match &e {
3517                    aptu_coder_remote::RemoteError::MissingGitLabToken => "missing_gitlab_token",
3518                    aptu_coder_remote::RemoteError::MissingGitHubToken => "missing_github_token",
3519                    aptu_coder_remote::RemoteError::UnsupportedHost(_) => "unsupported_host",
3520                    aptu_coder_remote::RemoteError::NotFound(_) => "not_found",
3521                    aptu_coder_remote::RemoteError::InvalidLineRange(_) => "invalid_line_range",
3522                    _ => "remote_error",
3523                };
3524                self.metrics_tx.send(crate::metrics::MetricEvent {
3525                    ts: crate::metrics::unix_ms(),
3526                    tool: "remote_file",
3527                    duration_ms: dur,
3528                    output_chars: 0,
3529                    param_path_depth: 0,
3530                    max_depth: None,
3531                    result: "error",
3532                    error_type: Some(error_type.to_string()),
3533                    session_id: sid,
3534                    seq: Some(seq),
3535                    cache_hit: None,
3536                    cache_write_failure: None,
3537                    cache_tier: None,
3538                    exit_code: None,
3539                    timed_out: false,
3540                });
3541                Ok(err_to_tool_result(ErrorData::new(
3542                    code,
3543                    e.to_string(),
3544                    Some(error_meta(category, retryable, action)),
3545                )))
3546            }
3547        }
3548    }
3549}
3550
3551/// Build and configure a tokio::process::Command with stdio, working directory, and resource limits.
3552fn build_exec_command(
3553    command: &str,
3554    working_dir_path: Option<&std::path::PathBuf>,
3555    memory_limit_mb: Option<u64>,
3556    cpu_limit_secs: Option<u64>,
3557    stdin_present: bool,
3558) -> tokio::process::Command {
3559    let shell = resolve_shell();
3560    let mut cmd = tokio::process::Command::new(shell);
3561    cmd.arg("-c").arg(command);
3562
3563    if let Some(wd) = working_dir_path {
3564        cmd.current_dir(wd);
3565    }
3566
3567    cmd.stdout(std::process::Stdio::piped())
3568        .stderr(std::process::Stdio::piped());
3569
3570    if stdin_present {
3571        cmd.stdin(std::process::Stdio::piped());
3572    } else {
3573        cmd.stdin(std::process::Stdio::null());
3574    }
3575
3576    #[cfg(unix)]
3577    {
3578        #[cfg(not(target_os = "linux"))]
3579        if memory_limit_mb.is_some() {
3580            warn!("memory_limit_mb is not enforced on this platform (Linux only)");
3581        }
3582        if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
3583            unsafe {
3584                cmd.pre_exec(move || {
3585                    #[cfg(target_os = "linux")]
3586                    if let Some(mb) = memory_limit_mb {
3587                        let bytes = mb.saturating_mul(1024 * 1024);
3588                        setrlimit(Resource::RLIMIT_AS, bytes, bytes)
3589                            .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3590                    }
3591                    if let Some(cpu) = cpu_limit_secs {
3592                        setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
3593                            .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3594                    }
3595                    Ok(())
3596                });
3597            }
3598        }
3599    }
3600
3601    cmd
3602}
3603
3604/// Run a spawned child process with timeout handling and output draining.
3605/// Returns (exit_code, timed_out, output_truncated, output_collection_error).
3606async fn run_with_timeout(
3607    mut child: tokio::process::Child,
3608    timeout_secs: Option<u64>,
3609    tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
3610) -> (Option<i32>, bool, bool, Option<String>) {
3611    use tokio::io::AsyncBufReadExt as _;
3612    use tokio_stream::StreamExt as TokioStreamExt;
3613    use tokio_stream::wrappers::LinesStream;
3614
3615    let stdout_pipe = child.stdout.take();
3616    let stderr_pipe = child.stderr.take();
3617
3618    let mut drain_task = tokio::spawn(async move {
3619        let so_stream = stdout_pipe.map(|p| {
3620            LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (false, s)))
3621        });
3622        let se_stream = stderr_pipe.map(|p| {
3623            LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3624        });
3625
3626        match (so_stream, se_stream) {
3627            (Some(so), Some(se)) => {
3628                let mut merged = so.merge(se);
3629                while let Some(Ok((is_stderr, line))) = merged.next().await {
3630                    let _ = tx.send((is_stderr, line));
3631                }
3632            }
3633            (Some(so), None) => {
3634                let mut stream = so;
3635                while let Some(Ok((_, line))) = stream.next().await {
3636                    let _ = tx.send((false, line));
3637                }
3638            }
3639            (None, Some(se)) => {
3640                let mut stream = se;
3641                while let Some(Ok((_, line))) = stream.next().await {
3642                    let _ = tx.send((true, line));
3643                }
3644            }
3645            (None, None) => {}
3646        }
3647    });
3648
3649    tokio::select! {
3650        _ = &mut drain_task => {
3651            let (status, drain_truncated) = match tokio::time::timeout(
3652                std::time::Duration::from_millis(500),
3653                child.wait()
3654            ).await {
3655                Ok(Ok(s)) => (Some(s), false),
3656                Ok(Err(_)) => (None, false),
3657                Err(_) => {
3658                    child.start_kill().ok();
3659                    let _ = child.wait().await;
3660                    (None, true)
3661                }
3662            };
3663            let exit_code = status.and_then(|s| s.code());
3664            let ocerr = if drain_truncated {
3665                Some("post-exit drain timeout: background process held pipes".to_string())
3666            } else {
3667                None
3668            };
3669            (exit_code, false, drain_truncated, ocerr)
3670        }
3671        _ = async {
3672            if let Some(secs) = timeout_secs {
3673                tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3674            } else {
3675                std::future::pending::<()>().await;
3676            }
3677        } => {
3678            let _ = child.kill().await;
3679            let _ = child.wait().await;
3680            drain_task.abort();
3681            (None, true, false, None)
3682        }
3683    }
3684}
3685
3686/// Executes a shell command and returns the output.
3687/// This is a free async function (not a method) to allow use in moka::future::Cache::get_with().
3688/// It spawns the command, collects output with timeout handling, and persists output to slot files.
3689async fn run_exec_impl(
3690    command: String,
3691    working_dir_path: Option<std::path::PathBuf>,
3692    timeout_secs: Option<u64>,
3693    memory_limit_mb: Option<u64>,
3694    cpu_limit_secs: Option<u64>,
3695    stdin: Option<String>,
3696    seq: u32,
3697) -> types::ShellOutput {
3698    let mut cmd = build_exec_command(
3699        &command,
3700        working_dir_path.as_ref(),
3701        memory_limit_mb,
3702        cpu_limit_secs,
3703        stdin.is_some(),
3704    );
3705
3706    let mut child = match cmd.spawn() {
3707        Ok(c) => c,
3708        Err(e) => {
3709            return types::ShellOutput::new(
3710                String::new(),
3711                format!("failed to spawn command: {e}"),
3712                format!("failed to spawn command: {e}"),
3713                None,
3714                false,
3715                false,
3716            );
3717        }
3718    };
3719
3720    if let Some(stdin_content) = stdin
3721        && let Some(mut stdin_handle) = child.stdin.take()
3722    {
3723        use tokio::io::AsyncWriteExt as _;
3724        match stdin_handle.write_all(stdin_content.as_bytes()).await {
3725            Ok(()) => {
3726                drop(stdin_handle);
3727            }
3728            Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
3729            Err(e) => {
3730                warn!("failed to write stdin: {e}");
3731            }
3732        }
3733    }
3734
3735    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, String)>();
3736
3737    let (exit_code, timed_out, mut output_truncated, output_collection_error) =
3738        run_with_timeout(child, timeout_secs, tx).await;
3739
3740    let mut lines: Vec<(bool, String)> = Vec::new();
3741    while let Some(item) = rx.recv().await {
3742        lines.push(item);
3743    }
3744
3745    // Split tagged lines into stdout, stderr, interleaved post-facto (no locks needed).
3746    const MAX_BYTES: usize = 50 * 1024;
3747    let mut stdout_str = String::new();
3748    let mut stderr_str = String::new();
3749    let mut interleaved_str = String::new();
3750    let mut so_bytes = 0usize;
3751    let mut se_bytes = 0usize;
3752    let mut il_bytes = 0usize;
3753    for (is_stderr, line) in &lines {
3754        let entry = format!("{line}\n");
3755        if il_bytes < 2 * MAX_BYTES {
3756            il_bytes += entry.len();
3757            interleaved_str.push_str(&entry);
3758        }
3759        if *is_stderr {
3760            if se_bytes < MAX_BYTES {
3761                se_bytes += entry.len();
3762                stderr_str.push_str(&entry);
3763            }
3764        } else if so_bytes < MAX_BYTES {
3765            so_bytes += entry.len();
3766            stdout_str.push_str(&entry);
3767        }
3768    }
3769
3770    let slot = seq % 8;
3771    let (stdout, stderr, stdout_path, stderr_path) =
3772        handle_output_persist(stdout_str, stderr_str, slot);
3773    output_truncated = output_truncated || stdout_path.is_some();
3774
3775    let mut output = types::ShellOutput::new(
3776        stdout,
3777        stderr,
3778        interleaved_str,
3779        exit_code,
3780        timed_out,
3781        output_truncated,
3782    );
3783    output.output_collection_error = output_collection_error;
3784    output.stdout_path = stdout_path;
3785    output.stderr_path = stderr_path;
3786
3787    output
3788}
3789
3790/// Handles output persistence by writing to slot files only when output overflows the line limit.
3791/// Writes full stdout/stderr to:
3792///   {temp_dir}/aptu-coder-overflow/slot-{slot}/{stdout,stderr}
3793/// Returns (stdout_out, stderr_out, stdout_path, stderr_path).
3794/// On overflow: truncates to last 50 lines and sets paths to Some.
3795/// Under limit: returns output unchanged and paths as None (no I/O).
3796fn handle_output_persist(
3797    stdout: String,
3798    stderr: String,
3799    slot: u32,
3800) -> (String, String, Option<String>, Option<String>) {
3801    const MAX_OUTPUT_LINES: usize = 2000;
3802    const OVERFLOW_PREVIEW_LINES: usize = 50;
3803
3804    let stdout_lines: Vec<&str> = stdout.lines().collect();
3805    let stderr_lines: Vec<&str> = stderr.lines().collect();
3806
3807    // No overflow: return as-is with no I/O.
3808    if stdout_lines.len() <= MAX_OUTPUT_LINES && stderr_lines.len() <= MAX_OUTPUT_LINES {
3809        return (stdout, stderr, None, None);
3810    }
3811
3812    // Overflow: write slot files and return last-N-lines preview.
3813    let base = std::env::temp_dir()
3814        .join("aptu-coder-overflow")
3815        .join(format!("slot-{slot}"));
3816    let _ = std::fs::create_dir_all(&base);
3817
3818    let stdout_path = base.join("stdout");
3819    let stderr_path = base.join("stderr");
3820
3821    let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3822    let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3823
3824    let stdout_path_str = stdout_path.display().to_string();
3825    let stderr_path_str = stderr_path.display().to_string();
3826
3827    let stdout_preview = if stdout_lines.len() > MAX_OUTPUT_LINES {
3828        stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3829    } else {
3830        stdout
3831    };
3832    let stderr_preview = if stderr_lines.len() > MAX_OUTPUT_LINES {
3833        stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3834    } else {
3835        stderr
3836    };
3837
3838    (
3839        stdout_preview,
3840        stderr_preview,
3841        Some(stdout_path_str),
3842        Some(stderr_path_str),
3843    )
3844}
3845
3846/// Truncates output to a maximum number of lines and bytes.
3847/// Returns (truncated_output, was_truncated).
3848
3849#[derive(Clone)]
3850struct FocusedAnalysisParams {
3851    path: std::path::PathBuf,
3852    symbol: String,
3853    match_mode: SymbolMatchMode,
3854    follow_depth: u32,
3855    max_depth: Option<u32>,
3856    ast_recursion_limit: Option<usize>,
3857    use_summary: bool,
3858    impl_only: Option<bool>,
3859    def_use: bool,
3860    parse_timeout_micros: Option<u64>,
3861}
3862
3863fn disable_routes(router: &mut ToolRouter<CodeAnalyzer>, tools: &[&'static str]) {
3864    for tool in tools {
3865        router.disable_route(*tool);
3866    }
3867}
3868
3869#[tool_handler]
3870impl ServerHandler for CodeAnalyzer {
3871    #[instrument(skip(self, context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
3872    async fn initialize(
3873        &self,
3874        request: InitializeRequestParams,
3875        context: RequestContext<RoleServer>,
3876    ) -> Result<InitializeResult, ErrorData> {
3877        let span = tracing::Span::current();
3878        span.record("service.name", "aptu-coder");
3879        span.record("service.version", env!("CARGO_PKG_VERSION"));
3880
3881        // Store client_info from the initialize request
3882        {
3883            let mut client_name_lock = self.client_name.lock().await;
3884            *client_name_lock = Some(request.client_info.name.clone());
3885        }
3886        {
3887            let mut client_version_lock = self.client_version.lock().await;
3888            *client_version_lock = Some(request.client_info.version.clone());
3889        }
3890
3891        // The _meta field is extracted from params and stored in request extensions.
3892        // Extract it and store for use in on_initialized.
3893        if let Some(meta) = context.extensions.get::<Meta>() {
3894            let mut meta_lock = self.profile_meta.lock().await;
3895            *meta_lock = Some(meta.0.clone());
3896        }
3897        Ok(self.get_info())
3898    }
3899
3900    fn get_info(&self) -> InitializeResult {
3901        let excluded = crate::EXCLUDED_DIRS.join(", ");
3902        let instructions = format!(
3903            "Recommended workflow:\n\
3904            1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3905            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\
3906            3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3907            4. Use analyze_symbol to trace call graphs.\n\
3908            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."
3909        );
3910        let capabilities = ServerCapabilities::builder()
3911            .enable_logging()
3912            .enable_tools()
3913            .enable_tool_list_changed()
3914            .enable_completions()
3915            .build();
3916        let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3917            .with_title("Aptu Coder")
3918            .with_description("MCP server for code structure analysis using tree-sitter");
3919        InitializeResult::new(capabilities)
3920            .with_server_info(server_info)
3921            .with_instructions(&instructions)
3922    }
3923
3924    async fn list_tools(
3925        &self,
3926        _request: Option<rmcp::model::PaginatedRequestParams>,
3927        _context: RequestContext<RoleServer>,
3928    ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
3929        let router = self.tool_router.read().await;
3930        Ok(rmcp::model::ListToolsResult {
3931            tools: router.list_all(),
3932            meta: None,
3933            next_cursor: None,
3934        })
3935    }
3936
3937    async fn call_tool(
3938        &self,
3939        request: rmcp::model::CallToolRequestParams,
3940        context: RequestContext<RoleServer>,
3941    ) -> Result<CallToolResult, ErrorData> {
3942        let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
3943        let router = self.tool_router.read().await;
3944        router.call(tcc).await
3945    }
3946
3947    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
3948        let mut peer_lock = self.peer.lock().await;
3949        *peer_lock = Some(context.peer.clone());
3950        drop(peer_lock);
3951
3952        // Generate session_id in MILLIS-N format
3953        let millis = std::time::SystemTime::now()
3954            .duration_since(std::time::UNIX_EPOCH)
3955            .unwrap_or_default()
3956            .as_millis()
3957            .try_into()
3958            .unwrap_or(u64::MAX);
3959        let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3960        let sid = format!("{millis}-{counter}");
3961        {
3962            let mut session_id_lock = self.session_id.lock().await;
3963            *session_id_lock = Some(sid);
3964        }
3965        self.session_call_seq
3966            .store(0, std::sync::atomic::Ordering::Relaxed);
3967
3968        // NON-STANDARD VENDOR EXTENSION: profile-based tool filtering.
3969        // The MCP 2025-11-25 spec has no profile or tool-subset concept; tools/list returns
3970        // all tools with no filtering parameters. This mechanism is retained solely for
3971        // controlled benchmarking (wave10/11). Do not promote or document it as a product
3972        // feature. The spec-compliant way to restrict tools is for the orchestrator to pass
3973        // a filtered `tools` array in the API call, or for clients to use tool annotations
3974        // (readOnlyHint/destructiveHint) to apply their own policy.
3975        // Profiles: "edit" (3 tools), "analyze" (5 tools), "compact" (7 tools), "remote" (9 tools), absent/unknown (9 tools).
3976        // _meta key "io.clouatre-labs/profile" takes precedence over APTU_CODER_PROFILE env var.
3977        let meta_lock = self.profile_meta.lock().await;
3978        let meta_profile = meta_lock
3979            .as_ref()
3980            .and_then(|m| m.get("io.clouatre-labs/profile"))
3981            .and_then(|v| v.as_str())
3982            .map(str::to_owned);
3983        drop(meta_lock);
3984
3985        // Resolve the active profile: _meta wins; fall back to env var.
3986        let active_profile = meta_profile.or(std::env::var("APTU_CODER_PROFILE").ok());
3987
3988        {
3989            let mut router = self.tool_router.write().await;
3990
3991            // Default: all 9 tools enabled unless profile explicitly disables them.
3992            // Profiles: "edit" (3 tools), "analyze" (5 tools), "compact" (7 tools), "remote" (9 tools), absent/unknown (9 tools).
3993            let enable_remote = !matches!(
3994                active_profile.as_deref(),
3995                Some("compact") | Some("edit") | Some("analyze")
3996            );
3997            // Add new remote_* tool names here when introduced.
3998            if !enable_remote {
3999                disable_routes(&mut router, &["remote_tree", "remote_file"]);
4000            }
4001
4002            if let Some(ref profile) = active_profile {
4003                match profile.as_str() {
4004                    "edit" => {
4005                        // Enable only: edit_replace, edit_overwrite, exec_command
4006                        disable_routes(
4007                            &mut router,
4008                            &[
4009                                "analyze_directory",
4010                                "analyze_file",
4011                                "analyze_module",
4012                                "analyze_symbol",
4013                            ],
4014                        );
4015                        // remote_tree and remote_file already disabled above
4016                    }
4017                    "analyze" => {
4018                        // Enable only: analyze_directory, analyze_file, analyze_module, analyze_symbol, exec_command
4019                        disable_routes(&mut router, &["edit_replace", "edit_overwrite"]);
4020                        // remote_tree and remote_file already disabled above
4021                    }
4022                    "compact" => {
4023                        // Enable 7 tools: all except remote_tree and remote_file
4024                        // remote_tree and remote_file already disabled above
4025                    }
4026                    "remote" => {
4027                        // All 9 tools enabled; remote tools re-enabled by enable_remote=true above
4028                    }
4029                    _ => {
4030                        // Unknown profile: all 9 tools enabled (lenient fallback)
4031                    }
4032                }
4033            }
4034
4035            // Bind peer notifier after disabling tools to send tools/list_changed notification
4036            router.bind_peer_notifier(&context.peer);
4037        }
4038
4039        // Spawn consumer task to drain log events from channel with batching.
4040        let peer = self.peer.clone();
4041        let event_rx = self.event_rx.clone();
4042
4043        tokio::spawn(async move {
4044            let rx = {
4045                let mut rx_lock = event_rx.lock().await;
4046                rx_lock.take()
4047            };
4048
4049            if let Some(mut receiver) = rx {
4050                let mut buffer = Vec::with_capacity(64);
4051                loop {
4052                    // Drain up to 64 events from channel
4053                    receiver.recv_many(&mut buffer, 64).await;
4054
4055                    if buffer.is_empty() {
4056                        // Channel closed, exit consumer task
4057                        break;
4058                    }
4059
4060                    // Acquire peer lock once per batch
4061                    let peer_lock = peer.lock().await;
4062                    if let Some(peer) = peer_lock.as_ref() {
4063                        for log_event in buffer.drain(..) {
4064                            let notification = ServerNotification::LoggingMessageNotification(
4065                                Notification::new(LoggingMessageNotificationParam {
4066                                    level: log_event.level,
4067                                    logger: Some(log_event.logger),
4068                                    data: log_event.data,
4069                                }),
4070                            );
4071                            if let Err(e) = peer.send_notification(notification).await {
4072                                warn!("Failed to send logging notification: {}", e);
4073                            }
4074                        }
4075                    }
4076                }
4077            }
4078        });
4079    }
4080
4081    #[instrument(skip(self, _context))]
4082    async fn on_cancelled(
4083        &self,
4084        notification: CancelledNotificationParam,
4085        _context: NotificationContext<RoleServer>,
4086    ) {
4087        tracing::info!(
4088            request_id = ?notification.request_id,
4089            reason = ?notification.reason,
4090            "Received cancellation notification"
4091        );
4092    }
4093
4094    #[instrument(skip(self, _context))]
4095    async fn complete(
4096        &self,
4097        request: CompleteRequestParams,
4098        _context: RequestContext<RoleServer>,
4099    ) -> Result<CompleteResult, ErrorData> {
4100        // Dispatch on argument name: "path" or "symbol"
4101        let argument_name = &request.argument.name;
4102        let argument_value = &request.argument.value;
4103
4104        let completions = match argument_name.as_str() {
4105            "path" => {
4106                // Path completions: use current directory as root
4107                let root = Path::new(".");
4108                completion::path_completions(root, argument_value)
4109            }
4110            "symbol" => {
4111                // Symbol completions: need the path argument from context
4112                let path_arg = request
4113                    .context
4114                    .as_ref()
4115                    .and_then(|ctx| ctx.get_argument("path"));
4116
4117                match path_arg {
4118                    Some(path_str) => {
4119                        let path = Path::new(path_str);
4120                        completion::symbol_completions(&self.cache, path, argument_value)
4121                    }
4122                    None => Vec::new(),
4123                }
4124            }
4125            _ => Vec::new(),
4126        };
4127
4128        // Create CompletionInfo with has_more flag if >100 results
4129        let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
4130        let (values, has_more) = if completions.len() > 100 {
4131            (completions.into_iter().take(100).collect(), true)
4132        } else {
4133            (completions, false)
4134        };
4135
4136        let completion_info =
4137            match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
4138                Ok(info) => info,
4139                Err(_) => {
4140                    // Graceful degradation: return empty on error
4141                    CompletionInfo::with_all_values(Vec::new())
4142                        .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
4143                }
4144            };
4145
4146        Ok(CompleteResult::new(completion_info))
4147    }
4148
4149    async fn set_level(
4150        &self,
4151        params: SetLevelRequestParams,
4152        _context: RequestContext<RoleServer>,
4153    ) -> Result<(), ErrorData> {
4154        let level_filter = match params.level {
4155            LoggingLevel::Debug => LevelFilter::DEBUG,
4156            LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
4157            LoggingLevel::Warning => LevelFilter::WARN,
4158            LoggingLevel::Error
4159            | LoggingLevel::Critical
4160            | LoggingLevel::Alert
4161            | LoggingLevel::Emergency => LevelFilter::ERROR,
4162        };
4163
4164        let mut filter_lock = self
4165            .log_level_filter
4166            .lock()
4167            .unwrap_or_else(|e| e.into_inner());
4168        *filter_lock = level_filter;
4169        Ok(())
4170    }
4171}
4172
4173#[cfg(test)]
4174mod tests {
4175    use super::*;
4176
4177    #[tokio::test]
4178    async fn test_emit_progress_none_peer_is_noop() {
4179        let peer = Arc::new(TokioMutex::new(None));
4180        let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4181        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4182        let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4183        let analyzer = CodeAnalyzer::new(
4184            peer,
4185            log_level_filter,
4186            rx,
4187            crate::metrics::MetricsSender(metrics_tx),
4188        );
4189        let token = ProgressToken(NumberOrString::String("test".into()));
4190        // Should complete without panic
4191        analyzer
4192            .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
4193            .await;
4194    }
4195
4196    fn make_analyzer() -> CodeAnalyzer {
4197        let peer = Arc::new(TokioMutex::new(None));
4198        let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4199        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4200        let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4201        CodeAnalyzer::new(
4202            peer,
4203            log_level_filter,
4204            rx,
4205            crate::metrics::MetricsSender(metrics_tx),
4206        )
4207    }
4208
4209    #[test]
4210    fn test_summary_cursor_conflict() {
4211        assert!(summary_cursor_conflict(Some(true), Some("cursor")));
4212        assert!(!summary_cursor_conflict(Some(true), None));
4213        assert!(!summary_cursor_conflict(None, Some("x")));
4214        assert!(!summary_cursor_conflict(None, None));
4215    }
4216
4217    #[tokio::test]
4218    async fn test_validate_impl_only_non_rust_returns_invalid_params() {
4219        use tempfile::TempDir;
4220
4221        let dir = TempDir::new().unwrap();
4222        std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
4223
4224        let analyzer = make_analyzer();
4225        // Call analyze_symbol with impl_only=true on a Python-only directory via the tool API.
4226        // We use handle_focused_mode which calls validate_impl_only internally.
4227        let entries: Vec<traversal::WalkEntry> =
4228            traversal::walk_directory(dir.path(), None).unwrap_or_default();
4229        let result = CodeAnalyzer::validate_impl_only(&entries);
4230        assert!(result.is_err());
4231        let err = result.unwrap_err();
4232        assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
4233        drop(analyzer); // ensure it compiles with analyzer in scope
4234    }
4235
4236    #[tokio::test]
4237    async fn test_no_cache_meta_on_analyze_directory_result() {
4238        use aptu_coder_core::types::{
4239            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4240        };
4241        use tempfile::TempDir;
4242
4243        let dir = TempDir::new().unwrap();
4244        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4245
4246        let analyzer = make_analyzer();
4247        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4248            "path": dir.path().to_str().unwrap(),
4249        }))
4250        .unwrap();
4251        let ct = tokio_util::sync::CancellationToken::new();
4252        let (arc_output, _cache_hit) = analyzer.handle_overview_mode(&params, ct).await.unwrap();
4253        // Verify the no_cache_meta shape by constructing it directly and checking the shape
4254        let meta = no_cache_meta();
4255        assert_eq!(
4256            meta.0.get("cache_hint").and_then(|v| v.as_str()),
4257            Some("no-cache"),
4258        );
4259        drop(arc_output);
4260    }
4261
4262    #[test]
4263    fn test_complete_path_completions_returns_suggestions() {
4264        // Test the underlying completion function (same code path as complete()) directly
4265        // to avoid needing a constructed RequestContext<RoleServer>.
4266        // CARGO_MANIFEST_DIR is <workspace>/aptu-coder; parent is the workspace root,
4267        // which contains aptu-coder-core/ and aptu-coder/ matching the "aptu-" prefix.
4268        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4269        let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
4270        let suggestions = completion::path_completions(workspace_root, "aptu-");
4271        assert!(
4272            !suggestions.is_empty(),
4273            "expected completions for prefix 'aptu-' in workspace root"
4274        );
4275    }
4276
4277    #[tokio::test]
4278    async fn test_handle_overview_mode_verbose_no_summary_block() {
4279        use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
4280        use aptu_coder_core::types::{
4281            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4282        };
4283        use tempfile::TempDir;
4284
4285        let tmp = TempDir::new().unwrap();
4286        std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
4287
4288        let peer = Arc::new(TokioMutex::new(None));
4289        let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4290        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4291        let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4292        let analyzer = CodeAnalyzer::new(
4293            peer,
4294            log_level_filter,
4295            rx,
4296            crate::metrics::MetricsSender(metrics_tx),
4297        );
4298
4299        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4300            "path": tmp.path().to_str().unwrap(),
4301            "verbose": true,
4302        }))
4303        .unwrap();
4304
4305        let ct = tokio_util::sync::CancellationToken::new();
4306        let (output, _cache_hit) = analyzer.handle_overview_mode(&params, ct).await.unwrap();
4307
4308        // Replicate the handler's formatting path (the fix site)
4309        let use_summary = output.formatted.len() > SIZE_LIMIT; // summary=None, force=None, small output
4310        let paginated =
4311            paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
4312        let verbose = true;
4313        let formatted = if !use_summary {
4314            format_structure_paginated(
4315                &paginated.items,
4316                paginated.total,
4317                params.max_depth,
4318                Some(std::path::Path::new(&params.path)),
4319                verbose,
4320            )
4321        } else {
4322            output.formatted.clone()
4323        };
4324
4325        // After the fix: verbose=true must not emit the SUMMARY: block
4326        assert!(
4327            !formatted.contains("SUMMARY:"),
4328            "verbose=true must not emit SUMMARY: block; got: {}",
4329            &formatted[..formatted.len().min(300)]
4330        );
4331        assert!(
4332            formatted.contains("PAGINATED:"),
4333            "verbose=true must emit PAGINATED: header"
4334        );
4335        assert!(
4336            formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
4337            "verbose=true must emit FILES section header"
4338        );
4339    }
4340
4341    // --- cache_hit integration tests ---
4342
4343    #[tokio::test]
4344    async fn test_analyze_directory_cache_hit_metrics() {
4345        use aptu_coder_core::types::{
4346            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4347        };
4348        use tempfile::TempDir;
4349
4350        // Arrange: a temp dir with one file
4351        let dir = TempDir::new().unwrap();
4352        std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
4353        let analyzer = make_analyzer();
4354        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4355            "path": dir.path().to_str().unwrap(),
4356        }))
4357        .unwrap();
4358
4359        // Act: first call (cache miss)
4360        let ct1 = tokio_util::sync::CancellationToken::new();
4361        let (_out1, hit1) = analyzer.handle_overview_mode(&params, ct1).await.unwrap();
4362
4363        // Act: second call (cache hit)
4364        let ct2 = tokio_util::sync::CancellationToken::new();
4365        let (_out2, hit2) = analyzer.handle_overview_mode(&params, ct2).await.unwrap();
4366
4367        // Assert
4368        assert_eq!(hit1, CacheTier::Miss, "first call must be a cache miss");
4369        assert_eq!(hit2, CacheTier::L1Memory, "second call must be a cache hit");
4370    }
4371
4372    #[tokio::test]
4373    async fn test_analyze_module_cache_hit_metrics() {
4374        use std::io::Write as _;
4375        use tempfile::NamedTempFile;
4376
4377        // Arrange: create a temp Rust file; prime the file cache via analyze_file handler
4378        let mut f = NamedTempFile::with_suffix(".rs").unwrap();
4379        writeln!(f, "fn bar() {{}}").unwrap();
4380        let path = f.path().to_str().unwrap().to_string();
4381
4382        let analyzer = make_analyzer();
4383
4384        // Prime the file cache by calling handle_file_details_mode once
4385        let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
4386        file_params.path = path.clone();
4387        file_params.ast_recursion_limit = None;
4388        file_params.fields = None;
4389        file_params.pagination.cursor = None;
4390        file_params.pagination.page_size = None;
4391        file_params.output_control.summary = None;
4392        file_params.output_control.force = None;
4393        file_params.output_control.verbose = None;
4394        let (_cached, _) = analyzer
4395            .handle_file_details_mode(&file_params)
4396            .await
4397            .unwrap();
4398
4399        // Act: now call analyze_module; the cache key is mtime-based so same file = hit
4400        let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
4401        module_params.path = path.clone();
4402
4403        // Replicate the cache lookup the handler does (no public method; test via build path)
4404        let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
4405            meta.modified()
4406                .ok()
4407                .map(|mtime| aptu_coder_core::cache::CacheKey {
4408                    path: std::path::PathBuf::from(&path),
4409                    modified: mtime,
4410                    mode: aptu_coder_core::types::AnalysisMode::FileDetails,
4411                })
4412        });
4413        let cache_hit = module_cache_key
4414            .as_ref()
4415            .and_then(|k| analyzer.cache.get(k))
4416            .is_some();
4417
4418        // Assert: the file cache must have been populated by the earlier handle_file_details_mode call
4419        assert!(
4420            cache_hit,
4421            "analyze_module should find the file in the shared file cache"
4422        );
4423        drop(module_params);
4424    }
4425
4426    // --- import_lookup tests ---
4427
4428    #[test]
4429    fn test_analyze_symbol_import_lookup_invalid_params() {
4430        // Arrange: empty symbol with import_lookup=true (violates the guard:
4431        // symbol must hold the module path when import_lookup=true).
4432        // Act: call the validate helper directly (same pattern as validate_impl_only).
4433        let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
4434
4435        // Assert: INVALID_PARAMS is returned.
4436        assert!(
4437            result.is_err(),
4438            "import_lookup=true with empty symbol must return Err"
4439        );
4440        let err = result.unwrap_err();
4441        assert_eq!(
4442            err.code,
4443            rmcp::model::ErrorCode::INVALID_PARAMS,
4444            "expected INVALID_PARAMS; got {:?}",
4445            err.code
4446        );
4447    }
4448
4449    #[tokio::test]
4450    async fn test_analyze_symbol_import_lookup_found() {
4451        use tempfile::TempDir;
4452
4453        // Arrange: a Rust file that imports "std::collections"
4454        let dir = TempDir::new().unwrap();
4455        std::fs::write(
4456            dir.path().join("main.rs"),
4457            "use std::collections::HashMap;\nfn main() {}\n",
4458        )
4459        .unwrap();
4460
4461        let entries = traversal::walk_directory(dir.path(), None).unwrap();
4462
4463        // Act: search for the module "std::collections"
4464        let output =
4465            analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
4466
4467        // Assert: one match found
4468        assert!(
4469            output.formatted.contains("MATCHES: 1"),
4470            "expected 1 match; got: {}",
4471            output.formatted
4472        );
4473        assert!(
4474            output.formatted.contains("main.rs"),
4475            "expected main.rs in output; got: {}",
4476            output.formatted
4477        );
4478    }
4479
4480    #[tokio::test]
4481    async fn test_analyze_symbol_import_lookup_empty() {
4482        use tempfile::TempDir;
4483
4484        // Arrange: a Rust file that does NOT import "no_such_module"
4485        let dir = TempDir::new().unwrap();
4486        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
4487
4488        let entries = traversal::walk_directory(dir.path(), None).unwrap();
4489
4490        // Act
4491        let output =
4492            analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
4493
4494        // Assert: zero matches
4495        assert!(
4496            output.formatted.contains("MATCHES: 0"),
4497            "expected 0 matches; got: {}",
4498            output.formatted
4499        );
4500    }
4501
4502    // --- git_ref tests ---
4503
4504    #[tokio::test]
4505    async fn test_analyze_directory_git_ref_non_git_repo() {
4506        use aptu_coder_core::traversal::changed_files_from_git_ref;
4507        use tempfile::TempDir;
4508
4509        // Arrange: a temp dir that is NOT a git repository
4510        let dir = TempDir::new().unwrap();
4511        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4512
4513        // Act: attempt git_ref resolution in a non-git dir
4514        let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
4515
4516        // Assert: must return a GitError
4517        assert!(result.is_err(), "non-git dir must return an error");
4518        let err_msg = result.unwrap_err().to_string();
4519        assert!(
4520            err_msg.contains("git"),
4521            "error must mention git; got: {err_msg}"
4522        );
4523    }
4524
4525    #[tokio::test]
4526    async fn test_analyze_directory_git_ref_filters_changed_files() {
4527        use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
4528        use std::collections::HashSet;
4529        use tempfile::TempDir;
4530
4531        // Arrange: build a set of fake "changed" paths and a walk entry list
4532        let dir = TempDir::new().unwrap();
4533        let changed_file = dir.path().join("changed.rs");
4534        let unchanged_file = dir.path().join("unchanged.rs");
4535        std::fs::write(&changed_file, "fn changed() {}").unwrap();
4536        std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
4537
4538        let entries = traversal::walk_directory(dir.path(), None).unwrap();
4539        let total_files = entries.iter().filter(|e| !e.is_dir).count();
4540        assert_eq!(total_files, 2, "sanity: 2 files before filtering");
4541
4542        // Simulate: only changed.rs is in the changed set
4543        let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
4544        changed.insert(changed_file.clone());
4545
4546        // Act: filter entries
4547        let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
4548        let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
4549
4550        // Assert: only changed.rs remains
4551        assert_eq!(
4552            filtered_files.len(),
4553            1,
4554            "only 1 file must remain after git_ref filter"
4555        );
4556        assert_eq!(
4557            filtered_files[0].path, changed_file,
4558            "the remaining file must be the changed one"
4559        );
4560
4561        // Verify changed_files_from_git_ref is at least callable (tested separately for non-git error)
4562        let _ = changed_files_from_git_ref;
4563    }
4564
4565    #[tokio::test]
4566    async fn test_handle_overview_mode_git_ref_filters_via_handler() {
4567        use aptu_coder_core::types::{
4568            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4569        };
4570        use std::process::Command;
4571        use tempfile::TempDir;
4572
4573        // Arrange: create a real git repo with two commits.
4574        let dir = TempDir::new().unwrap();
4575        let repo = dir.path();
4576
4577        // Init repo and configure minimal identity so git commit works.
4578        // Use no-hooks to avoid project-local commit hooks that enforce email allowlists.
4579        let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
4580            let mut cmd = std::process::Command::new("git");
4581            cmd.args(["-c", "core.hooksPath=/dev/null"]);
4582            cmd.args(args);
4583            cmd.current_dir(repo_path);
4584            let out = cmd.output().unwrap();
4585            assert!(out.status.success(), "{out:?}");
4586        };
4587        git_no_hook(repo, &["init"]);
4588        git_no_hook(
4589            repo,
4590            &[
4591                "-c",
4592                "user.email=ci@example.com",
4593                "-c",
4594                "user.name=CI",
4595                "commit",
4596                "--allow-empty",
4597                "-m",
4598                "initial",
4599            ],
4600        );
4601
4602        // Commit file_a.rs in the first commit.
4603        std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
4604        git_no_hook(repo, &["add", "file_a.rs"]);
4605        git_no_hook(
4606            repo,
4607            &[
4608                "-c",
4609                "user.email=ci@example.com",
4610                "-c",
4611                "user.name=CI",
4612                "commit",
4613                "-m",
4614                "add a",
4615            ],
4616        );
4617
4618        // Add file_b.rs in a second commit (this is what HEAD changes relative to HEAD~1).
4619        std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
4620        git_no_hook(repo, &["add", "file_b.rs"]);
4621        git_no_hook(
4622            repo,
4623            &[
4624                "-c",
4625                "user.email=ci@example.com",
4626                "-c",
4627                "user.name=CI",
4628                "commit",
4629                "-m",
4630                "add b",
4631            ],
4632        );
4633
4634        // Act: call handle_overview_mode with git_ref=HEAD~1.
4635        // `git diff --name-only HEAD~1` compares working tree against HEAD~1, returning
4636        // only file_b.rs (added in the last commit, so present in working tree but not in HEAD~1).
4637        // Use the canonical path so walk entries match what `git rev-parse --show-toplevel` returns
4638        // (macOS /tmp is a symlink to /private/tmp; without canonicalization paths would differ).
4639        let canon_repo = std::fs::canonicalize(repo).unwrap();
4640        let analyzer = make_analyzer();
4641        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4642            "path": canon_repo.to_str().unwrap(),
4643            "git_ref": "HEAD~1",
4644        }))
4645        .unwrap();
4646        let ct = tokio_util::sync::CancellationToken::new();
4647        let (arc_output, _cache_hit) = analyzer
4648            .handle_overview_mode(&params, ct)
4649            .await
4650            .expect("handle_overview_mode with git_ref must succeed");
4651
4652        // Assert: only file_b.rs (changed since HEAD~1) appears; file_a.rs must be absent.
4653        let formatted = &arc_output.formatted;
4654        assert!(
4655            formatted.contains("file_b.rs"),
4656            "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
4657        );
4658        assert!(
4659            !formatted.contains("file_a.rs"),
4660            "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
4661        );
4662    }
4663
4664    #[test]
4665    fn test_validate_path_rejects_absolute_path_outside_cwd() {
4666        // S4: Verify that absolute paths outside the current working directory are rejected.
4667        // This test directly calls validate_path with /etc/passwd, which should fail.
4668        let result = validate_path("/etc/passwd", true);
4669        assert!(
4670            result.is_err(),
4671            "validate_path should reject /etc/passwd (outside CWD)"
4672        );
4673        let err = result.unwrap_err();
4674        let err_msg = err.message.to_lowercase();
4675        assert!(
4676            err_msg.contains("outside") || err_msg.contains("not found"),
4677            "Error message should mention 'outside' or 'not found': {}",
4678            err.message
4679        );
4680    }
4681
4682    #[test]
4683    fn test_validate_path_accepts_relative_path_in_cwd() {
4684        // Happy path: relative path within CWD should be accepted.
4685        // Use Cargo.toml which exists in the crate root.
4686        let result = validate_path("Cargo.toml", true);
4687        assert!(
4688            result.is_ok(),
4689            "validate_path should accept Cargo.toml (exists in CWD)"
4690        );
4691    }
4692
4693    #[test]
4694    fn test_validate_path_creates_parent_for_nonexistent_file() {
4695        // Edge case: non-existent file with non-existent parent should still be accepted
4696        // if the ancestor chain leads back to CWD.
4697        let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4698        assert!(
4699            result.is_ok(),
4700            "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4701        );
4702        let path = result.unwrap();
4703        let cwd = std::env::current_dir().expect("should get cwd");
4704        let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4705        assert!(
4706            path.starts_with(&canonical_cwd),
4707            "Resolved path should be within CWD: {:?} should start with {:?}",
4708            path,
4709            canonical_cwd
4710        );
4711    }
4712
4713    #[test]
4714    fn test_edit_overwrite_with_working_dir() {
4715        // Arrange: create a temporary directory within CWD to use as working_dir
4716        let cwd = std::env::current_dir().expect("should get cwd");
4717        let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4718        let temp_path = temp_dir.path();
4719
4720        // Act: call validate_path_in_dir with a relative path
4721        let result = validate_path_in_dir("test_file.txt", false, temp_path);
4722
4723        // Assert: path should be resolved relative to working_dir
4724        assert!(
4725            result.is_ok(),
4726            "validate_path_in_dir should accept relative path in valid working_dir: {:?}",
4727            result.err()
4728        );
4729        let resolved = result.unwrap();
4730        assert!(
4731            resolved.starts_with(temp_path),
4732            "Resolved path should be within working_dir: {:?} should start with {:?}",
4733            resolved,
4734            temp_path
4735        );
4736    }
4737
4738    #[test]
4739    fn test_edit_overwrite_working_dir_traversal() {
4740        // Arrange: create a temporary directory within CWD to use as working_dir
4741        let cwd = std::env::current_dir().expect("should get cwd");
4742        let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4743        let temp_path = temp_dir.path();
4744
4745        // Act: try to traverse outside working_dir with ../../../etc/passwd
4746        let result = validate_path_in_dir("../../../etc/passwd", false, temp_path);
4747
4748        // Assert: should reject path traversal attack
4749        assert!(
4750            result.is_err(),
4751            "validate_path_in_dir should reject path traversal outside working_dir"
4752        );
4753        let err = result.unwrap_err();
4754        let err_msg = err.message.to_lowercase();
4755        assert!(
4756            err_msg.contains("outside") || err_msg.contains("working"),
4757            "Error message should mention 'outside' or 'working': {}",
4758            err.message
4759        );
4760    }
4761
4762    #[test]
4763    fn test_edit_replace_with_working_dir() {
4764        // Arrange: create a temporary directory within CWD and file
4765        let cwd = std::env::current_dir().expect("should get cwd");
4766        let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4767        let temp_path = temp_dir.path();
4768        let file_path = temp_path.join("test.txt");
4769        std::fs::write(&file_path, "hello world").expect("should write test file");
4770
4771        // Act: call validate_path_in_dir with require_exists=true
4772        let result = validate_path_in_dir("test.txt", true, temp_path);
4773
4774        // Assert: should find the file relative to working_dir
4775        assert!(
4776            result.is_ok(),
4777            "validate_path_in_dir should find existing file in working_dir: {:?}",
4778            result.err()
4779        );
4780        let resolved = result.unwrap();
4781        assert_eq!(
4782            resolved, file_path,
4783            "Resolved path should match the actual file path"
4784        );
4785    }
4786
4787    #[test]
4788    fn test_edit_overwrite_no_working_dir() {
4789        // Arrange: use validate_path without working_dir (existing behavior)
4790        // Use Cargo.toml which exists in the crate root
4791
4792        // Act: call validate_path with require_exists=true
4793        let result = validate_path("Cargo.toml", true);
4794
4795        // Assert: should work as before
4796        assert!(
4797            result.is_ok(),
4798            "validate_path should still work without working_dir"
4799        );
4800    }
4801
4802    #[test]
4803    fn test_edit_overwrite_working_dir_is_file() {
4804        // Arrange: create a temporary file (not directory) to use as working_dir
4805        let cwd = std::env::current_dir().expect("should get cwd");
4806        let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4807        let temp_file = temp_dir.path().join("test_file.txt");
4808        std::fs::write(&temp_file, "test content").expect("should write test file");
4809
4810        // Act: call validate_path_in_dir with a file as working_dir
4811        let result = validate_path_in_dir("some_file.txt", false, &temp_file);
4812
4813        // Assert: should reject because working_dir is not a directory
4814        assert!(
4815            result.is_err(),
4816            "validate_path_in_dir should reject a file as working_dir"
4817        );
4818        let err = result.unwrap_err();
4819        let err_msg = err.message.to_lowercase();
4820        assert!(
4821            err_msg.contains("directory"),
4822            "Error message should mention 'directory': {}",
4823            err.message
4824        );
4825    }
4826
4827    #[test]
4828    fn test_tool_annotations() {
4829        // Arrange: get tool list via static method
4830        let tools = CodeAnalyzer::list_tools();
4831
4832        // Act: find specific tools by name
4833        let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
4834        let exec_command = tools.iter().find(|t| t.name == "exec_command");
4835
4836        // Assert: analyze_directory has correct annotations
4837        let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
4838        let analyze_dir_annot = analyze_dir_tool
4839            .annotations
4840            .as_ref()
4841            .expect("analyze_directory should have annotations");
4842        assert_eq!(
4843            analyze_dir_annot.read_only_hint,
4844            Some(true),
4845            "analyze_directory read_only_hint should be true"
4846        );
4847        assert_eq!(
4848            analyze_dir_annot.destructive_hint,
4849            Some(false),
4850            "analyze_directory destructive_hint should be false"
4851        );
4852
4853        // Assert: exec_command has correct annotations
4854        let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
4855        let exec_cmd_annot = exec_cmd_tool
4856            .annotations
4857            .as_ref()
4858            .expect("exec_command should have annotations");
4859        assert_eq!(
4860            exec_cmd_annot.open_world_hint,
4861            Some(true),
4862            "exec_command open_world_hint should be true"
4863        );
4864    }
4865
4866    #[test]
4867    fn test_profile_remote_enables_remote_tools() {
4868        // Arrange: get tool list via static method
4869        let tools = CodeAnalyzer::list_tools();
4870
4871        // Act: check for remote_tree and remote_file
4872        let remote_tree = tools.iter().find(|t| t.name == "remote_tree");
4873        let remote_file = tools.iter().find(|t| t.name == "remote_file");
4874
4875        // Assert: both remote tools should exist in the full tool list
4876        // (profile filtering happens at runtime in on_initialized, not in list_tools)
4877        assert!(
4878            remote_tree.is_some(),
4879            "remote_tree should exist in full tool list"
4880        );
4881        assert!(
4882            remote_file.is_some(),
4883            "remote_file should exist in full tool list"
4884        );
4885    }
4886
4887    #[test]
4888    fn test_profile_none_disables_remote_tools() {
4889        // Arrange: get tool list via static method
4890        let tools = CodeAnalyzer::list_tools();
4891
4892        // Act: count total tools (should be 9 in the static list)
4893        let tool_count = tools.len();
4894
4895        // Assert: static list has all 9 tools
4896        // (remote_tree and remote_file are disabled at runtime in on_initialized when no profile is set)
4897        assert_eq!(
4898            tool_count, 9,
4899            "static tool list should contain all 9 tools; filtering happens at runtime"
4900        );
4901
4902        // Verify remote tools exist in static list
4903        let remote_tree = tools.iter().find(|t| t.name == "remote_tree");
4904        let remote_file = tools.iter().find(|t| t.name == "remote_file");
4905        assert!(
4906            remote_tree.is_some(),
4907            "remote_tree should exist in static list"
4908        );
4909        assert!(
4910            remote_file.is_some(),
4911            "remote_file should exist in static list"
4912        );
4913    }
4914
4915    #[test]
4916    fn test_exec_stdin_size_cap_validation() {
4917        // Test: stdin size cap check (1 MB limit)
4918        // Arrange: create oversized stdin
4919        let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
4920
4921        // Act & Assert: verify size exceeds limit
4922        assert!(
4923            oversized_stdin.len() > STDIN_MAX_BYTES,
4924            "test setup: oversized stdin should exceed 1 MB"
4925        );
4926
4927        // Verify that a 1 MB stdin is accepted
4928        let max_stdin = "y".repeat(STDIN_MAX_BYTES);
4929        assert_eq!(
4930            max_stdin.len(),
4931            STDIN_MAX_BYTES,
4932            "test setup: max stdin should be exactly 1 MB"
4933        );
4934    }
4935
4936    #[tokio::test]
4937    async fn test_exec_stdin_cat_roundtrip() {
4938        // Test: stdin content is piped to process and readable via stdout
4939        // Arrange: prepare stdin content
4940        let stdin_content = "hello world";
4941
4942        // Act: execute cat with stdin via shell
4943        let mut child = tokio::process::Command::new("sh")
4944            .arg("-c")
4945            .arg("cat")
4946            .stdin(std::process::Stdio::piped())
4947            .stdout(std::process::Stdio::piped())
4948            .stderr(std::process::Stdio::piped())
4949            .spawn()
4950            .expect("spawn cat");
4951
4952        if let Some(mut stdin_handle) = child.stdin.take() {
4953            use tokio::io::AsyncWriteExt as _;
4954            stdin_handle
4955                .write_all(stdin_content.as_bytes())
4956                .await
4957                .expect("write stdin");
4958            drop(stdin_handle);
4959        }
4960
4961        let output = child.wait_with_output().await.expect("wait for cat");
4962
4963        // Assert: stdout contains the piped stdin content
4964        let stdout_str = String::from_utf8_lossy(&output.stdout);
4965        assert!(
4966            stdout_str.contains(stdin_content),
4967            "stdout should contain stdin content: {}",
4968            stdout_str
4969        );
4970    }
4971
4972    #[tokio::test]
4973    async fn test_exec_stdin_none_no_regression() {
4974        // Test: command without stdin executes normally (no regression)
4975        // Act: execute echo without stdin
4976        let child = tokio::process::Command::new("sh")
4977            .arg("-c")
4978            .arg("echo hi")
4979            .stdin(std::process::Stdio::null())
4980            .stdout(std::process::Stdio::piped())
4981            .stderr(std::process::Stdio::piped())
4982            .spawn()
4983            .expect("spawn echo");
4984
4985        let output = child.wait_with_output().await.expect("wait for echo");
4986
4987        // Assert: command executes successfully
4988        let stdout_str = String::from_utf8_lossy(&output.stdout);
4989        assert!(
4990            stdout_str.contains("hi"),
4991            "stdout should contain echo output: {}",
4992            stdout_str
4993        );
4994    }
4995
4996    #[test]
4997    fn test_validate_path_in_dir_rejects_sibling_prefix() {
4998        // Arrange: create a parent temp dir, then two subdirs:
4999        //   allowed/   -- the working_dir
5000        //   allowed_sibling/  -- a sibling whose name shares the prefix
5001        // This mirrors CVE-2025-53110: "/work_evil" must not match "/work".
5002        let cwd = std::env::current_dir().expect("should get cwd");
5003        let parent = tempfile::TempDir::new_in(&cwd).expect("should create parent temp dir");
5004        let allowed = parent.path().join("allowed");
5005        let sibling = parent.path().join("allowed_sibling");
5006        std::fs::create_dir_all(&allowed).expect("should create allowed dir");
5007        std::fs::create_dir_all(&sibling).expect("should create sibling dir");
5008
5009        // Act: ask for a file inside the sibling dir, using a path that
5010        // traverses from allowed/ into allowed_sibling/
5011        let result = validate_path_in_dir("../allowed_sibling/secret.txt", false, &allowed);
5012
5013        // Assert: must be rejected even though "allowed_sibling" starts with "allowed"
5014        assert!(
5015            result.is_err(),
5016            "validate_path_in_dir must reject a path resolving to a sibling directory \
5017             sharing the working_dir name prefix (CVE-2025-53110 pattern)"
5018        );
5019        let err = result.unwrap_err();
5020        let msg = err.message.to_lowercase();
5021        assert!(
5022            msg.contains("outside") || msg.contains("working"),
5023            "Error should mention 'outside' or 'working', got: {}",
5024            err.message
5025        );
5026    }
5027
5028    #[test]
5029    #[serial_test::serial]
5030    fn test_file_cache_capacity_default() {
5031        // Arrange: ensure the env var is not set
5032        unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
5033
5034        // Act
5035        let analyzer = make_analyzer();
5036
5037        // Assert: default file cache capacity is 100
5038        assert_eq!(analyzer.cache.file_capacity(), 100);
5039    }
5040
5041    #[test]
5042    #[serial_test::serial]
5043    fn test_file_cache_capacity_from_env() {
5044        // Arrange
5045        unsafe { std::env::set_var("APTU_CODER_FILE_CACHE_CAPACITY", "42") };
5046
5047        // Act
5048        let analyzer = make_analyzer();
5049
5050        // Cleanup before assertions to minimise env pollution window
5051        unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
5052
5053        // Assert
5054        assert_eq!(analyzer.cache.file_capacity(), 42);
5055    }
5056}