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). Omitting start_line/end_line returns the full file; for files over 100 lines use analyze_module first to locate the range. 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(aptu_coder_core::AnalyzeError::RangelessLargeFile { total_lines })) => {
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("invalid_params".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::INVALID_PARAMS,
2061                    format!(
2062                        "file has {total_lines} lines; provide start_line and end_line, or call analyze_module first to locate the range"
2063                    ),
2064                    Some(error_meta(
2065                        "validation",
2066                        false,
2067                        "call analyze_module to get function line numbers, then retry with start_line and end_line",
2068                    )),
2069                )));
2070            }
2071            Ok(Err(e)) => {
2072                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2073                self.metrics_tx.send(crate::metrics::MetricEvent {
2074                    ts: crate::metrics::unix_ms(),
2075                    tool: "analyze_raw",
2076                    duration_ms: dur,
2077                    output_chars: 0,
2078                    param_path_depth: crate::metrics::path_component_count(&param_path),
2079                    max_depth: None,
2080                    result: "error",
2081                    error_type: Some("internal_error".to_string()),
2082                    session_id: sid.clone(),
2083                    seq: Some(seq),
2084                    cache_hit: None,
2085                });
2086                return Ok(err_to_tool_result(ErrorData::new(
2087                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2088                    e.to_string(),
2089                    Some(error_meta(
2090                        "resource",
2091                        false,
2092                        "check file path and permissions",
2093                    )),
2094                )));
2095            }
2096            Err(e) => {
2097                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2098                self.metrics_tx.send(crate::metrics::MetricEvent {
2099                    ts: crate::metrics::unix_ms(),
2100                    tool: "analyze_raw",
2101                    duration_ms: dur,
2102                    output_chars: 0,
2103                    param_path_depth: crate::metrics::path_component_count(&param_path),
2104                    max_depth: None,
2105                    result: "error",
2106                    error_type: Some("internal_error".to_string()),
2107                    session_id: sid.clone(),
2108                    seq: Some(seq),
2109                    cache_hit: None,
2110                });
2111                return Ok(err_to_tool_result(ErrorData::new(
2112                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2113                    e.to_string(),
2114                    Some(error_meta(
2115                        "resource",
2116                        false,
2117                        "check file path and permissions",
2118                    )),
2119                )));
2120            }
2121        };
2122
2123        let text = format!(
2124            "File: {} (total: {} lines, showing: {}-{})\n\n{}",
2125            output.path, output.total_lines, output.start_line, output.end_line, output.content
2126        );
2127        let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2128            .with_meta(Some(no_cache_meta()));
2129        let structured = match serde_json::to_value(&output).map_err(|e| {
2130            ErrorData::new(
2131                rmcp::model::ErrorCode::INTERNAL_ERROR,
2132                format!("serialization failed: {e}"),
2133                Some(error_meta("internal", false, "report this as a bug")),
2134            )
2135        }) {
2136            Ok(v) => v,
2137            Err(e) => return Ok(err_to_tool_result(e)),
2138        };
2139        result.structured_content = Some(structured);
2140        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2141        self.metrics_tx.send(crate::metrics::MetricEvent {
2142            ts: crate::metrics::unix_ms(),
2143            tool: "analyze_raw",
2144            duration_ms: dur,
2145            output_chars: text.len(),
2146            param_path_depth: crate::metrics::path_component_count(&param_path),
2147            max_depth: None,
2148            result: "ok",
2149            error_type: None,
2150            session_id: sid,
2151            seq: Some(seq),
2152            cache_hit: None,
2153        });
2154        Ok(result)
2155    }
2156
2157    #[instrument(skip(self, _context))]
2158    #[tool(
2159        name = "edit_overwrite",
2160        title = "Edit Overwrite",
2161        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.",
2162        output_schema = schema_for_type::<EditOverwriteOutput>(),
2163        annotations(
2164            title = "Edit Overwrite",
2165            read_only_hint = false,
2166            destructive_hint = true,
2167            idempotent_hint = false,
2168            open_world_hint = false
2169        )
2170    )]
2171    async fn edit_overwrite(
2172        &self,
2173        params: Parameters<EditOverwriteParams>,
2174        _context: RequestContext<RoleServer>,
2175    ) -> Result<CallToolResult, ErrorData> {
2176        let params = params.0;
2177        let _validated_path = match validate_path(&params.path, false) {
2178            Ok(p) => p,
2179            Err(e) => return Ok(err_to_tool_result(e)),
2180        };
2181        let t_start = std::time::Instant::now();
2182        let param_path = params.path.clone();
2183        let seq = self
2184            .session_call_seq
2185            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2186        let sid = self.session_id.lock().await.clone();
2187
2188        // Guard against directory paths
2189        if std::fs::metadata(&params.path)
2190            .map(|m| m.is_dir())
2191            .unwrap_or(false)
2192        {
2193            let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2194            self.metrics_tx.send(crate::metrics::MetricEvent {
2195                ts: crate::metrics::unix_ms(),
2196                tool: "edit_overwrite",
2197                duration_ms: dur,
2198                output_chars: 0,
2199                param_path_depth: crate::metrics::path_component_count(&param_path),
2200                max_depth: None,
2201                result: "error",
2202                error_type: Some("invalid_params".to_string()),
2203                session_id: sid.clone(),
2204                seq: Some(seq),
2205                cache_hit: None,
2206            });
2207            return Ok(err_to_tool_result(ErrorData::new(
2208                rmcp::model::ErrorCode::INVALID_PARAMS,
2209                "path is a directory; cannot write to a directory".to_string(),
2210                Some(error_meta(
2211                    "validation",
2212                    false,
2213                    "provide a file path, not a directory",
2214                )),
2215            )));
2216        }
2217
2218        let path = std::path::PathBuf::from(&params.path);
2219        let content = params.content.clone();
2220        let handle = tokio::task::spawn_blocking(move || {
2221            aptu_coder_core::edit_overwrite_content(&path, &content)
2222        });
2223
2224        let output = match handle.await {
2225            Ok(Ok(v)) => v,
2226            Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2227                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2228                self.metrics_tx.send(crate::metrics::MetricEvent {
2229                    ts: crate::metrics::unix_ms(),
2230                    tool: "edit_overwrite",
2231                    duration_ms: dur,
2232                    output_chars: 0,
2233                    param_path_depth: crate::metrics::path_component_count(&param_path),
2234                    max_depth: None,
2235                    result: "error",
2236                    error_type: Some("invalid_params".to_string()),
2237                    session_id: sid.clone(),
2238                    seq: Some(seq),
2239                    cache_hit: None,
2240                });
2241                return Ok(err_to_tool_result(ErrorData::new(
2242                    rmcp::model::ErrorCode::INVALID_PARAMS,
2243                    "path is a directory".to_string(),
2244                    Some(error_meta(
2245                        "validation",
2246                        false,
2247                        "provide a file path, not a directory",
2248                    )),
2249                )));
2250            }
2251            Ok(Err(e)) => {
2252                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2253                self.metrics_tx.send(crate::metrics::MetricEvent {
2254                    ts: crate::metrics::unix_ms(),
2255                    tool: "edit_overwrite",
2256                    duration_ms: dur,
2257                    output_chars: 0,
2258                    param_path_depth: crate::metrics::path_component_count(&param_path),
2259                    max_depth: None,
2260                    result: "error",
2261                    error_type: Some("internal_error".to_string()),
2262                    session_id: sid.clone(),
2263                    seq: Some(seq),
2264                    cache_hit: None,
2265                });
2266                return Ok(err_to_tool_result(ErrorData::new(
2267                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2268                    e.to_string(),
2269                    Some(error_meta(
2270                        "resource",
2271                        false,
2272                        "check file path and permissions",
2273                    )),
2274                )));
2275            }
2276            Err(e) => {
2277                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2278                self.metrics_tx.send(crate::metrics::MetricEvent {
2279                    ts: crate::metrics::unix_ms(),
2280                    tool: "edit_overwrite",
2281                    duration_ms: dur,
2282                    output_chars: 0,
2283                    param_path_depth: crate::metrics::path_component_count(&param_path),
2284                    max_depth: None,
2285                    result: "error",
2286                    error_type: Some("internal_error".to_string()),
2287                    session_id: sid.clone(),
2288                    seq: Some(seq),
2289                    cache_hit: None,
2290                });
2291                return Ok(err_to_tool_result(ErrorData::new(
2292                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2293                    e.to_string(),
2294                    Some(error_meta(
2295                        "resource",
2296                        false,
2297                        "check file path and permissions",
2298                    )),
2299                )));
2300            }
2301        };
2302
2303        let text = format!("Wrote {} bytes to {}", output.bytes_written, output.path);
2304        let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2305            .with_meta(Some(no_cache_meta()));
2306        let structured = match serde_json::to_value(&output).map_err(|e| {
2307            ErrorData::new(
2308                rmcp::model::ErrorCode::INTERNAL_ERROR,
2309                format!("serialization failed: {e}"),
2310                Some(error_meta("internal", false, "report this as a bug")),
2311            )
2312        }) {
2313            Ok(v) => v,
2314            Err(e) => return Ok(err_to_tool_result(e)),
2315        };
2316        result.structured_content = Some(structured);
2317        self.cache
2318            .invalidate_file(&std::path::PathBuf::from(&param_path));
2319        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2320        self.metrics_tx.send(crate::metrics::MetricEvent {
2321            ts: crate::metrics::unix_ms(),
2322            tool: "edit_overwrite",
2323            duration_ms: dur,
2324            output_chars: text.len(),
2325            param_path_depth: crate::metrics::path_component_count(&param_path),
2326            max_depth: None,
2327            result: "ok",
2328            error_type: None,
2329            session_id: sid,
2330            seq: Some(seq),
2331            cache_hit: None,
2332        });
2333        Ok(result)
2334    }
2335
2336    #[instrument(skip(self, _context))]
2337    #[tool(
2338        name = "edit_replace",
2339        title = "Edit Replace",
2340        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.",
2341        output_schema = schema_for_type::<EditReplaceOutput>(),
2342        annotations(
2343            title = "Edit Replace",
2344            read_only_hint = false,
2345            destructive_hint = true,
2346            idempotent_hint = false,
2347            open_world_hint = false
2348        )
2349    )]
2350    async fn edit_replace(
2351        &self,
2352        params: Parameters<EditReplaceParams>,
2353        _context: RequestContext<RoleServer>,
2354    ) -> Result<CallToolResult, ErrorData> {
2355        let params = params.0;
2356        let _validated_path = match validate_path(&params.path, true) {
2357            Ok(p) => p,
2358            Err(e) => return Ok(err_to_tool_result(e)),
2359        };
2360        let t_start = std::time::Instant::now();
2361        let param_path = params.path.clone();
2362        let seq = self
2363            .session_call_seq
2364            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2365        let sid = self.session_id.lock().await.clone();
2366
2367        // Guard against directory paths
2368        if std::fs::metadata(&params.path)
2369            .map(|m| m.is_dir())
2370            .unwrap_or(false)
2371        {
2372            let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2373            self.metrics_tx.send(crate::metrics::MetricEvent {
2374                ts: crate::metrics::unix_ms(),
2375                tool: "edit_replace",
2376                duration_ms: dur,
2377                output_chars: 0,
2378                param_path_depth: crate::metrics::path_component_count(&param_path),
2379                max_depth: None,
2380                result: "error",
2381                error_type: Some("invalid_params".to_string()),
2382                session_id: sid.clone(),
2383                seq: Some(seq),
2384                cache_hit: None,
2385            });
2386            return Ok(err_to_tool_result(ErrorData::new(
2387                rmcp::model::ErrorCode::INVALID_PARAMS,
2388                "path is a directory; cannot edit a directory".to_string(),
2389                Some(error_meta(
2390                    "validation",
2391                    false,
2392                    "provide a file path, not a directory",
2393                )),
2394            )));
2395        }
2396
2397        let path = std::path::PathBuf::from(&params.path);
2398        let old_text = params.old_text.clone();
2399        let new_text = params.new_text.clone();
2400        let handle = tokio::task::spawn_blocking(move || {
2401            aptu_coder_core::edit_replace_block(&path, &old_text, &new_text)
2402        });
2403
2404        let output = match handle.await {
2405            Ok(Ok(v)) => v,
2406            Ok(Err(aptu_coder_core::EditError::NotFound { path: _ })) => {
2407                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2408                self.metrics_tx.send(crate::metrics::MetricEvent {
2409                    ts: crate::metrics::unix_ms(),
2410                    tool: "edit_replace",
2411                    duration_ms: dur,
2412                    output_chars: 0,
2413                    param_path_depth: crate::metrics::path_component_count(&param_path),
2414                    max_depth: None,
2415                    result: "error",
2416                    error_type: Some("invalid_params".to_string()),
2417                    session_id: sid.clone(),
2418                    seq: Some(seq),
2419                    cache_hit: None,
2420                });
2421                return Ok(err_to_tool_result(ErrorData::new(
2422                    rmcp::model::ErrorCode::INVALID_PARAMS,
2423                    "old_text not found in file — verify the text matches exactly, including whitespace and newlines".to_string(),
2424                    Some(error_meta(
2425                        "validation",
2426                        false,
2427                        "check that old_text appears in the file",
2428                    )),
2429                )));
2430            }
2431            Ok(Err(aptu_coder_core::EditError::Ambiguous { count, path: _ })) => {
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                    format!(
2449                        "old_text appears {count} times in file — make old_text longer and more specific to uniquely identify the block"
2450                    ),
2451                    Some(error_meta(
2452                        "validation",
2453                        false,
2454                        "include more context in old_text to make it unique",
2455                    )),
2456                )));
2457            }
2458            Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2459                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2460                self.metrics_tx.send(crate::metrics::MetricEvent {
2461                    ts: crate::metrics::unix_ms(),
2462                    tool: "edit_replace",
2463                    duration_ms: dur,
2464                    output_chars: 0,
2465                    param_path_depth: crate::metrics::path_component_count(&param_path),
2466                    max_depth: None,
2467                    result: "error",
2468                    error_type: Some("invalid_params".to_string()),
2469                    session_id: sid.clone(),
2470                    seq: Some(seq),
2471                    cache_hit: None,
2472                });
2473                return Ok(err_to_tool_result(ErrorData::new(
2474                    rmcp::model::ErrorCode::INVALID_PARAMS,
2475                    "path is a directory".to_string(),
2476                    Some(error_meta(
2477                        "validation",
2478                        false,
2479                        "provide a file path, not a directory",
2480                    )),
2481                )));
2482            }
2483            Ok(Err(e)) => {
2484                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2485                self.metrics_tx.send(crate::metrics::MetricEvent {
2486                    ts: crate::metrics::unix_ms(),
2487                    tool: "edit_replace",
2488                    duration_ms: dur,
2489                    output_chars: 0,
2490                    param_path_depth: crate::metrics::path_component_count(&param_path),
2491                    max_depth: None,
2492                    result: "error",
2493                    error_type: Some("internal_error".to_string()),
2494                    session_id: sid.clone(),
2495                    seq: Some(seq),
2496                    cache_hit: None,
2497                });
2498                return Ok(err_to_tool_result(ErrorData::new(
2499                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2500                    e.to_string(),
2501                    Some(error_meta(
2502                        "resource",
2503                        false,
2504                        "check file path and permissions",
2505                    )),
2506                )));
2507            }
2508            Err(e) => {
2509                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2510                self.metrics_tx.send(crate::metrics::MetricEvent {
2511                    ts: crate::metrics::unix_ms(),
2512                    tool: "edit_replace",
2513                    duration_ms: dur,
2514                    output_chars: 0,
2515                    param_path_depth: crate::metrics::path_component_count(&param_path),
2516                    max_depth: None,
2517                    result: "error",
2518                    error_type: Some("internal_error".to_string()),
2519                    session_id: sid.clone(),
2520                    seq: Some(seq),
2521                    cache_hit: None,
2522                });
2523                return Ok(err_to_tool_result(ErrorData::new(
2524                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2525                    e.to_string(),
2526                    Some(error_meta(
2527                        "resource",
2528                        false,
2529                        "check file path and permissions",
2530                    )),
2531                )));
2532            }
2533        };
2534
2535        let text = format!(
2536            "Edited {}: {} bytes -> {} bytes",
2537            output.path, output.bytes_before, output.bytes_after
2538        );
2539        let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2540            .with_meta(Some(no_cache_meta()));
2541        let structured = match serde_json::to_value(&output).map_err(|e| {
2542            ErrorData::new(
2543                rmcp::model::ErrorCode::INTERNAL_ERROR,
2544                format!("serialization failed: {e}"),
2545                Some(error_meta("internal", false, "report this as a bug")),
2546            )
2547        }) {
2548            Ok(v) => v,
2549            Err(e) => return Ok(err_to_tool_result(e)),
2550        };
2551        result.structured_content = Some(structured);
2552        self.cache
2553            .invalidate_file(&std::path::PathBuf::from(&param_path));
2554        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2555        self.metrics_tx.send(crate::metrics::MetricEvent {
2556            ts: crate::metrics::unix_ms(),
2557            tool: "edit_replace",
2558            duration_ms: dur,
2559            output_chars: text.len(),
2560            param_path_depth: crate::metrics::path_component_count(&param_path),
2561            max_depth: None,
2562            result: "ok",
2563            error_type: None,
2564            session_id: sid,
2565            seq: Some(seq),
2566            cache_hit: None,
2567        });
2568        Ok(result)
2569    }
2570
2571    #[instrument(skip(self, _context))]
2572    #[tool(
2573        name = "edit_rename",
2574        title = "Edit Rename",
2575        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/.",
2576        output_schema = schema_for_type::<EditRenameOutput>(),
2577        annotations(
2578            title = "Edit Rename",
2579            read_only_hint = false,
2580            destructive_hint = false,
2581            idempotent_hint = true,
2582            open_world_hint = false
2583        )
2584    )]
2585    async fn edit_rename(
2586        &self,
2587        params: Parameters<EditRenameParams>,
2588        _context: RequestContext<RoleServer>,
2589    ) -> Result<CallToolResult, ErrorData> {
2590        let params = params.0;
2591        let _validated_path = match validate_path(&params.path, true) {
2592            Ok(p) => p,
2593            Err(e) => return Ok(err_to_tool_result(e)),
2594        };
2595        let t_start = std::time::Instant::now();
2596        let param_path = params.path.clone();
2597        let seq = self
2598            .session_call_seq
2599            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2600        let sid = self.session_id.lock().await.clone();
2601
2602        let is_dir = std::fs::metadata(&params.path)
2603            .map(|m| m.is_dir())
2604            .unwrap_or(false);
2605
2606        let path = std::path::PathBuf::from(&params.path);
2607        let old_name = params.old_name.clone();
2608        let new_name = params.new_name.clone();
2609
2610        let handle = if is_dir {
2611            tokio::task::spawn_blocking(move || {
2612                edit_rename_directory(&path, &old_name, &new_name, None).map(|(results, errors)| {
2613                    let total_occurrences: usize =
2614                        results.iter().map(|r| r.occurrences_renamed).sum();
2615                    aptu_coder_core::types::EditRenameOutput {
2616                        path: path.display().to_string(),
2617                        old_name,
2618                        new_name,
2619                        occurrences_renamed: total_occurrences,
2620                        files_changed: Some(results),
2621                        errors: Some(errors),
2622                    }
2623                })
2624            })
2625        } else {
2626            tokio::task::spawn_blocking(move || {
2627                edit_rename_in_file(&path, &old_name, &new_name, None)
2628            })
2629        };
2630
2631        let output = match handle.await {
2632            Ok(Ok(v)) => v,
2633            Ok(Err(aptu_coder_core::EditError::SymbolNotFound { .. })) => {
2634                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2635                self.metrics_tx.send(crate::metrics::MetricEvent {
2636                    ts: crate::metrics::unix_ms(),
2637                    tool: "edit_rename",
2638                    duration_ms: dur,
2639                    output_chars: 0,
2640                    param_path_depth: crate::metrics::path_component_count(&param_path),
2641                    max_depth: None,
2642                    result: "error",
2643                    error_type: Some("invalid_params".to_string()),
2644                    session_id: sid.clone(),
2645                    seq: Some(seq),
2646                    cache_hit: None,
2647                });
2648                return Ok(err_to_tool_result(ErrorData::new(
2649                    rmcp::model::ErrorCode::INVALID_PARAMS,
2650                    "symbol not found in file".to_string(),
2651                    Some(error_meta(
2652                        "validation",
2653                        false,
2654                        "verify the symbol name and file path",
2655                    )),
2656                )));
2657            }
2658            Ok(Err(aptu_coder_core::EditError::AmbiguousKind { .. })) => {
2659                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2660                self.metrics_tx.send(crate::metrics::MetricEvent {
2661                    ts: crate::metrics::unix_ms(),
2662                    tool: "edit_rename",
2663                    duration_ms: dur,
2664                    output_chars: 0,
2665                    param_path_depth: crate::metrics::path_component_count(&param_path),
2666                    max_depth: None,
2667                    result: "error",
2668                    error_type: Some("invalid_params".to_string()),
2669                    session_id: sid.clone(),
2670                    seq: Some(seq),
2671                    cache_hit: None,
2672                });
2673                return Ok(err_to_tool_result(ErrorData::new(
2674                    rmcp::model::ErrorCode::INVALID_PARAMS,
2675                    "symbol name is ambiguous".to_string(),
2676                    Some(error_meta(
2677                        "validation",
2678                        false,
2679                        "verify the symbol name is unique",
2680                    )),
2681                )));
2682            }
2683            Ok(Err(aptu_coder_core::EditError::UnsupportedLanguage(_))) => {
2684                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2685                self.metrics_tx.send(crate::metrics::MetricEvent {
2686                    ts: crate::metrics::unix_ms(),
2687                    tool: "edit_rename",
2688                    duration_ms: dur,
2689                    output_chars: 0,
2690                    param_path_depth: crate::metrics::path_component_count(&param_path),
2691                    max_depth: None,
2692                    result: "error",
2693                    error_type: Some("invalid_params".to_string()),
2694                    session_id: sid.clone(),
2695                    seq: Some(seq),
2696                    cache_hit: None,
2697                });
2698                return Ok(err_to_tool_result(ErrorData::new(
2699                    rmcp::model::ErrorCode::INVALID_PARAMS,
2700                    "file language is not supported".to_string(),
2701                    Some(error_meta(
2702                        "validation",
2703                        false,
2704                        "check that the file has a supported language extension",
2705                    )),
2706                )));
2707            }
2708            Ok(Err(aptu_coder_core::EditError::KindFilterUnsupported)) => {
2709                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2710                self.metrics_tx.send(crate::metrics::MetricEvent {
2711                    ts: crate::metrics::unix_ms(),
2712                    tool: "edit_rename",
2713                    duration_ms: dur,
2714                    output_chars: 0,
2715                    param_path_depth: crate::metrics::path_component_count(&param_path),
2716                    max_depth: None,
2717                    result: "error",
2718                    error_type: Some("invalid_params".to_string()),
2719                    session_id: sid.clone(),
2720                    seq: Some(seq),
2721                    cache_hit: None,
2722                });
2723                return Ok(err_to_tool_result(ErrorData::new(
2724                    rmcp::model::ErrorCode::INVALID_PARAMS,
2725                    "kind filtering is not supported with the current identifier query infrastructure"
2726                        .to_string(),
2727                    Some(error_meta(
2728                        "validation",
2729                        false,
2730                        "omit the kind parameter",
2731                    )),
2732                )));
2733            }
2734            Ok(Err(aptu_coder_core::EditError::NotAFile(_))) => {
2735                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2736                self.metrics_tx.send(crate::metrics::MetricEvent {
2737                    ts: crate::metrics::unix_ms(),
2738                    tool: "edit_rename",
2739                    duration_ms: dur,
2740                    output_chars: 0,
2741                    param_path_depth: crate::metrics::path_component_count(&param_path),
2742                    max_depth: None,
2743                    result: "error",
2744                    error_type: Some("invalid_params".to_string()),
2745                    session_id: sid.clone(),
2746                    seq: Some(seq),
2747                    cache_hit: None,
2748                });
2749                return Ok(err_to_tool_result(ErrorData::new(
2750                    rmcp::model::ErrorCode::INVALID_PARAMS,
2751                    "path is a directory".to_string(),
2752                    Some(error_meta(
2753                        "validation",
2754                        false,
2755                        "provide a file path, not a directory",
2756                    )),
2757                )));
2758            }
2759            Ok(Err(e)) => {
2760                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2761                self.metrics_tx.send(crate::metrics::MetricEvent {
2762                    ts: crate::metrics::unix_ms(),
2763                    tool: "edit_rename",
2764                    duration_ms: dur,
2765                    output_chars: 0,
2766                    param_path_depth: crate::metrics::path_component_count(&param_path),
2767                    max_depth: None,
2768                    result: "error",
2769                    error_type: Some("internal_error".to_string()),
2770                    session_id: sid.clone(),
2771                    seq: Some(seq),
2772                    cache_hit: None,
2773                });
2774                return Ok(err_to_tool_result(ErrorData::new(
2775                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2776                    e.to_string(),
2777                    Some(error_meta(
2778                        "resource",
2779                        false,
2780                        "check file path and permissions",
2781                    )),
2782                )));
2783            }
2784            Err(e) => {
2785                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2786                self.metrics_tx.send(crate::metrics::MetricEvent {
2787                    ts: crate::metrics::unix_ms(),
2788                    tool: "edit_rename",
2789                    duration_ms: dur,
2790                    output_chars: 0,
2791                    param_path_depth: crate::metrics::path_component_count(&param_path),
2792                    max_depth: None,
2793                    result: "error",
2794                    error_type: Some("internal_error".to_string()),
2795                    session_id: sid.clone(),
2796                    seq: Some(seq),
2797                    cache_hit: None,
2798                });
2799                return Ok(err_to_tool_result(ErrorData::new(
2800                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2801                    e.to_string(),
2802                    Some(error_meta(
2803                        "resource",
2804                        false,
2805                        "check file path and permissions",
2806                    )),
2807                )));
2808            }
2809        };
2810
2811        let text = format!(
2812            "Renamed '{}' to '{}' in {} ({} occurrence(s))",
2813            output.old_name, output.new_name, output.path, output.occurrences_renamed
2814        );
2815        let mut result = CallToolResult::success(vec![Content::text(text.clone())])
2816            .with_meta(Some(no_cache_meta()));
2817        let structured = match serde_json::to_value(&output).map_err(|e| {
2818            ErrorData::new(
2819                rmcp::model::ErrorCode::INTERNAL_ERROR,
2820                format!("serialization failed: {e}"),
2821                Some(error_meta("internal", false, "report this as a bug")),
2822            )
2823        }) {
2824            Ok(v) => v,
2825            Err(e) => return Ok(err_to_tool_result(e)),
2826        };
2827        result.structured_content = Some(structured);
2828        self.cache
2829            .invalidate_file(&std::path::PathBuf::from(&param_path));
2830        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2831        self.metrics_tx.send(crate::metrics::MetricEvent {
2832            ts: crate::metrics::unix_ms(),
2833            tool: "edit_rename",
2834            duration_ms: dur,
2835            output_chars: text.len(),
2836            param_path_depth: crate::metrics::path_component_count(&param_path),
2837            max_depth: None,
2838            result: "ok",
2839            error_type: None,
2840            session_id: sid,
2841            seq: Some(seq),
2842            cache_hit: None,
2843        });
2844        Ok(result)
2845    }
2846
2847    #[instrument(skip(self, _context))]
2848    #[tool(
2849        name = "edit_insert",
2850        title = "Edit Insert",
2851        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.",
2852        output_schema = schema_for_type::<EditInsertOutput>(),
2853        annotations(
2854            title = "Edit Insert",
2855            read_only_hint = false,
2856            destructive_hint = true,
2857            idempotent_hint = false,
2858            open_world_hint = false
2859        )
2860    )]
2861    async fn edit_insert(
2862        &self,
2863        params: Parameters<EditInsertParams>,
2864        _context: RequestContext<RoleServer>,
2865    ) -> Result<CallToolResult, ErrorData> {
2866        let params = params.0;
2867        let _validated_path = match validate_path(&params.path, true) {
2868            Ok(p) => p,
2869            Err(e) => return Ok(err_to_tool_result(e)),
2870        };
2871        let t_start = std::time::Instant::now();
2872        let param_path = params.path.clone();
2873        let seq = self
2874            .session_call_seq
2875            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
2876        let sid = self.session_id.lock().await.clone();
2877
2878        // Guard against directory paths
2879        if std::fs::metadata(&params.path)
2880            .map(|m| m.is_dir())
2881            .unwrap_or(false)
2882        {
2883            let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2884            self.metrics_tx.send(crate::metrics::MetricEvent {
2885                ts: crate::metrics::unix_ms(),
2886                tool: "edit_insert",
2887                duration_ms: dur,
2888                output_chars: 0,
2889                param_path_depth: crate::metrics::path_component_count(&param_path),
2890                max_depth: None,
2891                result: "error",
2892                error_type: Some("invalid_params".to_string()),
2893                session_id: sid.clone(),
2894                seq: Some(seq),
2895                cache_hit: None,
2896            });
2897            return Ok(err_to_tool_result(ErrorData::new(
2898                rmcp::model::ErrorCode::INVALID_PARAMS,
2899                "edit_insert operates on a single file — provide a file path, not a directory"
2900                    .to_string(),
2901                Some(error_meta(
2902                    "validation",
2903                    false,
2904                    "provide a file path, not a directory",
2905                )),
2906            )));
2907        }
2908
2909        let path = std::path::PathBuf::from(&params.path);
2910        let symbol_name = params.symbol_name.clone();
2911        let position = params.position;
2912        let content = params.content.clone();
2913        let handle = tokio::task::spawn_blocking(move || {
2914            edit_insert_at_symbol(&path, &symbol_name, position, &content)
2915        });
2916
2917        let output = match handle.await {
2918            Ok(Ok(v)) => v,
2919            Ok(Err(aptu_coder_core::EditError::SymbolNotFound { .. })) => {
2920                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2921                self.metrics_tx.send(crate::metrics::MetricEvent {
2922                    ts: crate::metrics::unix_ms(),
2923                    tool: "edit_insert",
2924                    duration_ms: dur,
2925                    output_chars: 0,
2926                    param_path_depth: crate::metrics::path_component_count(&param_path),
2927                    max_depth: None,
2928                    result: "error",
2929                    error_type: Some("invalid_params".to_string()),
2930                    session_id: sid.clone(),
2931                    seq: Some(seq),
2932                    cache_hit: None,
2933                });
2934                return Ok(err_to_tool_result(ErrorData::new(
2935                    rmcp::model::ErrorCode::INVALID_PARAMS,
2936                    "symbol not found in file".to_string(),
2937                    Some(error_meta(
2938                        "validation",
2939                        false,
2940                        "verify the symbol name and file path",
2941                    )),
2942                )));
2943            }
2944            Ok(Err(aptu_coder_core::EditError::UnsupportedLanguage(_))) => {
2945                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2946                self.metrics_tx.send(crate::metrics::MetricEvent {
2947                    ts: crate::metrics::unix_ms(),
2948                    tool: "edit_insert",
2949                    duration_ms: dur,
2950                    output_chars: 0,
2951                    param_path_depth: crate::metrics::path_component_count(&param_path),
2952                    max_depth: None,
2953                    result: "error",
2954                    error_type: Some("invalid_params".to_string()),
2955                    session_id: sid.clone(),
2956                    seq: Some(seq),
2957                    cache_hit: None,
2958                });
2959                return Ok(err_to_tool_result(ErrorData::new(
2960                    rmcp::model::ErrorCode::INVALID_PARAMS,
2961                    "file language is not supported".to_string(),
2962                    Some(error_meta(
2963                        "validation",
2964                        false,
2965                        "check that the file has a supported language extension",
2966                    )),
2967                )));
2968            }
2969            Ok(Err(e)) => {
2970                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2971                self.metrics_tx.send(crate::metrics::MetricEvent {
2972                    ts: crate::metrics::unix_ms(),
2973                    tool: "edit_insert",
2974                    duration_ms: dur,
2975                    output_chars: 0,
2976                    param_path_depth: crate::metrics::path_component_count(&param_path),
2977                    max_depth: None,
2978                    result: "error",
2979                    error_type: Some("internal_error".to_string()),
2980                    session_id: sid.clone(),
2981                    seq: Some(seq),
2982                    cache_hit: None,
2983                });
2984                return Ok(err_to_tool_result(ErrorData::new(
2985                    rmcp::model::ErrorCode::INTERNAL_ERROR,
2986                    e.to_string(),
2987                    Some(error_meta(
2988                        "resource",
2989                        false,
2990                        "check file path and permissions",
2991                    )),
2992                )));
2993            }
2994            Err(e) => {
2995                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
2996                self.metrics_tx.send(crate::metrics::MetricEvent {
2997                    ts: crate::metrics::unix_ms(),
2998                    tool: "edit_insert",
2999                    duration_ms: dur,
3000                    output_chars: 0,
3001                    param_path_depth: crate::metrics::path_component_count(&param_path),
3002                    max_depth: None,
3003                    result: "error",
3004                    error_type: Some("internal_error".to_string()),
3005                    session_id: sid.clone(),
3006                    seq: Some(seq),
3007                    cache_hit: None,
3008                });
3009                return Ok(err_to_tool_result(ErrorData::new(
3010                    rmcp::model::ErrorCode::INTERNAL_ERROR,
3011                    e.to_string(),
3012                    Some(error_meta(
3013                        "resource",
3014                        false,
3015                        "check file path and permissions",
3016                    )),
3017                )));
3018            }
3019        };
3020
3021        let position_str = match output.position {
3022            aptu_coder_core::types::InsertPosition::Before => "before",
3023            aptu_coder_core::types::InsertPosition::After => "after",
3024        };
3025        let text = format!(
3026            "Inserted content {} '{}' in {} (at byte offset {})",
3027            position_str, output.symbol_name, output.path, output.byte_offset
3028        );
3029        let mut result = CallToolResult::success(vec![Content::text(text.clone())])
3030            .with_meta(Some(no_cache_meta()));
3031        let structured = match serde_json::to_value(&output).map_err(|e| {
3032            ErrorData::new(
3033                rmcp::model::ErrorCode::INTERNAL_ERROR,
3034                format!("serialization failed: {e}"),
3035                Some(error_meta("internal", false, "report this as a bug")),
3036            )
3037        }) {
3038            Ok(v) => v,
3039            Err(e) => return Ok(err_to_tool_result(e)),
3040        };
3041        result.structured_content = Some(structured);
3042        self.cache
3043            .invalidate_file(&std::path::PathBuf::from(&param_path));
3044        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3045        self.metrics_tx.send(crate::metrics::MetricEvent {
3046            ts: crate::metrics::unix_ms(),
3047            tool: "edit_insert",
3048            duration_ms: dur,
3049            output_chars: text.len(),
3050            param_path_depth: crate::metrics::path_component_count(&param_path),
3051            max_depth: None,
3052            result: "ok",
3053            error_type: None,
3054            session_id: sid,
3055            seq: Some(seq),
3056            cache_hit: None,
3057        });
3058        Ok(result)
3059    }
3060
3061    #[tool(
3062        name = "exec_command",
3063        title = "Exec Command",
3064        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.",
3065        output_schema = schema_for_type::<types::ShellOutput>(),
3066        annotations(
3067            title = "Exec Command",
3068            read_only_hint = false,
3069            destructive_hint = true,
3070            idempotent_hint = false,
3071            open_world_hint = true
3072        )
3073    )]
3074    #[instrument(skip(self, context))]
3075    pub async fn exec_command(
3076        &self,
3077        params: Parameters<types::ExecCommandParams>,
3078        context: RequestContext<RoleServer>,
3079    ) -> Result<CallToolResult, ErrorData> {
3080        let t_start = std::time::Instant::now();
3081        let params = params.0;
3082
3083        // Validate working_dir if provided
3084        let working_dir_path = if let Some(ref wd) = params.working_dir {
3085            match validate_path(wd, true) {
3086                Ok(p) => {
3087                    // Verify it's a directory
3088                    if !std::fs::metadata(&p).map(|m| m.is_dir()).unwrap_or(false) {
3089                        return Ok(err_to_tool_result(ErrorData::new(
3090                            rmcp::model::ErrorCode::INVALID_PARAMS,
3091                            "working_dir must be a directory".to_string(),
3092                            Some(error_meta(
3093                                "validation",
3094                                false,
3095                                "provide a valid directory path",
3096                            )),
3097                        )));
3098                    }
3099                    Some(p)
3100                }
3101                Err(e) => {
3102                    return Ok(err_to_tool_result(e));
3103                }
3104            }
3105        } else {
3106            None
3107        };
3108
3109        let param_path = params.working_dir.clone();
3110        let seq = self
3111            .session_call_seq
3112            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
3113        let sid = self.session_id.lock().await.clone();
3114
3115        // Validate stdin size cap (1 MB)
3116        if let Some(ref stdin_content) = params.stdin
3117            && stdin_content.len() > STDIN_MAX_BYTES
3118        {
3119            return Ok(err_to_tool_result(ErrorData::new(
3120                rmcp::model::ErrorCode::INVALID_PARAMS,
3121                "stdin exceeds 1 MB limit".to_string(),
3122                Some(error_meta("validation", false, "reduce stdin content size")),
3123            )));
3124        }
3125
3126        let command = params.command.clone();
3127        let timeout_secs = params.timeout_secs;
3128
3129        // Acquire peer and progress token for optional progress notifications
3130        let peer = self.peer.lock().await.clone();
3131        let progress_token = context.meta.get_progress_token();
3132
3133        // Spawn a progress task that emits every 5s for long-running commands (>10s timeout)
3134        let progress_handle: Option<tokio::task::JoinHandle<()>> =
3135            if timeout_secs.is_none_or(|t| t > 10) {
3136                if let (Some(token), Some(peer_conn)) = (progress_token.clone(), peer.clone()) {
3137                    let self_clone = self.clone();
3138                    Some(tokio::spawn(async move {
3139                        let mut interval = tokio::time::interval(std::time::Duration::from_secs(5));
3140                        interval.tick().await; // skip the immediate first tick
3141                        let mut tick = 0u64;
3142                        loop {
3143                            interval.tick().await;
3144                            tick += 1;
3145                            let progress = match timeout_secs {
3146                                Some(secs) => ((tick * 5) as f64 / secs as f64).min(0.99),
3147                                None => 0.0,
3148                            };
3149                            self_clone
3150                                .emit_progress(
3151                                    Some(peer_conn.clone()),
3152                                    &token,
3153                                    progress,
3154                                    1.0,
3155                                    "command running".to_string(),
3156                                )
3157                                .await;
3158                        }
3159                    }))
3160                } else {
3161                    None
3162                }
3163            } else {
3164                None
3165            };
3166
3167        // Spawn the command using tokio::process::Command for proper async handling
3168        let shell = resolve_shell();
3169
3170        let mut cmd = tokio::process::Command::new(shell);
3171        cmd.arg("-c").arg(&command);
3172
3173        if let Some(ref wd) = working_dir_path {
3174            cmd.current_dir(wd);
3175        }
3176
3177        cmd.stdout(std::process::Stdio::piped())
3178            .stderr(std::process::Stdio::piped());
3179
3180        // Set stdin mode: piped if stdin content provided, null otherwise
3181        if params.stdin.is_some() {
3182            cmd.stdin(std::process::Stdio::piped());
3183        } else {
3184            cmd.stdin(std::process::Stdio::null());
3185        }
3186
3187        #[cfg(unix)]
3188        {
3189            let memory_limit_mb = params.memory_limit_mb;
3190            let cpu_limit_secs = params.cpu_limit_secs;
3191            #[cfg(not(target_os = "linux"))]
3192            if memory_limit_mb.is_some() {
3193                warn!("memory_limit_mb is not enforced on this platform (Linux only)");
3194            }
3195            if memory_limit_mb.is_some() || cpu_limit_secs.is_some() {
3196                // SAFETY: pre_exec runs in the forked child process before exec.
3197                // nix::sys::resource::setrlimit is a safe Rust API.
3198                // The unsafe block is required by tokio::process::Command::pre_exec API contract.
3199                unsafe {
3200                    cmd.pre_exec(move || {
3201                        #[cfg(target_os = "linux")]
3202                        if let Some(mb) = memory_limit_mb {
3203                            let bytes = mb.saturating_mul(1024 * 1024);
3204                            setrlimit(Resource::RLIMIT_AS, bytes, bytes)
3205                                .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3206                        }
3207                        if let Some(cpu) = cpu_limit_secs {
3208                            setrlimit(Resource::RLIMIT_CPU, cpu, cpu)
3209                                .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?;
3210                        }
3211                        Ok(())
3212                    });
3213                }
3214            }
3215        }
3216
3217        let mut child = match cmd.spawn() {
3218            Ok(c) => c,
3219            Err(e) => {
3220                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3221                self.metrics_tx.send(crate::metrics::MetricEvent {
3222                    ts: crate::metrics::unix_ms(),
3223                    tool: "exec_command",
3224                    duration_ms: dur,
3225                    output_chars: 0,
3226                    param_path_depth: crate::metrics::path_component_count(
3227                        param_path.as_deref().unwrap_or(""),
3228                    ),
3229                    max_depth: None,
3230                    result: "error",
3231                    error_type: Some("internal_error".to_string()),
3232                    session_id: sid.clone(),
3233                    seq: Some(seq),
3234                    cache_hit: None,
3235                });
3236                return Ok(err_to_tool_result(ErrorData::new(
3237                    rmcp::model::ErrorCode::INTERNAL_ERROR,
3238                    format!("failed to spawn command: {e}"),
3239                    Some(error_meta(
3240                        "resource",
3241                        false,
3242                        "check command syntax and permissions",
3243                    )),
3244                )));
3245            }
3246        };
3247
3248        // Wait for the command with timeout using tokio::select! to race wait against sleep
3249        const MAX_BYTES: usize = 50 * 1024;
3250
3251        let stdout_pipe = child.stdout.take();
3252        let stderr_pipe = child.stderr.take();
3253
3254        // Write stdin if provided, before spawning drain_task to avoid deadlock
3255        if let Some(stdin_content) = params.stdin
3256            && let Some(mut stdin_handle) = child.stdin.take()
3257        {
3258            use tokio::io::AsyncWriteExt as _;
3259            match stdin_handle.write_all(stdin_content.as_bytes()).await {
3260                Ok(()) => {
3261                    drop(stdin_handle); // Close stdin pipe
3262                }
3263                Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => {
3264                    // Child closed stdin early; non-fatal, continue collecting output
3265                }
3266                Err(e) => {
3267                    warn!("failed to write stdin: {e}");
3268                }
3269            }
3270        }
3271
3272        use std::sync::Arc;
3273        use tokio::io::AsyncBufReadExt as _;
3274        use tokio::sync::Mutex as TokioMutex;
3275        use tokio_stream::StreamExt as TokioStreamExt;
3276        use tokio_stream::wrappers::LinesStream;
3277
3278        // Shared heap buffers: written per-line (brief lock, not held across await) so the
3279        // timeout arm can read partial output even after the drain task is aborted.
3280        let stdout_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
3281        let stderr_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
3282        let interleaved_shared: Arc<TokioMutex<String>> = Arc::new(TokioMutex::new(String::new()));
3283
3284        let so_acc = Arc::clone(&stdout_shared);
3285        let se_acc = Arc::clone(&stderr_shared);
3286        let il_acc = Arc::clone(&interleaved_shared);
3287
3288        // Spawn the drain as a separate task so it can be aborted on wall-clock timeout
3289        // while shared buffers retain whatever lines were collected before the kill.
3290        let mut drain_task = tokio::spawn(async move {
3291            let mut so_bytes = 0usize;
3292            let mut se_bytes = 0usize;
3293            let mut il_bytes = 0usize;
3294
3295            let so_stream = stdout_pipe.map(|p| {
3296                LinesStream::new(tokio::io::BufReader::new(p).lines())
3297                    .map(|l| l.map(|s| (false, s)))
3298            });
3299            let se_stream = stderr_pipe.map(|p| {
3300                LinesStream::new(tokio::io::BufReader::new(p).lines()).map(|l| l.map(|s| (true, s)))
3301            });
3302
3303            match (so_stream, se_stream) {
3304                (Some(so), Some(se)) => {
3305                    let mut merged = so.merge(se);
3306                    while let Some(item) = merged.next().await {
3307                        if let Ok((is_stderr, line)) = item {
3308                            let entry = format!("{line}\n");
3309                            if is_stderr {
3310                                if se_bytes < MAX_BYTES {
3311                                    se_bytes += entry.len();
3312                                    se_acc.lock().await.push_str(&entry);
3313                                    if il_bytes < 2 * MAX_BYTES {
3314                                        il_bytes += entry.len();
3315                                        il_acc.lock().await.push_str(&entry);
3316                                    }
3317                                }
3318                            } else if so_bytes < MAX_BYTES {
3319                                so_bytes += entry.len();
3320                                so_acc.lock().await.push_str(&entry);
3321                                if il_bytes < 2 * MAX_BYTES {
3322                                    il_bytes += entry.len();
3323                                    il_acc.lock().await.push_str(&entry);
3324                                }
3325                            }
3326                        }
3327                    }
3328                }
3329                (Some(so), None) => {
3330                    let mut stream = so;
3331                    while let Some(item) = stream.next().await {
3332                        if let Ok((_, line)) = item
3333                            && so_bytes < MAX_BYTES
3334                        {
3335                            let entry = format!("{line}\n");
3336                            so_bytes += entry.len();
3337                            so_acc.lock().await.push_str(&entry);
3338                            if il_bytes < 2 * MAX_BYTES {
3339                                il_bytes += entry.len();
3340                                il_acc.lock().await.push_str(&entry);
3341                            }
3342                        }
3343                    }
3344                }
3345                (None, Some(se)) => {
3346                    let mut stream = se;
3347                    while let Some(item) = stream.next().await {
3348                        if let Ok((_, line)) = item
3349                            && se_bytes < MAX_BYTES
3350                        {
3351                            let entry = format!("{line}\n");
3352                            se_bytes += entry.len();
3353                            se_acc.lock().await.push_str(&entry);
3354                            if il_bytes < 2 * MAX_BYTES {
3355                                il_bytes += entry.len();
3356                                il_acc.lock().await.push_str(&entry);
3357                            }
3358                        }
3359                    }
3360                }
3361                (None, None) => {}
3362            }
3363        });
3364
3365        let (exit_code, timed_out, mut output_truncated, output_collection_error) = tokio::select! {
3366            _ = &mut drain_task => {
3367                // Pipes fully drained. Wait up to 500ms for child to exit.
3368                // Background processes may hold pipes open after the main work is done.
3369                let (status, drain_truncated) = match tokio::time::timeout(
3370                    std::time::Duration::from_millis(500),
3371                    child.wait()
3372                ).await {
3373                    Ok(Ok(s)) => (Some(s), false),
3374                    Ok(Err(_)) => (None, false),
3375                    Err(_) => {
3376                        child.start_kill().ok();
3377                        let _ = child.wait().await;
3378                        (None, true)
3379                    }
3380                };
3381                let exit_code = status.and_then(|s| s.code());
3382                let ocerr = if drain_truncated {
3383                    Some("post-exit drain timeout: background process held pipes".to_string())
3384                } else {
3385                    None
3386                };
3387                (exit_code, false, drain_truncated, ocerr)
3388            }
3389            _ = async {
3390                if let Some(secs) = timeout_secs {
3391                    tokio::time::sleep(std::time::Duration::from_secs(secs)).await;
3392                } else {
3393                    std::future::pending::<()>().await;
3394                }
3395            } => {
3396                // Wall-clock timeout fired. Kill process; drain task gets EOF and exits.
3397                let _ = child.kill().await;
3398                let _ = child.wait().await;
3399                drain_task.abort();
3400                // Shared buffers retain whatever lines were collected before the kill.
3401                (None, true, false, None)
3402            }
3403        };
3404
3405        // Cancel progress task now that drain is complete
3406        if let Some(handle) = progress_handle {
3407            handle.abort();
3408        }
3409
3410        // Read accumulated output from shared buffers
3411        let stdout_str = std::mem::take(&mut *stdout_shared.lock().await);
3412        let stderr_str = std::mem::take(&mut *stderr_shared.lock().await);
3413        let interleaved_str = std::mem::take(&mut *interleaved_shared.lock().await);
3414
3415        // Handle output overflow with slot isolation
3416        let slot = seq % 8;
3417        let (stdout, stderr, overflow_notice) =
3418            handle_output_overflow(stdout_str, stderr_str, slot);
3419        output_truncated = output_truncated || overflow_notice.is_some();
3420
3421        let mut output = types::ShellOutput::new(
3422            stdout,
3423            stderr,
3424            interleaved_str,
3425            exit_code,
3426            timed_out,
3427            output_truncated,
3428        );
3429        output.output_collection_error = output_collection_error;
3430
3431        // Use interleaved if non-empty; fall back to separated stdout/stderr for empty-output commands
3432        let output_text = if output.interleaved.is_empty() {
3433            format!("Stdout:\n{}\n\nStderr:\n{}", output.stdout, output.stderr)
3434        } else {
3435            format!("Output:\n{}", output.interleaved)
3436        };
3437
3438        let text = format!(
3439            "Command: {}\nExit code: {}\nTimed out: {}\nOutput truncated: {}\n\n{}",
3440            params.command,
3441            exit_code
3442                .map(|c| c.to_string())
3443                .unwrap_or_else(|| "null".to_string()),
3444            timed_out,
3445            output_truncated,
3446            output_text,
3447        );
3448
3449        let mut content_blocks = vec![Content::text(text.clone()).with_priority(0.0)];
3450        if let Some(notice) = overflow_notice {
3451            content_blocks.push(Content::text(notice).with_priority(0.0));
3452        }
3453
3454        // Determine if command failed: timeout or non-zero exit code.
3455        // exit_code is None when: (a) process killed by O1 post-exit drain timeout (background child
3456        // holding pipes -- command work was done, treat as success) or (b) externally killed; both
3457        // cases use unwrap_or(false) to avoid false negatives.
3458        let command_failed = timed_out || exit_code.map(|c| c != 0).unwrap_or(false);
3459
3460        let mut result = if command_failed {
3461            CallToolResult::error(content_blocks)
3462        } else {
3463            CallToolResult::success(content_blocks)
3464        }
3465        .with_meta(Some(no_cache_meta()));
3466
3467        let structured = match serde_json::to_value(&output).map_err(|e| {
3468            ErrorData::new(
3469                rmcp::model::ErrorCode::INTERNAL_ERROR,
3470                format!("serialization failed: {e}"),
3471                Some(error_meta("internal", false, "report this as a bug")),
3472            )
3473        }) {
3474            Ok(v) => v,
3475            Err(e) => {
3476                let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3477                self.metrics_tx.send(crate::metrics::MetricEvent {
3478                    ts: crate::metrics::unix_ms(),
3479                    tool: "exec_command",
3480                    duration_ms: dur,
3481                    output_chars: 0,
3482                    param_path_depth: crate::metrics::path_component_count(
3483                        param_path.as_deref().unwrap_or(""),
3484                    ),
3485                    max_depth: None,
3486                    result: "error",
3487                    error_type: Some("internal_error".to_string()),
3488                    session_id: sid.clone(),
3489                    seq: Some(seq),
3490                    cache_hit: None,
3491                });
3492                return Ok(err_to_tool_result(e));
3493            }
3494        };
3495
3496        result.structured_content = Some(structured);
3497        let dur = t_start.elapsed().as_millis().try_into().unwrap_or(u64::MAX);
3498        self.metrics_tx.send(crate::metrics::MetricEvent {
3499            ts: crate::metrics::unix_ms(),
3500            tool: "exec_command",
3501            duration_ms: dur,
3502            output_chars: text.len(),
3503            param_path_depth: crate::metrics::path_component_count(
3504                param_path.as_deref().unwrap_or(""),
3505            ),
3506            max_depth: None,
3507            result: "ok",
3508            error_type: None,
3509            session_id: sid,
3510            seq: Some(seq),
3511            cache_hit: None,
3512        });
3513        Ok(result)
3514    }
3515}
3516
3517/// Handles output overflow by writing to temp files and returning preview + notice.
3518/// If output exceeds 2000 lines, writes full stdout/stderr to:
3519///   {temp_dir}/aptu-coder-overflow/slot-{slot}/{stdout,stderr}
3520/// Returns (stdout_preview, stderr_preview, overflow_notice).
3521/// If no overflow: returns (stdout, stderr, None).
3522fn handle_output_overflow(
3523    stdout: String,
3524    stderr: String,
3525    slot: u32,
3526) -> (String, String, Option<String>) {
3527    const MAX_OUTPUT_LINES: usize = 2000;
3528    const OVERFLOW_PREVIEW_LINES: usize = 50;
3529
3530    let stdout_lines: Vec<&str> = stdout.lines().collect();
3531    let stderr_lines: Vec<&str> = stderr.lines().collect();
3532
3533    if stdout_lines.len() <= MAX_OUTPUT_LINES && stderr_lines.len() <= MAX_OUTPUT_LINES {
3534        return (stdout, stderr, None);
3535    }
3536
3537    // Write overflow to temp file
3538    let base = std::env::temp_dir()
3539        .join("aptu-coder-overflow")
3540        .join(format!("slot-{slot}"));
3541    let _ = std::fs::create_dir_all(&base);
3542
3543    let stdout_path = base.join("stdout");
3544    let stderr_path = base.join("stderr");
3545
3546    let _ = std::fs::write(&stdout_path, stdout.as_bytes());
3547    let _ = std::fs::write(&stderr_path, stderr.as_bytes());
3548
3549    // Last 50 lines as preview
3550    let stdout_preview = if stdout_lines.len() > MAX_OUTPUT_LINES {
3551        stdout_lines[stdout_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3552    } else {
3553        stdout
3554    };
3555    let stderr_preview = if stderr_lines.len() > MAX_OUTPUT_LINES {
3556        stderr_lines[stderr_lines.len().saturating_sub(OVERFLOW_PREVIEW_LINES)..].join("\n")
3557    } else {
3558        stderr
3559    };
3560
3561    let notice = format!(
3562        "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 {}",
3563        stdout_path.display(),
3564        stderr_path.display(),
3565        stdout_path.display(),
3566    );
3567
3568    (stdout_preview, stderr_preview, Some(notice))
3569}
3570
3571/// Truncates output to a maximum number of lines and bytes.
3572/// Returns (truncated_output, was_truncated).
3573
3574#[derive(Clone)]
3575struct FocusedAnalysisParams {
3576    path: std::path::PathBuf,
3577    symbol: String,
3578    match_mode: SymbolMatchMode,
3579    follow_depth: u32,
3580    max_depth: Option<u32>,
3581    ast_recursion_limit: Option<usize>,
3582    use_summary: bool,
3583    impl_only: Option<bool>,
3584    def_use: bool,
3585}
3586
3587#[tool_handler]
3588impl ServerHandler for CodeAnalyzer {
3589    async fn initialize(
3590        &self,
3591        _request: InitializeRequestParams,
3592        context: RequestContext<RoleServer>,
3593    ) -> Result<InitializeResult, ErrorData> {
3594        // The _meta field is extracted from params and stored in request extensions.
3595        // Extract it and store for use in on_initialized.
3596        if let Some(meta) = context.extensions.get::<Meta>() {
3597            let mut meta_lock = self.profile_meta.lock().await;
3598            *meta_lock = Some(meta.0.clone());
3599        }
3600        Ok(self.get_info())
3601    }
3602
3603    fn get_info(&self) -> InitializeResult {
3604        let excluded = crate::EXCLUDED_DIRS.join(", ");
3605        let instructions = format!(
3606            "Recommended workflow:\n\
3607            1. Start with analyze_directory(path=<repo_root>, max_depth=2, summary=true) to identify source package (largest by file count; exclude {excluded}).\n\
3608            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\
3609            3. For key files, prefer analyze_module for function/import index; use analyze_file for signatures and types.\n\
3610            4. Use analyze_symbol to trace call graphs.\n\
3611            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."
3612        );
3613        let capabilities = ServerCapabilities::builder()
3614            .enable_logging()
3615            .enable_tools()
3616            .enable_tool_list_changed()
3617            .enable_completions()
3618            .build();
3619        let server_info = Implementation::new("aptu-coder", env!("CARGO_PKG_VERSION"))
3620            .with_title("Aptu Coder")
3621            .with_description("MCP server for code structure analysis using tree-sitter");
3622        InitializeResult::new(capabilities)
3623            .with_server_info(server_info)
3624            .with_instructions(&instructions)
3625    }
3626
3627    async fn list_tools(
3628        &self,
3629        _request: Option<rmcp::model::PaginatedRequestParams>,
3630        _context: RequestContext<RoleServer>,
3631    ) -> Result<rmcp::model::ListToolsResult, ErrorData> {
3632        let router = self.tool_router.read().await;
3633        Ok(rmcp::model::ListToolsResult {
3634            tools: router.list_all(),
3635            meta: None,
3636            next_cursor: None,
3637        })
3638    }
3639
3640    async fn call_tool(
3641        &self,
3642        request: rmcp::model::CallToolRequestParams,
3643        context: RequestContext<RoleServer>,
3644    ) -> Result<CallToolResult, ErrorData> {
3645        let tcc = rmcp::handler::server::tool::ToolCallContext::new(self, request, context);
3646        let router = self.tool_router.read().await;
3647        router.call(tcc).await
3648    }
3649
3650    async fn on_initialized(&self, context: NotificationContext<RoleServer>) {
3651        let mut peer_lock = self.peer.lock().await;
3652        *peer_lock = Some(context.peer.clone());
3653        drop(peer_lock);
3654
3655        // Generate session_id in MILLIS-N format
3656        let millis = std::time::SystemTime::now()
3657            .duration_since(std::time::UNIX_EPOCH)
3658            .unwrap_or_default()
3659            .as_millis()
3660            .try_into()
3661            .unwrap_or(u64::MAX);
3662        let counter = GLOBAL_SESSION_COUNTER.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
3663        let sid = format!("{millis}-{counter}");
3664        {
3665            let mut session_id_lock = self.session_id.lock().await;
3666            *session_id_lock = Some(sid);
3667        }
3668        self.session_call_seq
3669            .store(0, std::sync::atomic::Ordering::Relaxed);
3670
3671        // Parse client profile from stored metadata and disable tools accordingly.
3672        // Profiles: "edit" (4 tools), "analyze" (6 tools), absent/unknown (10 tools).
3673        let meta_lock = self.profile_meta.lock().await;
3674        if let Some(meta) = meta_lock.as_ref()
3675            && let Some(profile_val) = meta.get("io.clouatre-labs/profile")
3676            && let Some(profile) = profile_val.as_str()
3677        {
3678            let mut router = self.tool_router.write().await;
3679            match profile {
3680                "edit" => {
3681                    // Enable only: analyze_raw, edit_replace, edit_overwrite, exec_command
3682                    router.disable_route("analyze_directory");
3683                    router.disable_route("analyze_file");
3684                    router.disable_route("analyze_module");
3685                    router.disable_route("analyze_symbol");
3686                    router.disable_route("edit_rename");
3687                    router.disable_route("edit_insert");
3688                }
3689                "analyze" => {
3690                    // Enable only: analyze_directory, analyze_file, analyze_module, analyze_symbol, analyze_raw, exec_command
3691                    router.disable_route("edit_replace");
3692                    router.disable_route("edit_overwrite");
3693                    router.disable_route("edit_rename");
3694                    router.disable_route("edit_insert");
3695                }
3696                _ => {
3697                    // Unknown profile: leave all tools enabled (lenient fallback)
3698                }
3699            }
3700            // Bind peer notifier after disabling tools to send tools/list_changed notification
3701            router.bind_peer_notifier(&context.peer);
3702        }
3703        drop(meta_lock);
3704
3705        // Spawn consumer task to drain log events from channel with batching.
3706        let peer = self.peer.clone();
3707        let event_rx = self.event_rx.clone();
3708
3709        tokio::spawn(async move {
3710            let rx = {
3711                let mut rx_lock = event_rx.lock().await;
3712                rx_lock.take()
3713            };
3714
3715            if let Some(mut receiver) = rx {
3716                let mut buffer = Vec::with_capacity(64);
3717                loop {
3718                    // Drain up to 64 events from channel
3719                    receiver.recv_many(&mut buffer, 64).await;
3720
3721                    if buffer.is_empty() {
3722                        // Channel closed, exit consumer task
3723                        break;
3724                    }
3725
3726                    // Acquire peer lock once per batch
3727                    let peer_lock = peer.lock().await;
3728                    if let Some(peer) = peer_lock.as_ref() {
3729                        for log_event in buffer.drain(..) {
3730                            let notification = ServerNotification::LoggingMessageNotification(
3731                                Notification::new(LoggingMessageNotificationParam {
3732                                    level: log_event.level,
3733                                    logger: Some(log_event.logger),
3734                                    data: log_event.data,
3735                                }),
3736                            );
3737                            if let Err(e) = peer.send_notification(notification).await {
3738                                warn!("Failed to send logging notification: {}", e);
3739                            }
3740                        }
3741                    }
3742                }
3743            }
3744        });
3745    }
3746
3747    #[instrument(skip(self, _context))]
3748    async fn on_cancelled(
3749        &self,
3750        notification: CancelledNotificationParam,
3751        _context: NotificationContext<RoleServer>,
3752    ) {
3753        tracing::info!(
3754            request_id = ?notification.request_id,
3755            reason = ?notification.reason,
3756            "Received cancellation notification"
3757        );
3758    }
3759
3760    #[instrument(skip(self, _context))]
3761    async fn complete(
3762        &self,
3763        request: CompleteRequestParams,
3764        _context: RequestContext<RoleServer>,
3765    ) -> Result<CompleteResult, ErrorData> {
3766        // Dispatch on argument name: "path" or "symbol"
3767        let argument_name = &request.argument.name;
3768        let argument_value = &request.argument.value;
3769
3770        let completions = match argument_name.as_str() {
3771            "path" => {
3772                // Path completions: use current directory as root
3773                let root = Path::new(".");
3774                completion::path_completions(root, argument_value)
3775            }
3776            "symbol" => {
3777                // Symbol completions: need the path argument from context
3778                let path_arg = request
3779                    .context
3780                    .as_ref()
3781                    .and_then(|ctx| ctx.get_argument("path"));
3782
3783                match path_arg {
3784                    Some(path_str) => {
3785                        let path = Path::new(path_str);
3786                        completion::symbol_completions(&self.cache, path, argument_value)
3787                    }
3788                    None => Vec::new(),
3789                }
3790            }
3791            _ => Vec::new(),
3792        };
3793
3794        // Create CompletionInfo with has_more flag if >100 results
3795        let total_count = u32::try_from(completions.len()).unwrap_or(u32::MAX);
3796        let (values, has_more) = if completions.len() > 100 {
3797            (completions.into_iter().take(100).collect(), true)
3798        } else {
3799            (completions, false)
3800        };
3801
3802        let completion_info =
3803            match CompletionInfo::with_pagination(values, Some(total_count), has_more) {
3804                Ok(info) => info,
3805                Err(_) => {
3806                    // Graceful degradation: return empty on error
3807                    CompletionInfo::with_all_values(Vec::new())
3808                        .unwrap_or_else(|_| CompletionInfo::new(Vec::new()).unwrap())
3809                }
3810            };
3811
3812        Ok(CompleteResult::new(completion_info))
3813    }
3814
3815    async fn set_level(
3816        &self,
3817        params: SetLevelRequestParams,
3818        _context: RequestContext<RoleServer>,
3819    ) -> Result<(), ErrorData> {
3820        let level_filter = match params.level {
3821            LoggingLevel::Debug => LevelFilter::DEBUG,
3822            LoggingLevel::Info | LoggingLevel::Notice => LevelFilter::INFO,
3823            LoggingLevel::Warning => LevelFilter::WARN,
3824            LoggingLevel::Error
3825            | LoggingLevel::Critical
3826            | LoggingLevel::Alert
3827            | LoggingLevel::Emergency => LevelFilter::ERROR,
3828        };
3829
3830        let mut filter_lock = self
3831            .log_level_filter
3832            .lock()
3833            .unwrap_or_else(|e| e.into_inner());
3834        *filter_lock = level_filter;
3835        Ok(())
3836    }
3837}
3838
3839#[cfg(test)]
3840mod tests {
3841    use super::*;
3842
3843    #[tokio::test]
3844    async fn test_emit_progress_none_peer_is_noop() {
3845        let peer = Arc::new(TokioMutex::new(None));
3846        let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3847        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3848        let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3849        let analyzer = CodeAnalyzer::new(
3850            peer,
3851            log_level_filter,
3852            rx,
3853            crate::metrics::MetricsSender(metrics_tx),
3854        );
3855        let token = ProgressToken(NumberOrString::String("test".into()));
3856        // Should complete without panic
3857        analyzer
3858            .emit_progress(None, &token, 0.0, 10.0, "test".to_string())
3859            .await;
3860    }
3861
3862    fn make_analyzer() -> CodeAnalyzer {
3863        let peer = Arc::new(TokioMutex::new(None));
3864        let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3865        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3866        let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3867        CodeAnalyzer::new(
3868            peer,
3869            log_level_filter,
3870            rx,
3871            crate::metrics::MetricsSender(metrics_tx),
3872        )
3873    }
3874
3875    #[test]
3876    fn test_summary_cursor_conflict() {
3877        assert!(summary_cursor_conflict(Some(true), Some("cursor")));
3878        assert!(!summary_cursor_conflict(Some(true), None));
3879        assert!(!summary_cursor_conflict(None, Some("x")));
3880        assert!(!summary_cursor_conflict(None, None));
3881    }
3882
3883    #[tokio::test]
3884    async fn test_validate_impl_only_non_rust_returns_invalid_params() {
3885        use tempfile::TempDir;
3886
3887        let dir = TempDir::new().unwrap();
3888        std::fs::write(dir.path().join("main.py"), "def foo(): pass").unwrap();
3889
3890        let analyzer = make_analyzer();
3891        // Call analyze_symbol with impl_only=true on a Python-only directory via the tool API.
3892        // We use handle_focused_mode which calls validate_impl_only internally.
3893        let entries: Vec<traversal::WalkEntry> =
3894            traversal::walk_directory(dir.path(), None).unwrap_or_default();
3895        let result = CodeAnalyzer::validate_impl_only(&entries);
3896        assert!(result.is_err());
3897        let err = result.unwrap_err();
3898        assert_eq!(err.code, rmcp::model::ErrorCode::INVALID_PARAMS);
3899        drop(analyzer); // ensure it compiles with analyzer in scope
3900    }
3901
3902    #[tokio::test]
3903    async fn test_no_cache_meta_on_analyze_directory_result() {
3904        use aptu_coder_core::types::{
3905            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3906        };
3907        use tempfile::TempDir;
3908
3909        let dir = TempDir::new().unwrap();
3910        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
3911
3912        let analyzer = make_analyzer();
3913        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3914            "path": dir.path().to_str().unwrap(),
3915        }))
3916        .unwrap();
3917        let ct = tokio_util::sync::CancellationToken::new();
3918        let (arc_output, _cache_hit) = analyzer.handle_overview_mode(&params, ct).await.unwrap();
3919        // Verify the no_cache_meta shape by constructing it directly and checking the shape
3920        let meta = no_cache_meta();
3921        assert_eq!(
3922            meta.0.get("cache_hint").and_then(|v| v.as_str()),
3923            Some("no-cache"),
3924        );
3925        drop(arc_output);
3926    }
3927
3928    #[test]
3929    fn test_complete_path_completions_returns_suggestions() {
3930        // Test the underlying completion function (same code path as complete()) directly
3931        // to avoid needing a constructed RequestContext<RoleServer>.
3932        // CARGO_MANIFEST_DIR is <workspace>/aptu-coder; parent is the workspace root,
3933        // which contains aptu-coder-core/ and aptu-coder/ matching the "aptu-" prefix.
3934        let manifest_dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
3935        let workspace_root = manifest_dir.parent().expect("manifest dir has parent");
3936        let suggestions = completion::path_completions(workspace_root, "aptu-");
3937        assert!(
3938            !suggestions.is_empty(),
3939            "expected completions for prefix 'aptu-' in workspace root"
3940        );
3941    }
3942
3943    #[tokio::test]
3944    async fn test_handle_overview_mode_verbose_no_summary_block() {
3945        use aptu_coder_core::pagination::{PaginationMode, paginate_slice};
3946        use aptu_coder_core::types::{
3947            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
3948        };
3949        use tempfile::TempDir;
3950
3951        let tmp = TempDir::new().unwrap();
3952        std::fs::write(tmp.path().join("main.rs"), "fn main() {}").unwrap();
3953
3954        let peer = Arc::new(TokioMutex::new(None));
3955        let log_level_filter = Arc::new(Mutex::new(LevelFilter::INFO));
3956        let (_tx, rx) = tokio::sync::mpsc::unbounded_channel();
3957        let (metrics_tx, _metrics_rx) = tokio::sync::mpsc::unbounded_channel();
3958        let analyzer = CodeAnalyzer::new(
3959            peer,
3960            log_level_filter,
3961            rx,
3962            crate::metrics::MetricsSender(metrics_tx),
3963        );
3964
3965        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
3966            "path": tmp.path().to_str().unwrap(),
3967            "verbose": true,
3968        }))
3969        .unwrap();
3970
3971        let ct = tokio_util::sync::CancellationToken::new();
3972        let (output, _cache_hit) = analyzer.handle_overview_mode(&params, ct).await.unwrap();
3973
3974        // Replicate the handler's formatting path (the fix site)
3975        let use_summary = output.formatted.len() > SIZE_LIMIT; // summary=None, force=None, small output
3976        let paginated =
3977            paginate_slice(&output.files, 0, DEFAULT_PAGE_SIZE, PaginationMode::Default).unwrap();
3978        let verbose = true;
3979        let formatted = if !use_summary {
3980            format_structure_paginated(
3981                &paginated.items,
3982                paginated.total,
3983                params.max_depth,
3984                Some(std::path::Path::new(&params.path)),
3985                verbose,
3986            )
3987        } else {
3988            output.formatted.clone()
3989        };
3990
3991        // After the fix: verbose=true must not emit the SUMMARY: block
3992        assert!(
3993            !formatted.contains("SUMMARY:"),
3994            "verbose=true must not emit SUMMARY: block; got: {}",
3995            &formatted[..formatted.len().min(300)]
3996        );
3997        assert!(
3998            formatted.contains("PAGINATED:"),
3999            "verbose=true must emit PAGINATED: header"
4000        );
4001        assert!(
4002            formatted.contains("FILES [LOC, FUNCTIONS, CLASSES]"),
4003            "verbose=true must emit FILES section header"
4004        );
4005    }
4006
4007    // --- cache_hit integration tests ---
4008
4009    #[tokio::test]
4010    async fn test_analyze_directory_cache_hit_metrics() {
4011        use aptu_coder_core::types::{
4012            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4013        };
4014        use tempfile::TempDir;
4015
4016        // Arrange: a temp dir with one file
4017        let dir = TempDir::new().unwrap();
4018        std::fs::write(dir.path().join("lib.rs"), "fn foo() {}").unwrap();
4019        let analyzer = make_analyzer();
4020        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4021            "path": dir.path().to_str().unwrap(),
4022        }))
4023        .unwrap();
4024
4025        // Act: first call (cache miss)
4026        let ct1 = tokio_util::sync::CancellationToken::new();
4027        let (_out1, hit1) = analyzer.handle_overview_mode(&params, ct1).await.unwrap();
4028
4029        // Act: second call (cache hit)
4030        let ct2 = tokio_util::sync::CancellationToken::new();
4031        let (_out2, hit2) = analyzer.handle_overview_mode(&params, ct2).await.unwrap();
4032
4033        // Assert
4034        assert!(!hit1, "first call must be a cache miss");
4035        assert!(hit2, "second call must be a cache hit");
4036    }
4037
4038    #[tokio::test]
4039    async fn test_analyze_module_cache_hit_metrics() {
4040        use std::io::Write as _;
4041        use tempfile::NamedTempFile;
4042
4043        // Arrange: create a temp Rust file; prime the file cache via analyze_file handler
4044        let mut f = NamedTempFile::with_suffix(".rs").unwrap();
4045        writeln!(f, "fn bar() {{}}").unwrap();
4046        let path = f.path().to_str().unwrap().to_string();
4047
4048        let analyzer = make_analyzer();
4049
4050        // Prime the file cache by calling handle_file_details_mode once
4051        let mut file_params = aptu_coder_core::types::AnalyzeFileParams::default();
4052        file_params.path = path.clone();
4053        file_params.ast_recursion_limit = None;
4054        file_params.fields = None;
4055        file_params.pagination.cursor = None;
4056        file_params.pagination.page_size = None;
4057        file_params.output_control.summary = None;
4058        file_params.output_control.force = None;
4059        file_params.output_control.verbose = None;
4060        let (_cached, _) = analyzer
4061            .handle_file_details_mode(&file_params)
4062            .await
4063            .unwrap();
4064
4065        // Act: now call analyze_module; the cache key is mtime-based so same file = hit
4066        let mut module_params = aptu_coder_core::types::AnalyzeModuleParams::default();
4067        module_params.path = path.clone();
4068
4069        // Replicate the cache lookup the handler does (no public method; test via build path)
4070        let module_cache_key = std::fs::metadata(&path).ok().and_then(|meta| {
4071            meta.modified()
4072                .ok()
4073                .map(|mtime| aptu_coder_core::cache::CacheKey {
4074                    path: std::path::PathBuf::from(&path),
4075                    modified: mtime,
4076                    mode: aptu_coder_core::types::AnalysisMode::FileDetails,
4077                })
4078        });
4079        let cache_hit = module_cache_key
4080            .as_ref()
4081            .and_then(|k| analyzer.cache.get(k))
4082            .is_some();
4083
4084        // Assert: the file cache must have been populated by the earlier handle_file_details_mode call
4085        assert!(
4086            cache_hit,
4087            "analyze_module should find the file in the shared file cache"
4088        );
4089        drop(module_params);
4090    }
4091
4092    // --- import_lookup tests ---
4093
4094    #[test]
4095    fn test_analyze_symbol_import_lookup_invalid_params() {
4096        // Arrange: empty symbol with import_lookup=true (violates the guard:
4097        // symbol must hold the module path when import_lookup=true).
4098        // Act: call the validate helper directly (same pattern as validate_impl_only).
4099        let result = CodeAnalyzer::validate_import_lookup(Some(true), "");
4100
4101        // Assert: INVALID_PARAMS is returned.
4102        assert!(
4103            result.is_err(),
4104            "import_lookup=true with empty symbol must return Err"
4105        );
4106        let err = result.unwrap_err();
4107        assert_eq!(
4108            err.code,
4109            rmcp::model::ErrorCode::INVALID_PARAMS,
4110            "expected INVALID_PARAMS; got {:?}",
4111            err.code
4112        );
4113    }
4114
4115    #[tokio::test]
4116    async fn test_analyze_symbol_import_lookup_found() {
4117        use tempfile::TempDir;
4118
4119        // Arrange: a Rust file that imports "std::collections"
4120        let dir = TempDir::new().unwrap();
4121        std::fs::write(
4122            dir.path().join("main.rs"),
4123            "use std::collections::HashMap;\nfn main() {}\n",
4124        )
4125        .unwrap();
4126
4127        let entries = traversal::walk_directory(dir.path(), None).unwrap();
4128
4129        // Act: search for the module "std::collections"
4130        let output =
4131            analyze::analyze_import_lookup(dir.path(), "std::collections", &entries, None).unwrap();
4132
4133        // Assert: one match found
4134        assert!(
4135            output.formatted.contains("MATCHES: 1"),
4136            "expected 1 match; got: {}",
4137            output.formatted
4138        );
4139        assert!(
4140            output.formatted.contains("main.rs"),
4141            "expected main.rs in output; got: {}",
4142            output.formatted
4143        );
4144    }
4145
4146    #[tokio::test]
4147    async fn test_analyze_symbol_import_lookup_empty() {
4148        use tempfile::TempDir;
4149
4150        // Arrange: a Rust file that does NOT import "no_such_module"
4151        let dir = TempDir::new().unwrap();
4152        std::fs::write(dir.path().join("main.rs"), "fn main() {}\n").unwrap();
4153
4154        let entries = traversal::walk_directory(dir.path(), None).unwrap();
4155
4156        // Act
4157        let output =
4158            analyze::analyze_import_lookup(dir.path(), "no_such_module", &entries, None).unwrap();
4159
4160        // Assert: zero matches
4161        assert!(
4162            output.formatted.contains("MATCHES: 0"),
4163            "expected 0 matches; got: {}",
4164            output.formatted
4165        );
4166    }
4167
4168    // --- git_ref tests ---
4169
4170    #[tokio::test]
4171    async fn test_analyze_directory_git_ref_non_git_repo() {
4172        use aptu_coder_core::traversal::changed_files_from_git_ref;
4173        use tempfile::TempDir;
4174
4175        // Arrange: a temp dir that is NOT a git repository
4176        let dir = TempDir::new().unwrap();
4177        std::fs::write(dir.path().join("main.rs"), "fn main() {}").unwrap();
4178
4179        // Act: attempt git_ref resolution in a non-git dir
4180        let result = changed_files_from_git_ref(dir.path(), "HEAD~1");
4181
4182        // Assert: must return a GitError
4183        assert!(result.is_err(), "non-git dir must return an error");
4184        let err_msg = result.unwrap_err().to_string();
4185        assert!(
4186            err_msg.contains("git"),
4187            "error must mention git; got: {err_msg}"
4188        );
4189    }
4190
4191    #[tokio::test]
4192    async fn test_analyze_directory_git_ref_filters_changed_files() {
4193        use aptu_coder_core::traversal::{changed_files_from_git_ref, filter_entries_by_git_ref};
4194        use std::collections::HashSet;
4195        use tempfile::TempDir;
4196
4197        // Arrange: build a set of fake "changed" paths and a walk entry list
4198        let dir = TempDir::new().unwrap();
4199        let changed_file = dir.path().join("changed.rs");
4200        let unchanged_file = dir.path().join("unchanged.rs");
4201        std::fs::write(&changed_file, "fn changed() {}").unwrap();
4202        std::fs::write(&unchanged_file, "fn unchanged() {}").unwrap();
4203
4204        let entries = traversal::walk_directory(dir.path(), None).unwrap();
4205        let total_files = entries.iter().filter(|e| !e.is_dir).count();
4206        assert_eq!(total_files, 2, "sanity: 2 files before filtering");
4207
4208        // Simulate: only changed.rs is in the changed set
4209        let mut changed: HashSet<std::path::PathBuf> = HashSet::new();
4210        changed.insert(changed_file.clone());
4211
4212        // Act: filter entries
4213        let filtered = filter_entries_by_git_ref(entries, &changed, dir.path());
4214        let filtered_files: Vec<_> = filtered.iter().filter(|e| !e.is_dir).collect();
4215
4216        // Assert: only changed.rs remains
4217        assert_eq!(
4218            filtered_files.len(),
4219            1,
4220            "only 1 file must remain after git_ref filter"
4221        );
4222        assert_eq!(
4223            filtered_files[0].path, changed_file,
4224            "the remaining file must be the changed one"
4225        );
4226
4227        // Verify changed_files_from_git_ref is at least callable (tested separately for non-git error)
4228        let _ = changed_files_from_git_ref;
4229    }
4230
4231    #[tokio::test]
4232    async fn test_handle_overview_mode_git_ref_filters_via_handler() {
4233        use aptu_coder_core::types::{
4234            AnalyzeDirectoryParams, OutputControlParams, PaginationParams,
4235        };
4236        use std::process::Command;
4237        use tempfile::TempDir;
4238
4239        // Arrange: create a real git repo with two commits.
4240        let dir = TempDir::new().unwrap();
4241        let repo = dir.path();
4242
4243        // Init repo and configure minimal identity so git commit works.
4244        // Use no-hooks to avoid project-local commit hooks that enforce email allowlists.
4245        let git_no_hook = |repo_path: &std::path::Path, args: &[&str]| {
4246            let mut cmd = std::process::Command::new("git");
4247            cmd.args(["-c", "core.hooksPath=/dev/null"]);
4248            cmd.args(args);
4249            cmd.current_dir(repo_path);
4250            let out = cmd.output().unwrap();
4251            assert!(out.status.success(), "{out:?}");
4252        };
4253        git_no_hook(repo, &["init"]);
4254        git_no_hook(
4255            repo,
4256            &[
4257                "-c",
4258                "user.email=ci@example.com",
4259                "-c",
4260                "user.name=CI",
4261                "commit",
4262                "--allow-empty",
4263                "-m",
4264                "initial",
4265            ],
4266        );
4267
4268        // Commit file_a.rs in the first commit.
4269        std::fs::write(repo.join("file_a.rs"), "fn a() {}").unwrap();
4270        git_no_hook(repo, &["add", "file_a.rs"]);
4271        git_no_hook(
4272            repo,
4273            &[
4274                "-c",
4275                "user.email=ci@example.com",
4276                "-c",
4277                "user.name=CI",
4278                "commit",
4279                "-m",
4280                "add a",
4281            ],
4282        );
4283
4284        // Add file_b.rs in a second commit (this is what HEAD changes relative to HEAD~1).
4285        std::fs::write(repo.join("file_b.rs"), "fn b() {}").unwrap();
4286        git_no_hook(repo, &["add", "file_b.rs"]);
4287        git_no_hook(
4288            repo,
4289            &[
4290                "-c",
4291                "user.email=ci@example.com",
4292                "-c",
4293                "user.name=CI",
4294                "commit",
4295                "-m",
4296                "add b",
4297            ],
4298        );
4299
4300        // Act: call handle_overview_mode with git_ref=HEAD~1.
4301        // `git diff --name-only HEAD~1` compares working tree against HEAD~1, returning
4302        // only file_b.rs (added in the last commit, so present in working tree but not in HEAD~1).
4303        // Use the canonical path so walk entries match what `git rev-parse --show-toplevel` returns
4304        // (macOS /tmp is a symlink to /private/tmp; without canonicalization paths would differ).
4305        let canon_repo = std::fs::canonicalize(repo).unwrap();
4306        let analyzer = make_analyzer();
4307        let params: AnalyzeDirectoryParams = serde_json::from_value(serde_json::json!({
4308            "path": canon_repo.to_str().unwrap(),
4309            "git_ref": "HEAD~1",
4310        }))
4311        .unwrap();
4312        let ct = tokio_util::sync::CancellationToken::new();
4313        let (arc_output, _cache_hit) = analyzer
4314            .handle_overview_mode(&params, ct)
4315            .await
4316            .expect("handle_overview_mode with git_ref must succeed");
4317
4318        // Assert: only file_b.rs (changed since HEAD~1) appears; file_a.rs must be absent.
4319        let formatted = &arc_output.formatted;
4320        assert!(
4321            formatted.contains("file_b.rs"),
4322            "git_ref=HEAD~1 output must include file_b.rs; got:\n{formatted}"
4323        );
4324        assert!(
4325            !formatted.contains("file_a.rs"),
4326            "git_ref=HEAD~1 output must exclude file_a.rs; got:\n{formatted}"
4327        );
4328    }
4329
4330    #[test]
4331    fn test_validate_path_rejects_absolute_path_outside_cwd() {
4332        // S4: Verify that absolute paths outside the current working directory are rejected.
4333        // This test directly calls validate_path with /etc/passwd, which should fail.
4334        let result = validate_path("/etc/passwd", true);
4335        assert!(
4336            result.is_err(),
4337            "validate_path should reject /etc/passwd (outside CWD)"
4338        );
4339        let err = result.unwrap_err();
4340        let err_msg = err.message.to_lowercase();
4341        assert!(
4342            err_msg.contains("outside") || err_msg.contains("not found"),
4343            "Error message should mention 'outside' or 'not found': {}",
4344            err.message
4345        );
4346    }
4347
4348    #[test]
4349    fn test_validate_path_accepts_relative_path_in_cwd() {
4350        // Happy path: relative path within CWD should be accepted.
4351        // Use Cargo.toml which exists in the crate root.
4352        let result = validate_path("Cargo.toml", true);
4353        assert!(
4354            result.is_ok(),
4355            "validate_path should accept Cargo.toml (exists in CWD)"
4356        );
4357    }
4358
4359    #[test]
4360    fn test_validate_path_creates_parent_for_nonexistent_file() {
4361        // Edge case: non-existent file with non-existent parent should still be accepted
4362        // if the ancestor chain leads back to CWD.
4363        let result = validate_path("nonexistent_dir/nonexistent_file.txt", false);
4364        assert!(
4365            result.is_ok(),
4366            "validate_path should accept non-existent file with non-existent parent (require_exists=false)"
4367        );
4368        let path = result.unwrap();
4369        let cwd = std::env::current_dir().expect("should get cwd");
4370        let canonical_cwd = std::fs::canonicalize(&cwd).unwrap_or(cwd);
4371        assert!(
4372            path.starts_with(&canonical_cwd),
4373            "Resolved path should be within CWD: {:?} should start with {:?}",
4374            path,
4375            canonical_cwd
4376        );
4377    }
4378
4379    #[test]
4380    fn test_tool_annotations() {
4381        // Arrange: get tool list via static method
4382        let tools = CodeAnalyzer::list_tools();
4383
4384        // Act: find specific tools by name
4385        let edit_rename = tools.iter().find(|t| t.name == "edit_rename");
4386        let analyze_directory = tools.iter().find(|t| t.name == "analyze_directory");
4387        let exec_command = tools.iter().find(|t| t.name == "exec_command");
4388
4389        // Assert: edit_rename has correct annotations
4390        let edit_rename_tool = edit_rename.expect("edit_rename tool should exist");
4391        let edit_rename_annot = edit_rename_tool
4392            .annotations
4393            .as_ref()
4394            .expect("edit_rename should have annotations");
4395        assert_eq!(
4396            edit_rename_annot.destructive_hint,
4397            Some(false),
4398            "edit_rename destructive_hint should be false"
4399        );
4400        assert_eq!(
4401            edit_rename_annot.idempotent_hint,
4402            Some(true),
4403            "edit_rename idempotent_hint should be true"
4404        );
4405
4406        // Assert: analyze_directory has correct annotations
4407        let analyze_dir_tool = analyze_directory.expect("analyze_directory tool should exist");
4408        let analyze_dir_annot = analyze_dir_tool
4409            .annotations
4410            .as_ref()
4411            .expect("analyze_directory should have annotations");
4412        assert_eq!(
4413            analyze_dir_annot.read_only_hint,
4414            Some(true),
4415            "analyze_directory read_only_hint should be true"
4416        );
4417        assert_eq!(
4418            analyze_dir_annot.destructive_hint,
4419            Some(false),
4420            "analyze_directory destructive_hint should be false"
4421        );
4422
4423        // Assert: exec_command has correct annotations
4424        let exec_cmd_tool = exec_command.expect("exec_command tool should exist");
4425        let exec_cmd_annot = exec_cmd_tool
4426            .annotations
4427            .as_ref()
4428            .expect("exec_command should have annotations");
4429        assert_eq!(
4430            exec_cmd_annot.open_world_hint,
4431            Some(true),
4432            "exec_command open_world_hint should be true"
4433        );
4434    }
4435
4436    #[test]
4437    fn test_exec_stdin_size_cap_validation() {
4438        // Test: stdin size cap check (1 MB limit)
4439        // Arrange: create oversized stdin
4440        let oversized_stdin = "x".repeat(STDIN_MAX_BYTES + 1);
4441
4442        // Act & Assert: verify size exceeds limit
4443        assert!(
4444            oversized_stdin.len() > STDIN_MAX_BYTES,
4445            "test setup: oversized stdin should exceed 1 MB"
4446        );
4447
4448        // Verify that a 1 MB stdin is accepted
4449        let max_stdin = "y".repeat(STDIN_MAX_BYTES);
4450        assert_eq!(
4451            max_stdin.len(),
4452            STDIN_MAX_BYTES,
4453            "test setup: max stdin should be exactly 1 MB"
4454        );
4455    }
4456
4457    #[tokio::test]
4458    async fn test_exec_stdin_cat_roundtrip() {
4459        // Test: stdin content is piped to process and readable via stdout
4460        // Arrange: prepare stdin content
4461        let stdin_content = "hello world";
4462
4463        // Act: execute cat with stdin via shell
4464        let mut child = tokio::process::Command::new("sh")
4465            .arg("-c")
4466            .arg("cat")
4467            .stdin(std::process::Stdio::piped())
4468            .stdout(std::process::Stdio::piped())
4469            .stderr(std::process::Stdio::piped())
4470            .spawn()
4471            .expect("spawn cat");
4472
4473        if let Some(mut stdin_handle) = child.stdin.take() {
4474            use tokio::io::AsyncWriteExt as _;
4475            stdin_handle
4476                .write_all(stdin_content.as_bytes())
4477                .await
4478                .expect("write stdin");
4479            drop(stdin_handle);
4480        }
4481
4482        let output = child.wait_with_output().await.expect("wait for cat");
4483
4484        // Assert: stdout contains the piped stdin content
4485        let stdout_str = String::from_utf8_lossy(&output.stdout);
4486        assert!(
4487            stdout_str.contains(stdin_content),
4488            "stdout should contain stdin content: {}",
4489            stdout_str
4490        );
4491    }
4492
4493    #[tokio::test]
4494    async fn test_exec_stdin_none_no_regression() {
4495        // Test: command without stdin executes normally (no regression)
4496        // Act: execute echo without stdin
4497        let child = tokio::process::Command::new("sh")
4498            .arg("-c")
4499            .arg("echo hi")
4500            .stdin(std::process::Stdio::null())
4501            .stdout(std::process::Stdio::piped())
4502            .stderr(std::process::Stdio::piped())
4503            .spawn()
4504            .expect("spawn echo");
4505
4506        let output = child.wait_with_output().await.expect("wait for echo");
4507
4508        // Assert: command executes successfully
4509        let stdout_str = String::from_utf8_lossy(&output.stdout);
4510        assert!(
4511            stdout_str.contains("hi"),
4512            "stdout should contain echo output: {}",
4513            stdout_str
4514        );
4515    }
4516}