Skip to main content

krypt_core/
runner.rs

1//! Step runner — the engine behind `[[command]]` and `[[hook]]` execution.
2//!
3//! This module executes a [`Vec<Step>`] declaratively, with injected
4//! dependencies for process execution, desktop notification, and user
5//! prompting. Every downstream feature that needs to run user-defined steps
6//! (post-update hooks #43, `krypt menu` #25, `krypt <group> <name>` #25)
7//! delegates here.
8//!
9//! # Predicate evaluator (stub)
10//!
11//! The `eval_predicate` parameter is a stub for issue #24. All predicate
12//! strings currently evaluate to `true` (no-op). Issue #24 will implement
13//! the real grammar: `command_exists:foo`, `platform:linux`, `env:FOO=bar`,
14//! `!file_exists:/path`, and so on. Tests that need predicate gating supply
15//! their own closure.
16//!
17//! # on_fail semantics
18//!
19//! | value            | behaviour                                         |
20//! |-----------------|---------------------------------------------------|
21//! | `abort` (default) | bubble up `RunnerError::NonZeroExit`            |
22//! | `ignore`          | swallow, increment `steps_failed_ignored`        |
23//! | `notify`          | call Notifier with failure details, then abort   |
24//! | `prompt`          | ask Prompter; `true` → ignore, `false` → abort  |
25//!
26//! `ignore_failure = true` is a shortcut alias for `on_fail = "ignore"` and
27//! wins over a conflicting `on_fail = "abort"` if both are present (with a
28//! `tracing::warn!`).
29//!
30//! # Cross-platform note
31//!
32//! [`RealProcessExec`] wraps `std::process::Command` directly. No shell is
33//! injected — `run = ["echo", "hi"]` must reference a real binary in `PATH`.
34//! Shell builtins (e.g. `echo` on Windows outside Git Bash) are the caller's
35//! responsibility. Path-containing args are passed through unchanged.
36//!
37//! [`AutoNotifier`] auto-detects the best notification backend at
38//! construction. Pin [`crate::notify::NotifyBackend::Stderr`] in tests.
39
40#![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// ─── Errors ──────────────────────────────────────────────────────────────────
53
54/// Anything that can go wrong while running steps.
55#[derive(Debug, Error)]
56pub enum RunnerError {
57    /// A step has an invalid shape (e.g. `run` and `pipe` both set).
58    #[error("step {step_index}: invalid shape — {reason}")]
59    StepShape {
60        /// Zero-based index of the offending step.
61        step_index: usize,
62        /// Human-readable description of the problem.
63        reason: &'static str,
64    },
65
66    /// The underlying process could not be spawned.
67    #[error("step {step_index}: process I/O error — {source}")]
68    Process {
69        /// Zero-based index of the step.
70        step_index: usize,
71        /// The underlying I/O error.
72        source: io::Error,
73    },
74
75    /// The process exited with a non-zero status and `on_fail` was `abort`.
76    #[error("step {step_index}: exited with status {status} — {stderr}")]
77    NonZeroExit {
78        /// Zero-based index of the step.
79        step_index: usize,
80        /// The exit status code.
81        status: i32,
82        /// Captured stderr output.
83        stderr: String,
84    },
85
86    /// The notifier returned an error.
87    #[error("step {step_index}: notify error — {source}")]
88    Notify {
89        /// Zero-based index of the step.
90        step_index: usize,
91        /// The underlying I/O error.
92        source: io::Error,
93    },
94
95    /// The prompter returned an I/O error.
96    #[error("step {step_index}: prompt I/O error — {source}")]
97    PromptIo {
98        /// Zero-based index of the step.
99        step_index: usize,
100        /// The underlying I/O error.
101        source: io::Error,
102    },
103
104    /// Variable interpolation produced an error (reserved for future use).
105    #[error("step {step_index}: interpolation error — {reason}")]
106    Interpolation {
107        /// Zero-based index of the step.
108        step_index: usize,
109        /// Description of why interpolation failed.
110        reason: String,
111    },
112}
113
114// ─── Traits ──────────────────────────────────────────────────────────────────
115
116/// Outcome of a single process execution.
117pub struct ProcessResult {
118    /// Exit status code (0 = success).
119    pub status: i32,
120    /// Captured standard output.
121    pub stdout: String,
122    /// Captured standard error.
123    pub stderr: String,
124}
125
126/// Abstraction over process spawning, allowing test mocks.
127pub trait ProcessExec {
128    /// Execute `cmd` with `args`, optionally piping `stdin` to the process.
129    ///
130    /// Returns [`ProcessResult`] even on non-zero exit — the runner decides
131    /// whether a non-zero status is an error.
132    fn exec(
133        &self,
134        cmd: &str,
135        args: &[String],
136        stdin: Option<&str>,
137    ) -> Result<ProcessResult, io::Error>;
138}
139
140/// Abstraction over desktop notification, allowing test mocks.
141///
142/// The production implementation is [`AutoNotifier`], which shells out to
143/// `notify-send` (Linux), `osascript` / `terminal-notifier` (macOS), or
144/// PowerShell (Windows). See [`crate::notify`] for details.
145pub trait Notifier {
146    /// Send a desktop notification with the given title and body.
147    fn notify(&self, title: &str, body: &str) -> Result<(), io::Error>;
148}
149
150/// Abstraction over interactive prompting, allowing test mocks.
151pub trait Prompter {
152    /// Ask the user whether to continue after a step fails with
153    /// `on_fail = "prompt"`. Returns `true` to continue, `false` to abort.
154    fn ask_continue(&mut self, step_description: &str, error: &str) -> Result<bool, io::Error>;
155}
156
157// ─── Context ─────────────────────────────────────────────────────────────────
158
159/// Execution context threaded through all steps.
160///
161/// Captures accumulated during earlier steps are stored in [`captures`] and
162/// become available for `{name}` interpolation in later steps.
163pub struct Context {
164    /// Named captures accumulated from earlier `capture =` steps.
165    pub captures: BTreeMap<String, String>,
166    /// Positional arguments passed to the command, indexed as `{0}`..`{9}`.
167    pub args: Vec<String>,
168    /// Optional pipeline input available as `{stdin}`.
169    pub stdin: Option<String>,
170}
171
172// ─── Report ──────────────────────────────────────────────────────────────────
173
174/// Summary of a completed step sequence.
175#[derive(Debug, Default)]
176pub struct RunReport {
177    /// Number of steps that were executed (including ignored failures).
178    pub steps_run: usize,
179    /// Number of steps skipped because their `if` predicate returned false.
180    pub steps_skipped_by_predicate: usize,
181    /// Number of steps that failed but were ignored via `on_fail = "ignore"`
182    /// or `ignore_failure = true`.
183    pub steps_failed_ignored: usize,
184    /// All captures accumulated across the run.
185    pub final_captures: BTreeMap<String, String>,
186}
187
188// ─── Real implementations ────────────────────────────────────────────────────
189
190/// Production process executor using [`std::process::Command`].
191///
192/// No shell wrapping is applied. `run = ["echo", "hi"]` must reference a real
193/// binary in `PATH`. Shell builtins (e.g. `echo` on Windows outside Git Bash)
194/// are the caller's responsibility.
195pub 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
232/// Auto-detecting notifier.
233///
234/// Selects the best available backend via [`crate::notify::detect`] at
235/// construction time. For deterministic tests, prefer
236/// [`crate::notify::AutoNotifier::with_backend`] with
237/// [`crate::notify::NotifyBackend::Stderr`].
238pub use crate::notify::AutoNotifier;
239
240/// Interactive prompter backed by stdin readline.
241///
242/// `dialoguer::Confirm` integration is tracked alongside issue #24; for now
243/// we read a line and treat `y`/`Y` as "continue".
244pub 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
259// ─── Mock implementations (test helpers) ─────────────────────────────────────
260
261/// Mock process executor with scripted responses.
262///
263/// Responses are consumed in FIFO order. The recorded calls can be inspected
264/// via [`MockProcessExec::calls`] after the run.
265pub struct MockProcessExec {
266    /// Scripted responses, consumed in order. Panics if exhausted before the
267    /// test ends.
268    responses: RefCell<VecDeque<Result<ProcessResult, io::Error>>>,
269    /// Record of `(cmd, args, stdin)` tuples, in invocation order.
270    #[allow(clippy::type_complexity)]
271    pub calls: RefCell<Vec<(String, Vec<String>, Option<String>)>>,
272}
273
274impl MockProcessExec {
275    /// Create a new mock with the given scripted responses.
276    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    /// Return a snapshot of recorded calls, cloned out.
284    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/// Mock notifier that records calls.
307#[derive(Default)]
308pub struct MockNotifier {
309    /// Recorded `(title, body)` pairs, in invocation order.
310    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/// Mock prompter with scripted boolean responses.
323#[derive(Default)]
324pub struct MockPrompter {
325    /// Scripted responses, consumed in order.
326    pub responses: VecDeque<bool>,
327}
328
329impl MockPrompter {
330    /// Create a new mock from an iterator of boolean responses.
331    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
347// ─── Interpolation ───────────────────────────────────────────────────────────
348
349/// Interpolate `{name}`, `{0}`..`{9}`, `{stdin}`, and `{{`/`}}` escapes.
350///
351/// Unknown `{xyz}` placeholders are left as-is with a `tracing::warn!`. This
352/// is intentional: a step that references `{1}` but received no positional
353/// args should degrade gracefully rather than hard-error.
354pub 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                // Escaped `{{` → literal `{`
363                out.push('{');
364                i += 2;
365                continue;
366            }
367            // Find the closing `}`
368            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; // skip past `}`
371
372                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            // No closing brace found — emit literal `{`
389            out.push(chars[i]);
390            i += 1;
391        } else if chars[i] == '}' && i + 1 < chars.len() && chars[i + 1] == '}' {
392            // Escaped `}}` → literal `}`
393            out.push('}');
394            i += 2;
395        } else {
396            out.push(chars[i]);
397            i += 1;
398        }
399    }
400
401    out
402}
403
404// ─── Core engine ─────────────────────────────────────────────────────────────
405
406/// Execute a slice of steps with the given execution context and injected
407/// dependencies.
408///
409/// Returns a [`RunReport`] on success. If any step fails with `on_fail =
410/// "abort"` (the default), execution stops and the error is returned.
411pub 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        // ── Shape validation ────────────────────────────────────────────────
423        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        // ── Conflicting ignore_failure + on_fail warning ────────────────────
440        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        // ── Predicate gating ────────────────────────────────────────────────
449        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        // ── Dispatch by kind ────────────────────────────────────────────────
457        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            // Validate: notify steps must not have `capture`.
488            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
519/// Execute a [`KryptCommand`] as a step sequence.
520pub 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
536/// Execute a [`Hook`] as a single-step equivalent.
537///
538/// The hook's `run` field maps to a `run`-kind step with the hook's
539/// `ignore_failure` and `r#if` applied.
540pub 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// ─── Internals ───────────────────────────────────────────────────────────────
566
567/// Run a `run`- or `pipe`-kind step, handling capture and on_fail.
568#[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    // Capture stdout if requested.
609    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
617/// Apply `on_fail` semantics for a failed step.
618///
619/// Returns `Ok(())` if the failure is absorbed; returns `Err(err)` to abort.
620fn 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    // ignore_failure wins over on_fail if set.
629    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        // "abort" or any unknown value
662        _ => Err(err),
663    }
664}
665
666// ─── Tests ───────────────────────────────────────────────────────────────────
667
668#[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    // ── 1. Acceptance: 5-step fixture ────────────────────────────────────────
715
716    #[test]
717    fn acceptance_five_step_fixture() {
718        // Step 0: run echo "hello" → capture out
719        // Step 1: run echo "{out}-world" → capture out2
720        // Step 2: pipe wc -c with input={out2} → capture len
721        // Step 3: notify "title" "{len} bytes"
722        // Step 4: run printf {0} (positional)
723        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"),       // step 0
749            ok_result("hello-world\n"), // step 1
750            ok_result("12\n"),          // step 2: wc -c
751            ok_result("ok\n"),          // step 4: printf
752        ]);
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            &notifier,
767            &mut prompter,
768            &noop_predicate,
769        )
770        .unwrap();
771
772        assert_eq!(report.steps_run, 5); // 4 process steps + 1 notify step
773        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        // Verify process was called correctly
780        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        // step 2: pipe with interpolated input
785        assert_eq!(pcalls[2].0, "wc");
786        assert_eq!(pcalls[2].2.as_deref(), Some("hello-world"));
787        // step 4: positional arg
788        assert_eq!(pcalls[3].1, &["argzero".to_owned()]);
789        drop(pcalls);
790        // step 3: notify
791        let ncalls = notifier.calls.borrow();
792        assert_eq!(ncalls[0].0, "title");
793        assert_eq!(ncalls[0].1, "12 bytes");
794    }
795
796    // ── 2. Variable interpolation ─────────────────────────────────────────────
797
798    #[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        // Unknown {xyz} → left as {xyz}, no panic.
839        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    // ── 3. Mutual exclusion ───────────────────────────────────────────────────
853
854    #[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            &notifier,
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            &notifier,
890            &mut prompter,
891            &noop_predicate,
892        )
893        .unwrap_err();
894
895        assert!(matches!(err, RunnerError::StepShape { step_index: 0, .. }));
896    }
897
898    // ── 4. Predicate gating ───────────────────────────────────────────────────
899
900    #[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            &notifier,
917            &mut prompter,
918            &|_pred, _ctx| false, // always false
919        )
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    // ── 5. on_fail = "abort" (default) ───────────────────────────────────────
929
930    #[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            &notifier,
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        // Second step was never executed.
959        assert_eq!(process.calls.borrow().len(), 1);
960    }
961
962    // ── 6. on_fail = "ignore" ────────────────────────────────────────────────
963
964    #[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            &notifier,
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    // ── 7. ignore_failure = true ──────────────────────────────────────────────
993
994    #[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            &notifier,
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    // ── 8. on_fail = "notify" ────────────────────────────────────────────────
1023
1024    #[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            &notifier,
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    // ── 9. on_fail = "prompt" ────────────────────────────────────────────────
1052
1053    #[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]); // user says "continue"
1066
1067        let report = execute_steps(
1068            &steps,
1069            empty_ctx(),
1070            &process,
1071            &notifier,
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]); // user says "abort"
1091
1092        let err = execute_steps(
1093            &[step],
1094            empty_ctx(),
1095            &process,
1096            &notifier,
1097            &mut prompter,
1098            &noop_predicate,
1099        )
1100        .unwrap_err();
1101
1102        assert!(matches!(err, RunnerError::NonZeroExit { .. }));
1103    }
1104
1105    // ── 10. notify step ───────────────────────────────────────────────────────
1106
1107    #[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            &notifier,
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    // ── 11. pipe step with captured input ─────────────────────────────────────
1139
1140    #[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            &notifier,
1164            &mut prompter,
1165            &noop_predicate,
1166        )
1167        .unwrap();
1168
1169        // The stdin passed to wc should be the captured value.
1170        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    // ── 12. execute_hook ──────────────────────────────────────────────────────
1178
1179    #[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, &notifier, &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, &notifier, &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    // ── 15. default_predicate_evaluator integration ───────────────────────────
1222
1223    #[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        // Build a mock env: linux, has "sh", does NOT have "rofi"
1229        let mut mock = MockPredicateEnv::new(Platform::Linux);
1230        mock.commands.insert("sh".to_owned());
1231
1232        let evaluator = default_predicate_evaluator(mock);
1233
1234        // Step 0: if = "platform:linux,command_exists:sh" → should run
1235        // Step 1: if = "command_exists:rofi" → should skip
1236        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            &notifier,
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, &notifier, &mut prompter, &noop_predicate).unwrap();
1290
1291        assert_eq!(report.steps_failed_ignored, 1);
1292    }
1293}