Skip to main content

aptu_coder/
lib.rs

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