Skip to main content

treeboot_core/
output.rs

1use std::path::PathBuf;
2
3use crate::FileOperationKind;
4
5/// A structured message produced during a treeboot operation.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum OutputEvent {
8    /// A non-executable script candidate was ignored.
9    IgnoredInitScript {
10        /// Script candidate path.
11        path: PathBuf,
12    },
13
14    /// A dry run would execute the given init script.
15    WouldRunInitScript {
16        /// Script path.
17        path: PathBuf,
18        /// Root checkout path passed as the script argument.
19        root_path: PathBuf,
20    },
21
22    /// An init script is about to run.
23    RunInitScript {
24        /// Script path.
25        path: PathBuf,
26    },
27
28    /// No script or config was found.
29    NoConfigDetected,
30
31    /// The run started from the root checkout instead of a separate worktree.
32    RootWorktreeDetected,
33
34    /// A config file was found.
35    ConfigDetected {
36        /// Config file path.
37        path: PathBuf,
38    },
39
40    /// A file operation was applied.
41    FileApplied {
42        /// File operation kind.
43        operation: FileOperationKind,
44        /// Display source path.
45        source: PathBuf,
46        /// Display target path.
47        target: PathBuf,
48    },
49
50    /// A dry run would apply a file operation.
51    FileWouldApply {
52        /// File operation kind.
53        operation: FileOperationKind,
54        /// Display source path.
55        source: PathBuf,
56        /// Display target path.
57        target: PathBuf,
58    },
59
60    /// A file operation was skipped.
61    FileSkipped {
62        /// File operation kind.
63        operation: FileOperationKind,
64        /// Display target path.
65        target: PathBuf,
66        /// Reason the operation was skipped.
67        reason: String,
68    },
69
70    /// A dry run would skip a file operation.
71    FileWouldSkip {
72        /// File operation kind.
73        operation: FileOperationKind,
74        /// Display target path.
75        target: PathBuf,
76        /// Reason the operation would be skipped.
77        reason: String,
78    },
79
80    /// A sync operation deleted a target-only path.
81    FileDeleted {
82        /// Deleted path.
83        path: PathBuf,
84    },
85
86    /// A dry-run sync operation would delete a target-only path.
87    FileWouldDelete {
88        /// Path that would be deleted.
89        path: PathBuf,
90    },
91
92    /// A file operation warning was produced.
93    FileWarning {
94        /// Warning path.
95        path: PathBuf,
96        /// Human-readable warning detail.
97        reason: String,
98    },
99
100    /// A command is about to run.
101    CommandStarted {
102        /// Human-readable command label.
103        label: String,
104    },
105
106    /// A dry run would execute a command.
107    CommandWouldRun {
108        /// Human-readable command label.
109        label: String,
110    },
111
112    /// A command failure was allowed and execution will continue.
113    CommandAllowedFailure {
114        /// Human-readable command label.
115        label: String,
116        /// Failure detail.
117        reason: String,
118    },
119
120    /// An init file was created.
121    InitCreated {
122        /// Created file path.
123        path: PathBuf,
124    },
125}
126
127impl OutputEvent {
128    /// Formats the event as a user-facing line.
129    #[must_use]
130    pub fn message(&self) -> String {
131        match self {
132            Self::IgnoredInitScript { path } => {
133                format!("treeboot: ignore {}; not executable", path.display())
134            }
135            Self::WouldRunInitScript { path, root_path } => format!(
136                "treeboot: would run {} {}",
137                path.display(),
138                root_path.display()
139            ),
140            Self::RunInitScript { path } => {
141                format!("treeboot: run {}", path.display())
142            }
143            Self::NoConfigDetected => "treeboot: no config detected".to_owned(),
144            Self::RootWorktreeDetected => "treeboot: This is not a work tree".to_owned(),
145            Self::ConfigDetected { path } => {
146                format!("treeboot: config detected {}", path.display())
147            }
148            Self::FileApplied {
149                operation,
150                source,
151                target,
152            } => format!(
153                "treeboot: {} {} -> {}",
154                operation.as_str(),
155                source.display(),
156                target.display()
157            ),
158            Self::FileWouldApply {
159                operation,
160                source,
161                target,
162            } => format!(
163                "treeboot: would {} {} -> {}",
164                operation.as_str(),
165                source.display(),
166                target.display()
167            ),
168            Self::FileSkipped {
169                operation,
170                target,
171                reason,
172            } => format!(
173                "treeboot: skip {} {}; {}",
174                operation.as_str(),
175                target.display(),
176                reason
177            ),
178            Self::FileWouldSkip {
179                operation,
180                target,
181                reason,
182            } => format!(
183                "treeboot: would skip {} {}; {}",
184                operation.as_str(),
185                target.display(),
186                reason
187            ),
188            Self::FileDeleted { path } => {
189                format!("treeboot: delete {}", path.display())
190            }
191            Self::FileWouldDelete { path } => {
192                format!("treeboot: would delete {}", path.display())
193            }
194            Self::FileWarning { path, reason } => {
195                format!("treeboot: warning: {} {}", path.display(), reason)
196            }
197            Self::CommandStarted { label } => {
198                format!("treeboot: run {label}")
199            }
200            Self::CommandWouldRun { label } => {
201                format!("treeboot: would run {label}")
202            }
203            Self::CommandAllowedFailure { label, reason } => {
204                format!("treeboot: warning: command {label} {reason}")
205            }
206            Self::InitCreated { path } => {
207                format!("treeboot: created {}", path.display())
208            }
209        }
210    }
211}
212
213/// Receives structured output events from core operations.
214pub trait Reporter {
215    /// Handles one output event.
216    fn report(&mut self, event: OutputEvent) -> std::io::Result<()>;
217}
218
219#[cfg(test)]
220mod tests {
221    use std::path::PathBuf;
222
223    use super::*;
224    use crate::FileOperationKind;
225
226    #[test]
227    fn message_should_format_ignored_init_script() {
228        let event = OutputEvent::IgnoredInitScript {
229            path: PathBuf::from(".treeboot.sh"),
230        };
231
232        assert_eq!(
233            event.message(),
234            "treeboot: ignore .treeboot.sh; not executable"
235        );
236    }
237
238    #[test]
239    fn message_should_format_dry_run_init_script() {
240        let event = OutputEvent::WouldRunInitScript {
241            path: PathBuf::from(".treeboot.sh"),
242            root_path: PathBuf::from("/repo"),
243        };
244
245        assert_eq!(event.message(), "treeboot: would run .treeboot.sh /repo");
246    }
247
248    #[test]
249    fn message_should_format_config_detected() {
250        let event = OutputEvent::ConfigDetected {
251            path: PathBuf::from(".treeboot.toml"),
252        };
253
254        assert_eq!(event.message(), "treeboot: config detected .treeboot.toml");
255    }
256
257    #[test]
258    fn message_should_format_file_applied() {
259        let event = OutputEvent::FileApplied {
260            operation: FileOperationKind::Copy,
261            source: PathBuf::from(".env"),
262            target: PathBuf::from(".env"),
263        };
264
265        assert_eq!(event.message(), "treeboot: copy .env -> .env");
266    }
267
268    #[test]
269    fn message_should_format_file_would_apply() {
270        let event = OutputEvent::FileWouldApply {
271            operation: FileOperationKind::Symlink,
272            source: PathBuf::from("tool"),
273            target: PathBuf::from(".tool"),
274        };
275
276        assert_eq!(event.message(), "treeboot: would symlink tool -> .tool");
277    }
278
279    #[test]
280    fn message_should_format_file_skipped() {
281        let event = OutputEvent::FileSkipped {
282            operation: FileOperationKind::Copy,
283            target: PathBuf::from(".env"),
284            reason: "target exists".to_owned(),
285        };
286
287        assert_eq!(event.message(), "treeboot: skip copy .env; target exists");
288    }
289
290    #[test]
291    fn message_should_format_file_would_skip() {
292        let event = OutputEvent::FileWouldSkip {
293            operation: FileOperationKind::Sync,
294            target: PathBuf::from("shared"),
295            reason: "missing source".to_owned(),
296        };
297
298        assert_eq!(
299            event.message(),
300            "treeboot: would skip sync shared; missing source"
301        );
302    }
303
304    #[test]
305    fn message_should_format_file_deleted() {
306        let event = OutputEvent::FileDeleted {
307            path: PathBuf::from(".config/old.toml"),
308        };
309
310        assert_eq!(event.message(), "treeboot: delete .config/old.toml");
311    }
312
313    #[test]
314    fn message_should_format_file_would_delete() {
315        let event = OutputEvent::FileWouldDelete {
316            path: PathBuf::from(".config/old.toml"),
317        };
318
319        assert_eq!(event.message(), "treeboot: would delete .config/old.toml");
320    }
321
322    #[test]
323    fn message_should_format_file_warning() {
324        let event = OutputEvent::FileWarning {
325            path: PathBuf::from("shared/link"),
326            reason: "symlink target does not exist".to_owned(),
327        };
328
329        assert_eq!(
330            event.message(),
331            "treeboot: warning: shared/link symlink target does not exist"
332        );
333    }
334
335    #[test]
336    fn message_should_format_root_worktree_detected() {
337        let event = OutputEvent::RootWorktreeDetected;
338
339        assert_eq!(event.message(), "treeboot: This is not a work tree");
340    }
341
342    #[test]
343    fn message_should_format_command_started() {
344        let event = OutputEvent::CommandStarted {
345            label: "Install packages: npm install".to_owned(),
346        };
347
348        assert_eq!(
349            event.message(),
350            "treeboot: run Install packages: npm install"
351        );
352    }
353
354    #[test]
355    fn message_should_format_command_allowed_failure() {
356        let event = OutputEvent::CommandAllowedFailure {
357            label: "lint".to_owned(),
358            reason: "failed with exit status: 1".to_owned(),
359        };
360
361        assert_eq!(
362            event.message(),
363            "treeboot: warning: command lint failed with exit status: 1"
364        );
365    }
366}