Skip to main content

aptu_coder/
lib.rs

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