Skip to main content

krypt_core/
predicate.rs

1//! Predicate grammar + evaluator for `if =` conditions in step definitions.
2//!
3//! # Grammar
4//!
5//! | Form | Meaning |
6//! |------|---------|
7//! | `command_exists:<name>` | command is on `PATH` |
8//! | `env:VAR` | env var is present (any value, including empty) |
9//! | `env:VAR=value` | env var is present **and** equals `value` |
10//! | `platform:linux` / `platform:macos` / `platform:windows` | current OS |
11//! | `file_exists:<path>` | path exists after `${VAR}` resolution |
12//! | `!<predicate>` | negation of any of the above |
13//! | `a,b,c` | logical AND; whitespace around commas is allowed |
14//!
15//! Negation binds tighter than AND. An empty predicate string evaluates to
16//! `Ok(true)` (vacuously true). OR (`||`) is not supported yet — tracked as a
17//! follow-up.
18
19use std::collections::{BTreeMap, BTreeSet};
20use std::path::{Path, PathBuf};
21
22use thiserror::Error;
23
24use crate::paths::{Platform, ResolveError, Resolver};
25
26// ─── Errors ──────────────────────────────────────────────────────────────────
27
28/// Everything that can go wrong while parsing or evaluating a predicate string.
29#[derive(Debug, Error)]
30pub enum PredicateError {
31    /// A predicate kind that isn't part of the grammar (e.g. `weather:sunny`).
32    #[error("unknown predicate kind `{kind}`")]
33    UnknownKind {
34        /// The unrecognised kind name (text before the colon).
35        kind: String,
36    },
37
38    /// The predicate string is syntactically invalid.
39    #[error("malformed predicate `{input}`: {reason}")]
40    Malformed {
41        /// The exact input that failed.
42        input: String,
43        /// Why it failed.
44        reason: String,
45    },
46
47    /// Variable resolution inside a `file_exists:` path failed.
48    #[error("resolve error: {source}")]
49    Resolve {
50        /// The underlying resolver error.
51        #[source]
52        source: Box<ResolveError>,
53    },
54}
55
56impl From<ResolveError> for PredicateError {
57    fn from(e: ResolveError) -> Self {
58        Self::Resolve {
59            source: Box::new(e),
60        }
61    }
62}
63
64// ─── Trait ───────────────────────────────────────────────────────────────────
65
66/// Environment abstraction used by the predicate evaluator.
67///
68/// Implement this trait to control what the evaluator sees. The default
69/// production implementation is [`DefaultPredicateEnv`]; a mock suitable for
70/// unit tests is [`MockPredicateEnv`].
71pub trait PredicateEnv {
72    /// The platform to match `platform:linux` / `platform:macos` /
73    /// `platform:windows` against.
74    fn platform(&self) -> Platform;
75
76    /// Look up an environment variable. Returns `None` when the variable is
77    /// not set (as opposed to set-but-empty, which returns `Some("")`).
78    fn env(&self, var: &str) -> Option<String>;
79
80    /// Returns `true` when `name` is found on `PATH`.
81    fn command_exists(&self, name: &str) -> bool;
82
83    /// Returns `true` when `path` exists on the filesystem.
84    fn file_exists(&self, path: &Path) -> bool;
85
86    /// The [`Resolver`] used to expand `${VAR}` tokens in `file_exists:` paths.
87    fn resolver(&self) -> &Resolver;
88}
89
90// ─── DefaultPredicateEnv ─────────────────────────────────────────────────────
91
92/// Production [`PredicateEnv`] backed by the real OS.
93pub struct DefaultPredicateEnv {
94    resolver: Resolver,
95}
96
97impl DefaultPredicateEnv {
98    /// Create with a freshly-detected platform and real process env.
99    pub fn new() -> Self {
100        Self {
101            resolver: Resolver::new(),
102        }
103    }
104
105    /// Create with a specific [`Resolver`] (useful for tests that want a
106    /// controlled env snapshot without full mocking).
107    pub fn with_resolver(resolver: Resolver) -> Self {
108        Self { resolver }
109    }
110}
111
112impl Default for DefaultPredicateEnv {
113    fn default() -> Self {
114        Self::new()
115    }
116}
117
118impl PredicateEnv for DefaultPredicateEnv {
119    fn platform(&self) -> Platform {
120        Platform::current()
121    }
122
123    fn env(&self, var: &str) -> Option<String> {
124        std::env::var(var).ok()
125    }
126
127    fn command_exists(&self, name: &str) -> bool {
128        which::which(name).is_ok()
129    }
130
131    fn file_exists(&self, path: &Path) -> bool {
132        path.exists()
133    }
134
135    fn resolver(&self) -> &Resolver {
136        &self.resolver
137    }
138}
139
140// ─── MockPredicateEnv ────────────────────────────────────────────────────────
141
142/// Test double for [`PredicateEnv`].
143///
144/// Build an instance, populate the public fields, and pass a reference to
145/// [`eval`]. All lookups are pure in-memory — the host filesystem, PATH, and
146/// environment are never consulted.
147pub struct MockPredicateEnv {
148    /// Platform reported to `platform:` predicates.
149    pub platform: Platform,
150    /// Env vars available to `env:` predicates.
151    pub env: BTreeMap<String, String>,
152    /// Commands that are considered to exist for `command_exists:` predicates.
153    pub commands: BTreeSet<String>,
154    /// Paths that are considered to exist for `file_exists:` predicates.
155    pub files: BTreeSet<PathBuf>,
156    /// Resolver used to expand `${VAR}` tokens in `file_exists:` paths.
157    pub resolver: Resolver,
158}
159
160impl MockPredicateEnv {
161    /// Create a blank mock on the given platform.
162    pub fn new(platform: Platform) -> Self {
163        Self {
164            platform,
165            env: BTreeMap::new(),
166            commands: BTreeSet::new(),
167            files: BTreeSet::new(),
168            resolver: Resolver::for_platform(platform),
169        }
170    }
171}
172
173impl PredicateEnv for MockPredicateEnv {
174    fn platform(&self) -> Platform {
175        self.platform
176    }
177
178    fn env(&self, var: &str) -> Option<String> {
179        self.env.get(var).cloned()
180    }
181
182    fn command_exists(&self, name: &str) -> bool {
183        self.commands.contains(name)
184    }
185
186    fn file_exists(&self, path: &Path) -> bool {
187        self.files.contains(path)
188    }
189
190    fn resolver(&self) -> &Resolver {
191        &self.resolver
192    }
193}
194
195// ─── Evaluator ───────────────────────────────────────────────────────────────
196
197/// Evaluate a predicate string against the supplied environment.
198///
199/// An empty string returns `Ok(true)` — no condition means no constraint.
200/// A comma-separated list (`a,b,c`) is a logical AND; all terms must be true.
201/// Whitespace around commas is ignored. Negation (`!`) is a prefix on any atom.
202pub fn eval(predicate: &str, env: &dyn PredicateEnv) -> Result<bool, PredicateError> {
203    if predicate.is_empty() {
204        return Ok(true);
205    }
206
207    for term in predicate.split(',') {
208        let term = term.trim();
209
210        if term.is_empty() {
211            return Err(PredicateError::Malformed {
212                input: predicate.to_owned(),
213                reason: "empty term after splitting by `,` (consecutive commas?)".to_owned(),
214            });
215        }
216
217        if !eval_atom(term, predicate, env)? {
218            return Ok(false);
219        }
220    }
221
222    Ok(true)
223}
224
225/// Evaluate a single (possibly negated) atom against the environment.
226fn eval_atom(atom: &str, original: &str, env: &dyn PredicateEnv) -> Result<bool, PredicateError> {
227    if let Some(inner) = atom.strip_prefix('!') {
228        if inner.is_empty() {
229            return Err(PredicateError::Malformed {
230                input: original.to_owned(),
231                reason: "`!` must be followed by a predicate, not end-of-input".to_owned(),
232            });
233        }
234        return eval_atom(inner, original, env).map(|v| !v);
235    }
236
237    let (kind, arg) = atom
238        .split_once(':')
239        .ok_or_else(|| PredicateError::Malformed {
240            input: original.to_owned(),
241            reason: format!("`{atom}` has no `:` separator — expected `<kind>:<arg>`"),
242        })?;
243
244    match kind {
245        "command_exists" => {
246            if arg.is_empty() {
247                return Err(PredicateError::Malformed {
248                    input: original.to_owned(),
249                    reason: "`command_exists:` requires a non-empty command name".to_owned(),
250                });
251            }
252            Ok(env.command_exists(arg))
253        }
254
255        "env" => {
256            if arg.is_empty() {
257                return Err(PredicateError::Malformed {
258                    input: original.to_owned(),
259                    reason: "`env:` requires a variable name".to_owned(),
260                });
261            }
262            if let Some((var, expected)) = arg.split_once('=') {
263                // env:VAR=value — present AND matches
264                Ok(env.env(var).as_deref() == Some(expected))
265            } else {
266                // env:VAR — present with any value (including empty string)
267                Ok(env.env(arg).is_some())
268            }
269        }
270
271        "platform" => {
272            let expected = match arg {
273                "linux" => Platform::Linux,
274                "macos" => Platform::Macos,
275                "windows" => Platform::Windows,
276                other => {
277                    return Err(PredicateError::Malformed {
278                        input: original.to_owned(),
279                        reason: format!(
280                            "`platform:{other}` is not a recognised platform; \
281                             use `linux`, `macos`, or `windows`"
282                        ),
283                    });
284                }
285            };
286            Ok(env.platform() == expected)
287        }
288
289        "file_exists" => {
290            if arg.is_empty() {
291                return Err(PredicateError::Malformed {
292                    input: original.to_owned(),
293                    reason: "`file_exists:` requires a path".to_owned(),
294                });
295            }
296            let resolved = env.resolver().resolve(arg)?;
297            Ok(env.file_exists(Path::new(&resolved)))
298        }
299
300        other => Err(PredicateError::UnknownKind {
301            kind: other.to_owned(),
302        }),
303    }
304}
305
306// ─── Runner convenience adapter ──────────────────────────────────────────────
307
308/// Wrap a [`PredicateEnv`] into the closure signature expected by
309/// [`crate::runner::execute_steps`].
310///
311/// Parse errors are swallowed (logged as `tracing::warn!`) and the step is
312/// skipped (`false`). This matches the failing-closed principle: a config bug
313/// is surfaced as a warning, not a process abort.
314pub fn default_predicate_evaluator(
315    env: impl PredicateEnv + 'static,
316) -> impl Fn(&str, &crate::runner::Context) -> bool {
317    move |pred, _ctx| match eval(pred, &env) {
318        Ok(v) => v,
319        Err(e) => {
320            tracing::warn!(predicate = pred, error = %e, "predicate eval error — skipping step");
321            false
322        }
323    }
324}
325
326// ─── Tests ───────────────────────────────────────────────────────────────────
327
328#[cfg(test)]
329mod tests {
330    use std::collections::HashMap;
331
332    use super::*;
333    use crate::paths::Resolver;
334
335    fn mock(platform: Platform) -> MockPredicateEnv {
336        MockPredicateEnv::new(platform)
337    }
338
339    fn linux() -> MockPredicateEnv {
340        mock(Platform::Linux)
341    }
342
343    fn macos() -> MockPredicateEnv {
344        mock(Platform::Macos)
345    }
346
347    fn windows_env() -> MockPredicateEnv {
348        mock(Platform::Windows)
349    }
350
351    // ── 1. command_exists ─────────────────────────────────────────────────────
352
353    #[test]
354    fn command_exists_false_when_not_in_set() {
355        let env = linux();
356        assert!(!eval("command_exists:nonexistent_binary_xyz", &env).unwrap());
357    }
358
359    #[test]
360    fn command_exists_true_when_in_set() {
361        let mut env = linux();
362        env.commands.insert("my_tool".to_owned());
363        assert!(eval("command_exists:my_tool", &env).unwrap());
364    }
365
366    // ── 2. env:VAR ────────────────────────────────────────────────────────────
367
368    #[test]
369    fn env_var_true_when_set() {
370        let mut env = linux();
371        env.env.insert("HOME".to_owned(), "/home/user".to_owned());
372        assert!(eval("env:HOME", &env).unwrap());
373    }
374
375    #[test]
376    fn env_var_false_when_not_set() {
377        let env = linux();
378        assert!(!eval("env:DOES_NOT_EXIST_XYZ", &env).unwrap());
379    }
380
381    #[test]
382    fn env_var_true_when_set_to_empty() {
383        let mut env = linux();
384        env.env.insert("EMPTY_VAR".to_owned(), String::new());
385        // present-but-empty still counts as present
386        assert!(eval("env:EMPTY_VAR", &env).unwrap());
387    }
388
389    // ── 3. env:VAR=value ─────────────────────────────────────────────────────
390
391    #[test]
392    fn env_value_match_true() {
393        let mut env = linux();
394        env.env.insert("USER".to_owned(), "root".to_owned());
395        assert!(eval("env:USER=root", &env).unwrap());
396    }
397
398    #[test]
399    fn env_value_match_false_when_different() {
400        let mut env = linux();
401        env.env.insert("USER".to_owned(), "alice".to_owned());
402        assert!(!eval("env:USER=root", &env).unwrap());
403    }
404
405    #[test]
406    fn env_value_match_false_when_unset() {
407        let env = linux();
408        assert!(!eval("env:USER=root", &env).unwrap());
409    }
410
411    // ── 4. platform ───────────────────────────────────────────────────────────
412
413    #[test]
414    fn platform_linux_true_on_linux() {
415        let env = linux();
416        assert!(eval("platform:linux", &env).unwrap());
417    }
418
419    #[test]
420    fn platform_linux_false_on_macos() {
421        let env = macos();
422        assert!(!eval("platform:linux", &env).unwrap());
423    }
424
425    #[test]
426    fn platform_macos_true_on_macos() {
427        let env = macos();
428        assert!(eval("platform:macos", &env).unwrap());
429    }
430
431    #[test]
432    fn platform_windows_true_on_windows() {
433        let env = windows_env();
434        assert!(eval("platform:windows", &env).unwrap());
435    }
436
437    // ── 5. file_exists ────────────────────────────────────────────────────────
438
439    #[test]
440    fn file_exists_true_when_in_set() {
441        let mut env = linux();
442        env.files.insert(PathBuf::from("/etc/passwd"));
443        assert!(eval("file_exists:/etc/passwd", &env).unwrap());
444    }
445
446    #[test]
447    fn file_exists_false_when_not_in_set() {
448        let env = linux();
449        assert!(!eval("file_exists:/no/such/file", &env).unwrap());
450    }
451
452    #[test]
453    fn file_exists_resolves_env_var_before_checking() {
454        let mut env = linux();
455        // Provide a resolver with HOME set so ${HOME} expands.
456        let resolver = Resolver::for_platform(Platform::Linux).with_env(HashMap::from([(
457            "HOME".to_owned(),
458            "/home/testuser".to_owned(),
459        )]));
460        env.resolver = resolver;
461        env.files.insert(PathBuf::from("/home/testuser/.bashrc"));
462        assert!(eval("file_exists:${HOME}/.bashrc", &env).unwrap());
463    }
464
465    // ── 6. Negation ───────────────────────────────────────────────────────────
466
467    #[test]
468    fn negation_of_false_is_true() {
469        let env = linux();
470        assert!(eval("!command_exists:nonexistent_xyz", &env).unwrap());
471    }
472
473    #[test]
474    fn negation_of_true_is_false() {
475        let env = linux();
476        assert!(!eval("!platform:linux", &env).unwrap());
477    }
478
479    // ── 7. AND (comma-separated) ──────────────────────────────────────────────
480
481    #[test]
482    fn and_both_true_is_true() {
483        let mut env = linux();
484        env.commands.insert("sh".to_owned());
485        assert!(eval("platform:linux,command_exists:sh", &env).unwrap());
486    }
487
488    #[test]
489    fn and_one_false_is_false() {
490        let env = linux();
491        // sh is NOT in commands set → second term is false
492        assert!(!eval("platform:linux,command_exists:sh", &env).unwrap());
493    }
494
495    #[test]
496    fn and_three_terms_all_true() {
497        let mut env = linux();
498        env.commands.insert("sh".to_owned());
499        env.commands.insert("ls".to_owned());
500        assert!(eval("platform:linux,command_exists:sh,command_exists:ls", &env).unwrap());
501    }
502
503    #[test]
504    fn and_three_terms_one_false() {
505        let mut env = linux();
506        env.commands.insert("sh".to_owned());
507        // ls NOT in set
508        assert!(!eval("platform:linux,command_exists:sh,command_exists:ls", &env).unwrap());
509    }
510
511    // ── 8. Whitespace around commas ───────────────────────────────────────────
512
513    #[test]
514    fn whitespace_around_commas_accepted() {
515        let mut env = linux();
516        env.commands.insert("sh".to_owned());
517        assert!(eval("platform:linux , command_exists:sh", &env).unwrap());
518    }
519
520    // ── 9. Empty predicate ────────────────────────────────────────────────────
521
522    #[test]
523    fn empty_predicate_is_vacuously_true() {
524        let env = linux();
525        assert!(eval("", &env).unwrap());
526    }
527
528    // ── 10. Empty after split ─────────────────────────────────────────────────
529
530    #[test]
531    fn empty_after_split_is_malformed() {
532        let env = linux();
533        assert!(matches!(
534            eval("platform:linux,,command_exists:sh", &env),
535            Err(PredicateError::Malformed { .. })
536        ));
537    }
538
539    // ── 11. Unknown kind ──────────────────────────────────────────────────────
540
541    #[test]
542    fn unknown_kind_is_error() {
543        let env = linux();
544        assert!(matches!(
545            eval("weather:sunny", &env),
546            Err(PredicateError::UnknownKind { kind }) if kind == "weather"
547        ));
548    }
549
550    // ── 12. Malformed predicates ──────────────────────────────────────────────
551
552    #[test]
553    fn no_colon_is_malformed() {
554        let env = linux();
555        assert!(matches!(
556            eval("command_exists", &env),
557            Err(PredicateError::Malformed { .. })
558        ));
559    }
560
561    #[test]
562    fn env_empty_var_name_is_malformed() {
563        let env = linux();
564        assert!(matches!(
565            eval("env:", &env),
566            Err(PredicateError::Malformed { .. })
567        ));
568    }
569
570    // ── 13. Negation of empty ─────────────────────────────────────────────────
571
572    #[test]
573    fn negation_of_empty_is_malformed() {
574        let env = linux();
575        assert!(matches!(
576            eval("!", &env),
577            Err(PredicateError::Malformed { .. })
578        ));
579    }
580
581    // ── 14. Negation combined with AND ────────────────────────────────────────
582
583    #[test]
584    fn negation_and_combo() {
585        // !platform:linux on linux → false; combo is false
586        let mut env = linux();
587        env.commands.insert("rofi".to_owned());
588        assert!(!eval("!platform:linux,command_exists:rofi", &env).unwrap());
589    }
590
591    #[test]
592    fn negation_and_combo_true_on_macos() {
593        // !platform:linux on macos → true; rofi present → true → AND true
594        let mut env = macos();
595        env.commands.insert("rofi".to_owned());
596        assert!(eval("!platform:linux,command_exists:rofi", &env).unwrap());
597    }
598}