1pub mod ai_reasoner;
2pub mod config;
3pub mod function_extractor;
4pub mod gap_detector;
5pub mod git_diff;
6pub mod language_registry;
7pub mod reporter;
8pub mod test_mapper;
9pub mod types;
10
11use config::TestGapConfig;
12use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
13use rayon::prelude::*;
14use std::path::Path;
15use types::AnalysisReport;
16
17#[derive(Debug, thiserror::Error)]
18pub enum TestGapError {
19 #[error("IO error: {0}")]
20 Io(#[from] std::io::Error),
21
22 #[error("Config error: {0}")]
23 Config(String),
24
25 #[error("Parse error in {file}: {message}")]
26 Parse { file: String, message: String },
27
28 #[error("AI API error: {0}")]
29 AiApi(String),
30
31 #[error("No supported files found in {0}")]
32 NoFiles(String),
33}
34
35pub type Result<T> = std::result::Result<T, TestGapError>;
36
37fn spinner_style() -> ProgressStyle {
38 ProgressStyle::with_template("{spinner:.cyan} {msg}")
39 .unwrap()
40 .tick_chars("\u{25DC}\u{25DD}\u{25DE}\u{25DF}\u{2714} ")
41}
42
43fn bar_style() -> ProgressStyle {
44 ProgressStyle::with_template("{spinner:.cyan} {msg} [{bar:20.cyan/dim}] {pos}/{len}")
45 .unwrap()
46 .tick_chars("\u{25DC}\u{25DD}\u{25DE}\u{25DF}\u{2714} ")
47 .progress_chars("\u{2588}\u{2591} ")
48}
49
50pub async fn analyze(
55 path: &Path,
56 config: &TestGapConfig,
57 diff_base: Option<&str>,
58) -> Result<AnalysisReport> {
59 let path = path.canonicalize().map_err(|e| {
60 TestGapError::Io(std::io::Error::new(
61 e.kind(),
62 format!("{}: {e}", path.display()),
63 ))
64 })?;
65
66 tracing::info!("Analyzing {}", path.display());
67
68 let mp = MultiProgress::new();
69
70 let scan_spinner = mp.add(ProgressBar::new_spinner());
72 scan_spinner.set_style(spinner_style());
73 scan_spinner.set_message("Scanning\u{2026}");
74 scan_spinner.enable_steady_tick(std::time::Duration::from_millis(80));
75
76 let mut files = test_mapper::scan_directory(&path, config)?;
77 if files.source_files.is_empty() {
78 scan_spinner.finish_and_clear();
79 return Err(TestGapError::NoFiles(path.display().to_string()));
80 }
81
82 if let Some(base) = diff_base {
84 let changed = git_diff::get_changed_files(&path, base)?;
85 let before = files.source_files.len();
86 files.source_files.retain(|f| {
87 f.path
89 .strip_prefix(&path)
90 .ok()
91 .map(|rel| changed.contains(rel))
92 .unwrap_or(false)
93 });
94 tracing::info!(
95 "Diff filter: {before} → {} source files (base: {base})",
96 files.source_files.len()
97 );
98 if files.source_files.is_empty() {
99 scan_spinner.finish_and_clear();
100 eprintln!("No changed source files found relative to {base}.");
101 return Ok(AnalysisReport {
102 project_path: path,
103 total_functions: 0,
104 tested_functions: 0,
105 gaps: vec![],
106 languages_analyzed: vec![],
107 ai_enabled: config.ai.enabled,
108 token_usage: None,
109 diff_base: diff_base.map(String::from),
110 });
111 }
112 }
113
114 scan_spinner.set_style(spinner_style());
115 scan_spinner.finish_with_message(format!(
116 "\u{2714} Scanned: {} source + {} test files",
117 files.source_files.len(),
118 files.test_files.len(),
119 ));
120
121 let total_files = files.source_files.len() + files.test_files.len();
123 let extract_bar = mp.add(ProgressBar::new(total_files as u64));
124 extract_bar.set_style(bar_style());
125 extract_bar.set_message("Extracting");
126
127 let source_results: Vec<_> = files
128 .source_files
129 .par_iter()
130 .map(|file| {
131 let result = function_extractor::extract_functions(file);
132 extract_bar.inc(1);
133 (file, result)
134 })
135 .collect();
136
137 let mut all_functions = Vec::new();
138 let mut languages_seen = std::collections::HashSet::new();
139
140 for (file, result) in source_results {
141 match result {
142 Ok(funcs) => {
143 for f in &funcs {
144 languages_seen.insert(f.language);
145 }
146 all_functions.extend(funcs);
147 }
148 Err(e) => {
149 tracing::warn!("Skipping {}: {e}", file.path.display());
150 }
151 }
152 }
153
154 let test_results: Vec<_> = files
155 .test_files
156 .par_iter()
157 .map(|file| {
158 let result = function_extractor::extract_functions(file);
159 extract_bar.inc(1);
160 (file, result)
161 })
162 .collect();
163
164 let mut test_functions = Vec::new();
165 for (file, result) in test_results {
166 match result {
167 Ok(funcs) => test_functions.extend(funcs),
168 Err(e) => {
169 tracing::warn!("Skipping test file {}: {e}", file.path.display());
170 }
171 }
172 }
173
174 extract_bar.finish_and_clear();
175
176 let test_mapping = test_mapper::map_tests_to_functions(&all_functions, &test_functions);
178
179 let mut gaps = gap_detector::detect_gaps(&all_functions, &test_mapping, config);
181
182 let mut token_usage = None;
184 if config.ai.enabled {
185 let ai_min = config.ai.ai_min_severity;
186 let mut ai_gaps: Vec<_> = gaps.iter_mut().filter(|g| g.severity >= ai_min).collect();
187 if !ai_gaps.is_empty() {
188 let ai_bar = mp.add(ProgressBar::new(ai_gaps.len() as u64));
189 ai_bar.set_style(bar_style());
190 ai_bar.set_message("AI analysis");
191
192 match ai_reasoner::analyze_gaps(&mut ai_gaps, config, Some(&ai_bar)).await {
193 Ok(usage) => token_usage = Some(usage),
194 Err(e) => {
195 tracing::warn!("AI analysis failed, continuing without it: {e}");
196 }
197 }
198 ai_bar.finish_and_clear();
199 }
200 }
201
202 let source_functions: Vec<_> = all_functions.iter().filter(|f| !f.is_test).collect();
203 let total_functions = source_functions.len();
204 let tested_functions = source_functions
205 .iter()
206 .filter(|f| test_mapping.contains(&test_mapper::function_key(f)))
207 .count();
208
209 mp.clear().ok();
211
212 Ok(AnalysisReport {
213 project_path: path,
214 total_functions,
215 tested_functions,
216 gaps,
217 languages_analyzed: languages_seen.into_iter().collect(),
218 ai_enabled: config.ai.enabled,
219 token_usage,
220 diff_base: diff_base.map(String::from),
221 })
222}