1use crate::datastore::DataStore;
7use crate::fs::Fs;
8use crate::operations::{HandlerIntent, Operation, OperationResult};
9use crate::Result;
10
11pub struct Executor<'a> {
13 datastore: &'a dyn DataStore,
14 fs: &'a dyn Fs,
15 dry_run: bool,
16 force: bool,
17 provision_rerun: bool,
18}
19
20impl<'a> Executor<'a> {
21 pub fn new(
22 datastore: &'a dyn DataStore,
23 fs: &'a dyn Fs,
24 dry_run: bool,
25 force: bool,
26 provision_rerun: bool,
27 ) -> Self {
28 Self {
29 datastore,
30 fs,
31 dry_run,
32 force,
33 provision_rerun,
34 }
35 }
36
37 pub fn execute(&self, intents: Vec<HandlerIntent>) -> Result<Vec<OperationResult>> {
46 let mut results = Vec::new();
47
48 for intent in intents {
49 let intent_results = if self.dry_run {
50 self.simulate(&intent)
51 } else {
52 self.execute_one(&intent)?
53 };
54 results.extend(intent_results);
55 }
56
57 Ok(results)
58 }
59
60 fn execute_one(&self, intent: &HandlerIntent) -> Result<Vec<OperationResult>> {
62 match intent {
63 HandlerIntent::Link {
64 pack,
65 handler,
66 source,
67 user_path,
68 } => {
69 if !self.fs.is_symlink(user_path) && self.fs.exists(user_path) {
73 if self.force {
74 if self.fs.is_dir(user_path) {
76 self.fs.remove_dir_all(user_path)?;
77 } else {
78 self.fs.remove_file(user_path)?;
79 }
80 } else {
81 let op = Operation::CreateUserLink {
84 pack: pack.clone(),
85 handler: handler.clone(),
86 datastore_path: Default::default(),
87 user_path: user_path.clone(),
88 };
89 return Ok(vec![OperationResult::fail(
90 op,
91 format!(
92 "conflict: {} already exists (use --force to overwrite)",
93 user_path.display()
94 ),
95 )]);
96 }
97 }
98
99 let datastore_path = self.datastore.create_data_link(pack, handler, source)?;
101
102 self.datastore
104 .create_user_link(&datastore_path, user_path)?;
105
106 let op = Operation::CreateUserLink {
107 pack: pack.clone(),
108 handler: handler.clone(),
109 datastore_path: datastore_path.clone(),
110 user_path: user_path.clone(),
111 };
112
113 Ok(vec![OperationResult::ok(
114 op,
115 format!(
116 "{} → {}",
117 source.file_name().unwrap_or_default().to_string_lossy(),
118 user_path.display()
119 ),
120 )])
121 }
122
123 HandlerIntent::Stage {
124 pack,
125 handler,
126 source,
127 } => {
128 self.datastore.create_data_link(pack, handler, source)?;
129
130 let op = Operation::CreateDataLink {
131 pack: pack.clone(),
132 handler: handler.clone(),
133 source: source.clone(),
134 };
135
136 Ok(vec![OperationResult::ok(
137 op,
138 format!(
139 "staged {}",
140 source.file_name().unwrap_or_default().to_string_lossy(),
141 ),
142 )])
143 }
144
145 HandlerIntent::Run {
146 pack,
147 handler,
148 executable,
149 arguments,
150 sentinel,
151 } => {
152 if !self.provision_rerun {
154 let already_done = self.datastore.has_sentinel(pack, handler, sentinel)?;
155
156 if already_done {
157 let op = Operation::CheckSentinel {
158 pack: pack.clone(),
159 handler: handler.clone(),
160 sentinel: sentinel.clone(),
161 };
162 return Ok(vec![OperationResult::ok(op, "already completed")]);
163 }
164 }
165
166 self.datastore.run_and_record(
168 pack,
169 handler,
170 executable,
171 arguments,
172 sentinel,
173 self.provision_rerun,
174 )?;
175
176 let cmd_str = format!("{} {}", executable, arguments.join(" "));
177
178 let op = Operation::RunCommand {
179 pack: pack.clone(),
180 handler: handler.clone(),
181 executable: executable.clone(),
182 arguments: arguments.clone(),
183 sentinel: sentinel.clone(),
184 };
185
186 Ok(vec![OperationResult::ok(
187 op,
188 format!("executed: {}", cmd_str.trim()),
189 )])
190 }
191 }
192 }
193
194 fn simulate(&self, intent: &HandlerIntent) -> Vec<OperationResult> {
196 match intent {
197 HandlerIntent::Link {
198 pack,
199 handler,
200 source,
201 user_path,
202 } => {
203 if !self.fs.is_symlink(user_path) && self.fs.exists(user_path) {
205 if self.force {
206 return vec![OperationResult::ok(
207 Operation::CreateUserLink {
208 pack: pack.clone(),
209 handler: handler.clone(),
210 datastore_path: Default::default(),
211 user_path: user_path.clone(),
212 },
213 format!(
214 "[dry-run] would overwrite {} → {}",
215 source.file_name().unwrap_or_default().to_string_lossy(),
216 user_path.display()
217 ),
218 )];
219 } else {
220 return vec![OperationResult::fail(
221 Operation::CreateUserLink {
222 pack: pack.clone(),
223 handler: handler.clone(),
224 datastore_path: Default::default(),
225 user_path: user_path.clone(),
226 },
227 format!(
228 "conflict: {} already exists (use --force to overwrite)",
229 user_path.display()
230 ),
231 )];
232 }
233 }
234
235 vec![OperationResult::ok(
236 Operation::CreateUserLink {
237 pack: pack.clone(),
238 handler: handler.clone(),
239 datastore_path: Default::default(),
240 user_path: user_path.clone(),
241 },
242 format!(
243 "[dry-run] would link {} → {}",
244 source.file_name().unwrap_or_default().to_string_lossy(),
245 user_path.display()
246 ),
247 )]
248 }
249
250 HandlerIntent::Stage {
251 pack,
252 handler,
253 source,
254 } => {
255 vec![OperationResult::ok(
256 Operation::CreateDataLink {
257 pack: pack.clone(),
258 handler: handler.clone(),
259 source: source.clone(),
260 },
261 format!(
262 "[dry-run] would stage: {}",
263 source.file_name().unwrap_or_default().to_string_lossy()
264 ),
265 )]
266 }
267
268 HandlerIntent::Run {
269 pack,
270 handler,
271 executable,
272 arguments,
273 sentinel,
274 } => {
275 let cmd_str = format!("{} {}", executable, arguments.join(" "));
276 vec![OperationResult::ok(
277 Operation::RunCommand {
278 pack: pack.clone(),
279 handler: handler.clone(),
280 executable: executable.clone(),
281 arguments: arguments.clone(),
282 sentinel: sentinel.clone(),
283 },
284 format!("[dry-run] would execute: {}", cmd_str.trim()),
285 )]
286 }
287 }
288 }
289}
290
291#[cfg(test)]
292mod tests {
293 use super::*;
294 use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
295 use crate::paths::Pather;
296 use crate::testing::TempEnvironment;
297 use std::sync::{Arc, Mutex};
298
299 struct MockCommandRunner {
300 calls: Mutex<Vec<String>>,
301 }
302
303 impl MockCommandRunner {
304 fn new() -> Self {
305 Self {
306 calls: Mutex::new(Vec::new()),
307 }
308 }
309 }
310
311 impl CommandRunner for MockCommandRunner {
312 fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
313 let cmd_str = format!("{} {}", executable, arguments.join(" "));
314 self.calls.lock().unwrap().push(cmd_str.trim().to_string());
315 Ok(CommandOutput {
316 exit_code: 0,
317 stdout: String::new(),
318 stderr: String::new(),
319 })
320 }
321 }
322
323 fn make_datastore(env: &TempEnvironment) -> (FilesystemDataStore, Arc<MockCommandRunner>) {
324 let runner = Arc::new(MockCommandRunner::new());
325 let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner.clone());
326 (ds, runner)
327 }
328
329 #[test]
330 fn execute_link_creates_double_link() {
331 let env = TempEnvironment::builder()
332 .pack("vim")
333 .file("vimrc", "set nocompatible")
334 .done()
335 .build();
336 let (ds, _) = make_datastore(&env);
337 let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false);
338
339 let source = env.dotfiles_root.join("vim/vimrc");
340 let user_path = env.home.join(".vimrc");
341
342 let results = executor
343 .execute(vec![HandlerIntent::Link {
344 pack: "vim".into(),
345 handler: "symlink".into(),
346 source: source.clone(),
347 user_path: user_path.clone(),
348 }])
349 .unwrap();
350
351 assert_eq!(results.len(), 1);
352 assert!(results[0].success);
353
354 env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
356 }
357
358 #[test]
359 fn execute_link_conflict_returns_failed_result() {
360 let env = TempEnvironment::builder()
361 .pack("vim")
362 .file("vimrc", "set nocompatible")
363 .done()
364 .home_file(".vimrc", "existing content")
365 .build();
366 let (ds, _) = make_datastore(&env);
367 let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false);
368
369 let source = env.dotfiles_root.join("vim/vimrc");
370 let user_path = env.home.join(".vimrc");
371
372 let results = executor
373 .execute(vec![HandlerIntent::Link {
374 pack: "vim".into(),
375 handler: "symlink".into(),
376 source: source.clone(),
377 user_path: user_path.clone(),
378 }])
379 .unwrap();
380
381 assert_eq!(results.len(), 1);
382 assert!(!results[0].success, "should report conflict");
383 assert!(
384 results[0].message.contains("conflict"),
385 "msg: {}",
386 results[0].message
387 );
388 assert!(
389 results[0].message.contains("--force"),
390 "msg: {}",
391 results[0].message
392 );
393
394 env.assert_no_handler_state("vim", "symlink");
396
397 env.assert_file_contents(&user_path, "existing content");
399 }
400
401 #[test]
402 fn execute_link_force_overwrites_existing_file() {
403 let env = TempEnvironment::builder()
404 .pack("vim")
405 .file("vimrc", "set nocompatible")
406 .done()
407 .home_file(".vimrc", "existing content")
408 .build();
409 let (ds, _) = make_datastore(&env);
410 let executor = Executor::new(&ds, env.fs.as_ref(), false, true, false);
411
412 let source = env.dotfiles_root.join("vim/vimrc");
413 let user_path = env.home.join(".vimrc");
414
415 let results = executor
416 .execute(vec![HandlerIntent::Link {
417 pack: "vim".into(),
418 handler: "symlink".into(),
419 source: source.clone(),
420 user_path: user_path.clone(),
421 }])
422 .unwrap();
423
424 assert_eq!(results.len(), 1);
425 assert!(results[0].success, "force should succeed");
426
427 env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
429
430 let content = env.fs.read_to_string(&user_path).unwrap();
432 assert_eq!(content, "set nocompatible");
433 }
434
435 #[test]
436 fn execute_link_conflict_does_not_block_other_intents() {
437 let env = TempEnvironment::builder()
438 .pack("vim")
439 .file("vimrc", "set nocompatible")
440 .file("gvimrc", "set guifont=Mono")
441 .done()
442 .home_file(".vimrc", "existing content")
443 .build();
444 let (ds, _) = make_datastore(&env);
445 let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false);
446
447 let results = executor
448 .execute(vec![
449 HandlerIntent::Link {
450 pack: "vim".into(),
451 handler: "symlink".into(),
452 source: env.dotfiles_root.join("vim/vimrc"),
453 user_path: env.home.join(".vimrc"),
454 },
455 HandlerIntent::Link {
456 pack: "vim".into(),
457 handler: "symlink".into(),
458 source: env.dotfiles_root.join("vim/gvimrc"),
459 user_path: env.home.join(".gvimrc"),
460 },
461 ])
462 .unwrap();
463
464 assert_eq!(results.len(), 2);
465 assert!(!results[0].success);
467 assert!(results[1].success);
469
470 env.assert_double_link(
472 "vim",
473 "symlink",
474 "gvimrc",
475 &env.dotfiles_root.join("vim/gvimrc"),
476 &env.home.join(".gvimrc"),
477 );
478 }
479
480 #[test]
481 fn execute_stage_creates_data_link_only() {
482 let env = TempEnvironment::builder()
483 .pack("vim")
484 .file("aliases.sh", "alias vi=vim")
485 .done()
486 .build();
487 let (ds, _) = make_datastore(&env);
488 let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false);
489
490 let source = env.dotfiles_root.join("vim/aliases.sh");
491
492 let results = executor
493 .execute(vec![HandlerIntent::Stage {
494 pack: "vim".into(),
495 handler: "shell".into(),
496 source: source.clone(),
497 }])
498 .unwrap();
499
500 assert_eq!(results.len(), 1);
501 assert!(results[0].success);
502
503 let datastore_link = env
505 .paths
506 .handler_data_dir("vim", "shell")
507 .join("aliases.sh");
508 env.assert_symlink(&datastore_link, &source);
509 }
510
511 #[test]
512 fn execute_run_creates_sentinel() {
513 let env = TempEnvironment::builder().build();
514 let (ds, runner) = make_datastore(&env);
515 let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false);
516
517 let results = executor
518 .execute(vec![HandlerIntent::Run {
519 pack: "vim".into(),
520 handler: "install".into(),
521 executable: "echo".into(),
522 arguments: vec!["hello".into()],
523 sentinel: "install.sh-abc123".into(),
524 }])
525 .unwrap();
526
527 assert_eq!(results.len(), 1);
528 assert!(results[0].success);
529 assert_eq!(runner.calls.lock().unwrap().as_slice(), &["echo hello"]);
530 env.assert_sentinel("vim", "install", "install.sh-abc123");
531 }
532
533 #[test]
534 fn execute_run_skips_when_sentinel_exists() {
535 let env = TempEnvironment::builder().build();
536 let (ds, runner) = make_datastore(&env);
537
538 let sentinel_dir = env.paths.handler_data_dir("vim", "install");
540 env.fs.mkdir_all(&sentinel_dir).unwrap();
541 env.fs
542 .write_file(&sentinel_dir.join("install.sh-abc123"), b"completed|12345")
543 .unwrap();
544
545 let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false);
546 let results = executor
547 .execute(vec![HandlerIntent::Run {
548 pack: "vim".into(),
549 handler: "install".into(),
550 executable: "echo".into(),
551 arguments: vec!["should-not-run".into()],
552 sentinel: "install.sh-abc123".into(),
553 }])
554 .unwrap();
555
556 assert_eq!(results.len(), 1);
557 assert!(results[0].success);
558 assert!(results[0].message.contains("already completed"));
559 assert!(runner.calls.lock().unwrap().is_empty());
560 }
561
562 #[test]
563 fn provision_rerun_ignores_sentinel() {
564 let env = TempEnvironment::builder().build();
565 let (ds, runner) = make_datastore(&env);
566
567 let sentinel_dir = env.paths.handler_data_dir("vim", "install");
569 env.fs.mkdir_all(&sentinel_dir).unwrap();
570 env.fs
571 .write_file(&sentinel_dir.join("install.sh-abc123"), b"completed|12345")
572 .unwrap();
573
574 let executor = Executor::new(&ds, env.fs.as_ref(), false, false, true);
575 let results = executor
576 .execute(vec![HandlerIntent::Run {
577 pack: "vim".into(),
578 handler: "install".into(),
579 executable: "echo".into(),
580 arguments: vec!["rerun".into()],
581 sentinel: "install.sh-abc123".into(),
582 }])
583 .unwrap();
584
585 assert_eq!(results.len(), 1);
586 assert!(results[0].success);
587 assert!(
588 results[0].message.contains("executed"),
589 "msg: {}",
590 results[0].message
591 );
592 assert_eq!(runner.calls.lock().unwrap().as_slice(), &["echo rerun"]);
593 }
594
595 #[test]
596 fn dry_run_does_not_modify_filesystem() {
597 let env = TempEnvironment::builder()
598 .pack("vim")
599 .file("vimrc", "x")
600 .done()
601 .build();
602 let (ds, _) = make_datastore(&env);
603 let executor = Executor::new(&ds, env.fs.as_ref(), true, false, false);
604
605 let results = executor
606 .execute(vec![
607 HandlerIntent::Link {
608 pack: "vim".into(),
609 handler: "symlink".into(),
610 source: env.dotfiles_root.join("vim/vimrc"),
611 user_path: env.home.join(".vimrc"),
612 },
613 HandlerIntent::Stage {
614 pack: "vim".into(),
615 handler: "shell".into(),
616 source: env.dotfiles_root.join("vim/vimrc"),
617 },
618 HandlerIntent::Run {
619 pack: "vim".into(),
620 handler: "install".into(),
621 executable: "echo".into(),
622 arguments: vec!["hi".into()],
623 sentinel: "s1".into(),
624 },
625 ])
626 .unwrap();
627
628 assert_eq!(results.len(), 3); for r in &results {
631 assert!(r.success);
632 assert!(r.message.contains("[dry-run]"), "msg: {}", r.message);
633 }
634
635 env.assert_not_exists(&env.home.join(".vimrc"));
637 env.assert_no_handler_state("vim", "symlink");
638 env.assert_no_handler_state("vim", "shell");
639 env.assert_no_handler_state("vim", "install");
640 }
641
642 #[test]
643 fn dry_run_detects_conflict() {
644 let env = TempEnvironment::builder()
645 .pack("vim")
646 .file("vimrc", "x")
647 .done()
648 .home_file(".vimrc", "existing")
649 .build();
650 let (ds, _) = make_datastore(&env);
651 let executor = Executor::new(&ds, env.fs.as_ref(), true, false, false);
652
653 let results = executor
654 .execute(vec![HandlerIntent::Link {
655 pack: "vim".into(),
656 handler: "symlink".into(),
657 source: env.dotfiles_root.join("vim/vimrc"),
658 user_path: env.home.join(".vimrc"),
659 }])
660 .unwrap();
661
662 assert_eq!(results.len(), 1);
663 assert!(!results[0].success);
664 assert!(results[0].message.contains("conflict"));
665 }
666
667 #[test]
668 fn execute_multiple_intents_sequentially() {
669 let env = TempEnvironment::builder()
670 .pack("vim")
671 .file("vimrc", "set nocompatible")
672 .file("gvimrc", "set guifont=Mono")
673 .done()
674 .build();
675 let (ds, _) = make_datastore(&env);
676 let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false);
677
678 let results = executor
679 .execute(vec![
680 HandlerIntent::Link {
681 pack: "vim".into(),
682 handler: "symlink".into(),
683 source: env.dotfiles_root.join("vim/vimrc"),
684 user_path: env.home.join(".vimrc"),
685 },
686 HandlerIntent::Link {
687 pack: "vim".into(),
688 handler: "symlink".into(),
689 source: env.dotfiles_root.join("vim/gvimrc"),
690 user_path: env.home.join(".gvimrc"),
691 },
692 ])
693 .unwrap();
694
695 assert_eq!(results.len(), 2); assert!(results.iter().all(|r| r.success));
697
698 env.assert_double_link(
699 "vim",
700 "symlink",
701 "vimrc",
702 &env.dotfiles_root.join("vim/vimrc"),
703 &env.home.join(".vimrc"),
704 );
705 env.assert_double_link(
706 "vim",
707 "symlink",
708 "gvimrc",
709 &env.dotfiles_root.join("vim/gvimrc"),
710 &env.home.join(".gvimrc"),
711 );
712 }
713}