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