Skip to main content

aptu_coder/
lib.rs

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