chicago_tdd_tools/validation/
coverage.rs

1//! Coverage Analysis
2//!
3//! Provides test coverage analysis and reporting.
4//!
5//! # Poka-Yoke: Type-Level Validation
6//!
7//! This module uses newtypes to prevent count and percentage errors at compile time.
8//! Use `TotalCount`, `CoveredCount`, and `CoveragePercentage` instead of raw `usize`/`f64`.
9
10use std::collections::HashMap;
11
12// ============================================================================
13// Poka-Yoke: Type-Level Validation
14// ============================================================================
15
16/// Total count newtype
17///
18/// **Poka-Yoke**: Use this newtype instead of `usize` to prevent count errors.
19/// Ensures total count is always >= 0 and >= covered count.
20///
21/// # Example
22///
23/// ```rust
24/// use chicago_tdd_tools::coverage::{TotalCount, CoveredCount};
25///
26/// let total = TotalCount::new(100).unwrap();
27/// let covered = CoveredCount::new(80).unwrap();
28///
29/// // Validate: covered <= total
30/// assert!(covered.get() <= total.get());
31/// assert_eq!(total.get(), 100);
32/// assert_eq!(covered.get(), 80);
33/// ```
34#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
35pub struct TotalCount(usize);
36
37impl TotalCount {
38    /// Zero total count constant
39    ///
40    /// **Poka-Yoke**: Infallible constructor - no Option wrapping needed.
41    /// Use this instead of `TotalCount::new(0).unwrap()`.
42    pub const ZERO: Self = Self(0);
43
44    /// Create a new total count (legacy API - prefer `from_usize`)
45    ///
46    /// **Note**: This always returns `Some`. For infallible construction, use `from_usize()`.
47    #[must_use]
48    #[allow(clippy::unnecessary_wraps)] // API design - Option allows future validation without breaking changes
49    pub const fn new(value: usize) -> Option<Self> {
50        Some(Self(value))
51    }
52
53    /// Create a new total count (infallible)
54    ///
55    /// **Poka-Yoke**: Infallible constructor - use this instead of `.new().unwrap()`.
56    /// Any `usize` value is valid for total count.
57    #[must_use]
58    pub const fn from_usize(value: usize) -> Self {
59        Self(value)
60    }
61
62    /// Get the count value
63    #[must_use]
64    #[allow(clippy::trivially_copy_pass_by_ref)] // Const fn - cannot change signature to pass by value
65    pub const fn get(&self) -> usize {
66        self.0
67    }
68
69    /// Convert to usize
70    #[must_use]
71    pub const fn into_usize(self) -> usize {
72        self.0
73    }
74}
75
76impl From<TotalCount> for usize {
77    fn from(count: TotalCount) -> Self {
78        count.0
79    }
80}
81
82/// Covered count newtype
83///
84/// **Poka-Yoke**: Use this newtype instead of `usize` to prevent count errors.
85/// Ensures covered count is always <= total count.
86///
87/// # Example
88///
89/// ```rust
90/// use chicago_tdd_tools::coverage::{TotalCount, CoveredCount};
91///
92/// let total = TotalCount::new(100).unwrap();
93/// let covered = CoveredCount::new_for_total(80, total).unwrap();
94///
95/// // Validated: covered <= total
96/// assert_eq!(covered.get(), 80);
97/// assert_eq!(total.get(), 100);
98/// ```
99#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
100pub struct CoveredCount(usize);
101
102impl CoveredCount {
103    /// Zero covered count constant
104    ///
105    /// **Poka-Yoke**: Infallible constructor - no Option wrapping needed.
106    /// Use this instead of `CoveredCount::new(0).unwrap()`.
107    pub const ZERO: Self = Self(0);
108
109    /// Create a new covered count (legacy API - prefer `from_usize`)
110    ///
111    /// **Note**: This always returns `Some`. For infallible construction, use `from_usize()`.
112    #[must_use]
113    #[allow(clippy::unnecessary_wraps)] // API design - Option allows future validation without breaking changes
114    pub const fn new(value: usize) -> Option<Self> {
115        Some(Self(value))
116    }
117
118    /// Create a new covered count (infallible)
119    ///
120    /// **Poka-Yoke**: Infallible constructor - use this instead of `.new().unwrap()`.
121    /// Any `usize` value is valid for covered count (validation against total happens separately).
122    #[must_use]
123    pub const fn from_usize(value: usize) -> Self {
124        Self(value)
125    }
126
127    /// Create a new covered count validated against total count
128    ///
129    /// Returns `None` if covered > total.
130    ///
131    /// # Example
132    ///
133    /// ```rust
134    /// use chicago_tdd_tools::coverage::{TotalCount, CoveredCount};
135    ///
136    /// let total = TotalCount::new(100).unwrap();
137    /// let covered = CoveredCount::new_for_total(80, total).unwrap(); // Valid
138    /// assert_eq!(covered.get(), 80);
139    /// let invalid = CoveredCount::new_for_total(150, total); // None - 150 > 100
140    /// assert!(invalid.is_none());
141    /// ```
142    #[must_use]
143    pub const fn new_for_total(covered: usize, total: TotalCount) -> Option<Self> {
144        if covered <= total.get() {
145            Some(Self(covered))
146        } else {
147            None
148        }
149    }
150
151    /// Get the count value
152    #[must_use]
153    #[allow(clippy::trivially_copy_pass_by_ref)] // Const fn - cannot change signature to pass by value
154    pub const fn get(&self) -> usize {
155        self.0
156    }
157
158    /// Convert to usize
159    #[must_use]
160    pub const fn into_usize(self) -> usize {
161        self.0
162    }
163}
164
165impl From<CoveredCount> for usize {
166    fn from(count: CoveredCount) -> Self {
167        count.0
168    }
169}
170
171/// Coverage percentage newtype
172///
173/// **Poka-Yoke**: Use this newtype instead of `f64` to prevent invalid percentage values.
174/// Ensures percentage is always in range [0.0, 100.0].
175///
176/// # Example
177///
178/// ```rust
179/// use chicago_tdd_tools::coverage::{CoveragePercentage, TotalCount, CoveredCount};
180///
181/// let total = TotalCount::new(100).unwrap();
182/// let covered = CoveredCount::new_for_total(80, total).unwrap();
183/// let percentage = CoveragePercentage::from_counts(covered, total).unwrap();
184///
185/// assert_eq!(percentage.get(), 80.0);
186/// assert!(percentage.get() >= 0.0);
187/// assert!(percentage.get() <= 100.0);
188/// ```
189#[derive(Debug, Clone, Copy, PartialEq, PartialOrd)]
190pub struct CoveragePercentage(f64);
191
192impl CoveragePercentage {
193    /// Minimum valid percentage value
194    pub const MIN: f64 = 0.0;
195
196    /// Maximum valid percentage value
197    pub const MAX: f64 = 100.0;
198
199    /// Zero percentage constant (0.0%)
200    ///
201    /// **Poka-Yoke**: Infallible constructor - no Option wrapping needed.
202    /// Use this instead of `CoveragePercentage::new(0.0).unwrap()`.
203    pub const ZERO: Self = Self(0.0);
204
205    /// Create a new coverage percentage from a value
206    ///
207    /// Returns `None` if value is outside [0.0, 100.0].
208    ///
209    /// # Example
210    ///
211    /// ```rust
212    /// use chicago_tdd_tools::coverage::CoveragePercentage;
213    ///
214    /// let valid = CoveragePercentage::new(80.0).unwrap();
215    /// assert_eq!(valid.get(), 80.0);
216    ///
217    /// let invalid = CoveragePercentage::new(150.0); // None - > 100%
218    /// assert!(invalid.is_none());
219    ///
220    /// let invalid_negative = CoveragePercentage::new(-10.0); // None - < 0%
221    /// assert!(invalid_negative.is_none());
222    /// ```
223    #[must_use]
224    pub fn new(value: f64) -> Option<Self> {
225        if (Self::MIN..=Self::MAX).contains(&value) {
226            Some(Self(value))
227        } else {
228            None
229        }
230    }
231
232    /// Create a coverage percentage from covered and total counts
233    ///
234    /// Returns `None` if total is 0 (division by zero) or if calculated percentage is invalid.
235    ///
236    /// # Example
237    ///
238    /// ```rust
239    /// use chicago_tdd_tools::coverage::{CoveragePercentage, TotalCount, CoveredCount};
240    ///
241    /// let total = TotalCount::new(100).unwrap();
242    /// let covered = CoveredCount::new_for_total(80, total).unwrap();
243    /// let percentage = CoveragePercentage::from_counts(covered, total).unwrap();
244    ///
245    /// assert_eq!(percentage.get(), 80.0);
246    ///
247    /// // Division by zero case
248    /// let zero_total = TotalCount::new(0).unwrap();
249    /// let zero_covered = CoveredCount::new(0).unwrap();
250    /// let result = CoveragePercentage::from_counts(zero_covered, zero_total);
251    /// assert!(result.is_none()); // Cannot calculate percentage for zero total
252    /// ```
253    #[must_use]
254    pub fn from_counts(covered: CoveredCount, total: TotalCount) -> Option<Self> {
255        if total.get() == 0 {
256            return None; // Division by zero
257        }
258
259        #[allow(clippy::cast_precision_loss)]
260        // Percentage calculation - precision loss acceptable for coverage percentages
261        let percentage = (covered.get() as f64 / total.get() as f64) * 100.0;
262        Self::new(percentage)
263    }
264
265    /// Get the percentage value
266    #[must_use]
267    #[allow(clippy::trivially_copy_pass_by_ref)] // Const fn - cannot change signature to pass by value
268    pub const fn get(&self) -> f64 {
269        self.0
270    }
271
272    /// Convert to f64
273    #[must_use]
274    pub const fn into_f64(self) -> f64 {
275        self.0
276    }
277}
278
279impl From<CoveragePercentage> for f64 {
280    fn from(percentage: CoveragePercentage) -> Self {
281        percentage.0
282    }
283}
284
285/// Coverage report
286#[derive(Debug, Clone)]
287pub struct CoverageReport {
288    /// Total items
289    /// **Poka-Yoke**: Uses `TotalCount` newtype to prevent count errors
290    pub total: TotalCount,
291    /// Covered items
292    /// **Poka-Yoke**: Uses `CoveredCount` newtype to prevent count errors
293    pub covered: CoveredCount,
294    /// Coverage percentage
295    /// **Poka-Yoke**: Uses `CoveragePercentage` newtype to prevent invalid percentage values
296    pub percentage: CoveragePercentage,
297    /// Coverage details
298    pub details: HashMap<String, bool>,
299}
300
301impl CoverageReport {
302    /// Create new coverage report
303    ///
304    /// **Poka-Yoke**: Uses const ZERO constructors - no panic possible.
305    #[must_use]
306    pub fn new() -> Self {
307        Self {
308            // Poka-Yoke: Infallible constructors - no unwrap/expect needed
309            total: TotalCount::ZERO,
310            covered: CoveredCount::ZERO,
311            percentage: CoveragePercentage::ZERO,
312            details: HashMap::new(),
313        }
314    }
315
316    /// Add coverage item
317    /// Add an item to the coverage report
318    ///
319    /// **Poka-Yoke**: Uses infallible constructors - no panic possible.
320    pub fn add_item(&mut self, name: String, covered: bool) {
321        self.details.insert(name, covered);
322        let new_total = self.total.get() + 1;
323        // Poka-Yoke: Infallible constructor - any usize is valid
324        self.total = TotalCount::from_usize(new_total);
325        if covered {
326            let new_covered = self.covered.get() + 1;
327            // Validate: covered <= total
328            if let Some(new_covered_count) = CoveredCount::new_for_total(new_covered, self.total) {
329                self.covered = new_covered_count;
330            }
331        }
332        // Update percentage using Poka-Yoke validated type
333        if self.total.get() > 0 {
334            // Poka-Yoke: from_counts validates percentage range
335            if let Some(percentage) = CoveragePercentage::from_counts(self.covered, self.total) {
336                self.percentage = percentage;
337            }
338        } else {
339            // Total is 0, percentage is 0.0
340            self.percentage = CoveragePercentage::ZERO;
341        }
342    }
343
344    /// Generate markdown report
345    #[must_use]
346    pub fn generate_markdown(&self) -> String {
347        format!(
348            "# Coverage Report\n\n**Coverage**: {:.2}% ({} / {})\n\n## Details\n\n",
349            self.percentage.get(),
350            self.covered.get(),
351            self.total.get()
352        )
353    }
354}
355
356impl Default for CoverageReport {
357    fn default() -> Self {
358        Self::new()
359    }
360}
361
362#[cfg(test)]
363#[allow(clippy::panic)] // Test code - panic is appropriate for test failures
364#[allow(clippy::unwrap_used)] // Test code - unwrap is acceptable for test setup
365#[allow(clippy::float_cmp)] // Test code - exact float comparison is intentional
366mod tests {
367    use super::*;
368
369    #[test]
370    fn test_total_count() {
371        let total = TotalCount::new(100).unwrap();
372        assert_eq!(total.get(), 100);
373
374        let usize_value: usize = total.into();
375        assert_eq!(usize_value, 100);
376    }
377
378    #[test]
379    fn test_covered_count() {
380        let covered = CoveredCount::new(80).unwrap();
381        assert_eq!(covered.get(), 80);
382
383        let usize_value: usize = covered.into();
384        assert_eq!(usize_value, 80);
385    }
386
387    #[test]
388    fn test_covered_count_validation() {
389        let total = TotalCount::new(100).unwrap();
390
391        // Valid: covered <= total
392        let covered = CoveredCount::new_for_total(80, total).unwrap();
393        assert_eq!(covered.get(), 80);
394
395        // Valid: covered == total
396        let covered = CoveredCount::new_for_total(100, total).unwrap();
397        assert_eq!(covered.get(), 100);
398
399        // Invalid: covered > total
400        let invalid = CoveredCount::new_for_total(150, total);
401        assert!(invalid.is_none());
402    }
403
404    #[test]
405    fn test_coverage_report_with_newtypes() {
406        let mut report = CoverageReport::new();
407        assert_eq!(report.total.get(), 0);
408        assert_eq!(report.covered.get(), 0);
409
410        // Add covered item
411        report.add_item("test1".to_string(), true);
412        assert_eq!(report.total.get(), 1);
413        assert_eq!(report.covered.get(), 1);
414
415        // Add uncovered item
416        report.add_item("test2".to_string(), false);
417        assert_eq!(report.total.get(), 2);
418        assert_eq!(report.covered.get(), 1); // Still 1 covered
419
420        // Add another covered item
421        report.add_item("test3".to_string(), true);
422        assert_eq!(report.total.get(), 3);
423        assert_eq!(report.covered.get(), 2);
424
425        // Verify percentage using Poka-Yoke validated type
426        let expected_percentage = CoveragePercentage::from_counts(
427            CoveredCount::new(2).unwrap(),
428            TotalCount::new(3).unwrap(),
429        )
430        .unwrap();
431        assert_eq!(report.percentage.get(), expected_percentage.get());
432    }
433
434    #[test]
435    fn test_coverage_percentage_new() {
436        // Valid percentages
437        let p50 = CoveragePercentage::new(50.0).unwrap();
438        assert_eq!(p50.get(), 50.0);
439
440        let p0 = CoveragePercentage::new(0.0).unwrap();
441        assert_eq!(p0.get(), 0.0);
442
443        let p100 = CoveragePercentage::new(100.0).unwrap();
444        assert_eq!(p100.get(), 100.0);
445
446        // Invalid percentages
447        let invalid_high = CoveragePercentage::new(150.0);
448        assert!(invalid_high.is_none());
449
450        let invalid_low = CoveragePercentage::new(-10.0);
451        assert!(invalid_low.is_none());
452    }
453
454    #[test]
455    fn test_coverage_percentage_from_counts() {
456        let total = TotalCount::new(100).unwrap();
457        let covered = CoveredCount::new_for_total(80, total).unwrap();
458
459        let percentage = CoveragePercentage::from_counts(covered, total).unwrap();
460        assert_eq!(percentage.get(), 80.0);
461
462        // Edge case: 100% coverage
463        let covered_all = CoveredCount::new_for_total(100, total).unwrap();
464        let percentage_all = CoveragePercentage::from_counts(covered_all, total).unwrap();
465        assert_eq!(percentage_all.get(), 100.0);
466
467        // Edge case: 0% coverage
468        let covered_none = CoveredCount::ZERO;
469        let percentage_none = CoveragePercentage::from_counts(covered_none, total).unwrap();
470        assert_eq!(percentage_none.get(), 0.0);
471
472        // Division by zero case
473        let zero_total = TotalCount::ZERO;
474        let zero_covered = CoveredCount::ZERO;
475        let result = CoveragePercentage::from_counts(zero_covered, zero_total);
476        assert!(result.is_none());
477    }
478
479    #[test]
480    fn test_coverage_percentage_into_f64() {
481        let percentage = CoveragePercentage::new(75.5).unwrap();
482        let f64_value: f64 = percentage.into();
483        assert_eq!(f64_value, 75.5);
484    }
485}