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