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