1use std::path::PathBuf;
4use std::time::Duration;
5
6use super::error::ProcessingError;
7
8#[derive(Debug)]
10pub enum FileOutcome {
11 Formatted {
13 changed: bool,
15 duration: Duration,
17 },
18 Unchanged {
20 duration: Duration,
22 },
23 Skipped {
25 duration: Duration,
27 },
28 Failed {
30 error: ProcessingError,
32 duration: Duration,
34 },
35}
36
37impl FileOutcome {
38 pub const fn is_success(&self) -> bool {
40 !matches!(self, Self::Failed { .. })
41 }
42
43 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 pub const fn was_changed(&self) -> bool {
55 matches!(self, Self::Formatted { changed: true, .. })
56 }
57}
58
59#[derive(Debug)]
61pub struct FileResult {
62 pub path: PathBuf,
64 pub outcome: FileOutcome,
66}
67
68impl FileResult {
69 pub const fn new(path: PathBuf, outcome: FileOutcome) -> Self {
71 Self { path, outcome }
72 }
73
74 pub const fn is_success(&self) -> bool {
76 self.outcome.is_success()
77 }
78}
79
80#[derive(Debug, Default)]
82pub struct BatchResult {
83 pub total: usize,
85 pub formatted: usize,
87 pub unchanged: usize,
89 pub skipped: usize,
91 pub failed: usize,
93 pub duration: Duration,
95 pub errors: Vec<(PathBuf, ProcessingError)>,
97}
98
99impl BatchResult {
100 pub fn new() -> Self {
102 Self::default()
103 }
104
105 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 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 pub const fn is_success(&self) -> bool {
149 self.failed == 0
150 }
151
152 pub const fn success_count(&self) -> usize {
154 self.formatted + self.unchanged + self.skipped
155 }
156
157 #[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 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 use proptest::prelude::*;
358
359 proptest! {
360 #[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 #[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 #[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 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 prop_assert_eq!(batch.total, batch.success_count() + batch.failed);
428 }
429
430 #[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}