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                if let Some(file_name) = ancestor.file_name() {
265                    suffix = std::path::PathBuf::from(file_name).join(&suffix);
266                }
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                if let Some(file_name) = ancestor.file_name() {
399                    suffix = std::path::PathBuf::from(file_name).join(&suffix);
400                }
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                return Ok(err_to_tool_result(e));
1698            }
1699        };
1700
1701        // Clone only the two fields that may be mutated per-request (formatted and
1702        // next_cursor). The heavy SemanticAnalysis data is shared via Arc and never
1703        // modified, so we borrow it directly from the cached pointer.
1704        let mut formatted = arc_output.formatted.clone();
1705        let line_count = arc_output.line_count;
1706
1707        // Apply summary/output size limiting logic
1708        let use_summary = if params.output_control.force == Some(true) {
1709            false
1710        } else if params.output_control.summary == Some(true) {
1711            true
1712        } else if params.output_control.summary == Some(false) {
1713            false
1714        } else {
1715            formatted.len() > SIZE_LIMIT
1716        };
1717
1718        if use_summary {
1719            formatted = format_file_details_summary(&arc_output.semantic, &params.path, line_count);
1720        } else if formatted.len() > SIZE_LIMIT && params.output_control.force != Some(true) {
1721            span.record("error", true);
1722            span.record("error.type", "invalid_params");
1723            let estimated_tokens = formatted.len() / 4;
1724            let message = format!(
1725                "Output exceeds 50K chars ({} chars, ~{} tokens). Use one of:\n\
1726                 - force=true to return full output\n\
1727                 - Use fields to limit output to specific sections (functions, classes, or imports)\n\
1728                 - Use summary=true for a compact overview",
1729                formatted.len(),
1730                estimated_tokens
1731            );
1732            return Ok(err_to_tool_result(ErrorData::new(
1733                rmcp::model::ErrorCode::INVALID_PARAMS,
1734                message,
1735                Some(error_meta(
1736                    "validation",
1737                    false,
1738                    "use force=true, fields, or summary=true",
1739                )),
1740            )));
1741        }
1742
1743        // Decode pagination cursor if provided (analyze_file)
1744        let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
1745        let offset = if let Some(ref cursor_str) = params.pagination.cursor {
1746            let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
1747                ErrorData::new(
1748                    rmcp::model::ErrorCode::INVALID_PARAMS,
1749                    e.to_string(),
1750                    Some(error_meta("validation", false, "invalid cursor format")),
1751                )
1752            }) {
1753                Ok(v) => v,
1754                Err(e) => {
1755                    span.record("error", true);
1756                    span.record("error.type", "invalid_params");
1757                    return Ok(err_to_tool_result(e));
1758                }
1759            };
1760            cursor_data.offset
1761        } else {
1762            0
1763        };
1764
1765        // Filter to top-level functions only (exclude methods) before pagination
1766        let top_level_fns: Vec<crate::types::FunctionInfo> = arc_output
1767            .semantic
1768            .functions
1769            .iter()
1770            .filter(|func| {
1771                !arc_output
1772                    .semantic
1773                    .classes
1774                    .iter()
1775                    .any(|class| func.line >= class.line && func.end_line <= class.end_line)
1776            })
1777            .cloned()
1778            .collect();
1779
1780        // Paginate top-level functions only
1781        let paginated =
1782            match paginate_slice(&top_level_fns, offset, page_size, PaginationMode::Default) {
1783                Ok(v) => v,
1784                Err(e) => {
1785                    return Ok(err_to_tool_result(ErrorData::new(
1786                        rmcp::model::ErrorCode::INTERNAL_ERROR,
1787                        e.to_string(),
1788                        Some(error_meta("transient", true, "retry the request")),
1789                    )));
1790                }
1791            };
1792
1793        // Regenerate formatted output using the paginated formatter (handles verbose and pagination correctly)
1794        let verbose = params.output_control.verbose.unwrap_or(false);
1795        if !use_summary {
1796            // fields: serde rejects unknown enum variants at deserialization; no runtime validation required
1797            formatted = format_file_details_paginated(
1798                &paginated.items,
1799                paginated.total,
1800                &arc_output.semantic,
1801                &params.path,
1802                line_count,
1803                offset,
1804                verbose,
1805                params.fields.as_deref(),
1806            );
1807        }
1808
1809        // Capture next_cursor from pagination result (unless using summary mode)
1810        let next_cursor = if use_summary {
1811            None
1812        } else {
1813            paginated.next_cursor.clone()
1814        };
1815
1816        // Build final text output with pagination cursor if present (unless using summary mode)
1817        let mut final_text = formatted.clone();
1818        if !use_summary && let Some(ref cursor) = next_cursor {
1819            final_text.push('\n');
1820            final_text.push_str("NEXT_CURSOR: ");
1821            final_text.push_str(cursor);
1822        }
1823
1824        // Build the response output, sharing SemanticAnalysis from the Arc to avoid cloning it.
1825        let response_output = analyze::FileAnalysisOutput::new(
1826            formatted,
1827            arc_output.semantic.clone(),
1828            line_count,
1829            next_cursor,
1830        );
1831
1832        // Record cache tier in span
1833        tracing::Span::current().record("cache_tier", file_cache_hit.as_str());
1834
1835        // Add content_hash to _meta
1836        let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
1837        let mut meta = no_cache_meta().0;
1838        meta.insert(
1839            "content_hash".to_string(),
1840            serde_json::Value::String(content_hash),
1841        );
1842        let meta = rmcp::model::Meta(meta);
1843
1844        let mut result =
1845            CallToolResult::success(vec![Content::text(final_text.clone())]).with_meta(Some(meta));
1846        let structured = serde_json::to_value(&response_output).unwrap_or(Value::Null);
1847        result.structured_content = Some(structured);
1848        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
1849        self.metrics_tx.send(crate::metrics::MetricEvent {
1850            ts: crate::metrics::unix_ms(),
1851            tool: "analyze_file",
1852            duration_ms: dur,
1853            output_chars: final_text.len(),
1854            param_path_depth: crate::metrics::path_component_count(&param_path),
1855            max_depth: None,
1856            result: "ok",
1857            error_type: None,
1858            session_id: sid,
1859            seq: Some(seq),
1860            cache_hit: Some(file_cache_hit != CacheTier::Miss),
1861            cache_write_failure: None,
1862            cache_tier: Some(file_cache_hit.as_str()),
1863            exit_code: None,
1864            timed_out: false,
1865            output_truncated: None,
1866            ..Default::default()
1867        });
1868        Ok(result)
1869    }
1870
1871    #[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))]
1872    #[tool(
1873        name = "analyze_symbol",
1874        title = "Analyze Symbol",
1875        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.",
1876        output_schema = schema_for_type::<analyze::FocusedAnalysisOutput>(),
1877        annotations(
1878            title = "Analyze Symbol",
1879            read_only_hint = true,
1880            destructive_hint = false,
1881            idempotent_hint = true,
1882            open_world_hint = false
1883        )
1884    )]
1885    async fn analyze_symbol(
1886        &self,
1887        params: Parameters<AnalyzeSymbolParams>,
1888        context: RequestContext<RoleServer>,
1889    ) -> Result<CallToolResult, ErrorData> {
1890        let params = params.0;
1891        // Extract W3C Trace Context from request _meta if present
1892        let session_id = self.session_id.lock().await.clone();
1893        let client_name = self.client_name.lock().await.clone();
1894        let client_version = self.client_version.lock().await.clone();
1895        extract_and_set_trace_context(
1896            Some(&context.meta),
1897            ClientMetadata {
1898                session_id,
1899                client_name,
1900                client_version,
1901            },
1902        );
1903        let span = tracing::Span::current();
1904        span.record("gen_ai.system", "mcp");
1905        span.record("gen_ai.operation.name", "execute_tool");
1906        span.record("gen_ai.tool.name", "analyze_symbol");
1907        span.record("symbol", &params.symbol);
1908        let _validated_path = match validate_path(&params.path, true) {
1909            Ok(p) => p,
1910            Err(e) => {
1911                span.record("error", true);
1912                span.record("error.type", "invalid_params");
1913                return Ok(err_to_tool_result(e));
1914            }
1915        };
1916        let ct = context.ct.clone();
1917        let t_start = std::time::Instant::now();
1918        let param_path = params.path.clone();
1919        let max_depth_val = params.follow_depth;
1920        let seq = self
1921            .session_call_seq
1922            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1923        let sid = self.session_id.lock().await.clone();
1924
1925        // Check if path is a file (not allowed for analyze_symbol)
1926        if std::path::Path::new(&params.path).is_file() {
1927            span.record("error", true);
1928            span.record("error.type", "invalid_params");
1929            return Ok(err_to_tool_result(ErrorData::new(
1930                rmcp::model::ErrorCode::INVALID_PARAMS,
1931                format!(
1932                    "'{}' is a file; analyze_symbol requires a directory path",
1933                    params.path
1934                ),
1935                Some(error_meta(
1936                    "validation",
1937                    false,
1938                    "pass a directory path, not a file",
1939                )),
1940            )));
1941        }
1942
1943        // summary=true and cursor are mutually exclusive
1944        if summary_cursor_conflict(
1945            params.output_control.summary,
1946            params.pagination.cursor.as_deref(),
1947        ) {
1948            span.record("error", true);
1949            span.record("error.type", "invalid_params");
1950            return Ok(err_to_tool_result(ErrorData::new(
1951                rmcp::model::ErrorCode::INVALID_PARAMS,
1952                "summary=true is incompatible with a pagination cursor; use one or the other"
1953                    .to_string(),
1954                Some(error_meta(
1955                    "validation",
1956                    false,
1957                    "remove cursor or set summary=false",
1958                )),
1959            )));
1960        }
1961
1962        // import_lookup=true is mutually exclusive with a non-empty symbol.
1963        if let Err(e) = Self::validate_import_lookup(params.import_lookup, &params.symbol) {
1964            span.record("error", true);
1965            span.record("error.type", "invalid_params");
1966            return Ok(err_to_tool_result(e));
1967        }
1968
1969        // import_lookup mode: scan for files importing `params.symbol` as a module path.
1970        if params.import_lookup == Some(true) {
1971            let path_owned = PathBuf::from(&params.path);
1972            let symbol = params.symbol.clone();
1973            let git_ref = params.git_ref.clone();
1974            let max_depth = params.max_depth;
1975            let ast_recursion_limit = params.ast_recursion_limit;
1976
1977            let handle = tokio::task::spawn_blocking(move || {
1978                let path = path_owned.as_path();
1979                let raw_entries = match walk_directory(path, max_depth) {
1980                    Ok(e) => e,
1981                    Err(e) => {
1982                        return Err(ErrorData::new(
1983                            rmcp::model::ErrorCode::INTERNAL_ERROR,
1984                            format!("Failed to walk directory: {e}"),
1985                            Some(error_meta(
1986                                "resource",
1987                                false,
1988                                "check path permissions and availability",
1989                            )),
1990                        ));
1991                    }
1992                };
1993                // Apply git_ref filter when requested (non-empty string only).
1994                let entries = if let Some(ref git_ref_val) = git_ref
1995                    && !git_ref_val.is_empty()
1996                {
1997                    let changed = match changed_files_from_git_ref(path, git_ref_val) {
1998                        Ok(c) => c,
1999                        Err(e) => {
2000                            return Err(ErrorData::new(
2001                                rmcp::model::ErrorCode::INVALID_PARAMS,
2002                                format!("git_ref filter failed: {e}"),
2003                                Some(error_meta(
2004                                    "resource",
2005                                    false,
2006                                    "ensure git is installed and path is inside a git repository",
2007                                )),
2008                            ));
2009                        }
2010                    };
2011                    filter_entries_by_git_ref(raw_entries, &changed, path)
2012                } else {
2013                    raw_entries
2014                };
2015                let output = match analyze::analyze_import_lookup(
2016                    path,
2017                    &symbol,
2018                    &entries,
2019                    ast_recursion_limit,
2020                ) {
2021                    Ok(v) => v,
2022                    Err(e) => {
2023                        return Err(ErrorData::new(
2024                            rmcp::model::ErrorCode::INTERNAL_ERROR,
2025                            format!("import_lookup failed: {e}"),
2026                            Some(error_meta(
2027                                "resource",
2028                                false,
2029                                "check path and file permissions",
2030                            )),
2031                        ));
2032                    }
2033                };
2034                Ok(output)
2035            });
2036
2037            let output = match handle.await {
2038                Ok(Ok(v)) => v,
2039                Ok(Err(e)) => return Ok(err_to_tool_result(e)),
2040                Err(e) => {
2041                    return Ok(err_to_tool_result(ErrorData::new(
2042                        rmcp::model::ErrorCode::INTERNAL_ERROR,
2043                        format!("spawn_blocking failed: {e}"),
2044                        Some(error_meta("resource", false, "internal error")),
2045                    )));
2046                }
2047            };
2048
2049            let final_text = output.formatted.clone();
2050
2051            // Record cache tier in span
2052            tracing::Span::current().record("cache_tier", "Miss");
2053
2054            // Add content_hash to _meta
2055            let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
2056            let mut meta = no_cache_meta().0;
2057            meta.insert(
2058                "content_hash".to_string(),
2059                serde_json::Value::String(content_hash),
2060            );
2061
2062            let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
2063                .with_meta(Some(Meta(meta)));
2064            let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
2065            result.structured_content = Some(structured);
2066            let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2067            self.metrics_tx.send(crate::metrics::MetricEvent {
2068                ts: crate::metrics::unix_ms(),
2069                tool: "analyze_symbol",
2070                duration_ms: dur,
2071                output_chars: final_text.len(),
2072                param_path_depth: crate::metrics::path_component_count(&param_path),
2073                max_depth: max_depth_val,
2074                result: "ok",
2075                error_type: None,
2076                session_id: sid,
2077                seq: Some(seq),
2078                cache_hit: Some(false),
2079                cache_tier: Some(CacheTier::Miss.as_str()),
2080                cache_write_failure: None,
2081                exit_code: None,
2082                timed_out: false,
2083                output_truncated: None,
2084                ..Default::default()
2085            });
2086            return Ok(result);
2087        }
2088
2089        // Call handler for analysis and progress tracking
2090        let mut output = match self.handle_focused_mode(&params, ct).await {
2091            Ok(v) => v,
2092            Err(e) => return Ok(err_to_tool_result(e)),
2093        };
2094
2095        // Decode pagination cursor if provided (analyze_symbol)
2096        let page_size = params.pagination.page_size.unwrap_or(DEFAULT_PAGE_SIZE);
2097        let offset = if let Some(ref cursor_str) = params.pagination.cursor {
2098            let cursor_data = match decode_cursor(cursor_str).map_err(|e| {
2099                ErrorData::new(
2100                    rmcp::model::ErrorCode::INVALID_PARAMS,
2101                    e.to_string(),
2102                    Some(error_meta("validation", false, "invalid cursor format")),
2103                )
2104            }) {
2105                Ok(v) => v,
2106                Err(e) => return Ok(err_to_tool_result(e)),
2107            };
2108            cursor_data.offset
2109        } else {
2110            0
2111        };
2112
2113        // SymbolFocus pagination: decode cursor mode to determine callers vs callees
2114        let cursor_mode = if let Some(ref cursor_str) = params.pagination.cursor {
2115            decode_cursor(cursor_str)
2116                .map(|c| c.mode)
2117                .unwrap_or(PaginationMode::Callers)
2118        } else {
2119            PaginationMode::Callers
2120        };
2121
2122        let mut use_summary = params.output_control.summary == Some(true);
2123        if params.output_control.force == Some(true) {
2124            use_summary = false;
2125        }
2126        let verbose = params.output_control.verbose.unwrap_or(false);
2127
2128        let mut callee_cursor = match cursor_mode {
2129            PaginationMode::Callers => {
2130                let (paginated_items, paginated_next) = match paginate_focus_chains(
2131                    &output.prod_chains,
2132                    PaginationMode::Callers,
2133                    offset,
2134                    page_size,
2135                ) {
2136                    Ok(v) => v,
2137                    Err(e) => return Ok(err_to_tool_result(e)),
2138                };
2139
2140                if !use_summary
2141                    && (paginated_next.is_some()
2142                        || offset > 0
2143                        || !verbose
2144                        || !output.outgoing_chains.is_empty())
2145                {
2146                    let base_path = Path::new(&params.path);
2147                    output.formatted = format_focused_paginated(
2148                        &paginated_items,
2149                        output.prod_chains.len(),
2150                        PaginationMode::Callers,
2151                        &params.symbol,
2152                        &output.prod_chains,
2153                        &output.test_chains,
2154                        &output.outgoing_chains,
2155                        output.def_count,
2156                        offset,
2157                        Some(base_path),
2158                        verbose,
2159                    );
2160                    paginated_next
2161                } else {
2162                    None
2163                }
2164            }
2165            PaginationMode::Callees => {
2166                let (paginated_items, paginated_next) = match paginate_focus_chains(
2167                    &output.outgoing_chains,
2168                    PaginationMode::Callees,
2169                    offset,
2170                    page_size,
2171                ) {
2172                    Ok(v) => v,
2173                    Err(e) => return Ok(err_to_tool_result(e)),
2174                };
2175
2176                if paginated_next.is_some() || offset > 0 || !verbose {
2177                    let base_path = Path::new(&params.path);
2178                    output.formatted = format_focused_paginated(
2179                        &paginated_items,
2180                        output.outgoing_chains.len(),
2181                        PaginationMode::Callees,
2182                        &params.symbol,
2183                        &output.prod_chains,
2184                        &output.test_chains,
2185                        &output.outgoing_chains,
2186                        output.def_count,
2187                        offset,
2188                        Some(base_path),
2189                        verbose,
2190                    );
2191                    paginated_next
2192                } else {
2193                    None
2194                }
2195            }
2196            PaginationMode::Default => {
2197                return Ok(err_to_tool_result(ErrorData::new(
2198                    rmcp::model::ErrorCode::INVALID_PARAMS,
2199                    "invalid cursor: unknown pagination mode".to_string(),
2200                    Some(error_meta(
2201                        "validation",
2202                        false,
2203                        "use a cursor returned by a previous analyze_symbol call",
2204                    )),
2205                )));
2206            }
2207            PaginationMode::DefUse => {
2208                let total_sites = output.def_use_sites.len();
2209                let (paginated_sites, paginated_next) = match paginate_slice(
2210                    &output.def_use_sites,
2211                    offset,
2212                    page_size,
2213                    PaginationMode::DefUse,
2214                ) {
2215                    Ok(r) => (r.items, r.next_cursor),
2216                    Err(e) => return Ok(err_to_tool_result_from_pagination(e)),
2217                };
2218
2219                // Always regenerate formatted output for DefUse mode so the
2220                // first page (offset=0, verbose=true) is not skipped.
2221                if !use_summary {
2222                    let base_path = Path::new(&params.path);
2223                    output.formatted = format_focused_paginated_defuse(
2224                        &paginated_sites,
2225                        total_sites,
2226                        &params.symbol,
2227                        offset,
2228                        Some(base_path),
2229                        verbose,
2230                    );
2231                }
2232
2233                // Slice output.def_use_sites to the current page window so
2234                // structuredContent only contains the paginated subset.
2235                output.def_use_sites = paginated_sites;
2236
2237                paginated_next
2238            }
2239        };
2240
2241        // When callers are exhausted and callees exist, bootstrap callee pagination
2242        // by emitting a {mode:callees, offset:0} cursor. This makes PaginationMode::Callees
2243        // reachable; without it the branch was dead code. Suppressed in summary mode
2244        // because summary and pagination are mutually exclusive.
2245        if callee_cursor.is_none()
2246            && cursor_mode == PaginationMode::Callers
2247            && !output.outgoing_chains.is_empty()
2248            && !use_summary
2249            && let Ok(cursor) = encode_cursor(&CursorData {
2250                mode: PaginationMode::Callees,
2251                offset: 0,
2252            })
2253        {
2254            callee_cursor = Some(cursor);
2255        }
2256
2257        // When callees are exhausted and def_use_sites exist, bootstrap defuse cursor
2258        // by emitting a {mode:defuse, offset:0} cursor. This makes PaginationMode::DefUse
2259        // reachable. Suppressed in summary mode because summary and pagination are mutually exclusive.
2260        // Also bootstrap directly from Callers mode when there are no outgoing chains
2261        // (e.g. SymbolNotFound path or symbols with no callees) so def-use pagination
2262        // is reachable even without a Callees phase.
2263        if callee_cursor.is_none()
2264            && matches!(
2265                cursor_mode,
2266                PaginationMode::Callees | PaginationMode::Callers
2267            )
2268            && !output.def_use_sites.is_empty()
2269            && !use_summary
2270            && let Ok(cursor) = encode_cursor(&CursorData {
2271                mode: PaginationMode::DefUse,
2272                offset: 0,
2273            })
2274        {
2275            // Only bootstrap from Callers when callees are empty (otherwise
2276            // the Callees bootstrap above takes priority).
2277            if cursor_mode == PaginationMode::Callees || output.outgoing_chains.is_empty() {
2278                callee_cursor = Some(cursor);
2279            }
2280        }
2281
2282        // Update next_cursor in output
2283        output.next_cursor.clone_from(&callee_cursor);
2284
2285        // Build final text output with pagination cursor if present
2286        let mut final_text = output.formatted.clone();
2287        if let Some(cursor) = callee_cursor {
2288            final_text.push('\n');
2289            final_text.push_str("NEXT_CURSOR: ");
2290            final_text.push_str(&cursor);
2291        }
2292
2293        // Record cache tier in span
2294        tracing::Span::current().record("cache_tier", "Miss");
2295
2296        // Add content_hash to _meta
2297        let content_hash = format!("{}", blake3::hash(final_text.as_bytes()));
2298        let mut meta = no_cache_meta().0;
2299        meta.insert(
2300            "content_hash".to_string(),
2301            serde_json::Value::String(content_hash),
2302        );
2303
2304        let mut result = CallToolResult::success(vec![Content::text(final_text.clone())])
2305            .with_meta(Some(Meta(meta)));
2306        // Only include def_use_sites in structuredContent when in DefUse mode.
2307        // In Callers/Callees modes, clearing the vec prevents large def-use
2308        // payloads from leaking into paginated non-def-use responses.
2309        if cursor_mode != PaginationMode::DefUse {
2310            output.def_use_sites = Vec::new();
2311        }
2312        let structured = serde_json::to_value(&output).unwrap_or(Value::Null);
2313        result.structured_content = Some(structured);
2314        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2315        self.metrics_tx.send(crate::metrics::MetricEvent {
2316            ts: crate::metrics::unix_ms(),
2317            tool: "analyze_symbol",
2318            duration_ms: dur,
2319            output_chars: final_text.len(),
2320            param_path_depth: crate::metrics::path_component_count(&param_path),
2321            max_depth: max_depth_val,
2322            result: "ok",
2323            error_type: None,
2324            session_id: sid,
2325            seq: Some(seq),
2326            cache_hit: Some(false),
2327            cache_tier: Some(CacheTier::Miss.as_str()),
2328            cache_write_failure: None,
2329            exit_code: None,
2330            timed_out: false,
2331            output_truncated: None,
2332            ..Default::default()
2333        });
2334        Ok(result)
2335    }
2336
2337    #[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))]
2338    #[tool(
2339        name = "analyze_module",
2340        title = "Analyze Module",
2341        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?",
2342        output_schema = schema_for_type::<types::ModuleInfo>(),
2343        annotations(
2344            title = "Analyze Module",
2345            read_only_hint = true,
2346            destructive_hint = false,
2347            idempotent_hint = true,
2348            open_world_hint = false
2349        )
2350    )]
2351    async fn analyze_module(
2352        &self,
2353        params: Parameters<AnalyzeModuleParams>,
2354        context: RequestContext<RoleServer>,
2355    ) -> Result<CallToolResult, ErrorData> {
2356        let params = params.0;
2357        // Extract W3C Trace Context from request _meta if present
2358        let session_id = self.session_id.lock().await.clone();
2359        let client_name = self.client_name.lock().await.clone();
2360        let client_version = self.client_version.lock().await.clone();
2361        extract_and_set_trace_context(
2362            Some(&context.meta),
2363            ClientMetadata {
2364                session_id,
2365                client_name,
2366                client_version,
2367            },
2368        );
2369        let span = tracing::Span::current();
2370        span.record("gen_ai.system", "mcp");
2371        span.record("gen_ai.operation.name", "execute_tool");
2372        span.record("gen_ai.tool.name", "analyze_module");
2373        span.record("path", &params.path);
2374        let _validated_path = match validate_path(&params.path, true) {
2375            Ok(p) => p,
2376            Err(e) => {
2377                span.record("error", true);
2378                span.record("error.type", "invalid_params");
2379                return Ok(err_to_tool_result(e));
2380            }
2381        };
2382        let t_start = std::time::Instant::now();
2383        let param_path = params.path.clone();
2384        let seq = self
2385            .session_call_seq
2386            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2387        let sid = self.session_id.lock().await.clone();
2388
2389        // Issue 340: Guard against directory paths
2390        if std::fs::metadata(&params.path)
2391            .map(|m| m.is_dir())
2392            .unwrap_or(false)
2393        {
2394            span.record("error", true);
2395            span.record("error.type", "invalid_params");
2396            let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2397            self.metrics_tx.send(crate::metrics::MetricEvent {
2398                ts: crate::metrics::unix_ms(),
2399                tool: "analyze_module",
2400                duration_ms: dur,
2401                output_chars: 0,
2402                param_path_depth: crate::metrics::path_component_count(&param_path),
2403                max_depth: None,
2404                result: "error",
2405                error_type: Some("invalid_params".to_string()),
2406                session_id: sid.clone(),
2407                seq: Some(seq),
2408                cache_hit: None,
2409                cache_write_failure: None,
2410                cache_tier: None,
2411                exit_code: None,
2412                timed_out: false,
2413                output_truncated: None,
2414                ..Default::default()
2415            });
2416            return Ok(err_to_tool_result(ErrorData::new(
2417                rmcp::model::ErrorCode::INVALID_PARAMS,
2418                format!(
2419                    "'{}' is a directory. Use analyze_directory to analyze a directory, or pass a specific file path to analyze_module.",
2420                    params.path
2421                ),
2422                Some(error_meta(
2423                    "validation",
2424                    false,
2425                    "use analyze_directory for directories",
2426                )),
2427            )));
2428        }
2429
2430        // Route through handle_file_details_mode to inherit L1+L2 caching
2431        let mut analyze_file_params: AnalyzeFileParams = Default::default();
2432        analyze_file_params.path = params.path.clone();
2433        let (arc_output, module_tier) =
2434            match self.handle_file_details_mode(&analyze_file_params).await {
2435                Ok((output, tier)) => (output, tier),
2436                Err(e) => {
2437                    let error_data = match e.code {
2438                        rmcp::model::ErrorCode::INVALID_PARAMS => e,
2439                        _ => ErrorData::new(
2440                            rmcp::model::ErrorCode::INTERNAL_ERROR,
2441                            format!("Failed to analyze module: {}", e.message),
2442                            Some(error_meta("internal", false, "report this as a bug")),
2443                        ),
2444                    };
2445                    return Ok(err_to_tool_result(error_data));
2446                }
2447            };
2448
2449        // Reconstruct ModuleInfo from FileAnalysisOutput
2450        let file_path = std::path::Path::new(&params.path);
2451        let name = file_path
2452            .file_name()
2453            .and_then(|n: &std::ffi::OsStr| n.to_str())
2454            .unwrap_or("unknown")
2455            .to_string();
2456        let language = file_path
2457            .extension()
2458            .and_then(|e| e.to_str())
2459            .and_then(aptu_coder_core::lang::language_for_extension)
2460            .unwrap_or("unknown")
2461            .to_string();
2462        let functions = arc_output
2463            .semantic
2464            .functions
2465            .iter()
2466            .map(|f| {
2467                let mut mfi = types::ModuleFunctionInfo::default();
2468                mfi.name = f.name.clone();
2469                mfi.line = f.line;
2470                mfi
2471            })
2472            .collect();
2473        let imports = arc_output
2474            .semantic
2475            .imports
2476            .iter()
2477            .map(|i| {
2478                let mut mii = types::ModuleImportInfo::default();
2479                mii.module = i.module.clone();
2480                mii.items = i.items.clone();
2481                mii
2482            })
2483            .collect();
2484        let module_info =
2485            types::ModuleInfo::new(name, arc_output.line_count, language, functions, imports);
2486
2487        let text = format_module_info(&module_info);
2488
2489        // Record cache tier in span
2490        tracing::Span::current().record("cache_tier", module_tier.as_str());
2491
2492        // Add content_hash to _meta
2493        let content_hash = format!("{}", blake3::hash(text.as_bytes()));
2494        let mut meta = no_cache_meta().0;
2495        meta.insert(
2496            "content_hash".to_string(),
2497            serde_json::Value::String(content_hash),
2498        );
2499
2500        let mut result =
2501            CallToolResult::success(vec![Content::text(text.clone())]).with_meta(Some(Meta(meta)));
2502        let structured = match serde_json::to_value(&module_info).map_err(|e| {
2503            ErrorData::new(
2504                rmcp::model::ErrorCode::INTERNAL_ERROR,
2505                format!("serialization failed: {e}"),
2506                Some(error_meta("internal", false, "report this as a bug")),
2507            )
2508        }) {
2509            Ok(v) => v,
2510            Err(e) => return Ok(err_to_tool_result(e)),
2511        };
2512        result.structured_content = Some(structured);
2513        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2514        self.metrics_tx.send(crate::metrics::MetricEvent {
2515            ts: crate::metrics::unix_ms(),
2516            tool: "analyze_module",
2517            duration_ms: dur,
2518            output_chars: text.len(),
2519            param_path_depth: crate::metrics::path_component_count(&param_path),
2520            max_depth: None,
2521            result: "ok",
2522            error_type: None,
2523            session_id: sid,
2524            seq: Some(seq),
2525            cache_hit: Some(module_tier != CacheTier::Miss),
2526            cache_tier: Some(module_tier.as_str()),
2527            cache_write_failure: None,
2528            exit_code: None,
2529            timed_out: false,
2530            output_truncated: None,
2531            ..Default::default()
2532        });
2533        Ok(result)
2534    }
2535
2536    #[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))]
2537    #[tool(
2538        name = "edit_overwrite",
2539        title = "Edit Overwrite",
2540        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.",
2541        output_schema = schema_for_type::<EditOverwriteOutput>(),
2542        annotations(
2543            title = "Edit Overwrite",
2544            read_only_hint = false,
2545            destructive_hint = true,
2546            idempotent_hint = false,
2547            open_world_hint = false
2548        )
2549    )]
2550    async fn edit_overwrite(
2551        &self,
2552        params: Parameters<EditOverwriteParams>,
2553        context: RequestContext<RoleServer>,
2554    ) -> Result<CallToolResult, ErrorData> {
2555        let params = params.0;
2556        // Extract W3C Trace Context from request _meta if present
2557        let session_id = self.session_id.lock().await.clone();
2558        let client_name = self.client_name.lock().await.clone();
2559        let client_version = self.client_version.lock().await.clone();
2560        extract_and_set_trace_context(
2561            Some(&context.meta),
2562            ClientMetadata {
2563                session_id,
2564                client_name,
2565                client_version,
2566            },
2567        );
2568        let span = tracing::Span::current();
2569        span.record("gen_ai.system", "mcp");
2570        span.record("gen_ai.operation.name", "execute_tool");
2571        span.record("gen_ai.tool.name", "edit_overwrite");
2572        span.record("path", &params.path);
2573        let _validated_path = if let Some(ref wd) = params.working_dir {
2574            match validate_path_in_dir(&params.path, false, std::path::Path::new(wd)) {
2575                Ok(p) => p,
2576                Err(e) => {
2577                    span.record("error", true);
2578                    span.record("error.type", "invalid_params");
2579                    return Ok(err_to_tool_result(e));
2580                }
2581            }
2582        } else {
2583            match validate_path(&params.path, false) {
2584                Ok(p) => p,
2585                Err(e) => {
2586                    span.record("error", true);
2587                    span.record("error.type", "invalid_params");
2588                    return Ok(err_to_tool_result(e));
2589                }
2590            }
2591        };
2592        let t_start = std::time::Instant::now();
2593        let param_path = params.path.clone();
2594        let seq = self
2595            .session_call_seq
2596            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2597        let sid = self.session_id.lock().await.clone();
2598
2599        // Guard against directory paths
2600        if std::fs::metadata(&params.path)
2601            .map(|m| m.is_dir())
2602            .unwrap_or(false)
2603        {
2604            span.record("error", true);
2605            span.record("error.type", "invalid_params");
2606            let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2607            self.metrics_tx.send(crate::metrics::MetricEvent {
2608                ts: crate::metrics::unix_ms(),
2609                tool: "edit_overwrite",
2610                duration_ms: dur,
2611                output_chars: 0,
2612                param_path_depth: crate::metrics::path_component_count(&param_path),
2613                max_depth: None,
2614                result: "error",
2615                error_type: Some("invalid_params".to_string()),
2616                session_id: sid.clone(),
2617                seq: Some(seq),
2618                cache_hit: None,
2619                cache_write_failure: None,
2620                cache_tier: None,
2621                exit_code: None,
2622                timed_out: false,
2623                output_truncated: None,
2624                ..Default::default()
2625            });
2626            return Ok(err_to_tool_result(ErrorData::new(
2627                rmcp::model::ErrorCode::INVALID_PARAMS,
2628                "path is a directory; cannot write to a directory".to_string(),
2629                Some(error_meta(
2630                    "validation",
2631                    false,
2632                    "provide a file path, not a directory",
2633                )),
2634            )));
2635        }
2636
2637        let path = std::path::PathBuf::from(&params.path);
2638        let content = params.content.clone();
2639        let handle = tokio::task::spawn_blocking(move || {
2640            aptu_coder_core::edit_overwrite_content(&path, &content)
2641        });
2642
2643        let output = match handle.await {
2644            Ok(Ok(v)) => v,
2645            Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2646                span.record("error", true);
2647                span.record("error.type", "invalid_params");
2648                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2649                self.metrics_tx.send(crate::metrics::MetricEvent {
2650                    ts: crate::metrics::unix_ms(),
2651                    tool: "edit_overwrite",
2652                    duration_ms: dur,
2653                    output_chars: 0,
2654                    param_path_depth: crate::metrics::path_component_count(&param_path),
2655                    max_depth: None,
2656                    result: "error",
2657                    error_type: Some("invalid_params".to_string()),
2658                    session_id: sid.clone(),
2659                    seq: Some(seq),
2660                    cache_hit: None,
2661                    cache_write_failure: None,
2662                    cache_tier: None,
2663                    exit_code: None,
2664                    timed_out: false,
2665                    output_truncated: None,
2666                    ..Default::default()
2667                });
2668                return Ok(err_to_tool_result(ErrorData::new(
2669                    rmcp::model::ErrorCode::INVALID_PARAMS,
2670                    "path is a directory".to_string(),
2671                    Some(error_meta(
2672                        "validation",
2673                        false,
2674                        "provide a file path, not a directory",
2675                    )),
2676                )));
2677            }
2678            Ok(Err(e)) => {
2679                span.record("error", true);
2680                span.record("error.type", "internal_error");
2681                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2682                self.metrics_tx.send(crate::metrics::MetricEvent {
2683                    ts: crate::metrics::unix_ms(),
2684                    tool: "edit_overwrite",
2685                    duration_ms: dur,
2686                    output_chars: 0,
2687                    param_path_depth: crate::metrics::path_component_count(&param_path),
2688                    max_depth: None,
2689                    result: "error",
2690                    error_type: Some("internal_error".to_string()),
2691                    session_id: sid.clone(),
2692                    seq: Some(seq),
2693                    cache_hit: None,
2694                    cache_write_failure: None,
2695                    cache_tier: None,
2696                    exit_code: None,
2697                    timed_out: false,
2698                    output_truncated: None,
2699                    ..Default::default()
2700                });
2701                return Ok(err_to_tool_result(ErrorData::new(
2702                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2703                    e.to_string(),
2704                    Some(error_meta(
2705                        "resource",
2706                        false,
2707                        "check file path and permissions",
2708                    )),
2709                )));
2710            }
2711            Err(e) => {
2712                span.record("error", true);
2713                span.record("error.type", "internal_error");
2714                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2715                self.metrics_tx.send(crate::metrics::MetricEvent {
2716                    ts: crate::metrics::unix_ms(),
2717                    tool: "edit_overwrite",
2718                    duration_ms: dur,
2719                    output_chars: 0,
2720                    param_path_depth: crate::metrics::path_component_count(&param_path),
2721                    max_depth: None,
2722                    result: "error",
2723                    error_type: Some("internal_error".to_string()),
2724                    session_id: sid.clone(),
2725                    seq: Some(seq),
2726                    cache_hit: None,
2727                    cache_write_failure: None,
2728                    cache_tier: None,
2729                    exit_code: None,
2730                    timed_out: false,
2731                    output_truncated: None,
2732                    ..Default::default()
2733                });
2734                return Ok(err_to_tool_result(ErrorData::new(
2735                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2736                    e.to_string(),
2737                    Some(error_meta(
2738                        "resource",
2739                        false,
2740                        "check file path and permissions",
2741                    )),
2742                )));
2743            }
2744        };
2745
2746        let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2747        let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2748            .with_meta(Some(no_cache_meta()));
2749        let structured = match serde_json::to_value(&output).map_err(|e| {
2750            ErrorData::new(
2751                rmcp::model::ErrorCode::INTERNAL_ERROR,
2752                format!("serialization failed: {e}"),
2753                Some(error_meta("internal", false, "report this as a bug")),
2754            )
2755        }) {
2756            Ok(v) => v,
2757            Err(e) => return Ok(err_to_tool_result(e)),
2758        };
2759        result.structured_content = Some(structured);
2760        self.cache
2761            .invalidate_file(&std::path::PathBuf::from(&param_path));
2762        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2763        self.metrics_tx.send(crate::metrics::MetricEvent {
2764            ts: crate::metrics::unix_ms(),
2765            tool: "edit_overwrite",
2766            duration_ms: dur,
2767            output_chars: text.len(),
2768            param_path_depth: crate::metrics::path_component_count(&param_path),
2769            max_depth: None,
2770            result: "ok",
2771            error_type: None,
2772            session_id: sid,
2773            seq: Some(seq),
2774            cache_hit: None,
2775            cache_write_failure: None,
2776            cache_tier: None,
2777            exit_code: None,
2778            timed_out: false,
2779            output_truncated: None,
2780            ..Default::default()
2781        });
2782        Ok(result)
2783    }
2784
2785    #[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))]
2786    #[tool(
2787        name = "edit_replace",
2788        title = "Edit Replace",
2789        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.",
2790        output_schema = schema_for_type::<EditReplaceOutput>(),
2791        annotations(
2792            title = "Edit Replace",
2793            read_only_hint = false,
2794            destructive_hint = true,
2795            idempotent_hint = false,
2796            open_world_hint = false
2797        )
2798    )]
2799    async fn edit_replace(
2800        &self,
2801        params: Parameters<EditReplaceParams>,
2802        context: RequestContext<RoleServer>,
2803    ) -> Result<CallToolResult, ErrorData> {
2804        let params = params.0;
2805        // Extract W3C Trace Context from request _meta if present
2806        let session_id = self.session_id.lock().await.clone();
2807        let client_name = self.client_name.lock().await.clone();
2808        let client_version = self.client_version.lock().await.clone();
2809        extract_and_set_trace_context(
2810            Some(&context.meta),
2811            ClientMetadata {
2812                session_id,
2813                client_name,
2814                client_version,
2815            },
2816        );
2817        let span = tracing::Span::current();
2818        span.record("gen_ai.system", "mcp");
2819        span.record("gen_ai.operation.name", "execute_tool");
2820        span.record("gen_ai.tool.name", "edit_replace");
2821        span.record("path", &params.path);
2822        let _validated_path = if let Some(ref wd) = params.working_dir {
2823            match validate_path_in_dir(&params.path, true, std::path::Path::new(wd)) {
2824                Ok(p) => p,
2825                Err(e) => {
2826                    span.record("error", true);
2827                    span.record("error.type", "invalid_params");
2828                    return Ok(err_to_tool_result(e));
2829                }
2830            }
2831        } else {
2832            match validate_path(&params.path, true) {
2833                Ok(p) => p,
2834                Err(e) => {
2835                    span.record("error", true);
2836                    span.record("error.type", "invalid_params");
2837                    return Ok(err_to_tool_result(e));
2838                }
2839            }
2840        };
2841        let t_start = std::time::Instant::now();
2842        let param_path = params.path.clone();
2843        let seq = self
2844            .session_call_seq
2845            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2846        let sid = self.session_id.lock().await.clone();
2847
2848        // Guard against directory paths
2849        if std::fs::metadata(&params.path)
2850            .map(|m| m.is_dir())
2851            .unwrap_or(false)
2852        {
2853            span.record("error", true);
2854            span.record("error.type", "invalid_params");
2855            let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2856            self.metrics_tx.send(crate::metrics::MetricEvent {
2857                ts: crate::metrics::unix_ms(),
2858                tool: "edit_replace",
2859                duration_ms: dur,
2860                output_chars: 0,
2861                param_path_depth: crate::metrics::path_component_count(&param_path),
2862                max_depth: None,
2863                result: "error",
2864                error_type: Some("invalid_params".to_string()),
2865                session_id: sid.clone(),
2866                seq: Some(seq),
2867                cache_hit: None,
2868                cache_write_failure: None,
2869                cache_tier: None,
2870                exit_code: None,
2871                timed_out: false,
2872                output_truncated: None,
2873                ..Default::default()
2874            });
2875            return Ok(err_to_tool_result(ErrorData::new(
2876                rmcp::model::ErrorCode::INVALID_PARAMS,
2877                "path is a directory; cannot edit a directory".to_string(),
2878                Some(error_meta(
2879                    "validation",
2880                    false,
2881                    "provide a file path, not a directory",
2882                )),
2883            )));
2884        }
2885
2886        let path = std::path::PathBuf::from(&params.path);
2887        let old_text = params.old_text.clone();
2888        let new_text = params.new_text.clone();
2889        let handle = tokio::task::spawn_blocking(move || {
2890            aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2891        });
2892
2893        let output = match handle.await {
2894            Ok(Ok(v)) => v,
2895            Ok(Err(aptu_coder_core::EditError::NotFound {
2896                path: notfound_path,
2897            })) => {
2898                span.record("error", true);
2899                span.record("error.type", "invalid_params");
2900                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2901                self.metrics_tx.send(crate::metrics::MetricEvent {
2902                    ts: crate::metrics::unix_ms(),
2903                    tool: "edit_replace",
2904                    duration_ms: dur,
2905                    output_chars: 0,
2906                    param_path_depth: crate::metrics::path_component_count(&param_path),
2907                    max_depth: None,
2908                    result: "error",
2909                    error_type: Some("invalid_params".to_string()),
2910                    session_id: sid.clone(),
2911                    seq: Some(seq),
2912                    cache_hit: None,
2913                    cache_write_failure: None,
2914                    cache_tier: None,
2915                    exit_code: None,
2916                    timed_out: false,
2917                    output_truncated: None,
2918                    ..Default::default()
2919                });
2920                return Ok(err_to_tool_result(ErrorData::new(
2921                    rmcp::model::ErrorCode::INVALID_PARAMS,
2922                    format!(
2923                        "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."
2924                    ),
2925                    Some(error_meta(
2926                        "validation",
2927                        false,
2928                        "re-read the file with analyze_file or analyze_module, then derive old_text from the live content",
2929                    )),
2930                )));
2931            }
2932            Ok(Err(aptu_coder_core::EditError::Ambiguous {
2933                count,
2934                path: ambiguous_path,
2935            })) => {
2936                span.record("error", true);
2937                span.record("error.type", "invalid_params");
2938                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2939                self.metrics_tx.send(crate::metrics::MetricEvent {
2940                    ts: crate::metrics::unix_ms(),
2941                    tool: "edit_replace",
2942                    duration_ms: dur,
2943                    output_chars: 0,
2944                    param_path_depth: crate::metrics::path_component_count(&param_path),
2945                    max_depth: None,
2946                    result: "error",
2947                    error_type: Some("invalid_params".to_string()),
2948                    session_id: sid.clone(),
2949                    seq: Some(seq),
2950                    cache_hit: None,
2951                    cache_write_failure: None,
2952                    cache_tier: None,
2953                    exit_code: None,
2954                    timed_out: false,
2955                    output_truncated: None,
2956                    ..Default::default()
2957                });
2958                return Ok(err_to_tool_result(ErrorData::new(
2959                    rmcp::model::ErrorCode::INVALID_PARAMS,
2960                    format!(
2961                        "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."
2962                    ),
2963                    Some(error_meta(
2964                        "validation",
2965                        false,
2966                        "extend old_text with more surrounding context, or re-read with analyze_file to confirm the exact text",
2967                    )),
2968                )));
2969            }
2970            Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2971                span.record("error", true);
2972                span.record("error.type", "invalid_params");
2973                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2974                self.metrics_tx.send(crate::metrics::MetricEvent {
2975                    ts: crate::metrics::unix_ms(),
2976                    tool: "edit_replace",
2977                    duration_ms: dur,
2978                    output_chars: 0,
2979                    param_path_depth: crate::metrics::path_component_count(&param_path),
2980                    max_depth: None,
2981                    result: "error",
2982                    error_type: Some("invalid_params".to_string()),
2983                    session_id: sid.clone(),
2984                    seq: Some(seq),
2985                    cache_hit: None,
2986                    cache_write_failure: None,
2987                    cache_tier: None,
2988                    exit_code: None,
2989                    timed_out: false,
2990                    output_truncated: None,
2991                    ..Default::default()
2992                });
2993                return Ok(err_to_tool_result(ErrorData::new(
2994                    rmcp::model::ErrorCode::INVALID_PARAMS,
2995                    "path is a directory".to_string(),
2996                    Some(error_meta(
2997                        "validation",
2998                        false,
2999                        "provide a file path, not a directory",
3000                    )),
3001                )));
3002            }
3003            Ok(Err(e)) => {
3004                span.record("error", true);
3005                span.record("error.type", "internal_error");
3006                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3007                self.metrics_tx.send(crate::metrics::MetricEvent {
3008                    ts: crate::metrics::unix_ms(),
3009                    tool: "edit_replace",
3010                    duration_ms: dur,
3011                    output_chars: 0,
3012                    param_path_depth: crate::metrics::path_component_count(&param_path),
3013                    max_depth: None,
3014                    result: "error",
3015                    error_type: Some("internal_error".to_string()),
3016                    session_id: sid.clone(),
3017                    seq: Some(seq),
3018                    cache_hit: None,
3019                    cache_write_failure: None,
3020                    cache_tier: None,
3021                    exit_code: None,
3022                    timed_out: false,
3023                    output_truncated: None,
3024                    ..Default::default()
3025                });
3026                return Ok(err_to_tool_result(ErrorData::new(
3027                    rmcp::model::ErrorCode::INTERNAL_ERROR,
3028                    e.to_string(),
3029                    Some(error_meta(
3030                        "resource",
3031                        false,
3032                        "check file path and permissions",
3033                    )),
3034                )));
3035            }
3036            Err(e) => {
3037                span.record("error", true);
3038                span.record("error.type", "internal_error");
3039                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3040                self.metrics_tx.send(crate::metrics::MetricEvent {
3041                    ts: crate::metrics::unix_ms(),
3042                    tool: "edit_replace",
3043                    duration_ms: dur,
3044                    output_chars: 0,
3045                    param_path_depth: crate::metrics::path_component_count(&param_path),
3046                    max_depth: None,
3047                    result: "error",
3048                    error_type: Some("internal_error".to_string()),
3049                    session_id: sid.clone(),
3050                    seq: Some(seq),
3051                    cache_hit: None,
3052                    cache_write_failure: None,
3053                    cache_tier: None,
3054                    exit_code: None,
3055                    timed_out: false,
3056                    output_truncated: None,
3057                    ..Default::default()
3058                });
3059                return Ok(err_to_tool_result(ErrorData::new(
3060                    rmcp::model::ErrorCode::INTERNAL_ERROR,
3061                    e.to_string(),
3062                    Some(error_meta(
3063                        "resource",
3064                        false,
3065                        "check file path and permissions",
3066                    )),
3067                )));
3068            }
3069        };
3070
3071        let text = format!(
3072            "Edited {}: {} bytes -> {} bytes",
3073            output.path, output.bytes_before, output.bytes_after
3074        );
3075        let mut result = CallToolResult::success(vec![Content::text(text.clone())])
3076            .with_meta(Some(no_cache_meta()));
3077        let structured = match serde_json::to_value(&output).map_err(|e| {
3078            ErrorData::new(
3079                rmcp::model::ErrorCode::INTERNAL_ERROR,
3080                format!("serialization failed: {e}"),
3081                Some(error_meta("internal", false, "report this as a bug")),
3082            )
3083        }) {
3084            Ok(v) => v,
3085            Err(e) => return Ok(err_to_tool_result(e)),
3086        };
3087        result.structured_content = Some(structured);
3088        self.cache
3089            .invalidate_file(&std::path::PathBuf::from(&param_path));
3090        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3091        self.metrics_tx.send(crate::metrics::MetricEvent {
3092            ts: crate::metrics::unix_ms(),
3093            tool: "edit_replace",
3094            duration_ms: dur,
3095            output_chars: text.len(),
3096            param_path_depth: crate::metrics::path_component_count(&param_path),
3097            max_depth: None,
3098            result: "ok",
3099            error_type: None,
3100            session_id: sid,
3101            seq: Some(seq),
3102            cache_hit: None,
3103            cache_write_failure: None,
3104            cache_tier: None,
3105            exit_code: None,
3106            timed_out: false,
3107            output_truncated: None,
3108            ..Default::default()
3109        });
3110        Ok(result)
3111    }
3112
3113    #[tool(
3114        name = "exec_command",
3115        title = "Exec Command",
3116        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.",
3117        output_schema = schema_for_type::<types::ShellOutput>(),
3118        annotations(
3119            title = "Exec Command",
3120            read_only_hint = false,
3121            destructive_hint = true,
3122            idempotent_hint = false,
3123            open_world_hint = true
3124        )
3125    )]
3126    #[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))]
3127    pub async fn exec_command(
3128        &self,
3129        params: Parameters<types::ExecCommandParams>,
3130        context: RequestContext<RoleServer>,
3131    ) -> Result<CallToolResult, ErrorData> {
3132        let t_start = std::time::Instant::now();
3133        let params = params.0;
3134        // Extract W3C Trace Context from request _meta if present
3135        let session_id = self.session_id.lock().await.clone();
3136        let client_name = self.client_name.lock().await.clone();
3137        let client_version = self.client_version.lock().await.clone();
3138        extract_and_set_trace_context(
3139            Some(&context.meta),
3140            ClientMetadata {
3141                session_id,
3142                client_name,
3143                client_version,
3144            },
3145        );
3146        let span = tracing::Span::current();
3147        span.record("gen_ai.system", "mcp");
3148        span.record("gen_ai.operation.name", "execute_tool");
3149        span.record("gen_ai.tool.name", "exec_command");
3150        span.record("command", &params.command);
3151
3152        // Validate working_dir if provided
3153        let working_dir_path = if let Some(ref wd) = params.working_dir {
3154            match validate_path(wd, true) {
3155                Ok(p) => {
3156                    // Verify it's a directory
3157                    if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
3158                        span.record("error", true);
3159                        span.record("error.type", "invalid_params");
3160                        return Ok(err_to_tool_result(ErrorData::new(
3161                            rmcp::model::ErrorCode::INVALID_PARAMS,
3162                            "working_dir must be a directory".to_string(),
3163                            Some(error_meta(
3164                                "validation",
3165                                false,
3166                                "provide a valid directory path",
3167                            )),
3168                        )));
3169                    }
3170                    Some(p)
3171                }
3172                Err(e) => {
3173                    span.record("error", true);
3174                    span.record("error.type", "invalid_params");
3175                    return Ok(err_to_tool_result(e));
3176                }
3177            }
3178        } else {
3179            None
3180        };
3181
3182        let param_path = params.working_dir.clone();
3183        let seq = self
3184            .session_call_seq
3185            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3186        let sid = self.session_id.lock().await.clone();
3187
3188        // Validate stdin size cap (1 MB)
3189        if let Some(ref stdin_content) = params.stdin
3190            && stdin_content.len() > STDIN_MAX_BYTES
3191        {
3192            span.record("error", true);
3193            span.record("error.type", "invalid_params");
3194            return Ok(err_to_tool_result(ErrorData::new(
3195                rmcp::model::ErrorCode::INVALID_PARAMS,
3196                "stdin exceeds 1 MB limit".to_string(),
3197                Some(error_meta("validation", false, "reduce stdin content size")),
3198            )));
3199        }
3200
3201        let command = params.command.clone();
3202        let timeout_secs = params.timeout_secs;
3203
3204        // Determine cache key and whether to use cache
3205        let _cache_key = (
3206            command.clone(),
3207            working_dir_path
3208                .as_ref()
3209                .map(|p| p.display().to_string())
3210                .unwrap_or_default(),
3211        );
3212        // Execute command (caching disabled; explicit opt-in via cache=true not implemented)
3213        let resolved_path_str = self.resolved_path.as_ref().as_deref();
3214        let output = run_exec_impl(
3215            command.clone(),
3216            working_dir_path.clone(),
3217            timeout_secs,
3218            params.memory_limit_mb,
3219            params.cpu_limit_secs,
3220            params.stdin.clone(),
3221            seq,
3222            resolved_path_str,
3223            &self.filter_table,
3224        )
3225        .await;
3226
3227        let exit_code = output.exit_code;
3228        let timed_out = output.timed_out;
3229        let mut output_truncated = output.output_truncated;
3230
3231        // Record execution results on span
3232        if let Some(code) = exit_code {
3233            span.record("exit_code", code);
3234        }
3235        span.record("timed_out", timed_out);
3236        span.record("output_truncated", output_truncated);
3237
3238        // Emit debug event for truncation
3239        if output_truncated {
3240            tracing::debug!(truncated = true, message = "output truncated");
3241        }
3242
3243        // Use interleaved if non-empty; fall back to separated stdout/stderr for empty-output commands
3244        let output_text = if output.interleaved.is_empty() {
3245            format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
3246        } else {
3247            format!("Output:\n{}", output.interleaved)
3248        };
3249
3250        // Apply combined output size limit (SIZE_LIMIT = 50k chars). Per-stream caps
3251        // (MAX_STDOUT_BYTES = 30k stdout, MAX_STDERR_BYTES = 10k stderr) already fired in
3252        // handle_output_persist; this is the safety net for the interleaved assembly which
3253        // can still reach up to ~40k chars from per-stream content plus headers and formatting.
3254        let mut combined_truncated = false;
3255        let truncated_output_text = if output_text.len() > SIZE_LIMIT {
3256            combined_truncated = true;
3257            // Use char-boundary-safe tail truncation
3258            let tail_start = output_text.len().saturating_sub(SIZE_LIMIT);
3259            let safe_start = output_text[..tail_start].floor_char_boundary(tail_start);
3260            output_text[safe_start..].to_string()
3261        } else {
3262            output_text
3263        };
3264
3265        // Update output_truncated flag to include combined truncation
3266        output_truncated = output_truncated || combined_truncated;
3267
3268        let text = format!(
3269            "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
3270            params.command,
3271            exit_code
3272                .map(|c| c.to_string())
3273                .unwrap_or_else(|| "null".to_string()),
3274            timed_out,
3275            output_truncated,
3276            truncated_output_text,
3277        );
3278
3279        let content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
3280
3281        // Determine if command failed: timeout or non-zero exit code.
3282        // exit_code is None when: (a) process killed by O1 post-exit drain timeout (background child
3283        // holding pipes -- command work was done, treat as success) or (b) externally killed; both
3284        // cases use unwrap_or(false) to avoid false negatives.
3285        let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
3286
3287        let mut result = if command_failed {
3288            CallToolResult::error(content_blocks)
3289        } else {
3290            CallToolResult::success(content_blocks)
3291        }
3292        .with_meta(Some(no_cache_meta()));
3293
3294        let structured = match serde_json::to_value(&output).map_err(|e| {
3295            ErrorData::new(
3296                rmcp::model::ErrorCode::INTERNAL_ERROR,
3297                format!("serialization failed: {e}"),
3298                Some(error_meta("internal", false, "report this as a bug")),
3299            )
3300        }) {
3301            Ok(v) => v,
3302            Err(e) => {
3303                span.record("error", true);
3304                span.record("error.type", "internal_error");
3305                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3306                self.metrics_tx.send(crate::metrics::MetricEvent {
3307                    ts: crate::metrics::unix_ms(),
3308                    tool: "exec_command",
3309                    duration_ms: dur,
3310                    output_chars: 0,
3311                    param_path_depth: crate::metrics::path_component_count(
3312                        param_path.as_deref().unwrap_or(""),
3313                    ),
3314                    max_depth: None,
3315                    result: "error",
3316                    error_type: Some("internal_error".to_string()),
3317                    session_id: sid.clone(),
3318                    seq: Some(seq),
3319                    cache_hit: Some(false),
3320                    cache_write_failure: None,
3321                    cache_tier: None,
3322                    exit_code,
3323                    timed_out,
3324                    output_truncated: Some(output_truncated),
3325                    ..Default::default()
3326                });
3327                return Ok(err_to_tool_result(e));
3328            }
3329        };
3330
3331        result.structured_content = Some(structured);
3332        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3333        self.metrics_tx.send(crate::metrics::MetricEvent {
3334            ts: crate::metrics::unix_ms(),
3335            tool: "exec_command",
3336            duration_ms: dur,
3337            output_chars: text.len(),
3338            param_path_depth: crate::metrics::path_component_count(
3339                param_path.as_deref().unwrap_or(""),
3340            ),
3341            max_depth: None,
3342            result: "ok",
3343            error_type: None,
3344            session_id: sid,
3345            seq: Some(seq),
3346            cache_hit: Some(false),
3347            cache_write_failure: None,
3348            cache_tier: None,
3349            exit_code,
3350            timed_out,
3351            output_truncated: Some(output_truncated),
3352            chars_threshold_breach: text.len() > 30_000,
3353        });
3354        Ok(result)
3355    }
3356}
3357
3358/// Build and configure a tokio::process::Command with stdio, working directory, and resource limits.
3359fn build_exec_command(
3360    command: &str,
3361    working_dir_path: Option<&std::path::PathBuf>,
3362    memory_limit_mb: Option<u64>,
3363    cpu_limit_secs: Option<u64>,
3364    stdin_present: bool,
3365    resolved_path: Option<&str>,
3366) -> tokio::process::Command {
3367    let shell = resolve_shell();
3368    let mut cmd = tokio::process::Command::new(shell);
3369    cmd.arg("-c").arg(command);
3370
3371    if let Some(wd) = working_dir_path {
3372        cmd.current_dir(wd);
3373    }
3374
3375    // Inject resolved login shell PATH if available
3376    if let Some(path) = resolved_path {
3377        cmd.env("PATH", path);
3378    }
3379
3380    cmd.stdout(std::process::Stdio::piped())
3381        .stderr(std::process::Stdio::piped());
3382
3383    if stdin_present {
3384        cmd.stdin(std::process::Stdio::piped());
3385    } else {
3386        cmd.stdin(std::process::Stdio::null());
3387    }
3388
3389    #[cfg(unix)]
3390    {
3391        #[cfg(not(target_os = "linux"))]
3392        if memory_limit_mb.is_some() {
3393            warn!("memory_limit_mb is not enforced on this platform (Linux only)");
3394        }
3395        if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
3396            // SAFETY: This closure runs in the child process after fork() and before exec(),
3397            // making it safe to call setrlimit (a signal-safe syscall). No Rust objects are
3398            // accessed or mutated, and the closure does not unwind.
3399            unsafe {
3400                cmd.pre_exec(move || {
3401                    #[cfg(target_os = "linux")]
3402                    if let Some(mb) = memory_limit_mb {
3403                        let bytes = mb.saturating_mul(1024 * 1024);
3404                        setrlimit(Resource::RLIMIT_AS, bytes, bytes)
3405                            .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3406                    }
3407                    if let Some(cpu) = cpu_limit_secs {
3408                        setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
3409                            .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3410                    }
3411                    Ok(())
3412                });
3413            }
3414        }
3415    }
3416
3417    cmd
3418}
3419
3420/// Run a spawned child process with timeout handling and output draining.
3421/// Returns (exit_code, timed_out, output_truncated, output_collection_error).
3422async fn run_with_timeout(
3423    mut child: tokio::process::Child,
3424    timeout_secs: Option<u64>,
3425    tx: tokio::sync::mpsc::UnboundedSender<(bool, String)>,
3426) -> (Option<i32>, bool, bool, Option<String>) {
3427    use tokio::io::AsyncBufReadExt as _;
3428    use tokio_stream::StreamExt as TokioStreamExt;
3429    use tokio_stream::wrappers::LinesStream;
3430
3431    let stdout_pipe = child.stdout.take();
3432    let stderr_pipe = child.stderr.take();
3433
3434    let mut drain_task = tokio::spawn(async move {
3435        let so_stream = stdout_pipe.map(|p| {
3436            LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (false, s)))
3437        });
3438        let se_stream = stderr_pipe.map(|p| {
3439            LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3440        });
3441
3442        match (so_stream, se_stream) {
3443            (Some(so), Some(se)) => {
3444                let mut merged = so.merge(se);
3445                while let Some(Ok((is_stderr, line))) = merged.next().await {
3446                    let _ = tx.send((is_stderr, line));
3447                }
3448            }
3449            (Some(so), None) => {
3450                let mut stream = so;
3451                while let Some(Ok((_, line))) = stream.next().await {
3452                    let _ = tx.send((false, line));
3453                }
3454            }
3455            (None, Some(se)) => {
3456                let mut stream = se;
3457                while let Some(Ok((_, line))) = stream.next().await {
3458                    let _ = tx.send((true, line));
3459                }
3460            }
3461            (None, None) => {}
3462        }
3463    });
3464
3465    tokio::select! {
3466        _ = &mut drain_task => {
3467            let (status, drain_truncated) = match tokio::time::timeout(
3468                std::time::Duration::from_millis(500),
3469                child.wait()
3470            ).await {
3471                Ok(Ok(s)) => (Some(s), false),
3472                Ok(Err(_)) => (None, false),
3473                Err(_) => {
3474                    child.start_kill().ok();
3475                    let _ = child.wait().await;
3476                    (None, true)
3477                }
3478            };
3479            let exit_code = status.and_then(|s| s.code());
3480            let ocerr = if drain_truncated {
3481                Some("post-exit drain timeout: background process held pipes".to_string())
3482            } else {
3483                None
3484            };
3485            (exit_code, false, drain_truncated, ocerr)
3486        }
3487        _ = async {
3488            if let Some(secs) = timeout_secs {
3489                tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3490            } else {
3491                std::future::pending::<()>().await;
3492            }
3493        } => {
3494            let _ = child.kill().await;
3495            let _ = child.wait().await;
3496            drain_task.abort();
3497            (None, true, false, None)
3498        }
3499    }
3500}
3501
3502/// Executes a shell command and returns the output.
3503/// This is a free async function (not a method) to allow use in moka::future::Cache::get_with().
3504/// It spawns the command, collects output with timeout handling, and persists output to slot files.
3505#[allow(clippy::too_many_arguments)]
3506async fn run_exec_impl(
3507    command: String,
3508    working_dir_path: Option<std::path::PathBuf>,
3509    timeout_secs: Option<u64>,
3510    memory_limit_mb: Option<u64>,
3511    cpu_limit_secs: Option<u64>,
3512    stdin: Option<String>,
3513    seq: u32,
3514    resolved_path: Option<&str>,
3515    filter_table: &Arc<Vec<CompiledRule>>,
3516) -> types::ShellOutput {
3517    // Inject --no-stat for git pull if not already present
3518    let command = maybe_inject_no_stat(&command);
3519
3520    let mut cmd = build_exec_command(
3521        &command,
3522        working_dir_path.as_ref(),
3523        memory_limit_mb,
3524        cpu_limit_secs,
3525        stdin.is_some(),
3526        resolved_path,
3527    );
3528
3529    let mut child = match cmd.spawn() {
3530        Ok(c) => c,
3531        Err(e) => {
3532            return types::ShellOutput::new(
3533                String::new(),
3534                format!("failed to spawn command: {e}"),
3535                format!("failed to spawn command: {e}"),
3536                None,
3537                false,
3538                false,
3539            );
3540        }
3541    };
3542
3543    if let Some(stdin_content) = stdin
3544        && let Some(mut stdin_handle) = child.stdin.take()
3545    {
3546        use tokio::io::AsyncWriteExt as _;
3547        match stdin_handle.write_all(stdin_content.as_bytes()).await {
3548            Ok(()) => {
3549                drop(stdin_handle);
3550            }
3551            Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {}
3552            Err(e) => {
3553                warn!("failed to write stdin: {e}");
3554            }
3555        }
3556    }
3557
3558    let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<(bool, String)>();
3559
3560    let (exit_code, timed_out, mut output_truncated, output_collection_error) =
3561        run_with_timeout(child, timeout_secs, tx).await;
3562
3563    let mut lines: Vec<(bool, String)> = Vec::new();
3564    while let Some(item) = rx.recv().await {
3565        lines.push(item);
3566    }
3567
3568    // Split tagged lines into stdout, stderr, interleaved post-facto (no locks needed).
3569    const MAX_BYTES: usize = 50 * 1024;
3570    let mut stdout_str = String::new();
3571    let mut stderr_str = String::new();
3572    let mut interleaved_str = String::new();
3573    let mut so_bytes = 0usize;
3574    let mut se_bytes = 0usize;
3575    let mut il_bytes = 0usize;
3576    for (is_stderr, line) in &lines {
3577        let entry = format!("{line}\n");
3578        if il_bytes < 2 * MAX_BYTES {
3579            il_bytes += entry.len();
3580            interleaved_str.push_str(&entry);
3581        }
3582        if *is_stderr {
3583            if se_bytes < MAX_BYTES {
3584                se_bytes += entry.len();
3585                stderr_str.push_str(&entry);
3586            }
3587        } else if so_bytes < MAX_BYTES {
3588            so_bytes += entry.len();
3589            stdout_str.push_str(&entry);
3590        }
3591    }
3592
3593    let slot = seq % 8;
3594    let (stdout, stderr, stdout_path, stderr_path, byte_truncated) =
3595        handle_output_persist(stdout_str, stderr_str, slot);
3596    output_truncated = output_truncated || stdout_path.is_some() || byte_truncated;
3597
3598    let mut output = types::ShellOutput::new(
3599        stdout,
3600        stderr,
3601        interleaved_str,
3602        exit_code,
3603        timed_out,
3604        output_truncated,
3605    );
3606    output.output_collection_error = output_collection_error;
3607    output.stdout_path = stdout_path;
3608    output.stderr_path = stderr_path;
3609
3610    // Apply filter if exit_code == 0 and not timed out
3611    if exit_code == Some(0) && !timed_out {
3612        for compiled_rule in filter_table.iter() {
3613            if compiled_rule.pattern.is_match(&command) {
3614                let filtered_stdout = apply_filter(compiled_rule, &output.stdout);
3615                output.stdout = filtered_stdout;
3616                output.filter_applied = compiled_rule
3617                    .rule
3618                    .description
3619                    .clone()
3620                    .or_else(|| Some(compiled_rule.rule.match_command.clone()));
3621                break;
3622            }
3623        }
3624    }
3625
3626    output
3627}
3628
3629/// Handles output persistence by writing to slot files only when output overflows the line limit.
3630/// Writes full stdout/stderr to:
3631///   {temp_dir}/aptu-coder-overflow/slot-{slot}/{stdout,stderr}
3632/// Returns (stdout_out, stderr_out, stdout_path, stderr_path).
3633/// On overflow: truncates to last 50 lines and sets paths to Some.
3634/// Under limit: returns output unchanged and paths as None (no I/O).
3635fn handle_output_persist(
3636    stdout: String,
3637    stderr: String,
3638    slot: u32,
3639) -> (String, String, Option<String>, Option<String>, bool) {
3640    const MAX_OUTPUT_LINES: usize = 2000;
3641    // Sized at p99.3 of observed exec_command output_chars (27k calls): 99.27% of calls are
3642    // under 20k chars; raising to 30k covers 99.67% while still capping pathological cases
3643    // (git pull on large repos, cargo test on large workspaces) that exceed 100k chars.
3644    const MAX_STDOUT_BYTES: usize = 30_000;
3645    const MAX_STDERR_BYTES: usize = 10_000;
3646    const OVERFLOW_PREVIEW_LINES: usize = 50;
3647
3648    let stdout_lines: Vec<&str> = stdout.lines().collect();
3649    let stderr_lines: Vec<&str> = stderr.lines().collect();
3650
3651    let mut byte_truncated = false;
3652
3653    // Check for line overflow or byte overflow
3654    let line_overflow =
3655        stdout_lines.len() > MAX_OUTPUT_LINES || stderr_lines.len() > MAX_OUTPUT_LINES;
3656    let stdout_byte_overflow = stdout.len() > MAX_STDOUT_BYTES;
3657    let stderr_byte_overflow = stderr.len() > MAX_STDERR_BYTES;
3658    let byte_overflow = stdout_byte_overflow || stderr_byte_overflow;
3659
3660    // No overflow: return as-is with no I/O.
3661    if !line_overflow && !byte_overflow {
3662        return (stdout, stderr, None, None, false);
3663    }
3664
3665    // Overflow: write slot files and return last-N-lines preview.
3666    let base = std::env::temp_dir()
3667        .join("aptu-coder-overflow")
3668        .join(format!("slot-{slot}"));
3669    let _ = std::fs::create_dir_all(&base);
3670
3671    let stdout_path = base.join("stdout");
3672    let stderr_path = base.join("stderr");
3673
3674    let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3675    let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3676
3677    let stdout_path_str = stdout_path.display().to_string();
3678    let stderr_path_str = stderr_path.display().to_string();
3679
3680    // Truncate stdout if it exceeds byte limit
3681    let stdout_preview = if stdout_byte_overflow {
3682        byte_truncated = true;
3683        // Use char-boundary-safe tail truncation
3684        let tail_start = stdout.len().saturating_sub(MAX_STDOUT_BYTES);
3685        let safe_start = stdout[..tail_start].floor_char_boundary(tail_start);
3686        stdout[safe_start..].to_string()
3687    } else if stdout_lines.len() > MAX_OUTPUT_LINES {
3688        stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3689    } else {
3690        stdout
3691    };
3692
3693    // Truncate stderr if it exceeds byte limit
3694    let stderr_preview = if stderr_byte_overflow {
3695        byte_truncated = true;
3696        // Use char-boundary-safe tail truncation
3697        let tail_start = stderr.len().saturating_sub(MAX_STDERR_BYTES);
3698        let safe_start = stderr[..tail_start].floor_char_boundary(tail_start);
3699        stderr[safe_start..].to_string()
3700    } else if stderr_lines.len() > MAX_OUTPUT_LINES {
3701        stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3702    } else {
3703        stderr
3704    };
3705
3706    (
3707        stdout_preview,
3708        stderr_preview,
3709        Some(stdout_path_str),
3710        Some(stderr_path_str),
3711        byte_truncated,
3712    )
3713}
3714
3715/// Truncates output to a maximum number of lines and bytes.
3716/// Returns (truncated_output, was_truncated).
3717
3718#[derive(Clone)]
3719struct FocusedAnalysisParams {
3720    path: std::path::PathBuf,
3721    symbol: String,
3722    match_mode: SymbolMatchMode,
3723    follow_depth: u32,
3724    max_depth: Option<u32>,
3725    ast_recursion_limit: Option<usize>,
3726    use_summary: bool,
3727    impl_only: Option<bool>,
3728    def_use: bool,
3729    parse_timeout_micros: Option<u64>,
3730}
3731
3732fn disable_routes(router: &mut ToolRouter<CodeAnalyzer>, tools: &[&'static str]) {
3733    for tool in tools {
3734        router.disable_route(*tool);
3735    }
3736}
3737
3738#[tool_handler]
3739impl ServerHandler for CodeAnalyzer {
3740    #[instrument(skip(self, context), fields(service.name = tracing::field::Empty, service.version = tracing::field::Empty))]
3741    async fn initialize(
3742        &self,
3743        request: InitializeRequestParams,
3744        context: RequestContext<RoleServer>,
3745    ) -> Result<InitializeResult, ErrorData> {
3746        let span = tracing::Span::current();
3747        span.record("service.name", "aptu-coder");
3748        span.record("service.version", env!("CARGO_PKG_VERSION"));
3749
3750        // Store client_info from the initialize request
3751        {
3752            let mut client_name_lock = self.client_name.lock().await;
3753            *client_name_lock = Some(request.client_info.name.clone());
3754        }
3755        {
3756            let mut client_version_lock = self.client_version.lock().await;
3757            *client_version_lock = Some(request.client_info.version.clone());
3758        }
3759
3760        // The _meta field is extracted from params and stored in request extensions.
3761        // Extract it and store for use in on_initialized.
3762        if let Some(meta) = context.extensions.get::<Meta>() {
3763            let mut meta_lock = self.profile_meta.lock().await;
3764            *meta_lock = Some(meta.0.clone());
3765        }
3766        Ok(self.get_info())
3767    }
3768
3769    fn get_info(&self) -> InitializeResult {
3770        let excluded = crate::EXCLUDED_DIRS.join(", ");
3771        let instructions = format!(
3772            "Recommended workflow:\n\
3773            1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3774            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\
3775            3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3776            4. Use analyze_symbol to trace call graphs.\n\
3777            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."
3778        );
3779        let capabilities = ServerCapabilities::builder()
3780            .enable_logging()
3781            .enable_tools()
3782            .enable_tool_list_changed()
3783            .enable_completions()
3784            .build();
3785        let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3786            .with_title("Aptu Coder")
3787            .with_description("MCP server for code structure analysis using tree-sitter");
3788        InitializeResult::new(capabilities)
3789            .with_server_info(server_info)
3790            .with_instructions(&instructions)
3791    }
3792
3793    async fn list_tools(
3794        &self,
3795        _request: Option<rmcp::model::PaginatedRequestParams>,
3796        _context: RequestContext<RoleServer>,
3797    ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
3798        let router = self.tool_router.read().await;
3799        Ok(rmcp::model::ListToolsResult {
3800            tools: router.list_all(),
3801            meta: None,
3802            next_cursor: None,
3803        })
3804    }
3805
3806    async fn call_tool(
3807        &self,
3808        request: rmcp::model::CallToolRequestParams,
3809        context: RequestContext<RoleServer>,
3810    ) -> Result<CallToolResult, ErrorData> {
3811        let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
3812        let router = self.tool_router.read().await;
3813        router.call(tcc).await
3814    }
3815
3816    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
3817        let mut peer_lock = self.peer.lock().await;
3818        *peer_lock = Some(context.peer.clone());
3819        drop(peer_lock);
3820
3821        // Generate session_id in MILLIS-N format
3822        let millis = std::time::SystemTime::now()
3823            .duration_since(std::time::UNIX_EPOCH)
3824            .unwrap_or_default()
3825            .as_millis()
3826            .try_into()
3827            .unwrap_or(u64::MAX);
3828        let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3829        let sid = format!("{millis}-{counter}");
3830        {
3831            let mut session_id_lock = self.session_id.lock().await;
3832            *session_id_lock = Some(sid);
3833        }
3834        self.session_call_seq
3835            .store(0, std::sync::atomic::Ordering::Relaxed);
3836
3837        // NON-STANDARD VENDOR EXTENSION: profile-based tool filtering.
3838        // The MCP 2025-11-25 spec has no profile or tool-subset concept; tools/list returns
3839        // all tools with no filtering parameters. This mechanism is retained solely for
3840        // controlled benchmarking (wave10/11). Do not promote or document it as a product
3841        // feature. The spec-compliant way to restrict tools is for the orchestrator to pass
3842        // a filtered `tools` array in the API call, or for clients to use tool annotations
3843        // (readOnlyHint/destructiveHint) to apply their own policy.
3844        // Two profiles: "edit" (3 tools), "analyze" (5 tools); absent/unknown = all 7 tools.
3845        // _meta key "io.clouatre-labs/profile" takes precedence over APTU_CODER_PROFILE env var.
3846        let meta_lock = self.profile_meta.lock().await;
3847        let meta_profile = meta_lock
3848            .as_ref()
3849            .and_then(|m| m.get("io.clouatre-labs/profile"))
3850            .and_then(|v| v.as_str())
3851            .map(str::to_owned);
3852        drop(meta_lock);
3853
3854        // Resolve the active profile: _meta wins; fall back to env var.
3855        let active_profile = meta_profile.or(std::env::var("APTU_CODER_PROFILE").ok());
3856
3857        {
3858            let mut router = self.tool_router.write().await;
3859
3860            // Default: all 7 tools enabled unless profile explicitly disables them.
3861            // Two profiles: "edit" (3 tools), "analyze" (5 tools); absent/unknown = all 7 tools.
3862
3863            if let Some(ref profile) = active_profile {
3864                match profile.as_str() {
3865                    "edit" => {
3866                        // Enable only: edit_replace, edit_overwrite, exec_command
3867                        disable_routes(
3868                            &mut router,
3869                            &[
3870                                "analyze_directory",
3871                                "analyze_file",
3872                                "analyze_module",
3873                                "analyze_symbol",
3874                            ],
3875                        );
3876                    }
3877                    "analyze" => {
3878                        // Enable only: analyze_directory, analyze_file, analyze_module, analyze_symbol, exec_command
3879                        disable_routes(&mut router, &["edit_replace", "edit_overwrite"]);
3880                    }
3881                    _ => {
3882                        // Unknown profile: all 7 tools enabled (lenient fallback)
3883                    }
3884                }
3885            }
3886
3887            // Bind peer notifier after disabling tools to send tools/list_changed notification
3888            router.bind_peer_notifier(&context.peer);
3889        }
3890
3891        // Spawn consumer task to drain log events from channel with batching.
3892        let peer = self.peer.clone();
3893        let event_rx = self.event_rx.clone();
3894
3895        tokio::spawn(async move {
3896            let rx = {
3897                let mut rx_lock = event_rx.lock().await;
3898                rx_lock.take()
3899            };
3900
3901            if let Some(mut receiver) = rx {
3902                let mut buffer = Vec::with_capacity(64);
3903                loop {
3904                    // Drain up to 64 events from channel
3905                    receiver.recv_many(&mut buffer, 64).await;
3906
3907                    if buffer.is_empty() {
3908                        // Channel closed, exit consumer task
3909                        break;
3910                    }
3911
3912                    // Acquire peer lock once per batch
3913                    let peer_lock = peer.lock().await;
3914                    if let Some(peer) = peer_lock.as_ref() {
3915                        for log_event in buffer.drain(..) {
3916                            let notification = ServerNotification::LoggingMessageNotification(
3917                                Notification::new(LoggingMessageNotificationParam {
3918                                    level: log_event.level,
3919                                    logger: Some(log_event.logger),
3920                                    data: log_event.data,
3921                                }),
3922                            );
3923                            if let Err(e) = peer.send_notification(notification).await {
3924                                warn!("Failed to send logging notification: {}", e);
3925                            }
3926                        }
3927                    }
3928                }
3929            }
3930        });
3931    }
3932
3933    #[instrument(skip(self, _context))]
3934    async fn on_cancelled(
3935        &self,
3936        notification: CancelledNotificationParam,
3937        _context: NotificationContext<RoleServer>,
3938    ) {
3939        tracing::info!(
3940            request_id = ?notification.request_id,
3941            reason = ?notification.reason,
3942            "Received cancellation notification"
3943        );
3944    }
3945
3946    #[instrument(skip(self, _context))]
3947    async fn complete(
3948        &self,
3949        request: CompleteRequestParams,
3950        _context: RequestContext<RoleServer>,
3951    ) -> Result<CompleteResult, ErrorData> {
3952        // Dispatch on argument name: "path" or "symbol"
3953        let argument_name = &request.argument.name;
3954        let argument_value = &request.argument.value;
3955
3956        let completions = match argument_name.as_str() {
3957            "path" => {
3958                // Path completions: use current directory as root
3959                let root = Path::new(".");
3960                completion::path_completions(root, argument_value)
3961            }
3962            "symbol" => {
3963                // Symbol completions: need the path argument from context
3964                let path_arg = request
3965                    .context
3966                    .as_ref()
3967                    .and_then(|ctx| ctx.get_argument("path"));
3968
3969                match path_arg {
3970                    Some(path_str) => {
3971                        let path = Path::new(path_str);
3972                        completion::symbol_completions(&self.cache, path, argument_value)
3973                    }
3974                    None => Vec::new(),
3975                }
3976            }
3977            _ => Vec::new(),
3978        };
3979
3980        // Create CompletionInfo with has_more flag if >100 results
3981        let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
3982        let (values, has_more) = if completions.len() > 100 {
3983            (completions.into_iter().take(100).collect(), true)
3984        } else {
3985            (completions, false)
3986        };
3987
3988        let completion_info =
3989            match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
3990                Ok(info) => info,
3991                Err(_) => {
3992                    // Graceful degradation: return empty on error
3993                    CompletionInfo::with_all_values(Vec::new())
3994                        .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
3995                }
3996            };
3997
3998        Ok(CompleteResult::new(completion_info))
3999    }
4000
4001    async fn set_level(
4002        &self,
4003        params: SetLevelRequestParams,
4004        _context: RequestContext<RoleServer>,
4005    ) -> Result<(), ErrorData> {
4006        let level_filter = match params.level {
4007            LoggingLevel::Debug => LevelFilter::DEBUG,
4008            LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
4009            LoggingLevel::Warning => LevelFilter::WARN,
4010            LoggingLevel::Error
4011            | LoggingLevel::Critical
4012            | LoggingLevel::Alert
4013            | LoggingLevel::Emergency => LevelFilter::ERROR,
4014        };
4015
4016        let mut filter_lock = self
4017            .log_level_filter
4018            .lock()
4019            .unwrap_or_else(|e| e.into_inner());
4020        *filter_lock = level_filter;
4021        Ok(())
4022    }
4023}
4024
4025#[cfg(test)]
4026mod tests {
4027    use super::*;
4028    use regex::Regex;
4029
4030    #[tokio::test]
4031    async fn test_emit_progress_none_peer_is_noop() {
4032        let peer = Arc::new(TokioMutex::new(None));
4033        let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4034        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4035        let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4036        let analyzer = CodeAnalyzer::new(
4037            peer,
4038            log_level_filter,
4039            rx,
4040            crate::metrics::MetricsSender(metrics_tx),
4041        );
4042        let token = ProgressToken(NumberOrString::String("test".into()));
4043        // Should complete without panic
4044        analyzer
4045            .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
4046            .await;
4047    }
4048
4049    fn make_analyzer() -> CodeAnalyzer {
4050        let peer = Arc::new(TokioMutex::new(None));
4051        let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4052        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4053        let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4054        CodeAnalyzer::new(
4055            peer,
4056            log_level_filter,
4057            rx,
4058            crate::metrics::MetricsSender(metrics_tx),
4059        )
4060    }
4061
4062    #[test]
4063    fn test_summary_cursor_conflict() {
4064        assert!(summary_cursor_conflict(Some(true), Some("cursor")));
4065        assert!(!summary_cursor_conflict(Some(true), None));
4066        assert!(!summary_cursor_conflict(None, Some("x")));
4067        assert!(!summary_cursor_conflict(None, None));
4068    }
4069
4070    #[tokio::test]
4071    async fn test_validate_impl_only_non_rust_returns_invalid_params() {
4072        use tempfile::TempDir;
4073
4074        let dir = TempDir::new().unwrap();
4075        std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
4076
4077        let analyzer = make_analyzer();
4078        // Call analyze_symbol with impl_only=true on a Python-only directory via the tool API.
4079        // We use handle_focused_mode which calls validate_impl_only internally.
4080        let entries: Vec<traversal::WalkEntry> =
4081            traversal::walk_directory(dir.path(), None).unwrap_or_default();
4082        let result = CodeAnalyzer::validate_impl_only(&entries);
4083        assert!(result.is_err());
4084        let err = result.unwrap_err();
4085        assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
4086        drop(analyzer); // ensure it compiles with analyzer in scope
4087    }
4088
4089    #[tokio::test]
4090    async fn test_no_cache_meta_on_analyze_directory_result() {
4091        use aptu_coder_core::types::{
4092            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4093        };
4094        use tempfile::TempDir;
4095
4096        let dir = TempDir::new().unwrap();
4097        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4098
4099        let analyzer = make_analyzer();
4100        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4101            "path": dir.path().to_str().unwrap(),
4102        }))
4103        .unwrap();
4104        let ct = tokio_util::sync::CancellationToken::new();
4105        let (arc_output, _cache_hit) = analyzer.handle_overview_mode(&params, ct).await.unwrap();
4106        // Verify the no_cache_meta shape by constructing it directly and checking the shape
4107        let meta = no_cache_meta();
4108        assert_eq!(
4109            meta.0.get("cache_hint").and_then(|v| v.as_str()),
4110            Some("no-cache"),
4111        );
4112        drop(arc_output);
4113    }
4114
4115    #[test]
4116    fn test_complete_path_completions_returns_suggestions() {
4117        // Test the underlying completion function (same code path as complete()) directly
4118        // to avoid needing a constructed RequestContext<RoleServer>.
4119        // CARGO_MANIFEST_DIR is <workspace>/aptu-coder; parent is the workspace root,
4120        // which contains aptu-coder-core/ and aptu-coder/ matching the "aptu-" prefix.
4121        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
4122        let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
4123        let suggestions = completion::path_completions(workspace_root, "aptu-");
4124        assert!(
4125            !suggestions.is_empty(),
4126            "expected completions for prefix 'aptu-' in workspace root"
4127        );
4128    }
4129
4130    #[tokio::test]
4131    async fn test_handle_overview_mode_verbose_no_summary_block() {
4132        use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
4133        use aptu_coder_core::types::{
4134            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4135        };
4136        use tempfile::TempDir;
4137
4138        let tmp = TempDir::new().unwrap();
4139        std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
4140
4141        let peer = Arc::new(TokioMutex::new(None));
4142        let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
4143        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
4144        let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
4145        let analyzer = CodeAnalyzer::new(
4146            peer,
4147            log_level_filter,
4148            rx,
4149            crate::metrics::MetricsSender(metrics_tx),
4150        );
4151
4152        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4153            "path": tmp.path().to_str().unwrap(),
4154            "verbose": true,
4155        }))
4156        .unwrap();
4157
4158        let ct = tokio_util::sync::CancellationToken::new();
4159        let (output, _cache_hit) = analyzer.handle_overview_mode(&params, ct).await.unwrap();
4160
4161        // Replicate the handler's formatting path (the fix site)
4162        let use_summary = output.formatted.len() > SIZE_LIMIT; // summary=None, force=None, small output
4163        let paginated =
4164            paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
4165        let verbose = true;
4166        let formatted = if !use_summary {
4167            format_structure_paginated(
4168                &paginated.items,
4169                paginated.total,
4170                params.max_depth,
4171                Some(std::path::Path::new(&params.path)),
4172                verbose,
4173            )
4174        } else {
4175            output.formatted.clone()
4176        };
4177
4178        // After the fix: verbose=true must not emit the SUMMARY: block
4179        assert!(
4180            !formatted.contains("SUMMARY:"),
4181            "verbose=true must not emit SUMMARY: block; got: {}",
4182            &formatted[..formatted.len().min(300)]
4183        );
4184        assert!(
4185            formatted.contains("PAGINATED:"),
4186            "verbose=true must emit PAGINATED: header"
4187        );
4188        assert!(
4189            formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
4190            "verbose=true must emit FILES section header"
4191        );
4192    }
4193
4194    // --- cache_hit integration tests ---
4195
4196    #[tokio::test]
4197    async fn test_analyze_directory_cache_hit_metrics() {
4198        use aptu_coder_core::types::{
4199            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4200        };
4201        use tempfile::TempDir;
4202
4203        // Arrange: a temp dir with one file
4204        let dir = TempDir::new().unwrap();
4205        std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
4206        let analyzer = make_analyzer();
4207        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4208            "path": dir.path().to_str().unwrap(),
4209        }))
4210        .unwrap();
4211
4212        // Act: first call (cache miss)
4213        let ct1 = tokio_util::sync::CancellationToken::new();
4214        let (_out1, hit1) = analyzer.handle_overview_mode(&params, ct1).await.unwrap();
4215
4216        // Act: second call (cache hit)
4217        let ct2 = tokio_util::sync::CancellationToken::new();
4218        let (_out2, hit2) = analyzer.handle_overview_mode(&params, ct2).await.unwrap();
4219
4220        // Assert
4221        assert_eq!(hit1, CacheTier::Miss, "first call must be a cache miss");
4222        assert_eq!(hit2, CacheTier::L1Memory, "second call must be a cache hit");
4223    }
4224
4225    #[tokio::test]
4226    async fn test_analyze_module_cache_hit_metrics() {
4227        use std::io::Write as _;
4228        use tempfile::NamedTempFile;
4229
4230        // Arrange: create a temp Rust file; prime the file cache via analyze_file handler
4231        let mut f = NamedTempFile::with_suffix(".rs").unwrap();
4232        writeln!(f, "fn bar() {{}}").unwrap();
4233        let path = f.path().to_str().unwrap().to_string();
4234
4235        let analyzer = make_analyzer();
4236
4237        // Prime the file cache by calling handle_file_details_mode once
4238        let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
4239        file_params.path = path.clone();
4240        file_params.ast_recursion_limit = None;
4241        file_params.fields = None;
4242        file_params.pagination.cursor = None;
4243        file_params.pagination.page_size = None;
4244        file_params.output_control.summary = None;
4245        file_params.output_control.force = None;
4246        file_params.output_control.verbose = None;
4247        let (_cached, _) = analyzer
4248            .handle_file_details_mode(&file_params)
4249            .await
4250            .unwrap();
4251
4252        // Act: now call analyze_module; the cache key is mtime-based so same file = hit
4253        let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
4254        module_params.path = path.clone();
4255
4256        // Replicate the cache lookup the handler does (no public method; test via build path)
4257        let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
4258            meta.modified()
4259                .ok()
4260                .map(|mtime| aptu_coder_core::cache::CacheKey {
4261                    path: std::path::PathBuf::from(&path),
4262                    modified: mtime,
4263                    mode: aptu_coder_core::types::AnalysisMode::FileDetails,
4264                })
4265        });
4266        let cache_hit = module_cache_key
4267            .as_ref()
4268            .and_then(|k| analyzer.cache.get(k))
4269            .is_some();
4270
4271        // Assert: the file cache must have been populated by the earlier handle_file_details_mode call
4272        assert!(
4273            cache_hit,
4274            "analyze_module should find the file in the shared file cache"
4275        );
4276        drop(module_params);
4277    }
4278
4279    // --- import_lookup tests ---
4280
4281    #[test]
4282    fn test_analyze_symbol_import_lookup_invalid_params() {
4283        // Arrange: empty symbol with import_lookup=true (violates the guard:
4284        // symbol must hold the module path when import_lookup=true).
4285        // Act: call the validate helper directly (same pattern as validate_impl_only).
4286        let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
4287
4288        // Assert: INVALID_PARAMS is returned.
4289        assert!(
4290            result.is_err(),
4291            "import_lookup=true with empty symbol must return Err"
4292        );
4293        let err = result.unwrap_err();
4294        assert_eq!(
4295            err.code,
4296            rmcp::model::ErrorCode::INVALID_PARAMS,
4297            "expected INVALID_PARAMS; got {:?}",
4298            err.code
4299        );
4300    }
4301
4302    #[tokio::test]
4303    async fn test_analyze_symbol_import_lookup_found() {
4304        use tempfile::TempDir;
4305
4306        // Arrange: a Rust file that imports "std::collections"
4307        let dir = TempDir::new().unwrap();
4308        std::fs::write(
4309            dir.path().join("main.rs"),
4310            "use std::collections::HashMap;\nfn main() {}\n",
4311        )
4312        .unwrap();
4313
4314        let entries = traversal::walk_directory(dir.path(), None).unwrap();
4315
4316        // Act: search for the module "std::collections"
4317        let output =
4318            analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
4319
4320        // Assert: one match found
4321        assert!(
4322            output.formatted.contains("MATCHES: 1"),
4323            "expected 1 match; got: {}",
4324            output.formatted
4325        );
4326        assert!(
4327            output.formatted.contains("main.rs"),
4328            "expected main.rs in output; got: {}",
4329            output.formatted
4330        );
4331    }
4332
4333    #[tokio::test]
4334    async fn test_analyze_symbol_import_lookup_empty() {
4335        use tempfile::TempDir;
4336
4337        // Arrange: a Rust file that does NOT import "no_such_module"
4338        let dir = TempDir::new().unwrap();
4339        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
4340
4341        let entries = traversal::walk_directory(dir.path(), None).unwrap();
4342
4343        // Act
4344        let output =
4345            analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
4346
4347        // Assert: zero matches
4348        assert!(
4349            output.formatted.contains("MATCHES: 0"),
4350            "expected 0 matches; got: {}",
4351            output.formatted
4352        );
4353    }
4354
4355    // --- git_ref tests ---
4356
4357    #[tokio::test]
4358    async fn test_analyze_directory_git_ref_non_git_repo() {
4359        use aptu_coder_core::traversal::changed_files_from_git_ref;
4360        use tempfile::TempDir;
4361
4362        // Arrange: a temp dir that is NOT a git repository
4363        let dir = TempDir::new().unwrap();
4364        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4365
4366        // Act: attempt git_ref resolution in a non-git dir
4367        let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
4368
4369        // Assert: must return a GitError
4370        assert!(result.is_err(), "non-git dir must return an error");
4371        let err_msg = result.unwrap_err().to_string();
4372        assert!(
4373            err_msg.contains("git"),
4374            "error must mention git; got: {err_msg}"
4375        );
4376    }
4377
4378    #[tokio::test]
4379    async fn test_analyze_directory_git_ref_filters_changed_files() {
4380        use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
4381        use std::collections::HashSet;
4382        use tempfile::TempDir;
4383
4384        // Arrange: build a set of fake "changed" paths and a walk entry list
4385        let dir = TempDir::new().unwrap();
4386        let changed_file = dir.path().join("changed.rs");
4387        let unchanged_file = dir.path().join("unchanged.rs");
4388        std::fs::write(&changed_file, "fn changed() {}").unwrap();
4389        std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
4390
4391        let entries = traversal::walk_directory(dir.path(), None).unwrap();
4392        let total_files = entries.iter().filter(|e| !e.is_dir).count();
4393        assert_eq!(total_files, 2, "sanity: 2 files before filtering");
4394
4395        // Simulate: only changed.rs is in the changed set
4396        let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
4397        changed.insert(changed_file.clone());
4398
4399        // Act: filter entries
4400        let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
4401        let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
4402
4403        // Assert: only changed.rs remains
4404        assert_eq!(
4405            filtered_files.len(),
4406            1,
4407            "only 1 file must remain after git_ref filter"
4408        );
4409        assert_eq!(
4410            filtered_files[0].path, changed_file,
4411            "the remaining file must be the changed one"
4412        );
4413
4414        // Verify changed_files_from_git_ref is at least callable (tested separately for non-git error)
4415        let _ = changed_files_from_git_ref;
4416    }
4417
4418    #[tokio::test]
4419    async fn test_handle_overview_mode_git_ref_filters_via_handler() {
4420        use aptu_coder_core::types::{
4421            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4422        };
4423        use std::process::Command;
4424        use tempfile::TempDir;
4425
4426        // Arrange: create a real git repo with two commits.
4427        let dir = TempDir::new().unwrap();
4428        let repo = dir.path();
4429
4430        // Init repo and configure minimal identity so git commit works.
4431        // Use no-hooks to avoid project-local commit hooks that enforce email allowlists.
4432        let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
4433            let mut cmd = std::process::Command::new("git");
4434            cmd.args(["-c", "core.hooksPath=/dev/null"]);
4435            cmd.args(args);
4436            cmd.current_dir(repo_path);
4437            let out = cmd.output().unwrap();
4438            assert!(out.status.success(), "{out:?}");
4439        };
4440        git_no_hook(repo, &["init"]);
4441        git_no_hook(
4442            repo,
4443            &[
4444                "-c",
4445                "user.email=ci@example.com",
4446                "-c",
4447                "user.name=CI",
4448                "commit",
4449                "--allow-empty",
4450                "-m",
4451                "initial",
4452            ],
4453        );
4454
4455        // Commit file_a.rs in the first commit.
4456        std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
4457        git_no_hook(repo, &["add", "file_a.rs"]);
4458        git_no_hook(
4459            repo,
4460            &[
4461                "-c",
4462                "user.email=ci@example.com",
4463                "-c",
4464                "user.name=CI",
4465                "commit",
4466                "-m",
4467                "add a",
4468            ],
4469        );
4470
4471        // Add file_b.rs in a second commit (this is what HEAD changes relative to HEAD~1).
4472        std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
4473        git_no_hook(repo, &["add", "file_b.rs"]);
4474        git_no_hook(
4475            repo,
4476            &[
4477                "-c",
4478                "user.email=ci@example.com",
4479                "-c",
4480                "user.name=CI",
4481                "commit",
4482                "-m",
4483                "add b",
4484            ],
4485        );
4486
4487        // Act: call handle_overview_mode with git_ref=HEAD~1.
4488        // `git diff --name-only HEAD~1` compares working tree against HEAD~1, returning
4489        // only file_b.rs (added in the last commit, so present in working tree but not in HEAD~1).
4490        // Use the canonical path so walk entries match what `git rev-parse --show-toplevel` returns
4491        // (macOS /tmp is a symlink to /private/tmp; without canonicalization paths would differ).
4492        let canon_repo = std::fs::canonicalize(repo).unwrap();
4493        let analyzer = make_analyzer();
4494        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4495            "path": canon_repo.to_str().unwrap(),
4496            "git_ref": "HEAD~1",
4497        }))
4498        .unwrap();
4499        let ct = tokio_util::sync::CancellationToken::new();
4500        let (arc_output, _cache_hit) = analyzer
4501            .handle_overview_mode(&params, ct)
4502            .await
4503            .expect("handle_overview_mode with git_ref must succeed");
4504
4505        // Assert: only file_b.rs (changed since HEAD~1) appears; file_a.rs must be absent.
4506        let formatted = &arc_output.formatted;
4507        assert!(
4508            formatted.contains("file_b.rs"),
4509            "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
4510        );
4511        assert!(
4512            !formatted.contains("file_a.rs"),
4513            "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
4514        );
4515    }
4516
4517    #[test]
4518    fn test_validate_path_rejects_absolute_path_outside_cwd() {
4519        // S4: Verify that absolute paths outside the current working directory are rejected.
4520        // This test directly calls validate_path with /etc/passwd, which should fail.
4521        let result = validate_path("/etc/passwd", true);
4522        assert!(
4523            result.is_err(),
4524            "validate_path should reject /etc/passwd (outside CWD)"
4525        );
4526        let err = result.unwrap_err();
4527        let err_msg = err.message.to_lowercase();
4528        assert!(
4529            err_msg.contains("outside") || err_msg.contains("not found"),
4530            "Error message should mention 'outside' or 'not found': {}",
4531            err.message
4532        );
4533    }
4534
4535    #[test]
4536    fn test_validate_path_accepts_relative_path_in_cwd() {
4537        // Happy path: relative path within CWD should be accepted.
4538        // Use Cargo.toml which exists in the crate root.
4539        let result = validate_path("Cargo.toml", true);
4540        assert!(
4541            result.is_ok(),
4542            "validate_path should accept Cargo.toml (exists in CWD)"
4543        );
4544    }
4545
4546    #[test]
4547    fn test_validate_path_creates_parent_for_nonexistent_file() {
4548        // Edge case: non-existent file with non-existent parent should still be accepted
4549        // if the ancestor chain leads back to CWD.
4550        let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4551        assert!(
4552            result.is_ok(),
4553            "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4554        );
4555        let path = result.unwrap();
4556        let cwd = std::env::current_dir().expect("should get cwd");
4557        let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4558        assert!(
4559            path.starts_with(&canonical_cwd),
4560            "Resolved path should be within CWD: {:?} should start with {:?}",
4561            path,
4562            canonical_cwd
4563        );
4564    }
4565
4566    #[test]
4567    fn test_edit_overwrite_with_working_dir() {
4568        // Arrange: create a temporary directory within CWD to use as working_dir
4569        let cwd = std::env::current_dir().expect("should get cwd");
4570        let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4571        let temp_path = temp_dir.path();
4572
4573        // Act: call validate_path_in_dir with a relative path
4574        let result = validate_path_in_dir("test_file.txt", false, temp_path);
4575
4576        // Assert: path should be resolved relative to working_dir
4577        assert!(
4578            result.is_ok(),
4579            "validate_path_in_dir should accept relative path in valid working_dir: {:?}",
4580            result.err()
4581        );
4582        let resolved = result.unwrap();
4583        assert!(
4584            resolved.starts_with(temp_path),
4585            "Resolved path should be within working_dir: {:?} should start with {:?}",
4586            resolved,
4587            temp_path
4588        );
4589    }
4590
4591    #[test]
4592    fn test_edit_overwrite_working_dir_traversal() {
4593        // Arrange: create a temporary directory within CWD to use as working_dir
4594        let cwd = std::env::current_dir().expect("should get cwd");
4595        let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4596        let temp_path = temp_dir.path();
4597
4598        // Act: try to traverse outside working_dir with ../../../etc/passwd
4599        let result = validate_path_in_dir("../../../etc/passwd", false, temp_path);
4600
4601        // Assert: should reject path traversal attack
4602        assert!(
4603            result.is_err(),
4604            "validate_path_in_dir should reject path traversal outside working_dir"
4605        );
4606        let err = result.unwrap_err();
4607        let err_msg = err.message.to_lowercase();
4608        assert!(
4609            err_msg.contains("outside") || err_msg.contains("working"),
4610            "Error message should mention 'outside' or 'working': {}",
4611            err.message
4612        );
4613    }
4614
4615    #[test]
4616    fn test_edit_replace_with_working_dir() {
4617        // Arrange: create a temporary directory within CWD and file
4618        let cwd = std::env::current_dir().expect("should get cwd");
4619        let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4620        let temp_path = temp_dir.path();
4621        let file_path = temp_path.join("test.txt");
4622        std::fs::write(&file_path, "hello world").expect("should write test file");
4623
4624        // Act: call validate_path_in_dir with require_exists=true
4625        let result = validate_path_in_dir("test.txt", true, temp_path);
4626
4627        // Assert: should find the file relative to working_dir
4628        assert!(
4629            result.is_ok(),
4630            "validate_path_in_dir should find existing file in working_dir: {:?}",
4631            result.err()
4632        );
4633        let resolved = result.unwrap();
4634        assert_eq!(
4635            resolved, file_path,
4636            "Resolved path should match the actual file path"
4637        );
4638    }
4639
4640    #[test]
4641    fn test_edit_overwrite_no_working_dir() {
4642        // Arrange: use validate_path without working_dir (existing behavior)
4643        // Use Cargo.toml which exists in the crate root
4644
4645        // Act: call validate_path with require_exists=true
4646        let result = validate_path("Cargo.toml", true);
4647
4648        // Assert: should work as before
4649        assert!(
4650            result.is_ok(),
4651            "validate_path should still work without working_dir"
4652        );
4653    }
4654
4655    #[test]
4656    fn test_edit_overwrite_working_dir_is_file() {
4657        // Arrange: create a temporary file (not directory) to use as working_dir
4658        let cwd = std::env::current_dir().expect("should get cwd");
4659        let temp_dir = tempfile::TempDir::new_in(&cwd).expect("should create temp dir in cwd");
4660        let temp_file = temp_dir.path().join("test_file.txt");
4661        std::fs::write(&temp_file, "test content").expect("should write test file");
4662
4663        // Act: call validate_path_in_dir with a file as working_dir
4664        let result = validate_path_in_dir("some_file.txt", false, &temp_file);
4665
4666        // Assert: should reject because working_dir is not a directory
4667        assert!(
4668            result.is_err(),
4669            "validate_path_in_dir should reject a file as working_dir"
4670        );
4671        let err = result.unwrap_err();
4672        let err_msg = err.message.to_lowercase();
4673        assert!(
4674            err_msg.contains("directory"),
4675            "Error message should mention 'directory': {}",
4676            err.message
4677        );
4678    }
4679
4680    #[test]
4681    fn test_tool_annotations() {
4682        // Arrange: get tool list via static method
4683        let tools = CodeAnalyzer::list_tools();
4684
4685        // Act: find specific tools by name
4686        let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
4687        let exec_command = tools.iter().find(|t| t.name == "exec_command");
4688
4689        // Assert: analyze_directory has correct annotations
4690        let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
4691        let analyze_dir_annot = analyze_dir_tool
4692            .annotations
4693            .as_ref()
4694            .expect("analyze_directory should have annotations");
4695        assert_eq!(
4696            analyze_dir_annot.read_only_hint,
4697            Some(true),
4698            "analyze_directory read_only_hint should be true"
4699        );
4700        assert_eq!(
4701            analyze_dir_annot.destructive_hint,
4702            Some(false),
4703            "analyze_directory destructive_hint should be false"
4704        );
4705
4706        // Assert: exec_command has correct annotations
4707        let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
4708        let exec_cmd_annot = exec_cmd_tool
4709            .annotations
4710            .as_ref()
4711            .expect("exec_command should have annotations");
4712        assert_eq!(
4713            exec_cmd_annot.open_world_hint,
4714            Some(true),
4715            "exec_command open_world_hint should be true"
4716        );
4717    }
4718
4719    #[test]
4720    fn test_exec_stdin_size_cap_validation() {
4721        // Test: stdin size cap check (1 MB limit)
4722        // Arrange: create oversized stdin
4723        let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
4724
4725        // Act & Assert: verify size exceeds limit
4726        assert!(
4727            oversized_stdin.len() > STDIN_MAX_BYTES,
4728            "test setup: oversized stdin should exceed 1 MB"
4729        );
4730
4731        // Verify that a 1 MB stdin is accepted
4732        let max_stdin = "y".repeat(STDIN_MAX_BYTES);
4733        assert_eq!(
4734            max_stdin.len(),
4735            STDIN_MAX_BYTES,
4736            "test setup: max stdin should be exactly 1 MB"
4737        );
4738    }
4739
4740    #[tokio::test]
4741    async fn test_exec_stdin_cat_roundtrip() {
4742        // Test: stdin content is piped to process and readable via stdout
4743        // Arrange: prepare stdin content
4744        let stdin_content = "hello world";
4745
4746        // Act: execute cat with stdin via shell
4747        let mut child = tokio::process::Command::new("sh")
4748            .arg("-c")
4749            .arg("cat")
4750            .stdin(std::process::Stdio::piped())
4751            .stdout(std::process::Stdio::piped())
4752            .stderr(std::process::Stdio::piped())
4753            .spawn()
4754            .expect("spawn cat");
4755
4756        if let Some(mut stdin_handle) = child.stdin.take() {
4757            use tokio::io::AsyncWriteExt as _;
4758            stdin_handle
4759                .write_all(stdin_content.as_bytes())
4760                .await
4761                .expect("write stdin");
4762            drop(stdin_handle);
4763        }
4764
4765        let output = child.wait_with_output().await.expect("wait for cat");
4766
4767        // Assert: stdout contains the piped stdin content
4768        let stdout_str = String::from_utf8_lossy(&output.stdout);
4769        assert!(
4770            stdout_str.contains(stdin_content),
4771            "stdout should contain stdin content: {}",
4772            stdout_str
4773        );
4774    }
4775
4776    #[tokio::test]
4777    async fn test_exec_stdin_none_no_regression() {
4778        // Test: command without stdin executes normally (no regression)
4779        // Act: execute echo without stdin
4780        let child = tokio::process::Command::new("sh")
4781            .arg("-c")
4782            .arg("echo hi")
4783            .stdin(std::process::Stdio::null())
4784            .stdout(std::process::Stdio::piped())
4785            .stderr(std::process::Stdio::piped())
4786            .spawn()
4787            .expect("spawn echo");
4788
4789        let output = child.wait_with_output().await.expect("wait for echo");
4790
4791        // Assert: command executes successfully
4792        let stdout_str = String::from_utf8_lossy(&output.stdout);
4793        assert!(
4794            stdout_str.contains("hi"),
4795            "stdout should contain echo output: {}",
4796            stdout_str
4797        );
4798    }
4799
4800    #[test]
4801    fn test_validate_path_in_dir_rejects_sibling_prefix() {
4802        // Arrange: create a parent temp dir, then two subdirs:
4803        //   allowed/   -- the working_dir
4804        //   allowed_sibling/  -- a sibling whose name shares the prefix
4805        // This mirrors CVE-2025-53110: "/work_evil" must not match "/work".
4806        let cwd = std::env::current_dir().expect("should get cwd");
4807        let parent = tempfile::TempDir::new_in(&cwd).expect("should create parent temp dir");
4808        let allowed = parent.path().join("allowed");
4809        let sibling = parent.path().join("allowed_sibling");
4810        std::fs::create_dir_all(&allowed).expect("should create allowed dir");
4811        std::fs::create_dir_all(&sibling).expect("should create sibling dir");
4812
4813        // Act: ask for a file inside the sibling dir, using a path that
4814        // traverses from allowed/ into allowed_sibling/
4815        let result = validate_path_in_dir("../allowed_sibling/secret.txt", false, &allowed);
4816
4817        // Assert: must be rejected even though "allowed_sibling" starts with "allowed"
4818        assert!(
4819            result.is_err(),
4820            "validate_path_in_dir must reject a path resolving to a sibling directory \
4821             sharing the working_dir name prefix (CVE-2025-53110 pattern)"
4822        );
4823        let err = result.unwrap_err();
4824        let msg = err.message.to_lowercase();
4825        assert!(
4826            msg.contains("outside") || msg.contains("working"),
4827            "Error should mention 'outside' or 'working', got: {}",
4828            err.message
4829        );
4830    }
4831
4832    #[test]
4833    #[serial_test::serial]
4834    fn test_file_cache_capacity_default() {
4835        // Arrange: ensure the env var is not set
4836        unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
4837
4838        // Act
4839        let analyzer = make_analyzer();
4840
4841        // Assert: default file cache capacity is 100
4842        assert_eq!(analyzer.cache.file_capacity(), 100);
4843    }
4844
4845    #[test]
4846    #[serial_test::serial]
4847    fn test_file_cache_capacity_from_env() {
4848        // Arrange
4849        unsafe { std::env::set_var("APTU_CODER_FILE_CACHE_CAPACITY", "42") };
4850
4851        // Act
4852        let analyzer = make_analyzer();
4853
4854        // Cleanup before assertions to minimise env pollution window
4855        unsafe { std::env::remove_var("APTU_CODER_FILE_CACHE_CAPACITY") };
4856
4857        // Assert
4858        assert_eq!(analyzer.cache.file_capacity(), 42);
4859    }
4860
4861    #[test]
4862    fn test_exec_command_path_injected() {
4863        // Arrange: call build_exec_command with Some("...") resolved_path
4864        let resolved_path = Some("/usr/local/bin:/usr/bin:/bin");
4865        let cmd = build_exec_command("echo test", None, None, None, false, resolved_path);
4866
4867        // Act: verify the command was created without panic
4868        // (We cannot directly inspect env vars on the Command object,
4869        // but we verify no panic occurred and the command is valid)
4870        let cmd_str = format!("{:?}", cmd);
4871
4872        // Assert: command should be created successfully
4873        assert!(
4874            !cmd_str.is_empty(),
4875            "build_exec_command should return a valid Command"
4876        );
4877    }
4878
4879    #[test]
4880    fn test_exec_command_path_fallback() {
4881        // Arrange: call build_exec_command with None resolved_path
4882        let cmd = build_exec_command("echo test", None, None, None, false, None);
4883
4884        // Act: verify the command was created without panic
4885        let cmd_str = format!("{:?}", cmd);
4886
4887        // Assert: command should be created successfully even with None
4888        assert!(
4889            !cmd_str.is_empty(),
4890            "build_exec_command should handle None resolved_path gracefully"
4891        );
4892    }
4893
4894    #[test]
4895    fn test_analyze_symbol_cache_fields_use_cache_tier_enum() {
4896        // Verify that CacheTier::Miss produces the expected cache_hit/cache_tier
4897        // values that analyze_symbol writes in both code paths (#950).
4898        // Guards against string drift if CacheTier::Miss.as_str() ever changes.
4899        assert_eq!(
4900            CacheTier::Miss.as_str(),
4901            "miss",
4902            "CacheTier::Miss.as_str() must stay \"miss\" -- analyze_symbol metrics depend on it"
4903        );
4904        assert!(
4905            !matches!(CacheTier::Miss, CacheTier::L1Memory | CacheTier::L2Disk),
4906            "CacheTier::Miss must not be a hit variant (cache_hit=false for a miss)"
4907        );
4908    }
4909
4910    #[tokio::test]
4911    async fn test_unsupported_extension_returns_invalid_params() {
4912        // Arrange: unsupported extension; both analyze_file and analyze_module
4913        // route through handle_file_details_mode so one test covers both.
4914        let temp_dir = tempfile::TempDir::new().expect("should create temp dir");
4915        let unsupported_file = temp_dir.path().join("notes.md");
4916        std::fs::write(&unsupported_file, "# notes").expect("should write file");
4917
4918        let analyzer = make_analyzer();
4919        let mut params = AnalyzeFileParams::default();
4920        params.path = unsupported_file.to_string_lossy().to_string();
4921
4922        let result = analyzer.handle_file_details_mode(&params).await;
4923
4924        assert!(result.is_err(), "should error for unsupported extension");
4925        let err = result.unwrap_err();
4926        assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
4927        assert!(err.message.to_lowercase().contains("unsupported"));
4928    }
4929
4930    #[test]
4931    fn test_exec_no_truncation_under_limits() {
4932        // Happy path: small output under all caps
4933        let stdout = "hello world".to_string();
4934        let stderr = "no errors".to_string();
4935        let slot = 0u32;
4936
4937        let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
4938            handle_output_persist(stdout, stderr, slot);
4939
4940        assert_eq!(out_stdout, "hello world");
4941        assert_eq!(out_stderr, "no errors");
4942        assert!(stdout_path.is_none());
4943        assert!(stderr_path.is_none());
4944        assert!(!byte_truncated);
4945    }
4946
4947    #[test]
4948    fn test_exec_byte_overflow_stdout_exceeds_30k() {
4949        // Edge case: stdout exceeds 30k byte limit
4950        let stdout = "x".repeat(35_000);
4951        let stderr = "small".to_string();
4952        let slot = 0u32;
4953
4954        let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
4955            handle_output_persist(stdout.clone(), stderr.clone(), slot);
4956
4957        // Verify truncation occurred
4958        assert!(byte_truncated, "byte_truncated should be true");
4959        assert!(stdout_path.is_some(), "stdout_path should be set");
4960        assert!(stderr_path.is_some(), "stderr_path should be set");
4961
4962        // Verify output was truncated
4963        assert!(
4964            out_stdout.len() <= 30_000,
4965            "stdout should be truncated to <= 30k"
4966        );
4967        assert_eq!(out_stderr, "small", "stderr should be unchanged");
4968
4969        // Verify slot file was written
4970        let base = std::env::temp_dir()
4971            .join("aptu-coder-overflow")
4972            .join(format!("slot-{slot}"));
4973        let stdout_file = base.join("stdout");
4974        assert!(
4975            stdout_file.exists(),
4976            "stdout slot file should exist after byte overflow"
4977        );
4978    }
4979
4980    #[test]
4981    fn test_exec_byte_overflow_stderr_exceeds_10k() {
4982        // Edge case: stderr exceeds 10k byte limit
4983        let stdout = "small".to_string();
4984        let stderr = "y".repeat(15_000);
4985        let slot = 1u32;
4986
4987        let (out_stdout, out_stderr, stdout_path, stderr_path, byte_truncated) =
4988            handle_output_persist(stdout.clone(), stderr.clone(), slot);
4989
4990        // Verify truncation occurred
4991        assert!(byte_truncated, "byte_truncated should be true");
4992        assert!(stdout_path.is_some(), "stdout_path should be set");
4993        assert!(stderr_path.is_some(), "stderr_path should be set");
4994
4995        // Verify output was truncated
4996        assert_eq!(out_stdout, "small", "stdout should be unchanged");
4997        assert!(
4998            out_stderr.len() <= 10_000,
4999            "stderr should be truncated to <= 10k"
5000        );
5001
5002        // Verify slot file was written
5003        let base = std::env::temp_dir()
5004            .join("aptu-coder-overflow")
5005            .join(format!("slot-{slot}"));
5006        let stderr_file = base.join("stderr");
5007        assert!(
5008            stderr_file.exists(),
5009            "stderr slot file should exist after byte overflow"
5010        );
5011    }
5012
5013    #[test]
5014    fn test_exec_byte_overflow_combined_exceeds_50k() {
5015        // Edge case: combined output_text exceeds 50k char limit
5016        // This is tested by verifying the truncation logic in exec_command
5017        let large_output = "z".repeat(60_000);
5018        assert!(large_output.len() > SIZE_LIMIT);
5019
5020        // Simulate the truncation logic from exec_command
5021        let mut combined_truncated = false;
5022        let truncated = if large_output.len() > SIZE_LIMIT {
5023            combined_truncated = true;
5024            let tail_start = large_output.len().saturating_sub(SIZE_LIMIT);
5025            let safe_start = large_output[..tail_start].floor_char_boundary(tail_start);
5026            large_output[safe_start..].to_string()
5027        } else {
5028            large_output.clone()
5029        };
5030
5031        assert!(combined_truncated, "combined_truncated should be true");
5032        assert!(
5033            truncated.len() <= SIZE_LIMIT,
5034            "output should be truncated to <= 50k"
5035        );
5036    }
5037
5038    #[test]
5039    fn test_exec_line_and_byte_interaction() {
5040        // Edge case: line cap and byte cap are independent
5041        // 1500 lines with long content to exceed 30k bytes should trigger byte cap, not line cap
5042        let lines: Vec<String> = (0..1500)
5043            .map(|i| {
5044                format!(
5045                    "line {} with some padding to make it longer: {}",
5046                    i,
5047                    "x".repeat(15)
5048                )
5049            })
5050            .collect();
5051        let stdout = lines.join("\n");
5052        assert!(stdout.lines().count() <= 2000, "should have <= 2000 lines");
5053        assert!(stdout.len() > 30_000, "should exceed 30k bytes");
5054
5055        let stderr = "".to_string();
5056        let slot = 2u32;
5057
5058        let (out_stdout, _out_stderr, stdout_path, _stderr_path, byte_truncated) =
5059            handle_output_persist(stdout.clone(), stderr, slot);
5060
5061        // Byte cap should fire, not line cap
5062        assert!(byte_truncated, "byte_truncated should be true");
5063        assert!(stdout_path.is_some(), "stdout_path should be set");
5064        assert!(
5065            out_stdout.len() <= 30_000,
5066            "stdout should be truncated by byte cap"
5067        );
5068    }
5069
5070    #[test]
5071    fn test_exec_utf8_boundary_safety() {
5072        // Edge case: ensure truncation doesn't split multi-byte UTF-8 chars
5073        // Create a string with multi-byte characters near the boundary
5074        let mut stdout = String::new();
5075        for _ in 0..4000 {
5076            stdout.push_str("hello world ");
5077        }
5078        // Add some multi-byte chars
5079        stdout.push_str("こんにちは"); // Japanese characters (3 bytes each)
5080        assert!(stdout.len() > 30_000, "stdout should exceed 30k bytes");
5081
5082        let stderr = "".to_string();
5083        let slot = 5u32;
5084
5085        let (out_stdout, _out_stderr, _stdout_path, _stderr_path, byte_truncated) =
5086            handle_output_persist(stdout, stderr, slot);
5087
5088        // Verify truncation happened and result is valid UTF-8
5089        assert!(byte_truncated, "byte_truncated should be true");
5090        assert!(
5091            out_stdout.is_char_boundary(0),
5092            "start should be char boundary"
5093        );
5094        assert!(
5095            out_stdout.is_char_boundary(out_stdout.len()),
5096            "end should be char boundary"
5097        );
5098        // Verify we can iterate chars without panic
5099        let _char_count = out_stdout.chars().count();
5100    }
5101
5102    #[test]
5103    fn test_filter_strip_lines_matching() {
5104        // Happy path: filter matches command prefix and strips lines
5105        let rule = types::FilterRule {
5106            match_command: "^git\\s+pull".to_string(),
5107            description: Some("test filter".to_string()),
5108            strip_ansi: false,
5109            strip_lines_matching: vec!["^\\s*\\|\\s*\\d+\\s*[+-]+".to_string()],
5110            keep_lines_matching: vec![],
5111            max_lines: None,
5112            on_empty: None,
5113        };
5114
5115        let strip_patterns = vec![Regex::new("^\\s*\\|\\s*\\d+\\s*[+-]+").unwrap()];
5116        let compiled = CompiledRule {
5117            pattern: Regex::new("^git\\s+pull").unwrap(),
5118            strip_patterns,
5119            keep_patterns: vec![],
5120            rule,
5121        };
5122
5123        let stdout = "Updating abc123..def456\n | 5 ++++\n | 3 ---\nFast-forward\n";
5124        let filtered = apply_filter(&compiled, stdout);
5125
5126        assert!(!filtered.contains("| 5 ++++"), "should strip stat lines");
5127        assert!(!filtered.contains("| 3 ---"), "should strip stat lines");
5128        assert!(
5129            filtered.contains("Updating"),
5130            "should keep non-matching lines"
5131        );
5132        assert!(
5133            filtered.contains("Fast-forward"),
5134            "should keep non-matching lines"
5135        );
5136    }
5137
5138    #[test]
5139    fn test_filter_on_empty_substitution() {
5140        // Edge case: on_empty substitution when filtered stdout is empty
5141        let rule = types::FilterRule {
5142            match_command: "^git\\s+fetch".to_string(),
5143            description: Some("test fetch".to_string()),
5144            strip_ansi: false,
5145            strip_lines_matching: vec!["^From ".to_string(), "^\\s+[a-f0-9]+\\.\\.".to_string()],
5146            keep_lines_matching: vec![],
5147            max_lines: None,
5148            on_empty: Some("ok fetched".to_string()),
5149        };
5150
5151        let strip_patterns = vec![
5152            Regex::new("^From ").unwrap(),
5153            Regex::new("^\\s+[a-f0-9]+\\.\\.").unwrap(),
5154        ];
5155        let compiled = CompiledRule {
5156            pattern: Regex::new("^git\\s+fetch").unwrap(),
5157            strip_patterns,
5158            keep_patterns: vec![],
5159            rule,
5160        };
5161
5162        let stdout = "From github.com:user/repo\n  abc123..def456 main -> origin/main\n";
5163        let filtered = apply_filter(&compiled, stdout);
5164
5165        assert_eq!(
5166            filtered, "ok fetched",
5167            "should return on_empty when all lines stripped"
5168        );
5169    }
5170
5171    #[test]
5172    fn test_filter_passthrough_on_failure() {
5173        // Test the exit-code guard in run_exec_impl: filter only applied when exit_code == Some(0)
5174        let rule = types::FilterRule {
5175            match_command: "^cargo\\s+build".to_string(),
5176            description: Some("cargo build filter".to_string()),
5177            strip_ansi: false,
5178            strip_lines_matching: vec!["^\\s*Compiling ".to_string()],
5179            keep_lines_matching: vec![],
5180            max_lines: None,
5181            on_empty: None,
5182        };
5183
5184        let strip_patterns = vec![Regex::new("^\\s*Compiling ").unwrap()];
5185        let compiled = CompiledRule {
5186            pattern: Regex::new("^cargo\\s+build").unwrap(),
5187            strip_patterns,
5188            keep_patterns: vec![],
5189            rule,
5190        };
5191
5192        let stdout = "   Compiling mylib v0.1.0\nerror: failed to compile\n";
5193
5194        // Sub-case 1: non-zero exit code (exit_code != Some(0))
5195        // The guard condition fails, so filter_applied must remain None and stdout unchanged
5196        let mut output = types::ShellOutput::new(
5197            stdout.to_string(),
5198            "".to_string(),
5199            "".to_string(),
5200            Some(1), // non-zero exit
5201            false,
5202            false,
5203        );
5204
5205        // Simulate the guard: if exit_code == Some(0) && !timed_out { apply filter }
5206        if output.exit_code == Some(0) && !output.timed_out {
5207            output.stdout = apply_filter(&compiled, &output.stdout);
5208            output.filter_applied = compiled
5209                .rule
5210                .description
5211                .clone()
5212                .or_else(|| Some(compiled.rule.match_command.clone()));
5213        }
5214
5215        assert!(
5216            output.filter_applied.is_none(),
5217            "filter_applied should be None when exit_code != Some(0)"
5218        );
5219        assert!(
5220            output.stdout.contains("Compiling"),
5221            "stdout should be unchanged when exit_code != Some(0)"
5222        );
5223
5224        // Sub-case 2: zero exit code (exit_code == Some(0))
5225        // The guard condition passes, so filter_applied is set and stdout is filtered
5226        let mut output2 = types::ShellOutput::new(
5227            stdout.to_string(),
5228            "".to_string(),
5229            "".to_string(),
5230            Some(0), // zero exit
5231            false,
5232            false,
5233        );
5234
5235        if output2.exit_code == Some(0) && !output2.timed_out {
5236            output2.stdout = apply_filter(&compiled, &output2.stdout);
5237            output2.filter_applied = compiled
5238                .rule
5239                .description
5240                .clone()
5241                .or_else(|| Some(compiled.rule.match_command.clone()));
5242        }
5243
5244        assert!(
5245            output2.filter_applied.is_some(),
5246            "filter_applied should be set when exit_code == Some(0)"
5247        );
5248        assert_eq!(
5249            output2.filter_applied.as_ref().unwrap(),
5250            "cargo build filter"
5251        );
5252        assert!(
5253            !output2.stdout.contains("Compiling"),
5254            "stdout should be filtered when exit_code == Some(0)"
5255        );
5256    }
5257
5258    #[test]
5259    fn test_no_stat_injection() {
5260        // Happy path: --no-stat injection for bare git pull
5261        let command = "git pull origin main";
5262        let result = maybe_inject_no_stat(command);
5263        assert_eq!(
5264            result, "git pull origin main --no-stat",
5265            "should inject --no-stat"
5266        );
5267    }
5268
5269    #[test]
5270    fn test_no_stat_not_injected_when_present() {
5271        // Edge case: --no-stat not injected when --stat already present
5272        let command = "git pull --stat origin main";
5273        let result = maybe_inject_no_stat(command);
5274        assert_eq!(result, command, "should not inject when --stat present");
5275
5276        let command2 = "git pull --no-stat origin main";
5277        let result2 = maybe_inject_no_stat(command2);
5278        assert_eq!(
5279            result2, command2,
5280            "should not inject when --no-stat present"
5281        );
5282
5283        let command3 = "git pull --verbose origin main";
5284        let result3 = maybe_inject_no_stat(command3);
5285        assert_eq!(
5286            result3, command3,
5287            "should not inject when --verbose present"
5288        );
5289    }
5290
5291    #[test]
5292    fn test_filter_applied_field_present() {
5293        // Test apply_filter() end-to-end and verify filter_applied field is set correctly
5294        let rule = types::FilterRule {
5295            match_command: "^git\\s+status".to_string(),
5296            description: Some("git status filter".to_string()),
5297            strip_ansi: false,
5298            strip_lines_matching: vec!["^On branch".to_string()],
5299            keep_lines_matching: vec![],
5300            max_lines: Some(20),
5301            on_empty: None,
5302        };
5303
5304        let strip_patterns = vec![Regex::new("^On branch").unwrap()];
5305        let compiled = CompiledRule {
5306            pattern: Regex::new("^git\\s+status").unwrap(),
5307            strip_patterns,
5308            keep_patterns: vec![],
5309            rule,
5310        };
5311
5312        let stdout = "On branch main\nnothing to commit\n";
5313
5314        // Call apply_filter() and verify the returned string is filtered
5315        let filtered = apply_filter(&compiled, stdout);
5316        assert!(
5317            !filtered.contains("On branch"),
5318            "apply_filter should strip matching lines"
5319        );
5320        assert!(
5321            filtered.contains("nothing to commit"),
5322            "apply_filter should keep non-matching lines"
5323        );
5324
5325        // Simulate the guard and field assignment from run_exec_impl
5326        let mut output = types::ShellOutput::new(
5327            filtered,
5328            "".to_string(),
5329            "".to_string(),
5330            Some(0),
5331            false,
5332            false,
5333        );
5334
5335        // Set filter_applied as run_exec_impl does
5336        output.filter_applied = compiled
5337            .rule
5338            .description
5339            .clone()
5340            .or_else(|| Some(compiled.rule.match_command.clone()));
5341
5342        assert!(
5343            output.filter_applied.is_some(),
5344            "filter_applied should be set when filter matches"
5345        );
5346        assert_eq!(output.filter_applied.as_ref().unwrap(), "git status filter");
5347    }
5348
5349    #[test]
5350    fn test_filter_keep_lines_matching() {
5351        // Happy path: filter matches command prefix and keeps only matching lines
5352        let rule = types::FilterRule {
5353            match_command: "^cargo\\s+test".to_string(),
5354            description: Some("test keep filter".to_string()),
5355            strip_ansi: false,
5356            strip_lines_matching: vec![],
5357            keep_lines_matching: vec!["^test ".to_string(), "^FAILED".to_string()],
5358            max_lines: None,
5359            on_empty: None,
5360        };
5361        let compiled = filters::CompiledRule {
5362            pattern: Regex::new("^cargo\\s+test").unwrap(),
5363            strip_patterns: vec![],
5364            keep_patterns: vec![
5365                Regex::new("^test ").unwrap(),
5366                Regex::new("^FAILED").unwrap(),
5367            ],
5368            rule,
5369        };
5370
5371        let stdout = "   Compiling mylib v0.1.0\ntest foo::bar ... ok\ntest foo::baz ... FAILED\ntest result: FAILED\n";
5372        let filtered = filters::apply_filter(&compiled, stdout);
5373
5374        assert!(filtered.contains("test foo::bar"), "should keep test lines");
5375        assert!(
5376            filtered.contains("test foo::baz"),
5377            "should keep FAILED test lines"
5378        );
5379        assert!(!filtered.contains("Compiling"), "should drop compile lines");
5380    }
5381
5382    #[test]
5383    fn test_filter_max_lines_cap() {
5384        // Edge case: filter caps output to max_lines
5385        let rule = types::FilterRule {
5386            match_command: "^git\\s+log".to_string(),
5387            description: Some("test max lines".to_string()),
5388            strip_ansi: false,
5389            strip_lines_matching: vec![],
5390            keep_lines_matching: vec![],
5391            max_lines: Some(3),
5392            on_empty: None,
5393        };
5394        let compiled = filters::CompiledRule {
5395            pattern: Regex::new("^git\\s+log").unwrap(),
5396            strip_patterns: vec![],
5397            keep_patterns: vec![],
5398            rule,
5399        };
5400
5401        let stdout = "line1\nline2\nline3\nline4\nline5\n";
5402        let filtered = filters::apply_filter(&compiled, stdout);
5403
5404        assert_eq!(filtered.lines().count(), 3, "should cap at 3 lines");
5405        assert!(filtered.contains("line1"));
5406        assert!(filtered.contains("line3"));
5407        assert!(
5408            !filtered.contains("line4"),
5409            "should not include lines beyond max"
5410        );
5411    }
5412
5413    #[test]
5414    fn test_line_cap_fires_before_byte_cap() {
5415        // Edge case: 2500 lines x 5 chars each = 12500 bytes (under 30k byte cap)
5416        // Line cap (2000) should fire; returned content has ~50 lines (OVERFLOW_PREVIEW_LINES)
5417        let line = "abcde";
5418        let stdout: String = std::iter::repeat(format!("{}\n", line))
5419            .take(2500)
5420            .collect();
5421        assert_eq!(stdout.lines().count(), 2500, "should have 2500 lines");
5422        assert!(stdout.len() < 30_000, "should be under byte cap");
5423
5424        let stderr = String::new();
5425        let slot = 42u32;
5426
5427        let (out_stdout, _out_stderr, stdout_path, _stderr_path, byte_truncated) =
5428            handle_output_persist(stdout, stderr, slot);
5429
5430        // Line cap fires: output_truncated should be indicated via stdout_path being set
5431        assert!(
5432            !byte_truncated,
5433            "byte cap should NOT fire (under 30k bytes)"
5434        );
5435        assert!(
5436            stdout_path.is_some(),
5437            "stdout_path should be set when line cap fires"
5438        );
5439        // Returned preview is last OVERFLOW_PREVIEW_LINES (50) lines
5440        let line_count = out_stdout.lines().count();
5441        assert!(
5442            line_count <= 50,
5443            "returned content should have at most 50 lines, got {}",
5444            line_count
5445        );
5446        assert!(line_count > 0, "returned content should not be empty");
5447    }
5448
5449    #[test]
5450    fn test_project_local_overrides_builtin() {
5451        // Edge case: project-local rule inserted at index 0 takes precedence (first-match semantics).
5452        // Use a unique command name that does NOT match any built-in rule to verify
5453        // that project-local rules are loaded and placed before built-ins.
5454        use std::io::Write;
5455
5456        let tmp = std::env::temp_dir().join(format!(
5457            "aptu-test-project-local-{}",
5458            std::time::SystemTime::now()
5459                .duration_since(std::time::UNIX_EPOCH)
5460                .map(|d| d.as_nanos())
5461                .unwrap_or(0)
5462        ));
5463        let aptu_dir = tmp.join(".aptu");
5464        std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
5465
5466        // Use a unique command not matching any built-in rule; include required schema_version field
5467        let toml_content = "schema_version = 1\n[[filters]]\nmatch_command = \"^my-custom-tool\"\nkeep_lines_matching = []\non_empty = \"project-local-only-marker\"\n";
5468        let mut f = std::fs::File::create(aptu_dir.join("filters.toml"))
5469            .expect("should create filters.toml");
5470        f.write_all(toml_content.as_bytes())
5471            .expect("should write toml");
5472        drop(f);
5473
5474        let rules = filters::load_filter_table(&tmp);
5475
5476        // The project-local rule should appear at index 0
5477        let first_rule = rules.first().expect("should have at least one rule");
5478        assert!(
5479            first_rule.pattern.is_match("my-custom-tool --flag"),
5480            "project-local rule should be first (index 0)"
5481        );
5482        assert_eq!(
5483            first_rule.rule.on_empty.as_deref(),
5484            Some("project-local-only-marker"),
5485            "project-local rule on_empty should match what was written"
5486        );
5487
5488        // Also verify that built-in rules are still present (after the project-local rule)
5489        let has_git_pull = rules
5490            .iter()
5491            .any(|r| r.pattern.is_match("git pull origin main"));
5492        assert!(
5493            has_git_pull,
5494            "built-in git pull rule should still be present"
5495        );
5496
5497        // Cleanup
5498        let _ = std::fs::remove_dir_all(&tmp);
5499    }
5500
5501    #[test]
5502    fn test_invalid_toml_falls_back_gracefully() {
5503        // Edge case: invalid TOML in .aptu/filters.toml should fall back to built-ins without panic
5504        use std::io::Write;
5505
5506        let tmp = std::env::temp_dir().join(format!(
5507            "aptu-test-invalid-toml-{}",
5508            std::time::SystemTime::now()
5509                .duration_since(std::time::UNIX_EPOCH)
5510                .map(|d| d.as_nanos())
5511                .unwrap_or(0)
5512        ));
5513        let aptu_dir = tmp.join(".aptu");
5514        std::fs::create_dir_all(&aptu_dir).expect("should create .aptu dir");
5515
5516        let mut f = std::fs::File::create(aptu_dir.join("filters.toml"))
5517            .expect("should create filters.toml");
5518        // invalid TOML: use "garbage" that is syntactically invalid TOML
5519        // Note: the TOML also requires schema_version field in FilterTableConfig;
5520        // invalid content ensures the serde parse fails
5521        f.write_all(b"schema_version = INVALID_VALUE {{{{")
5522            .expect("should write garbage");
5523        drop(f);
5524
5525        // Should not panic; should return built-in rules only
5526        let rules = filters::load_filter_table(&tmp);
5527
5528        // Built-in rules include git pull, git fetch, etc.
5529        let has_git_pull = rules
5530            .iter()
5531            .any(|r| r.pattern.is_match("git pull origin main"));
5532        assert!(
5533            has_git_pull,
5534            "should have git pull built-in rule after invalid TOML"
5535        );
5536
5537        // Cleanup
5538        let _ = std::fs::remove_dir_all(&tmp);
5539    }
5540
5541    #[test]
5542    fn test_metric_chars_threshold_breach_fires() {
5543        // Happy path: chars_threshold_breach is true when output_chars > 30_000
5544        let output_chars: usize = 35_000;
5545        let event = crate::metrics::MetricEvent {
5546            ts: 0,
5547            tool: "exec_command",
5548            duration_ms: 1,
5549            output_chars,
5550            param_path_depth: 0,
5551            max_depth: None,
5552            result: "ok",
5553            error_type: None,
5554            session_id: None,
5555            seq: None,
5556            cache_hit: None,
5557            cache_write_failure: None,
5558            cache_tier: None,
5559            exit_code: None,
5560            timed_out: false,
5561            output_truncated: None,
5562            chars_threshold_breach: output_chars > 30_000,
5563        };
5564        assert!(
5565            event.chars_threshold_breach,
5566            "chars_threshold_breach should be true for output_chars=35000"
5567        );
5568    }
5569
5570    #[test]
5571    fn test_metric_chars_threshold_breach_no_fire() {
5572        // Edge case: chars_threshold_breach is false when output_chars <= 30_000
5573        let output_chars: usize = 5_000;
5574        let event = crate::metrics::MetricEvent {
5575            ts: 0,
5576            tool: "exec_command",
5577            duration_ms: 1,
5578            output_chars,
5579            param_path_depth: 0,
5580            max_depth: None,
5581            result: "ok",
5582            error_type: None,
5583            session_id: None,
5584            seq: None,
5585            cache_hit: None,
5586            cache_write_failure: None,
5587            cache_tier: None,
5588            exit_code: None,
5589            timed_out: false,
5590            output_truncated: None,
5591            chars_threshold_breach: output_chars > 30_000,
5592        };
5593        assert!(
5594            !event.chars_threshold_breach,
5595            "chars_threshold_breach should be false for output_chars=5000"
5596        );
5597    }
5598}