Skip to main content

features_cli/
scan.rs

1//! Unified scanning API for features
2//!
3//! This module provides a high-level interface for scanning features with configurable options.
4//! It consolidates the functionality from file_scanner and coverage_parser into a single,
5//! easy-to-use API.
6
7use anyhow::Result;
8use std::path::Path;
9
10use crate::coverage_parser::{self, map_coverage_to_features, parse_coverage_reports};
11use crate::file_scanner::{list_files_recursive, list_files_recursive_with_changes};
12use crate::models::Feature;
13
14/// Configuration options for scanning features
15#[derive(Debug, Clone)]
16pub struct ScanConfig<'a> {
17    /// Whether to include git history (changes, commits, stats)
18    pub skip_changes: bool,
19
20    /// Whether to add coverage information to features
21    pub should_add_coverage: bool,
22
23    /// Optional override for coverage directory location
24    /// If None, will search in multiple default locations
25    pub coverage_dir_override: Option<&'a Path>,
26
27    /// Current working directory (used for finding coverage)
28    pub current_dir: &'a Path,
29
30    /// Optional project directory (used for finding coverage)
31    pub project_dir: Option<&'a Path>,
32}
33
34impl<'a> ScanConfig<'a> {
35    /// Create a new ScanConfig with minimal required parameters
36    pub fn new(current_dir: &'a Path) -> Self {
37        Self {
38            skip_changes: false,
39            should_add_coverage: false,
40            coverage_dir_override: None,
41            current_dir,
42            project_dir: None,
43        }
44    }
45
46    /// Set whether to skip git history
47    pub fn skip_changes(mut self, skip: bool) -> Self {
48        self.skip_changes = skip;
49        self
50    }
51
52    /// Set whether to add coverage information
53    pub fn with_coverage(mut self, should_add: bool) -> Self {
54        self.should_add_coverage = should_add;
55        self
56    }
57
58    /// Set a specific coverage directory to use
59    pub fn coverage_dir(mut self, dir: &'a Path) -> Self {
60        self.coverage_dir_override = Some(dir);
61        self
62    }
63
64    /// Set the project directory for finding coverage
65    pub fn project_dir(mut self, dir: &'a Path) -> Self {
66        self.project_dir = Some(dir);
67        self
68    }
69}
70
71/// Scan features in a directory with the given configuration
72///
73/// # Arguments
74///
75/// * `base_path` - The directory to scan for features
76/// * `config` - Configuration options for the scan
77///
78/// # Returns
79///
80/// A vector of Feature objects representing the discovered features
81///
82/// # Example
83///
84/// ```no_run
85/// use features_cli::scan::{scan_features, ScanConfig};
86/// use std::path::Path;
87///
88/// let current_dir = std::env::current_dir().unwrap();
89/// let config = ScanConfig::new(&current_dir)
90///     .skip_changes(false)
91///     .with_coverage(true);
92///
93/// let features = scan_features(Path::new("./src"), config).unwrap();
94/// println!("Found {} features", features.len());
95/// ```
96pub fn scan_features(base_path: &Path, config: ScanConfig) -> Result<Vec<Feature>> {
97    // Step 1: Scan features with or without git history
98    let mut features = if config.skip_changes {
99        list_files_recursive(base_path)?
100    } else {
101        list_files_recursive_with_changes(base_path)?
102    };
103
104    // Step 2: Add coverage if requested
105    if config.should_add_coverage {
106        add_coverage_to_features(
107            &mut features,
108            base_path,
109            config.coverage_dir_override,
110            config.current_dir,
111            config.project_dir,
112        );
113    }
114
115    Ok(features)
116}
117
118/// Add coverage information to features
119///
120/// This function searches for coverage reports in multiple locations and
121/// updates the features with coverage statistics.
122fn add_coverage_to_features(
123    features: &mut [Feature],
124    base_path: &Path,
125    coverage_dir_override: Option<&Path>,
126    current_dir: &Path,
127    project_dir: Option<&Path>,
128) {
129    let coverage_dirs = if let Some(override_dir) = coverage_dir_override {
130        // If override is provided, only use that directory
131        vec![override_dir.to_path_buf()]
132    } else {
133        // Check multiple locations:
134        // 1. .coverage and coverage in base_path
135        // 2. .coverage and coverage in current directory (where executable is run)
136        // 3. .coverage and coverage in project_dir (if provided)
137        let mut dirs = vec![
138            base_path.join(".coverage"),
139            base_path.join("coverage"),
140            current_dir.join(".coverage"),
141            current_dir.join("coverage"),
142        ];
143
144        // Add project_dir coverage directories if provided
145        if let Some(proj_dir) = project_dir {
146            let proj_coverage = proj_dir.join(".coverage");
147            let proj_coverage_plain = proj_dir.join("coverage");
148
149            // Only add if different from already added paths
150            if !dirs.contains(&proj_coverage) {
151                dirs.push(proj_coverage);
152            }
153            if !dirs.contains(&proj_coverage_plain) {
154                dirs.push(proj_coverage_plain);
155            }
156        }
157
158        dirs
159    };
160
161    for coverage_dir in &coverage_dirs {
162        // Parse coverage reports if the directory exists
163        if let Ok(coverage_map) = parse_coverage_reports(coverage_dir, base_path)
164            && !coverage_map.is_empty()
165        {
166            // Use coverage from the first directory found
167            let feature_coverage = map_coverage_to_features(features, coverage_map, base_path);
168            update_features_with_coverage(features, &feature_coverage);
169            break; // Stop after finding coverage in one directory
170        }
171    }
172}
173
174/// Recursively update features with coverage data
175fn update_features_with_coverage(
176    features: &mut [Feature],
177    feature_coverage: &std::collections::HashMap<String, coverage_parser::CoverageStats>,
178) {
179    for feature in features {
180        if let Some(coverage) = feature_coverage.get(&feature.path) {
181            // Update or create stats
182            if let Some(stats) = &mut feature.stats {
183                stats.coverage = Some(coverage.clone());
184            } else {
185                feature.stats = Some(crate::models::Stats {
186                    files_count: None,
187                    lines_count: None,
188                    todos_count: None,
189                    commits: std::collections::BTreeMap::new(),
190                    coverage: Some(coverage.clone()),
191                });
192            }
193        }
194
195        // Recursively update nested features
196        update_features_with_coverage(&mut feature.features, feature_coverage);
197    }
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use std::path::PathBuf;
204
205    #[test]
206    fn test_scan_config_builder() {
207        let current_dir = PathBuf::from("/tmp");
208        let project_dir = PathBuf::from("/project");
209
210        let config = ScanConfig::new(&current_dir)
211            .skip_changes(true)
212            .with_coverage(true)
213            .project_dir(&project_dir);
214
215        assert!(config.skip_changes);
216        assert!(config.should_add_coverage);
217        assert!(config.project_dir.is_some());
218    }
219
220    #[test]
221    fn test_scan_config_defaults() {
222        let current_dir = PathBuf::from("/tmp");
223        let config = ScanConfig::new(&current_dir);
224
225        assert!(!config.skip_changes);
226        assert!(!config.should_add_coverage);
227        assert!(config.coverage_dir_override.is_none());
228        assert!(config.project_dir.is_none());
229    }
230
231    #[test]
232    fn test_scan_features_basic() {
233        let test_path = PathBuf::from("../../examples/tests_skip_changes/src");
234
235        if !test_path.exists() {
236            println!("Skipping test - test path does not exist");
237            return;
238        }
239
240        let current_dir = std::env::current_dir().unwrap();
241        let config = ScanConfig::new(&current_dir).skip_changes(true);
242
243        let result = scan_features(&test_path, config);
244        assert!(result.is_ok(), "Failed to scan features");
245
246        let features = result.unwrap();
247        assert!(!features.is_empty(), "Should find at least one feature");
248    }
249
250    #[test]
251    fn test_scan_features_with_changes() {
252        let test_path = PathBuf::from("../../examples/tests-with-changes/src");
253
254        if !test_path.exists() {
255            println!("Skipping test - test path does not exist");
256            return;
257        }
258
259        let current_dir = std::env::current_dir().unwrap();
260        let config = ScanConfig::new(&current_dir).skip_changes(false);
261
262        let result = scan_features(&test_path, config);
263        assert!(result.is_ok(), "Failed to scan features with changes");
264
265        let features = result.unwrap();
266        assert!(!features.is_empty(), "Should find at least one feature");
267
268        // At least one feature should have changes
269        let has_changes = features.iter().any(|f| !f.changes.is_empty());
270        assert!(has_changes, "At least one feature should have git history");
271    }
272}