Skip to main content

aptu_coder/
lib.rs

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