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