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 std::path::{Path, PathBuf};
20
21use tracing::{debug, info};
22
23use crate::datastore::DataStore;
24use crate::fs::Fs;
25use crate::handlers::HANDLER_PATH;
26use crate::operations::{HandlerIntent, Operation, OperationResult};
27use crate::paths::Pather;
28use crate::Result;
29
30/// Executes handler intents by dispatching to the DataStore.
31pub struct Executor<'a> {
32    datastore: &'a dyn DataStore,
33    fs: &'a dyn Fs,
34    paths: &'a dyn Pather,
35    dry_run: bool,
36    force: bool,
37    provision_rerun: bool,
38    auto_chmod_exec: bool,
39}
40
41impl<'a> Executor<'a> {
42    pub fn new(
43        datastore: &'a dyn DataStore,
44        fs: &'a dyn Fs,
45        paths: &'a dyn Pather,
46        dry_run: bool,
47        force: bool,
48        provision_rerun: bool,
49        auto_chmod_exec: bool,
50    ) -> Self {
51        Self {
52            datastore,
53            fs,
54            paths,
55            dry_run,
56            force,
57            provision_rerun,
58            auto_chmod_exec,
59        }
60    }
61
62    /// Execute a list of handler intents, returning one result per
63    /// atomic operation performed.
64    ///
65    /// Conflicts (pre-existing files at target paths) are returned as
66    /// failed `OperationResult`s — non-fatal, so other intents still
67    /// execute. Hard errors (I/O failures, command failures) stop
68    /// execution immediately via `?`.
69    /// In dry-run mode, all intents are simulated regardless of errors.
70    pub fn execute(&self, intents: Vec<HandlerIntent>) -> Result<Vec<OperationResult>> {
71        debug!(
72            count = intents.len(),
73            dry_run = self.dry_run,
74            force = self.force,
75            "executor starting"
76        );
77        let mut results = Vec::new();
78
79        for intent in intents {
80            let intent_results = if self.dry_run {
81                self.simulate(&intent)
82            } else {
83                self.execute_one(&intent)?
84            };
85            results.extend(intent_results);
86        }
87
88        let succeeded = results.iter().filter(|r| r.success).count();
89        let failed = results.iter().filter(|r| !r.success).count();
90        debug!(succeeded, failed, "executor finished");
91
92        Ok(results)
93    }
94
95    /// Execute a single intent, which may produce multiple operations.
96    fn execute_one(&self, intent: &HandlerIntent) -> Result<Vec<OperationResult>> {
97        match intent {
98            HandlerIntent::Link {
99                pack,
100                handler,
101                source,
102                user_path,
103            } => {
104                debug!(
105                    pack,
106                    handler,
107                    source = %source.display(),
108                    user_path = %user_path.display(),
109                    "executing link intent"
110                );
111
112                // Refuse to deploy when an ancestor of user_path is a symlink
113                // that resolves back into the pack store or dodot data dir.
114                // Writing through such an ancestor lands back inside the pack
115                // (clobbering source files) or creates a pack↔data-dir cycle.
116                if let Some((ancestor, target)) = self.ancestor_cycles_into_store(user_path) {
117                    let op = Operation::CreateUserLink {
118                        pack: pack.clone(),
119                        handler: handler.clone(),
120                        datastore_path: Default::default(),
121                        user_path: user_path.clone(),
122                    };
123                    return Ok(vec![OperationResult::fail(
124                        op,
125                        cycle_message(user_path, &ancestor, &target),
126                    )]);
127                }
128
129                // Pre-check: does a non-symlink file exist at user_path?
130                // We check BEFORE creating the data link to avoid leaving
131                // dangling state when the user link would fail.
132                //
133                // #44: if the existing file's content is byte-identical to
134                // the source we'd deploy, treat it as safe to replace —
135                // the content reaching `user_path` doesn't change, only
136                // the storage representation does. No `--force` required.
137                if !self.fs.is_symlink(user_path) && self.fs.exists(user_path) {
138                    let content_equivalent =
139                        crate::equivalence::is_equivalent(user_path, source, self.fs);
140                    if self.force || content_equivalent {
141                        if content_equivalent {
142                            info!(
143                                pack,
144                                path = %user_path.display(),
145                                "auto-replacing content-equivalent file with dodot symlink"
146                            );
147                        } else {
148                            info!(
149                                pack,
150                                path = %user_path.display(),
151                                "force-removing existing file"
152                            );
153                        }
154                        // Remove the existing path before creating the symlink
155                        if self.fs.is_dir(user_path) {
156                            self.fs.remove_dir_all(user_path)?;
157                        } else {
158                            self.fs.remove_file(user_path)?;
159                        }
160                    } else {
161                        info!(
162                            pack,
163                            path = %user_path.display(),
164                            "conflict: file already exists"
165                        );
166                        // Return a failed result — non-fatal so other files
167                        // in the pack can still be processed.
168                        let op = Operation::CreateUserLink {
169                            pack: pack.clone(),
170                            handler: handler.clone(),
171                            datastore_path: Default::default(),
172                            user_path: user_path.clone(),
173                        };
174                        return Ok(vec![OperationResult::fail(
175                            op,
176                            format!(
177                                "conflict: {} already exists (use --force to overwrite)",
178                                user_path.display()
179                            ),
180                        )]);
181                    }
182                }
183
184                // Step 1: Create data link (source → datastore)
185                let datastore_path = self.datastore.create_data_link(pack, handler, source)?;
186                debug!(
187                    pack,
188                    datastore_path = %datastore_path.display(),
189                    "created data link"
190                );
191
192                // Step 2: Create user link (datastore → user location)
193                self.datastore
194                    .create_user_link(&datastore_path, user_path)?;
195
196                let filename = source.file_name().unwrap_or_default().to_string_lossy();
197                info!(
198                    pack,
199                    file = %filename,
200                    target = %user_path.display(),
201                    "created symlink"
202                );
203
204                let op = Operation::CreateUserLink {
205                    pack: pack.clone(),
206                    handler: handler.clone(),
207                    datastore_path: datastore_path.clone(),
208                    user_path: user_path.clone(),
209                };
210
211                Ok(vec![OperationResult::ok(
212                    op,
213                    format!("{} → {}", filename, user_path.display()),
214                )])
215            }
216
217            HandlerIntent::Stage {
218                pack,
219                handler,
220                source,
221            } => {
222                let filename = source.file_name().unwrap_or_default().to_string_lossy();
223                info!(pack, handler = handler.as_str(), file = %filename, "staging file");
224
225                self.datastore.create_data_link(pack, handler, source)?;
226
227                let op = Operation::CreateDataLink {
228                    pack: pack.clone(),
229                    handler: handler.clone(),
230                    source: source.clone(),
231                };
232
233                let mut results = vec![OperationResult::ok(op, format!("staged {}", filename))];
234
235                // Auto-chmod +x for path handler directories
236                if handler == HANDLER_PATH && self.auto_chmod_exec {
237                    debug!(pack, source = %source.display(), "checking executable permissions");
238                    results.extend(self.ensure_executable(pack, source));
239                }
240
241                Ok(results)
242            }
243
244            HandlerIntent::Run {
245                pack,
246                handler,
247                executable,
248                arguments,
249                sentinel,
250            } => {
251                // Check sentinel first — unless provision_rerun is set
252                if !self.provision_rerun {
253                    let already_done = self.datastore.has_sentinel(pack, handler, sentinel)?;
254
255                    if already_done {
256                        info!(
257                            pack,
258                            handler = handler.as_str(),
259                            sentinel,
260                            "sentinel found, skipping"
261                        );
262                        let op = Operation::CheckSentinel {
263                            pack: pack.clone(),
264                            handler: handler.clone(),
265                            sentinel: sentinel.clone(),
266                        };
267                        return Ok(vec![OperationResult::ok(op, "already completed")]);
268                    }
269                }
270
271                let cmd_str = format!("{} {}", executable, arguments.join(" "));
272                info!(pack, handler = handler.as_str(), command = %cmd_str.trim(), "running command");
273
274                // Run the command
275                self.datastore.run_and_record(
276                    pack,
277                    handler,
278                    executable,
279                    arguments,
280                    sentinel,
281                    self.provision_rerun,
282                )?;
283
284                info!(pack, sentinel, "command completed, sentinel recorded");
285
286                let op = Operation::RunCommand {
287                    pack: pack.clone(),
288                    handler: handler.clone(),
289                    executable: executable.clone(),
290                    arguments: arguments.clone(),
291                    sentinel: sentinel.clone(),
292                };
293
294                Ok(vec![OperationResult::ok(
295                    op,
296                    format!("executed: {}", cmd_str.trim()),
297                )])
298            }
299        }
300    }
301
302    /// Ensure all files in a path-handler directory are executable.
303    ///
304    /// Iterates files in `dir`, checks each for the execute bit, and
305    /// adds it if missing. Returns one `OperationResult` per file that
306    /// was made executable (or that failed). Files that are already
307    /// executable produce no output.
308    ///
309    /// Permission failures are non-fatal: they are reported as
310    /// *successful* operations with a warning message, so they don't
311    /// flip the pack to "failed" status. The file is still staged and
312    /// visible in `$PATH`, it just won't be runnable until the user
313    /// fixes permissions manually.
314    fn ensure_executable(&self, pack: &str, dir: &std::path::Path) -> Vec<OperationResult> {
315        let mut results = Vec::new();
316        let entries = match self.fs.read_dir(dir) {
317            Ok(e) => e,
318            Err(e) => {
319                let op = Operation::CreateDataLink {
320                    pack: pack.into(),
321                    handler: HANDLER_PATH.into(),
322                    source: dir.to_path_buf(),
323                };
324                results.push(OperationResult::ok(
325                    op,
326                    format!(
327                        "warning: could not list {} for auto-chmod: {}",
328                        dir.display(),
329                        e
330                    ),
331                ));
332                return results;
333            }
334        };
335
336        for entry in entries {
337            if !entry.is_file {
338                continue;
339            }
340            let meta = match self.fs.stat(&entry.path) {
341                Ok(m) => m,
342                Err(e) => {
343                    let op = Operation::CreateDataLink {
344                        pack: pack.into(),
345                        handler: HANDLER_PATH.into(),
346                        source: entry.path.clone(),
347                    };
348                    results.push(OperationResult::ok(
349                        op,
350                        format!("warning: could not stat {}: {}", entry.name, e),
351                    ));
352                    continue;
353                }
354            };
355
356            let is_exec = meta.mode & 0o111 != 0;
357            if is_exec {
358                continue;
359            }
360
361            // Add user/group/other execute bits, preserving existing permissions.
362            let new_mode = meta.mode | 0o111;
363            let op = Operation::CreateDataLink {
364                pack: pack.into(),
365                handler: HANDLER_PATH.into(),
366                source: entry.path.clone(),
367            };
368
369            match self.fs.set_permissions(&entry.path, new_mode) {
370                Ok(()) => {
371                    info!(pack, file = %entry.name, mode = format!("{:o}", new_mode), "chmod +x");
372                    results.push(OperationResult::ok(op, format!("chmod +x {}", entry.name)));
373                }
374                Err(e) => {
375                    info!(pack, file = %entry.name, error = %e, "chmod +x failed");
376                    // Warning, not failure — don't mark the pack as failed
377                    // just because chmod didn't work.
378                    results.push(OperationResult::ok(
379                        op,
380                        format!("warning: could not chmod +x {}: {}", entry.name, e),
381                    ));
382                }
383            }
384        }
385
386        results
387    }
388
389    /// Report files in a path-handler directory that lack execute
390    /// permissions (dry-run mode — no mutations).
391    fn report_non_executable(&self, pack: &str, dir: &std::path::Path) -> Vec<OperationResult> {
392        let mut results = Vec::new();
393        let entries = match self.fs.read_dir(dir) {
394            Ok(e) => e,
395            Err(_) => return results,
396        };
397
398        for entry in entries {
399            if !entry.is_file {
400                continue;
401            }
402            let meta = match self.fs.stat(&entry.path) {
403                Ok(m) => m,
404                Err(_) => continue,
405            };
406
407            let is_exec = meta.mode & 0o111 != 0;
408            if !is_exec {
409                let op = Operation::CreateDataLink {
410                    pack: pack.into(),
411                    handler: HANDLER_PATH.into(),
412                    source: entry.path.clone(),
413                };
414                results.push(OperationResult::ok(
415                    op,
416                    format!("[dry-run] would chmod +x {}", entry.name),
417                ));
418            }
419        }
420
421        results
422    }
423
424    /// Simulate an intent without touching the filesystem.
425    fn simulate(&self, intent: &HandlerIntent) -> Vec<OperationResult> {
426        match intent {
427            HandlerIntent::Link {
428                pack,
429                handler,
430                source,
431                user_path,
432            } => {
433                // Surface ancestor-into-pack-store cycles in dry-run too so
434                // the user sees the problem before committing.
435                if let Some((ancestor, target)) = self.ancestor_cycles_into_store(user_path) {
436                    return vec![OperationResult::fail(
437                        Operation::CreateUserLink {
438                            pack: pack.clone(),
439                            handler: handler.clone(),
440                            datastore_path: Default::default(),
441                            user_path: user_path.clone(),
442                        },
443                        cycle_message(user_path, &ancestor, &target),
444                    )];
445                }
446
447                // Check for conflicts even in dry-run
448                if !self.fs.is_symlink(user_path) && self.fs.exists(user_path) {
449                    if self.force {
450                        return vec![OperationResult::ok(
451                            Operation::CreateUserLink {
452                                pack: pack.clone(),
453                                handler: handler.clone(),
454                                datastore_path: Default::default(),
455                                user_path: user_path.clone(),
456                            },
457                            format!(
458                                "[dry-run] would overwrite {} → {}",
459                                source.file_name().unwrap_or_default().to_string_lossy(),
460                                user_path.display()
461                            ),
462                        )];
463                    } else {
464                        return vec![OperationResult::fail(
465                            Operation::CreateUserLink {
466                                pack: pack.clone(),
467                                handler: handler.clone(),
468                                datastore_path: Default::default(),
469                                user_path: user_path.clone(),
470                            },
471                            format!(
472                                "conflict: {} already exists (use --force to overwrite)",
473                                user_path.display()
474                            ),
475                        )];
476                    }
477                }
478
479                vec![OperationResult::ok(
480                    Operation::CreateUserLink {
481                        pack: pack.clone(),
482                        handler: handler.clone(),
483                        datastore_path: Default::default(),
484                        user_path: user_path.clone(),
485                    },
486                    format!(
487                        "[dry-run] would link {} → {}",
488                        source.file_name().unwrap_or_default().to_string_lossy(),
489                        user_path.display()
490                    ),
491                )]
492            }
493
494            HandlerIntent::Stage {
495                pack,
496                handler,
497                source,
498            } => {
499                let mut results = vec![OperationResult::ok(
500                    Operation::CreateDataLink {
501                        pack: pack.clone(),
502                        handler: handler.clone(),
503                        source: source.clone(),
504                    },
505                    format!(
506                        "[dry-run] would stage: {}",
507                        source.file_name().unwrap_or_default().to_string_lossy()
508                    ),
509                )];
510
511                if handler == HANDLER_PATH && self.auto_chmod_exec {
512                    results.extend(self.report_non_executable(pack, source));
513                }
514
515                results
516            }
517
518            HandlerIntent::Run {
519                pack,
520                handler,
521                executable,
522                arguments,
523                sentinel,
524            } => {
525                let cmd_str = format!("{} {}", executable, arguments.join(" "));
526                vec![OperationResult::ok(
527                    Operation::RunCommand {
528                        pack: pack.clone(),
529                        handler: handler.clone(),
530                        executable: executable.clone(),
531                        arguments: arguments.clone(),
532                        sentinel: sentinel.clone(),
533                    },
534                    format!("[dry-run] would execute: {}", cmd_str.trim()),
535                )]
536            }
537        }
538    }
539
540    /// Walk `user_path`'s ancestors. If any is a symlink whose single-hop
541    /// resolved target lives under `dotfiles_root` or `data_dir`, return
542    /// `(ancestor, resolved_target)`. Writing through such an ancestor
543    /// lands back inside the store and is always wrong — either it
544    /// clobbers a pack source or builds a pack↔data-dir cycle.
545    ///
546    /// The resolved target is lexically normalized before the prefix
547    /// comparison: relative symlinks like `~/.config/warp -> ../dotfiles/warp`
548    /// produce a joined path with `..` segments that would not naively
549    /// `starts_with(dotfiles_root)`.
550    fn ancestor_cycles_into_store(&self, user_path: &Path) -> Option<(PathBuf, PathBuf)> {
551        let dotfiles_root = self.paths.dotfiles_root();
552        let data_dir = self.paths.data_dir();
553        let mut current = user_path.parent()?;
554        loop {
555            if self.fs.is_symlink(current) {
556                if let Ok(raw_target) = self.fs.readlink(current) {
557                    let resolved = crate::equivalence::normalize_path(
558                        &crate::equivalence::resolve_symlink_target(current, &raw_target),
559                    );
560                    if resolved.starts_with(dotfiles_root) || resolved.starts_with(data_dir) {
561                        return Some((current.to_path_buf(), resolved));
562                    }
563                }
564            }
565            match current.parent() {
566                Some(p) if p != current => current = p,
567                _ => return None,
568            }
569        }
570    }
571}
572
573fn cycle_message(user_path: &Path, ancestor: &Path, target: &Path) -> String {
574    format!(
575        "cycle: {} is a symlink into the dodot store (-> {}); \
576         deploying {} through it would write back into the store. \
577         Remove or move {} and re-run.",
578        ancestor.display(),
579        target.display(),
580        user_path.display(),
581        ancestor.display(),
582    )
583}
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588    use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
589    use crate::paths::Pather;
590    use crate::testing::TempEnvironment;
591    use std::sync::{Arc, Mutex};
592
593    struct MockCommandRunner {
594        calls: Mutex<Vec<String>>,
595    }
596
597    impl MockCommandRunner {
598        fn new() -> Self {
599            Self {
600                calls: Mutex::new(Vec::new()),
601            }
602        }
603    }
604
605    impl CommandRunner for MockCommandRunner {
606        fn run(&self, executable: &str, arguments: &[String]) -> Result<CommandOutput> {
607            let cmd_str = format!("{} {}", executable, arguments.join(" "));
608            self.calls.lock().unwrap().push(cmd_str.trim().to_string());
609            Ok(CommandOutput {
610                exit_code: 0,
611                stdout: String::new(),
612                stderr: String::new(),
613            })
614        }
615    }
616
617    fn make_datastore(env: &TempEnvironment) -> (FilesystemDataStore, Arc<MockCommandRunner>) {
618        let runner = Arc::new(MockCommandRunner::new());
619        let ds = FilesystemDataStore::new(env.fs.clone(), env.paths.clone(), runner.clone());
620        (ds, runner)
621    }
622
623    #[test]
624    fn execute_link_creates_double_link() {
625        let env = TempEnvironment::builder()
626            .pack("vim")
627            .file("vimrc", "set nocompatible")
628            .done()
629            .build();
630        let (ds, _) = make_datastore(&env);
631        let executor = Executor::new(
632            &ds,
633            env.fs.as_ref(),
634            env.paths.as_ref(),
635            false,
636            false,
637            false,
638            true,
639        );
640
641        let source = env.dotfiles_root.join("vim/vimrc");
642        let user_path = env.home.join(".vimrc");
643
644        let results = executor
645            .execute(vec![HandlerIntent::Link {
646                pack: "vim".into(),
647                handler: "symlink".into(),
648                source: source.clone(),
649                user_path: user_path.clone(),
650            }])
651            .unwrap();
652
653        assert_eq!(results.len(), 1);
654        assert!(results[0].success);
655
656        // Verify the double-link chain
657        env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
658    }
659
660    #[test]
661    fn execute_link_conflict_returns_failed_result() {
662        let env = TempEnvironment::builder()
663            .pack("vim")
664            .file("vimrc", "set nocompatible")
665            .done()
666            .home_file(".vimrc", "existing content")
667            .build();
668        let (ds, _) = make_datastore(&env);
669        let executor = Executor::new(
670            &ds,
671            env.fs.as_ref(),
672            env.paths.as_ref(),
673            false,
674            false,
675            false,
676            true,
677        );
678
679        let source = env.dotfiles_root.join("vim/vimrc");
680        let user_path = env.home.join(".vimrc");
681
682        let results = executor
683            .execute(vec![HandlerIntent::Link {
684                pack: "vim".into(),
685                handler: "symlink".into(),
686                source: source.clone(),
687                user_path: user_path.clone(),
688            }])
689            .unwrap();
690
691        assert_eq!(results.len(), 1);
692        assert!(!results[0].success, "should report conflict");
693        assert!(
694            results[0].message.contains("conflict"),
695            "msg: {}",
696            results[0].message
697        );
698        assert!(
699            results[0].message.contains("--force"),
700            "msg: {}",
701            results[0].message
702        );
703
704        // Data link should NOT have been created (pre-check prevents it)
705        env.assert_no_handler_state("vim", "symlink");
706
707        // Original file should be untouched
708        env.assert_file_contents(&user_path, "existing content");
709    }
710
711    #[test]
712    fn execute_link_force_overwrites_existing_file() {
713        let env = TempEnvironment::builder()
714            .pack("vim")
715            .file("vimrc", "set nocompatible")
716            .done()
717            .home_file(".vimrc", "existing content")
718            .build();
719        let (ds, _) = make_datastore(&env);
720        let executor = Executor::new(
721            &ds,
722            env.fs.as_ref(),
723            env.paths.as_ref(),
724            false,
725            true,
726            false,
727            true,
728        );
729
730        let source = env.dotfiles_root.join("vim/vimrc");
731        let user_path = env.home.join(".vimrc");
732
733        let results = executor
734            .execute(vec![HandlerIntent::Link {
735                pack: "vim".into(),
736                handler: "symlink".into(),
737                source: source.clone(),
738                user_path: user_path.clone(),
739            }])
740            .unwrap();
741
742        assert_eq!(results.len(), 1);
743        assert!(results[0].success, "force should succeed");
744
745        // Verify the double-link chain was created
746        env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
747
748        // Content should now be from the pack
749        let content = env.fs.read_to_string(&user_path).unwrap();
750        assert_eq!(content, "set nocompatible");
751    }
752
753    #[test]
754    fn execute_link_conflict_does_not_block_other_intents() {
755        let env = TempEnvironment::builder()
756            .pack("vim")
757            .file("vimrc", "set nocompatible")
758            .file("gvimrc", "set guifont=Mono")
759            .done()
760            .home_file(".vimrc", "existing content")
761            .build();
762        let (ds, _) = make_datastore(&env);
763        let executor = Executor::new(
764            &ds,
765            env.fs.as_ref(),
766            env.paths.as_ref(),
767            false,
768            false,
769            false,
770            true,
771        );
772
773        let results = executor
774            .execute(vec![
775                HandlerIntent::Link {
776                    pack: "vim".into(),
777                    handler: "symlink".into(),
778                    source: env.dotfiles_root.join("vim/vimrc"),
779                    user_path: env.home.join(".vimrc"),
780                },
781                HandlerIntent::Link {
782                    pack: "vim".into(),
783                    handler: "symlink".into(),
784                    source: env.dotfiles_root.join("vim/gvimrc"),
785                    user_path: env.home.join(".gvimrc"),
786                },
787            ])
788            .unwrap();
789
790        assert_eq!(results.len(), 2);
791        // First should fail (conflict)
792        assert!(!results[0].success);
793        // Second should succeed (no conflict)
794        assert!(results[1].success);
795
796        // gvimrc should be deployed despite vimrc conflict
797        env.assert_double_link(
798            "vim",
799            "symlink",
800            "gvimrc",
801            &env.dotfiles_root.join("vim/gvimrc"),
802            &env.home.join(".gvimrc"),
803        );
804    }
805
806    #[test]
807    fn execute_stage_creates_data_link_only() {
808        let env = TempEnvironment::builder()
809            .pack("vim")
810            .file("aliases.sh", "alias vi=vim")
811            .done()
812            .build();
813        let (ds, _) = make_datastore(&env);
814        let executor = Executor::new(
815            &ds,
816            env.fs.as_ref(),
817            env.paths.as_ref(),
818            false,
819            false,
820            false,
821            true,
822        );
823
824        let source = env.dotfiles_root.join("vim/aliases.sh");
825
826        let results = executor
827            .execute(vec![HandlerIntent::Stage {
828                pack: "vim".into(),
829                handler: "shell".into(),
830                source: source.clone(),
831            }])
832            .unwrap();
833
834        assert_eq!(results.len(), 1);
835        assert!(results[0].success);
836
837        // Data link should exist
838        let datastore_link = env
839            .paths
840            .handler_data_dir("vim", "shell")
841            .join("aliases.sh");
842        env.assert_symlink(&datastore_link, &source);
843    }
844
845    #[test]
846    fn execute_run_creates_sentinel() {
847        let env = TempEnvironment::builder().build();
848        let (ds, runner) = make_datastore(&env);
849        let executor = Executor::new(
850            &ds,
851            env.fs.as_ref(),
852            env.paths.as_ref(),
853            false,
854            false,
855            false,
856            true,
857        );
858
859        let results = executor
860            .execute(vec![HandlerIntent::Run {
861                pack: "vim".into(),
862                handler: "install".into(),
863                executable: "echo".into(),
864                arguments: vec!["hello".into()],
865                sentinel: "install.sh-abc123".into(),
866            }])
867            .unwrap();
868
869        assert_eq!(results.len(), 1);
870        assert!(results[0].success);
871        assert_eq!(runner.calls.lock().unwrap().as_slice(), &["echo hello"]);
872        env.assert_sentinel("vim", "install", "install.sh-abc123");
873    }
874
875    #[test]
876    fn execute_run_skips_when_sentinel_exists() {
877        let env = TempEnvironment::builder().build();
878        let (ds, runner) = make_datastore(&env);
879
880        // Pre-create sentinel
881        let sentinel_dir = env.paths.handler_data_dir("vim", "install");
882        env.fs.mkdir_all(&sentinel_dir).unwrap();
883        env.fs
884            .write_file(&sentinel_dir.join("install.sh-abc123"), b"completed|12345")
885            .unwrap();
886
887        let executor = Executor::new(
888            &ds,
889            env.fs.as_ref(),
890            env.paths.as_ref(),
891            false,
892            false,
893            false,
894            true,
895        );
896        let results = executor
897            .execute(vec![HandlerIntent::Run {
898                pack: "vim".into(),
899                handler: "install".into(),
900                executable: "echo".into(),
901                arguments: vec!["should-not-run".into()],
902                sentinel: "install.sh-abc123".into(),
903            }])
904            .unwrap();
905
906        assert_eq!(results.len(), 1);
907        assert!(results[0].success);
908        assert!(results[0].message.contains("already completed"));
909        assert!(runner.calls.lock().unwrap().is_empty());
910    }
911
912    #[test]
913    fn provision_rerun_ignores_sentinel() {
914        let env = TempEnvironment::builder().build();
915        let (ds, runner) = make_datastore(&env);
916
917        // Pre-create sentinel
918        let sentinel_dir = env.paths.handler_data_dir("vim", "install");
919        env.fs.mkdir_all(&sentinel_dir).unwrap();
920        env.fs
921            .write_file(&sentinel_dir.join("install.sh-abc123"), b"completed|12345")
922            .unwrap();
923
924        let executor = Executor::new(
925            &ds,
926            env.fs.as_ref(),
927            env.paths.as_ref(),
928            false,
929            false,
930            true,
931            true,
932        );
933        let results = executor
934            .execute(vec![HandlerIntent::Run {
935                pack: "vim".into(),
936                handler: "install".into(),
937                executable: "echo".into(),
938                arguments: vec!["rerun".into()],
939                sentinel: "install.sh-abc123".into(),
940            }])
941            .unwrap();
942
943        assert_eq!(results.len(), 1);
944        assert!(results[0].success);
945        assert!(
946            results[0].message.contains("executed"),
947            "msg: {}",
948            results[0].message
949        );
950        assert_eq!(runner.calls.lock().unwrap().as_slice(), &["echo rerun"]);
951    }
952
953    #[test]
954    fn dry_run_does_not_modify_filesystem() {
955        let env = TempEnvironment::builder()
956            .pack("vim")
957            .file("vimrc", "x")
958            .done()
959            .build();
960        let (ds, _) = make_datastore(&env);
961        let executor = Executor::new(
962            &ds,
963            env.fs.as_ref(),
964            env.paths.as_ref(),
965            true,
966            false,
967            false,
968            true,
969        );
970
971        let results = executor
972            .execute(vec![
973                HandlerIntent::Link {
974                    pack: "vim".into(),
975                    handler: "symlink".into(),
976                    source: env.dotfiles_root.join("vim/vimrc"),
977                    user_path: env.home.join(".vimrc"),
978                },
979                HandlerIntent::Stage {
980                    pack: "vim".into(),
981                    handler: "shell".into(),
982                    source: env.dotfiles_root.join("vim/vimrc"),
983                },
984                HandlerIntent::Run {
985                    pack: "vim".into(),
986                    handler: "install".into(),
987                    executable: "echo".into(),
988                    arguments: vec!["hi".into()],
989                    sentinel: "s1".into(),
990                },
991            ])
992            .unwrap();
993
994        // All should succeed with dry-run messages
995        assert_eq!(results.len(), 3); // Link=1, Stage=1, Run=1
996        for r in &results {
997            assert!(r.success);
998            assert!(r.message.contains("[dry-run]"), "msg: {}", r.message);
999        }
1000
1001        // Nothing should have been created
1002        env.assert_not_exists(&env.home.join(".vimrc"));
1003        env.assert_no_handler_state("vim", "symlink");
1004        env.assert_no_handler_state("vim", "shell");
1005        env.assert_no_handler_state("vim", "install");
1006    }
1007
1008    #[test]
1009    fn dry_run_detects_conflict() {
1010        let env = TempEnvironment::builder()
1011            .pack("vim")
1012            .file("vimrc", "x")
1013            .done()
1014            .home_file(".vimrc", "existing")
1015            .build();
1016        let (ds, _) = make_datastore(&env);
1017        let executor = Executor::new(
1018            &ds,
1019            env.fs.as_ref(),
1020            env.paths.as_ref(),
1021            true,
1022            false,
1023            false,
1024            true,
1025        );
1026
1027        let results = executor
1028            .execute(vec![HandlerIntent::Link {
1029                pack: "vim".into(),
1030                handler: "symlink".into(),
1031                source: env.dotfiles_root.join("vim/vimrc"),
1032                user_path: env.home.join(".vimrc"),
1033            }])
1034            .unwrap();
1035
1036        assert_eq!(results.len(), 1);
1037        assert!(!results[0].success);
1038        assert!(results[0].message.contains("conflict"));
1039    }
1040
1041    #[test]
1042    fn execute_multiple_intents_sequentially() {
1043        let env = TempEnvironment::builder()
1044            .pack("vim")
1045            .file("vimrc", "set nocompatible")
1046            .file("gvimrc", "set guifont=Mono")
1047            .done()
1048            .build();
1049        let (ds, _) = make_datastore(&env);
1050        let executor = Executor::new(
1051            &ds,
1052            env.fs.as_ref(),
1053            env.paths.as_ref(),
1054            false,
1055            false,
1056            false,
1057            true,
1058        );
1059
1060        let results = executor
1061            .execute(vec![
1062                HandlerIntent::Link {
1063                    pack: "vim".into(),
1064                    handler: "symlink".into(),
1065                    source: env.dotfiles_root.join("vim/vimrc"),
1066                    user_path: env.home.join(".vimrc"),
1067                },
1068                HandlerIntent::Link {
1069                    pack: "vim".into(),
1070                    handler: "symlink".into(),
1071                    source: env.dotfiles_root.join("vim/gvimrc"),
1072                    user_path: env.home.join(".gvimrc"),
1073                },
1074            ])
1075            .unwrap();
1076
1077        assert_eq!(results.len(), 2); // 1 op per link
1078        assert!(results.iter().all(|r| r.success));
1079
1080        env.assert_double_link(
1081            "vim",
1082            "symlink",
1083            "vimrc",
1084            &env.dotfiles_root.join("vim/vimrc"),
1085            &env.home.join(".vimrc"),
1086        );
1087        env.assert_double_link(
1088            "vim",
1089            "symlink",
1090            "gvimrc",
1091            &env.dotfiles_root.join("vim/gvimrc"),
1092            &env.home.join(".gvimrc"),
1093        );
1094    }
1095
1096    // ── Auto-chmod +x for path handler ─────────────────────────
1097
1098    #[test]
1099    fn path_stage_adds_execute_permission() {
1100        let env = TempEnvironment::builder()
1101            .pack("tools")
1102            .file("bin/mytool", "#!/bin/sh\necho hello")
1103            .done()
1104            .build();
1105        let (ds, _) = make_datastore(&env);
1106
1107        // Verify the file starts without execute permission
1108        let tool_path = env.dotfiles_root.join("tools/bin/mytool");
1109        let meta_before = env.fs.stat(&tool_path).unwrap();
1110        assert_eq!(
1111            meta_before.mode & 0o111,
1112            0,
1113            "file should start non-executable"
1114        );
1115
1116        let executor = Executor::new(
1117            &ds,
1118            env.fs.as_ref(),
1119            env.paths.as_ref(),
1120            false,
1121            false,
1122            false,
1123            true,
1124        );
1125        let results = executor
1126            .execute(vec![HandlerIntent::Stage {
1127                pack: "tools".into(),
1128                handler: "path".into(),
1129                source: env.dotfiles_root.join("tools/bin"),
1130            }])
1131            .unwrap();
1132
1133        // Should have the stage result + chmod result
1134        assert!(results.len() >= 2, "results: {results:?}");
1135        let chmod_result = results.iter().find(|r| r.message.contains("chmod +x"));
1136        assert!(
1137            chmod_result.is_some(),
1138            "should have a chmod +x result: {results:?}"
1139        );
1140        assert!(chmod_result.unwrap().success);
1141
1142        // Verify file is now executable
1143        let meta_after = env.fs.stat(&tool_path).unwrap();
1144        assert_ne!(
1145            meta_after.mode & 0o111,
1146            0,
1147            "file should be executable after up"
1148        );
1149    }
1150
1151    #[test]
1152    fn path_stage_skips_already_executable() {
1153        let env = TempEnvironment::builder()
1154            .pack("tools")
1155            .file("bin/mytool", "#!/bin/sh\necho hello")
1156            .done()
1157            .build();
1158        let (ds, _) = make_datastore(&env);
1159
1160        // Pre-set execute permission
1161        let tool_path = env.dotfiles_root.join("tools/bin/mytool");
1162        env.fs.set_permissions(&tool_path, 0o755).unwrap();
1163
1164        let executor = Executor::new(
1165            &ds,
1166            env.fs.as_ref(),
1167            env.paths.as_ref(),
1168            false,
1169            false,
1170            false,
1171            true,
1172        );
1173        let results = executor
1174            .execute(vec![HandlerIntent::Stage {
1175                pack: "tools".into(),
1176                handler: "path".into(),
1177                source: env.dotfiles_root.join("tools/bin"),
1178            }])
1179            .unwrap();
1180
1181        // Should only have the stage result — no chmod needed
1182        let chmod_results: Vec<_> = results
1183            .iter()
1184            .filter(|r| r.message.contains("chmod"))
1185            .collect();
1186        assert!(
1187            chmod_results.is_empty(),
1188            "already-executable file should not produce chmod result: {chmod_results:?}"
1189        );
1190    }
1191
1192    #[test]
1193    fn path_stage_auto_chmod_disabled() {
1194        let env = TempEnvironment::builder()
1195            .pack("tools")
1196            .file("bin/mytool", "#!/bin/sh\necho hello")
1197            .done()
1198            .build();
1199        let (ds, _) = make_datastore(&env);
1200
1201        // auto_chmod_exec = false
1202        let executor = Executor::new(
1203            &ds,
1204            env.fs.as_ref(),
1205            env.paths.as_ref(),
1206            false,
1207            false,
1208            false,
1209            false,
1210        );
1211        let results = executor
1212            .execute(vec![HandlerIntent::Stage {
1213                pack: "tools".into(),
1214                handler: "path".into(),
1215                source: env.dotfiles_root.join("tools/bin"),
1216            }])
1217            .unwrap();
1218
1219        // Should only have the stage result — no chmod attempted
1220        let chmod_results: Vec<_> = results
1221            .iter()
1222            .filter(|r| r.message.contains("chmod"))
1223            .collect();
1224        assert!(
1225            chmod_results.is_empty(),
1226            "auto_chmod_exec=false should skip chmod: {chmod_results:?}"
1227        );
1228
1229        // File should remain non-executable
1230        let tool_path = env.dotfiles_root.join("tools/bin/mytool");
1231        let meta = env.fs.stat(&tool_path).unwrap();
1232        assert_eq!(meta.mode & 0o111, 0, "file should remain non-executable");
1233    }
1234
1235    #[test]
1236    fn path_stage_skips_directories() {
1237        let env = TempEnvironment::builder()
1238            .pack("tools")
1239            .file("bin/subdir/nested", "#!/bin/sh")
1240            .done()
1241            .build();
1242        let (ds, _) = make_datastore(&env);
1243
1244        let executor = Executor::new(
1245            &ds,
1246            env.fs.as_ref(),
1247            env.paths.as_ref(),
1248            false,
1249            false,
1250            false,
1251            true,
1252        );
1253        let results = executor
1254            .execute(vec![HandlerIntent::Stage {
1255                pack: "tools".into(),
1256                handler: "path".into(),
1257                source: env.dotfiles_root.join("tools/bin"),
1258            }])
1259            .unwrap();
1260
1261        // The chmod should only apply to files, not the subdir directory
1262        let chmod_results: Vec<_> = results
1263            .iter()
1264            .filter(|r| r.message.contains("chmod"))
1265            .collect();
1266        // subdir is a directory, not a file — should not be chmod'd
1267        for r in &chmod_results {
1268            assert!(
1269                !r.message.contains("subdir"),
1270                "directories should not be chmod'd: {}",
1271                r.message
1272            );
1273        }
1274    }
1275
1276    #[test]
1277    fn shell_stage_does_not_auto_chmod() {
1278        let env = TempEnvironment::builder()
1279            .pack("vim")
1280            .file("aliases.sh", "alias vi=vim")
1281            .done()
1282            .build();
1283        let (ds, _) = make_datastore(&env);
1284
1285        let executor = Executor::new(
1286            &ds,
1287            env.fs.as_ref(),
1288            env.paths.as_ref(),
1289            false,
1290            false,
1291            false,
1292            true,
1293        );
1294        let results = executor
1295            .execute(vec![HandlerIntent::Stage {
1296                pack: "vim".into(),
1297                handler: "shell".into(),
1298                source: env.dotfiles_root.join("vim/aliases.sh"),
1299            }])
1300            .unwrap();
1301
1302        let chmod_results: Vec<_> = results
1303            .iter()
1304            .filter(|r| r.message.contains("chmod"))
1305            .collect();
1306        assert!(
1307            chmod_results.is_empty(),
1308            "shell handler should not auto-chmod: {chmod_results:?}"
1309        );
1310    }
1311
1312    #[test]
1313    fn dry_run_reports_non_executable_without_modifying() {
1314        let env = TempEnvironment::builder()
1315            .pack("tools")
1316            .file("bin/mytool", "#!/bin/sh\necho hello")
1317            .done()
1318            .build();
1319        let (ds, _) = make_datastore(&env);
1320
1321        let executor = Executor::new(
1322            &ds,
1323            env.fs.as_ref(),
1324            env.paths.as_ref(),
1325            true,
1326            false,
1327            false,
1328            true,
1329        );
1330        let results = executor
1331            .execute(vec![HandlerIntent::Stage {
1332                pack: "tools".into(),
1333                handler: "path".into(),
1334                source: env.dotfiles_root.join("tools/bin"),
1335            }])
1336            .unwrap();
1337
1338        // Should report what would be chmod'd
1339        let chmod_results: Vec<_> = results
1340            .iter()
1341            .filter(|r| r.message.contains("chmod"))
1342            .collect();
1343        assert!(
1344            !chmod_results.is_empty(),
1345            "dry-run should report non-executable files"
1346        );
1347        assert!(chmod_results[0].message.contains("[dry-run]"));
1348
1349        // File should NOT have been modified
1350        let tool_path = env.dotfiles_root.join("tools/bin/mytool");
1351        let meta = env.fs.stat(&tool_path).unwrap();
1352        assert_eq!(
1353            meta.mode & 0o111,
1354            0,
1355            "dry-run should not modify permissions"
1356        );
1357    }
1358
1359    #[test]
1360    fn path_stage_auto_chmod_multiple_files() {
1361        let env = TempEnvironment::builder()
1362            .pack("tools")
1363            .file("bin/tool-a", "#!/bin/sh\necho a")
1364            .file("bin/tool-b", "#!/bin/sh\necho b")
1365            .done()
1366            .build();
1367        let (ds, _) = make_datastore(&env);
1368
1369        let executor = Executor::new(
1370            &ds,
1371            env.fs.as_ref(),
1372            env.paths.as_ref(),
1373            false,
1374            false,
1375            false,
1376            true,
1377        );
1378        let results = executor
1379            .execute(vec![HandlerIntent::Stage {
1380                pack: "tools".into(),
1381                handler: "path".into(),
1382                source: env.dotfiles_root.join("tools/bin"),
1383            }])
1384            .unwrap();
1385
1386        let chmod_results: Vec<_> = results
1387            .iter()
1388            .filter(|r| r.message.contains("chmod +x"))
1389            .collect();
1390        assert_eq!(
1391            chmod_results.len(),
1392            2,
1393            "should chmod both files: {chmod_results:?}"
1394        );
1395
1396        // Both files should be executable
1397        for name in ["tool-a", "tool-b"] {
1398            let path = env.dotfiles_root.join(format!("tools/bin/{name}"));
1399            let meta = env.fs.stat(&path).unwrap();
1400            assert_ne!(meta.mode & 0o111, 0, "{name} should be executable");
1401        }
1402    }
1403
1404    // ── Ancestor-cycle detection ────────────────────────────────────
1405
1406    /// If an ancestor of `user_path` is a symlink into the pack store,
1407    /// deploy must refuse before touching anything — otherwise the
1408    /// write goes through the ancestor and lands inside the pack,
1409    /// clobbering source files or building a pack↔data-dir cycle.
1410    #[test]
1411    fn link_refuses_when_user_path_parent_symlinks_into_pack() {
1412        let env = TempEnvironment::builder()
1413            .pack("warp")
1414            .file("keybindings.yaml", "keep me")
1415            .done()
1416            .build();
1417        // Legacy setup: ~/.config/warp is a symlink into the pack itself.
1418        let pack_dir = env.dotfiles_root.join("warp");
1419        let config_warp = env.config_home.join("warp");
1420        env.fs.mkdir_all(&env.config_home).unwrap();
1421        env.fs.symlink(&pack_dir, &config_warp).unwrap();
1422
1423        let (ds, _) = make_datastore(&env);
1424        let executor = Executor::new(
1425            &ds,
1426            env.fs.as_ref(),
1427            env.paths.as_ref(),
1428            false,
1429            false,
1430            false,
1431            true,
1432        );
1433
1434        let source = pack_dir.join("keybindings.yaml");
1435        let user_path = config_warp.join("keybindings.yaml");
1436
1437        let results = executor
1438            .execute(vec![HandlerIntent::Link {
1439                pack: "warp".into(),
1440                handler: "symlink".into(),
1441                source: source.clone(),
1442                user_path: user_path.clone(),
1443            }])
1444            .unwrap();
1445
1446        assert_eq!(results.len(), 1);
1447        assert!(!results[0].success, "expected failure, got: {:?}", results);
1448        assert!(
1449            results[0].message.contains("cycle"),
1450            "expected cycle message, got: {}",
1451            results[0].message
1452        );
1453
1454        // No data link created, source file untouched.
1455        env.assert_no_handler_state("warp", "symlink");
1456        env.assert_file_contents(&source, "keep me");
1457    }
1458
1459    /// Same check but the ancestor points into `data_dir`. Writing
1460    /// through it would land in the datastore and still wedge the
1461    /// system.
1462    #[test]
1463    fn link_refuses_when_user_path_parent_symlinks_into_data_dir() {
1464        let env = TempEnvironment::builder()
1465            .pack("warp")
1466            .file("keybindings.yaml", "keep me")
1467            .done()
1468            .build();
1469        let config_warp = env.config_home.join("warp");
1470        env.fs.mkdir_all(&env.config_home).unwrap();
1471        env.fs.mkdir_all(&env.data_dir).unwrap();
1472        env.fs.symlink(&env.data_dir, &config_warp).unwrap();
1473
1474        let (ds, _) = make_datastore(&env);
1475        let executor = Executor::new(
1476            &ds,
1477            env.fs.as_ref(),
1478            env.paths.as_ref(),
1479            false,
1480            false,
1481            false,
1482            true,
1483        );
1484
1485        let source = env.dotfiles_root.join("warp/keybindings.yaml");
1486        let user_path = config_warp.join("keybindings.yaml");
1487
1488        let results = executor
1489            .execute(vec![HandlerIntent::Link {
1490                pack: "warp".into(),
1491                handler: "symlink".into(),
1492                source: source.clone(),
1493                user_path: user_path.clone(),
1494            }])
1495            .unwrap();
1496
1497        assert_eq!(results.len(), 1);
1498        assert!(!results[0].success);
1499        assert!(results[0].message.contains("cycle"));
1500        env.assert_no_handler_state("warp", "symlink");
1501    }
1502
1503    /// Dry-run must surface the same error, not silently report
1504    /// "would link".
1505    #[test]
1506    fn simulate_link_reports_ancestor_cycle() {
1507        let env = TempEnvironment::builder()
1508            .pack("warp")
1509            .file("keybindings.yaml", "keep me")
1510            .done()
1511            .build();
1512        let pack_dir = env.dotfiles_root.join("warp");
1513        let config_warp = env.config_home.join("warp");
1514        env.fs.mkdir_all(&env.config_home).unwrap();
1515        env.fs.symlink(&pack_dir, &config_warp).unwrap();
1516
1517        let (ds, _) = make_datastore(&env);
1518        let executor = Executor::new(
1519            &ds,
1520            env.fs.as_ref(),
1521            env.paths.as_ref(),
1522            true, // dry_run
1523            false,
1524            false,
1525            true,
1526        );
1527
1528        let source = pack_dir.join("keybindings.yaml");
1529        let user_path = config_warp.join("keybindings.yaml");
1530
1531        let results = executor
1532            .execute(vec![HandlerIntent::Link {
1533                pack: "warp".into(),
1534                handler: "symlink".into(),
1535                source,
1536                user_path,
1537            }])
1538            .unwrap();
1539
1540        assert_eq!(results.len(), 1);
1541        assert!(!results[0].success);
1542        assert!(
1543            results[0].message.contains("cycle"),
1544            "msg: {}",
1545            results[0].message
1546        );
1547    }
1548
1549    /// --force must NOT bypass the ancestor-cycle check. A cycle can
1550    /// never be "forced through" — it would corrupt the pack.
1551    #[test]
1552    fn force_does_not_bypass_ancestor_cycle_check() {
1553        let env = TempEnvironment::builder()
1554            .pack("warp")
1555            .file("keybindings.yaml", "keep me")
1556            .done()
1557            .build();
1558        let pack_dir = env.dotfiles_root.join("warp");
1559        let config_warp = env.config_home.join("warp");
1560        env.fs.mkdir_all(&env.config_home).unwrap();
1561        env.fs.symlink(&pack_dir, &config_warp).unwrap();
1562
1563        let (ds, _) = make_datastore(&env);
1564        let executor = Executor::new(
1565            &ds,
1566            env.fs.as_ref(),
1567            env.paths.as_ref(),
1568            false,
1569            true, // force
1570            false,
1571            true,
1572        );
1573
1574        let source = pack_dir.join("keybindings.yaml");
1575        let user_path = config_warp.join("keybindings.yaml");
1576
1577        let results = executor
1578            .execute(vec![HandlerIntent::Link {
1579                pack: "warp".into(),
1580                handler: "symlink".into(),
1581                source: source.clone(),
1582                user_path,
1583            }])
1584            .unwrap();
1585
1586        assert!(!results[0].success, "force must not bypass cycle check");
1587        env.assert_file_contents(&source, "keep me");
1588    }
1589
1590    /// Relative-target ancestor symlinks must also be detected. A link
1591    /// like `~/.config/warp -> ../../h/dotfiles/warp` joins lexically to
1592    /// a path containing `..` segments that wouldn't naively pass
1593    /// `starts_with(dotfiles_root)` — we normalize first.
1594    #[test]
1595    fn link_refuses_relative_ancestor_symlink_into_pack() {
1596        let env = TempEnvironment::builder()
1597            .pack("warp")
1598            .file("keybindings.yaml", "keep me")
1599            .done()
1600            .build();
1601        let pack_dir = env.dotfiles_root.join("warp");
1602        let config_warp = env.config_home.join("warp");
1603        env.fs.mkdir_all(&env.config_home).unwrap();
1604
1605        // config_home is home/.config, dotfiles_root is home/dotfiles,
1606        // so the relative hop is `../dotfiles/warp` — exactly the shape
1607        // Copilot flagged: contains `..`, joins to a path that would
1608        // NOT naively `starts_with(dotfiles_root)` without normalization.
1609        let rel_target = Path::new("../dotfiles/warp");
1610        env.fs.symlink(rel_target, &config_warp).unwrap();
1611
1612        let (ds, _) = make_datastore(&env);
1613        let executor = Executor::new(
1614            &ds,
1615            env.fs.as_ref(),
1616            env.paths.as_ref(),
1617            false,
1618            false,
1619            false,
1620            true,
1621        );
1622
1623        let source = pack_dir.join("keybindings.yaml");
1624        let user_path = config_warp.join("keybindings.yaml");
1625
1626        let results = executor
1627            .execute(vec![HandlerIntent::Link {
1628                pack: "warp".into(),
1629                handler: "symlink".into(),
1630                source: source.clone(),
1631                user_path,
1632            }])
1633            .unwrap();
1634
1635        assert_eq!(results.len(), 1);
1636        assert!(
1637            !results[0].success,
1638            "relative ancestor symlink must still be caught: {:?}",
1639            results
1640        );
1641        assert!(results[0].message.contains("cycle"));
1642        env.assert_no_handler_state("warp", "symlink");
1643        env.assert_file_contents(&source, "keep me");
1644    }
1645}