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