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    /// A file operation was applied.
111    FileApplied {
112        /// File operation kind.
113        operation: FileOperationKind,
114        /// Display source path.
115        source: PathBuf,
116        /// Display target path.
117        target: PathBuf,
118    },
119
120    /// A dry run would apply a file operation.
121    FileWouldApply {
122        /// File operation kind.
123        operation: FileOperationKind,
124        /// Display source path.
125        source: PathBuf,
126        /// Display target path.
127        target: PathBuf,
128    },
129
130    /// A sync operation applied metadata-only changes.
131    FileMetadataApplied {
132        /// Display source path.
133        source: PathBuf,
134        /// Display target path.
135        target: PathBuf,
136    },
137
138    /// A dry run would apply metadata-only sync changes.
139    FileMetadataWouldApply {
140        /// Display source path.
141        source: PathBuf,
142        /// Display target path.
143        target: PathBuf,
144    },
145
146    /// A file operation was skipped.
147    FileSkipped {
148        /// File operation kind.
149        operation: FileOperationKind,
150        /// Display target path.
151        target: PathBuf,
152        /// Reason the operation was skipped.
153        reason: String,
154    },
155
156    /// A dry run would skip a file operation.
157    FileWouldSkip {
158        /// File operation kind.
159        operation: FileOperationKind,
160        /// Display target path.
161        target: PathBuf,
162        /// Reason the operation would be skipped.
163        reason: String,
164    },
165
166    /// A sync operation deleted a target-only path.
167    FileDeleted {
168        /// Deleted path.
169        path: PathBuf,
170    },
171
172    /// A dry-run sync operation would delete a target-only path.
173    FileWouldDelete {
174        /// Path that would be deleted.
175        path: PathBuf,
176    },
177
178    /// A file operation warning was produced.
179    FileWarning {
180        /// Warning path.
181        path: PathBuf,
182        /// Human-readable warning detail.
183        reason: String,
184    },
185
186    /// Ownership metadata could not be preserved.
187    OwnershipWarning {
188        /// Warning path.
189        path: PathBuf,
190        /// Human-readable warning detail.
191        reason: String,
192    },
193
194    /// A command is about to run.
195    CommandStarted {
196        /// Human-readable command label.
197        label: String,
198    },
199
200    /// A dry run would execute a command.
201    CommandWouldRun {
202        /// Human-readable command label.
203        label: String,
204    },
205
206    /// A command failure was allowed and execution will continue.
207    CommandAllowedFailure {
208        /// Human-readable command label.
209        label: String,
210        /// Failure detail.
211        reason: String,
212    },
213
214    /// An init file was created.
215    InitCreated {
216        /// Created file path.
217        path: PathBuf,
218    },
219}
220
221impl OutputEvent {
222    /// Formats the event as a user-facing line.
223    #[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
409/// Receives structured output events from core operations.
410pub trait Reporter {
411    /// Handles one output event.
412    fn report(&mut self, event: OutputEvent) -> std::io::Result<()>;
413
414    /// Handles the start of planning for one top-level file operation.
415    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    /// Handles completion of planning for one top-level file operation.
426    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    /// Handles the start of execution for one top-level file operation.
438    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    /// Handles completion of one concrete file-operation action.
450    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    /// Handles completion of one top-level compact file operation.
461    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}