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