Skip to main content

probador/handlers/
coverage.rs

1//! Coverage command handler
2
3#![allow(clippy::cast_precision_loss)]
4#![allow(clippy::cast_possible_truncation)]
5#![allow(clippy::cast_sign_loss)]
6
7use crate::config::CliConfig;
8use crate::error::{CliError, CliResult};
9use crate::{CoverageArgs, PaletteArg};
10use jugar_probar::pixel_coverage::{ColorPalette, CoverageCell, PixelCoverageReport, PngHeatmap};
11use std::path::Path;
12
13/// Execute the coverage command
14pub fn execute_coverage(_config: &CliConfig, args: &CoverageArgs) -> CliResult<()> {
15    println!("Generating coverage heatmap...");
16    let cells = load_cells(args)?;
17    let heatmap = build_heatmap(args);
18
19    if let Some(ref png_path) = args.png {
20        export_png(&heatmap, &cells, png_path)?;
21    }
22    if let Some(ref json_path) = args.json {
23        export_json(&cells, json_path)?;
24    }
25    if args.png.is_none() && args.json.is_none() {
26        print_summary(&cells);
27    }
28    Ok(())
29}
30
31fn load_cells(args: &CoverageArgs) -> CliResult<Vec<Vec<CoverageCell>>> {
32    let Some(ref input) = args.input else {
33        println!("No input file specified, using sample data");
34        return Ok(create_sample_coverage_data());
35    };
36    println!("Loading coverage data from {}...", input.display());
37    load_coverage_from_json(input)
38}
39
40fn build_heatmap(args: &CoverageArgs) -> PngHeatmap {
41    let palette = match args.palette {
42        PaletteArg::Viridis => ColorPalette::viridis(),
43        PaletteArg::Magma => ColorPalette::magma(),
44        PaletteArg::Heat => ColorPalette::heat(),
45    };
46    let mut heatmap = PngHeatmap::new(args.width, args.height).with_palette(palette);
47    if args.legend {
48        heatmap = heatmap.with_legend();
49    }
50    if args.gaps {
51        heatmap = heatmap.with_gap_highlighting();
52    }
53    if let Some(ref title) = args.title {
54        heatmap = heatmap.with_title(title);
55    }
56    heatmap
57}
58
59fn export_png(heatmap: &PngHeatmap, cells: &[Vec<CoverageCell>], png_path: &Path) -> CliResult<()> {
60    heatmap
61        .export_to_file(cells, png_path)
62        .map_err(|e| CliError::report_generation(e.to_string()))?;
63    println!("PNG heatmap exported to: {}", png_path.display());
64    Ok(())
65}
66
67fn export_json(cells: &[Vec<CoverageCell>], json_path: &Path) -> CliResult<()> {
68    let report = generate_coverage_report(cells);
69    let json = serde_json::to_string_pretty(&report)
70        .map_err(|e| CliError::report_generation(e.to_string()))?;
71    std::fs::write(json_path, json).map_err(|e| CliError::report_generation(e.to_string()))?;
72    println!("Coverage report exported to: {}", json_path.display());
73    Ok(())
74}
75
76fn print_summary(cells: &[Vec<CoverageCell>]) {
77    let report = generate_coverage_report(cells);
78    println!("\nCoverage Summary:");
79    println!(
80        "  Overall Coverage: {:.1}%",
81        report.overall_coverage * 100.0
82    );
83    println!(
84        "  Covered Cells: {}/{}",
85        report.covered_cells, report.total_cells
86    );
87    println!(
88        "  Meets Threshold: {}",
89        if report.meets_threshold { "Y" } else { "N" }
90    );
91    println!("\nUse --png <path> to export a heatmap image.");
92}
93
94/// Load coverage data from a JSON file
95pub fn load_coverage_from_json(path: &Path) -> CliResult<Vec<Vec<CoverageCell>>> {
96    #[derive(serde::Deserialize)]
97    struct CoverageData {
98        cells: Option<Vec<Vec<CoverageCell>>>,
99        #[serde(flatten)]
100        _extra: std::collections::HashMap<String, serde_json::Value>,
101    }
102
103    let content = std::fs::read_to_string(path).map_err(|e| {
104        CliError::report_generation(format!("Failed to read {}: {}", path.display(), e))
105    })?;
106
107    if let Ok(data) = serde_json::from_str::<CoverageData>(&content) {
108        if let Some(cells) = data.cells {
109            return Ok(cells);
110        }
111    }
112
113    serde_json::from_str::<Vec<Vec<CoverageCell>>>(&content)
114        .map_err(|e| CliError::report_generation(format!("Invalid JSON format: {e}")))
115}
116
117/// Check if a cell is in a gap region (no coverage)
118#[must_use]
119pub fn is_gap_cell(row: usize, col: usize) -> bool {
120    let middle_gap = row == 5 && (5..=7).contains(&col);
121    let end_gap = row == 2 && col > 10;
122    middle_gap || end_gap
123}
124
125/// Calculate coverage value for a cell based on position
126#[must_use]
127pub fn calculate_coverage(row: usize, col: usize, rows: usize, cols: usize) -> f32 {
128    if is_gap_cell(row, col) {
129        return 0.0;
130    }
131    let x_factor = col as f32 / (cols - 1) as f32;
132    let y_factor = row as f32 / (rows - 1) as f32;
133    (x_factor + y_factor) / 2.0
134}
135
136/// Create sample coverage data for demonstration
137#[must_use]
138pub fn create_sample_coverage_data() -> Vec<Vec<CoverageCell>> {
139    const ROWS: usize = 10;
140    const COLS: usize = 15;
141
142    (0..ROWS)
143        .map(|row| {
144            (0..COLS)
145                .map(|col| {
146                    let coverage = calculate_coverage(row, col, ROWS, COLS);
147                    CoverageCell {
148                        coverage,
149                        hit_count: (coverage * 10.0) as u64,
150                    }
151                })
152                .collect()
153        })
154        .collect()
155}
156
157/// Generate coverage report from cells
158#[must_use]
159pub fn generate_coverage_report(cells: &[Vec<CoverageCell>]) -> PixelCoverageReport {
160    let total_cells = cells.iter().map(std::vec::Vec::len).sum::<usize>() as u32;
161    let covered_cells = cells
162        .iter()
163        .flat_map(|r| r.iter())
164        .filter(|c| c.coverage > 0.0)
165        .count() as u32;
166
167    let overall_coverage = if total_cells > 0 {
168        covered_cells as f32 / total_cells as f32
169    } else {
170        0.0
171    };
172
173    PixelCoverageReport {
174        grid_width: cells.first().map_or(0, |r| r.len() as u32),
175        grid_height: cells.len() as u32,
176        overall_coverage,
177        covered_cells,
178        total_cells,
179        min_coverage: 0.0,
180        max_coverage: 1.0,
181        total_interactions: 0,
182        meets_threshold: overall_coverage >= 0.8,
183        uncovered_regions: Vec::new(),
184    }
185}
186
187#[cfg(test)]
188#[allow(clippy::unwrap_used, clippy::float_cmp)]
189mod tests {
190    use super::*;
191    use tempfile::TempDir;
192
193    #[test]
194    fn test_is_gap_cell_middle() {
195        assert!(is_gap_cell(5, 5));
196        assert!(is_gap_cell(5, 6));
197        assert!(is_gap_cell(5, 7));
198    }
199
200    #[test]
201    fn test_is_gap_cell_end() {
202        assert!(is_gap_cell(2, 11));
203        assert!(is_gap_cell(2, 12));
204        assert!(is_gap_cell(2, 100));
205    }
206
207    #[test]
208    fn test_is_gap_cell_not_gap() {
209        assert!(!is_gap_cell(0, 0));
210        assert!(!is_gap_cell(5, 4));
211        assert!(!is_gap_cell(5, 8));
212        assert!(!is_gap_cell(2, 10));
213        assert!(!is_gap_cell(3, 11));
214    }
215
216    #[test]
217    fn test_calculate_coverage_gap_cell() {
218        assert_eq!(calculate_coverage(5, 6, 10, 15), 0.0);
219        assert_eq!(calculate_coverage(2, 11, 10, 15), 0.0);
220    }
221
222    #[test]
223    fn test_calculate_coverage_corners() {
224        assert_eq!(calculate_coverage(0, 0, 10, 15), 0.0);
225        assert_eq!(calculate_coverage(9, 14, 10, 15), 1.0);
226    }
227
228    #[test]
229    fn test_calculate_coverage_middle() {
230        let coverage = calculate_coverage(4, 7, 10, 15);
231        assert!(coverage > 0.0);
232        assert!(coverage < 1.0);
233    }
234
235    #[test]
236    fn test_create_sample_coverage_data() {
237        let data = create_sample_coverage_data();
238        assert_eq!(data.len(), 10);
239        assert_eq!(data[0].len(), 15);
240
241        // Check corners
242        assert_eq!(data[0][0].coverage, 0.0);
243        assert_eq!(data[9][14].coverage, 1.0);
244
245        // Check gap cells have 0 coverage
246        assert_eq!(data[5][5].coverage, 0.0);
247        assert_eq!(data[5][6].coverage, 0.0);
248        assert_eq!(data[5][7].coverage, 0.0);
249    }
250
251    #[test]
252    fn test_generate_coverage_report() {
253        let data = create_sample_coverage_data();
254        let report = generate_coverage_report(&data);
255
256        assert_eq!(report.grid_width, 15);
257        assert_eq!(report.grid_height, 10);
258        assert_eq!(report.total_cells, 150);
259        assert!(report.covered_cells < 150); // Some cells are gaps
260        assert!(report.overall_coverage > 0.0);
261        assert!(report.overall_coverage < 1.0);
262    }
263
264    #[test]
265    fn test_generate_coverage_report_empty() {
266        let data: Vec<Vec<CoverageCell>> = vec![];
267        let report = generate_coverage_report(&data);
268
269        assert_eq!(report.total_cells, 0);
270        assert_eq!(report.covered_cells, 0);
271        assert_eq!(report.overall_coverage, 0.0);
272        assert!(!report.meets_threshold);
273    }
274
275    #[test]
276    fn test_generate_coverage_report_full_coverage() {
277        let data = vec![vec![
278            CoverageCell {
279                coverage: 1.0,
280                hit_count: 10,
281            };
282            10
283        ]];
284        let report = generate_coverage_report(&data);
285
286        assert_eq!(report.total_cells, 10);
287        assert_eq!(report.covered_cells, 10);
288        assert_eq!(report.overall_coverage, 1.0);
289        assert!(report.meets_threshold);
290    }
291
292    #[test]
293    fn test_load_coverage_from_json_array_format() {
294        let temp = TempDir::new().unwrap();
295        let path = temp.path().join("coverage.json");
296
297        let json = r#"[[{"coverage": 0.5, "hit_count": 5}]]"#;
298        std::fs::write(&path, json).unwrap();
299
300        let cells = load_coverage_from_json(&path).unwrap();
301        assert_eq!(cells.len(), 1);
302        assert_eq!(cells[0].len(), 1);
303        assert_eq!(cells[0][0].coverage, 0.5);
304    }
305
306    #[test]
307    fn test_load_coverage_from_json_wrapped_format() {
308        let temp = TempDir::new().unwrap();
309        let path = temp.path().join("coverage.json");
310
311        let json = r#"{"cells": [[{"coverage": 0.8, "hit_count": 8}]]}"#;
312        std::fs::write(&path, json).unwrap();
313
314        let cells = load_coverage_from_json(&path).unwrap();
315        assert_eq!(cells.len(), 1);
316        assert_eq!(cells[0][0].coverage, 0.8);
317    }
318
319    #[test]
320    fn test_load_coverage_from_json_not_found() {
321        let result = load_coverage_from_json(Path::new("/nonexistent/path.json"));
322        assert!(result.is_err());
323    }
324
325    #[test]
326    fn test_load_coverage_from_json_invalid() {
327        let temp = TempDir::new().unwrap();
328        let path = temp.path().join("invalid.json");
329
330        std::fs::write(&path, "not valid json").unwrap();
331
332        let result = load_coverage_from_json(&path);
333        assert!(result.is_err());
334    }
335
336    #[test]
337    fn test_execute_coverage_sample_data() {
338        let config = CliConfig::default();
339        let args = CoverageArgs {
340            png: None,
341            json: None,
342            palette: PaletteArg::Viridis,
343            legend: false,
344            gaps: false,
345            title: None,
346            width: 800,
347            height: 600,
348            input: None,
349        };
350
351        // Should not panic with sample data
352        let result = execute_coverage(&config, &args);
353        assert!(result.is_ok());
354    }
355
356    #[test]
357    fn test_execute_coverage_with_json_output() {
358        let temp = TempDir::new().unwrap();
359        let json_path = temp.path().join("output.json");
360
361        let config = CliConfig::default();
362        let args = CoverageArgs {
363            png: None,
364            json: Some(json_path.clone()),
365            palette: PaletteArg::Magma,
366            legend: true,
367            gaps: true,
368            title: Some("Test Coverage".to_string()),
369            width: 400,
370            height: 300,
371            input: None,
372        };
373
374        let result = execute_coverage(&config, &args);
375        assert!(result.is_ok());
376        assert!(json_path.exists());
377
378        let content = std::fs::read_to_string(&json_path).unwrap();
379        let _: PixelCoverageReport = serde_json::from_str(&content).unwrap();
380    }
381}