Skip to main content

aptu_coder/
lib.rs

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