Skip to main content

aptu_coder/
lib.rs

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