Skip to main content

aptu_coder/
lib.rs

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