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