1use std::path::PathBuf;
2
3use crate::FileOperationKind;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum OutputEvent {
8 IgnoredInitScript {
10 path: PathBuf,
12 },
13
14 WouldRunInitScript {
16 path: PathBuf,
18 root_path: PathBuf,
20 },
21
22 RunInitScript {
24 path: PathBuf,
26 },
27
28 NoConfigDetected,
30
31 RootWorktreeDetected,
33
34 ConfigDetected {
36 path: PathBuf,
38 },
39
40 FileApplied {
42 operation: FileOperationKind,
44 source: PathBuf,
46 target: PathBuf,
48 },
49
50 FileWouldApply {
52 operation: FileOperationKind,
54 source: PathBuf,
56 target: PathBuf,
58 },
59
60 FileSkipped {
62 operation: FileOperationKind,
64 target: PathBuf,
66 reason: String,
68 },
69
70 FileWouldSkip {
72 operation: FileOperationKind,
74 target: PathBuf,
76 reason: String,
78 },
79
80 FileDeleted {
82 path: PathBuf,
84 },
85
86 FileWouldDelete {
88 path: PathBuf,
90 },
91
92 FileWarning {
94 path: PathBuf,
96 reason: String,
98 },
99
100 CommandStarted {
102 label: String,
104 },
105
106 CommandWouldRun {
108 label: String,
110 },
111
112 CommandAllowedFailure {
114 label: String,
116 reason: String,
118 },
119
120 InitCreated {
122 path: PathBuf,
124 },
125}
126
127impl OutputEvent {
128 #[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
213pub trait Reporter {
215 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}