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