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