Skip to main content

grex_core/pack/
action.rs

1//! Tier-1 action variants and their key-dispatched deserializer.
2//!
3//! Every action in a pack's `actions:` list is a YAML map with **exactly
4//! one key**. That key names the action; its value carries the typed
5//! arguments. We reject `#[serde(untagged)]` for dispatch because its
6//! error messages collapse all variant attempts into "did not match any
7//! variant" — useless for authors. Instead we:
8//!
9//! 1. Deserialize each entry into a `RawAction` (a single-key map).
10//! 2. Inspect the key, dispatch to the correct typed arg deserializer.
11//! 3. Emit a precise error citing the offending key.
12
13use std::collections::BTreeMap;
14
15use serde::{Deserialize, Serialize};
16
17use super::error::PackParseError;
18use super::predicate::{Combiner, ExecOnFail, OsKind, Predicate, RequireOnFail};
19
20/// Symlink link-kind selector.
21///
22/// Marked `#[non_exhaustive]` so future platform-specific kinds (e.g. NTFS
23/// junctions) can land without breaking external match sites.
24#[non_exhaustive]
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
26#[serde(rename_all = "lowercase")]
27pub enum SymlinkKind {
28    /// Infer from `src` (file → file-link, dir → dir-link). Default.
29    #[default]
30    Auto,
31    /// Force a file-symlink (Windows `symlink_file`).
32    File,
33    /// Force a directory-symlink (Windows `symlink_dir`).
34    Directory,
35}
36
37/// Environment-variable persistence scope.
38///
39/// Marked `#[non_exhaustive]` so future scopes (e.g. per-shell rc-file,
40/// systemd user-session) can land without breaking external match sites.
41#[non_exhaustive]
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
43#[serde(rename_all = "lowercase")]
44pub enum EnvScope {
45    /// Current user (HKCU / shell rc). Default.
46    #[default]
47    User,
48    /// System-wide (HKLM / `/etc/environment`). Needs admin.
49    Machine,
50    /// Current process only.
51    Session,
52}
53
54/// `- symlink: { ... }`
55///
56/// Marked `#[non_exhaustive]` so spec additions (e.g. `relative`, `force`)
57/// in later milestones do not break external library consumers who
58/// destructure the struct.
59#[non_exhaustive]
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct SymlinkArgs {
62    /// Source path, relative to pack workdir.
63    pub src: String,
64    /// Destination path (may contain env-var tokens; not expanded at parse).
65    pub dst: String,
66    /// Rename existing `dst` before creating the link. Defaults to `false`.
67    #[serde(default)]
68    pub backup: bool,
69    /// Canonicalize both sides. Defaults to `true`.
70    #[serde(default = "default_true")]
71    pub normalize: bool,
72    /// Link kind selector.
73    #[serde(default)]
74    pub kind: SymlinkKind,
75}
76
77fn default_true() -> bool {
78    true
79}
80
81impl SymlinkArgs {
82    /// Construct a [`SymlinkArgs`] with all current fields in canonical
83    /// order. Exposed so external callers (and in-workspace test crates)
84    /// can materialise values even though the struct is
85    /// `#[non_exhaustive]`.
86    #[must_use]
87    pub fn new(src: String, dst: String, backup: bool, normalize: bool, kind: SymlinkKind) -> Self {
88        Self { src, dst, backup, normalize, kind }
89    }
90}
91
92impl EnvArgs {
93    /// Construct an [`EnvArgs`] with all current fields in canonical order.
94    #[must_use]
95    pub fn new(name: String, value: String, scope: EnvScope) -> Self {
96        Self { name, value, scope }
97    }
98}
99
100impl MkdirArgs {
101    /// Construct a [`MkdirArgs`] with all current fields in canonical order.
102    #[must_use]
103    pub fn new(path: String, mode: Option<String>) -> Self {
104        Self { path, mode }
105    }
106}
107
108impl RmdirArgs {
109    /// Construct a [`RmdirArgs`] with all current fields in canonical order.
110    #[must_use]
111    pub fn new(path: String, backup: bool, force: bool) -> Self {
112        Self { path, backup, force }
113    }
114}
115
116impl RequireSpec {
117    /// Construct a [`RequireSpec`] with all current fields in canonical order.
118    #[must_use]
119    pub fn new(combiner: Combiner, on_fail: RequireOnFail) -> Self {
120        Self { combiner, on_fail }
121    }
122}
123
124impl WhenSpec {
125    /// Construct a [`WhenSpec`] with all current fields in canonical order.
126    #[must_use]
127    pub fn new(
128        os: Option<OsKind>,
129        all_of: Option<Vec<Predicate>>,
130        any_of: Option<Vec<Predicate>>,
131        none_of: Option<Vec<Predicate>>,
132        actions: Vec<Action>,
133    ) -> Self {
134        Self { os, all_of, any_of, none_of, actions }
135    }
136}
137
138impl ExecSpec {
139    /// Construct an [`ExecSpec`] with all current fields in canonical order.
140    #[must_use]
141    pub fn new(
142        cmd: Option<Vec<String>>,
143        cmd_shell: Option<String>,
144        shell: bool,
145        cwd: Option<String>,
146        env: Option<BTreeMap<String, String>>,
147        on_fail: ExecOnFail,
148    ) -> Self {
149        Self { cmd, cmd_shell, shell, cwd, env, on_fail }
150    }
151}
152
153/// `- unlink: { ... }` — synthesized inverse of a `symlink` action for
154/// auto-reverse teardown (R-M5-09). Not a YAML-parseable action: no
155/// pack author writes `unlink` by hand. The declarative teardown
156/// auto-reverse path in [`crate::plugin::pack_type::DeclarativePlugin`]
157/// manufactures these from the recorded `symlink.dst` values when a
158/// pack omits an explicit `teardown:` block.
159///
160/// Mirrors [`SymlinkArgs`] in shape but carries only `dst` — that is
161/// the only field needed to locate and remove the symlink. Non-symlink
162/// files at `dst` are left untouched by the wet-run plugin, so a
163/// misdirected teardown cannot clobber operator-managed content.
164#[non_exhaustive]
165#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
166pub struct UnlinkArgs {
167    /// Destination path of the symlink to remove.
168    pub dst: String,
169}
170
171impl UnlinkArgs {
172    /// Construct an [`UnlinkArgs`]. Exposed so the auto-reverse
173    /// synthesizer (and tests) can build values even though the struct
174    /// is `#[non_exhaustive]`.
175    #[must_use]
176    pub fn new(dst: String) -> Self {
177        Self { dst }
178    }
179}
180
181/// `- env: { ... }`
182///
183/// Marked `#[non_exhaustive]` so the spec can grow new knobs (e.g.
184/// `append`, `only_if_unset`) without breaking library consumers.
185#[non_exhaustive]
186#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
187pub struct EnvArgs {
188    /// Variable name.
189    pub name: String,
190    /// Variable value (pre-expansion form).
191    pub value: String,
192    /// Persistence scope. Defaults to [`EnvScope::User`].
193    #[serde(default)]
194    pub scope: EnvScope,
195}
196
197/// `- mkdir: { ... }`
198///
199/// Marked `#[non_exhaustive]` so spec-level growth (ownership, umask
200/// overrides, …) is non-breaking for library consumers.
201#[non_exhaustive]
202#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
203pub struct MkdirArgs {
204    /// Directory to create.
205    pub path: String,
206    /// POSIX mode string (ignored on Windows).
207    #[serde(default, skip_serializing_if = "Option::is_none")]
208    pub mode: Option<String>,
209}
210
211/// `- rmdir: { ... }`
212///
213/// Marked `#[non_exhaustive]` so spec-level growth (retention policy,
214/// tombstone dir override, …) is non-breaking for library consumers.
215#[non_exhaustive]
216#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
217pub struct RmdirArgs {
218    /// Directory to remove.
219    pub path: String,
220    /// Rename to `<path>.grex-bak.<ts>` instead of deleting. Default `false`.
221    #[serde(default)]
222    pub backup: bool,
223    /// Allow recursive delete of non-empty directory. Default `false`.
224    #[serde(default)]
225    pub force: bool,
226}
227
228/// `- require: { ... }` — prerequisite / idempotency gate.
229///
230/// Marked `#[non_exhaustive]` so M4 lockfile integration can attach
231/// additional audit fields (hash-pinning, cache tokens) without breaking
232/// downstream destructuring.
233#[non_exhaustive]
234#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
235pub struct RequireSpec {
236    /// Combiner populated by `all_of` / `any_of` / `none_of`.
237    pub combiner: Combiner,
238    /// Behaviour when the combiner evaluates to false.
239    pub on_fail: RequireOnFail,
240}
241
242/// `- when: { ... }` — conditional gate wrapping nested actions.
243///
244/// Per `actions.md`, the shorthand `os:` and the explicit combiners
245/// compose conjunctively. Stage A preserves all fields as-is; evaluation
246/// logic is a later stage.
247///
248/// Marked `#[non_exhaustive]` so new shorthand gates can land without
249/// breaking library consumers.
250#[non_exhaustive]
251#[derive(Debug, Clone, PartialEq, Eq)]
252pub struct WhenSpec {
253    /// Shorthand OS gate (equivalent to `os:` predicate in an implicit AND).
254    pub os: Option<OsKind>,
255    /// Explicit AND combiner predicates.
256    pub all_of: Option<Vec<Predicate>>,
257    /// Explicit OR combiner predicates.
258    pub any_of: Option<Vec<Predicate>>,
259    /// Explicit NOR combiner predicates.
260    pub none_of: Option<Vec<Predicate>>,
261    /// Nested actions to run when the composite condition holds.
262    pub actions: Vec<Action>,
263}
264
265/// `- exec: { ... }` — shell-escape hatch.
266///
267/// The `cmd` XOR `cmd_shell` invariant is enforced in the custom
268/// deserializer. `shell=false` (default) requires `cmd`; `shell=true`
269/// requires `cmd_shell`.
270///
271/// Marked `#[non_exhaustive]` so spec growth (timeout, stdout capture,
272/// sandboxing flags) is non-breaking for library consumers.
273#[non_exhaustive]
274#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
275pub struct ExecSpec {
276    /// Argv form. Populated when `shell=false`.
277    #[serde(skip_serializing_if = "Option::is_none")]
278    pub cmd: Option<Vec<String>>,
279    /// Single-string shell form. Populated when `shell=true`.
280    #[serde(skip_serializing_if = "Option::is_none")]
281    pub cmd_shell: Option<String>,
282    /// Whether to parse through `sh -c` / `cmd /c`.
283    pub shell: bool,
284    /// Working directory. Defaults to pack workdir at execute time.
285    #[serde(skip_serializing_if = "Option::is_none")]
286    pub cwd: Option<String>,
287    /// Extra environment variables for this invocation.
288    #[serde(skip_serializing_if = "Option::is_none")]
289    pub env: Option<BTreeMap<String, String>>,
290    /// Error-propagation policy.
291    pub on_fail: ExecOnFail,
292}
293
294/// One entry in a pack's `actions:` (or `teardown:`) list.
295///
296/// Marked `#[non_exhaustive]` because M4 ships plugin-contributed action
297/// kinds; external match sites must carry a `_` arm so the Tier-1 registry
298/// can grow without a major-version bump.
299#[non_exhaustive]
300#[derive(Debug, Clone, PartialEq, Eq)]
301pub enum Action {
302    /// `symlink` primitive.
303    Symlink(SymlinkArgs),
304    /// Synthesized inverse of `symlink` for auto-reverse teardown
305    /// (R-M5-09). Not a YAML-parseable action.
306    Unlink(UnlinkArgs),
307    /// `env` primitive.
308    Env(EnvArgs),
309    /// `mkdir` primitive.
310    Mkdir(MkdirArgs),
311    /// `rmdir` primitive.
312    Rmdir(RmdirArgs),
313    /// `require` gate.
314    Require(RequireSpec),
315    /// `when` conditional block.
316    When(WhenSpec),
317    /// `exec` shell escape.
318    Exec(ExecSpec),
319}
320
321/// Valid action keys. Re-exported for documentation + error-message
322/// composition.
323pub const VALID_ACTION_KEYS: &[&str] =
324    &["symlink", "env", "mkdir", "rmdir", "require", "when", "exec"];
325
326impl Action {
327    /// Parse a single action entry from a YAML value.
328    ///
329    /// Rejects zero-key and multi-key entries with
330    /// [`PackParseError::EmptyActionEntry`] / [`PackParseError::MultipleActionKeys`],
331    /// and unknown keys with [`PackParseError::UnknownActionKey`].
332    pub fn from_yaml(value: &serde_yaml::Value) -> Result<Self, PackParseError> {
333        let (key, v) = single_key_entry(value)?;
334        match key.as_str() {
335            "symlink" => parse_symlink(v),
336            "env" => parse_env(v),
337            "mkdir" => parse_mkdir(v),
338            "rmdir" => parse_rmdir(v),
339            "require" => parse_require(v).map(Self::Require),
340            "when" => parse_when(v).map(Self::When),
341            "exec" => parse_exec(v).map(Self::Exec),
342            other => Err(PackParseError::UnknownActionKey { key: other.to_string() }),
343        }
344    }
345
346    /// Parse an entire `actions:` sequence.
347    pub fn parse_list(value: Option<&serde_yaml::Value>) -> Result<Vec<Self>, PackParseError> {
348        let Some(value) = value else {
349            return Ok(Vec::new());
350        };
351        if value.is_null() {
352            return Ok(Vec::new());
353        }
354        let seq = value.as_sequence().ok_or_else(|| PackParseError::UnknownActionKey {
355            key: "<actions must be a sequence>".to_string(),
356        })?;
357        seq.iter().map(Self::from_yaml).collect()
358    }
359
360    /// Short kebab-case identifier matching the YAML key that produced this
361    /// variant (and the name plugins register under). Returned as
362    /// `&'static str` so callers can zero-cost compare against constants
363    /// like `ACTION_SYMLINK` (defined in the `execute::step` module).
364    #[must_use]
365    pub fn name(&self) -> &'static str {
366        match self {
367            Self::Symlink(_) => "symlink",
368            Self::Unlink(_) => "unlink",
369            Self::Env(_) => "env",
370            Self::Mkdir(_) => "mkdir",
371            Self::Rmdir(_) => "rmdir",
372            Self::Require(_) => "require",
373            Self::When(_) => "when",
374            Self::Exec(_) => "exec",
375        }
376    }
377
378    /// Walk this action (and any nested `when.actions`) yielding every
379    /// [`SymlinkArgs`] reached.
380    ///
381    /// * [`Action::Symlink`] yields the wrapped args.
382    /// * [`Action::When`] recurses into `when.actions` (which themselves may
383    ///   be `when` blocks — recursion is unbounded because the parse-time
384    ///   depth bound applies to predicate trees, not action nesting; in
385    ///   practice authors do not nest `when` deeply, and validators consume
386    ///   whatever the parser accepted).
387    /// * Every other variant yields an empty iterator.
388    ///
389    /// The iterator is boxed so variant-specific concrete iterator types can
390    /// share a single return shape. Boxing cost is negligible against the
391    /// outer YAML parse and well-bounded action lists; swapping to a custom
392    /// enum-iterator later is YAGNI for now.
393    #[must_use]
394    pub fn iter_symlinks(&self) -> Box<dyn Iterator<Item = &SymlinkArgs> + '_> {
395        match self {
396            Self::Symlink(s) => Box::new(std::iter::once(s)),
397            Self::When(w) => Box::new(w.actions.iter().flat_map(Self::iter_symlinks)),
398            _ => Box::new(std::iter::empty()),
399        }
400    }
401}
402
403/// Validate that `value` is a single-key mapping and return the owned key
404/// plus a reference to its value. Emits the same errors the inline form did.
405fn single_key_entry(
406    value: &serde_yaml::Value,
407) -> Result<(String, &serde_yaml::Value), PackParseError> {
408    let mapping = value.as_mapping().ok_or(PackParseError::EmptyActionEntry)?;
409    match mapping.len() {
410        0 => return Err(PackParseError::EmptyActionEntry),
411        1 => {}
412        _ => {
413            let keys = mapping.iter().filter_map(|(k, _)| k.as_str().map(str::to_owned)).collect();
414            return Err(PackParseError::MultipleActionKeys { keys });
415        }
416    }
417    let (k, v) = mapping.iter().next().expect("len==1 checked above");
418    let key =
419        k.as_str().ok_or_else(|| PackParseError::UnknownActionKey { key: format!("{k:?}") })?;
420    Ok((key.to_string(), v))
421}
422
423fn parse_symlink(value: &serde_yaml::Value) -> Result<Action, PackParseError> {
424    Ok(Action::Symlink(serde_yaml::from_value(value.clone())?))
425}
426
427fn parse_env(value: &serde_yaml::Value) -> Result<Action, PackParseError> {
428    Ok(Action::Env(serde_yaml::from_value(value.clone())?))
429}
430
431fn parse_mkdir(value: &serde_yaml::Value) -> Result<Action, PackParseError> {
432    Ok(Action::Mkdir(serde_yaml::from_value(value.clone())?))
433}
434
435fn parse_rmdir(value: &serde_yaml::Value) -> Result<Action, PackParseError> {
436    Ok(Action::Rmdir(serde_yaml::from_value(value.clone())?))
437}
438
439fn parse_require(value: &serde_yaml::Value) -> Result<RequireSpec, PackParseError> {
440    let mapping = value.as_mapping().ok_or_else(|| PackParseError::InvalidPredicate {
441        detail: "require: expects a mapping".to_string(),
442    })?;
443    let combiner = Combiner::from_mapping(mapping, 0)?;
444    let on_fail = match mapping.get(serde_yaml::Value::String("on_fail".to_string())) {
445        Some(v) => serde_yaml::from_value::<RequireOnFail>(v.clone())?,
446        None => RequireOnFail::default(),
447    };
448    Ok(RequireSpec { combiner, on_fail })
449}
450
451fn parse_when(value: &serde_yaml::Value) -> Result<WhenSpec, PackParseError> {
452    let mapping = value.as_mapping().ok_or_else(|| PackParseError::InvalidPredicate {
453        detail: "when: expects a mapping".to_string(),
454    })?;
455
456    let os = match mapping.get(serde_yaml::Value::String("os".to_string())) {
457        Some(v) => Some(serde_yaml::from_value::<OsKind>(v.clone())?),
458        None => None,
459    };
460
461    let all_of = optional_predicate_list(mapping, "all_of")?;
462    let any_of = optional_predicate_list(mapping, "any_of")?;
463    let none_of = optional_predicate_list(mapping, "none_of")?;
464
465    let actions_value = mapping.get(serde_yaml::Value::String("actions".to_string()));
466    let actions = Action::parse_list(actions_value)?;
467
468    Ok(WhenSpec { os, all_of, any_of, none_of, actions })
469}
470
471fn optional_predicate_list(
472    mapping: &serde_yaml::Mapping,
473    key: &str,
474) -> Result<Option<Vec<Predicate>>, PackParseError> {
475    let Some(value) = mapping.get(serde_yaml::Value::String(key.to_string())) else {
476        return Ok(None);
477    };
478    let seq = value.as_sequence().ok_or_else(|| PackParseError::InvalidPredicate {
479        detail: format!("{key} must be a sequence of predicates"),
480    })?;
481    let preds: Vec<Predicate> =
482        seq.iter().map(|v| Predicate::from_yaml(v, 1)).collect::<Result<_, _>>()?;
483    Ok(Some(preds))
484}
485
486fn parse_exec(value: &serde_yaml::Value) -> Result<ExecSpec, PackParseError> {
487    // Shape-flex deserialize: use a helper struct with all fields optional,
488    // then enforce the XOR post-parse.
489    #[derive(Deserialize)]
490    struct Raw {
491        #[serde(default)]
492        cmd: Option<Vec<String>>,
493        #[serde(default)]
494        cmd_shell: Option<String>,
495        #[serde(default)]
496        shell: bool,
497        #[serde(default)]
498        cwd: Option<String>,
499        #[serde(default)]
500        env: Option<BTreeMap<String, String>>,
501        #[serde(default)]
502        on_fail: ExecOnFail,
503    }
504
505    let raw: Raw = serde_yaml::from_value(value.clone())?;
506
507    let cmd_present = raw.cmd.is_some();
508    let cmd_shell_present = raw.cmd_shell.is_some();
509
510    let valid = match raw.shell {
511        false => cmd_present && !cmd_shell_present,
512        true => !cmd_present && cmd_shell_present,
513    };
514    if !valid {
515        return Err(PackParseError::ExecCmdMutex {
516            shell: raw.shell,
517            cmd_present,
518            cmd_shell_present,
519        });
520    }
521
522    Ok(ExecSpec {
523        cmd: raw.cmd,
524        cmd_shell: raw.cmd_shell,
525        shell: raw.shell,
526        cwd: raw.cwd,
527        env: raw.env,
528        on_fail: raw.on_fail,
529    })
530}