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}