Skip to main content

aptu_coder/
lib.rs

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