Skip to main content

testgap_core/
lib.rs

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
50/// Run the full analysis pipeline on a project directory.
51///
52/// If `diff_base` is provided, only source files changed relative to that git ref
53/// are analyzed. Test files are always included for complete test mapping.
54pub 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    // Step 1: Scan
71    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    // Filter source files to only changed files when diff mode is active
83    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            // Match relative path against the changed set
88            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    // Step 2: Extract functions
122    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    // Step 3: Map tests to source functions
177    let test_mapping = test_mapper::map_tests_to_functions(&all_functions, &test_functions);
178
179    // Step 4: Detect gaps
180    let mut gaps = gap_detector::detect_gaps(&all_functions, &test_mapping, config);
181
182    // Step 5: AI analysis (optional)
183    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    // Clear all progress bars before report output
210    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}