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