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