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