Skip to main content

aptu_coder_core/
analyze.rs

1// SPDX-FileCopyrightText: 2026 aptu-coder contributors
2// SPDX-License-Identifier: Apache-2.0
3//! Main analysis engine for extracting code structure from files and directories.
4//!
5//! Implements the four MCP tools: `analyze_directory` (Overview), `analyze_file` (`FileDetails`),
6//! `analyze_symbol` (call graph), and `analyze_module` (lightweight index). Handles parallel processing and cancellation.
7
8use crate::formatter::{format_file_details, format_structure};
9use crate::graph::InternalCallChain;
10use crate::lang::{language_for_extension, supported_languages};
11use crate::parser::{ElementExtractor, SemanticExtractor};
12use crate::test_detection::is_test_file;
13use crate::traversal::{WalkEntry, walk_directory};
14use crate::types::{AnalysisMode, FileInfo, SemanticAnalysis, SymbolMatchMode};
15use rayon::prelude::*;
16#[cfg(feature = "schemars")]
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::sync::atomic::{AtomicUsize, Ordering};
22use std::time::Instant;
23use thiserror::Error;
24use tokio_util::sync::CancellationToken;
25use tracing::instrument;
26
27pub const MAX_FILE_SIZE_BYTES: u64 = 10_000_000;
28
29#[derive(Debug, Error)]
30#[non_exhaustive]
31pub enum AnalyzeError {
32    #[error("Traversal error: {0}")]
33    Traversal(#[from] crate::traversal::TraversalError),
34    #[error("Parser error: {0}")]
35    Parser(#[from] crate::parser::ParserError),
36    #[error("Graph error: {0}")]
37    Graph(#[from] crate::graph::GraphError),
38    #[error("Formatter error: {0}")]
39    Formatter(#[from] crate::formatter::FormatterError),
40    #[error("Analysis cancelled")]
41    Cancelled,
42    #[error("unsupported language: {0}")]
43    UnsupportedLanguage(String),
44    #[error("I/O error: {0}")]
45    Io(#[from] std::io::Error),
46    #[error("invalid range: start ({start}) > end ({end}); file has {total} lines")]
47    InvalidRange {
48        start: usize,
49        end: usize,
50        total: usize,
51    },
52    #[error("path is a directory, not a file: {0}")]
53    NotAFile(PathBuf),
54    #[error(
55        "file has {total_lines} lines; provide start_line and end_line, or call analyze_module first to locate the range"
56    )]
57    RangelessLargeFile { total_lines: usize },
58    #[error("parse timeout exceeded for {path}: {micros} microseconds")]
59    ParseTimeout { path: PathBuf, micros: u64 },
60}
61
62/// Result of directory analysis containing both formatted output and file data.
63#[derive(Debug, Clone, Serialize, Deserialize)]
64#[cfg_attr(feature = "schemars", derive(JsonSchema))]
65#[non_exhaustive]
66pub struct AnalysisOutput {
67    #[cfg_attr(
68        feature = "schemars",
69        schemars(description = "Formatted text representation of the analysis")
70    )]
71    pub formatted: String,
72    #[cfg_attr(
73        feature = "schemars",
74        schemars(description = "List of files analyzed in the directory")
75    )]
76    pub files: Vec<FileInfo>,
77    /// Walk entries used internally for summary generation; not serialized.
78    #[serde(skip)]
79    #[serde(default)]
80    #[cfg_attr(feature = "schemars", schemars(skip))]
81    pub entries: Vec<WalkEntry>,
82    /// Subtree file counts computed from an unbounded walk; used by `format_summary`; not serialized.
83    #[serde(skip)]
84    #[serde(default)]
85    #[cfg_attr(feature = "schemars", schemars(skip))]
86    pub subtree_counts: Option<Vec<(std::path::PathBuf, usize)>>,
87    #[serde(skip_serializing_if = "Option::is_none")]
88    #[cfg_attr(
89        feature = "schemars",
90        schemars(
91            description = "Opaque cursor token for the next page of results (absent when no more results)"
92        )
93    )]
94    pub next_cursor: Option<String>,
95}
96
97/// Result of file-level semantic analysis.
98#[derive(Debug, Clone, Serialize, Deserialize)]
99#[cfg_attr(feature = "schemars", derive(JsonSchema))]
100#[non_exhaustive]
101pub struct FileAnalysisOutput {
102    #[cfg_attr(
103        feature = "schemars",
104        schemars(description = "Formatted text representation of the analysis")
105    )]
106    pub formatted: String,
107    #[cfg_attr(
108        feature = "schemars",
109        schemars(description = "Semantic analysis data including functions, classes, and imports")
110    )]
111    pub semantic: SemanticAnalysis,
112    #[cfg_attr(
113        feature = "schemars",
114        schemars(description = "Total line count of the analyzed file")
115    )]
116    #[cfg_attr(
117        feature = "schemars",
118        schemars(schema_with = "crate::schema_helpers::integer_schema")
119    )]
120    pub line_count: usize,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    #[cfg_attr(
123        feature = "schemars",
124        schemars(
125            description = "Opaque cursor token for the next page of results (absent when no more results)"
126        )
127    )]
128    pub next_cursor: Option<String>,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    #[cfg_attr(
131        feature = "schemars",
132        schemars(
133            description = "True when the file extension is not supported; semantic fields are empty and formatted contains a raw preview"
134        )
135    )]
136    pub unsupported: Option<bool>,
137}
138
139impl FileAnalysisOutput {
140    /// Create a new `FileAnalysisOutput`.
141    #[must_use]
142    pub fn new(
143        formatted: String,
144        semantic: SemanticAnalysis,
145        line_count: usize,
146        next_cursor: Option<String>,
147    ) -> Self {
148        Self {
149            formatted,
150            semantic,
151            line_count,
152            next_cursor,
153            unsupported: None,
154        }
155    }
156}
157/// Reason a file was skipped during eligibility check.
158#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159enum SkipReason {
160    Oversized,
161    Unreadable,
162}
163
164/// Check if a file is eligible for analysis based on size and readability.
165///
166/// Returns `Ok(content)` when the file should be analyzed, `Err(reason)` to skip it.
167fn check_file_eligibility(entry: &WalkEntry) -> Result<String, SkipReason> {
168    // Check file size before reading
169    if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
170        tracing::debug!("skipping large file: {}", entry.path.display());
171        return Err(SkipReason::Oversized);
172    }
173
174    // Try to read file content; skip binary or unreadable files
175    std::fs::read_to_string(&entry.path).map_err(|_| SkipReason::Unreadable)
176}
177
178/// Process a single file entry and extract its analysis data.
179fn process_file_entry(entry: &WalkEntry, source: &str) -> FileInfo {
180    let path_str = entry.path.display().to_string();
181    let line_count = source.lines().count();
182
183    // Detect language from extension
184    let ext = entry.path.extension().and_then(|e| e.to_str());
185
186    // Detect language and extract counts
187    let (language, function_count, class_count) = if let Some(ext_str) = ext
188        && let Some(lang) = language_for_extension(ext_str)
189    {
190        let lang_str = lang.to_string();
191        match ElementExtractor::extract_with_depth(source, &lang_str) {
192            Ok((func_count, class_count)) => (lang_str, func_count, class_count),
193            Err(_) => (lang_str, 0, 0),
194        }
195    } else {
196        (
197            ext.map(|e| e.to_lowercase())
198                .unwrap_or_else(|| "unknown".to_string()),
199            0,
200            0,
201        )
202    };
203
204    let is_test = is_test_file(&entry.path);
205
206    FileInfo {
207        path: path_str,
208        line_count,
209        function_count,
210        class_count,
211        language,
212        is_test,
213    }
214}
215
216/// Analyze a single file entry in parallel context.
217fn analyze_single_file(
218    entry: &WalkEntry,
219    progress: &Arc<AtomicUsize>,
220    ct: &CancellationToken,
221) -> Option<FileInfo> {
222    // Check cancellation per file
223    if ct.is_cancelled() {
224        return None;
225    }
226
227    // Check file eligibility; progress accounting happens on all exit paths below
228    let source = match check_file_eligibility(entry) {
229        Ok(content) => content,
230        Err(_) => {
231            progress.fetch_add(1, Ordering::Relaxed);
232            return None;
233        }
234    };
235
236    let file_info = process_file_entry(entry, &source);
237    progress.fetch_add(1, Ordering::Relaxed);
238
239    Some(file_info)
240}
241
242/// Initialize analysis context and collect file entries.
243fn init_analysis_context(entries: &[WalkEntry]) -> Vec<&WalkEntry> {
244    entries
245        .iter()
246        .filter(|e| !e.is_dir && !e.is_symlink)
247        .collect()
248}
249
250/// Build the final analysis output from results.
251fn build_analysis_output(
252    entries: Vec<WalkEntry>,
253    analysis_results: Vec<FileInfo>,
254) -> AnalysisOutput {
255    let formatted = format_structure(&entries, &analysis_results, None);
256    AnalysisOutput {
257        formatted,
258        files: analysis_results,
259        entries,
260        next_cursor: None,
261        subtree_counts: None,
262    }
263}
264
265/// Run parallel analysis on file entries and log completion.
266fn run_parallel_analysis(
267    file_entries: &[&WalkEntry],
268    progress: &Arc<AtomicUsize>,
269    ct: &CancellationToken,
270) -> Result<Vec<FileInfo>, AnalyzeError> {
271    let start = Instant::now();
272    tracing::debug!(file_count = file_entries.len(), "analysis start");
273
274    let _parse_span = tracing::info_span!("ast.parse_batch", count = file_entries.len()).entered();
275
276    // Parallel analysis of files
277    let analysis_results: Vec<FileInfo> = file_entries
278        .par_iter()
279        .filter_map(|entry| analyze_single_file(entry, progress, ct))
280        .collect();
281
282    // Check if cancelled after parallel processing
283    if ct.is_cancelled() {
284        return Err(AnalyzeError::Cancelled);
285    }
286
287    tracing::debug!(
288        file_count = file_entries.len(),
289        duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
290        "analysis complete"
291    );
292
293    Ok(analysis_results)
294}
295
296#[instrument(skip_all, fields(path = %root.display()))]
297// public API; callers expect owned semantics
298#[allow(clippy::needless_pass_by_value)]
299pub fn analyze_directory_with_progress(
300    root: &Path,
301    entries: Vec<WalkEntry>,
302    progress: Arc<AtomicUsize>,
303    ct: CancellationToken,
304) -> Result<AnalysisOutput, AnalyzeError> {
305    // Check if already cancelled
306    if ct.is_cancelled() {
307        return Err(AnalyzeError::Cancelled);
308    }
309
310    tracing::debug!(root = %root.display(), "analysis start");
311
312    let file_entries = init_analysis_context(&entries);
313    let analysis_results = run_parallel_analysis(&file_entries, &progress, &ct)?;
314
315    let _format_span = tracing::info_span!("output.format").entered();
316
317    // Build and return output
318    Ok(build_analysis_output(entries, analysis_results))
319}
320
321/// Analyze a directory structure and return formatted output and file data.
322#[instrument(skip_all, fields(path = %root.display()))]
323pub fn analyze_directory(
324    root: &Path,
325    max_depth: Option<u32>,
326) -> Result<AnalysisOutput, AnalyzeError> {
327    let entries = walk_directory(root, max_depth)?;
328    let counter = Arc::new(AtomicUsize::new(0));
329    let ct = CancellationToken::new();
330    analyze_directory_with_progress(root, entries, counter, ct)
331}
332
333/// Determine analysis mode based on parameters and path.
334#[must_use]
335pub fn determine_mode(path: &str, focus: Option<&str>) -> AnalysisMode {
336    if focus.is_some() {
337        return AnalysisMode::SymbolFocus;
338    }
339
340    let path_obj = Path::new(path);
341    if path_obj.is_dir() {
342        AnalysisMode::Overview
343    } else {
344        AnalysisMode::FileDetails
345    }
346}
347
348/// Analyze a single file and return semantic analysis with formatted output.
349#[instrument(skip_all, fields(path))]
350pub fn analyze_file(
351    path: &str,
352    ast_recursion_limit: Option<usize>,
353) -> Result<FileAnalysisOutput, AnalyzeError> {
354    let start = Instant::now();
355
356    // Check file size before reading
357    if Path::new(path).metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
358        tracing::debug!("skipping large file: {}", path);
359        return Err(AnalyzeError::Parser(
360            crate::parser::ParserError::ParseError("file too large".to_string()),
361        ));
362    }
363
364    let source = std::fs::read_to_string(path)
365        .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
366
367    let line_count = source.lines().count();
368
369    // Detect language from extension
370    let ext = Path::new(path)
371        .extension()
372        .and_then(|e| e.to_str())
373        .and_then(language_for_extension)
374        .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string);
375
376    // Extract semantic information
377    let mut semantic = SemanticExtractor::extract(&source, &ext, ast_recursion_limit, None)?;
378
379    // Populate the file path on references now that the path is known
380    for r in &mut semantic.references {
381        r.location = path.to_string();
382    }
383
384    // Resolve Python wildcard imports
385    if ext == "python" {
386        resolve_wildcard_imports(Path::new(path), &mut semantic.imports);
387    }
388
389    // Detect if this is a test file
390    let is_test = is_test_file(Path::new(path));
391
392    // Extract parent directory for relative path display
393    let parent_dir = Path::new(path).parent();
394
395    // Format output
396    let formatted = format_file_details(path, &semantic, line_count, is_test, parent_dir);
397
398    tracing::debug!(path = %path, language = %ext, functions = semantic.functions.len(), classes = semantic.classes.len(), imports = semantic.imports.len(), duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX), "file analysis complete");
399
400    Ok(FileAnalysisOutput::new(
401        formatted, semantic, line_count, None,
402    ))
403}
404
405/// Analyze source code from a string buffer without filesystem access.
406///
407/// This function analyzes in-memory source code by language identifier. The `language`
408/// parameter can be either a language name (e.g., `"rust"`, `"python"`, `"go"`) or a file
409/// extension (e.g., `"rs"`, `"py"`).
410///
411/// Accepted language identifiers depend on compiled features. Use [`supported_languages()`] to
412/// discover the available language names at runtime, and [`language_for_extension()`] to resolve
413/// a file extension to its supported language identifier.
414///
415/// # Arguments
416///
417/// * `source` - The source code to analyze
418/// * `language` - The language identifier (language name or extension)
419/// * `ast_recursion_limit` - Optional limit for AST traversal depth
420///
421/// # Returns
422///
423/// - `Ok(FileAnalysisOutput)` on success
424/// - `Err(AnalyzeError::UnsupportedLanguage)` if the language is not recognized
425/// - `Err(AnalyzeError::Parser)` if parsing fails
426///
427/// # Notes
428///
429/// - Python wildcard import resolution is skipped for in-memory analysis (no filesystem path available)
430/// - The formatted output uses the standard file-details formatter, so it includes a `FILE:` header with an empty path
431#[inline]
432pub fn analyze_str(
433    source: &str,
434    language: &str,
435    ast_recursion_limit: Option<usize>,
436) -> Result<FileAnalysisOutput, AnalyzeError> {
437    // Resolve language: first try as a file extension, then as a language name
438    // (case-insensitive match against supported_languages()).
439    let lang = language_for_extension(language).or_else(|| {
440        let lower = language.to_ascii_lowercase();
441        supported_languages()
442            .iter()
443            .find(|&&name| name == lower)
444            .copied()
445    });
446    let lang = lang.ok_or_else(|| AnalyzeError::UnsupportedLanguage(language.to_string()))?;
447
448    // Extract semantic information
449    let mut semantic = SemanticExtractor::extract(source, lang, ast_recursion_limit, None)?;
450
451    // Populate a stable in-memory sentinel on all reference locations
452    for r in &mut semantic.references {
453        r.location = "<memory>".to_string();
454    }
455
456    // Count lines in the source
457    let line_count = source.lines().count();
458
459    // Format output with empty path (no filesystem access)
460    let formatted = format_file_details("", &semantic, line_count, false, None);
461
462    Ok(FileAnalysisOutput::new(
463        formatted, semantic, line_count, None,
464    ))
465}
466
467/// Single entry in a call chain (depth-1 direct caller or callee).
468#[non_exhaustive]
469#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
470#[cfg_attr(feature = "schemars", derive(JsonSchema))]
471pub struct CallChainEntry {
472    #[cfg_attr(
473        feature = "schemars",
474        schemars(description = "Symbol name of the caller or callee")
475    )]
476    pub symbol: String,
477    #[cfg_attr(
478        feature = "schemars",
479        schemars(description = "File path relative to the repository root")
480    )]
481    pub file: String,
482    #[cfg_attr(
483        feature = "schemars",
484        schemars(
485            description = "Line number of the definition or call site (1-indexed)",
486            schema_with = "crate::schema_helpers::integer_schema"
487        )
488    )]
489    pub line: usize,
490}
491
492/// Result of focused symbol analysis.
493#[derive(Debug, Clone, Serialize, Deserialize)]
494#[cfg_attr(feature = "schemars", derive(JsonSchema))]
495#[non_exhaustive]
496pub struct FocusedAnalysisOutput {
497    #[cfg_attr(
498        feature = "schemars",
499        schemars(description = "Formatted text representation of the call graph analysis")
500    )]
501    pub formatted: String,
502    #[serde(skip_serializing_if = "Option::is_none")]
503    #[cfg_attr(
504        feature = "schemars",
505        schemars(
506            description = "Opaque cursor token for the next page of results (absent when no more results)"
507        )
508    )]
509    pub next_cursor: Option<String>,
510    /// Production caller chains (partitioned from incoming chains, excluding test callers).
511    /// Not serialized; used for pagination in lib.rs.
512    #[serde(skip)]
513    #[serde(default)]
514    #[cfg_attr(feature = "schemars", schemars(skip))]
515    pub prod_chains: Vec<InternalCallChain>,
516    /// Test caller chains. Not serialized; used for pagination summary in lib.rs.
517    #[serde(skip)]
518    #[serde(default)]
519    #[cfg_attr(feature = "schemars", schemars(skip))]
520    pub test_chains: Vec<InternalCallChain>,
521    /// Outgoing (callee) chains. Not serialized; used for pagination in lib.rs.
522    #[serde(skip)]
523    #[serde(default)]
524    #[cfg_attr(feature = "schemars", schemars(skip))]
525    pub outgoing_chains: Vec<InternalCallChain>,
526    /// Number of definitions for the symbol. Not serialized; used for pagination headers.
527    #[serde(skip)]
528    #[serde(default)]
529    #[cfg_attr(feature = "schemars", schemars(skip))]
530    pub def_count: usize,
531    /// Total unique callers before `impl_only` filter. Not serialized; used for FILTER header.
532    #[serde(skip)]
533    #[serde(default)]
534    #[cfg_attr(feature = "schemars", schemars(skip))]
535    pub unfiltered_caller_count: usize,
536    /// Unique callers after `impl_only` filter. Not serialized; used for FILTER header.
537    #[serde(skip)]
538    #[serde(default)]
539    #[cfg_attr(feature = "schemars", schemars(skip))]
540    pub impl_trait_caller_count: usize,
541    /// Direct (depth-1) production callers. `follow_depth` does not affect this field.
542    #[serde(skip_serializing_if = "Option::is_none")]
543    pub callers: Option<Vec<CallChainEntry>>,
544    /// Direct (depth-1) test callers. `follow_depth` does not affect this field.
545    #[serde(skip_serializing_if = "Option::is_none")]
546    pub test_callers: Option<Vec<CallChainEntry>>,
547    /// Direct (depth-1) callees. `follow_depth` does not affect this field.
548    #[serde(skip_serializing_if = "Option::is_none")]
549    pub callees: Option<Vec<CallChainEntry>>,
550    /// Definition and use sites for the symbol.
551    #[serde(default)]
552    pub def_use_sites: Vec<crate::types::DefUseSite>,
553    /// Cache tier for this result: `"l1_memory"`, `"l2_disk"`, or `"miss"`.
554    /// Populated by the MCP handler after cache lookup.
555    ///
556    /// This field is `None` in the following cases:
557    /// - `import_lookup=true` responses: the import-lookup path does not consult the call
558    ///   graph cache, so no tier is recorded.
559    /// - Non-symbol analysis modes (directory and file tools): `FocusedAnalysisOutput` is
560    ///   not produced by those handlers, and the field is therefore absent.
561    /// - Any `FocusedAnalysisOutput` constructed outside the `handle_focused_mode` return
562    ///   path (e.g. legacy cached entries that pre-date this field).
563    #[serde(skip_serializing_if = "Option::is_none")]
564    #[cfg_attr(
565        feature = "schemars",
566        schemars(description = "Cache tier for this result: l1_memory, l2_disk, or miss")
567    )]
568    pub cache_tier: Option<String>,
569}
570
571/// Parameters for focused symbol analysis. Groups high-arity parameters to keep
572/// function signatures under clippy's default 7-argument threshold.
573#[derive(Clone)]
574pub struct FocusedAnalysisConfig {
575    pub focus: String,
576    pub match_mode: SymbolMatchMode,
577    pub follow_depth: u32,
578    pub max_depth: Option<u32>,
579    pub ast_recursion_limit: Option<usize>,
580    pub use_summary: bool,
581    pub impl_only: Option<bool>,
582    pub def_use: bool,
583    pub parse_timeout_micros: Option<u64>,
584}
585
586#[cfg(test)]
587pub(crate) use crate::analyze_focused::chains_to_entries;
588pub(crate) use crate::analyze_focused::resolve_wildcard_imports;
589pub use crate::analyze_focused::{
590    analyze_focused, analyze_focused_with_progress, analyze_focused_with_progress_with_entries,
591    analyze_import_lookup, analyze_module_file,
592};
593/// Read a file and return its raw content with line numbers for a specified range.
594#[cfg(test)]
595mod tests {
596    use super::*;
597    use crate::formatter::format_focused_paginated;
598    use crate::graph::InternalCallChain;
599    use crate::pagination::{PaginationMode, decode_cursor, paginate_slice};
600    use std::fs;
601    use std::path::PathBuf;
602    use tempfile::TempDir;
603
604    #[test]
605    fn analyze_str_rust_happy_path() {
606        let source = "fn hello() -> i32 { 42 }";
607        let result = analyze_str(source, "rs", None);
608        assert!(result.is_ok());
609    }
610
611    #[test]
612    fn analyze_str_python_happy_path() {
613        let source = "def greet(name):\n    return f'Hello {name}'";
614        let result = analyze_str(source, "py", None);
615        assert!(result.is_ok());
616    }
617
618    #[test]
619    fn analyze_str_rust_by_language_name() {
620        let source = "fn hello() -> i32 { 42 }";
621        let result = analyze_str(source, "rust", None);
622        assert!(result.is_ok());
623    }
624
625    #[test]
626    fn analyze_str_python_by_language_name() {
627        let source = "def greet(name):\n    return f'Hello {name}'";
628        let result = analyze_str(source, "python", None);
629        assert!(result.is_ok());
630    }
631
632    #[test]
633    fn analyze_str_rust_mixed_case() {
634        let source = "fn hello() -> i32 { 42 }";
635        let result = analyze_str(source, "RuSt", None);
636        assert!(result.is_ok());
637    }
638
639    #[test]
640    fn analyze_str_python_mixed_case() {
641        let source = "def greet(name):\n    return f'Hello {name}'";
642        let result = analyze_str(source, "PyThOn", None);
643        assert!(result.is_ok());
644    }
645
646    #[test]
647    fn analyze_str_unsupported_language() {
648        let result = analyze_str("code", "brainfuck", None);
649        assert!(
650            matches!(result, Err(AnalyzeError::UnsupportedLanguage(lang)) if lang == "brainfuck")
651        );
652    }
653
654    #[test]
655    fn test_symbol_focus_callers_pagination_first_page() {
656        let temp_dir = TempDir::new().unwrap();
657
658        // Create a file with many callers of `target`
659        let mut code = String::from("fn target() {}\n");
660        for i in 0..15 {
661            code.push_str(&format!("fn caller_{:02}() {{ target(); }}\n", i));
662        }
663        fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
664
665        // Act
666        let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
667
668        // Paginate prod callers with page_size=5
669        let paginated = paginate_slice(&output.prod_chains, 0, 5, PaginationMode::Callers)
670            .expect("paginate failed");
671        assert!(
672            paginated.total >= 5,
673            "should have enough callers to paginate"
674        );
675        assert!(
676            paginated.next_cursor.is_some(),
677            "should have next_cursor for page 1"
678        );
679
680        // Verify cursor encodes callers mode
681        assert_eq!(paginated.items.len(), 5);
682    }
683
684    #[test]
685    fn test_symbol_focus_callers_pagination_second_page() {
686        let temp_dir = TempDir::new().unwrap();
687
688        let mut code = String::from("fn target() {}\n");
689        for i in 0..12 {
690            code.push_str(&format!("fn caller_{:02}() {{ target(); }}\n", i));
691        }
692        fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
693
694        let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
695        let total_prod = output.prod_chains.len();
696
697        if total_prod > 5 {
698            // Get page 1 cursor
699            let p1 = paginate_slice(&output.prod_chains, 0, 5, PaginationMode::Callers)
700                .expect("paginate failed");
701            assert!(p1.next_cursor.is_some());
702
703            let cursor_str = p1.next_cursor.unwrap();
704            let cursor_data = decode_cursor(&cursor_str).expect("decode failed");
705
706            // Get page 2
707            let p2 = paginate_slice(
708                &output.prod_chains,
709                cursor_data.offset,
710                5,
711                PaginationMode::Callers,
712            )
713            .expect("paginate failed");
714
715            // Format paginated output
716            let formatted = format_focused_paginated(
717                &p2.items,
718                total_prod,
719                PaginationMode::Callers,
720                "target",
721                &output.prod_chains,
722                &output.test_chains,
723                &output.outgoing_chains,
724                output.def_count,
725                cursor_data.offset,
726                Some(temp_dir.path()),
727                true,
728            );
729
730            // Assert: header shows correct range for page 2
731            let expected_start = cursor_data.offset + 1;
732            assert!(
733                formatted.contains(&format!("CALLERS ({}", expected_start)),
734                "header should show page 2 range, got: {}",
735                formatted
736            );
737        }
738    }
739
740    #[test]
741    fn test_chains_to_entries_empty_returns_none() {
742        // Arrange
743        let chains: Vec<InternalCallChain> = vec![];
744
745        // Act
746        let result = chains_to_entries(&chains, None);
747
748        // Assert
749        assert!(result.is_none());
750    }
751
752    #[test]
753    fn test_chains_to_entries_with_data_returns_entries() {
754        // Arrange
755        let chains = vec![
756            InternalCallChain {
757                chain: vec![("caller1".to_string(), PathBuf::from("/root/lib.rs"), 10)],
758            },
759            InternalCallChain {
760                chain: vec![("caller2".to_string(), PathBuf::from("/root/other.rs"), 20)],
761            },
762        ];
763        let root = PathBuf::from("/root");
764
765        // Act
766        let result = chains_to_entries(&chains, Some(root.as_path()));
767
768        // Assert
769        assert!(result.is_some());
770        let entries = result.unwrap();
771        assert_eq!(entries.len(), 2);
772        assert_eq!(entries[0].symbol, "caller1");
773        assert_eq!(entries[0].file, "lib.rs");
774        assert_eq!(entries[0].line, 10);
775        assert_eq!(entries[1].symbol, "caller2");
776        assert_eq!(entries[1].file, "other.rs");
777        assert_eq!(entries[1].line, 20);
778    }
779
780    #[test]
781    fn test_symbol_focus_callees_pagination() {
782        let temp_dir = TempDir::new().unwrap();
783
784        // target calls many functions
785        let mut code = String::from("fn target() {\n");
786        for i in 0..10 {
787            code.push_str(&format!("    callee_{:02}();\n", i));
788        }
789        code.push_str("}\n");
790        for i in 0..10 {
791            code.push_str(&format!("fn callee_{:02}() {{}}\n", i));
792        }
793        fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
794
795        let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
796        let total_callees = output.outgoing_chains.len();
797
798        if total_callees > 3 {
799            let paginated = paginate_slice(&output.outgoing_chains, 0, 3, PaginationMode::Callees)
800                .expect("paginate failed");
801
802            let formatted = format_focused_paginated(
803                &paginated.items,
804                total_callees,
805                PaginationMode::Callees,
806                "target",
807                &output.prod_chains,
808                &output.test_chains,
809                &output.outgoing_chains,
810                output.def_count,
811                0,
812                Some(temp_dir.path()),
813                true,
814            );
815
816            assert!(
817                formatted.contains(&format!(
818                    "CALLEES (1-{} of {})",
819                    paginated.items.len(),
820                    total_callees
821                )),
822                "header should show callees range, got: {}",
823                formatted
824            );
825        }
826    }
827
828    #[test]
829    fn test_symbol_focus_empty_prod_callers() {
830        let temp_dir = TempDir::new().unwrap();
831
832        // target is only called from test functions
833        let code = r#"
834fn target() {}
835
836#[cfg(test)]
837mod tests {
838    use super::*;
839    #[test]
840    fn test_something() { target(); }
841}
842"#;
843        fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
844
845        let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
846
847        // prod_chains may be empty; pagination should handle it gracefully
848        let paginated = paginate_slice(&output.prod_chains, 0, 100, PaginationMode::Callers)
849            .expect("paginate failed");
850        assert_eq!(paginated.items.len(), output.prod_chains.len());
851        assert!(
852            paginated.next_cursor.is_none(),
853            "no next_cursor for empty or single-page prod_chains"
854        );
855    }
856
857    #[test]
858    fn test_impl_only_filter_header_correct_counts() {
859        let temp_dir = TempDir::new().unwrap();
860
861        // Create a Rust fixture with:
862        // - A trait definition
863        // - An impl Trait for SomeType block that calls the focus symbol
864        // - A regular (non-trait-impl) function that also calls the focus symbol
865        let code = r#"
866trait MyTrait {
867    fn focus_symbol();
868}
869
870struct SomeType;
871
872impl MyTrait for SomeType {
873    fn focus_symbol() {}
874}
875
876fn impl_caller() {
877    SomeType::focus_symbol();
878}
879
880fn regular_caller() {
881    SomeType::focus_symbol();
882}
883"#;
884        fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
885
886        // Call analyze_focused with impl_only=Some(true)
887        let params = FocusedAnalysisConfig {
888            focus: "focus_symbol".to_string(),
889            match_mode: SymbolMatchMode::Insensitive,
890            follow_depth: 1,
891            max_depth: None,
892            ast_recursion_limit: None,
893            use_summary: false,
894            impl_only: Some(true),
895            def_use: false,
896            parse_timeout_micros: None,
897        };
898        let output = analyze_focused_with_progress(
899            temp_dir.path(),
900            &params,
901            Arc::new(AtomicUsize::new(0)),
902            CancellationToken::new(),
903        )
904        .unwrap();
905
906        // Assert the result contains "FILTER: impl_only=true"
907        assert!(
908            output.formatted.contains("FILTER: impl_only=true"),
909            "formatted output should contain FILTER header for impl_only=true, got: {}",
910            output.formatted
911        );
912
913        // Assert the retained count N < total count M
914        assert!(
915            output.impl_trait_caller_count < output.unfiltered_caller_count,
916            "impl_trait_caller_count ({}) should be less than unfiltered_caller_count ({})",
917            output.impl_trait_caller_count,
918            output.unfiltered_caller_count
919        );
920
921        // Assert format is "FILTER: impl_only=true (N of M callers shown)"
922        let filter_line = output
923            .formatted
924            .lines()
925            .find(|line| line.contains("FILTER: impl_only=true"))
926            .expect("should find FILTER line");
927        assert!(
928            filter_line.contains(&format!(
929                "({} of {} callers shown)",
930                output.impl_trait_caller_count, output.unfiltered_caller_count
931            )),
932            "FILTER line should show correct N of M counts, got: {}",
933            filter_line
934        );
935    }
936
937    #[test]
938    fn test_callers_count_matches_formatted_output() {
939        let temp_dir = TempDir::new().unwrap();
940
941        // Create a file with multiple callers of `target`
942        let code = r#"
943fn target() {}
944fn caller_a() { target(); }
945fn caller_b() { target(); }
946fn caller_c() { target(); }
947"#;
948        fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
949
950        // Analyze the symbol
951        let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
952
953        // Extract CALLERS count from formatted output
954        let formatted = &output.formatted;
955        let callers_count_from_output = formatted
956            .lines()
957            .find(|line| line.contains("FOCUS:"))
958            .and_then(|line| {
959                line.split(',')
960                    .find(|part| part.contains("callers"))
961                    .and_then(|part| {
962                        part.trim()
963                            .split_whitespace()
964                            .next()
965                            .and_then(|s| s.parse::<usize>().ok())
966                    })
967            })
968            .expect("should find CALLERS count in formatted output");
969
970        // Compute expected count from prod_chains (unique first-caller names)
971        let expected_callers_count = output
972            .prod_chains
973            .iter()
974            .filter_map(|chain| chain.chain.first().map(|(name, _, _)| name))
975            .collect::<std::collections::HashSet<_>>()
976            .len();
977
978        assert_eq!(
979            callers_count_from_output, expected_callers_count,
980            "CALLERS count in formatted output should match unique-first-caller count in prod_chains"
981        );
982    }
983
984    #[test]
985    fn test_def_use_focused_analysis() {
986        let temp_dir = TempDir::new().unwrap();
987        fs::write(
988            temp_dir.path().join("lib.rs"),
989            "fn example() {\n    let x = 10;\n    x += 1;\n    println!(\"{}\", x);\n    let y = x + 1;\n}\n",
990        )
991        .unwrap();
992
993        let entries = walk_directory(temp_dir.path(), None).unwrap();
994        let counter = Arc::new(AtomicUsize::new(0));
995        let ct = CancellationToken::new();
996        let params = FocusedAnalysisConfig {
997            focus: "x".to_string(),
998            match_mode: SymbolMatchMode::Exact,
999            follow_depth: 1,
1000            max_depth: None,
1001            ast_recursion_limit: None,
1002            use_summary: false,
1003            impl_only: None,
1004            def_use: true,
1005            parse_timeout_micros: None,
1006        };
1007
1008        let output = analyze_focused_with_progress_with_entries(
1009            temp_dir.path(),
1010            &params,
1011            &counter,
1012            &ct,
1013            &entries,
1014        )
1015        .expect("def_use analysis should succeed");
1016
1017        assert!(
1018            !output.def_use_sites.is_empty(),
1019            "should find def-use sites for x"
1020        );
1021        assert!(
1022            output
1023                .def_use_sites
1024                .iter()
1025                .any(|s| s.kind == crate::types::DefUseKind::Write),
1026            "should have at least one Write site",
1027        );
1028        // No location appears as both write and read
1029        let write_locs: std::collections::HashSet<_> = output
1030            .def_use_sites
1031            .iter()
1032            .filter(|s| {
1033                matches!(
1034                    s.kind,
1035                    crate::types::DefUseKind::Write | crate::types::DefUseKind::WriteRead
1036                )
1037            })
1038            .map(|s| (&s.file, s.line, s.column))
1039            .collect();
1040        assert!(
1041            output
1042                .def_use_sites
1043                .iter()
1044                .filter(|s| s.kind == crate::types::DefUseKind::Read)
1045                .all(|s| !write_locs.contains(&(&s.file, s.line, s.column))),
1046            "no location should appear as both write and read",
1047        );
1048        assert!(
1049            output.formatted.contains("DEF-USE SITES"),
1050            "formatted output should contain DEF-USE SITES"
1051        );
1052    }
1053
1054    fn make_temp_file(content: &str) -> tempfile::NamedTempFile {
1055        let mut f = tempfile::NamedTempFile::new().unwrap();
1056        use std::io::Write;
1057        f.write_all(content.as_bytes()).unwrap();
1058        f.flush().unwrap();
1059        f
1060    }
1061}