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