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