fast_yaml_cli/batch/
result.rs

1//! Result types for batch file processing.
2
3use std::path::PathBuf;
4use std::time::Duration;
5
6use super::error::ProcessingError;
7
8/// Outcome of processing a single file.
9#[derive(Debug)]
10pub enum FileOutcome {
11    /// File formatted successfully and content changed
12    Formatted {
13        /// True if content changed
14        changed: bool,
15        /// Processing duration
16        duration: Duration,
17    },
18    /// File unchanged (already formatted)
19    Unchanged {
20        /// Processing duration
21        duration: Duration,
22    },
23    /// Skipped (dry-run mode, would change)
24    Skipped {
25        /// Processing duration
26        duration: Duration,
27    },
28    /// Processing failed
29    Failed {
30        /// The error that occurred
31        error: ProcessingError,
32        /// Processing duration
33        duration: Duration,
34    },
35}
36
37impl FileOutcome {
38    /// Returns true if the file was successfully processed
39    pub const fn is_success(&self) -> bool {
40        !matches!(self, Self::Failed { .. })
41    }
42
43    /// Returns the processing duration
44    pub const fn duration(&self) -> Duration {
45        match self {
46            Self::Formatted { duration, .. }
47            | Self::Unchanged { duration }
48            | Self::Skipped { duration }
49            | Self::Failed { duration, .. } => *duration,
50        }
51    }
52
53    /// Returns true if the file was changed
54    pub const fn was_changed(&self) -> bool {
55        matches!(self, Self::Formatted { changed: true, .. })
56    }
57}
58
59/// Result for a single file with path context.
60#[derive(Debug)]
61pub struct FileResult {
62    /// Path to the processed file
63    pub path: PathBuf,
64    /// Processing outcome
65    pub outcome: FileOutcome,
66}
67
68impl FileResult {
69    /// Creates a new `FileResult`
70    pub const fn new(path: PathBuf, outcome: FileOutcome) -> Self {
71        Self { path, outcome }
72    }
73
74    /// Returns true if processing was successful
75    pub const fn is_success(&self) -> bool {
76        self.outcome.is_success()
77    }
78}
79
80/// Aggregated results from batch processing.
81#[derive(Debug, Default)]
82pub struct BatchResult {
83    /// Total number of files processed
84    pub total: usize,
85    /// Number of files formatted (changed)
86    pub formatted: usize,
87    /// Number of files unchanged (already formatted)
88    pub unchanged: usize,
89    /// Number of files skipped (dry-run mode)
90    pub skipped: usize,
91    /// Number of files that failed processing
92    pub failed: usize,
93    /// Total processing duration
94    pub duration: Duration,
95    /// List of errors with file paths
96    pub errors: Vec<(PathBuf, ProcessingError)>,
97}
98
99impl BatchResult {
100    /// Creates a new empty `BatchResult`
101    pub fn new() -> Self {
102        Self::default()
103    }
104
105    /// Creates a `BatchResult` from a list of `FileResult`s
106    pub fn from_results(results: Vec<FileResult>) -> Self {
107        let start = std::time::Instant::now();
108        let total = results.len();
109        let mut formatted = 0;
110        let mut unchanged = 0;
111        let mut skipped = 0;
112        let mut failed = 0;
113        // Pre-allocate for worst case: all files failed
114        let mut errors = Vec::with_capacity(total);
115
116        for result in results {
117            match result.outcome {
118                FileOutcome::Formatted { changed, .. } => {
119                    if changed {
120                        formatted += 1;
121                    } else {
122                        unchanged += 1;
123                    }
124                }
125                FileOutcome::Unchanged { .. } => unchanged += 1,
126                FileOutcome::Skipped { .. } => skipped += 1,
127                FileOutcome::Failed { error, .. } => {
128                    failed += 1;
129                    errors.push((result.path, error));
130                }
131            }
132        }
133
134        let duration = start.elapsed();
135
136        Self {
137            total,
138            formatted,
139            unchanged,
140            skipped,
141            failed,
142            duration,
143            errors,
144        }
145    }
146
147    /// Returns true if all files were processed successfully
148    pub const fn is_success(&self) -> bool {
149        self.failed == 0
150    }
151
152    /// Returns the number of successfully processed files
153    pub const fn success_count(&self) -> usize {
154        self.formatted + self.unchanged + self.skipped
155    }
156
157    /// Calculates files processed per second
158    #[allow(clippy::cast_precision_loss)]
159    pub fn files_per_second(&self) -> f64 {
160        let secs = self.duration.as_secs_f64();
161        if secs > 0.0 {
162            self.total as f64 / secs
163        } else {
164            0.0
165        }
166    }
167
168    /// Adds a file result to this batch result
169    pub fn add_result(&mut self, result: FileResult) {
170        self.total += 1;
171        match result.outcome {
172            FileOutcome::Formatted { changed, .. } => {
173                if changed {
174                    self.formatted += 1;
175                } else {
176                    self.unchanged += 1;
177                }
178            }
179            FileOutcome::Unchanged { .. } => self.unchanged += 1,
180            FileOutcome::Skipped { .. } => self.skipped += 1,
181            FileOutcome::Failed { error, .. } => {
182                self.failed += 1;
183                self.errors.push((result.path, error));
184            }
185        }
186    }
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn test_file_outcome_is_success() {
195        let success = FileOutcome::Formatted {
196            changed: true,
197            duration: Duration::from_millis(10),
198        };
199        assert!(success.is_success());
200
201        let failed = FileOutcome::Failed {
202            error: ProcessingError::ParseError("test".to_string()),
203            duration: Duration::from_millis(5),
204        };
205        assert!(!failed.is_success());
206    }
207
208    #[test]
209    fn test_file_outcome_duration() {
210        let outcome = FileOutcome::Formatted {
211            changed: true,
212            duration: Duration::from_millis(123),
213        };
214        assert_eq!(outcome.duration(), Duration::from_millis(123));
215    }
216
217    #[test]
218    fn test_file_outcome_was_changed() {
219        let changed = FileOutcome::Formatted {
220            changed: true,
221            duration: Duration::from_millis(10),
222        };
223        assert!(changed.was_changed());
224
225        let unchanged = FileOutcome::Formatted {
226            changed: false,
227            duration: Duration::from_millis(10),
228        };
229        assert!(!unchanged.was_changed());
230
231        let skipped = FileOutcome::Skipped {
232            duration: Duration::from_millis(5),
233        };
234        assert!(!skipped.was_changed());
235    }
236
237    #[test]
238    fn test_file_result_new() {
239        let path = PathBuf::from("/test/file.yaml");
240        let outcome = FileOutcome::Formatted {
241            changed: true,
242            duration: Duration::from_millis(10),
243        };
244        let result = FileResult::new(path.clone(), outcome);
245        assert_eq!(result.path, path);
246        assert!(result.is_success());
247    }
248
249    #[test]
250    fn test_batch_result_from_results() {
251        let results = vec![
252            FileResult::new(
253                PathBuf::from("/test/file1.yaml"),
254                FileOutcome::Formatted {
255                    changed: true,
256                    duration: Duration::from_millis(10),
257                },
258            ),
259            FileResult::new(
260                PathBuf::from("/test/file2.yaml"),
261                FileOutcome::Unchanged {
262                    duration: Duration::from_millis(5),
263                },
264            ),
265            FileResult::new(
266                PathBuf::from("/test/file3.yaml"),
267                FileOutcome::Skipped {
268                    duration: Duration::from_millis(3),
269                },
270            ),
271            FileResult::new(
272                PathBuf::from("/test/file4.yaml"),
273                FileOutcome::Failed {
274                    error: ProcessingError::ParseError("error".to_string()),
275                    duration: Duration::from_millis(2),
276                },
277            ),
278        ];
279
280        let batch = BatchResult::from_results(results);
281        assert_eq!(batch.total, 4);
282        assert_eq!(batch.formatted, 1);
283        assert_eq!(batch.unchanged, 1);
284        assert_eq!(batch.skipped, 1);
285        assert_eq!(batch.failed, 1);
286        assert_eq!(batch.errors.len(), 1);
287        assert!(!batch.is_success());
288    }
289
290    #[test]
291    fn test_batch_result_is_success() {
292        let mut batch = BatchResult::new();
293        assert!(batch.is_success());
294
295        batch.add_result(FileResult::new(
296            PathBuf::from("/test/file.yaml"),
297            FileOutcome::Formatted {
298                changed: true,
299                duration: Duration::from_millis(10),
300            },
301        ));
302        assert!(batch.is_success());
303
304        batch.add_result(FileResult::new(
305            PathBuf::from("/test/file2.yaml"),
306            FileOutcome::Failed {
307                error: ProcessingError::ParseError("error".to_string()),
308                duration: Duration::from_millis(5),
309            },
310        ));
311        assert!(!batch.is_success());
312    }
313
314    #[test]
315    fn test_batch_result_success_count() {
316        let batch = BatchResult {
317            total: 10,
318            formatted: 5,
319            unchanged: 3,
320            skipped: 1,
321            failed: 1,
322            duration: Duration::from_secs(1),
323            errors: vec![],
324        };
325        assert_eq!(batch.success_count(), 9);
326    }
327
328    #[test]
329    fn test_batch_result_files_per_second() {
330        let batch = BatchResult {
331            total: 100,
332            formatted: 50,
333            unchanged: 50,
334            skipped: 0,
335            failed: 0,
336            duration: Duration::from_secs(2),
337            errors: vec![],
338        };
339        assert!((batch.files_per_second() - 50.0).abs() < f64::EPSILON);
340    }
341
342    #[test]
343    fn test_batch_result_files_per_second_zero_duration() {
344        let batch = BatchResult {
345            total: 100,
346            formatted: 100,
347            unchanged: 0,
348            skipped: 0,
349            failed: 0,
350            duration: Duration::from_secs(0),
351            errors: vec![],
352        };
353        assert!((batch.files_per_second() - 0.0).abs() < f64::EPSILON);
354    }
355
356    // Property-based tests using proptest
357    use proptest::prelude::*;
358
359    proptest! {
360        /// Property: BatchResult.total == success_count() + failed
361        #[test]
362        fn prop_batch_result_total_invariant(
363            formatted in 0usize..1000,
364            unchanged in 0usize..1000,
365            skipped in 0usize..1000,
366            failed in 0usize..1000,
367        ) {
368            let total = formatted + unchanged + skipped + failed;
369            let batch = BatchResult {
370                total,
371                formatted,
372                unchanged,
373                skipped,
374                failed,
375                duration: Duration::from_secs(1),
376                errors: vec![],
377            };
378
379            prop_assert_eq!(batch.total, batch.success_count() + batch.failed);
380        }
381
382        /// Property: success_count() is always >= 0 and <= total
383        #[test]
384        fn prop_success_count_bounds(
385            total in 0usize..1000,
386            failed in 0usize..1000,
387        ) {
388            let success = total.saturating_sub(failed);
389            let batch = BatchResult {
390                total,
391                formatted: success / 2,
392                unchanged: success - success / 2,
393                skipped: 0,
394                failed,
395                duration: Duration::from_secs(1),
396                errors: vec![],
397            };
398
399            let success_count = batch.success_count();
400            prop_assert!(success_count <= batch.total);
401        }
402
403        /// Property: Adding results maintains total count invariant
404        #[test]
405        fn prop_add_result_maintains_invariant(
406            initial_count in 0usize..100,
407            add_success in proptest::bool::ANY,
408        ) {
409            let mut batch = BatchResult::new();
410
411            // Add initial results
412            for i in 0..initial_count {
413                let outcome = if add_success {
414                    FileOutcome::Unchanged {
415                        duration: Duration::from_millis(1),
416                    }
417                } else {
418                    FileOutcome::Failed {
419                        error: ProcessingError::ParseError(format!("error {i}")),
420                        duration: Duration::from_millis(1),
421                    }
422                };
423                batch.add_result(FileResult::new(PathBuf::from(format!("/file{i}.yaml")), outcome));
424            }
425
426            // Verify invariant
427            prop_assert_eq!(batch.total, batch.success_count() + batch.failed);
428        }
429
430        /// Property: files_per_second is always >= 0.0
431        #[test]
432        fn prop_files_per_second_non_negative(
433            total in 0usize..1000,
434            duration_ms in 0u64..10000,
435        ) {
436            let batch = BatchResult {
437                total,
438                formatted: total,
439                unchanged: 0,
440                skipped: 0,
441                failed: 0,
442                duration: Duration::from_millis(duration_ms),
443                errors: vec![],
444            };
445
446            let fps = batch.files_per_second();
447            prop_assert!(fps >= 0.0);
448            prop_assert!(fps.is_finite());
449        }
450    }
451}