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 FileOperationPlanningStarted {
112 operation: FileOperationKind,
114 source: PathBuf,
116 target: PathBuf,
118 },
119
120 FileOperationPlanningFinished {
122 operation: FileOperationKind,
124 source: PathBuf,
126 target: PathBuf,
128 action_count: usize,
130 },
131
132 FileOperationExecutionStarted {
134 operation: FileOperationKind,
136 source: PathBuf,
138 target: PathBuf,
140 action_count: usize,
142 },
143
144 FileOperationActionAdvanced {
146 operation: FileOperationKind,
148 source: PathBuf,
150 target: PathBuf,
152 },
153
154 FileOperationFinished {
156 operation: FileOperationKind,
158 source: PathBuf,
160 target: PathBuf,
162 summary: FileOperationSummary,
164 dry_run: bool,
166 },
167
168 FileApplied {
170 operation: FileOperationKind,
172 source: PathBuf,
174 target: PathBuf,
176 },
177
178 FileWouldApply {
180 operation: FileOperationKind,
182 source: PathBuf,
184 target: PathBuf,
186 },
187
188 FileMetadataApplied {
190 source: PathBuf,
192 target: PathBuf,
194 },
195
196 FileMetadataWouldApply {
198 source: PathBuf,
200 target: PathBuf,
202 },
203
204 FileSkipped {
206 operation: FileOperationKind,
208 target: PathBuf,
210 reason: String,
212 },
213
214 FileWouldSkip {
216 operation: FileOperationKind,
218 target: PathBuf,
220 reason: String,
222 },
223
224 FileDeleted {
226 path: PathBuf,
228 },
229
230 FileWouldDelete {
232 path: PathBuf,
234 },
235
236 FileWarning {
238 path: PathBuf,
240 reason: String,
242 },
243
244 OwnershipWarning {
246 path: PathBuf,
248 reason: String,
250 },
251
252 CommandStarted {
254 label: String,
256 },
257
258 CommandWouldRun {
260 label: String,
262 },
263
264 CommandAllowedFailure {
266 label: String,
268 reason: String,
270 },
271
272 InitCreated {
274 path: PathBuf,
276 },
277}
278
279impl OutputEvent {
280 #[must_use]
285 pub fn message(&self) -> String {
286 match self {
287 Self::IgnoredInitScript { path } => {
288 format!("treeboot: ignore {}; not executable", path.display())
289 }
290 Self::WouldRunInitScript { path, root_path } => format!(
291 "treeboot: would run {} {}",
292 path.display(),
293 root_path.display()
294 ),
295 Self::RunInitScript { path } => {
296 format!("treeboot: run {}", path.display())
297 }
298 Self::NoConfigDetected => "treeboot: no config detected".to_owned(),
299 Self::RootWorktreeDetected => "treeboot: This is not a work tree".to_owned(),
300 Self::ConfigDetected { path } => {
301 format!("treeboot: config detected {}", path.display())
302 }
303 Self::FileOperationPlanningStarted { .. }
304 | Self::FileOperationPlanningFinished { .. }
305 | Self::FileOperationExecutionStarted { .. }
306 | Self::FileOperationActionAdvanced { .. } => String::new(),
307 Self::FileOperationFinished {
308 operation,
309 source,
310 target,
311 summary,
312 dry_run,
313 } => summary.message(*operation, source, target, *dry_run),
314 Self::FileApplied {
315 operation,
316 source,
317 target,
318 } => format!(
319 "treeboot: {} {} -> {}",
320 operation.as_str(),
321 source.display(),
322 target.display()
323 ),
324 Self::FileWouldApply {
325 operation,
326 source,
327 target,
328 } => format!(
329 "treeboot: would {} {} -> {}",
330 operation.as_str(),
331 source.display(),
332 target.display()
333 ),
334 Self::FileMetadataApplied { source, target } => format!(
335 "treeboot: sync metadata {} -> {}",
336 source.display(),
337 target.display()
338 ),
339 Self::FileMetadataWouldApply { source, target } => format!(
340 "treeboot: would sync metadata {} -> {}",
341 source.display(),
342 target.display()
343 ),
344 Self::FileSkipped {
345 operation,
346 target,
347 reason,
348 } => format!(
349 "treeboot: skip {} {}; {}",
350 operation.as_str(),
351 target.display(),
352 reason
353 ),
354 Self::FileWouldSkip {
355 operation,
356 target,
357 reason,
358 } => format!(
359 "treeboot: would skip {} {}; {}",
360 operation.as_str(),
361 target.display(),
362 reason
363 ),
364 Self::FileDeleted { path } => {
365 format!("treeboot: delete {}", path.display())
366 }
367 Self::FileWouldDelete { path } => {
368 format!("treeboot: would delete {}", path.display())
369 }
370 Self::FileWarning { path, reason } => {
371 format!("treeboot: warning: {} {}", path.display(), reason)
372 }
373 Self::OwnershipWarning { path, reason } => format!(
374 "treeboot: warning: could not preserve ownership {}: {}",
375 path.display(),
376 reason
377 ),
378 Self::CommandStarted { label } => {
379 format!("treeboot: run {label}")
380 }
381 Self::CommandWouldRun { label } => {
382 format!("treeboot: would run {label}")
383 }
384 Self::CommandAllowedFailure { label, reason } => {
385 format!("treeboot: warning: command {label} {reason}")
386 }
387 Self::InitCreated { path } => {
388 format!("treeboot: created {}", path.display())
389 }
390 }
391 }
392}
393
394fn format_file_operation_summary(
395 operation: FileOperationKind,
396 source: &Path,
397 target: &Path,
398 summary: &FileOperationSummary,
399 dry_run: bool,
400) -> String {
401 if summary.decision_count() == 1 {
402 if summary.changed == 1 {
403 if summary.metadata_changed == 1 {
404 if dry_run {
405 return format!(
406 "treeboot: would sync metadata {} -> {}",
407 source.display(),
408 target.display()
409 );
410 }
411
412 return format!(
413 "treeboot: sync metadata {} -> {}",
414 source.display(),
415 target.display()
416 );
417 }
418
419 if !summary.expanded && dry_run {
420 return format!(
421 "treeboot: would {} {} -> {}",
422 operation.as_str(),
423 source.display(),
424 target.display()
425 );
426 }
427
428 if !summary.expanded {
429 return format!(
430 "treeboot: {} {} -> {}",
431 operation.as_str(),
432 source.display(),
433 target.display()
434 );
435 }
436 }
437
438 if summary.skipped == 1 {
439 let reason = summary.skip_reason.as_deref().unwrap_or("skipped");
440 if dry_run {
441 return format!(
442 "treeboot: would skip {} {}; {}",
443 operation.as_str(),
444 target.display(),
445 reason
446 );
447 }
448
449 return format!(
450 "treeboot: skip {} {}; {}",
451 operation.as_str(),
452 target.display(),
453 reason
454 );
455 }
456 }
457
458 let details = summary.count_details(dry_run).join(", ");
459 let suffix = if details.is_empty() {
460 String::new()
461 } else {
462 format!(" ({details})")
463 };
464 if dry_run {
465 format!(
466 "treeboot: would {} {} -> {}{suffix}",
467 operation.as_str(),
468 source.display(),
469 target.display()
470 )
471 } else {
472 format!(
473 "treeboot: {} {} -> {}{suffix}",
474 operation.as_str(),
475 source.display(),
476 target.display()
477 )
478 }
479}
480
481pub trait Reporter {
483 fn report(&mut self, event: OutputEvent) -> std::io::Result<()>;
485}
486
487#[cfg(test)]
488mod tests {
489 use std::path::PathBuf;
490
491 use super::*;
492 use crate::FileOperationKind;
493
494 #[test]
495 fn message_should_format_ignored_init_script() {
496 let event = OutputEvent::IgnoredInitScript {
497 path: PathBuf::from(".treeboot.sh"),
498 };
499
500 assert_eq!(
501 event.message(),
502 "treeboot: ignore .treeboot.sh; not executable"
503 );
504 }
505
506 #[test]
507 fn message_should_format_dry_run_init_script() {
508 let event = OutputEvent::WouldRunInitScript {
509 path: PathBuf::from(".treeboot.sh"),
510 root_path: PathBuf::from("/repo"),
511 };
512
513 assert_eq!(event.message(), "treeboot: would run .treeboot.sh /repo");
514 }
515
516 #[test]
517 fn message_should_format_config_detected() {
518 let event = OutputEvent::ConfigDetected {
519 path: PathBuf::from(".treeboot.toml"),
520 };
521
522 assert_eq!(event.message(), "treeboot: config detected .treeboot.toml");
523 }
524
525 #[test]
526 fn message_should_format_file_applied() {
527 let event = OutputEvent::FileApplied {
528 operation: FileOperationKind::Copy,
529 source: PathBuf::from(".env"),
530 target: PathBuf::from(".env"),
531 };
532
533 assert_eq!(event.message(), "treeboot: copy .env -> .env");
534 }
535
536 #[test]
537 fn message_should_format_file_would_apply() {
538 let event = OutputEvent::FileWouldApply {
539 operation: FileOperationKind::Symlink,
540 source: PathBuf::from("tool"),
541 target: PathBuf::from(".tool"),
542 };
543
544 assert_eq!(event.message(), "treeboot: would symlink tool -> .tool");
545 }
546
547 #[test]
548 fn message_should_format_file_metadata_applied() {
549 let event = OutputEvent::FileMetadataApplied {
550 source: PathBuf::from("shared/config"),
551 target: PathBuf::from(".config"),
552 };
553
554 assert_eq!(
555 event.message(),
556 "treeboot: sync metadata shared/config -> .config"
557 );
558 }
559
560 #[test]
561 fn message_should_format_file_metadata_would_apply() {
562 let event = OutputEvent::FileMetadataWouldApply {
563 source: PathBuf::from("shared/config"),
564 target: PathBuf::from(".config"),
565 };
566
567 assert_eq!(
568 event.message(),
569 "treeboot: would sync metadata shared/config -> .config"
570 );
571 }
572
573 #[test]
574 fn message_should_format_file_skipped() {
575 let event = OutputEvent::FileSkipped {
576 operation: FileOperationKind::Copy,
577 target: PathBuf::from(".env"),
578 reason: "target exists".to_owned(),
579 };
580
581 assert_eq!(event.message(), "treeboot: skip copy .env; target exists");
582 }
583
584 #[test]
585 fn message_should_format_file_would_skip() {
586 let event = OutputEvent::FileWouldSkip {
587 operation: FileOperationKind::Sync,
588 target: PathBuf::from("shared"),
589 reason: "missing source".to_owned(),
590 };
591
592 assert_eq!(
593 event.message(),
594 "treeboot: would skip sync shared; missing source"
595 );
596 }
597
598 #[test]
599 fn message_should_omit_file_operation_lifecycle_events() {
600 let events = [
601 OutputEvent::FileOperationPlanningStarted {
602 operation: FileOperationKind::Copy,
603 source: PathBuf::from(".env"),
604 target: PathBuf::from(".env"),
605 },
606 OutputEvent::FileOperationPlanningFinished {
607 operation: FileOperationKind::Copy,
608 source: PathBuf::from(".env"),
609 target: PathBuf::from(".env"),
610 action_count: 1,
611 },
612 OutputEvent::FileOperationExecutionStarted {
613 operation: FileOperationKind::Copy,
614 source: PathBuf::from(".env"),
615 target: PathBuf::from(".env"),
616 action_count: 1,
617 },
618 OutputEvent::FileOperationActionAdvanced {
619 operation: FileOperationKind::Copy,
620 source: PathBuf::from(".env"),
621 target: PathBuf::from(".env"),
622 },
623 ];
624
625 for event in events {
626 assert_eq!(event.message(), "");
627 }
628 }
629
630 #[test]
631 fn message_should_format_finished_file_operation_summary() {
632 let event = OutputEvent::FileOperationFinished {
633 operation: FileOperationKind::Sync,
634 source: PathBuf::from("shared"),
635 target: PathBuf::from("shared"),
636 summary: FileOperationSummary {
637 changed: 2,
638 deleted: 1,
639 expanded: true,
640 ..FileOperationSummary::default()
641 },
642 dry_run: false,
643 };
644
645 assert_eq!(
646 event.message(),
647 "treeboot: sync shared -> shared (2 changed, 1 deleted)"
648 );
649 }
650
651 #[test]
652 fn message_should_format_file_deleted() {
653 let event = OutputEvent::FileDeleted {
654 path: PathBuf::from(".config/old.toml"),
655 };
656
657 assert_eq!(event.message(), "treeboot: delete .config/old.toml");
658 }
659
660 #[test]
661 fn message_should_format_file_would_delete() {
662 let event = OutputEvent::FileWouldDelete {
663 path: PathBuf::from(".config/old.toml"),
664 };
665
666 assert_eq!(event.message(), "treeboot: would delete .config/old.toml");
667 }
668
669 #[test]
670 fn message_should_format_file_warning() {
671 let event = OutputEvent::FileWarning {
672 path: PathBuf::from("shared/link"),
673 reason: "symlink target does not exist".to_owned(),
674 };
675
676 assert_eq!(
677 event.message(),
678 "treeboot: warning: shared/link symlink target does not exist"
679 );
680 }
681
682 #[test]
683 fn message_should_format_ownership_warning() {
684 let event = OutputEvent::OwnershipWarning {
685 path: PathBuf::from("shared/config"),
686 reason: "operation not permitted".to_owned(),
687 };
688
689 assert_eq!(
690 event.message(),
691 "treeboot: warning: could not preserve ownership shared/config: operation not permitted"
692 );
693 }
694
695 #[test]
696 fn message_should_format_single_file_operation_summary_without_counts() {
697 let summary = FileOperationSummary {
698 changed: 1,
699 ..FileOperationSummary::default()
700 };
701
702 assert_eq!(
703 summary.message(
704 FileOperationKind::Copy,
705 Path::new(".env"),
706 Path::new(".env"),
707 false
708 ),
709 "treeboot: copy .env -> .env"
710 );
711 }
712
713 #[test]
714 fn message_should_format_expanded_file_operation_summary_with_counts() {
715 let summary = FileOperationSummary {
716 changed: 4,
717 deleted: 1,
718 expanded: true,
719 ..FileOperationSummary::default()
720 };
721
722 assert_eq!(
723 summary.message(
724 FileOperationKind::Sync,
725 Path::new("shared"),
726 Path::new("shared"),
727 false
728 ),
729 "treeboot: sync shared -> shared (4 changed, 1 deleted)"
730 );
731 }
732
733 #[test]
734 fn message_should_omit_empty_file_operation_summary_counts() {
735 let summary = FileOperationSummary {
736 warnings: 1,
737 ..FileOperationSummary::default()
738 };
739
740 assert_eq!(
741 summary.message(
742 FileOperationKind::Copy,
743 Path::new("shared/link"),
744 Path::new("shared/link"),
745 false
746 ),
747 "treeboot: copy shared/link -> shared/link"
748 );
749 }
750
751 #[test]
752 fn message_should_format_single_dry_run_skip_summary() {
753 let summary = FileOperationSummary {
754 skipped: 1,
755 skip_reason: Some("target exists".to_owned()),
756 ..FileOperationSummary::default()
757 };
758
759 assert_eq!(
760 summary.message(
761 FileOperationKind::Copy,
762 Path::new(".env"),
763 Path::new(".env"),
764 true
765 ),
766 "treeboot: would skip copy .env; target exists"
767 );
768 }
769
770 #[test]
771 fn message_should_format_root_worktree_detected() {
772 let event = OutputEvent::RootWorktreeDetected;
773
774 assert_eq!(event.message(), "treeboot: This is not a work tree");
775 }
776
777 #[test]
778 fn message_should_format_command_started() {
779 let event = OutputEvent::CommandStarted {
780 label: "Install packages: npm install".to_owned(),
781 };
782
783 assert_eq!(
784 event.message(),
785 "treeboot: run Install packages: npm install"
786 );
787 }
788
789 #[test]
790 fn message_should_format_command_allowed_failure() {
791 let event = OutputEvent::CommandAllowedFailure {
792 label: "lint".to_owned(),
793 reason: "failed with exit status: 1".to_owned(),
794 };
795
796 assert_eq!(
797 event.message(),
798 "treeboot: warning: command lint failed with exit status: 1"
799 );
800 }
801}