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::{
9    format_file_details, format_focused_internal, format_focused_summary_internal, format_structure,
10};
11use crate::graph::{CallGraph, InternalCallChain};
12use crate::lang::{language_for_extension, supported_languages};
13use crate::parser::{ElementExtractor, SemanticExtractor};
14use crate::test_detection::is_test_file;
15use crate::traversal::{WalkEntry, walk_directory};
16use crate::types::{
17    AnalysisMode, FileInfo, ImplTraitInfo, ImportInfo, SemanticAnalysis, SymbolMatchMode,
18};
19use rayon::prelude::*;
20#[cfg(feature = "schemars")]
21use schemars::JsonSchema;
22use serde::{Deserialize, Serialize};
23use std::path::{Path, PathBuf};
24use std::sync::Arc;
25use std::sync::atomic::{AtomicUsize, Ordering};
26use std::time::Instant;
27use thiserror::Error;
28use tokio_util::sync::CancellationToken;
29use tracing::instrument;
30
31pub const MAX_FILE_SIZE_BYTES: u64 = 10_000_000;
32
33#[derive(Debug, Error)]
34#[non_exhaustive]
35pub enum AnalyzeError {
36    #[error("Traversal error: {0}")]
37    Traversal(#[from] crate::traversal::TraversalError),
38    #[error("Parser error: {0}")]
39    Parser(#[from] crate::parser::ParserError),
40    #[error("Graph error: {0}")]
41    Graph(#[from] crate::graph::GraphError),
42    #[error("Formatter error: {0}")]
43    Formatter(#[from] crate::formatter::FormatterError),
44    #[error("Analysis cancelled")]
45    Cancelled,
46    #[error("unsupported language: {0}")]
47    UnsupportedLanguage(String),
48    #[error("I/O error: {0}")]
49    Io(#[from] std::io::Error),
50    #[error("invalid range: start ({start}) > end ({end}); file has {total} lines")]
51    InvalidRange {
52        start: usize,
53        end: usize,
54        total: usize,
55    },
56    #[error("path is a directory, not a file: {0}")]
57    NotAFile(PathBuf),
58    #[error(
59        "file has {total_lines} lines; provide start_line and end_line, or call analyze_module first to locate the range"
60    )]
61    RangelessLargeFile { total_lines: usize },
62    #[error("parse timeout exceeded for {path}: {micros} microseconds")]
63    ParseTimeout { path: PathBuf, micros: u64 },
64}
65
66/// Result of directory analysis containing both formatted output and file data.
67#[derive(Debug, Clone, Serialize, Deserialize)]
68#[cfg_attr(feature = "schemars", derive(JsonSchema))]
69#[non_exhaustive]
70pub struct AnalysisOutput {
71    #[cfg_attr(
72        feature = "schemars",
73        schemars(description = "Formatted text representation of the analysis")
74    )]
75    pub formatted: String,
76    #[cfg_attr(
77        feature = "schemars",
78        schemars(description = "List of files analyzed in the directory")
79    )]
80    pub files: Vec<FileInfo>,
81    /// Walk entries used internally for summary generation; not serialized.
82    #[serde(skip)]
83    #[serde(default)]
84    #[cfg_attr(feature = "schemars", schemars(skip))]
85    pub entries: Vec<WalkEntry>,
86    /// Subtree file counts computed from an unbounded walk; used by `format_summary`; not serialized.
87    #[serde(skip)]
88    #[serde(default)]
89    #[cfg_attr(feature = "schemars", schemars(skip))]
90    pub subtree_counts: Option<Vec<(std::path::PathBuf, usize)>>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    #[cfg_attr(
93        feature = "schemars",
94        schemars(
95            description = "Opaque cursor token for the next page of results (absent when no more results)"
96        )
97    )]
98    pub next_cursor: Option<String>,
99}
100
101/// Result of file-level semantic analysis.
102#[derive(Debug, Clone, Serialize, Deserialize)]
103#[cfg_attr(feature = "schemars", derive(JsonSchema))]
104#[non_exhaustive]
105pub struct FileAnalysisOutput {
106    #[cfg_attr(
107        feature = "schemars",
108        schemars(description = "Formatted text representation of the analysis")
109    )]
110    pub formatted: String,
111    #[cfg_attr(
112        feature = "schemars",
113        schemars(description = "Semantic analysis data including functions, classes, and imports")
114    )]
115    pub semantic: SemanticAnalysis,
116    #[cfg_attr(
117        feature = "schemars",
118        schemars(description = "Total line count of the analyzed file")
119    )]
120    #[cfg_attr(
121        feature = "schemars",
122        schemars(schema_with = "crate::schema_helpers::integer_schema")
123    )]
124    pub line_count: usize,
125    #[serde(skip_serializing_if = "Option::is_none")]
126    #[cfg_attr(
127        feature = "schemars",
128        schemars(
129            description = "Opaque cursor token for the next page of results (absent when no more results)"
130        )
131    )]
132    pub next_cursor: Option<String>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    #[cfg_attr(
135        feature = "schemars",
136        schemars(
137            description = "True when the file extension is not supported; semantic fields are empty and formatted contains a raw preview"
138        )
139    )]
140    pub unsupported: Option<bool>,
141}
142
143impl FileAnalysisOutput {
144    /// Create a new `FileAnalysisOutput`.
145    #[must_use]
146    pub fn new(
147        formatted: String,
148        semantic: SemanticAnalysis,
149        line_count: usize,
150        next_cursor: Option<String>,
151    ) -> Self {
152        Self {
153            formatted,
154            semantic,
155            line_count,
156            next_cursor,
157            unsupported: None,
158        }
159    }
160}
161/// Reason a file was skipped during eligibility check.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163enum SkipReason {
164    Oversized,
165    Unreadable,
166}
167
168/// Check if a file is eligible for analysis based on size and readability.
169///
170/// Returns `Ok(content)` when the file should be analyzed, `Err(reason)` to skip it.
171fn check_file_eligibility(entry: &WalkEntry) -> Result<String, SkipReason> {
172    // Check file size before reading
173    if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
174        tracing::debug!("skipping large file: {}", entry.path.display());
175        return Err(SkipReason::Oversized);
176    }
177
178    // Try to read file content; skip binary or unreadable files
179    std::fs::read_to_string(&entry.path).map_err(|_| SkipReason::Unreadable)
180}
181
182/// Process a single file entry and extract its analysis data.
183fn process_file_entry(entry: &WalkEntry, source: &str) -> FileInfo {
184    let path_str = entry.path.display().to_string();
185    let line_count = source.lines().count();
186
187    // Detect language from extension
188    let ext = entry.path.extension().and_then(|e| e.to_str());
189
190    // Detect language and extract counts
191    let (language, function_count, class_count) = if let Some(ext_str) = ext
192        && let Some(lang) = language_for_extension(ext_str)
193    {
194        let lang_str = lang.to_string();
195        match ElementExtractor::extract_with_depth(source, &lang_str) {
196            Ok((func_count, class_count)) => (lang_str, func_count, class_count),
197            Err(_) => (lang_str, 0, 0),
198        }
199    } else {
200        (
201            ext.map(|e| e.to_lowercase())
202                .unwrap_or_else(|| "unknown".to_string()),
203            0,
204            0,
205        )
206    };
207
208    let is_test = is_test_file(&entry.path);
209
210    FileInfo {
211        path: path_str,
212        line_count,
213        function_count,
214        class_count,
215        language,
216        is_test,
217    }
218}
219
220/// Analyze a single file entry in parallel context.
221fn analyze_single_file(
222    entry: &WalkEntry,
223    progress: &Arc<AtomicUsize>,
224    ct: &CancellationToken,
225) -> Option<FileInfo> {
226    // Check cancellation per file
227    if ct.is_cancelled() {
228        return None;
229    }
230
231    // Check file eligibility; progress accounting happens on all exit paths below
232    let source = match check_file_eligibility(entry) {
233        Ok(content) => content,
234        Err(_) => {
235            progress.fetch_add(1, Ordering::Relaxed);
236            return None;
237        }
238    };
239
240    let file_info = process_file_entry(entry, &source);
241    progress.fetch_add(1, Ordering::Relaxed);
242
243    Some(file_info)
244}
245
246/// Initialize analysis context and collect file entries.
247fn init_analysis_context(entries: &[WalkEntry]) -> Vec<&WalkEntry> {
248    entries
249        .iter()
250        .filter(|e| !e.is_dir && !e.is_symlink)
251        .collect()
252}
253
254/// Build the final analysis output from results.
255fn build_analysis_output(
256    entries: Vec<WalkEntry>,
257    analysis_results: Vec<FileInfo>,
258) -> AnalysisOutput {
259    let formatted = format_structure(&entries, &analysis_results, None);
260    AnalysisOutput {
261        formatted,
262        files: analysis_results,
263        entries,
264        next_cursor: None,
265        subtree_counts: None,
266    }
267}
268
269/// Run parallel analysis on file entries and log completion.
270fn run_parallel_analysis(
271    file_entries: &[&WalkEntry],
272    progress: &Arc<AtomicUsize>,
273    ct: &CancellationToken,
274) -> Result<Vec<FileInfo>, AnalyzeError> {
275    let start = Instant::now();
276    tracing::debug!(file_count = file_entries.len(), "analysis start");
277
278    let _parse_span = tracing::info_span!("ast.parse_batch", count = file_entries.len()).entered();
279
280    // Parallel analysis of files
281    let analysis_results: Vec<FileInfo> = file_entries
282        .par_iter()
283        .filter_map(|entry| analyze_single_file(entry, progress, ct))
284        .collect();
285
286    // Check if cancelled after parallel processing
287    if ct.is_cancelled() {
288        return Err(AnalyzeError::Cancelled);
289    }
290
291    tracing::debug!(
292        file_count = file_entries.len(),
293        duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
294        "analysis complete"
295    );
296
297    Ok(analysis_results)
298}
299
300#[instrument(skip_all, fields(path = %root.display()))]
301// public API; callers expect owned semantics
302#[allow(clippy::needless_pass_by_value)]
303pub fn analyze_directory_with_progress(
304    root: &Path,
305    entries: Vec<WalkEntry>,
306    progress: Arc<AtomicUsize>,
307    ct: CancellationToken,
308) -> Result<AnalysisOutput, AnalyzeError> {
309    // Check if already cancelled
310    if ct.is_cancelled() {
311        return Err(AnalyzeError::Cancelled);
312    }
313
314    tracing::debug!(root = %root.display(), "analysis start");
315
316    let file_entries = init_analysis_context(&entries);
317    let analysis_results = run_parallel_analysis(&file_entries, &progress, &ct)?;
318
319    let _format_span = tracing::info_span!("output.format").entered();
320
321    // Build and return output
322    Ok(build_analysis_output(entries, analysis_results))
323}
324
325/// Analyze a directory structure and return formatted output and file data.
326#[instrument(skip_all, fields(path = %root.display()))]
327pub fn analyze_directory(
328    root: &Path,
329    max_depth: Option<u32>,
330) -> Result<AnalysisOutput, AnalyzeError> {
331    let entries = walk_directory(root, max_depth)?;
332    let counter = Arc::new(AtomicUsize::new(0));
333    let ct = CancellationToken::new();
334    analyze_directory_with_progress(root, entries, counter, ct)
335}
336
337/// Determine analysis mode based on parameters and path.
338#[must_use]
339pub fn determine_mode(path: &str, focus: Option<&str>) -> AnalysisMode {
340    if focus.is_some() {
341        return AnalysisMode::SymbolFocus;
342    }
343
344    let path_obj = Path::new(path);
345    if path_obj.is_dir() {
346        AnalysisMode::Overview
347    } else {
348        AnalysisMode::FileDetails
349    }
350}
351
352/// Analyze a single file and return semantic analysis with formatted output.
353#[instrument(skip_all, fields(path))]
354pub fn analyze_file(
355    path: &str,
356    ast_recursion_limit: Option<usize>,
357) -> Result<FileAnalysisOutput, AnalyzeError> {
358    let start = Instant::now();
359
360    // Check file size before reading
361    if Path::new(path).metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
362        tracing::debug!("skipping large file: {}", path);
363        return Err(AnalyzeError::Parser(
364            crate::parser::ParserError::ParseError("file too large".to_string()),
365        ));
366    }
367
368    let source = std::fs::read_to_string(path)
369        .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
370
371    let line_count = source.lines().count();
372
373    // Detect language from extension
374    let ext = Path::new(path)
375        .extension()
376        .and_then(|e| e.to_str())
377        .and_then(language_for_extension)
378        .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string);
379
380    // Extract semantic information
381    let mut semantic = SemanticExtractor::extract(&source, &ext, ast_recursion_limit, None)?;
382
383    // Populate the file path on references now that the path is known
384    for r in &mut semantic.references {
385        r.location = path.to_string();
386    }
387
388    // Resolve Python wildcard imports
389    if ext == "python" {
390        resolve_wildcard_imports(Path::new(path), &mut semantic.imports);
391    }
392
393    // Detect if this is a test file
394    let is_test = is_test_file(Path::new(path));
395
396    // Extract parent directory for relative path display
397    let parent_dir = Path::new(path).parent();
398
399    // Format output
400    let formatted = format_file_details(path, &semantic, line_count, is_test, parent_dir);
401
402    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");
403
404    Ok(FileAnalysisOutput::new(
405        formatted, semantic, line_count, None,
406    ))
407}
408
409/// Analyze source code from a string buffer without filesystem access.
410///
411/// This function analyzes in-memory source code by language identifier. The `language`
412/// parameter can be either a language name (e.g., `"rust"`, `"python"`, `"go"`) or a file
413/// extension (e.g., `"rs"`, `"py"`).
414///
415/// Accepted language identifiers depend on compiled features. Use [`supported_languages()`] to
416/// discover the available language names at runtime, and [`language_for_extension()`] to resolve
417/// a file extension to its supported language identifier.
418///
419/// # Arguments
420///
421/// * `source` - The source code to analyze
422/// * `language` - The language identifier (language name or extension)
423/// * `ast_recursion_limit` - Optional limit for AST traversal depth
424///
425/// # Returns
426///
427/// - `Ok(FileAnalysisOutput)` on success
428/// - `Err(AnalyzeError::UnsupportedLanguage)` if the language is not recognized
429/// - `Err(AnalyzeError::Parser)` if parsing fails
430///
431/// # Notes
432///
433/// - Python wildcard import resolution is skipped for in-memory analysis (no filesystem path available)
434/// - The formatted output uses the standard file-details formatter, so it includes a `FILE:` header with an empty path
435#[inline]
436pub fn analyze_str(
437    source: &str,
438    language: &str,
439    ast_recursion_limit: Option<usize>,
440) -> Result<FileAnalysisOutput, AnalyzeError> {
441    // Resolve language: first try as a file extension, then as a language name
442    // (case-insensitive match against supported_languages()).
443    let lang = language_for_extension(language).or_else(|| {
444        let lower = language.to_ascii_lowercase();
445        supported_languages()
446            .iter()
447            .find(|&&name| name == lower)
448            .copied()
449    });
450    let lang = lang.ok_or_else(|| AnalyzeError::UnsupportedLanguage(language.to_string()))?;
451
452    // Extract semantic information
453    let mut semantic = SemanticExtractor::extract(source, lang, ast_recursion_limit, None)?;
454
455    // Populate a stable in-memory sentinel on all reference locations
456    for r in &mut semantic.references {
457        r.location = "<memory>".to_string();
458    }
459
460    // Count lines in the source
461    let line_count = source.lines().count();
462
463    // Format output with empty path (no filesystem access)
464    let formatted = format_file_details("", &semantic, line_count, false, None);
465
466    Ok(FileAnalysisOutput::new(
467        formatted, semantic, line_count, None,
468    ))
469}
470
471/// Single entry in a call chain (depth-1 direct caller or callee).
472#[non_exhaustive]
473#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
474#[cfg_attr(feature = "schemars", derive(JsonSchema))]
475pub struct CallChainEntry {
476    #[cfg_attr(
477        feature = "schemars",
478        schemars(description = "Symbol name of the caller or callee")
479    )]
480    pub symbol: String,
481    #[cfg_attr(
482        feature = "schemars",
483        schemars(description = "File path relative to the repository root")
484    )]
485    pub file: String,
486    #[cfg_attr(
487        feature = "schemars",
488        schemars(
489            description = "Line number of the definition or call site (1-indexed)",
490            schema_with = "crate::schema_helpers::integer_schema"
491        )
492    )]
493    pub line: usize,
494}
495
496/// Result of focused symbol analysis.
497#[derive(Debug, Clone, Serialize, Deserialize)]
498#[cfg_attr(feature = "schemars", derive(JsonSchema))]
499#[non_exhaustive]
500pub struct FocusedAnalysisOutput {
501    #[cfg_attr(
502        feature = "schemars",
503        schemars(description = "Formatted text representation of the call graph analysis")
504    )]
505    pub formatted: String,
506    #[serde(skip_serializing_if = "Option::is_none")]
507    #[cfg_attr(
508        feature = "schemars",
509        schemars(
510            description = "Opaque cursor token for the next page of results (absent when no more results)"
511        )
512    )]
513    pub next_cursor: Option<String>,
514    /// Production caller chains (partitioned from incoming chains, excluding test callers).
515    /// Not serialized; used for pagination in lib.rs.
516    #[serde(skip)]
517    #[serde(default)]
518    #[cfg_attr(feature = "schemars", schemars(skip))]
519    pub prod_chains: Vec<InternalCallChain>,
520    /// Test caller chains. Not serialized; used for pagination summary in lib.rs.
521    #[serde(skip)]
522    #[serde(default)]
523    #[cfg_attr(feature = "schemars", schemars(skip))]
524    pub test_chains: Vec<InternalCallChain>,
525    /// Outgoing (callee) chains. Not serialized; used for pagination in lib.rs.
526    #[serde(skip)]
527    #[serde(default)]
528    #[cfg_attr(feature = "schemars", schemars(skip))]
529    pub outgoing_chains: Vec<InternalCallChain>,
530    /// Number of definitions for the symbol. Not serialized; used for pagination headers.
531    #[serde(skip)]
532    #[serde(default)]
533    #[cfg_attr(feature = "schemars", schemars(skip))]
534    pub def_count: usize,
535    /// Total unique callers before `impl_only` filter. Not serialized; used for FILTER header.
536    #[serde(skip)]
537    #[serde(default)]
538    #[cfg_attr(feature = "schemars", schemars(skip))]
539    pub unfiltered_caller_count: usize,
540    /// Unique callers after `impl_only` filter. Not serialized; used for FILTER header.
541    #[serde(skip)]
542    #[serde(default)]
543    #[cfg_attr(feature = "schemars", schemars(skip))]
544    pub impl_trait_caller_count: usize,
545    /// Direct (depth-1) production callers. `follow_depth` does not affect this field.
546    #[serde(skip_serializing_if = "Option::is_none")]
547    pub callers: Option<Vec<CallChainEntry>>,
548    /// Direct (depth-1) test callers. `follow_depth` does not affect this field.
549    #[serde(skip_serializing_if = "Option::is_none")]
550    pub test_callers: Option<Vec<CallChainEntry>>,
551    /// Direct (depth-1) callees. `follow_depth` does not affect this field.
552    #[serde(skip_serializing_if = "Option::is_none")]
553    pub callees: Option<Vec<CallChainEntry>>,
554    /// Definition and use sites for the symbol.
555    #[serde(default)]
556    pub def_use_sites: Vec<crate::types::DefUseSite>,
557    /// Cache tier for this result: `"l1_memory"`, `"l2_disk"`, or `"miss"`.
558    /// Populated by the MCP handler after cache lookup.
559    ///
560    /// This field is `None` in the following cases:
561    /// - `import_lookup=true` responses: the import-lookup path does not consult the call
562    ///   graph cache, so no tier is recorded.
563    /// - Non-symbol analysis modes (directory and file tools): `FocusedAnalysisOutput` is
564    ///   not produced by those handlers, and the field is therefore absent.
565    /// - Any `FocusedAnalysisOutput` constructed outside the `handle_focused_mode` return
566    ///   path (e.g. legacy cached entries that pre-date this field).
567    #[serde(skip_serializing_if = "Option::is_none")]
568    #[cfg_attr(
569        feature = "schemars",
570        schemars(description = "Cache tier for this result: l1_memory, l2_disk, or miss")
571    )]
572    pub cache_tier: Option<String>,
573}
574
575/// Parameters for focused symbol analysis. Groups high-arity parameters to keep
576/// function signatures under clippy's default 7-argument threshold.
577#[derive(Clone)]
578pub struct FocusedAnalysisConfig {
579    pub focus: String,
580    pub match_mode: SymbolMatchMode,
581    pub follow_depth: u32,
582    pub max_depth: Option<u32>,
583    pub ast_recursion_limit: Option<usize>,
584    pub use_summary: bool,
585    pub impl_only: Option<bool>,
586    pub def_use: bool,
587    pub parse_timeout_micros: Option<u64>,
588}
589
590/// Internal parameters for focused analysis phases.
591#[derive(Clone)]
592struct InternalFocusedParams {
593    focus: String,
594    match_mode: SymbolMatchMode,
595    follow_depth: u32,
596    ast_recursion_limit: Option<usize>,
597    use_summary: bool,
598    impl_only: Option<bool>,
599    def_use: bool,
600    parse_timeout_micros: Option<u64>,
601}
602
603/// Type alias for analysis results: (`file_path`, `semantic_analysis`) pairs and impl-trait info.
604type FileAnalysisBatch = (Vec<(PathBuf, SemanticAnalysis)>, Vec<ImplTraitInfo>);
605
606/// Phase 1: Collect semantic analysis for all files in parallel.
607fn collect_file_analysis(
608    entries: &[WalkEntry],
609    progress: &Arc<AtomicUsize>,
610    ct: &CancellationToken,
611    ast_recursion_limit: Option<usize>,
612    parse_timeout_micros: Option<u64>,
613) -> Result<FileAnalysisBatch, AnalyzeError> {
614    // Check if already cancelled
615    if ct.is_cancelled() {
616        return Err(AnalyzeError::Cancelled);
617    }
618
619    // Use pre-walked entries (passed by caller)
620    // Collect semantic analysis for all files in parallel
621    let file_entries: Vec<&WalkEntry> = entries
622        .iter()
623        .filter(|e| !e.is_dir && !e.is_symlink)
624        .collect();
625
626    // Collect per-file timeout events so they can be surfaced as AnalyzeError::ParseTimeout.
627    let timed_out: std::sync::Mutex<Vec<(PathBuf, u64)>> = std::sync::Mutex::new(Vec::new());
628
629    let analysis_results: Vec<(PathBuf, SemanticAnalysis)> = file_entries
630        .par_iter()
631        .filter_map(|entry| {
632            // Check cancellation per file
633            if ct.is_cancelled() {
634                return None;
635            }
636
637            let ext = entry.path.extension().and_then(|e| e.to_str());
638
639            // Check file size before reading
640            if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
641                tracing::debug!("skipping large file: {}", entry.path.display());
642                progress.fetch_add(1, Ordering::Relaxed);
643                return None;
644            }
645
646            // Try to read file content
647            let Ok(source) = std::fs::read_to_string(&entry.path) else {
648                progress.fetch_add(1, Ordering::Relaxed);
649                return None;
650            };
651
652            // Detect language and extract semantic information
653            let language = if let Some(ext_str) = ext {
654                language_for_extension(ext_str)
655                    .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string)
656            } else {
657                "unknown".to_string()
658            };
659
660            match SemanticExtractor::extract(
661                &source,
662                &language,
663                ast_recursion_limit,
664                parse_timeout_micros,
665            ) {
666                Ok(mut semantic) => {
667                    // Populate file path on references
668                    for r in &mut semantic.references {
669                        r.location = entry.path.display().to_string();
670                    }
671                    // Populate file path on impl_traits (already extracted during SemanticExtractor::extract)
672                    for trait_info in &mut semantic.impl_traits {
673                        trait_info.path.clone_from(&entry.path);
674                    }
675                    progress.fetch_add(1, Ordering::Relaxed);
676                    Some((entry.path.clone(), semantic))
677                }
678                Err(crate::parser::ParserError::Timeout(micros)) => {
679                    tracing::warn!(
680                        "parse timeout exceeded for {}: {} microseconds",
681                        entry.path.display(),
682                        micros
683                    );
684                    if let Ok(mut v) = timed_out.lock() {
685                        v.push((entry.path.clone(), micros));
686                    }
687                    progress.fetch_add(1, Ordering::Relaxed);
688                    None
689                }
690                Err(_) => {
691                    progress.fetch_add(1, Ordering::Relaxed);
692                    None
693                }
694            }
695        })
696        .collect();
697
698    // Check if cancelled after parallel processing
699    if ct.is_cancelled() {
700        return Err(AnalyzeError::Cancelled);
701    }
702
703    // Surface the first timeout as AnalyzeError::ParseTimeout so callers can detect it.
704    if let Ok(mut v) = timed_out.lock()
705        && let Some((path, micros)) = v.drain(..).next()
706    {
707        return Err(AnalyzeError::ParseTimeout { path, micros });
708    }
709
710    // Collect all impl-trait info from analysis results
711    let all_impl_traits: Vec<ImplTraitInfo> = analysis_results
712        .iter()
713        .flat_map(|(_, sem)| sem.impl_traits.iter().cloned())
714        .collect();
715
716    Ok((analysis_results, all_impl_traits))
717}
718
719/// Phase 2: Build call graph from analysis results.
720fn build_call_graph(
721    analysis_results: Vec<(PathBuf, SemanticAnalysis)>,
722    all_impl_traits: &[ImplTraitInfo],
723) -> Result<CallGraph, AnalyzeError> {
724    // Build call graph. Always build without impl_only filter first so we can
725    // record the unfiltered caller count before discarding those edges.
726    CallGraph::build_from_results(
727        analysis_results,
728        all_impl_traits,
729        false, // filter applied below after counting
730    )
731    .map_err(std::convert::Into::into)
732}
733
734/// Phase 3: Resolve symbol and apply `impl_only` filter.
735/// Returns (`resolved_focus`, `unfiltered_caller_count`, `impl_trait_caller_count`).
736/// CRITICAL: Must capture `unfiltered_caller_count` BEFORE `retain()`, then apply `retain()`,
737/// then compute `impl_trait_caller_count`.
738fn resolve_symbol(
739    graph: &mut CallGraph,
740    params: &InternalFocusedParams,
741) -> Result<(String, usize, usize), AnalyzeError> {
742    // Resolve symbol name using the requested match mode.
743    let resolved_focus = if params.match_mode == SymbolMatchMode::Exact {
744        let exists = graph.definitions.contains_key(&params.focus)
745            || graph.callers.contains_key(&params.focus)
746            || graph.callees.contains_key(&params.focus);
747        if exists {
748            params.focus.clone()
749        } else {
750            return Err(crate::graph::GraphError::SymbolNotFound {
751                symbol: params.focus.clone(),
752                hint: "Try match_mode=insensitive for a case-insensitive search, or match_mode=prefix to list symbols starting with this name.".to_string(),
753            }
754            .into());
755        }
756    } else {
757        graph.resolve_symbol_indexed(&params.focus, &params.match_mode)?
758    };
759
760    // Count unique callers for the focus symbol before applying impl_only filter.
761    let unfiltered_caller_count = graph.callers.get(&resolved_focus).map_or(0, |edges| {
762        edges
763            .iter()
764            .map(|e| &e.neighbor_name)
765            .collect::<std::collections::HashSet<_>>()
766            .len()
767    });
768
769    // Apply impl_only filter now if requested, then count filtered callers.
770    // Filter all caller adjacency lists so traversal and formatting are consistently
771    // restricted to impl-trait edges regardless of follow_depth.
772    let impl_trait_caller_count = if params.impl_only.unwrap_or(false) {
773        for edges in graph.callers.values_mut() {
774            edges.retain(|e| e.is_impl_trait);
775        }
776        graph.callers.get(&resolved_focus).map_or(0, |edges| {
777            edges
778                .iter()
779                .map(|e| &e.neighbor_name)
780                .collect::<std::collections::HashSet<_>>()
781                .len()
782        })
783    } else {
784        unfiltered_caller_count
785    };
786
787    Ok((
788        resolved_focus,
789        unfiltered_caller_count,
790        impl_trait_caller_count,
791    ))
792}
793
794/// Type alias for `compute_chains` return type: (`formatted_output`, `prod_chains`, `test_chains`, `outgoing_chains`, `def_count`).
795type ChainComputeResult = (
796    String,
797    Vec<InternalCallChain>,
798    Vec<InternalCallChain>,
799    Vec<InternalCallChain>,
800    usize,
801);
802
803/// Helper function to convert InternalCallChain data to CallChainEntry vec.
804/// Takes the first (depth-1) element of each chain and converts it to a CallChainEntry.
805/// Returns None if chains is empty, otherwise returns a vec of up to 10 entries.
806fn chains_to_entries(
807    chains: &[InternalCallChain],
808    root: Option<&std::path::Path>,
809) -> Option<Vec<CallChainEntry>> {
810    if chains.is_empty() {
811        return None;
812    }
813    let entries: Vec<CallChainEntry> = chains
814        .iter()
815        .take(10)
816        .filter_map(|chain| {
817            let (symbol, path, line) = chain.chain.first()?;
818            let file = match root {
819                Some(root) => path
820                    .strip_prefix(root)
821                    .unwrap_or(path.as_path())
822                    .to_string_lossy()
823                    .into_owned(),
824                None => path.to_string_lossy().into_owned(),
825            };
826            Some(CallChainEntry {
827                symbol: symbol.clone(),
828                file,
829                line: *line,
830            })
831        })
832        .collect();
833    if entries.is_empty() {
834        None
835    } else {
836        Some(entries)
837    }
838}
839
840/// Phase 4: Compute chains and format output.
841fn compute_chains(
842    graph: &CallGraph,
843    resolved_focus: &str,
844    root: &Path,
845    params: &InternalFocusedParams,
846    unfiltered_caller_count: usize,
847    impl_trait_caller_count: usize,
848    def_use_sites: &[crate::types::DefUseSite],
849) -> Result<ChainComputeResult, AnalyzeError> {
850    // Compute chain data for pagination (always, regardless of summary mode)
851    let def_count = graph.definitions.get(resolved_focus).map_or(0, Vec::len);
852    let incoming_chains = graph.find_incoming_chains(resolved_focus, params.follow_depth)?;
853    let outgoing_chains = graph.find_outgoing_chains(resolved_focus, params.follow_depth)?;
854
855    let (prod_chains, test_chains): (Vec<_>, Vec<_>) =
856        incoming_chains.iter().cloned().partition(|chain| {
857            chain
858                .chain
859                .first()
860                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
861        });
862
863    // Format output with pre-computed chains
864    let mut formatted = if params.use_summary {
865        format_focused_summary_internal(
866            graph,
867            resolved_focus,
868            params.follow_depth,
869            Some(root),
870            Some(&incoming_chains),
871            Some(&outgoing_chains),
872            def_use_sites,
873        )?
874    } else {
875        format_focused_internal(
876            graph,
877            resolved_focus,
878            params.follow_depth,
879            Some(root),
880            Some(&incoming_chains),
881            Some(&outgoing_chains),
882            def_use_sites,
883        )?
884    };
885
886    // Add FILTER header if impl_only filter was applied
887    if params.impl_only.unwrap_or(false) {
888        let filter_header = format!(
889            "FILTER: impl_only=true ({impl_trait_caller_count} of {unfiltered_caller_count} callers shown)\n",
890        );
891        formatted = format!("{filter_header}{formatted}");
892    }
893
894    Ok((
895        formatted,
896        prod_chains,
897        test_chains,
898        outgoing_chains,
899        def_count,
900    ))
901}
902
903/// Analyze a symbol's call graph across a directory with progress tracking.
904// public API; callers expect owned semantics
905#[allow(clippy::needless_pass_by_value)]
906pub fn analyze_focused_with_progress(
907    root: &Path,
908    params: &FocusedAnalysisConfig,
909    progress: Arc<AtomicUsize>,
910    ct: CancellationToken,
911) -> Result<FocusedAnalysisOutput, AnalyzeError> {
912    let entries = walk_directory(root, params.max_depth)?;
913    let internal_params = InternalFocusedParams {
914        focus: params.focus.clone(),
915        match_mode: params.match_mode.clone(),
916        follow_depth: params.follow_depth,
917        ast_recursion_limit: params.ast_recursion_limit,
918        use_summary: params.use_summary,
919        impl_only: params.impl_only,
920        def_use: params.def_use,
921        parse_timeout_micros: params.parse_timeout_micros,
922    };
923    analyze_focused_with_progress_with_entries_internal(
924        root,
925        params.max_depth,
926        &progress,
927        &ct,
928        &internal_params,
929        &entries,
930    )
931}
932
933/// Internal implementation of focused analysis using pre-walked entries and params struct.
934#[instrument(skip_all, fields(path = %root.display(), symbol = %params.focus))]
935fn analyze_focused_with_progress_with_entries_internal(
936    root: &Path,
937    _max_depth: Option<u32>,
938    progress: &Arc<AtomicUsize>,
939    ct: &CancellationToken,
940    params: &InternalFocusedParams,
941    entries: &[WalkEntry],
942) -> Result<FocusedAnalysisOutput, AnalyzeError> {
943    // Check if already cancelled
944    if ct.is_cancelled() {
945        return Err(AnalyzeError::Cancelled);
946    }
947
948    // Check if path is a file (hint to use directory)
949    if root.is_file() {
950        let formatted =
951            "Single-file focus not supported. Please provide a directory path for cross-file call graph analysis.\n"
952                .to_string();
953        return Ok(FocusedAnalysisOutput {
954            formatted,
955            next_cursor: None,
956            prod_chains: vec![],
957            test_chains: vec![],
958            outgoing_chains: vec![],
959            def_count: 0,
960            unfiltered_caller_count: 0,
961            impl_trait_caller_count: 0,
962            callers: None,
963            test_callers: None,
964            callees: None,
965            def_use_sites: vec![],
966            cache_tier: None,
967        });
968    }
969
970    // Phase 1: Collect file analysis
971    let (analysis_results, all_impl_traits) = collect_file_analysis(
972        entries,
973        progress,
974        ct,
975        params.ast_recursion_limit,
976        params.parse_timeout_micros,
977    )?;
978
979    // Check for cancellation before building the call graph (phase 2)
980    if ct.is_cancelled() {
981        return Err(AnalyzeError::Cancelled);
982    }
983
984    // Phase 2: Build call graph
985    let mut graph = build_call_graph(analysis_results, &all_impl_traits)?;
986
987    // Check for cancellation before resolving the symbol (phase 3)
988    if ct.is_cancelled() {
989        return Err(AnalyzeError::Cancelled);
990    }
991
992    // Phase 3: Resolve symbol and apply impl_only filter.
993    // When def_use=true and the symbol is not in the call graph (e.g. a variable),
994    // fall through to def-use extraction instead of returning SymbolNotFound.
995    let resolve_result = resolve_symbol(&mut graph, params);
996    if let Err(AnalyzeError::Graph(crate::graph::GraphError::SymbolNotFound { .. })) =
997        &resolve_result
998    {
999        // Deliberately not collapsed: resolve_result must stay alive past this block
1000        // so that the `?` below can propagate non-SymbolNotFound errors.
1001        if params.def_use {
1002            let def_use_sites =
1003                collect_def_use_sites(entries, &params.focus, params.ast_recursion_limit, root, ct);
1004            if def_use_sites.is_empty() {
1005                // Symbol not found anywhere (neither in call graph nor as def/use site).
1006                // Propagate the original SymbolNotFound error instead of returning an
1007                // empty success response.
1008                if let Err(e) = resolve_result {
1009                    return Err(e);
1010                }
1011                unreachable!("resolve_result is Ok only when symbol was found");
1012            }
1013            use std::fmt::Write as _;
1014            let mut formatted = String::new();
1015            let _ = writeln!(
1016                formatted,
1017                "FOCUS: {} (0 defs, 0 callers, 0 callees)",
1018                params.focus
1019            );
1020            {
1021                let writes = def_use_sites
1022                    .iter()
1023                    .filter(|s| {
1024                        matches!(
1025                            s.kind,
1026                            crate::types::DefUseKind::Write | crate::types::DefUseKind::WriteRead
1027                        )
1028                    })
1029                    .count();
1030                let reads = def_use_sites
1031                    .iter()
1032                    .filter(|s| s.kind == crate::types::DefUseKind::Read)
1033                    .count();
1034                let _ = writeln!(
1035                    formatted,
1036                    "DEF-USE SITES  {}  ({} total: {} writes, {} reads)",
1037                    params.focus,
1038                    def_use_sites.len(),
1039                    writes,
1040                    reads
1041                );
1042            }
1043            return Ok(FocusedAnalysisOutput {
1044                formatted,
1045                next_cursor: None,
1046                callers: None,
1047                test_callers: None,
1048                callees: None,
1049                prod_chains: vec![],
1050                test_chains: vec![],
1051                outgoing_chains: vec![],
1052                def_count: 0,
1053                unfiltered_caller_count: 0,
1054                impl_trait_caller_count: 0,
1055                def_use_sites,
1056                cache_tier: None,
1057            });
1058        }
1059    }
1060    let (resolved_focus, unfiltered_caller_count, impl_trait_caller_count) = resolve_result?;
1061
1062    // Check for cancellation before computing chains (phase 4)
1063    if ct.is_cancelled() {
1064        return Err(AnalyzeError::Cancelled);
1065    }
1066
1067    // Phase 5 (optional, before formatting): Def-use site extraction.
1068    // Use params.focus (the raw user-supplied string) rather than resolved_focus
1069    // so that variable/field names that are not in the call graph still work.
1070    let def_use_sites = if params.def_use {
1071        collect_def_use_sites(entries, &params.focus, params.ast_recursion_limit, root, ct)
1072    } else {
1073        Vec::new()
1074    };
1075
1076    // Phase 4: Compute chains and format output (includes def_use_sites in one pass)
1077    let (formatted, prod_chains, test_chains, outgoing_chains, def_count) = compute_chains(
1078        &graph,
1079        &resolved_focus,
1080        root,
1081        params,
1082        unfiltered_caller_count,
1083        impl_trait_caller_count,
1084        &def_use_sites,
1085    )?;
1086
1087    // Compute depth-1 chains for structured output fields (always direct relationships only,
1088    // regardless of `follow_depth` used for the text-formatted output).
1089    let (depth1_callers, depth1_test_callers, depth1_callees) = if params.follow_depth <= 1 {
1090        // Chains already at depth 1; reuse the partitioned vecs.
1091        let callers = chains_to_entries(&prod_chains, Some(root));
1092        let test_callers = chains_to_entries(&test_chains, Some(root));
1093        let callees = chains_to_entries(&outgoing_chains, Some(root));
1094        (callers, test_callers, callees)
1095    } else {
1096        // follow_depth > 1: re-query at depth 1 to get only direct edges.
1097        let incoming1 = graph
1098            .find_incoming_chains(&resolved_focus, 1)
1099            .unwrap_or_default();
1100        let outgoing1 = graph
1101            .find_outgoing_chains(&resolved_focus, 1)
1102            .unwrap_or_default();
1103        let (prod1, test1): (Vec<_>, Vec<_>) = incoming1.into_iter().partition(|chain| {
1104            chain
1105                .chain
1106                .first()
1107                .is_none_or(|(name, path, _)| !is_test_file(path) && !name.starts_with("test_"))
1108        });
1109        let callers = chains_to_entries(&prod1, Some(root));
1110        let test_callers = chains_to_entries(&test1, Some(root));
1111        let callees = chains_to_entries(&outgoing1, Some(root));
1112        (callers, test_callers, callees)
1113    };
1114
1115    Ok(FocusedAnalysisOutput {
1116        formatted,
1117        next_cursor: None,
1118        callers: depth1_callers,
1119        test_callers: depth1_test_callers,
1120        callees: depth1_callees,
1121        prod_chains,
1122        test_chains,
1123        outgoing_chains,
1124        def_count,
1125        unfiltered_caller_count,
1126        impl_trait_caller_count,
1127        def_use_sites,
1128        cache_tier: None,
1129    })
1130}
1131
1132/// Phase 5: Extract def-use sites for `symbol` across all entries.
1133/// Writes go before reads; within each kind ordered by file, line, then column.
1134fn collect_def_use_sites(
1135    entries: &[WalkEntry],
1136    symbol: &str,
1137    ast_recursion_limit: Option<usize>,
1138    root: &std::path::Path,
1139    ct: &CancellationToken,
1140) -> Vec<crate::types::DefUseSite> {
1141    use crate::parser::SemanticExtractor;
1142
1143    let file_entries: Vec<&WalkEntry> = entries
1144        .iter()
1145        .filter(|e| !e.is_dir && !e.is_symlink)
1146        .collect();
1147
1148    let mut sites: Vec<crate::types::DefUseSite> = file_entries
1149        .par_iter()
1150        .filter_map(|entry| {
1151            if ct.is_cancelled() {
1152                return None;
1153            }
1154
1155            // Check file size before reading
1156            if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
1157                tracing::debug!("skipping large file: {}", entry.path.display());
1158                return None;
1159            }
1160
1161            let Ok(source) = std::fs::read_to_string(&entry.path) else {
1162                return None;
1163            };
1164            let ext = entry
1165                .path
1166                .extension()
1167                .and_then(|e| e.to_str())
1168                .unwrap_or("");
1169            let lang = crate::lang::language_for_extension(ext)?;
1170            let file_path = entry
1171                .path
1172                .strip_prefix(root)
1173                .unwrap_or(&entry.path)
1174                .display()
1175                .to_string();
1176            let sites = SemanticExtractor::extract_def_use_for_file(
1177                &source,
1178                lang,
1179                symbol,
1180                &file_path,
1181                ast_recursion_limit,
1182            );
1183            if sites.is_empty() { None } else { Some(sites) }
1184        })
1185        .flatten()
1186        .collect();
1187
1188    // Writes before reads; within each kind: file, line, then column for deterministic order
1189    sites.sort_by(|a, b| {
1190        use crate::types::DefUseKind;
1191        let kind_ord = |k: &DefUseKind| match k {
1192            DefUseKind::Write | DefUseKind::WriteRead => 0,
1193            DefUseKind::Read => 1,
1194        };
1195        kind_ord(&a.kind)
1196            .cmp(&kind_ord(&b.kind))
1197            .then_with(|| a.file.cmp(&b.file))
1198            .then_with(|| a.line.cmp(&b.line))
1199            .then_with(|| a.column.cmp(&b.column))
1200    });
1201
1202    sites
1203}
1204
1205/// Analyze a symbol's call graph using pre-walked directory entries.
1206pub fn analyze_focused_with_progress_with_entries(
1207    root: &Path,
1208    params: &FocusedAnalysisConfig,
1209    progress: &Arc<AtomicUsize>,
1210    ct: &CancellationToken,
1211    entries: &[WalkEntry],
1212) -> Result<FocusedAnalysisOutput, AnalyzeError> {
1213    let internal_params = InternalFocusedParams {
1214        focus: params.focus.clone(),
1215        match_mode: params.match_mode.clone(),
1216        follow_depth: params.follow_depth,
1217        ast_recursion_limit: params.ast_recursion_limit,
1218        use_summary: params.use_summary,
1219        impl_only: params.impl_only,
1220        def_use: params.def_use,
1221        parse_timeout_micros: params.parse_timeout_micros,
1222    };
1223    analyze_focused_with_progress_with_entries_internal(
1224        root,
1225        params.max_depth,
1226        progress,
1227        ct,
1228        &internal_params,
1229        entries,
1230    )
1231}
1232
1233#[instrument(skip_all, fields(path = %root.display(), symbol = %focus))]
1234pub fn analyze_focused(
1235    root: &Path,
1236    focus: &str,
1237    follow_depth: u32,
1238    max_depth: Option<u32>,
1239    ast_recursion_limit: Option<usize>,
1240) -> Result<FocusedAnalysisOutput, AnalyzeError> {
1241    let entries = walk_directory(root, max_depth)?;
1242    let counter = Arc::new(AtomicUsize::new(0));
1243    let ct = CancellationToken::new();
1244    let params = FocusedAnalysisConfig {
1245        focus: focus.to_string(),
1246        match_mode: SymbolMatchMode::Exact,
1247        follow_depth,
1248        max_depth,
1249        ast_recursion_limit,
1250        use_summary: false,
1251        impl_only: None,
1252        def_use: false,
1253        parse_timeout_micros: None,
1254    };
1255    analyze_focused_with_progress_with_entries(root, &params, &counter, &ct, &entries)
1256}
1257
1258/// Analyze a single file and return a minimal fixed schema (name, line count, language,
1259/// functions, imports) for lightweight code understanding.
1260#[instrument(skip_all, fields(path))]
1261pub fn analyze_module_file(path: &str) -> Result<crate::types::ModuleInfo, AnalyzeError> {
1262    // Check file size before reading
1263    if Path::new(path).metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
1264        tracing::debug!("skipping large file: {}", path);
1265        return Err(AnalyzeError::Parser(
1266            crate::parser::ParserError::ParseError("file too large".to_string()),
1267        ));
1268    }
1269
1270    let source = std::fs::read_to_string(path)
1271        .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
1272
1273    let file_path = Path::new(path);
1274    let name = file_path
1275        .file_name()
1276        .and_then(|s| s.to_str())
1277        .unwrap_or("unknown")
1278        .to_string();
1279
1280    let line_count = source.lines().count();
1281
1282    let language = file_path
1283        .extension()
1284        .and_then(|e| e.to_str())
1285        .and_then(language_for_extension)
1286        .ok_or_else(|| {
1287            AnalyzeError::Parser(crate::parser::ParserError::UnsupportedLanguage(
1288                file_path
1289                    .extension()
1290                    .and_then(|e| e.to_str())
1291                    .unwrap_or("(no extension)")
1292                    .to_string(),
1293            ))
1294        })?;
1295
1296    let mut module_info = SemanticExtractor::extract_module_info(&source, language, None)?;
1297    module_info.name = name;
1298    module_info.line_count = line_count;
1299
1300    Ok(module_info)
1301}
1302
1303/// Scan a directory for files that import a given module path.
1304///
1305/// For each non-directory walk entry, extracts imports via [`SemanticExtractor`] and
1306/// checks whether `module` matches `ImportInfo.module` or appears in `ImportInfo.items`.
1307/// Returns a [`FocusedAnalysisOutput`] whose `formatted` field lists matching files.
1308pub fn analyze_import_lookup(
1309    root: &Path,
1310    module: &str,
1311    entries: &[WalkEntry],
1312    ast_recursion_limit: Option<usize>,
1313) -> Result<FocusedAnalysisOutput, AnalyzeError> {
1314    let matches: Vec<(PathBuf, usize)> = entries
1315        .par_iter()
1316        .filter_map(|entry| {
1317            if entry.is_dir || entry.is_symlink {
1318                tracing::debug!("skipping symlink: {}", entry.path.display());
1319                return None;
1320            }
1321            let ext = entry
1322                .path
1323                .extension()
1324                .and_then(|e| e.to_str())
1325                .and_then(crate::lang::language_for_extension)?;
1326            let source = std::fs::read_to_string(&entry.path).ok()?;
1327            let semantic =
1328                SemanticExtractor::extract(&source, ext, ast_recursion_limit, None).ok()?;
1329            for import in &semantic.imports {
1330                if import.module == module || import.items.iter().any(|item| item == module) {
1331                    return Some((entry.path.clone(), import.line));
1332                }
1333            }
1334            None
1335        })
1336        .collect();
1337
1338    let mut text = format!("IMPORT_LOOKUP: {module}\n");
1339    text.push_str(&format!("ROOT: {}\n", root.display()));
1340    text.push_str(&format!("MATCHES: {}\n", matches.len()));
1341    for (path, line) in &matches {
1342        let rel = path.strip_prefix(root).unwrap_or(path);
1343        text.push_str(&format!("  {}:{line}\n", rel.display()));
1344    }
1345
1346    Ok(FocusedAnalysisOutput {
1347        formatted: text,
1348        next_cursor: None,
1349        prod_chains: vec![],
1350        test_chains: vec![],
1351        outgoing_chains: vec![],
1352        def_count: 0,
1353        unfiltered_caller_count: 0,
1354        impl_trait_caller_count: 0,
1355        callers: None,
1356        test_callers: None,
1357        callees: None,
1358        def_use_sites: vec![],
1359        cache_tier: None,
1360    })
1361}
1362
1363/// Resolve Python wildcard imports to actual symbol names.
1364///
1365/// For each import with items=`["*"]`, this function:
1366/// 1. Parses the relative dots (if any) and climbs the directory tree
1367/// 2. Finds the target .py file or __init__.py
1368/// 3. Extracts symbols (functions and classes) from the target
1369/// 4. Honors __all__ if defined, otherwise uses function+class names
1370///
1371/// All resolution failures are non-fatal: debug-logged and the wildcard is preserved.
1372fn resolve_wildcard_imports(file_path: &Path, imports: &mut [ImportInfo]) {
1373    use std::collections::HashMap;
1374
1375    let mut resolved_cache: HashMap<PathBuf, Vec<String>> = HashMap::new();
1376    let Ok(file_path_canonical) = file_path.canonicalize() else {
1377        tracing::debug!(file = ?file_path, "unable to canonicalize current file path");
1378        return;
1379    };
1380
1381    for import in imports.iter_mut() {
1382        if import.items != ["*"] {
1383            continue;
1384        }
1385        resolve_single_wildcard(import, file_path, &file_path_canonical, &mut resolved_cache);
1386    }
1387}
1388
1389/// Validate and canonicalize a wildcard target path, checking for self-references.
1390/// Returns the canonical path if valid, or None if validation fails.
1391fn validate_wildcard_target(
1392    target_to_read: &Path,
1393    file_path_canonical: &Path,
1394    module: &str,
1395) -> Option<PathBuf> {
1396    let Ok(canonical) = target_to_read.canonicalize() else {
1397        tracing::debug!(target = ?target_to_read, import = %module, "unable to canonicalize path");
1398        return None;
1399    };
1400
1401    if canonical == file_path_canonical {
1402        tracing::debug!(target = ?canonical, import = %module, "cannot import from self");
1403        return None;
1404    }
1405
1406    Some(canonical)
1407}
1408
1409/// Resolve one wildcard import in place. On any failure the import is left unchanged.
1410fn resolve_single_wildcard(
1411    import: &mut ImportInfo,
1412    file_path: &Path,
1413    file_path_canonical: &Path,
1414    resolved_cache: &mut std::collections::HashMap<PathBuf, Vec<String>>,
1415) {
1416    let module = import.module.clone();
1417    let dot_count = module.chars().take_while(|c| *c == '.').count();
1418    if dot_count == 0 {
1419        return;
1420    }
1421    let module_path = module.trim_start_matches('.');
1422
1423    let Some(target_to_read) = locate_target_file(file_path, dot_count, module_path, &module)
1424    else {
1425        return;
1426    };
1427
1428    let Some(canonical) = validate_wildcard_target(&target_to_read, file_path_canonical, &module)
1429    else {
1430        return;
1431    };
1432
1433    if let Some(cached) = resolved_cache.get(&canonical) {
1434        tracing::debug!(import = %module, symbols_count = cached.len(), "using cached symbols");
1435        import.items.clone_from(cached);
1436        return;
1437    }
1438
1439    if let Some(symbols) = parse_target_symbols(&target_to_read, &module) {
1440        tracing::debug!(import = %module, resolved_count = symbols.len(), "wildcard import resolved");
1441        import.items.clone_from(&symbols);
1442        resolved_cache.insert(canonical, symbols);
1443    }
1444}
1445
1446/// Locate the .py file that a wildcard import refers to. Returns None if not found.
1447fn locate_target_file(
1448    file_path: &Path,
1449    dot_count: usize,
1450    module_path: &str,
1451    module: &str,
1452) -> Option<PathBuf> {
1453    let mut target_dir = file_path.parent()?.to_path_buf();
1454
1455    for _ in 1..dot_count {
1456        if !target_dir.pop() {
1457            tracing::debug!(import = %module, "unable to climb {} levels", dot_count.saturating_sub(1));
1458            return None;
1459        }
1460    }
1461
1462    let target_file = if module_path.is_empty() {
1463        target_dir.join("__init__.py")
1464    } else {
1465        let rel_path = module_path.replace('.', "/");
1466        target_dir.join(format!("{rel_path}.py"))
1467    };
1468
1469    if target_file.exists() {
1470        Some(target_file)
1471    } else if target_file.with_extension("").is_dir() {
1472        let init = target_file.with_extension("").join("__init__.py");
1473        if init.exists() { Some(init) } else { None }
1474    } else {
1475        tracing::debug!(target = ?target_file, import = %module, "target file not found");
1476        None
1477    }
1478}
1479
1480/// Build a tree-sitter parser for Python and parse the source code.
1481fn build_parser_for_file(source: &str) -> Option<tree_sitter::Tree> {
1482    use tree_sitter::Parser;
1483
1484    let lang_info = crate::languages::get_language_info("python")?;
1485    let mut parser = Parser::new();
1486    if parser.set_language(&lang_info.language).is_err() {
1487        return None;
1488    }
1489    parser.parse(source, None)
1490}
1491
1492/// Extract all public symbols from a parsed tree (functions and classes).
1493fn extract_all_symbols(tree: &tree_sitter::Tree, source: &str) -> Vec<String> {
1494    let mut symbols = Vec::new();
1495    let root = tree.root_node();
1496    let mut cursor = root.walk();
1497    for child in root.children(&mut cursor) {
1498        if matches!(child.kind(), "function_definition" | "class_definition")
1499            && let Some(name_node) = child.child_by_field_name("name")
1500        {
1501            let name = source[name_node.start_byte()..name_node.end_byte()].to_string();
1502            if !name.starts_with('_') {
1503                symbols.push(name);
1504            }
1505        }
1506    }
1507    symbols
1508}
1509
1510/// Try to resolve symbols from __all__ or fallback to function/class extraction.
1511fn resolve_symbols_from_tree(tree: &tree_sitter::Tree, source: &str, module: &str) -> Vec<String> {
1512    let mut symbols = Vec::new();
1513    extract_all_from_tree(tree, source, &mut symbols);
1514    if !symbols.is_empty() {
1515        tracing::debug!(import = %module, symbols = ?symbols, "using __all__ symbols");
1516        return symbols;
1517    }
1518
1519    // Fallback: extract functions/classes from the tree
1520    let symbols = extract_all_symbols(tree, source);
1521    tracing::debug!(import = %module, fallback_symbols = ?symbols, "using fallback function/class names");
1522    symbols
1523}
1524
1525/// Read and parse a target .py file, returning its exported symbols.
1526fn parse_target_symbols(target_path: &Path, module: &str) -> Option<Vec<String>> {
1527    // Check file size before reading
1528    if target_path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
1529        tracing::debug!("skipping large file: {}", target_path.display());
1530        return None;
1531    }
1532
1533    let source = match std::fs::read_to_string(target_path) {
1534        Ok(s) => s,
1535        Err(e) => {
1536            tracing::debug!(target = ?target_path, import = %module, error = %e, "unable to read target file");
1537            return None;
1538        }
1539    };
1540
1541    // Parse once with tree-sitter
1542    let tree = build_parser_for_file(&source)?;
1543
1544    // Try to extract __all__ or fallback to function/class extraction
1545    let symbols = resolve_symbols_from_tree(&tree, &source, module);
1546    Some(symbols)
1547}
1548
1549/// Extract __all__ from a tree-sitter tree.
1550fn extract_all_from_tree(tree: &tree_sitter::Tree, source: &str, result: &mut Vec<String>) {
1551    let root = tree.root_node();
1552    let mut cursor = root.walk();
1553    for child in root.children(&mut cursor) {
1554        if child.kind() == "simple_statement" {
1555            // simple_statement contains assignment and other statement types
1556            let mut simple_cursor = child.walk();
1557            for simple_child in child.children(&mut simple_cursor) {
1558                if simple_child.kind() == "assignment"
1559                    && let Some(left) = simple_child.child_by_field_name("left")
1560                {
1561                    let target_text = source[left.start_byte()..left.end_byte()].trim();
1562                    if target_text == "__all__"
1563                        && let Some(right) = simple_child.child_by_field_name("right")
1564                    {
1565                        extract_string_list_from_list_node(&right, source, result);
1566                    }
1567                }
1568            }
1569        } else if child.kind() == "expression_statement" {
1570            // Fallback for older Python AST structures
1571            let mut stmt_cursor = child.walk();
1572            for stmt_child in child.children(&mut stmt_cursor) {
1573                if stmt_child.kind() == "assignment"
1574                    && let Some(left) = stmt_child.child_by_field_name("left")
1575                {
1576                    let target_text = source[left.start_byte()..left.end_byte()].trim();
1577                    if target_text == "__all__"
1578                        && let Some(right) = stmt_child.child_by_field_name("right")
1579                    {
1580                        extract_string_list_from_list_node(&right, source, result);
1581                    }
1582                }
1583            }
1584        }
1585    }
1586}
1587
1588/// Extract string literals from a Python list node.
1589fn extract_string_list_from_list_node(
1590    list_node: &tree_sitter::Node,
1591    source: &str,
1592    result: &mut Vec<String>,
1593) {
1594    let mut cursor = list_node.walk();
1595    for child in list_node.named_children(&mut cursor) {
1596        if child.kind() == "string" {
1597            let raw = source[child.start_byte()..child.end_byte()].trim();
1598            // Strip quotes: "name" -> name
1599            let unquoted = raw.trim_matches('"').trim_matches('\'').to_string();
1600            if !unquoted.is_empty() {
1601                result.push(unquoted);
1602            }
1603        }
1604    }
1605}
1606
1607/// Read a file and return its raw content with line numbers for a specified range.
1608#[cfg(test)]
1609mod tests {
1610    use super::*;
1611    use crate::formatter::format_focused_paginated;
1612    use crate::graph::InternalCallChain;
1613    use crate::pagination::{PaginationMode, decode_cursor, paginate_slice};
1614    use std::fs;
1615    use std::path::PathBuf;
1616    use tempfile::TempDir;
1617
1618    #[cfg(feature = "lang-rust")]
1619    #[test]
1620    fn analyze_str_rust_happy_path() {
1621        let source = "fn hello() -> i32 { 42 }";
1622        let result = analyze_str(source, "rs", None);
1623        assert!(result.is_ok());
1624    }
1625
1626    #[cfg(feature = "lang-python")]
1627    #[test]
1628    fn analyze_str_python_happy_path() {
1629        let source = "def greet(name):\n    return f'Hello {name}'";
1630        let result = analyze_str(source, "py", None);
1631        assert!(result.is_ok());
1632    }
1633
1634    #[cfg(feature = "lang-rust")]
1635    #[test]
1636    fn analyze_str_rust_by_language_name() {
1637        let source = "fn hello() -> i32 { 42 }";
1638        let result = analyze_str(source, "rust", None);
1639        assert!(result.is_ok());
1640    }
1641
1642    #[cfg(feature = "lang-python")]
1643    #[test]
1644    fn analyze_str_python_by_language_name() {
1645        let source = "def greet(name):\n    return f'Hello {name}'";
1646        let result = analyze_str(source, "python", None);
1647        assert!(result.is_ok());
1648    }
1649
1650    #[cfg(feature = "lang-rust")]
1651    #[test]
1652    fn analyze_str_rust_mixed_case() {
1653        let source = "fn hello() -> i32 { 42 }";
1654        let result = analyze_str(source, "RuSt", None);
1655        assert!(result.is_ok());
1656    }
1657
1658    #[cfg(feature = "lang-python")]
1659    #[test]
1660    fn analyze_str_python_mixed_case() {
1661        let source = "def greet(name):\n    return f'Hello {name}'";
1662        let result = analyze_str(source, "PyThOn", None);
1663        assert!(result.is_ok());
1664    }
1665
1666    #[test]
1667    fn analyze_str_unsupported_language() {
1668        let result = analyze_str("code", "brainfuck", None);
1669        assert!(
1670            matches!(result, Err(AnalyzeError::UnsupportedLanguage(lang)) if lang == "brainfuck")
1671        );
1672    }
1673
1674    #[cfg(feature = "lang-rust")]
1675    #[test]
1676    fn test_symbol_focus_callers_pagination_first_page() {
1677        let temp_dir = TempDir::new().unwrap();
1678
1679        // Create a file with many callers of `target`
1680        let mut code = String::from("fn target() {}\n");
1681        for i in 0..15 {
1682            code.push_str(&format!("fn caller_{:02}() {{ target(); }}\n", i));
1683        }
1684        fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
1685
1686        // Act
1687        let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1688
1689        // Paginate prod callers with page_size=5
1690        let paginated = paginate_slice(&output.prod_chains, 0, 5, PaginationMode::Callers)
1691            .expect("paginate failed");
1692        assert!(
1693            paginated.total >= 5,
1694            "should have enough callers to paginate"
1695        );
1696        assert!(
1697            paginated.next_cursor.is_some(),
1698            "should have next_cursor for page 1"
1699        );
1700
1701        // Verify cursor encodes callers mode
1702        assert_eq!(paginated.items.len(), 5);
1703    }
1704
1705    #[test]
1706    fn test_symbol_focus_callers_pagination_second_page() {
1707        let temp_dir = TempDir::new().unwrap();
1708
1709        let mut code = String::from("fn target() {}\n");
1710        for i in 0..12 {
1711            code.push_str(&format!("fn caller_{:02}() {{ target(); }}\n", i));
1712        }
1713        fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
1714
1715        let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1716        let total_prod = output.prod_chains.len();
1717
1718        if total_prod > 5 {
1719            // Get page 1 cursor
1720            let p1 = paginate_slice(&output.prod_chains, 0, 5, PaginationMode::Callers)
1721                .expect("paginate failed");
1722            assert!(p1.next_cursor.is_some());
1723
1724            let cursor_str = p1.next_cursor.unwrap();
1725            let cursor_data = decode_cursor(&cursor_str).expect("decode failed");
1726
1727            // Get page 2
1728            let p2 = paginate_slice(
1729                &output.prod_chains,
1730                cursor_data.offset,
1731                5,
1732                PaginationMode::Callers,
1733            )
1734            .expect("paginate failed");
1735
1736            // Format paginated output
1737            let formatted = format_focused_paginated(
1738                &p2.items,
1739                total_prod,
1740                PaginationMode::Callers,
1741                "target",
1742                &output.prod_chains,
1743                &output.test_chains,
1744                &output.outgoing_chains,
1745                output.def_count,
1746                cursor_data.offset,
1747                Some(temp_dir.path()),
1748                true,
1749            );
1750
1751            // Assert: header shows correct range for page 2
1752            let expected_start = cursor_data.offset + 1;
1753            assert!(
1754                formatted.contains(&format!("CALLERS ({}", expected_start)),
1755                "header should show page 2 range, got: {}",
1756                formatted
1757            );
1758        }
1759    }
1760
1761    #[test]
1762    fn test_chains_to_entries_empty_returns_none() {
1763        // Arrange
1764        let chains: Vec<InternalCallChain> = vec![];
1765
1766        // Act
1767        let result = chains_to_entries(&chains, None);
1768
1769        // Assert
1770        assert!(result.is_none());
1771    }
1772
1773    #[test]
1774    fn test_chains_to_entries_with_data_returns_entries() {
1775        // Arrange
1776        let chains = vec![
1777            InternalCallChain {
1778                chain: vec![("caller1".to_string(), PathBuf::from("/root/lib.rs"), 10)],
1779            },
1780            InternalCallChain {
1781                chain: vec![("caller2".to_string(), PathBuf::from("/root/other.rs"), 20)],
1782            },
1783        ];
1784        let root = PathBuf::from("/root");
1785
1786        // Act
1787        let result = chains_to_entries(&chains, Some(root.as_path()));
1788
1789        // Assert
1790        assert!(result.is_some());
1791        let entries = result.unwrap();
1792        assert_eq!(entries.len(), 2);
1793        assert_eq!(entries[0].symbol, "caller1");
1794        assert_eq!(entries[0].file, "lib.rs");
1795        assert_eq!(entries[0].line, 10);
1796        assert_eq!(entries[1].symbol, "caller2");
1797        assert_eq!(entries[1].file, "other.rs");
1798        assert_eq!(entries[1].line, 20);
1799    }
1800
1801    #[test]
1802    fn test_symbol_focus_callees_pagination() {
1803        let temp_dir = TempDir::new().unwrap();
1804
1805        // target calls many functions
1806        let mut code = String::from("fn target() {\n");
1807        for i in 0..10 {
1808            code.push_str(&format!("    callee_{:02}();\n", i));
1809        }
1810        code.push_str("}\n");
1811        for i in 0..10 {
1812            code.push_str(&format!("fn callee_{:02}() {{}}\n", i));
1813        }
1814        fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
1815
1816        let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1817        let total_callees = output.outgoing_chains.len();
1818
1819        if total_callees > 3 {
1820            let paginated = paginate_slice(&output.outgoing_chains, 0, 3, PaginationMode::Callees)
1821                .expect("paginate failed");
1822
1823            let formatted = format_focused_paginated(
1824                &paginated.items,
1825                total_callees,
1826                PaginationMode::Callees,
1827                "target",
1828                &output.prod_chains,
1829                &output.test_chains,
1830                &output.outgoing_chains,
1831                output.def_count,
1832                0,
1833                Some(temp_dir.path()),
1834                true,
1835            );
1836
1837            assert!(
1838                formatted.contains(&format!(
1839                    "CALLEES (1-{} of {})",
1840                    paginated.items.len(),
1841                    total_callees
1842                )),
1843                "header should show callees range, got: {}",
1844                formatted
1845            );
1846        }
1847    }
1848
1849    #[test]
1850    fn test_symbol_focus_empty_prod_callers() {
1851        let temp_dir = TempDir::new().unwrap();
1852
1853        // target is only called from test functions
1854        let code = r#"
1855fn target() {}
1856
1857#[cfg(test)]
1858mod tests {
1859    use super::*;
1860    #[test]
1861    fn test_something() { target(); }
1862}
1863"#;
1864        fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
1865
1866        let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1867
1868        // prod_chains may be empty; pagination should handle it gracefully
1869        let paginated = paginate_slice(&output.prod_chains, 0, 100, PaginationMode::Callers)
1870            .expect("paginate failed");
1871        assert_eq!(paginated.items.len(), output.prod_chains.len());
1872        assert!(
1873            paginated.next_cursor.is_none(),
1874            "no next_cursor for empty or single-page prod_chains"
1875        );
1876    }
1877
1878    #[test]
1879    fn test_impl_only_filter_header_correct_counts() {
1880        let temp_dir = TempDir::new().unwrap();
1881
1882        // Create a Rust fixture with:
1883        // - A trait definition
1884        // - An impl Trait for SomeType block that calls the focus symbol
1885        // - A regular (non-trait-impl) function that also calls the focus symbol
1886        let code = r#"
1887trait MyTrait {
1888    fn focus_symbol();
1889}
1890
1891struct SomeType;
1892
1893impl MyTrait for SomeType {
1894    fn focus_symbol() {}
1895}
1896
1897fn impl_caller() {
1898    SomeType::focus_symbol();
1899}
1900
1901fn regular_caller() {
1902    SomeType::focus_symbol();
1903}
1904"#;
1905        fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
1906
1907        // Call analyze_focused with impl_only=Some(true)
1908        let params = FocusedAnalysisConfig {
1909            focus: "focus_symbol".to_string(),
1910            match_mode: SymbolMatchMode::Insensitive,
1911            follow_depth: 1,
1912            max_depth: None,
1913            ast_recursion_limit: None,
1914            use_summary: false,
1915            impl_only: Some(true),
1916            def_use: false,
1917            parse_timeout_micros: None,
1918        };
1919        let output = analyze_focused_with_progress(
1920            temp_dir.path(),
1921            &params,
1922            Arc::new(AtomicUsize::new(0)),
1923            CancellationToken::new(),
1924        )
1925        .unwrap();
1926
1927        // Assert the result contains "FILTER: impl_only=true"
1928        assert!(
1929            output.formatted.contains("FILTER: impl_only=true"),
1930            "formatted output should contain FILTER header for impl_only=true, got: {}",
1931            output.formatted
1932        );
1933
1934        // Assert the retained count N < total count M
1935        assert!(
1936            output.impl_trait_caller_count < output.unfiltered_caller_count,
1937            "impl_trait_caller_count ({}) should be less than unfiltered_caller_count ({})",
1938            output.impl_trait_caller_count,
1939            output.unfiltered_caller_count
1940        );
1941
1942        // Assert format is "FILTER: impl_only=true (N of M callers shown)"
1943        let filter_line = output
1944            .formatted
1945            .lines()
1946            .find(|line| line.contains("FILTER: impl_only=true"))
1947            .expect("should find FILTER line");
1948        assert!(
1949            filter_line.contains(&format!(
1950                "({} of {} callers shown)",
1951                output.impl_trait_caller_count, output.unfiltered_caller_count
1952            )),
1953            "FILTER line should show correct N of M counts, got: {}",
1954            filter_line
1955        );
1956    }
1957
1958    #[test]
1959    fn test_callers_count_matches_formatted_output() {
1960        let temp_dir = TempDir::new().unwrap();
1961
1962        // Create a file with multiple callers of `target`
1963        let code = r#"
1964fn target() {}
1965fn caller_a() { target(); }
1966fn caller_b() { target(); }
1967fn caller_c() { target(); }
1968"#;
1969        fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
1970
1971        // Analyze the symbol
1972        let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
1973
1974        // Extract CALLERS count from formatted output
1975        let formatted = &output.formatted;
1976        let callers_count_from_output = formatted
1977            .lines()
1978            .find(|line| line.contains("FOCUS:"))
1979            .and_then(|line| {
1980                line.split(',')
1981                    .find(|part| part.contains("callers"))
1982                    .and_then(|part| {
1983                        part.trim()
1984                            .split_whitespace()
1985                            .next()
1986                            .and_then(|s| s.parse::<usize>().ok())
1987                    })
1988            })
1989            .expect("should find CALLERS count in formatted output");
1990
1991        // Compute expected count from prod_chains (unique first-caller names)
1992        let expected_callers_count = output
1993            .prod_chains
1994            .iter()
1995            .filter_map(|chain| chain.chain.first().map(|(name, _, _)| name))
1996            .collect::<std::collections::HashSet<_>>()
1997            .len();
1998
1999        assert_eq!(
2000            callers_count_from_output, expected_callers_count,
2001            "CALLERS count in formatted output should match unique-first-caller count in prod_chains"
2002        );
2003    }
2004
2005    #[cfg(feature = "lang-rust")]
2006    #[test]
2007    fn test_def_use_focused_analysis() {
2008        let temp_dir = TempDir::new().unwrap();
2009        fs::write(
2010            temp_dir.path().join("lib.rs"),
2011            "fn example() {\n    let x = 10;\n    x += 1;\n    println!(\"{}\", x);\n    let y = x + 1;\n}\n",
2012        )
2013        .unwrap();
2014
2015        let entries = walk_directory(temp_dir.path(), None).unwrap();
2016        let counter = Arc::new(AtomicUsize::new(0));
2017        let ct = CancellationToken::new();
2018        let params = FocusedAnalysisConfig {
2019            focus: "x".to_string(),
2020            match_mode: SymbolMatchMode::Exact,
2021            follow_depth: 1,
2022            max_depth: None,
2023            ast_recursion_limit: None,
2024            use_summary: false,
2025            impl_only: None,
2026            def_use: true,
2027            parse_timeout_micros: None,
2028        };
2029
2030        let output = analyze_focused_with_progress_with_entries(
2031            temp_dir.path(),
2032            &params,
2033            &counter,
2034            &ct,
2035            &entries,
2036        )
2037        .expect("def_use analysis should succeed");
2038
2039        assert!(
2040            !output.def_use_sites.is_empty(),
2041            "should find def-use sites for x"
2042        );
2043        assert!(
2044            output
2045                .def_use_sites
2046                .iter()
2047                .any(|s| s.kind == crate::types::DefUseKind::Write),
2048            "should have at least one Write site",
2049        );
2050        // No location appears as both write and read
2051        let write_locs: std::collections::HashSet<_> = output
2052            .def_use_sites
2053            .iter()
2054            .filter(|s| {
2055                matches!(
2056                    s.kind,
2057                    crate::types::DefUseKind::Write | crate::types::DefUseKind::WriteRead
2058                )
2059            })
2060            .map(|s| (&s.file, s.line, s.column))
2061            .collect();
2062        assert!(
2063            output
2064                .def_use_sites
2065                .iter()
2066                .filter(|s| s.kind == crate::types::DefUseKind::Read)
2067                .all(|s| !write_locs.contains(&(&s.file, s.line, s.column))),
2068            "no location should appear as both write and read",
2069        );
2070        assert!(
2071            output.formatted.contains("DEF-USE SITES"),
2072            "formatted output should contain DEF-USE SITES"
2073        );
2074    }
2075
2076    fn make_temp_file(content: &str) -> tempfile::NamedTempFile {
2077        let mut f = tempfile::NamedTempFile::new().unwrap();
2078        use std::io::Write;
2079        f.write_all(content.as_bytes()).unwrap();
2080        f.flush().unwrap();
2081        f
2082    }
2083}