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//!
6//! ## Auto-executable permissions
7//!
8//! When `auto_chmod_exec` is enabled (the default), the executor
9//! ensures that files inside path-handler staged directories have
10//! execute permissions (`+x`). This matches the user's intent: files
11//! in `bin/` are there to be runnable, but execute bits can be lost
12//! in common workflows (git on macOS, manual file creation).
13//!
14//! Permission failures are reported as warnings in the operation
15//! results, not hard errors — the file is still staged and added to
16//! `$PATH`, it just won't be directly runnable until the user fixes
17//! permissions manually.
18
19use tracing::{debug, info};
20
21use crate::datastore::DataStore;
22use crate::fs::Fs;
23use crate::handlers::HANDLER_PATH;
24use crate::operations::{HandlerIntent, Operation, OperationResult};
25use crate::Result;
26
27/// Executes handler intents by dispatching to the DataStore.
28pub struct Executor<'a> {
29    datastore: &'a dyn DataStore,
30    fs: &'a dyn Fs,
31    dry_run: bool,
32    force: bool,
33    provision_rerun: bool,
34    auto_chmod_exec: bool,
35}
36
37impl<'a> Executor<'a> {
38    pub fn new(
39        datastore: &'a dyn DataStore,
40        fs: &'a dyn Fs,
41        dry_run: bool,
42        force: bool,
43        provision_rerun: bool,
44        auto_chmod_exec: bool,
45    ) -> Self {
46        Self {
47            datastore,
48            fs,
49            dry_run,
50            force,
51            provision_rerun,
52            auto_chmod_exec,
53        }
54    }
55
56    /// Execute a list of handler intents, returning one result per
57    /// atomic operation performed.
58    ///
59    /// Conflicts (pre-existing files at target paths) are returned as
60    /// failed `OperationResult`s — non-fatal, so other intents still
61    /// execute. Hard errors (I/O failures, command failures) stop
62    /// execution immediately via `?`.
63    /// In dry-run mode, all intents are simulated regardless of errors.
64    pub fn execute(&self, intents: Vec<HandlerIntent>) -> Result<Vec<OperationResult>> {
65        debug!(
66            count = intents.len(),
67            dry_run = self.dry_run,
68            force = self.force,
69            "executor starting"
70        );
71        let mut results = Vec::new();
72
73        for intent in intents {
74            let intent_results = if self.dry_run {
75                self.simulate(&intent)
76            } else {
77                self.execute_one(&intent)?
78            };
79            results.extend(intent_results);
80        }
81
82        let succeeded = results.iter().filter(|r| r.success).count();
83        let failed = results.iter().filter(|r| !r.success).count();
84        debug!(succeeded, failed, "executor finished");
85
86        Ok(results)
87    }
88
89    /// Execute a single intent, which may produce multiple operations.
90    fn execute_one(&self, intent: &HandlerIntent) -> Result<Vec<OperationResult>> {
91        match intent {
92            HandlerIntent::Link {
93                pack,
94                handler,
95                source,
96                user_path,
97            } => {
98                debug!(
99                    pack,
100                    handler,
101                    source = %source.display(),
102                    user_path = %user_path.display(),
103                    "executing link intent"
104                );
105
106                // Pre-check: does a non-symlink file exist at user_path?
107                // We check BEFORE creating the data link to avoid leaving
108                // dangling state when the user link would fail.
109                if !self.fs.is_symlink(user_path) && self.fs.exists(user_path) {
110                    if self.force {
111                        info!(
112                            pack,
113                            path = %user_path.display(),
114                            "force-removing existing file"
115                        );
116                        // Remove the existing path before creating the symlink
117                        if self.fs.is_dir(user_path) {
118                            self.fs.remove_dir_all(user_path)?;
119                        } else {
120                            self.fs.remove_file(user_path)?;
121                        }
122                    } else {
123                        info!(
124                            pack,
125                            path = %user_path.display(),
126                            "conflict: file already exists"
127                        );
128                        // Return a failed result — non-fatal so other files
129                        // in the pack can still be processed.
130                        let op = Operation::CreateUserLink {
131                            pack: pack.clone(),
132                            handler: handler.clone(),
133                            datastore_path: Default::default(),
134                            user_path: user_path.clone(),
135                        };
136                        return Ok(vec![OperationResult::fail(
137                            op,
138                            format!(
139                                "conflict: {} already exists (use --force to overwrite)",
140                                user_path.display()
141                            ),
142                        )]);
143                    }
144                }
145
146                // Step 1: Create data link (source → datastore)
147                let datastore_path = self.datastore.create_data_link(pack, handler, source)?;
148                debug!(
149                    pack,
150                    datastore_path = %datastore_path.display(),
151                    "created data link"
152                );
153
154                // Step 2: Create user link (datastore → user location)
155                self.datastore
156                    .create_user_link(&datastore_path, user_path)?;
157
158                let filename = source.file_name().unwrap_or_default().to_string_lossy();
159                info!(
160                    pack,
161                    file = %filename,
162                    target = %user_path.display(),
163                    "created symlink"
164                );
165
166                let op = Operation::CreateUserLink {
167                    pack: pack.clone(),
168                    handler: handler.clone(),
169                    datastore_path: datastore_path.clone(),
170                    user_path: user_path.clone(),
171                };
172
173                Ok(vec![OperationResult::ok(
174                    op,
175                    format!("{} → {}", filename, user_path.display()),
176                )])
177            }
178
179            HandlerIntent::Stage {
180                pack,
181                handler,
182                source,
183            } => {
184                let filename = source.file_name().unwrap_or_default().to_string_lossy();
185                info!(pack, handler = handler.as_str(), file = %filename, "staging file");
186
187                self.datastore.create_data_link(pack, handler, source)?;
188
189                let op = Operation::CreateDataLink {
190                    pack: pack.clone(),
191                    handler: handler.clone(),
192                    source: source.clone(),
193                };
194
195                let mut results = vec![OperationResult::ok(op, format!("staged {}", filename))];
196
197                // Auto-chmod +x for path handler directories
198                if handler == HANDLER_PATH && self.auto_chmod_exec {
199                    debug!(pack, source = %source.display(), "checking executable permissions");
200                    results.extend(self.ensure_executable(pack, source));
201                }
202
203                Ok(results)
204            }
205
206            HandlerIntent::Run {
207                pack,
208                handler,
209                executable,
210                arguments,
211                sentinel,
212            } => {
213                // Check sentinel first — unless provision_rerun is set
214                if !self.provision_rerun {
215                    let already_done = self.datastore.has_sentinel(pack, handler, sentinel)?;
216
217                    if already_done {
218                        info!(
219                            pack,
220                            handler = handler.as_str(),
221                            sentinel,
222                            "sentinel found, skipping"
223                        );
224                        let op = Operation::CheckSentinel {
225                            pack: pack.clone(),
226                            handler: handler.clone(),
227                            sentinel: sentinel.clone(),
228                        };
229                        return Ok(vec![OperationResult::ok(op, "already completed")]);
230                    }
231                }
232
233                let cmd_str = format!("{} {}", executable, arguments.join(" "));
234                info!(pack, handler = handler.as_str(), command = %cmd_str.trim(), "running command");
235
236                // Run the command
237                self.datastore.run_and_record(
238                    pack,
239                    handler,
240                    executable,
241                    arguments,
242                    sentinel,
243                    self.provision_rerun,
244                )?;
245
246                info!(pack, sentinel, "command completed, sentinel recorded");
247
248                let op = Operation::RunCommand {
249                    pack: pack.clone(),
250                    handler: handler.clone(),
251                    executable: executable.clone(),
252                    arguments: arguments.clone(),
253                    sentinel: sentinel.clone(),
254                };
255
256                Ok(vec![OperationResult::ok(
257                    op,
258                    format!("executed: {}", cmd_str.trim()),
259                )])
260            }
261        }
262    }
263
264    /// Ensure all files in a path-handler directory are executable.
265    ///
266    /// Iterates files in `dir`, checks each for the execute bit, and
267    /// adds it if missing. Returns one `OperationResult` per file that
268    /// was made executable (or that failed). Files that are already
269    /// executable produce no output.
270    ///
271    /// Permission failures are non-fatal: they are reported as
272    /// *successful* operations with a warning message, so they don't
273    /// flip the pack to "failed" status. The file is still staged and
274    /// visible in `$PATH`, it just won't be runnable until the user
275    /// fixes permissions manually.
276    fn ensure_executable(&self, pack: &str, dir: &std::path::Path) -> Vec<OperationResult> {
277        let mut results = Vec::new();
278        let entries = match self.fs.read_dir(dir) {
279            Ok(e) => e,
280            Err(e) => {
281                let op = Operation::CreateDataLink {
282                    pack: pack.into(),
283                    handler: HANDLER_PATH.into(),
284                    source: dir.to_path_buf(),
285                };
286                results.push(OperationResult::ok(
287                    op,
288                    format!(
289                        "warning: could not list {} for auto-chmod: {}",
290                        dir.display(),
291                        e
292                    ),
293                ));
294                return results;
295            }
296        };
297
298        for entry in entries {
299            if !entry.is_file {
300                continue;
301            }
302            let meta = match self.fs.stat(&entry.path) {
303                Ok(m) => m,
304                Err(e) => {
305                    let op = Operation::CreateDataLink {
306                        pack: pack.into(),
307                        handler: HANDLER_PATH.into(),
308                        source: entry.path.clone(),
309                    };
310                    results.push(OperationResult::ok(
311                        op,
312                        format!("warning: could not stat {}: {}", entry.name, e),
313                    ));
314                    continue;
315                }
316            };
317
318            let is_exec = meta.mode & 0o111 != 0;
319            if is_exec {
320                continue;
321            }
322
323            // Add user/group/other execute bits, preserving existing permissions.
324            let new_mode = meta.mode | 0o111;
325            let op = Operation::CreateDataLink {
326                pack: pack.into(),
327                handler: HANDLER_PATH.into(),
328                source: entry.path.clone(),
329            };
330
331            match self.fs.set_permissions(&entry.path, new_mode) {
332                Ok(()) => {
333                    info!(pack, file = %entry.name, mode = format!("{:o}", new_mode), "chmod +x");
334                    results.push(OperationResult::ok(op, format!("chmod +x {}", entry.name)));
335                }
336                Err(e) => {
337                    info!(pack, file = %entry.name, error = %e, "chmod +x failed");
338                    // Warning, not failure — don't mark the pack as failed
339                    // just because chmod didn't work.
340                    results.push(OperationResult::ok(
341                        op,
342                        format!("warning: could not chmod +x {}: {}", entry.name, e),
343                    ));
344                }
345            }
346        }
347
348        results
349    }
350
351    /// Report files in a path-handler directory that lack execute
352    /// permissions (dry-run mode — no mutations).
353    fn report_non_executable(&self, pack: &str, dir: &std::path::Path) -> Vec<OperationResult> {
354        let mut results = Vec::new();
355        let entries = match self.fs.read_dir(dir) {
356            Ok(e) => e,
357            Err(_) => return results,
358        };
359
360        for entry in entries {
361            if !entry.is_file {
362                continue;
363            }
364            let meta = match self.fs.stat(&entry.path) {
365                Ok(m) => m,
366                Err(_) => continue,
367            };
368
369            let is_exec = meta.mode & 0o111 != 0;
370            if !is_exec {
371                let op = Operation::CreateDataLink {
372                    pack: pack.into(),
373                    handler: HANDLER_PATH.into(),
374                    source: entry.path.clone(),
375                };
376                results.push(OperationResult::ok(
377                    op,
378                    format!("[dry-run] would chmod +x {}", entry.name),
379                ));
380            }
381        }
382
383        results
384    }
385
386    /// Simulate an intent without touching the filesystem.
387    fn simulate(&self, intent: &HandlerIntent) -> Vec<OperationResult> {
388        match intent {
389            HandlerIntent::Link {
390                pack,
391                handler,
392                source,
393                user_path,
394            } => {
395                // Check for conflicts even in dry-run
396                if !self.fs.is_symlink(user_path) && self.fs.exists(user_path) {
397                    if self.force {
398                        return vec![OperationResult::ok(
399                            Operation::CreateUserLink {
400                                pack: pack.clone(),
401                                handler: handler.clone(),
402                                datastore_path: Default::default(),
403                                user_path: user_path.clone(),
404                            },
405                            format!(
406                                "[dry-run] would overwrite {} → {}",
407                                source.file_name().unwrap_or_default().to_string_lossy(),
408                                user_path.display()
409                            ),
410                        )];
411                    } else {
412                        return vec![OperationResult::fail(
413                            Operation::CreateUserLink {
414                                pack: pack.clone(),
415                                handler: handler.clone(),
416                                datastore_path: Default::default(),
417                                user_path: user_path.clone(),
418                            },
419                            format!(
420                                "conflict: {} already exists (use --force to overwrite)",
421                                user_path.display()
422                            ),
423                        )];
424                    }
425                }
426
427                vec![OperationResult::ok(
428                    Operation::CreateUserLink {
429                        pack: pack.clone(),
430                        handler: handler.clone(),
431                        datastore_path: Default::default(),
432                        user_path: user_path.clone(),
433                    },
434                    format!(
435                        "[dry-run] would link {} → {}",
436                        source.file_name().unwrap_or_default().to_string_lossy(),
437                        user_path.display()
438                    ),
439                )]
440            }
441
442            HandlerIntent::Stage {
443                pack,
444                handler,
445                source,
446            } => {
447                let mut results = vec![OperationResult::ok(
448                    Operation::CreateDataLink {
449                        pack: pack.clone(),
450                        handler: handler.clone(),
451                        source: source.clone(),
452                    },
453                    format!(
454                        "[dry-run] would stage: {}",
455                        source.file_name().unwrap_or_default().to_string_lossy()
456                    ),
457                )];
458
459                if handler == HANDLER_PATH && self.auto_chmod_exec {
460                    results.extend(self.report_non_executable(pack, source));
461                }
462
463                results
464            }
465
466            HandlerIntent::Run {
467                pack,
468                handler,
469                executable,
470                arguments,
471                sentinel,
472            } => {
473                let cmd_str = format!("{} {}", executable, arguments.join(" "));
474                vec![OperationResult::ok(
475                    Operation::RunCommand {
476                        pack: pack.clone(),
477                        handler: handler.clone(),
478                        executable: executable.clone(),
479                        arguments: arguments.clone(),
480                        sentinel: sentinel.clone(),
481                    },
482                    format!("[dry-run] would execute: {}", cmd_str.trim()),
483                )]
484            }
485        }
486    }
487}
488
489#[cfg(test)]
490mod tests {
491    use super::*;
492    use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
493    use crate::paths::Pather;
494    use crate::testing::TempEnvironment;
495    use std::sync::{Arc, Mutex};
496
497    struct MockCommandRunner {
498        calls: Mutex<Vec<String>>,
499    }
500
501    impl MockCommandRunner {
502        fn new() -> Self {
503            Self {
504                calls: Mutex::new(Vec::new()),
505            }
506        }
507    }
508
509    impl CommandRunner for MockCommandRunner {
510        fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
511            let cmd_str = format!("{} {}", executable, arguments.join(" "));
512            self.calls.lock().unwrap().push(cmd_str.trim().to_string());
513            Ok(CommandOutput {
514                exit_code: 0,
515                stdout: String::new(),
516                stderr: String::new(),
517            })
518        }
519    }
520
521    fn make_datastore(env: &TempEnvironment) -> (FilesystemDataStore, Arc<MockCommandRunner>) {
522        let runner = Arc::new(MockCommandRunner::new());
523        let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner.clone());
524        (ds, runner)
525    }
526
527    #[test]
528    fn execute_link_creates_double_link() {
529        let env = TempEnvironment::builder()
530            .pack("vim")
531            .file("vimrc", "set nocompatible")
532            .done()
533            .build();
534        let (ds, _) = make_datastore(&env);
535        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
536
537        let source = env.dotfiles_root.join("vim/vimrc");
538        let user_path = env.home.join(".vimrc");
539
540        let results = executor
541            .execute(vec![HandlerIntent::Link {
542                pack: "vim".into(),
543                handler: "symlink".into(),
544                source: source.clone(),
545                user_path: user_path.clone(),
546            }])
547            .unwrap();
548
549        assert_eq!(results.len(), 1);
550        assert!(results[0].success);
551
552        // Verify the double-link chain
553        env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
554    }
555
556    #[test]
557    fn execute_link_conflict_returns_failed_result() {
558        let env = TempEnvironment::builder()
559            .pack("vim")
560            .file("vimrc", "set nocompatible")
561            .done()
562            .home_file(".vimrc", "existing content")
563            .build();
564        let (ds, _) = make_datastore(&env);
565        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
566
567        let source = env.dotfiles_root.join("vim/vimrc");
568        let user_path = env.home.join(".vimrc");
569
570        let results = executor
571            .execute(vec![HandlerIntent::Link {
572                pack: "vim".into(),
573                handler: "symlink".into(),
574                source: source.clone(),
575                user_path: user_path.clone(),
576            }])
577            .unwrap();
578
579        assert_eq!(results.len(), 1);
580        assert!(!results[0].success, "should report conflict");
581        assert!(
582            results[0].message.contains("conflict"),
583            "msg: {}",
584            results[0].message
585        );
586        assert!(
587            results[0].message.contains("--force"),
588            "msg: {}",
589            results[0].message
590        );
591
592        // Data link should NOT have been created (pre-check prevents it)
593        env.assert_no_handler_state("vim", "symlink");
594
595        // Original file should be untouched
596        env.assert_file_contents(&user_path, "existing content");
597    }
598
599    #[test]
600    fn execute_link_force_overwrites_existing_file() {
601        let env = TempEnvironment::builder()
602            .pack("vim")
603            .file("vimrc", "set nocompatible")
604            .done()
605            .home_file(".vimrc", "existing content")
606            .build();
607        let (ds, _) = make_datastore(&env);
608        let executor = Executor::new(&ds, env.fs.as_ref(), false, true, false, true);
609
610        let source = env.dotfiles_root.join("vim/vimrc");
611        let user_path = env.home.join(".vimrc");
612
613        let results = executor
614            .execute(vec![HandlerIntent::Link {
615                pack: "vim".into(),
616                handler: "symlink".into(),
617                source: source.clone(),
618                user_path: user_path.clone(),
619            }])
620            .unwrap();
621
622        assert_eq!(results.len(), 1);
623        assert!(results[0].success, "force should succeed");
624
625        // Verify the double-link chain was created
626        env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
627
628        // Content should now be from the pack
629        let content = env.fs.read_to_string(&user_path).unwrap();
630        assert_eq!(content, "set nocompatible");
631    }
632
633    #[test]
634    fn execute_link_conflict_does_not_block_other_intents() {
635        let env = TempEnvironment::builder()
636            .pack("vim")
637            .file("vimrc", "set nocompatible")
638            .file("gvimrc", "set guifont=Mono")
639            .done()
640            .home_file(".vimrc", "existing content")
641            .build();
642        let (ds, _) = make_datastore(&env);
643        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
644
645        let results = executor
646            .execute(vec![
647                HandlerIntent::Link {
648                    pack: "vim".into(),
649                    handler: "symlink".into(),
650                    source: env.dotfiles_root.join("vim/vimrc"),
651                    user_path: env.home.join(".vimrc"),
652                },
653                HandlerIntent::Link {
654                    pack: "vim".into(),
655                    handler: "symlink".into(),
656                    source: env.dotfiles_root.join("vim/gvimrc"),
657                    user_path: env.home.join(".gvimrc"),
658                },
659            ])
660            .unwrap();
661
662        assert_eq!(results.len(), 2);
663        // First should fail (conflict)
664        assert!(!results[0].success);
665        // Second should succeed (no conflict)
666        assert!(results[1].success);
667
668        // gvimrc should be deployed despite vimrc conflict
669        env.assert_double_link(
670            "vim",
671            "symlink",
672            "gvimrc",
673            &env.dotfiles_root.join("vim/gvimrc"),
674            &env.home.join(".gvimrc"),
675        );
676    }
677
678    #[test]
679    fn execute_stage_creates_data_link_only() {
680        let env = TempEnvironment::builder()
681            .pack("vim")
682            .file("aliases.sh", "alias vi=vim")
683            .done()
684            .build();
685        let (ds, _) = make_datastore(&env);
686        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
687
688        let source = env.dotfiles_root.join("vim/aliases.sh");
689
690        let results = executor
691            .execute(vec![HandlerIntent::Stage {
692                pack: "vim".into(),
693                handler: "shell".into(),
694                source: source.clone(),
695            }])
696            .unwrap();
697
698        assert_eq!(results.len(), 1);
699        assert!(results[0].success);
700
701        // Data link should exist
702        let datastore_link = env
703            .paths
704            .handler_data_dir("vim", "shell")
705            .join("aliases.sh");
706        env.assert_symlink(&datastore_link, &source);
707    }
708
709    #[test]
710    fn execute_run_creates_sentinel() {
711        let env = TempEnvironment::builder().build();
712        let (ds, runner) = make_datastore(&env);
713        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
714
715        let results = executor
716            .execute(vec![HandlerIntent::Run {
717                pack: "vim".into(),
718                handler: "install".into(),
719                executable: "echo".into(),
720                arguments: vec!["hello".into()],
721                sentinel: "install.sh-abc123".into(),
722            }])
723            .unwrap();
724
725        assert_eq!(results.len(), 1);
726        assert!(results[0].success);
727        assert_eq!(runner.calls.lock().unwrap().as_slice(), &["echo hello"]);
728        env.assert_sentinel("vim", "install", "install.sh-abc123");
729    }
730
731    #[test]
732    fn execute_run_skips_when_sentinel_exists() {
733        let env = TempEnvironment::builder().build();
734        let (ds, runner) = make_datastore(&env);
735
736        // Pre-create sentinel
737        let sentinel_dir = env.paths.handler_data_dir("vim", "install");
738        env.fs.mkdir_all(&sentinel_dir).unwrap();
739        env.fs
740            .write_file(&sentinel_dir.join("install.sh-abc123"), b"completed|12345")
741            .unwrap();
742
743        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
744        let results = executor
745            .execute(vec![HandlerIntent::Run {
746                pack: "vim".into(),
747                handler: "install".into(),
748                executable: "echo".into(),
749                arguments: vec!["should-not-run".into()],
750                sentinel: "install.sh-abc123".into(),
751            }])
752            .unwrap();
753
754        assert_eq!(results.len(), 1);
755        assert!(results[0].success);
756        assert!(results[0].message.contains("already completed"));
757        assert!(runner.calls.lock().unwrap().is_empty());
758    }
759
760    #[test]
761    fn provision_rerun_ignores_sentinel() {
762        let env = TempEnvironment::builder().build();
763        let (ds, runner) = make_datastore(&env);
764
765        // Pre-create sentinel
766        let sentinel_dir = env.paths.handler_data_dir("vim", "install");
767        env.fs.mkdir_all(&sentinel_dir).unwrap();
768        env.fs
769            .write_file(&sentinel_dir.join("install.sh-abc123"), b"completed|12345")
770            .unwrap();
771
772        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, true, true);
773        let results = executor
774            .execute(vec![HandlerIntent::Run {
775                pack: "vim".into(),
776                handler: "install".into(),
777                executable: "echo".into(),
778                arguments: vec!["rerun".into()],
779                sentinel: "install.sh-abc123".into(),
780            }])
781            .unwrap();
782
783        assert_eq!(results.len(), 1);
784        assert!(results[0].success);
785        assert!(
786            results[0].message.contains("executed"),
787            "msg: {}",
788            results[0].message
789        );
790        assert_eq!(runner.calls.lock().unwrap().as_slice(), &["echo rerun"]);
791    }
792
793    #[test]
794    fn dry_run_does_not_modify_filesystem() {
795        let env = TempEnvironment::builder()
796            .pack("vim")
797            .file("vimrc", "x")
798            .done()
799            .build();
800        let (ds, _) = make_datastore(&env);
801        let executor = Executor::new(&ds, env.fs.as_ref(), true, false, false, true);
802
803        let results = executor
804            .execute(vec![
805                HandlerIntent::Link {
806                    pack: "vim".into(),
807                    handler: "symlink".into(),
808                    source: env.dotfiles_root.join("vim/vimrc"),
809                    user_path: env.home.join(".vimrc"),
810                },
811                HandlerIntent::Stage {
812                    pack: "vim".into(),
813                    handler: "shell".into(),
814                    source: env.dotfiles_root.join("vim/vimrc"),
815                },
816                HandlerIntent::Run {
817                    pack: "vim".into(),
818                    handler: "install".into(),
819                    executable: "echo".into(),
820                    arguments: vec!["hi".into()],
821                    sentinel: "s1".into(),
822                },
823            ])
824            .unwrap();
825
826        // All should succeed with dry-run messages
827        assert_eq!(results.len(), 3); // Link=1, Stage=1, Run=1
828        for r in &results {
829            assert!(r.success);
830            assert!(r.message.contains("[dry-run]"), "msg: {}", r.message);
831        }
832
833        // Nothing should have been created
834        env.assert_not_exists(&env.home.join(".vimrc"));
835        env.assert_no_handler_state("vim", "symlink");
836        env.assert_no_handler_state("vim", "shell");
837        env.assert_no_handler_state("vim", "install");
838    }
839
840    #[test]
841    fn dry_run_detects_conflict() {
842        let env = TempEnvironment::builder()
843            .pack("vim")
844            .file("vimrc", "x")
845            .done()
846            .home_file(".vimrc", "existing")
847            .build();
848        let (ds, _) = make_datastore(&env);
849        let executor = Executor::new(&ds, env.fs.as_ref(), true, false, false, true);
850
851        let results = executor
852            .execute(vec![HandlerIntent::Link {
853                pack: "vim".into(),
854                handler: "symlink".into(),
855                source: env.dotfiles_root.join("vim/vimrc"),
856                user_path: env.home.join(".vimrc"),
857            }])
858            .unwrap();
859
860        assert_eq!(results.len(), 1);
861        assert!(!results[0].success);
862        assert!(results[0].message.contains("conflict"));
863    }
864
865    #[test]
866    fn execute_multiple_intents_sequentially() {
867        let env = TempEnvironment::builder()
868            .pack("vim")
869            .file("vimrc", "set nocompatible")
870            .file("gvimrc", "set guifont=Mono")
871            .done()
872            .build();
873        let (ds, _) = make_datastore(&env);
874        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
875
876        let results = executor
877            .execute(vec![
878                HandlerIntent::Link {
879                    pack: "vim".into(),
880                    handler: "symlink".into(),
881                    source: env.dotfiles_root.join("vim/vimrc"),
882                    user_path: env.home.join(".vimrc"),
883                },
884                HandlerIntent::Link {
885                    pack: "vim".into(),
886                    handler: "symlink".into(),
887                    source: env.dotfiles_root.join("vim/gvimrc"),
888                    user_path: env.home.join(".gvimrc"),
889                },
890            ])
891            .unwrap();
892
893        assert_eq!(results.len(), 2); // 1 op per link
894        assert!(results.iter().all(|r| r.success));
895
896        env.assert_double_link(
897            "vim",
898            "symlink",
899            "vimrc",
900            &env.dotfiles_root.join("vim/vimrc"),
901            &env.home.join(".vimrc"),
902        );
903        env.assert_double_link(
904            "vim",
905            "symlink",
906            "gvimrc",
907            &env.dotfiles_root.join("vim/gvimrc"),
908            &env.home.join(".gvimrc"),
909        );
910    }
911
912    // ── Auto-chmod +x for path handler ─────────────────────────
913
914    #[test]
915    fn path_stage_adds_execute_permission() {
916        let env = TempEnvironment::builder()
917            .pack("tools")
918            .file("bin/mytool", "#!/bin/sh\necho hello")
919            .done()
920            .build();
921        let (ds, _) = make_datastore(&env);
922
923        // Verify the file starts without execute permission
924        let tool_path = env.dotfiles_root.join("tools/bin/mytool");
925        let meta_before = env.fs.stat(&tool_path).unwrap();
926        assert_eq!(
927            meta_before.mode & 0o111,
928            0,
929            "file should start non-executable"
930        );
931
932        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
933        let results = executor
934            .execute(vec![HandlerIntent::Stage {
935                pack: "tools".into(),
936                handler: "path".into(),
937                source: env.dotfiles_root.join("tools/bin"),
938            }])
939            .unwrap();
940
941        // Should have the stage result + chmod result
942        assert!(results.len() >= 2, "results: {results:?}");
943        let chmod_result = results.iter().find(|r| r.message.contains("chmod +x"));
944        assert!(
945            chmod_result.is_some(),
946            "should have a chmod +x result: {results:?}"
947        );
948        assert!(chmod_result.unwrap().success);
949
950        // Verify file is now executable
951        let meta_after = env.fs.stat(&tool_path).unwrap();
952        assert_ne!(
953            meta_after.mode & 0o111,
954            0,
955            "file should be executable after up"
956        );
957    }
958
959    #[test]
960    fn path_stage_skips_already_executable() {
961        let env = TempEnvironment::builder()
962            .pack("tools")
963            .file("bin/mytool", "#!/bin/sh\necho hello")
964            .done()
965            .build();
966        let (ds, _) = make_datastore(&env);
967
968        // Pre-set execute permission
969        let tool_path = env.dotfiles_root.join("tools/bin/mytool");
970        env.fs.set_permissions(&tool_path, 0o755).unwrap();
971
972        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
973        let results = executor
974            .execute(vec![HandlerIntent::Stage {
975                pack: "tools".into(),
976                handler: "path".into(),
977                source: env.dotfiles_root.join("tools/bin"),
978            }])
979            .unwrap();
980
981        // Should only have the stage result — no chmod needed
982        let chmod_results: Vec<_> = results
983            .iter()
984            .filter(|r| r.message.contains("chmod"))
985            .collect();
986        assert!(
987            chmod_results.is_empty(),
988            "already-executable file should not produce chmod result: {chmod_results:?}"
989        );
990    }
991
992    #[test]
993    fn path_stage_auto_chmod_disabled() {
994        let env = TempEnvironment::builder()
995            .pack("tools")
996            .file("bin/mytool", "#!/bin/sh\necho hello")
997            .done()
998            .build();
999        let (ds, _) = make_datastore(&env);
1000
1001        // auto_chmod_exec = false
1002        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, false);
1003        let results = executor
1004            .execute(vec![HandlerIntent::Stage {
1005                pack: "tools".into(),
1006                handler: "path".into(),
1007                source: env.dotfiles_root.join("tools/bin"),
1008            }])
1009            .unwrap();
1010
1011        // Should only have the stage result — no chmod attempted
1012        let chmod_results: Vec<_> = results
1013            .iter()
1014            .filter(|r| r.message.contains("chmod"))
1015            .collect();
1016        assert!(
1017            chmod_results.is_empty(),
1018            "auto_chmod_exec=false should skip chmod: {chmod_results:?}"
1019        );
1020
1021        // File should remain non-executable
1022        let tool_path = env.dotfiles_root.join("tools/bin/mytool");
1023        let meta = env.fs.stat(&tool_path).unwrap();
1024        assert_eq!(meta.mode & 0o111, 0, "file should remain non-executable");
1025    }
1026
1027    #[test]
1028    fn path_stage_skips_directories() {
1029        let env = TempEnvironment::builder()
1030            .pack("tools")
1031            .file("bin/subdir/nested", "#!/bin/sh")
1032            .done()
1033            .build();
1034        let (ds, _) = make_datastore(&env);
1035
1036        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
1037        let results = executor
1038            .execute(vec![HandlerIntent::Stage {
1039                pack: "tools".into(),
1040                handler: "path".into(),
1041                source: env.dotfiles_root.join("tools/bin"),
1042            }])
1043            .unwrap();
1044
1045        // The chmod should only apply to files, not the subdir directory
1046        let chmod_results: Vec<_> = results
1047            .iter()
1048            .filter(|r| r.message.contains("chmod"))
1049            .collect();
1050        // subdir is a directory, not a file — should not be chmod'd
1051        for r in &chmod_results {
1052            assert!(
1053                !r.message.contains("subdir"),
1054                "directories should not be chmod'd: {}",
1055                r.message
1056            );
1057        }
1058    }
1059
1060    #[test]
1061    fn shell_stage_does_not_auto_chmod() {
1062        let env = TempEnvironment::builder()
1063            .pack("vim")
1064            .file("aliases.sh", "alias vi=vim")
1065            .done()
1066            .build();
1067        let (ds, _) = make_datastore(&env);
1068
1069        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
1070        let results = executor
1071            .execute(vec![HandlerIntent::Stage {
1072                pack: "vim".into(),
1073                handler: "shell".into(),
1074                source: env.dotfiles_root.join("vim/aliases.sh"),
1075            }])
1076            .unwrap();
1077
1078        let chmod_results: Vec<_> = results
1079            .iter()
1080            .filter(|r| r.message.contains("chmod"))
1081            .collect();
1082        assert!(
1083            chmod_results.is_empty(),
1084            "shell handler should not auto-chmod: {chmod_results:?}"
1085        );
1086    }
1087
1088    #[test]
1089    fn dry_run_reports_non_executable_without_modifying() {
1090        let env = TempEnvironment::builder()
1091            .pack("tools")
1092            .file("bin/mytool", "#!/bin/sh\necho hello")
1093            .done()
1094            .build();
1095        let (ds, _) = make_datastore(&env);
1096
1097        let executor = Executor::new(&ds, env.fs.as_ref(), true, false, false, true);
1098        let results = executor
1099            .execute(vec![HandlerIntent::Stage {
1100                pack: "tools".into(),
1101                handler: "path".into(),
1102                source: env.dotfiles_root.join("tools/bin"),
1103            }])
1104            .unwrap();
1105
1106        // Should report what would be chmod'd
1107        let chmod_results: Vec<_> = results
1108            .iter()
1109            .filter(|r| r.message.contains("chmod"))
1110            .collect();
1111        assert!(
1112            !chmod_results.is_empty(),
1113            "dry-run should report non-executable files"
1114        );
1115        assert!(chmod_results[0].message.contains("[dry-run]"));
1116
1117        // File should NOT have been modified
1118        let tool_path = env.dotfiles_root.join("tools/bin/mytool");
1119        let meta = env.fs.stat(&tool_path).unwrap();
1120        assert_eq!(
1121            meta.mode & 0o111,
1122            0,
1123            "dry-run should not modify permissions"
1124        );
1125    }
1126
1127    #[test]
1128    fn path_stage_auto_chmod_multiple_files() {
1129        let env = TempEnvironment::builder()
1130            .pack("tools")
1131            .file("bin/tool-a", "#!/bin/sh\necho a")
1132            .file("bin/tool-b", "#!/bin/sh\necho b")
1133            .done()
1134            .build();
1135        let (ds, _) = make_datastore(&env);
1136
1137        let executor = Executor::new(&ds, env.fs.as_ref(), false, false, false, true);
1138        let results = executor
1139            .execute(vec![HandlerIntent::Stage {
1140                pack: "tools".into(),
1141                handler: "path".into(),
1142                source: env.dotfiles_root.join("tools/bin"),
1143            }])
1144            .unwrap();
1145
1146        let chmod_results: Vec<_> = results
1147            .iter()
1148            .filter(|r| r.message.contains("chmod +x"))
1149            .collect();
1150        assert_eq!(
1151            chmod_results.len(),
1152            2,
1153            "should chmod both files: {chmod_results:?}"
1154        );
1155
1156        // Both files should be executable
1157        for name in ["tool-a", "tool-b"] {
1158            let path = env.dotfiles_root.join(format!("tools/bin/{name}"));
1159            let meta = env.fs.stat(&path).unwrap();
1160            assert_ne!(meta.mode & 0o111, 0, "{name} should be executable");
1161        }
1162    }
1163}