Skip to main content

aptu_coder/
lib.rs

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