Skip to main content

grex_core/pack/
predicate.rs

1//! Predicate grammar for `require` and `when` blocks.
2//!
3//! A predicate tree is a recursive structure of leaf checks (path exists,
4//! command available, etc.) composed by Boolean combiners (`all_of`,
5//! `any_of`, `none_of`). Parsing is key-dispatched — never `#[serde(untagged)]`
6//! — so error messages can cite the offending key precisely.
7//!
8//! Execute-time evaluation is a later stage; Stage A only parses and
9//! preserves the tree.
10
11use serde::{Deserialize, Serialize};
12
13use super::error::{PackParseError, MAX_REQUIRE_DEPTH};
14
15/// Operating-system matcher used by `os:` predicates and `when.os`.
16///
17/// Marked `#[non_exhaustive]` so future OS tags (BSD variants, WASM,
18/// embedded) can land without breaking external match sites.
19#[non_exhaustive]
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(rename_all = "lowercase")]
22pub enum OsKind {
23    /// Microsoft Windows.
24    Windows,
25    /// Linux kernel (any distro).
26    Linux,
27    /// Apple macOS.
28    Macos,
29}
30
31/// Behaviour when a `require` block evaluates to false.
32///
33/// Per `actions.md` §require, the legal set here is `error | skip | warn`.
34/// `ignore` (an `exec`-only form) is deliberately rejected at parse time.
35///
36/// Marked `#[non_exhaustive]` so future on-fail modes (e.g. `prompt`) can
37/// land without breaking external match sites.
38#[non_exhaustive]
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
40#[serde(rename_all = "lowercase")]
41pub enum RequireOnFail {
42    /// Abort pack install with a non-zero exit code.
43    #[default]
44    Error,
45    /// Skip remaining actions in this pack; lifecycle reports "skipped".
46    Skip,
47    /// Log a warning and continue.
48    Warn,
49}
50
51/// Behaviour when an `exec` invocation returns a non-zero exit code.
52///
53/// Per `actions.md` §exec, the legal set here is `error | warn | ignore`.
54/// `skip` (a `require`-only form) is deliberately rejected at parse time.
55///
56/// Marked `#[non_exhaustive]` so future on-fail modes can land without
57/// breaking external match sites.
58#[non_exhaustive]
59#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
60#[serde(rename_all = "lowercase")]
61pub enum ExecOnFail {
62    /// Propagate the non-zero exit code and fail the pack lifecycle.
63    #[default]
64    Error,
65    /// Log a warning but continue running remaining actions.
66    Warn,
67    /// Treat the non-zero exit as success (used for idempotency workarounds).
68    Ignore,
69}
70
71/// A single leaf check or a nested combiner.
72///
73/// Parsed from a single-key YAML map via [`Predicate::from_yaml`]. The enum
74/// intentionally mirrors the key set documented in `actions.md`.
75///
76/// Marked `#[non_exhaustive]` so new leaf predicates (plugin-contributed or
77/// spec-extension) can land without breaking external match sites.
78#[non_exhaustive]
79#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
80#[serde(rename_all = "snake_case")]
81pub enum Predicate {
82    /// Filesystem path must exist.
83    PathExists(String),
84    /// Named command is resolvable via `PATH`.
85    CmdAvailable(String),
86    /// Windows registry value is present. Only the map form
87    /// `{ path, name }` is accepted — the legacy `hive\path!name` string
88    /// form is rejected at parse time for unambiguity.
89    RegKey {
90        /// Registry path including hive (e.g. `HKCU\Software\...`).
91        path: String,
92        /// Optional value name within the key.
93        name: Option<String>,
94    },
95    /// Current OS matches.
96    Os(OsKind),
97    /// PowerShell version spec (e.g. `>=5.1`).
98    PsVersion(String),
99    /// Privilege / developer-mode permits symlink creation for `src` → `dst`.
100    SymlinkOk {
101        /// Symlink source path.
102        src: String,
103        /// Symlink destination path.
104        dst: String,
105    },
106    /// Nested AND combiner.
107    AllOf(Vec<Predicate>),
108    /// Nested OR combiner.
109    AnyOf(Vec<Predicate>),
110    /// Nested NOR combiner.
111    NoneOf(Vec<Predicate>),
112}
113
114/// The one-of combiner declared at the top level of a `require` or `when`
115/// block. Exactly one variant is populated at parse time.
116///
117/// Marked `#[non_exhaustive]` so new combiner shapes (e.g. `xor_of`,
118/// `majority_of`) can land without breaking external match sites.
119#[non_exhaustive]
120#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
121#[serde(rename_all = "snake_case")]
122pub enum Combiner {
123    /// `all_of:` — every predicate must hold (AND).
124    AllOf(Vec<Predicate>),
125    /// `any_of:` — at least one predicate must hold (OR).
126    AnyOf(Vec<Predicate>),
127    /// `none_of:` — no predicate may hold (NOR).
128    NoneOf(Vec<Predicate>),
129}
130
131impl Predicate {
132    /// Parse a single predicate entry from a `serde_yaml::Value`.
133    ///
134    /// Each entry must be a map with **exactly one** key naming the
135    /// predicate kind. Depth-limited by [`MAX_REQUIRE_DEPTH`] to bound
136    /// pathological nesting.
137    pub fn from_yaml(value: &serde_yaml::Value, depth: usize) -> Result<Self, PackParseError> {
138        let (key, v) = predicate_single_key(value, depth)?;
139        match key.as_str() {
140            "path_exists" => parse_path_exists(v),
141            "cmd_available" => parse_cmd_available(v),
142            "reg_key" => parse_reg_key(v),
143            "os" => parse_os(v),
144            "psversion" => parse_ps_version(v),
145            "symlink_ok" => parse_symlink_ok(v),
146            "all_of" => parse_all_of(v, depth),
147            "any_of" => parse_any_of(v, depth),
148            "none_of" => parse_none_of(v, depth),
149            other => Err(unknown_predicate_err(other)),
150        }
151    }
152}
153
154/// Enforce the depth bound and unwrap the single-key mapping shape shared by
155/// every predicate entry. Returns the owned key name and a reference to its
156/// value.
157fn predicate_single_key(
158    value: &serde_yaml::Value,
159    depth: usize,
160) -> Result<(String, &serde_yaml::Value), PackParseError> {
161    if depth >= MAX_REQUIRE_DEPTH {
162        return Err(PackParseError::RequireDepthExceeded { depth, max: MAX_REQUIRE_DEPTH });
163    }
164
165    let mapping = value.as_mapping().ok_or_else(|| PackParseError::InvalidPredicate {
166        detail: "predicate must be a single-key mapping".to_string(),
167    })?;
168
169    if mapping.len() != 1 {
170        return Err(PackParseError::InvalidPredicate {
171            detail: format!("predicate must be a single-key mapping (got {} keys)", mapping.len()),
172        });
173    }
174
175    let (k, v) = mapping.iter().next().expect("len==1 checked above");
176    let key = k.as_str().ok_or_else(|| PackParseError::InvalidPredicate {
177        detail: "predicate key must be a string".to_string(),
178    })?;
179    Ok((key.to_string(), v))
180}
181
182fn parse_path_exists(value: &serde_yaml::Value) -> Result<Predicate, PackParseError> {
183    Ok(Predicate::PathExists(string_arg(value, "path_exists")?))
184}
185
186fn parse_cmd_available(value: &serde_yaml::Value) -> Result<Predicate, PackParseError> {
187    Ok(Predicate::CmdAvailable(string_arg(value, "cmd_available")?))
188}
189
190fn parse_reg_key(value: &serde_yaml::Value) -> Result<Predicate, PackParseError> {
191    Ok(Predicate::RegKey { path: reg_path(value)?, name: reg_name(value)? })
192}
193
194fn parse_os(value: &serde_yaml::Value) -> Result<Predicate, PackParseError> {
195    Ok(Predicate::Os(serde_yaml::from_value::<OsKind>(value.clone())?))
196}
197
198fn parse_ps_version(value: &serde_yaml::Value) -> Result<Predicate, PackParseError> {
199    Ok(Predicate::PsVersion(string_arg(value, "psversion")?))
200}
201
202fn parse_symlink_ok(value: &serde_yaml::Value) -> Result<Predicate, PackParseError> {
203    Ok(Predicate::SymlinkOk {
204        src: map_string(value, "symlink_ok", "src")?,
205        dst: map_string(value, "symlink_ok", "dst")?,
206    })
207}
208
209fn parse_all_of(value: &serde_yaml::Value, depth: usize) -> Result<Predicate, PackParseError> {
210    Ok(Predicate::AllOf(parse_list(value, depth + 1)?))
211}
212
213fn parse_any_of(value: &serde_yaml::Value, depth: usize) -> Result<Predicate, PackParseError> {
214    Ok(Predicate::AnyOf(parse_list(value, depth + 1)?))
215}
216
217fn parse_none_of(value: &serde_yaml::Value, depth: usize) -> Result<Predicate, PackParseError> {
218    Ok(Predicate::NoneOf(parse_list(value, depth + 1)?))
219}
220
221fn unknown_predicate_err(key: &str) -> PackParseError {
222    PackParseError::InvalidPredicate {
223        detail: format!(
224            "unknown predicate {key:?}: valid kinds are path_exists, cmd_available, \
225reg_key, os, psversion, symlink_ok, all_of, any_of, none_of"
226        ),
227    }
228}
229
230impl Combiner {
231    /// Parse a combiner from a YAML mapping. Caller is responsible for
232    /// handing down only the subset of keys relevant to combiner selection
233    /// (typically the full mapping; non-combiner keys are ignored by this
234    /// fn).
235    ///
236    /// Exactly one of `all_of` / `any_of` / `none_of` must be present.
237    pub fn from_mapping(
238        mapping: &serde_yaml::Mapping,
239        depth: usize,
240    ) -> Result<Self, PackParseError> {
241        let mut seen: Vec<(&'static str, &serde_yaml::Value)> = Vec::new();
242        for key in ["all_of", "any_of", "none_of"] {
243            if let Some(v) = mapping.get(serde_yaml::Value::String(key.to_string())) {
244                seen.push((key, v));
245            }
246        }
247        if seen.len() != 1 {
248            return Err(PackParseError::RequireCombinerArity { count: seen.len() });
249        }
250        let (key, value) = seen[0];
251        let list = parse_list(value, depth + 1)?;
252        Ok(match key {
253            "all_of" => Self::AllOf(list),
254            "any_of" => Self::AnyOf(list),
255            "none_of" => Self::NoneOf(list),
256            _ => unreachable!("iteration set is fixed"),
257        })
258    }
259}
260
261/// Parse a YAML sequence of predicate entries.
262fn parse_list(value: &serde_yaml::Value, depth: usize) -> Result<Vec<Predicate>, PackParseError> {
263    let seq = value.as_sequence().ok_or_else(|| PackParseError::InvalidPredicate {
264        detail: "combiner value must be a sequence of predicate entries".to_string(),
265    })?;
266    seq.iter().map(|v| Predicate::from_yaml(v, depth)).collect()
267}
268
269fn string_arg(value: &serde_yaml::Value, key: &str) -> Result<String, PackParseError> {
270    value.as_str().map(str::to_owned).ok_or_else(|| PackParseError::InvalidPredicate {
271        detail: format!("{key} expects a string argument"),
272    })
273}
274
275/// `reg_key` only accepts the map form `{ path, name }`. The legacy
276/// `hive\path!name` string form is rejected — the spec never defined it and
277/// ambiguity between a literal `!` in a registry path and the name
278/// separator motivates the strict shape.
279fn reg_path(value: &serde_yaml::Value) -> Result<String, PackParseError> {
280    if value.as_str().is_some() {
281        return Err(PackParseError::InvalidPredicate {
282            detail: "reg_key string form is not supported: use { path, name } map".to_string(),
283        });
284    }
285    map_string(value, "reg_key", "path")
286}
287
288fn reg_name(value: &serde_yaml::Value) -> Result<Option<String>, PackParseError> {
289    if value.as_str().is_some() {
290        return Err(PackParseError::InvalidPredicate {
291            detail: "reg_key string form is not supported: use { path, name } map".to_string(),
292        });
293    }
294    match value.as_mapping() {
295        Some(m) => match m.get(serde_yaml::Value::String("name".to_string())) {
296            Some(v) if v.is_null() => Ok(None),
297            Some(v) => v.as_str().map(str::to_owned).map(Some).ok_or_else(|| {
298                PackParseError::InvalidPredicate {
299                    detail: "reg_key.name must be a string".to_string(),
300                }
301            }),
302            None => Ok(None),
303        },
304        None => Err(PackParseError::InvalidPredicate {
305            detail: "reg_key expects a { path, name } map".to_string(),
306        }),
307    }
308}
309
310fn map_string(
311    value: &serde_yaml::Value,
312    outer: &str,
313    field: &str,
314) -> Result<String, PackParseError> {
315    let map = value.as_mapping().ok_or_else(|| PackParseError::InvalidPredicate {
316        detail: format!("{outer} expects a mapping argument"),
317    })?;
318    let v = map.get(serde_yaml::Value::String(field.to_string())).ok_or_else(|| {
319        PackParseError::InvalidPredicate {
320            detail: format!("{outer} missing required field {field:?}"),
321        }
322    })?;
323    v.as_str().map(str::to_owned).ok_or_else(|| PackParseError::InvalidPredicate {
324        detail: format!("{outer}.{field} must be a string"),
325    })
326}