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