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}