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