1use crate::formatter::{format_file_details, format_structure};
9use crate::graph::InternalCallChain;
10use crate::lang::{language_for_extension, supported_languages};
11use crate::parser::{ElementExtractor, SemanticExtractor};
12use crate::test_detection::is_test_file;
13use crate::traversal::{WalkEntry, walk_directory};
14use crate::types::{AnalysisMode, FileInfo, SemanticAnalysis, SymbolMatchMode};
15use rayon::prelude::*;
16#[cfg(feature = "schemars")]
17use schemars::JsonSchema;
18use serde::{Deserialize, Serialize};
19use std::path::{Path, PathBuf};
20use std::sync::Arc;
21use std::sync::atomic::{AtomicUsize, Ordering};
22use std::time::Instant;
23use thiserror::Error;
24use tokio_util::sync::CancellationToken;
25use tracing::instrument;
26
27pub const MAX_FILE_SIZE_BYTES: u64 = 10_000_000;
28
29#[derive(Debug, Error)]
30#[non_exhaustive]
31pub enum AnalyzeError {
32 #[error("Traversal error: {0}")]
33 Traversal(#[from] crate::traversal::TraversalError),
34 #[error("Parser error: {0}")]
35 Parser(#[from] crate::parser::ParserError),
36 #[error("Graph error: {0}")]
37 Graph(#[from] crate::graph::GraphError),
38 #[error("Formatter error: {0}")]
39 Formatter(#[from] crate::formatter::FormatterError),
40 #[error("Analysis cancelled")]
41 Cancelled,
42 #[error("unsupported language: {0}")]
43 UnsupportedLanguage(String),
44 #[error("I/O error: {0}")]
45 Io(#[from] std::io::Error),
46 #[error("invalid range: start ({start}) > end ({end}); file has {total} lines")]
47 InvalidRange {
48 start: usize,
49 end: usize,
50 total: usize,
51 },
52 #[error("path is a directory, not a file: {0}")]
53 NotAFile(PathBuf),
54 #[error(
55 "file has {total_lines} lines; provide start_line and end_line, or call analyze_module first to locate the range"
56 )]
57 RangelessLargeFile { total_lines: usize },
58 #[error("parse timeout exceeded for {path}: {micros} microseconds")]
59 ParseTimeout { path: PathBuf, micros: u64 },
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
64#[cfg_attr(feature = "schemars", derive(JsonSchema))]
65#[non_exhaustive]
66pub struct AnalysisOutput {
67 #[cfg_attr(
68 feature = "schemars",
69 schemars(description = "Formatted text representation of the analysis")
70 )]
71 pub formatted: String,
72 #[cfg_attr(
73 feature = "schemars",
74 schemars(description = "List of files analyzed in the directory")
75 )]
76 pub files: Vec<FileInfo>,
77 #[serde(skip)]
79 #[serde(default)]
80 #[cfg_attr(feature = "schemars", schemars(skip))]
81 pub entries: Vec<WalkEntry>,
82 #[serde(skip)]
84 #[serde(default)]
85 #[cfg_attr(feature = "schemars", schemars(skip))]
86 pub subtree_counts: Option<Vec<(std::path::PathBuf, usize)>>,
87 #[serde(skip_serializing_if = "Option::is_none")]
88 #[cfg_attr(
89 feature = "schemars",
90 schemars(
91 description = "Opaque cursor token for the next page of results (absent when no more results)"
92 )
93 )]
94 pub next_cursor: Option<String>,
95}
96
97#[derive(Debug, Clone, Serialize, Deserialize)]
99#[cfg_attr(feature = "schemars", derive(JsonSchema))]
100#[non_exhaustive]
101pub struct FileAnalysisOutput {
102 #[cfg_attr(
103 feature = "schemars",
104 schemars(description = "Formatted text representation of the analysis")
105 )]
106 pub formatted: String,
107 #[cfg_attr(
108 feature = "schemars",
109 schemars(description = "Semantic analysis data including functions, classes, and imports")
110 )]
111 pub semantic: SemanticAnalysis,
112 #[cfg_attr(
113 feature = "schemars",
114 schemars(description = "Total line count of the analyzed file")
115 )]
116 #[cfg_attr(
117 feature = "schemars",
118 schemars(schema_with = "crate::schema_helpers::integer_schema")
119 )]
120 pub line_count: usize,
121 #[serde(skip_serializing_if = "Option::is_none")]
122 #[cfg_attr(
123 feature = "schemars",
124 schemars(
125 description = "Opaque cursor token for the next page of results (absent when no more results)"
126 )
127 )]
128 pub next_cursor: Option<String>,
129 #[serde(skip_serializing_if = "Option::is_none")]
130 #[cfg_attr(
131 feature = "schemars",
132 schemars(
133 description = "True when the file extension is not supported; semantic fields are empty and formatted contains a raw preview"
134 )
135 )]
136 pub unsupported: Option<bool>,
137}
138
139impl FileAnalysisOutput {
140 #[must_use]
142 pub fn new(
143 formatted: String,
144 semantic: SemanticAnalysis,
145 line_count: usize,
146 next_cursor: Option<String>,
147 ) -> Self {
148 Self {
149 formatted,
150 semantic,
151 line_count,
152 next_cursor,
153 unsupported: None,
154 }
155 }
156}
157#[derive(Debug, Clone, Copy, PartialEq, Eq)]
159enum SkipReason {
160 Oversized,
161 Unreadable,
162}
163
164fn check_file_eligibility(entry: &WalkEntry) -> Result<String, SkipReason> {
168 if entry.path.metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
170 tracing::debug!("skipping large file: {}", entry.path.display());
171 return Err(SkipReason::Oversized);
172 }
173
174 std::fs::read_to_string(&entry.path).map_err(|_| SkipReason::Unreadable)
176}
177
178fn process_file_entry(entry: &WalkEntry, source: &str) -> FileInfo {
180 let path_str = entry.path.display().to_string();
181 let line_count = source.lines().count();
182
183 let ext = entry.path.extension().and_then(|e| e.to_str());
185
186 let (language, function_count, class_count) = if let Some(ext_str) = ext
188 && let Some(lang) = language_for_extension(ext_str)
189 {
190 let lang_str = lang.to_string();
191 match ElementExtractor::extract_with_depth(source, &lang_str) {
192 Ok((func_count, class_count)) => (lang_str, func_count, class_count),
193 Err(_) => (lang_str, 0, 0),
194 }
195 } else {
196 (
197 ext.map(|e| e.to_lowercase())
198 .unwrap_or_else(|| "unknown".to_string()),
199 0,
200 0,
201 )
202 };
203
204 let is_test = is_test_file(&entry.path);
205
206 FileInfo {
207 path: path_str,
208 line_count,
209 function_count,
210 class_count,
211 language,
212 is_test,
213 }
214}
215
216fn analyze_single_file(
218 entry: &WalkEntry,
219 progress: &Arc<AtomicUsize>,
220 ct: &CancellationToken,
221) -> Option<FileInfo> {
222 if ct.is_cancelled() {
224 return None;
225 }
226
227 let source = match check_file_eligibility(entry) {
229 Ok(content) => content,
230 Err(_) => {
231 progress.fetch_add(1, Ordering::Relaxed);
232 return None;
233 }
234 };
235
236 let file_info = process_file_entry(entry, &source);
237 progress.fetch_add(1, Ordering::Relaxed);
238
239 Some(file_info)
240}
241
242fn init_analysis_context(entries: &[WalkEntry]) -> Vec<&WalkEntry> {
244 entries
245 .iter()
246 .filter(|e| !e.is_dir && !e.is_symlink)
247 .collect()
248}
249
250fn build_analysis_output(
252 entries: Vec<WalkEntry>,
253 analysis_results: Vec<FileInfo>,
254) -> AnalysisOutput {
255 let formatted = format_structure(&entries, &analysis_results, None);
256 AnalysisOutput {
257 formatted,
258 files: analysis_results,
259 entries,
260 next_cursor: None,
261 subtree_counts: None,
262 }
263}
264
265fn run_parallel_analysis(
267 file_entries: &[&WalkEntry],
268 progress: &Arc<AtomicUsize>,
269 ct: &CancellationToken,
270) -> Result<Vec<FileInfo>, AnalyzeError> {
271 let start = Instant::now();
272 tracing::debug!(file_count = file_entries.len(), "analysis start");
273
274 let _parse_span = tracing::info_span!("ast.parse_batch", count = file_entries.len()).entered();
275
276 let analysis_results: Vec<FileInfo> = file_entries
278 .par_iter()
279 .filter_map(|entry| analyze_single_file(entry, progress, ct))
280 .collect();
281
282 if ct.is_cancelled() {
284 return Err(AnalyzeError::Cancelled);
285 }
286
287 tracing::debug!(
288 file_count = file_entries.len(),
289 duration_ms = u64::try_from(start.elapsed().as_millis()).unwrap_or(u64::MAX),
290 "analysis complete"
291 );
292
293 Ok(analysis_results)
294}
295
296#[instrument(skip_all, fields(path = %root.display()))]
297#[allow(clippy::needless_pass_by_value)]
299pub fn analyze_directory_with_progress(
300 root: &Path,
301 entries: Vec<WalkEntry>,
302 progress: Arc<AtomicUsize>,
303 ct: CancellationToken,
304) -> Result<AnalysisOutput, AnalyzeError> {
305 if ct.is_cancelled() {
307 return Err(AnalyzeError::Cancelled);
308 }
309
310 tracing::debug!(root = %root.display(), "analysis start");
311
312 let file_entries = init_analysis_context(&entries);
313 let analysis_results = run_parallel_analysis(&file_entries, &progress, &ct)?;
314
315 let _format_span = tracing::info_span!("output.format").entered();
316
317 Ok(build_analysis_output(entries, analysis_results))
319}
320
321#[instrument(skip_all, fields(path = %root.display()))]
323pub fn analyze_directory(
324 root: &Path,
325 max_depth: Option<u32>,
326) -> Result<AnalysisOutput, AnalyzeError> {
327 let entries = walk_directory(root, max_depth)?;
328 let counter = Arc::new(AtomicUsize::new(0));
329 let ct = CancellationToken::new();
330 analyze_directory_with_progress(root, entries, counter, ct)
331}
332
333#[must_use]
335pub fn determine_mode(path: &str, focus: Option<&str>) -> AnalysisMode {
336 if focus.is_some() {
337 return AnalysisMode::SymbolFocus;
338 }
339
340 let path_obj = Path::new(path);
341 if path_obj.is_dir() {
342 AnalysisMode::Overview
343 } else {
344 AnalysisMode::FileDetails
345 }
346}
347
348#[instrument(skip_all, fields(path))]
350pub fn analyze_file(
351 path: &str,
352 ast_recursion_limit: Option<usize>,
353) -> Result<FileAnalysisOutput, AnalyzeError> {
354 let start = Instant::now();
355
356 if Path::new(path).metadata().map(|m| m.len()).unwrap_or(0) > MAX_FILE_SIZE_BYTES {
358 tracing::debug!("skipping large file: {}", path);
359 return Err(AnalyzeError::Parser(
360 crate::parser::ParserError::ParseError("file too large".to_string()),
361 ));
362 }
363
364 let source = std::fs::read_to_string(path)
365 .map_err(|e| AnalyzeError::Parser(crate::parser::ParserError::ParseError(e.to_string())))?;
366
367 let line_count = source.lines().count();
368
369 let ext = Path::new(path)
371 .extension()
372 .and_then(|e| e.to_str())
373 .and_then(language_for_extension)
374 .map_or_else(|| "unknown".to_string(), std::string::ToString::to_string);
375
376 let mut semantic = SemanticExtractor::extract(&source, &ext, ast_recursion_limit, None)?;
378
379 for r in &mut semantic.references {
381 r.location = path.to_string();
382 }
383
384 if ext == "python" {
386 resolve_wildcard_imports(Path::new(path), &mut semantic.imports);
387 }
388
389 let is_test = is_test_file(Path::new(path));
391
392 let parent_dir = Path::new(path).parent();
394
395 let formatted = format_file_details(path, &semantic, line_count, is_test, parent_dir);
397
398 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");
399
400 Ok(FileAnalysisOutput::new(
401 formatted, semantic, line_count, None,
402 ))
403}
404
405#[inline]
432pub fn analyze_str(
433 source: &str,
434 language: &str,
435 ast_recursion_limit: Option<usize>,
436) -> Result<FileAnalysisOutput, AnalyzeError> {
437 let lang = language_for_extension(language).or_else(|| {
440 let lower = language.to_ascii_lowercase();
441 supported_languages()
442 .iter()
443 .find(|&&name| name == lower)
444 .copied()
445 });
446 let lang = lang.ok_or_else(|| AnalyzeError::UnsupportedLanguage(language.to_string()))?;
447
448 let mut semantic = SemanticExtractor::extract(source, lang, ast_recursion_limit, None)?;
450
451 for r in &mut semantic.references {
453 r.location = "<memory>".to_string();
454 }
455
456 let line_count = source.lines().count();
458
459 let formatted = format_file_details("", &semantic, line_count, false, None);
461
462 Ok(FileAnalysisOutput::new(
463 formatted, semantic, line_count, None,
464 ))
465}
466
467#[non_exhaustive]
469#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
470#[cfg_attr(feature = "schemars", derive(JsonSchema))]
471pub struct CallChainEntry {
472 #[cfg_attr(
473 feature = "schemars",
474 schemars(description = "Symbol name of the caller or callee")
475 )]
476 pub symbol: String,
477 #[cfg_attr(
478 feature = "schemars",
479 schemars(description = "File path relative to the repository root")
480 )]
481 pub file: String,
482 #[cfg_attr(
483 feature = "schemars",
484 schemars(
485 description = "Line number of the definition or call site (1-indexed)",
486 schema_with = "crate::schema_helpers::integer_schema"
487 )
488 )]
489 pub line: usize,
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize)]
494#[cfg_attr(feature = "schemars", derive(JsonSchema))]
495#[non_exhaustive]
496pub struct FocusedAnalysisOutput {
497 #[cfg_attr(
498 feature = "schemars",
499 schemars(description = "Formatted text representation of the call graph analysis")
500 )]
501 pub formatted: String,
502 #[serde(skip_serializing_if = "Option::is_none")]
503 #[cfg_attr(
504 feature = "schemars",
505 schemars(
506 description = "Opaque cursor token for the next page of results (absent when no more results)"
507 )
508 )]
509 pub next_cursor: Option<String>,
510 #[serde(skip)]
513 #[serde(default)]
514 #[cfg_attr(feature = "schemars", schemars(skip))]
515 pub prod_chains: Vec<InternalCallChain>,
516 #[serde(skip)]
518 #[serde(default)]
519 #[cfg_attr(feature = "schemars", schemars(skip))]
520 pub test_chains: Vec<InternalCallChain>,
521 #[serde(skip)]
523 #[serde(default)]
524 #[cfg_attr(feature = "schemars", schemars(skip))]
525 pub outgoing_chains: Vec<InternalCallChain>,
526 #[serde(skip)]
528 #[serde(default)]
529 #[cfg_attr(feature = "schemars", schemars(skip))]
530 pub def_count: usize,
531 #[serde(skip)]
533 #[serde(default)]
534 #[cfg_attr(feature = "schemars", schemars(skip))]
535 pub unfiltered_caller_count: usize,
536 #[serde(skip)]
538 #[serde(default)]
539 #[cfg_attr(feature = "schemars", schemars(skip))]
540 pub impl_trait_caller_count: usize,
541 #[serde(skip_serializing_if = "Option::is_none")]
543 pub callers: Option<Vec<CallChainEntry>>,
544 #[serde(skip_serializing_if = "Option::is_none")]
546 pub test_callers: Option<Vec<CallChainEntry>>,
547 #[serde(skip_serializing_if = "Option::is_none")]
549 pub callees: Option<Vec<CallChainEntry>>,
550 #[serde(default)]
552 pub def_use_sites: Vec<crate::types::DefUseSite>,
553 #[serde(skip_serializing_if = "Option::is_none")]
564 #[cfg_attr(
565 feature = "schemars",
566 schemars(description = "Cache tier for this result: l1_memory, l2_disk, or miss")
567 )]
568 pub cache_tier: Option<String>,
569}
570
571#[derive(Clone)]
574pub struct FocusedAnalysisConfig {
575 pub focus: String,
576 pub match_mode: SymbolMatchMode,
577 pub follow_depth: u32,
578 pub max_depth: Option<u32>,
579 pub ast_recursion_limit: Option<usize>,
580 pub use_summary: bool,
581 pub impl_only: Option<bool>,
582 pub def_use: bool,
583 pub parse_timeout_micros: Option<u64>,
584}
585
586#[cfg(test)]
587pub(crate) use crate::analyze_focused::chains_to_entries;
588pub(crate) use crate::analyze_focused::resolve_wildcard_imports;
589pub use crate::analyze_focused::{
590 analyze_focused, analyze_focused_with_progress, analyze_focused_with_progress_with_entries,
591 analyze_import_lookup, analyze_module_file,
592};
593#[cfg(test)]
595mod tests {
596 use super::*;
597 use crate::formatter::format_focused_paginated;
598 use crate::graph::InternalCallChain;
599 use crate::pagination::{PaginationMode, decode_cursor, paginate_slice};
600 use std::fs;
601 use std::path::PathBuf;
602 use tempfile::TempDir;
603
604 #[test]
605 fn analyze_str_rust_happy_path() {
606 let source = "fn hello() -> i32 { 42 }";
607 let result = analyze_str(source, "rs", None);
608 assert!(result.is_ok());
609 }
610
611 #[test]
612 fn analyze_str_python_happy_path() {
613 let source = "def greet(name):\n return f'Hello {name}'";
614 let result = analyze_str(source, "py", None);
615 assert!(result.is_ok());
616 }
617
618 #[test]
619 fn analyze_str_rust_by_language_name() {
620 let source = "fn hello() -> i32 { 42 }";
621 let result = analyze_str(source, "rust", None);
622 assert!(result.is_ok());
623 }
624
625 #[test]
626 fn analyze_str_python_by_language_name() {
627 let source = "def greet(name):\n return f'Hello {name}'";
628 let result = analyze_str(source, "python", None);
629 assert!(result.is_ok());
630 }
631
632 #[test]
633 fn analyze_str_rust_mixed_case() {
634 let source = "fn hello() -> i32 { 42 }";
635 let result = analyze_str(source, "RuSt", None);
636 assert!(result.is_ok());
637 }
638
639 #[test]
640 fn analyze_str_python_mixed_case() {
641 let source = "def greet(name):\n return f'Hello {name}'";
642 let result = analyze_str(source, "PyThOn", None);
643 assert!(result.is_ok());
644 }
645
646 #[test]
647 fn analyze_str_unsupported_language() {
648 let result = analyze_str("code", "brainfuck", None);
649 assert!(
650 matches!(result, Err(AnalyzeError::UnsupportedLanguage(lang)) if lang == "brainfuck")
651 );
652 }
653
654 #[test]
655 fn test_symbol_focus_callers_pagination_first_page() {
656 let temp_dir = TempDir::new().unwrap();
657
658 let mut code = String::from("fn target() {}\n");
660 for i in 0..15 {
661 code.push_str(&format!("fn caller_{:02}() {{ target(); }}\n", i));
662 }
663 fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
664
665 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
667
668 let paginated = paginate_slice(&output.prod_chains, 0, 5, PaginationMode::Callers)
670 .expect("paginate failed");
671 assert!(
672 paginated.total >= 5,
673 "should have enough callers to paginate"
674 );
675 assert!(
676 paginated.next_cursor.is_some(),
677 "should have next_cursor for page 1"
678 );
679
680 assert_eq!(paginated.items.len(), 5);
682 }
683
684 #[test]
685 fn test_symbol_focus_callers_pagination_second_page() {
686 let temp_dir = TempDir::new().unwrap();
687
688 let mut code = String::from("fn target() {}\n");
689 for i in 0..12 {
690 code.push_str(&format!("fn caller_{:02}() {{ target(); }}\n", i));
691 }
692 fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
693
694 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
695 let total_prod = output.prod_chains.len();
696
697 if total_prod > 5 {
698 let p1 = paginate_slice(&output.prod_chains, 0, 5, PaginationMode::Callers)
700 .expect("paginate failed");
701 assert!(p1.next_cursor.is_some());
702
703 let cursor_str = p1.next_cursor.unwrap();
704 let cursor_data = decode_cursor(&cursor_str).expect("decode failed");
705
706 let p2 = paginate_slice(
708 &output.prod_chains,
709 cursor_data.offset,
710 5,
711 PaginationMode::Callers,
712 )
713 .expect("paginate failed");
714
715 let formatted = format_focused_paginated(
717 &p2.items,
718 total_prod,
719 PaginationMode::Callers,
720 "target",
721 &output.prod_chains,
722 &output.test_chains,
723 &output.outgoing_chains,
724 output.def_count,
725 cursor_data.offset,
726 Some(temp_dir.path()),
727 true,
728 );
729
730 let expected_start = cursor_data.offset + 1;
732 assert!(
733 formatted.contains(&format!("CALLERS ({}", expected_start)),
734 "header should show page 2 range, got: {}",
735 formatted
736 );
737 }
738 }
739
740 #[test]
741 fn test_chains_to_entries_empty_returns_none() {
742 let chains: Vec<InternalCallChain> = vec![];
744
745 let result = chains_to_entries(&chains, None);
747
748 assert!(result.is_none());
750 }
751
752 #[test]
753 fn test_chains_to_entries_with_data_returns_entries() {
754 let chains = vec![
756 InternalCallChain {
757 chain: vec![("caller1".to_string(), PathBuf::from("/root/lib.rs"), 10)],
758 },
759 InternalCallChain {
760 chain: vec![("caller2".to_string(), PathBuf::from("/root/other.rs"), 20)],
761 },
762 ];
763 let root = PathBuf::from("/root");
764
765 let result = chains_to_entries(&chains, Some(root.as_path()));
767
768 assert!(result.is_some());
770 let entries = result.unwrap();
771 assert_eq!(entries.len(), 2);
772 assert_eq!(entries[0].symbol, "caller1");
773 assert_eq!(entries[0].file, "lib.rs");
774 assert_eq!(entries[0].line, 10);
775 assert_eq!(entries[1].symbol, "caller2");
776 assert_eq!(entries[1].file, "other.rs");
777 assert_eq!(entries[1].line, 20);
778 }
779
780 #[test]
781 fn test_symbol_focus_callees_pagination() {
782 let temp_dir = TempDir::new().unwrap();
783
784 let mut code = String::from("fn target() {\n");
786 for i in 0..10 {
787 code.push_str(&format!(" callee_{:02}();\n", i));
788 }
789 code.push_str("}\n");
790 for i in 0..10 {
791 code.push_str(&format!("fn callee_{:02}() {{}}\n", i));
792 }
793 fs::write(temp_dir.path().join("lib.rs"), &code).unwrap();
794
795 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
796 let total_callees = output.outgoing_chains.len();
797
798 if total_callees > 3 {
799 let paginated = paginate_slice(&output.outgoing_chains, 0, 3, PaginationMode::Callees)
800 .expect("paginate failed");
801
802 let formatted = format_focused_paginated(
803 &paginated.items,
804 total_callees,
805 PaginationMode::Callees,
806 "target",
807 &output.prod_chains,
808 &output.test_chains,
809 &output.outgoing_chains,
810 output.def_count,
811 0,
812 Some(temp_dir.path()),
813 true,
814 );
815
816 assert!(
817 formatted.contains(&format!(
818 "CALLEES (1-{} of {})",
819 paginated.items.len(),
820 total_callees
821 )),
822 "header should show callees range, got: {}",
823 formatted
824 );
825 }
826 }
827
828 #[test]
829 fn test_symbol_focus_empty_prod_callers() {
830 let temp_dir = TempDir::new().unwrap();
831
832 let code = r#"
834fn target() {}
835
836#[cfg(test)]
837mod tests {
838 use super::*;
839 #[test]
840 fn test_something() { target(); }
841}
842"#;
843 fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
844
845 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
846
847 let paginated = paginate_slice(&output.prod_chains, 0, 100, PaginationMode::Callers)
849 .expect("paginate failed");
850 assert_eq!(paginated.items.len(), output.prod_chains.len());
851 assert!(
852 paginated.next_cursor.is_none(),
853 "no next_cursor for empty or single-page prod_chains"
854 );
855 }
856
857 #[test]
858 fn test_impl_only_filter_header_correct_counts() {
859 let temp_dir = TempDir::new().unwrap();
860
861 let code = r#"
866trait MyTrait {
867 fn focus_symbol();
868}
869
870struct SomeType;
871
872impl MyTrait for SomeType {
873 fn focus_symbol() {}
874}
875
876fn impl_caller() {
877 SomeType::focus_symbol();
878}
879
880fn regular_caller() {
881 SomeType::focus_symbol();
882}
883"#;
884 fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
885
886 let params = FocusedAnalysisConfig {
888 focus: "focus_symbol".to_string(),
889 match_mode: SymbolMatchMode::Insensitive,
890 follow_depth: 1,
891 max_depth: None,
892 ast_recursion_limit: None,
893 use_summary: false,
894 impl_only: Some(true),
895 def_use: false,
896 parse_timeout_micros: None,
897 };
898 let output = analyze_focused_with_progress(
899 temp_dir.path(),
900 ¶ms,
901 Arc::new(AtomicUsize::new(0)),
902 CancellationToken::new(),
903 )
904 .unwrap();
905
906 assert!(
908 output.formatted.contains("FILTER: impl_only=true"),
909 "formatted output should contain FILTER header for impl_only=true, got: {}",
910 output.formatted
911 );
912
913 assert!(
915 output.impl_trait_caller_count < output.unfiltered_caller_count,
916 "impl_trait_caller_count ({}) should be less than unfiltered_caller_count ({})",
917 output.impl_trait_caller_count,
918 output.unfiltered_caller_count
919 );
920
921 let filter_line = output
923 .formatted
924 .lines()
925 .find(|line| line.contains("FILTER: impl_only=true"))
926 .expect("should find FILTER line");
927 assert!(
928 filter_line.contains(&format!(
929 "({} of {} callers shown)",
930 output.impl_trait_caller_count, output.unfiltered_caller_count
931 )),
932 "FILTER line should show correct N of M counts, got: {}",
933 filter_line
934 );
935 }
936
937 #[test]
938 fn test_callers_count_matches_formatted_output() {
939 let temp_dir = TempDir::new().unwrap();
940
941 let code = r#"
943fn target() {}
944fn caller_a() { target(); }
945fn caller_b() { target(); }
946fn caller_c() { target(); }
947"#;
948 fs::write(temp_dir.path().join("lib.rs"), code).unwrap();
949
950 let output = analyze_focused(temp_dir.path(), "target", 1, None, None).unwrap();
952
953 let formatted = &output.formatted;
955 let callers_count_from_output = formatted
956 .lines()
957 .find(|line| line.contains("FOCUS:"))
958 .and_then(|line| {
959 line.split(',')
960 .find(|part| part.contains("callers"))
961 .and_then(|part| {
962 part.trim()
963 .split_whitespace()
964 .next()
965 .and_then(|s| s.parse::<usize>().ok())
966 })
967 })
968 .expect("should find CALLERS count in formatted output");
969
970 let expected_callers_count = output
972 .prod_chains
973 .iter()
974 .filter_map(|chain| chain.chain.first().map(|(name, _, _)| name))
975 .collect::<std::collections::HashSet<_>>()
976 .len();
977
978 assert_eq!(
979 callers_count_from_output, expected_callers_count,
980 "CALLERS count in formatted output should match unique-first-caller count in prod_chains"
981 );
982 }
983
984 #[test]
985 fn test_def_use_focused_analysis() {
986 let temp_dir = TempDir::new().unwrap();
987 fs::write(
988 temp_dir.path().join("lib.rs"),
989 "fn example() {\n let x = 10;\n x += 1;\n println!(\"{}\", x);\n let y = x + 1;\n}\n",
990 )
991 .unwrap();
992
993 let entries = walk_directory(temp_dir.path(), None).unwrap();
994 let counter = Arc::new(AtomicUsize::new(0));
995 let ct = CancellationToken::new();
996 let params = FocusedAnalysisConfig {
997 focus: "x".to_string(),
998 match_mode: SymbolMatchMode::Exact,
999 follow_depth: 1,
1000 max_depth: None,
1001 ast_recursion_limit: None,
1002 use_summary: false,
1003 impl_only: None,
1004 def_use: true,
1005 parse_timeout_micros: None,
1006 };
1007
1008 let output = analyze_focused_with_progress_with_entries(
1009 temp_dir.path(),
1010 ¶ms,
1011 &counter,
1012 &ct,
1013 &entries,
1014 )
1015 .expect("def_use analysis should succeed");
1016
1017 assert!(
1018 !output.def_use_sites.is_empty(),
1019 "should find def-use sites for x"
1020 );
1021 assert!(
1022 output
1023 .def_use_sites
1024 .iter()
1025 .any(|s| s.kind == crate::types::DefUseKind::Write),
1026 "should have at least one Write site",
1027 );
1028 let write_locs: std::collections::HashSet<_> = output
1030 .def_use_sites
1031 .iter()
1032 .filter(|s| {
1033 matches!(
1034 s.kind,
1035 crate::types::DefUseKind::Write | crate::types::DefUseKind::WriteRead
1036 )
1037 })
1038 .map(|s| (&s.file, s.line, s.column))
1039 .collect();
1040 assert!(
1041 output
1042 .def_use_sites
1043 .iter()
1044 .filter(|s| s.kind == crate::types::DefUseKind::Read)
1045 .all(|s| !write_locs.contains(&(&s.file, s.line, s.column))),
1046 "no location should appear as both write and read",
1047 );
1048 assert!(
1049 output.formatted.contains("DEF-USE SITES"),
1050 "formatted output should contain DEF-USE SITES"
1051 );
1052 }
1053
1054 fn make_temp_file(content: &str) -> tempfile::NamedTempFile {
1055 let mut f = tempfile::NamedTempFile::new().unwrap();
1056 use std::io::Write;
1057 f.write_all(content.as_bytes()).unwrap();
1058 f.flush().unwrap();
1059 f
1060 }
1061}