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