1#![allow(clippy::result_large_err)]
41
42use std::cell::RefCell;
43use std::collections::{BTreeMap, VecDeque};
44use std::io;
45use std::process::Command as StdCommand;
46use std::process::Stdio;
47
48use thiserror::Error;
49
50use crate::config::{Command as KryptCommand, Hook, Step};
51
52#[derive(Debug, Error)]
56pub enum RunnerError {
57 #[error("step {step_index}: invalid shape — {reason}")]
59 StepShape {
60 step_index: usize,
62 reason: &'static str,
64 },
65
66 #[error("step {step_index}: process I/O error — {source}")]
68 Process {
69 step_index: usize,
71 source: io::Error,
73 },
74
75 #[error("step {step_index}: exited with status {status} — {stderr}")]
77 NonZeroExit {
78 step_index: usize,
80 status: i32,
82 stderr: String,
84 },
85
86 #[error("step {step_index}: notify error — {source}")]
88 Notify {
89 step_index: usize,
91 source: io::Error,
93 },
94
95 #[error("step {step_index}: prompt I/O error — {source}")]
97 PromptIo {
98 step_index: usize,
100 source: io::Error,
102 },
103
104 #[error("step {step_index}: interpolation error — {reason}")]
106 Interpolation {
107 step_index: usize,
109 reason: String,
111 },
112}
113
114pub struct ProcessResult {
118 pub status: i32,
120 pub stdout: String,
122 pub stderr: String,
124}
125
126pub trait ProcessExec {
128 fn exec(
133 &self,
134 cmd: &str,
135 args: &[String],
136 stdin: Option<&str>,
137 ) -> Result<ProcessResult, io::Error>;
138}
139
140pub trait Notifier {
146 fn notify(&self, title: &str, body: &str) -> Result<(), io::Error>;
148}
149
150pub trait Prompter {
152 fn ask_continue(&mut self, step_description: &str, error: &str) -> Result<bool, io::Error>;
155}
156
157pub struct Context {
164 pub captures: BTreeMap<String, String>,
166 pub args: Vec<String>,
168 pub stdin: Option<String>,
170}
171
172#[derive(Debug, Default)]
176pub struct RunReport {
177 pub steps_run: usize,
179 pub steps_skipped_by_predicate: usize,
181 pub steps_failed_ignored: usize,
184 pub final_captures: BTreeMap<String, String>,
186}
187
188pub struct RealProcessExec;
196
197impl ProcessExec for RealProcessExec {
198 fn exec(
199 &self,
200 cmd: &str,
201 args: &[String],
202 stdin: Option<&str>,
203 ) -> Result<ProcessResult, io::Error> {
204 let mut child = StdCommand::new(cmd);
205 child.args(args);
206 child.stdout(Stdio::piped());
207 child.stderr(Stdio::piped());
208 if stdin.is_some() {
209 child.stdin(Stdio::piped());
210 } else {
211 child.stdin(Stdio::null());
212 }
213
214 let mut handle = child.spawn()?;
215
216 if let Some(input) = stdin {
217 use io::Write as _;
218 let stdin_handle = handle.stdin.take().expect("stdin piped");
219 let mut writer = io::BufWriter::new(stdin_handle);
220 writer.write_all(input.as_bytes())?;
221 }
222
223 let output = handle.wait_with_output()?;
224 Ok(ProcessResult {
225 status: output.status.code().unwrap_or(-1),
226 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
227 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
228 })
229 }
230}
231
232pub use crate::notify::AutoNotifier;
239
240pub struct RealPrompter;
245
246impl Prompter for RealPrompter {
247 fn ask_continue(&mut self, step_description: &str, error: &str) -> Result<bool, io::Error> {
248 use io::BufRead as _;
249 eprintln!("Step failed: {step_description}");
250 eprintln!("Error: {error}");
251 eprint!("Continue? [y/N] ");
252 let stdin = io::stdin();
253 let mut line = String::new();
254 stdin.lock().read_line(&mut line)?;
255 Ok(matches!(line.trim(), "y" | "Y"))
256 }
257}
258
259pub struct MockProcessExec {
266 responses: RefCell<VecDeque<Result<ProcessResult, io::Error>>>,
269 #[allow(clippy::type_complexity)]
271 pub calls: RefCell<Vec<(String, Vec<String>, Option<String>)>>,
272}
273
274impl MockProcessExec {
275 pub fn new(responses: impl IntoIterator<Item = Result<ProcessResult, io::Error>>) -> Self {
277 Self {
278 responses: RefCell::new(responses.into_iter().collect()),
279 calls: RefCell::new(Vec::new()),
280 }
281 }
282
283 pub fn recorded_calls(&self) -> Vec<(String, Vec<String>, Option<String>)> {
285 self.calls.borrow().clone()
286 }
287}
288
289impl ProcessExec for MockProcessExec {
290 fn exec(
291 &self,
292 cmd: &str,
293 args: &[String],
294 stdin: Option<&str>,
295 ) -> Result<ProcessResult, io::Error> {
296 self.calls
297 .borrow_mut()
298 .push((cmd.to_owned(), args.to_vec(), stdin.map(ToOwned::to_owned)));
299 self.responses
300 .borrow_mut()
301 .pop_front()
302 .expect("MockProcessExec: no more scripted responses")
303 }
304}
305
306#[derive(Default)]
308pub struct MockNotifier {
309 pub calls: RefCell<Vec<(String, String)>>,
311}
312
313impl Notifier for MockNotifier {
314 fn notify(&self, title: &str, body: &str) -> Result<(), io::Error> {
315 self.calls
316 .borrow_mut()
317 .push((title.to_owned(), body.to_owned()));
318 Ok(())
319 }
320}
321
322#[derive(Default)]
324pub struct MockPrompter {
325 pub responses: VecDeque<bool>,
327}
328
329impl MockPrompter {
330 pub fn new(responses: impl IntoIterator<Item = bool>) -> Self {
332 Self {
333 responses: responses.into_iter().collect(),
334 }
335 }
336}
337
338impl Prompter for MockPrompter {
339 fn ask_continue(&mut self, _step_description: &str, _error: &str) -> Result<bool, io::Error> {
340 Ok(self
341 .responses
342 .pop_front()
343 .expect("MockPrompter: no more scripted responses"))
344 }
345}
346
347pub fn interpolate(template: &str, ctx: &Context) -> String {
355 let mut out = String::with_capacity(template.len());
356 let chars: Vec<char> = template.chars().collect();
357 let mut i = 0;
358
359 while i < chars.len() {
360 if chars[i] == '{' {
361 if i + 1 < chars.len() && chars[i + 1] == '{' {
362 out.push('{');
364 i += 2;
365 continue;
366 }
367 if let Some(close) = chars[i + 1..].iter().position(|&c| c == '}') {
369 let key: String = chars[i + 1..i + 1 + close].iter().collect();
370 i += 2 + close; if key.is_empty() {
373 out.push_str("{}");
374 } else if key == "stdin" {
375 out.push_str(ctx.stdin.as_deref().unwrap_or(""));
376 } else if let Ok(idx) = key.parse::<usize>() {
377 out.push_str(ctx.args.get(idx).map(String::as_str).unwrap_or(""));
378 } else if let Some(val) = ctx.captures.get(&key) {
379 out.push_str(val);
380 } else {
381 tracing::warn!(key, "unknown interpolation variable — leaving literal");
382 out.push('{');
383 out.push_str(&key);
384 out.push('}');
385 }
386 continue;
387 }
388 out.push(chars[i]);
390 i += 1;
391 } else if chars[i] == '}' && i + 1 < chars.len() && chars[i + 1] == '}' {
392 out.push('}');
394 i += 2;
395 } else {
396 out.push(chars[i]);
397 i += 1;
398 }
399 }
400
401 out
402}
403
404pub fn execute_steps(
412 steps: &[Step],
413 mut ctx: Context,
414 process: &dyn ProcessExec,
415 notifier: &dyn Notifier,
416 prompter: &mut dyn Prompter,
417 eval_predicate: &dyn Fn(&str, &Context) -> bool,
418) -> Result<RunReport, RunnerError> {
419 let mut report = RunReport::default();
420
421 for (idx, step) in steps.iter().enumerate() {
422 let kind_count =
424 step.run.is_some() as u8 + step.pipe.is_some() as u8 + step.notify.is_some() as u8;
425
426 if kind_count == 0 {
427 return Err(RunnerError::StepShape {
428 step_index: idx,
429 reason: "exactly one of run / pipe / notify must be set; none are",
430 });
431 }
432 if kind_count > 1 {
433 return Err(RunnerError::StepShape {
434 step_index: idx,
435 reason: "exactly one of run / pipe / notify must be set; multiple are",
436 });
437 }
438
439 if step.ignore_failure && step.on_fail.as_deref().is_some_and(|of| of != "ignore") {
441 tracing::warn!(
442 step_index = idx,
443 on_fail = %step.on_fail.as_deref().unwrap_or(""),
444 "ignore_failure = true conflicts with on_fail; ignore_failure wins",
445 );
446 }
447
448 if let Some(ref predicate) = step.r#if
450 && !eval_predicate(predicate, &ctx)
451 {
452 report.steps_skipped_by_predicate += 1;
453 continue;
454 }
455
456 if let Some(ref args_raw) = step.run.clone() {
458 run_process_step(
459 idx,
460 args_raw,
461 None,
462 step,
463 &mut ctx,
464 process,
465 notifier,
466 prompter,
467 &mut report,
468 )?;
469 } else if let Some(ref args_raw) = step.pipe.clone() {
470 let stdin_val = if let Some(ref input_tmpl) = step.input {
471 interpolate(input_tmpl, &ctx)
472 } else {
473 ctx.stdin.clone().unwrap_or_default()
474 };
475 run_process_step(
476 idx,
477 args_raw,
478 Some(&stdin_val),
479 step,
480 &mut ctx,
481 process,
482 notifier,
483 prompter,
484 &mut report,
485 )?;
486 } else if let Some(ref parts_raw) = step.notify.clone() {
487 if step.capture.is_some() {
489 return Err(RunnerError::StepShape {
490 step_index: idx,
491 reason: "notify steps cannot use capture",
492 });
493 }
494
495 let title = interpolate(parts_raw.first().map(String::as_str).unwrap_or(""), &ctx);
496 let body = interpolate(parts_raw.get(1).map(String::as_str).unwrap_or(""), &ctx);
497
498 report.steps_run += 1;
499 if let Err(e) = notifier.notify(&title, &body) {
500 handle_failure(
501 idx,
502 step,
503 RunnerError::Notify {
504 step_index: idx,
505 source: e,
506 },
507 notifier,
508 prompter,
509 &mut report,
510 )?;
511 }
512 }
513 }
514
515 report.final_captures = ctx.captures;
516 Ok(report)
517}
518
519pub fn execute_command(
521 cmd: &KryptCommand,
522 args: Vec<String>,
523 process: &dyn ProcessExec,
524 notifier: &dyn Notifier,
525 prompter: &mut dyn Prompter,
526 eval_predicate: &dyn Fn(&str, &Context) -> bool,
527) -> Result<RunReport, RunnerError> {
528 let ctx = Context {
529 captures: BTreeMap::new(),
530 args,
531 stdin: None,
532 };
533 execute_steps(&cmd.steps, ctx, process, notifier, prompter, eval_predicate)
534}
535
536pub fn execute_hook(
541 hook: &Hook,
542 process: &dyn ProcessExec,
543 notifier: &dyn Notifier,
544 prompter: &mut dyn Prompter,
545 eval_predicate: &dyn Fn(&str, &Context) -> bool,
546) -> Result<RunReport, RunnerError> {
547 let step = Step {
548 run: Some(hook.run.clone()),
549 pipe: None,
550 notify: None,
551 capture: None,
552 input: None,
553 r#if: hook.r#if.clone(),
554 on_fail: None,
555 ignore_failure: hook.ignore_failure,
556 };
557 let ctx = Context {
558 captures: BTreeMap::new(),
559 args: Vec::new(),
560 stdin: None,
561 };
562 execute_steps(&[step], ctx, process, notifier, prompter, eval_predicate)
563}
564
565#[allow(clippy::too_many_arguments)]
569fn run_process_step(
570 idx: usize,
571 args_raw: &[String],
572 stdin: Option<&str>,
573 step: &Step,
574 ctx: &mut Context,
575 process: &dyn ProcessExec,
576 notifier: &dyn Notifier,
577 prompter: &mut dyn Prompter,
578 report: &mut RunReport,
579) -> Result<(), RunnerError> {
580 if args_raw.is_empty() {
581 return Err(RunnerError::StepShape {
582 step_index: idx,
583 reason: "run/pipe args list is empty",
584 });
585 }
586
587 let interpolated: Vec<String> = args_raw.iter().map(|a| interpolate(a, ctx)).collect();
588 let (cmd, rest) = interpolated.split_first().expect("checked non-empty above");
589
590 report.steps_run += 1;
591
592 let result = process
593 .exec(cmd, rest, stdin)
594 .map_err(|e| RunnerError::Process {
595 step_index: idx,
596 source: e,
597 })?;
598
599 if result.status != 0 {
600 let err = RunnerError::NonZeroExit {
601 step_index: idx,
602 status: result.status,
603 stderr: result.stderr.clone(),
604 };
605 return handle_failure(idx, step, err, notifier, prompter, report);
606 }
607
608 if let Some(ref var) = step.capture {
610 let value = result.stdout.trim_end_matches('\n').to_owned();
611 ctx.captures.insert(var.clone(), value);
612 }
613
614 Ok(())
615}
616
617fn handle_failure(
621 idx: usize,
622 step: &Step,
623 err: RunnerError,
624 notifier: &dyn Notifier,
625 prompter: &mut dyn Prompter,
626 report: &mut RunReport,
627) -> Result<(), RunnerError> {
628 if step.ignore_failure {
630 report.steps_failed_ignored += 1;
631 return Ok(());
632 }
633
634 let mode = step.on_fail.as_deref().unwrap_or("abort");
635
636 match mode {
637 "ignore" => {
638 report.steps_failed_ignored += 1;
639 Ok(())
640 }
641 "notify" => {
642 let desc = err.to_string();
643 let _ = notifier.notify("krypt step failed", &desc);
644 Err(err)
645 }
646 "prompt" => {
647 let desc = format!("step {idx}");
648 let err_str = err.to_string();
649 match prompter.ask_continue(&desc, &err_str) {
650 Ok(true) => {
651 report.steps_failed_ignored += 1;
652 Ok(())
653 }
654 Ok(false) => Err(err),
655 Err(e) => Err(RunnerError::PromptIo {
656 step_index: idx,
657 source: e,
658 }),
659 }
660 }
661 _ => Err(err),
663 }
664}
665
666#[cfg(test)]
669mod tests {
670 use super::*;
671
672 fn ok_result(stdout: &str) -> Result<ProcessResult, io::Error> {
673 Ok(ProcessResult {
674 status: 0,
675 stdout: stdout.to_owned(),
676 stderr: String::new(),
677 })
678 }
679
680 fn fail_result(status: i32, stderr: &str) -> Result<ProcessResult, io::Error> {
681 Ok(ProcessResult {
682 status,
683 stdout: String::new(),
684 stderr: stderr.to_owned(),
685 })
686 }
687
688 fn noop_predicate(_: &str, _: &Context) -> bool {
689 true
690 }
691
692 fn step_run(args: &[&str]) -> Step {
693 Step {
694 run: Some(args.iter().map(|s| s.to_string()).collect()),
695 ..Default::default()
696 }
697 }
698
699 fn step_notify(title: &str, body: &str) -> Step {
700 Step {
701 notify: Some(vec![title.to_owned(), body.to_owned()]),
702 ..Default::default()
703 }
704 }
705
706 fn empty_ctx() -> Context {
707 Context {
708 captures: BTreeMap::new(),
709 args: Vec::new(),
710 stdin: None,
711 }
712 }
713
714 #[test]
717 fn acceptance_five_step_fixture() {
718 let steps = vec![
724 Step {
725 run: Some(vec!["echo".to_owned(), "hello".to_owned()]),
726 capture: Some("out".to_owned()),
727 ..Default::default()
728 },
729 Step {
730 run: Some(vec!["echo".to_owned(), "{out}-world".to_owned()]),
731 capture: Some("out2".to_owned()),
732 ..Default::default()
733 },
734 Step {
735 pipe: Some(vec!["wc".to_owned(), "-c".to_owned()]),
736 input: Some("{out2}".to_owned()),
737 capture: Some("len".to_owned()),
738 ..Default::default()
739 },
740 step_notify("title", "{len} bytes"),
741 Step {
742 run: Some(vec!["printf".to_owned(), "{0}".to_owned()]),
743 ..Default::default()
744 },
745 ];
746
747 let process = MockProcessExec::new([
748 ok_result("hello\n"), ok_result("hello-world\n"), ok_result("12\n"), ok_result("ok\n"), ]);
753 let notifier = MockNotifier::default();
754 let mut prompter = MockPrompter::default();
755
756 let ctx = Context {
757 captures: BTreeMap::new(),
758 args: vec!["argzero".to_owned()],
759 stdin: None,
760 };
761
762 let report = execute_steps(
763 &steps,
764 ctx,
765 &process,
766 ¬ifier,
767 &mut prompter,
768 &noop_predicate,
769 )
770 .unwrap();
771
772 assert_eq!(report.steps_run, 5); assert_eq!(report.steps_skipped_by_predicate, 0);
774 assert_eq!(report.steps_failed_ignored, 0);
775 assert_eq!(report.final_captures["out"], "hello");
776 assert_eq!(report.final_captures["out2"], "hello-world");
777 assert_eq!(report.final_captures["len"], "12");
778
779 let pcalls = process.calls.borrow();
781 assert_eq!(pcalls[0].0, "echo");
782 assert_eq!(pcalls[0].1, &["hello".to_owned()]);
783 assert_eq!(pcalls[1].1, &["hello-world".to_owned()]);
784 assert_eq!(pcalls[2].0, "wc");
786 assert_eq!(pcalls[2].2.as_deref(), Some("hello-world"));
787 assert_eq!(pcalls[3].1, &["argzero".to_owned()]);
789 drop(pcalls);
790 let ncalls = notifier.calls.borrow();
792 assert_eq!(ncalls[0].0, "title");
793 assert_eq!(ncalls[0].1, "12 bytes");
794 }
795
796 #[test]
799 fn interpolate_named_capture() {
800 let ctx = Context {
801 captures: [("foo".to_owned(), "bar".to_owned())].into(),
802 args: Vec::new(),
803 stdin: None,
804 };
805 assert_eq!(interpolate("{foo}", &ctx), "bar");
806 }
807
808 #[test]
809 fn interpolate_positional() {
810 let ctx = Context {
811 captures: BTreeMap::new(),
812 args: vec!["first".to_owned(), "second".to_owned()],
813 stdin: None,
814 };
815 assert_eq!(interpolate("{0} {1}", &ctx), "first second");
816 }
817
818 #[test]
819 fn interpolate_stdin() {
820 let ctx = Context {
821 captures: BTreeMap::new(),
822 args: Vec::new(),
823 stdin: Some("pipe-input".to_owned()),
824 };
825 assert_eq!(interpolate("{stdin}", &ctx), "pipe-input");
826 }
827
828 #[test]
829 fn interpolate_escaped_braces() {
830 let ctx = empty_ctx();
831 assert_eq!(interpolate("{{literal}}", &ctx), "{literal}");
832 assert_eq!(interpolate("{{}}", &ctx), "{}");
833 }
834
835 #[test]
836 fn interpolate_unknown_var_left_literal() {
837 let ctx = empty_ctx();
838 assert_eq!(interpolate("{xyz}", &ctx), "{xyz}");
840 }
841
842 #[test]
843 fn interpolate_out_of_range_positional_empty() {
844 let ctx = Context {
845 captures: BTreeMap::new(),
846 args: vec!["only-one".to_owned()],
847 stdin: None,
848 };
849 assert_eq!(interpolate("{5}", &ctx), "");
850 }
851
852 #[test]
855 fn mutual_exclusion_run_and_pipe_errors() {
856 let step = Step {
857 run: Some(vec!["echo".to_owned()]),
858 pipe: Some(vec!["cat".to_owned()]),
859 ..Default::default()
860 };
861 let process = MockProcessExec::new([]);
862 let notifier = MockNotifier::default();
863 let mut prompter = MockPrompter::default();
864
865 let err = execute_steps(
866 &[step],
867 empty_ctx(),
868 &process,
869 ¬ifier,
870 &mut prompter,
871 &noop_predicate,
872 )
873 .unwrap_err();
874
875 assert!(matches!(err, RunnerError::StepShape { step_index: 0, .. }));
876 }
877
878 #[test]
879 fn no_kind_set_errors() {
880 let step = Step::default();
881 let process = MockProcessExec::new([]);
882 let notifier = MockNotifier::default();
883 let mut prompter = MockPrompter::default();
884
885 let err = execute_steps(
886 &[step],
887 empty_ctx(),
888 &process,
889 ¬ifier,
890 &mut prompter,
891 &noop_predicate,
892 )
893 .unwrap_err();
894
895 assert!(matches!(err, RunnerError::StepShape { step_index: 0, .. }));
896 }
897
898 #[test]
901 fn predicate_false_skips_step() {
902 let step = Step {
903 run: Some(vec!["echo".to_owned(), "should-not-run".to_owned()]),
904 r#if: Some("platform:windows".to_owned()),
905 capture: Some("out".to_owned()),
906 ..Default::default()
907 };
908 let process = MockProcessExec::new([]);
909 let notifier = MockNotifier::default();
910 let mut prompter = MockPrompter::default();
911
912 let report = execute_steps(
913 &[step],
914 empty_ctx(),
915 &process,
916 ¬ifier,
917 &mut prompter,
918 &|_pred, _ctx| false, )
920 .unwrap();
921
922 assert_eq!(report.steps_run, 0);
923 assert_eq!(report.steps_skipped_by_predicate, 1);
924 assert!(!report.final_captures.contains_key("out"));
925 assert!(process.calls.borrow().is_empty());
926 }
927
928 #[test]
931 fn on_fail_abort_default_stops_execution() {
932 let steps = vec![
933 step_run(&["bad-cmd"]),
934 step_run(&["echo", "should-not-run"]),
935 ];
936 let process = MockProcessExec::new([fail_result(1, "bad exit")]);
937 let notifier = MockNotifier::default();
938 let mut prompter = MockPrompter::default();
939
940 let err = execute_steps(
941 &steps,
942 empty_ctx(),
943 &process,
944 ¬ifier,
945 &mut prompter,
946 &noop_predicate,
947 )
948 .unwrap_err();
949
950 assert!(matches!(
951 err,
952 RunnerError::NonZeroExit {
953 step_index: 0,
954 status: 1,
955 ..
956 }
957 ));
958 assert_eq!(process.calls.borrow().len(), 1);
960 }
961
962 #[test]
965 fn on_fail_ignore_continues_after_failure() {
966 let steps = vec![
967 Step {
968 run: Some(vec!["bad-cmd".to_owned()]),
969 on_fail: Some("ignore".to_owned()),
970 ..Default::default()
971 },
972 step_run(&["echo", "continued"]),
973 ];
974 let process = MockProcessExec::new([fail_result(1, "err"), ok_result("continued\n")]);
975 let notifier = MockNotifier::default();
976 let mut prompter = MockPrompter::default();
977
978 let report = execute_steps(
979 &steps,
980 empty_ctx(),
981 &process,
982 ¬ifier,
983 &mut prompter,
984 &noop_predicate,
985 )
986 .unwrap();
987
988 assert_eq!(report.steps_run, 2);
989 assert_eq!(report.steps_failed_ignored, 1);
990 }
991
992 #[test]
995 fn ignore_failure_true_same_as_on_fail_ignore() {
996 let steps = vec![
997 Step {
998 run: Some(vec!["bad-cmd".to_owned()]),
999 ignore_failure: true,
1000 ..Default::default()
1001 },
1002 step_run(&["echo", "next"]),
1003 ];
1004 let process = MockProcessExec::new([fail_result(2, "oops"), ok_result("next\n")]);
1005 let notifier = MockNotifier::default();
1006 let mut prompter = MockPrompter::default();
1007
1008 let report = execute_steps(
1009 &steps,
1010 empty_ctx(),
1011 &process,
1012 ¬ifier,
1013 &mut prompter,
1014 &noop_predicate,
1015 )
1016 .unwrap();
1017
1018 assert_eq!(report.steps_failed_ignored, 1);
1019 assert_eq!(report.steps_run, 2);
1020 }
1021
1022 #[test]
1025 fn on_fail_notify_calls_notifier_then_aborts() {
1026 let step = Step {
1027 run: Some(vec!["bad-cmd".to_owned()]),
1028 on_fail: Some("notify".to_owned()),
1029 ..Default::default()
1030 };
1031 let process = MockProcessExec::new([fail_result(1, "boom")]);
1032 let notifier = MockNotifier::default();
1033 let mut prompter = MockPrompter::default();
1034
1035 let err = execute_steps(
1036 &[step],
1037 empty_ctx(),
1038 &process,
1039 ¬ifier,
1040 &mut prompter,
1041 &noop_predicate,
1042 )
1043 .unwrap_err();
1044
1045 assert!(matches!(err, RunnerError::NonZeroExit { .. }));
1046 let ncalls = notifier.calls.borrow();
1047 assert_eq!(ncalls.len(), 1);
1048 assert_eq!(ncalls[0].0, "krypt step failed");
1049 }
1050
1051 #[test]
1054 fn on_fail_prompt_true_treats_as_ignore() {
1055 let steps = vec![
1056 Step {
1057 run: Some(vec!["bad-cmd".to_owned()]),
1058 on_fail: Some("prompt".to_owned()),
1059 ..Default::default()
1060 },
1061 step_run(&["echo", "after"]),
1062 ];
1063 let process = MockProcessExec::new([fail_result(1, "err"), ok_result("after\n")]);
1064 let notifier = MockNotifier::default();
1065 let mut prompter = MockPrompter::new([true]); let report = execute_steps(
1068 &steps,
1069 empty_ctx(),
1070 &process,
1071 ¬ifier,
1072 &mut prompter,
1073 &noop_predicate,
1074 )
1075 .unwrap();
1076
1077 assert_eq!(report.steps_failed_ignored, 1);
1078 assert_eq!(report.steps_run, 2);
1079 }
1080
1081 #[test]
1082 fn on_fail_prompt_false_aborts() {
1083 let step = Step {
1084 run: Some(vec!["bad-cmd".to_owned()]),
1085 on_fail: Some("prompt".to_owned()),
1086 ..Default::default()
1087 };
1088 let process = MockProcessExec::new([fail_result(1, "err")]);
1089 let notifier = MockNotifier::default();
1090 let mut prompter = MockPrompter::new([false]); let err = execute_steps(
1093 &[step],
1094 empty_ctx(),
1095 &process,
1096 ¬ifier,
1097 &mut prompter,
1098 &noop_predicate,
1099 )
1100 .unwrap_err();
1101
1102 assert!(matches!(err, RunnerError::NonZeroExit { .. }));
1103 }
1104
1105 #[test]
1108 fn notify_step_calls_notifier_with_interpolated_values() {
1109 let step = Step {
1110 notify: Some(vec!["My Title".to_owned(), "{msg} sent".to_owned()]),
1111 ..Default::default()
1112 };
1113 let ctx = Context {
1114 captures: [("msg".to_owned(), "hello".to_owned())].into(),
1115 args: Vec::new(),
1116 stdin: None,
1117 };
1118 let process = MockProcessExec::new([]);
1119 let notifier = MockNotifier::default();
1120 let mut prompter = MockPrompter::default();
1121
1122 execute_steps(
1123 &[step],
1124 ctx,
1125 &process,
1126 ¬ifier,
1127 &mut prompter,
1128 &noop_predicate,
1129 )
1130 .unwrap();
1131
1132 let ncalls = notifier.calls.borrow();
1133 assert_eq!(ncalls.len(), 1);
1134 assert_eq!(ncalls[0].0, "My Title");
1135 assert_eq!(ncalls[0].1, "hello sent");
1136 }
1137
1138 #[test]
1141 fn pipe_step_passes_captured_value_as_stdin() {
1142 let steps = vec![
1143 Step {
1144 run: Some(vec!["echo".to_owned(), "captured-data".to_owned()]),
1145 capture: Some("data".to_owned()),
1146 ..Default::default()
1147 },
1148 Step {
1149 pipe: Some(vec!["wc".to_owned(), "-c".to_owned()]),
1150 input: Some("{data}".to_owned()),
1151 capture: Some("count".to_owned()),
1152 ..Default::default()
1153 },
1154 ];
1155 let process = MockProcessExec::new([ok_result("captured-data\n"), ok_result("13\n")]);
1156 let notifier = MockNotifier::default();
1157 let mut prompter = MockPrompter::default();
1158
1159 let report = execute_steps(
1160 &steps,
1161 empty_ctx(),
1162 &process,
1163 ¬ifier,
1164 &mut prompter,
1165 &noop_predicate,
1166 )
1167 .unwrap();
1168
1169 assert_eq!(
1171 process.calls.borrow()[1].2.as_deref(),
1172 Some("captured-data")
1173 );
1174 assert_eq!(report.final_captures["count"], "13");
1175 }
1176
1177 #[test]
1180 fn execute_hook_runs_single_step() {
1181 let hook = Hook {
1182 name: "test-hook".to_owned(),
1183 when: "post-update".to_owned(),
1184 r#if: None,
1185 run: vec!["echo".to_owned(), "hooked".to_owned()],
1186 ignore_failure: false,
1187 };
1188 let process = MockProcessExec::new([ok_result("hooked\n")]);
1189 let notifier = MockNotifier::default();
1190 let mut prompter = MockPrompter::default();
1191
1192 let report =
1193 execute_hook(&hook, &process, ¬ifier, &mut prompter, &noop_predicate).unwrap();
1194
1195 assert_eq!(report.steps_run, 1);
1196 assert_eq!(process.calls.borrow()[0].0, "echo");
1197 }
1198
1199 #[test]
1200 fn execute_hook_respects_if_predicate() {
1201 let hook = Hook {
1202 name: "guarded".to_owned(),
1203 when: "post-update".to_owned(),
1204 r#if: Some("platform:linux".to_owned()),
1205 run: vec!["echo".to_owned()],
1206 ignore_failure: false,
1207 };
1208 let process = MockProcessExec::new([]);
1209 let notifier = MockNotifier::default();
1210 let mut prompter = MockPrompter::default();
1211
1212 let report = execute_hook(&hook, &process, ¬ifier, &mut prompter, &|_pred, _ctx| {
1213 false
1214 })
1215 .unwrap();
1216
1217 assert_eq!(report.steps_skipped_by_predicate, 1);
1218 assert_eq!(report.steps_run, 0);
1219 }
1220
1221 #[test]
1224 fn default_predicate_evaluator_gates_step_via_mock() {
1225 use crate::paths::Platform;
1226 use crate::predicate::{MockPredicateEnv, default_predicate_evaluator};
1227
1228 let mut mock = MockPredicateEnv::new(Platform::Linux);
1230 mock.commands.insert("sh".to_owned());
1231
1232 let evaluator = default_predicate_evaluator(mock);
1233
1234 let steps = vec![
1237 Step {
1238 run: Some(vec!["echo".to_owned(), "runs".to_owned()]),
1239 r#if: Some("platform:linux,command_exists:sh".to_owned()),
1240 capture: Some("ran".to_owned()),
1241 ..Default::default()
1242 },
1243 Step {
1244 run: Some(vec!["echo".to_owned(), "skipped".to_owned()]),
1245 r#if: Some("command_exists:rofi".to_owned()),
1246 ..Default::default()
1247 },
1248 ];
1249
1250 let process = MockProcessExec::new([ok_result("runs\n")]);
1251 let notifier = MockNotifier::default();
1252 let mut prompter = MockPrompter::default();
1253
1254 let report = execute_steps(
1255 &steps,
1256 empty_ctx(),
1257 &process,
1258 ¬ifier,
1259 &mut prompter,
1260 &evaluator,
1261 )
1262 .unwrap();
1263
1264 assert_eq!(report.steps_run, 1, "only the first step should run");
1265 assert_eq!(
1266 report.steps_skipped_by_predicate, 1,
1267 "second step should be skipped"
1268 );
1269 assert_eq!(
1270 report.final_captures.get("ran").map(String::as_str),
1271 Some("runs")
1272 );
1273 }
1274
1275 #[test]
1276 fn execute_hook_respects_ignore_failure() {
1277 let hook = Hook {
1278 name: "lenient".to_owned(),
1279 when: "post-update".to_owned(),
1280 r#if: None,
1281 run: vec!["bad-cmd".to_owned()],
1282 ignore_failure: true,
1283 };
1284 let process = MockProcessExec::new([fail_result(1, "fail")]);
1285 let notifier = MockNotifier::default();
1286 let mut prompter = MockPrompter::default();
1287
1288 let report =
1289 execute_hook(&hook, &process, ¬ifier, &mut prompter, &noop_predicate).unwrap();
1290
1291 assert_eq!(report.steps_failed_ignored, 1);
1292 }
1293}