Skip to main content

treeboot_core/
output.rs

1use std::path::{Path, PathBuf};
2
3use crate::FileOperationKind;
4
5/// Counts produced by one top-level file operation.
6#[derive(Debug, Clone, Default, PartialEq, Eq)]
7pub struct FileOperationSummary {
8    /// Number of created, updated, or replaced paths.
9    pub changed: usize,
10    /// Number of skipped paths.
11    pub skipped: usize,
12    /// Number of deleted target-only paths.
13    pub deleted: usize,
14    /// Number of warnings emitted.
15    pub warnings: usize,
16    /// Number of metadata-only sync repairs.
17    pub metadata_changed: usize,
18    /// Whether the summary represents expanded directory work.
19    pub expanded: bool,
20    /// Reason for a single skipped top-level operation.
21    pub skip_reason: Option<String>,
22}
23
24impl FileOperationSummary {
25    /// Returns the number of visible action decisions in the summary.
26    #[must_use]
27    pub const fn decision_count(&self) -> usize {
28        self.changed + self.skipped + self.deleted
29    }
30
31    /// Formats the summary as a user-facing file-operation line.
32    #[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/// A structured message produced during a treeboot operation.
76#[derive(Debug, Clone, PartialEq, Eq)]
77pub enum OutputEvent {
78    /// A non-executable script candidate was ignored.
79    IgnoredInitScript {
80        /// Script candidate path.
81        path: PathBuf,
82    },
83
84    /// A dry run would execute the given init script.
85    WouldRunInitScript {
86        /// Script path.
87        path: PathBuf,
88        /// Root checkout path passed as the script argument.
89        root_path: PathBuf,
90    },
91
92    /// An init script is about to run.
93    RunInitScript {
94        /// Script path.
95        path: PathBuf,
96    },
97
98    /// No script or config was found.
99    NoConfigDetected,
100
101    /// The run started from the root checkout instead of a separate worktree.
102    RootWorktreeDetected,
103
104    /// A config file was found.
105    ConfigDetected {
106        /// Config file path.
107        path: PathBuf,
108    },
109
110    /// Planning started for a top-level file operation.
111    FileOperationPlanningStarted {
112        /// File operation kind.
113        operation: FileOperationKind,
114        /// Display source path.
115        source: PathBuf,
116        /// Display target path.
117        target: PathBuf,
118    },
119
120    /// Planning finished for a top-level file operation.
121    FileOperationPlanningFinished {
122        /// File operation kind.
123        operation: FileOperationKind,
124        /// Display source path.
125        source: PathBuf,
126        /// Display target path.
127        target: PathBuf,
128        /// Number of progress-visible actions in the operation.
129        action_count: usize,
130    },
131
132    /// Execution started for a top-level file operation.
133    FileOperationExecutionStarted {
134        /// File operation kind.
135        operation: FileOperationKind,
136        /// Display source path.
137        source: PathBuf,
138        /// Display target path.
139        target: PathBuf,
140        /// Number of progress-visible actions in the operation.
141        action_count: usize,
142    },
143
144    /// One concrete file-operation action completed.
145    FileOperationActionAdvanced {
146        /// File operation kind.
147        operation: FileOperationKind,
148        /// Display source path.
149        source: PathBuf,
150        /// Display target path.
151        target: PathBuf,
152    },
153
154    /// A top-level file operation finished.
155    FileOperationFinished {
156        /// File operation kind.
157        operation: FileOperationKind,
158        /// Display source path.
159        source: PathBuf,
160        /// Display target path.
161        target: PathBuf,
162        /// Compact counts for the operation.
163        summary: FileOperationSummary,
164        /// Whether the operation was a dry run.
165        dry_run: bool,
166    },
167
168    /// A file operation was applied.
169    FileApplied {
170        /// File operation kind.
171        operation: FileOperationKind,
172        /// Display source path.
173        source: PathBuf,
174        /// Display target path.
175        target: PathBuf,
176    },
177
178    /// A dry run would apply a file operation.
179    FileWouldApply {
180        /// File operation kind.
181        operation: FileOperationKind,
182        /// Display source path.
183        source: PathBuf,
184        /// Display target path.
185        target: PathBuf,
186    },
187
188    /// A sync operation applied metadata-only changes.
189    FileMetadataApplied {
190        /// Display source path.
191        source: PathBuf,
192        /// Display target path.
193        target: PathBuf,
194    },
195
196    /// A dry run would apply metadata-only sync changes.
197    FileMetadataWouldApply {
198        /// Display source path.
199        source: PathBuf,
200        /// Display target path.
201        target: PathBuf,
202    },
203
204    /// A file operation was skipped.
205    FileSkipped {
206        /// File operation kind.
207        operation: FileOperationKind,
208        /// Display target path.
209        target: PathBuf,
210        /// Reason the operation was skipped.
211        reason: String,
212    },
213
214    /// A dry run would skip a file operation.
215    FileWouldSkip {
216        /// File operation kind.
217        operation: FileOperationKind,
218        /// Display target path.
219        target: PathBuf,
220        /// Reason the operation would be skipped.
221        reason: String,
222    },
223
224    /// A sync operation deleted a target-only path.
225    FileDeleted {
226        /// Deleted path.
227        path: PathBuf,
228    },
229
230    /// A dry-run sync operation would delete a target-only path.
231    FileWouldDelete {
232        /// Path that would be deleted.
233        path: PathBuf,
234    },
235
236    /// A file operation warning was produced.
237    FileWarning {
238        /// Warning path.
239        path: PathBuf,
240        /// Human-readable warning detail.
241        reason: String,
242    },
243
244    /// Ownership metadata could not be preserved.
245    OwnershipWarning {
246        /// Warning path.
247        path: PathBuf,
248        /// Human-readable warning detail.
249        reason: String,
250    },
251
252    /// A command is about to run.
253    CommandStarted {
254        /// Human-readable command label.
255        label: String,
256    },
257
258    /// A dry run would execute a command.
259    CommandWouldRun {
260        /// Human-readable command label.
261        label: String,
262    },
263
264    /// A command failure was allowed and execution will continue.
265    CommandAllowedFailure {
266        /// Human-readable command label.
267        label: String,
268        /// Failure detail.
269        reason: String,
270    },
271
272    /// An init file was created.
273    InitCreated {
274        /// Created file path.
275        path: PathBuf,
276    },
277}
278
279impl OutputEvent {
280    /// Formats the event as a user-facing line.
281    ///
282    /// Structured lifecycle events used only to drive presentation state return
283    /// an empty string because they do not have a durable text-line form.
284    #[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
481/// Receives structured output events from core operations.
482pub trait Reporter {
483    /// Handles one output event.
484    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}