1use std::path::{Path, PathBuf};
2
3use crate::FileOperationKind;
4
5#[derive(Debug, Clone, Default, PartialEq, Eq)]
7pub struct FileOperationSummary {
8 pub changed: usize,
10 pub skipped: usize,
12 pub deleted: usize,
14 pub warnings: usize,
16 pub metadata_changed: usize,
18 pub expanded: bool,
20 pub skip_reason: Option<String>,
22}
23
24impl FileOperationSummary {
25 #[must_use]
27 pub const fn decision_count(&self) -> usize {
28 self.changed + self.skipped + self.deleted
29 }
30
31 #[must_use]
33 pub fn message(
34 &self,
35 operation: FileOperationKind,
36 source: &Path,
37 target: &Path,
38 dry_run: bool,
39 ) -> String {
40 format_file_operation_summary(operation, source, target, self, dry_run)
41 }
42
43 fn count_details(&self, dry_run: bool) -> Vec<String> {
44 let mut details = Vec::new();
45 if self.changed > 0 {
46 details.push(count_detail(
47 self.changed,
48 if dry_run { "change" } else { "changed" },
49 if dry_run { "changes" } else { "changed" },
50 ));
51 }
52 if self.skipped > 0 {
53 details.push(count_detail(
54 self.skipped,
55 if dry_run { "skip" } else { "skipped" },
56 if dry_run { "skips" } else { "skipped" },
57 ));
58 }
59 if self.deleted > 0 {
60 details.push(count_detail(
61 self.deleted,
62 if dry_run { "delete" } else { "deleted" },
63 if dry_run { "deletes" } else { "deleted" },
64 ));
65 }
66 details
67 }
68}
69
70fn count_detail(count: usize, singular: &str, plural: &str) -> String {
71 let noun = if count == 1 { singular } else { plural };
72 format!("{count} {noun}")
73}
74
75#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum OutputEvent {
78 IgnoredInitScript {
80 path: PathBuf,
82 },
83
84 WouldRunInitScript {
86 path: PathBuf,
88 root_path: PathBuf,
90 },
91
92 RunInitScript {
94 path: PathBuf,
96 },
97
98 NoConfigDetected,
100
101 RootWorktreeDetected,
103
104 ConfigDetected {
106 path: PathBuf,
108 },
109
110 FileApplied {
112 operation: FileOperationKind,
114 source: PathBuf,
116 target: PathBuf,
118 },
119
120 FileWouldApply {
122 operation: FileOperationKind,
124 source: PathBuf,
126 target: PathBuf,
128 },
129
130 FileMetadataApplied {
132 source: PathBuf,
134 target: PathBuf,
136 },
137
138 FileMetadataWouldApply {
140 source: PathBuf,
142 target: PathBuf,
144 },
145
146 FileSkipped {
148 operation: FileOperationKind,
150 target: PathBuf,
152 reason: String,
154 },
155
156 FileWouldSkip {
158 operation: FileOperationKind,
160 target: PathBuf,
162 reason: String,
164 },
165
166 FileDeleted {
168 path: PathBuf,
170 },
171
172 FileWouldDelete {
174 path: PathBuf,
176 },
177
178 FileWarning {
180 path: PathBuf,
182 reason: String,
184 },
185
186 OwnershipWarning {
188 path: PathBuf,
190 reason: String,
192 },
193
194 CommandStarted {
196 label: String,
198 },
199
200 CommandWouldRun {
202 label: String,
204 },
205
206 CommandAllowedFailure {
208 label: String,
210 reason: String,
212 },
213
214 InitCreated {
216 path: PathBuf,
218 },
219}
220
221impl OutputEvent {
222 #[must_use]
224 pub fn message(&self) -> String {
225 match self {
226 Self::IgnoredInitScript { path } => {
227 format!("treeboot: ignore {}; not executable", path.display())
228 }
229 Self::WouldRunInitScript { path, root_path } => format!(
230 "treeboot: would run {} {}",
231 path.display(),
232 root_path.display()
233 ),
234 Self::RunInitScript { path } => {
235 format!("treeboot: run {}", path.display())
236 }
237 Self::NoConfigDetected => "treeboot: no config detected".to_owned(),
238 Self::RootWorktreeDetected => "treeboot: This is not a work tree".to_owned(),
239 Self::ConfigDetected { path } => {
240 format!("treeboot: config detected {}", path.display())
241 }
242 Self::FileApplied {
243 operation,
244 source,
245 target,
246 } => format!(
247 "treeboot: {} {} -> {}",
248 operation.as_str(),
249 source.display(),
250 target.display()
251 ),
252 Self::FileWouldApply {
253 operation,
254 source,
255 target,
256 } => format!(
257 "treeboot: would {} {} -> {}",
258 operation.as_str(),
259 source.display(),
260 target.display()
261 ),
262 Self::FileMetadataApplied { source, target } => format!(
263 "treeboot: sync metadata {} -> {}",
264 source.display(),
265 target.display()
266 ),
267 Self::FileMetadataWouldApply { source, target } => format!(
268 "treeboot: would sync metadata {} -> {}",
269 source.display(),
270 target.display()
271 ),
272 Self::FileSkipped {
273 operation,
274 target,
275 reason,
276 } => format!(
277 "treeboot: skip {} {}; {}",
278 operation.as_str(),
279 target.display(),
280 reason
281 ),
282 Self::FileWouldSkip {
283 operation,
284 target,
285 reason,
286 } => format!(
287 "treeboot: would skip {} {}; {}",
288 operation.as_str(),
289 target.display(),
290 reason
291 ),
292 Self::FileDeleted { path } => {
293 format!("treeboot: delete {}", path.display())
294 }
295 Self::FileWouldDelete { path } => {
296 format!("treeboot: would delete {}", path.display())
297 }
298 Self::FileWarning { path, reason } => {
299 format!("treeboot: warning: {} {}", path.display(), reason)
300 }
301 Self::OwnershipWarning { path, reason } => format!(
302 "treeboot: warning: could not preserve ownership {}: {}",
303 path.display(),
304 reason
305 ),
306 Self::CommandStarted { label } => {
307 format!("treeboot: run {label}")
308 }
309 Self::CommandWouldRun { label } => {
310 format!("treeboot: would run {label}")
311 }
312 Self::CommandAllowedFailure { label, reason } => {
313 format!("treeboot: warning: command {label} {reason}")
314 }
315 Self::InitCreated { path } => {
316 format!("treeboot: created {}", path.display())
317 }
318 }
319 }
320}
321
322fn format_file_operation_summary(
323 operation: FileOperationKind,
324 source: &Path,
325 target: &Path,
326 summary: &FileOperationSummary,
327 dry_run: bool,
328) -> String {
329 if summary.decision_count() == 1 {
330 if summary.changed == 1 {
331 if summary.metadata_changed == 1 {
332 if dry_run {
333 return format!(
334 "treeboot: would sync metadata {} -> {}",
335 source.display(),
336 target.display()
337 );
338 }
339
340 return format!(
341 "treeboot: sync metadata {} -> {}",
342 source.display(),
343 target.display()
344 );
345 }
346
347 if !summary.expanded && dry_run {
348 return format!(
349 "treeboot: would {} {} -> {}",
350 operation.as_str(),
351 source.display(),
352 target.display()
353 );
354 }
355
356 if !summary.expanded {
357 return format!(
358 "treeboot: {} {} -> {}",
359 operation.as_str(),
360 source.display(),
361 target.display()
362 );
363 }
364 }
365
366 if summary.skipped == 1 {
367 let reason = summary.skip_reason.as_deref().unwrap_or("skipped");
368 if dry_run {
369 return format!(
370 "treeboot: would skip {} {}; {}",
371 operation.as_str(),
372 target.display(),
373 reason
374 );
375 }
376
377 return format!(
378 "treeboot: skip {} {}; {}",
379 operation.as_str(),
380 target.display(),
381 reason
382 );
383 }
384 }
385
386 let details = summary.count_details(dry_run).join(", ");
387 let suffix = if details.is_empty() {
388 String::new()
389 } else {
390 format!(" ({details})")
391 };
392 if dry_run {
393 format!(
394 "treeboot: would {} {} -> {}{suffix}",
395 operation.as_str(),
396 source.display(),
397 target.display()
398 )
399 } else {
400 format!(
401 "treeboot: {} {} -> {}{suffix}",
402 operation.as_str(),
403 source.display(),
404 target.display()
405 )
406 }
407}
408
409pub trait Reporter {
411 fn report(&mut self, event: OutputEvent) -> std::io::Result<()>;
413
414 fn file_operation_planning_started(
416 &mut self,
417 operation: FileOperationKind,
418 source: &Path,
419 target: &Path,
420 ) -> std::io::Result<()> {
421 let _ = (operation, source, target);
422 Ok(())
423 }
424
425 fn file_operation_planning_finished(
427 &mut self,
428 operation: FileOperationKind,
429 source: &Path,
430 target: &Path,
431 action_count: usize,
432 ) -> std::io::Result<()> {
433 let _ = (operation, source, target, action_count);
434 Ok(())
435 }
436
437 fn file_operation_execution_started(
439 &mut self,
440 operation: FileOperationKind,
441 source: &Path,
442 target: &Path,
443 action_count: usize,
444 ) -> std::io::Result<()> {
445 let _ = (operation, source, target, action_count);
446 Ok(())
447 }
448
449 fn file_operation_action_advanced(
451 &mut self,
452 operation: FileOperationKind,
453 source: &Path,
454 target: &Path,
455 ) -> std::io::Result<()> {
456 let _ = (operation, source, target);
457 Ok(())
458 }
459
460 fn file_operation_finished(
462 &mut self,
463 operation: FileOperationKind,
464 source: &Path,
465 target: &Path,
466 summary: &FileOperationSummary,
467 dry_run: bool,
468 ) -> std::io::Result<()> {
469 let _ = (operation, source, target, summary, dry_run);
470 Ok(())
471 }
472}
473
474#[cfg(test)]
475mod tests {
476 use std::path::PathBuf;
477
478 use super::*;
479 use crate::FileOperationKind;
480
481 #[test]
482 fn message_should_format_ignored_init_script() {
483 let event = OutputEvent::IgnoredInitScript {
484 path: PathBuf::from(".treeboot.sh"),
485 };
486
487 assert_eq!(
488 event.message(),
489 "treeboot: ignore .treeboot.sh; not executable"
490 );
491 }
492
493 #[test]
494 fn message_should_format_dry_run_init_script() {
495 let event = OutputEvent::WouldRunInitScript {
496 path: PathBuf::from(".treeboot.sh"),
497 root_path: PathBuf::from("/repo"),
498 };
499
500 assert_eq!(event.message(), "treeboot: would run .treeboot.sh /repo");
501 }
502
503 #[test]
504 fn message_should_format_config_detected() {
505 let event = OutputEvent::ConfigDetected {
506 path: PathBuf::from(".treeboot.toml"),
507 };
508
509 assert_eq!(event.message(), "treeboot: config detected .treeboot.toml");
510 }
511
512 #[test]
513 fn message_should_format_file_applied() {
514 let event = OutputEvent::FileApplied {
515 operation: FileOperationKind::Copy,
516 source: PathBuf::from(".env"),
517 target: PathBuf::from(".env"),
518 };
519
520 assert_eq!(event.message(), "treeboot: copy .env -> .env");
521 }
522
523 #[test]
524 fn message_should_format_file_would_apply() {
525 let event = OutputEvent::FileWouldApply {
526 operation: FileOperationKind::Symlink,
527 source: PathBuf::from("tool"),
528 target: PathBuf::from(".tool"),
529 };
530
531 assert_eq!(event.message(), "treeboot: would symlink tool -> .tool");
532 }
533
534 #[test]
535 fn message_should_format_file_metadata_applied() {
536 let event = OutputEvent::FileMetadataApplied {
537 source: PathBuf::from("shared/config"),
538 target: PathBuf::from(".config"),
539 };
540
541 assert_eq!(
542 event.message(),
543 "treeboot: sync metadata shared/config -> .config"
544 );
545 }
546
547 #[test]
548 fn message_should_format_file_metadata_would_apply() {
549 let event = OutputEvent::FileMetadataWouldApply {
550 source: PathBuf::from("shared/config"),
551 target: PathBuf::from(".config"),
552 };
553
554 assert_eq!(
555 event.message(),
556 "treeboot: would sync metadata shared/config -> .config"
557 );
558 }
559
560 #[test]
561 fn message_should_format_file_skipped() {
562 let event = OutputEvent::FileSkipped {
563 operation: FileOperationKind::Copy,
564 target: PathBuf::from(".env"),
565 reason: "target exists".to_owned(),
566 };
567
568 assert_eq!(event.message(), "treeboot: skip copy .env; target exists");
569 }
570
571 #[test]
572 fn message_should_format_file_would_skip() {
573 let event = OutputEvent::FileWouldSkip {
574 operation: FileOperationKind::Sync,
575 target: PathBuf::from("shared"),
576 reason: "missing source".to_owned(),
577 };
578
579 assert_eq!(
580 event.message(),
581 "treeboot: would skip sync shared; missing source"
582 );
583 }
584
585 #[test]
586 fn message_should_format_file_deleted() {
587 let event = OutputEvent::FileDeleted {
588 path: PathBuf::from(".config/old.toml"),
589 };
590
591 assert_eq!(event.message(), "treeboot: delete .config/old.toml");
592 }
593
594 #[test]
595 fn message_should_format_file_would_delete() {
596 let event = OutputEvent::FileWouldDelete {
597 path: PathBuf::from(".config/old.toml"),
598 };
599
600 assert_eq!(event.message(), "treeboot: would delete .config/old.toml");
601 }
602
603 #[test]
604 fn message_should_format_file_warning() {
605 let event = OutputEvent::FileWarning {
606 path: PathBuf::from("shared/link"),
607 reason: "symlink target does not exist".to_owned(),
608 };
609
610 assert_eq!(
611 event.message(),
612 "treeboot: warning: shared/link symlink target does not exist"
613 );
614 }
615
616 #[test]
617 fn message_should_format_ownership_warning() {
618 let event = OutputEvent::OwnershipWarning {
619 path: PathBuf::from("shared/config"),
620 reason: "operation not permitted".to_owned(),
621 };
622
623 assert_eq!(
624 event.message(),
625 "treeboot: warning: could not preserve ownership shared/config: operation not permitted"
626 );
627 }
628
629 #[test]
630 fn message_should_format_single_file_operation_summary_without_counts() {
631 let summary = FileOperationSummary {
632 changed: 1,
633 ..FileOperationSummary::default()
634 };
635
636 assert_eq!(
637 summary.message(
638 FileOperationKind::Copy,
639 Path::new(".env"),
640 Path::new(".env"),
641 false
642 ),
643 "treeboot: copy .env -> .env"
644 );
645 }
646
647 #[test]
648 fn message_should_format_expanded_file_operation_summary_with_counts() {
649 let summary = FileOperationSummary {
650 changed: 4,
651 deleted: 1,
652 expanded: true,
653 ..FileOperationSummary::default()
654 };
655
656 assert_eq!(
657 summary.message(
658 FileOperationKind::Sync,
659 Path::new("shared"),
660 Path::new("shared"),
661 false
662 ),
663 "treeboot: sync shared -> shared (4 changed, 1 deleted)"
664 );
665 }
666
667 #[test]
668 fn message_should_omit_empty_file_operation_summary_counts() {
669 let summary = FileOperationSummary {
670 warnings: 1,
671 ..FileOperationSummary::default()
672 };
673
674 assert_eq!(
675 summary.message(
676 FileOperationKind::Copy,
677 Path::new("shared/link"),
678 Path::new("shared/link"),
679 false
680 ),
681 "treeboot: copy shared/link -> shared/link"
682 );
683 }
684
685 #[test]
686 fn message_should_format_single_dry_run_skip_summary() {
687 let summary = FileOperationSummary {
688 skipped: 1,
689 skip_reason: Some("target exists".to_owned()),
690 ..FileOperationSummary::default()
691 };
692
693 assert_eq!(
694 summary.message(
695 FileOperationKind::Copy,
696 Path::new(".env"),
697 Path::new(".env"),
698 true
699 ),
700 "treeboot: would skip copy .env; target exists"
701 );
702 }
703
704 #[test]
705 fn message_should_format_root_worktree_detected() {
706 let event = OutputEvent::RootWorktreeDetected;
707
708 assert_eq!(event.message(), "treeboot: This is not a work tree");
709 }
710
711 #[test]
712 fn message_should_format_command_started() {
713 let event = OutputEvent::CommandStarted {
714 label: "Install packages: npm install".to_owned(),
715 };
716
717 assert_eq!(
718 event.message(),
719 "treeboot: run Install packages: npm install"
720 );
721 }
722
723 #[test]
724 fn message_should_format_command_allowed_failure() {
725 let event = OutputEvent::CommandAllowedFailure {
726 label: "lint".to_owned(),
727 reason: "failed with exit status: 1".to_owned(),
728 };
729
730 assert_eq!(
731 event.message(),
732 "treeboot: warning: command lint failed with exit status: 1"
733 );
734 }
735}