Skip to main content

dodot_lib/execution/
mod.rs

1//! Executor — converts [`HandlerIntent`]s into [`DataStore`] calls.
2//!
3//! The executor is where the complexity lives. Handlers just declare
4//! what they want; the executor figures out how to make it happen.
5
6use crate::datastore::DataStore;
7use crate::fs::Fs;
8use crate::operations::{HandlerIntent, Operation, OperationResult};
9use crate::Result;
10
11/// Executes handler intents by dispatching to the DataStore.
12pub 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    /// Execute a list of handler intents, returning one result per
38    /// atomic operation performed.
39    ///
40    /// Conflicts (pre-existing files at target paths) are returned as
41    /// failed `OperationResult`s — non-fatal, so other intents still
42    /// execute. Hard errors (I/O failures, command failures) stop
43    /// execution immediately via `?`.
44    /// In dry-run mode, all intents are simulated regardless of errors.
45    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    /// Execute a single intent, which may produce multiple operations.
61    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                // Pre-check: does a non-symlink file exist at user_path?
70                // We check BEFORE creating the data link to avoid leaving
71                // dangling state when the user link would fail.
72                if !self.fs.is_symlink(user_path) && self.fs.exists(user_path) {
73                    if self.force {
74                        // Remove the existing path before creating the symlink
75                        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                        // Return a failed result — non-fatal so other files
82                        // in the pack can still be processed.
83                        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                // Step 1: Create data link (source → datastore)
100                let datastore_path = self.datastore.create_data_link(pack, handler, source)?;
101
102                // Step 2: Create user link (datastore → user location)
103                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                // Check sentinel first — unless provision_rerun is set
153                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                // Run the command
167                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    /// Simulate an intent without touching the filesystem.
195    fn simulate(&self, intent: &HandlerIntent) -> Vec<OperationResult> {
196        match intent {
197            HandlerIntent::Link {
198                pack,
199                handler,
200                source,
201                user_path,
202            } => {
203                // Check for conflicts even in dry-run
204                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        // Verify the double-link chain
355        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        // Data link should NOT have been created (pre-check prevents it)
395        env.assert_no_handler_state("vim", "symlink");
396
397        // Original file should be untouched
398        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        // Verify the double-link chain was created
428        env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
429
430        // Content should now be from the pack
431        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        // First should fail (conflict)
466        assert!(!results[0].success);
467        // Second should succeed (no conflict)
468        assert!(results[1].success);
469
470        // gvimrc should be deployed despite vimrc conflict
471        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        // Data link should exist
504        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        // Pre-create sentinel
539        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        // Pre-create sentinel
568        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        // All should succeed with dry-run messages
629        assert_eq!(results.len(), 3); // Link=1, Stage=1, Run=1
630        for r in &results {
631            assert!(r.success);
632            assert!(r.message.contains("[dry-run]"), "msg: {}", r.message);
633        }
634
635        // Nothing should have been created
636        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); // 1 op per link
696        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}