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