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