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