Skip to main content

grex_core/execute/
step.rs

1//! Observable record of a single action run.
2//!
3//! A planner produces an [`ExecStep`] describing _what would happen_ with
4//! [`ExecResult::WouldPerformChange`] or [`ExecResult::AlreadySatisfied`].
5//! A future wet-run executor produces the same shape with
6//! [`ExecResult::PerformedChange`]. Downstream audit tooling (lockfile,
7//! `grex status`) consumes this shape uniformly.
8//!
9//! # Why `StepKind` mirrors [`crate::pack::Action`] instead of referencing it
10//!
11//! `pack::Action` carries **parse-time** strings: `"$HOME/.foo"`. A step's
12//! job is to record the _post-expansion_ outcome: `"/home/user/.foo"`.
13//! Re-using the parse struct would force consumers to expand again (or
14//! thread the `VarEnv` into the audit log) and would conflate "the user
15//! wrote X" with "we resolved X to Y". Keeping a separate enum is a clean
16//! decoupling that also leaves room for wet-run executors to attach
17//! side-effect metadata (e.g. `backup_path`) without polluting the parse
18//! model.
19
20use std::borrow::Cow;
21use std::path::PathBuf;
22
23use crate::pack::{EnvScope, ExecOnFail, RequireOnFail, SymlinkKind};
24
25/// Coarse-grained outcome of a single step.
26///
27/// Marked `#[non_exhaustive]` so future milestones (M4 plugin system,
28/// lockfile idempotency) can introduce additional outcomes without breaking
29/// downstream consumers. External match sites must include a `_` arm.
30#[non_exhaustive]
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub enum ExecResult {
33    /// Wet-run executor actually mutated state.
34    PerformedChange,
35    /// Planner determined the change would happen in a wet run.
36    WouldPerformChange,
37    /// Target state already matches (e.g. symlink already points at the right
38    /// src). Idempotent short-circuit.
39    AlreadySatisfied,
40    /// Action was a no-op: `when.os` branch not taken, or `require` failed
41    /// with `on_fail: skip | warn`. Not an error.
42    NoOp,
43    /// Action was deliberately skipped by a caller-level policy — in M4 the
44    /// trigger is a lockfile `actions_hash` match on the pack. The variant
45    /// carries the pack path and the matched hash so downstream audit
46    /// tooling can render "pack X at hash Y was skipped" without having to
47    /// thread extra context.
48    ///
49    /// Marked `#[non_exhaustive]` at the variant level so future audit
50    /// fields (e.g. `skipped_at`, `policy_source`) can be added without
51    /// breaking downstream struct-pattern match sites.
52    #[non_exhaustive]
53    Skipped {
54        /// Path to the pack whose actions were skipped.
55        pack_path: std::path::PathBuf,
56        /// Actions-hash that matched the lockfile entry.
57        actions_hash: String,
58    },
59}
60
61/// Whether a `require` predicate tree evaluated to true.
62///
63/// Marked `#[non_exhaustive]` so predicate evaluation can grow richer
64/// outcomes (e.g. `Indeterminate` for deferred probes) without breaking
65/// downstream match sites.
66#[non_exhaustive]
67#[derive(Debug, Clone, PartialEq, Eq)]
68pub enum PredicateOutcome {
69    /// Predicate(s) held.
70    Satisfied,
71    /// Predicate(s) did not hold.
72    Unsatisfied,
73}
74
75/// Variant-specific detail for a recorded step.
76///
77/// Paths are [`PathBuf`] rather than `String` because after expansion every
78/// path field is a concrete OS path. Command lines remain [`String`]
79/// because argv joining for display is lossy by design — the wet-run
80/// executor re-reads the underlying [`crate::pack::ExecSpec`] when spawning.
81///
82/// Marked `#[non_exhaustive]` so the M4 plugin layer can contribute new
83/// step-detail shapes without breaking downstream renderers.
84#[non_exhaustive]
85#[derive(Debug, Clone)]
86pub enum StepKind {
87    /// Resolved symlink descriptor.
88    Symlink {
89        /// Post-expansion source path.
90        src: PathBuf,
91        /// Post-expansion destination path.
92        dst: PathBuf,
93        /// Link-kind selector, passed through from the action.
94        kind: SymlinkKind,
95        /// Whether an existing `dst` would be backed up.
96        backup: bool,
97        /// Whether both sides would be canonicalised.
98        normalize: bool,
99    },
100    /// Resolved unlink descriptor — synthesized inverse of
101    /// [`StepKind::Symlink`] for auto-reverse teardown (R-M5-09).
102    Unlink {
103        /// Post-expansion destination path to remove.
104        dst: PathBuf,
105    },
106    /// Resolved environment-variable assignment.
107    Env {
108        /// Variable name (not expanded).
109        name: String,
110        /// Post-expansion value.
111        value: String,
112        /// Persistence scope.
113        scope: EnvScope,
114    },
115    /// Resolved mkdir descriptor.
116    Mkdir {
117        /// Post-expansion path.
118        path: PathBuf,
119        /// Optional POSIX mode string, verbatim.
120        mode: Option<String>,
121    },
122    /// Resolved rmdir descriptor.
123    Rmdir {
124        /// Post-expansion path.
125        path: PathBuf,
126        /// Whether to rename instead of delete.
127        backup: bool,
128        /// Whether recursive delete is permitted.
129        force: bool,
130    },
131    /// Resolved require gate.
132    Require {
133        /// Whether the predicate tree held.
134        outcome: PredicateOutcome,
135        /// Behaviour configured for unsatisfied outcomes.
136        on_fail: RequireOnFail,
137    },
138    /// Resolved when gate.
139    When {
140        /// Whether the composite condition evaluated to true.
141        branch_taken: bool,
142        /// Nested planned steps when `branch_taken == true`. Empty otherwise.
143        nested_steps: Vec<ExecStep>,
144    },
145    /// Resolved exec descriptor.
146    Exec {
147        /// Display-friendly command line (argv joined or cmd_shell verbatim).
148        cmdline: String,
149        /// Post-expansion working directory, when set.
150        cwd: Option<PathBuf>,
151        /// Error-propagation policy.
152        on_fail: ExecOnFail,
153        /// Whether this is a shell form.
154        shell: bool,
155    },
156    /// Dedicated pack-level skip detail. Emitted when a pack's
157    /// `actions_hash` matches a prior lockfile entry and the sync layer
158    /// short-circuits the entire pack. Replaces the M4-B proxy of
159    /// `Require { Satisfied, Skip }` with `action_name == "pack"`.
160    PackSkipped {
161        /// Actions-hash that matched the lockfile entry for this pack.
162        actions_hash: String,
163    },
164}
165
166/// Observable record of a single action's execution (or planned execution).
167///
168/// Marked `#[non_exhaustive]` so adding audit fields (duration, dry-run tag,
169/// plugin-contributed metadata) is a non-breaking change for downstream
170/// library consumers.
171#[non_exhaustive]
172#[derive(Debug, Clone)]
173pub struct ExecStep {
174    /// Short stable action identifier (one of the action-key strings, or a
175    /// plugin-contributed label in future milestones).
176    ///
177    /// Typed as [`Cow<'static, str>`] so built-in executors can emit
178    /// zero-cost static strings via [`Cow::Borrowed`] while M4 plugins can
179    /// contribute heap-allocated names via [`Cow::Owned`].
180    pub action_name: Cow<'static, str>,
181    /// Coarse outcome.
182    pub result: ExecResult,
183    /// Variant-specific detail.
184    pub details: StepKind,
185}
186
187/// Short stable action identifiers emitted by built-in executors. Exposed
188/// for downstream consumers that need to match step kinds without
189/// hard-coding string literals.
190pub const ACTION_SYMLINK: &str = "symlink";
191/// Built-in `unlink` action identifier (synthesized inverse of symlink).
192pub const ACTION_UNLINK: &str = "unlink";
193/// Built-in `env` action identifier.
194pub const ACTION_ENV: &str = "env";
195/// Built-in `mkdir` action identifier.
196pub const ACTION_MKDIR: &str = "mkdir";
197/// Built-in `rmdir` action identifier.
198pub const ACTION_RMDIR: &str = "rmdir";
199/// Built-in `require` action identifier.
200pub const ACTION_REQUIRE: &str = "require";
201/// Built-in `when` action identifier.
202pub const ACTION_WHEN: &str = "when";
203/// Built-in `exec` action identifier.
204pub const ACTION_EXEC: &str = "exec";
205
206#[cfg(test)]
207mod tests {
208    use super::*;
209
210    #[test]
211    fn skipped_carries_pack_path_and_hash() {
212        // Within-crate construction of a `#[non_exhaustive]` variant is
213        // allowed without `..`; this guards against accidental promotion
214        // of the variant to `#[non_exhaustive(pub)]`-equivalent semantics.
215        let r = ExecResult::Skipped {
216            pack_path: PathBuf::from("/tmp/packs/foo"),
217            actions_hash: "a".repeat(64),
218        };
219        match r {
220            ExecResult::Skipped { pack_path, actions_hash } => {
221                assert_eq!(pack_path, PathBuf::from("/tmp/packs/foo"));
222                assert_eq!(actions_hash.len(), 64);
223            }
224            _ => panic!("expected Skipped"),
225        }
226    }
227
228    #[test]
229    fn pack_skipped_round_trips() {
230        let k = StepKind::PackSkipped { actions_hash: "abc".into() };
231        match k {
232            StepKind::PackSkipped { actions_hash } => {
233                assert_eq!(actions_hash, "abc");
234            }
235            _ => panic!("expected PackSkipped"),
236        }
237    }
238}