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