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