1use 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
30pub 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn simulate(&self, intent: &HandlerIntent) -> Vec<OperationResult> {
426 match intent {
427 HandlerIntent::Link {
428 pack,
429 handler,
430 source,
431 user_path,
432 } => {
433 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 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 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 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 env.assert_no_handler_state("vim", "symlink");
706
707 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 env.assert_double_link("vim", "symlink", "vimrc", &source, &user_path);
747
748 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 assert!(!results[0].success);
793 assert!(results[1].success);
795
796 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 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 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 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 assert_eq!(results.len(), 3); for r in &results {
997 assert!(r.success);
998 assert!(r.message.contains("[dry-run]"), "msg: {}", r.message);
999 }
1000
1001 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); 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 #[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 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 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 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 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 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 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 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 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 let chmod_results: Vec<_> = results
1263 .iter()
1264 .filter(|r| r.message.contains("chmod"))
1265 .collect();
1266 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 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 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 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 #[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 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 env.assert_no_handler_state("warp", "symlink");
1456 env.assert_file_contents(&source, "keep me");
1457 }
1458
1459 #[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 #[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, 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 #[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, 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 #[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 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}