Skip to main content

alint_core/when/
mod.rs

1//! The `when` expression language — bounded DSL for gating rules on facts.
2//!
3//! Grammar (hand-written recursive-descent; no parser combinator):
4//!
5//! ```text
6//! expr       := or_expr
7//! or_expr    := and_expr ('or' and_expr)*
8//! and_expr   := not_expr ('and' not_expr)*
9//! not_expr   := ['not'] cmp_expr
10//! cmp_expr   := primary [cmp_op primary]
11//! cmp_op     := '==' | '!=' | '<' | '<=' | '>' | '>=' | 'in' | 'matches'
12//! primary    := literal | ident_or_call | '(' expr ')'
13//! literal    := STRING | INT | BOOL | 'null' | list
14//! list       := '[' [expr (',' expr)*] ']'
15//! ident_or_call := NS '.' NAME ['(' [expr (',' expr)*] ')']
16//! NS         := 'facts' | 'vars' | 'iter' | 'env'
17//! ```
18//!
19//! Design choices (all load-bearing):
20//!
21//! - **No arithmetic.** Only comparison.
22//! - **Function calls limited to a fixed set on the `iter` namespace.**
23//!   `iter.has_file("Cargo.toml")` is supported; arbitrary user-defined
24//!   calls are not. Use declared `facts:` for repo-level computation.
25//! - **`iter.*` is only meaningful in iteration contexts** (per-iteration
26//!   `when_iter:` on `for_each_*`, and nested rules' `when:`). Outside
27//!   those, `iter.X` evaluates to `null` and `iter.has_file(_)` to `false`.
28//! - **`matches` RHS must be a string literal.** This lets us compile the
29//!   regex at parse time; dynamic patterns stay out of the hot path.
30//! - **Short-circuit `and` / `or`.** Unevaluated branches don't even touch
31//!   their subtree.
32//! - **Type coercion is explicit, not silent.** Comparing `Int` to `String`
33//!   is an error, not `false`.
34
35use std::collections::HashMap;
36use std::path::Path;
37
38use regex::Regex;
39use thiserror::Error;
40
41use crate::facts::{FactValue, FactValues};
42use crate::walker::FileIndex;
43
44// ─── Errors ──────────────────────────────────────────────────────────
45
46#[derive(Debug, Error)]
47pub enum WhenError {
48    #[error("when parse error at column {pos}: {message}")]
49    Parse { pos: usize, message: String },
50    #[error("when evaluation error: {0}")]
51    Eval(String),
52    #[error("invalid regex in `matches`: {0}")]
53    Regex(String),
54}
55
56// ─── Value (evaluation-time) ─────────────────────────────────────────
57
58#[derive(Debug, Clone)]
59pub enum Value {
60    Bool(bool),
61    Int(i64),
62    String(String),
63    List(Vec<Value>),
64    Null,
65}
66
67impl Value {
68    pub fn truthy(&self) -> bool {
69        match self {
70            Self::Bool(b) => *b,
71            Self::Int(n) => *n != 0,
72            Self::String(s) => !s.is_empty(),
73            Self::List(v) => !v.is_empty(),
74            Self::Null => false,
75        }
76    }
77
78    fn type_name(&self) -> &'static str {
79        match self {
80            Self::Bool(_) => "bool",
81            Self::Int(_) => "int",
82            Self::String(_) => "string",
83            Self::List(_) => "list",
84            Self::Null => "null",
85        }
86    }
87}
88
89impl From<&FactValue> for Value {
90    fn from(f: &FactValue) -> Self {
91        match f {
92            FactValue::Bool(b) => Self::Bool(*b),
93            FactValue::Int(n) => Self::Int(*n),
94            FactValue::String(s) => Self::String(s.clone()),
95        }
96    }
97}
98
99// ─── AST ─────────────────────────────────────────────────────────────
100
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum Namespace {
103    Facts,
104    Vars,
105    /// Per-iteration context. Available only when an `IterEnv`
106    /// is threaded into the evaluator (via
107    /// [`WhenEnv::with_iter`]). Outside those, `iter.X`
108    /// evaluates to `null` and `iter.has_file(_)` to `false` —
109    /// matching the "missing fact is falsy" rule.
110    Iter,
111    /// Environment variables. `env.CI`, `env.GITHUB_ACTIONS`, etc.
112    /// Resolved at evaluation time (env is constant during a run);
113    /// an unset variable evaluates to `null`, matching the
114    /// "missing fact is falsy" rule. Value-only — there are no
115    /// callable methods on `env`.
116    Env,
117}
118
119#[derive(Debug, Clone, Copy, PartialEq, Eq)]
120pub enum CmpOp {
121    Eq,
122    Ne,
123    Lt,
124    Le,
125    Gt,
126    Ge,
127    In,
128}
129
130#[derive(Debug, Clone)]
131pub enum WhenExpr {
132    Literal(Value),
133    Ident {
134        ns: Namespace,
135        name: String,
136    },
137    /// `<ns>.<method>(args...)`. Currently only the `iter`
138    /// namespace exposes callable methods; an unknown
139    /// (namespace, method) pair is rejected at parse time so
140    /// typos don't silently coerce to `null` like value-style
141    /// idents do.
142    Call {
143        ns: Namespace,
144        method: String,
145        args: Vec<WhenExpr>,
146    },
147    Not(Box<WhenExpr>),
148    And(Box<WhenExpr>, Box<WhenExpr>),
149    Or(Box<WhenExpr>, Box<WhenExpr>),
150    Cmp {
151        left: Box<WhenExpr>,
152        op: CmpOp,
153        right: Box<WhenExpr>,
154    },
155    /// `left matches <compiled regex>` — RHS is compiled at parse time.
156    Matches {
157        left: Box<WhenExpr>,
158        pattern: Regex,
159    },
160    List(Vec<WhenExpr>),
161}
162
163// ─── Evaluation environment ──────────────────────────────────────────
164
165#[derive(Debug)]
166pub struct WhenEnv<'a> {
167    pub facts: &'a FactValues,
168    pub vars: &'a HashMap<String, String>,
169    /// Per-iteration context, populated when this `WhenEnv`
170    /// gates an iterated rule (`for_each_dir` /
171    /// `for_each_file` / `every_matching_has`). `None` for
172    /// top-level rule gating, where `iter.*` references
173    /// resolve to falsy / null per the "unknown fact is
174    /// falsy" convention.
175    pub iter: Option<IterEnv<'a>>,
176    /// Optional environment-variable snapshot backing the `env.*`
177    /// namespace. `None` — the production default — means the
178    /// evaluator reads the live process environment via
179    /// `std::env::var` (env is constant during a run, so the
180    /// eval-time read matches a load-time snapshot). Tests inject
181    /// a fake map via [`WhenEnv::with_env`] so they never touch
182    /// the real environment (Rust 2024 marks `set_var` unsafe).
183    pub env: Option<&'a HashMap<String, String>>,
184}
185
186impl<'a> WhenEnv<'a> {
187    /// Construct a `WhenEnv` without iteration context — the
188    /// shape every existing call site uses. `iter.*` references
189    /// in the expression resolve to null / false; `env.*` reads
190    /// the live process environment.
191    #[must_use]
192    pub fn new(facts: &'a FactValues, vars: &'a HashMap<String, String>) -> Self {
193        Self {
194            facts,
195            vars,
196            iter: None,
197            env: None,
198        }
199    }
200
201    /// Attach an iteration context. The same `WhenEnv` shape can
202    /// then evaluate `iter.path`, `iter.basename`, and
203    /// `iter.has_file(...)` against the supplied path + index.
204    #[must_use]
205    pub fn with_iter(mut self, iter: IterEnv<'a>) -> Self {
206        self.iter = Some(iter);
207        self
208    }
209
210    /// Back the `env.*` namespace with an explicit map instead of
211    /// the live process environment. Used by tests to resolve
212    /// `env.X` hermetically.
213    #[must_use]
214    pub fn with_env(mut self, env: &'a HashMap<String, String>) -> Self {
215        self.env = Some(env);
216        self
217    }
218}
219
220/// Iteration context exposed to `when:` expressions through the
221/// `iter.*` namespace. Built once per iterated entry by
222/// `for_each_*` rules and threaded into both the outer
223/// `when_iter:` filter and any nested rule's `when:`.
224#[derive(Debug, Clone, Copy)]
225pub struct IterEnv<'a> {
226    /// Relative path of the iterated entry (as walker reported).
227    pub path: &'a Path,
228    /// Whether the iterated entry is a directory. `iter.has_file`
229    /// only does meaningful work when this is `true`; for files
230    /// it returns `false`.
231    pub is_dir: bool,
232    /// File index, used by `iter.has_file(pattern)` to look up
233    /// children of the iterated path.
234    pub index: &'a FileIndex,
235}
236
237// ─── Public entry points ─────────────────────────────────────────────
238
239pub fn parse(src: &str) -> Result<WhenExpr, WhenError> {
240    parse_inner(src).map_err(|e| enrich_diagnostic(src, e))
241}
242
243fn parse_inner(src: &str) -> Result<WhenExpr, WhenError> {
244    let tokens = lex(src)?;
245    let mut p = Parser::new(tokens);
246    let expr = p.parse_expr()?;
247    p.expect_eof()?;
248    Ok(expr)
249}
250
251/// Enrich a [`WhenError::Parse`] with domain-specific hints for the
252/// pitfalls catalogued in `docs/development/CONFIG-AUTHORING.md` § 12:
253///
254/// - **#12a** — `&&` / `||` / `!` symbols → suggest `and` / `or` / `not`.
255/// - **#12b** — `iter.foo.bar(` method-call shapes → suggest the
256///   `matches` operator or the bounded iter accessor set.
257///
258/// Only applies to `WhenError::Parse`; evaluation errors pass through
259/// unchanged. The original message is preserved; hints are appended on
260/// new lines so callers that just `Display` the error still get the
261/// position info.
262fn enrich_diagnostic(src: &str, err: WhenError) -> WhenError {
263    let WhenError::Parse { pos, message } = err else {
264        // Eval / Regex errors don't have positional context to
265        // diagnose; pass them through unchanged.
266        return err;
267    };
268    let hint = symbol_keyword_hint(src, pos).or_else(|| method_call_hint(src, pos));
269    match hint {
270        Some(h) => WhenError::Parse {
271            pos,
272            message: format!("{message}\n  hint: {h}"),
273        },
274        None => WhenError::Parse { pos, message },
275    }
276}
277
278/// Detect `&&` / `||` / `!` near `pos` and return a keyword
279/// suggestion. Pitfall #12a.
280fn symbol_keyword_hint(src: &str, pos: usize) -> Option<&'static str> {
281    let bytes = src.as_bytes();
282    let at = bytes.get(pos).copied();
283    let next = bytes.get(pos + 1).copied();
284    let prev = pos.checked_sub(1).and_then(|p| bytes.get(p).copied());
285
286    let _ = next; // kept for future second-character refinement
287    match at {
288        Some(b'&') if prev != Some(b'&') => {
289            Some("`&&` is not a `when:` operator. Use the keyword `and` instead.")
290        }
291        Some(b'|') if prev != Some(b'|') => {
292            Some("`||` is not a `when:` operator. Use the keyword `or` instead.")
293        }
294        Some(b'!') => Some("`!` is not a `when:` operator. Use the keyword `not` instead."),
295        _ => None,
296    }
297}
298
299/// Detect `iter.foo.bar(` method-call shapes anywhere in `src`
300/// and return a hint. Pitfall #12b.
301///
302/// The `iter.*` accessors are a fixed set: `iter.path`,
303/// `iter.basename`, `iter.parent_name`, `iter.is_dir`,
304/// `iter.has_file(...)`. There are no string method calls; use the
305/// `matches` operator for regex matching.
306///
307/// We use a global regex rather than a position-relative check
308/// because the lexer's failure column for `iter.path.contains("foo")`
309/// is on the second `.`, not the open paren — the position alone
310/// doesn't carry enough context to infer the bad shape.
311fn method_call_hint(src: &str, _pos: usize) -> Option<&'static str> {
312    static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
313    let re = RE.get_or_init(|| {
314        // `iter.<ident>.<ident>(` — a double-dot chain off iter that
315        // ends in a function-call-shaped token. Catches
316        // `iter.path.contains(...)`, `iter.basename.starts_with(...)`,
317        // `iter.parent_name.ends_with(...)`, etc.
318        regex::Regex::new(r"\biter\.\w+\.\w+\s*\(").expect("static regex")
319    });
320    if re.is_match(src) {
321        return Some(
322            "`iter.*` accessors are a fixed set; method calls aren't supported. Use the `matches` \
323             operator for regex matching, e.g. `iter.path matches \"node_modules\"`. The supported \
324             accessors are documented in `docs/development/CONFIG-AUTHORING.md` § 12b.",
325        );
326    }
327    None
328}
329
330impl WhenExpr {
331    pub fn evaluate(&self, env: &WhenEnv<'_>) -> Result<bool, WhenError> {
332        let v = eval(self, env)?;
333        Ok(v.truthy())
334    }
335}
336
337mod eval;
338mod lexer;
339mod parser;
340
341use eval::eval;
342use lexer::lex;
343use parser::Parser;
344
345// ─── Tests ───────────────────────────────────────────────────────────
346
347#[cfg(test)]
348mod tests {
349    use super::*;
350
351    fn env() -> (FactValues, HashMap<String, String>) {
352        let mut f = FactValues::new();
353        f.insert("is_rust".into(), FactValue::Bool(true));
354        f.insert("is_node".into(), FactValue::Bool(false));
355        f.insert("n_files".into(), FactValue::Int(42));
356        f.insert("primary".into(), FactValue::String("Rust".into()));
357        let mut v = HashMap::new();
358        v.insert("org".into(), "Acme Corp".into());
359        v.insert("year".into(), "2026".into());
360        (f, v)
361    }
362
363    fn check(src: &str) -> bool {
364        let (facts, vars) = env();
365        let expr = parse(src).unwrap();
366        expr.evaluate(&WhenEnv {
367            facts: &facts,
368            vars: &vars,
369            iter: None,
370            env: None,
371        })
372        .unwrap()
373    }
374
375    #[test]
376    fn simple_facts() {
377        assert!(check("facts.is_rust"));
378        assert!(!check("facts.is_node"));
379        assert!(check("not facts.is_node"));
380    }
381
382    #[test]
383    fn integer_comparison() {
384        assert!(check("facts.n_files > 0"));
385        assert!(check("facts.n_files == 42"));
386        assert!(!check("facts.n_files < 10"));
387        assert!(check("facts.n_files >= 42"));
388    }
389
390    #[test]
391    fn string_equality() {
392        assert!(check("facts.primary == \"Rust\""));
393        assert!(!check("facts.primary == \"Go\""));
394    }
395
396    #[test]
397    fn logical_ops_short_circuit() {
398        assert!(check("facts.is_rust and facts.n_files > 0"));
399        assert!(check("facts.is_node or facts.is_rust"));
400        assert!(!check("facts.is_node and facts.nonexistent == 5"));
401    }
402
403    #[test]
404    fn in_list() {
405        assert!(check("facts.primary in [\"Rust\", \"Go\"]"));
406        assert!(!check("facts.primary in [\"Python\", \"Java\"]"));
407    }
408
409    #[test]
410    fn in_string_is_substring() {
411        assert!(check("\"cme\" in vars.org"));
412        assert!(!check("\"Xyz\" in vars.org"));
413    }
414
415    #[test]
416    fn matches_regex() {
417        assert!(check("vars.org matches \"^Acme\""));
418        assert!(check("vars.year matches \"^\\\\d{4}$\""));
419        assert!(!check("vars.org matches \"^Xyz\""));
420    }
421
422    #[test]
423    fn parentheses_override_precedence() {
424        assert!(check(
425            "(facts.is_node or facts.is_rust) and facts.n_files > 0"
426        ));
427        assert!(!check("facts.is_node or facts.is_rust and facts.is_node"));
428        // Precedence: and binds tighter than or, so this is
429        // `is_node or (is_rust and is_node)` == false or (true and false) == false.
430    }
431
432    #[test]
433    fn unknown_facts_are_null_and_falsy() {
434        assert!(!check("facts.nonexistent"));
435        assert!(check("not facts.nonexistent"));
436    }
437
438    #[test]
439    fn unknown_vars_are_null() {
440        assert!(!check("vars.not_set"));
441    }
442
443    #[test]
444    fn null_equals_null() {
445        assert!(check("facts.nonexistent == null"));
446    }
447
448    #[test]
449    fn parse_rejects_bare_equals() {
450        let e = parse("facts.x = 1").unwrap_err();
451        matches!(e, WhenError::Parse { .. });
452    }
453
454    #[test]
455    fn parse_rejects_bang_alone() {
456        let e = parse("!facts.x").unwrap_err();
457        matches!(e, WhenError::Parse { .. });
458    }
459
460    #[test]
461    fn parse_rejects_invalid_identifier_namespace() {
462        let e = parse("ctx.x").unwrap_err();
463        let WhenError::Parse { message, .. } = e else {
464            panic!();
465        };
466        assert!(message.contains("facts.NAME"));
467    }
468
469    #[test]
470    fn parse_rejects_matches_with_non_literal_rhs() {
471        let e = parse("vars.org matches vars.pattern").unwrap_err();
472        let WhenError::Parse { message, .. } = e else {
473            panic!();
474        };
475        assert!(message.contains("string literal"));
476    }
477
478    #[test]
479    fn parse_rejects_invalid_regex() {
480        let e = parse("vars.org matches \"[unclosed\"").unwrap_err();
481        matches!(e, WhenError::Regex(_));
482    }
483
484    #[test]
485    fn evaluate_rejects_ordering_mixed_types() {
486        let (facts, vars) = env();
487        let expr = parse("facts.primary > facts.n_files").unwrap();
488        let result = expr.evaluate(&WhenEnv {
489            facts: &facts,
490            vars: &vars,
491            iter: None,
492            env: None,
493        });
494        assert!(result.is_err());
495    }
496
497    #[test]
498    fn string_escapes() {
499        let (facts, vars) = env();
500        let expr = parse("vars.org == \"Acme Corp\"").unwrap();
501        assert!(
502            expr.evaluate(&WhenEnv {
503                facts: &facts,
504                vars: &vars,
505                iter: None,
506                env: None,
507            })
508            .unwrap()
509        );
510    }
511
512    #[test]
513    fn nested_not_and_or() {
514        assert!(check(
515            "not (facts.is_node or (facts.n_files == 0 and facts.is_rust))"
516        ));
517    }
518
519    // ─── env namespace ───────────────────────────────────────────
520
521    fn check_env(src: &str, vars_env: &[(&str, &str)]) -> bool {
522        let (facts, vars) = env();
523        let env_map: HashMap<String, String> = vars_env
524            .iter()
525            .map(|(k, v)| ((*k).to_owned(), (*v).to_owned()))
526            .collect();
527        let expr = parse(src).unwrap();
528        expr.evaluate(&WhenEnv::new(&facts, &vars).with_env(&env_map))
529            .unwrap()
530    }
531
532    #[test]
533    fn env_namespace_resolves_injected_value() {
534        assert!(check_env("env.CI == \"true\"", &[("CI", "true")]));
535        assert!(check_env(
536            "env.GITHUB_ACTIONS == \"true\" or env.CI == \"true\"",
537            &[("CI", "true")],
538        ));
539    }
540
541    #[test]
542    fn env_unset_var_is_null_and_falsy() {
543        assert!(!check_env("env.NOT_SET", &[]));
544        assert!(check_env("env.NOT_SET == null", &[]));
545        assert!(!check_env("env.NOT_SET == \"true\"", &[]));
546    }
547
548    #[test]
549    fn env_composes_with_facts_and_vars() {
550        assert!(check_env(
551            "facts.is_rust and env.CI == \"true\"",
552            &[("CI", "true")],
553        ));
554        assert!(!check_env(
555            "facts.is_node and env.CI == \"true\"",
556            &[("CI", "true")],
557        ));
558    }
559
560    #[test]
561    fn env_values_are_always_strings_compare_against_string_literals() {
562        // env vars resolve to `String`, never `Int`. Comparing to a
563        // bare integer literal is silently false (mixed-type `==`),
564        // so users must quote: `env.PORT == "8080"`, not `== 8080`.
565        assert!(check_env("env.PORT == \"8080\"", &[("PORT", "8080")]));
566        assert!(!check_env("env.PORT == 8080", &[("PORT", "8080")]));
567    }
568
569    #[test]
570    fn env_matches_and_in_operators() {
571        assert!(check_env(
572            "env.REF matches \"^refs/tags/\"",
573            &[("REF", "refs/tags/v1.0",)]
574        ));
575        assert!(check_env(
576            "\"prod\" in env.ENVIRONMENT",
577            &[("ENVIRONMENT", "prod-east",)]
578        ));
579    }
580
581    #[test]
582    fn env_parses_as_valid_namespace() {
583        // `env.X` parses cleanly (regression guard for the parser
584        // namespace dispatch); a bogus namespace still rejects with
585        // the updated allowed-list message.
586        assert!(parse("env.CI == \"true\"").is_ok());
587        let WhenError::Parse { message, .. } = parse("environ.CI").unwrap_err() else {
588            panic!("expected parse error");
589        };
590        assert!(message.contains("env.NAME"), "msg: {message}");
591    }
592
593    // ─── iter namespace ──────────────────────────────────────────
594
595    use crate::walker::{FileEntry, FileIndex};
596    use std::path::Path;
597
598    fn idx(paths: &[(&str, bool)]) -> FileIndex {
599        FileIndex::from_entries(
600            paths
601                .iter()
602                .map(|(p, is_dir)| FileEntry {
603                    path: Path::new(p).into(),
604                    is_dir: *is_dir,
605                    size: 1,
606                })
607                .collect(),
608        )
609    }
610
611    fn check_iter(src: &str, iter_path: &Path, is_dir: bool, index: &FileIndex) -> bool {
612        let (facts, vars) = env();
613        let expr = parse(src).unwrap();
614        expr.evaluate(&WhenEnv {
615            facts: &facts,
616            vars: &vars,
617            iter: Some(IterEnv {
618                path: iter_path,
619                is_dir,
620                index,
621            }),
622            env: None,
623        })
624        .unwrap()
625    }
626
627    #[test]
628    fn iter_namespace_parses_and_resolves_value_fields() {
629        let index = idx(&[("crates/alint-core", true)]);
630        assert!(check_iter(
631            "iter.path == \"crates/alint-core\"",
632            Path::new("crates/alint-core"),
633            true,
634            &index,
635        ));
636        assert!(check_iter(
637            "iter.basename == \"alint-core\"",
638            Path::new("crates/alint-core"),
639            true,
640            &index,
641        ));
642        assert!(check_iter(
643            "iter.parent_name == \"crates\"",
644            Path::new("crates/alint-core"),
645            true,
646            &index,
647        ));
648        assert!(check_iter(
649            "iter.is_dir",
650            Path::new("crates/alint-core"),
651            true,
652            &index,
653        ));
654    }
655
656    #[test]
657    fn iter_has_file_matches_literal_child() {
658        let index = idx(&[
659            ("crates/alint-core", true),
660            ("crates/alint-core/Cargo.toml", false),
661            ("crates/alint-core/src", true),
662            ("crates/alint-core/src/lib.rs", false),
663            ("crates/other", true),
664            ("crates/other/Cargo.toml", false),
665        ]);
666        assert!(check_iter(
667            "iter.has_file(\"Cargo.toml\")",
668            Path::new("crates/alint-core"),
669            true,
670            &index,
671        ));
672        assert!(!check_iter(
673            "iter.has_file(\"package.json\")",
674            Path::new("crates/alint-core"),
675            true,
676            &index,
677        ));
678    }
679
680    #[test]
681    fn iter_has_file_supports_recursive_glob() {
682        let index = idx(&[
683            ("pkg", true),
684            ("pkg/src", true),
685            ("pkg/src/main.rs", false),
686            ("pkg/src/inner", true),
687            ("pkg/src/inner/lib.rs", false),
688        ]);
689        assert!(check_iter(
690            "iter.has_file(\"**/*.rs\")",
691            Path::new("pkg"),
692            true,
693            &index,
694        ));
695        assert!(!check_iter(
696            "iter.has_file(\"**/*.py\")",
697            Path::new("pkg"),
698            true,
699            &index,
700        ));
701    }
702
703    #[test]
704    fn iter_has_file_returns_false_for_file_iteration() {
705        let index = idx(&[("a.rs", false)]);
706        assert!(!check_iter(
707            "iter.has_file(\"x\")",
708            Path::new("a.rs"),
709            false,
710            &index,
711        ));
712    }
713
714    #[test]
715    fn iter_references_outside_iter_context_are_falsy() {
716        // Outside an iteration, `iter.X` resolves to null and
717        // `iter.has_file(...)` to false — same "missing fact"
718        // convention that `facts.unknown` already follows.
719        assert!(!check("iter.path"));
720        assert!(check("iter.path == null"));
721        assert!(!check("iter.has_file(\"X\")"));
722    }
723
724    #[test]
725    fn iter_has_file_can_compose_with_boolean_logic() {
726        let index = idx(&[("pkg", true), ("pkg/Cargo.toml", false), ("other", true)]);
727        assert!(check_iter(
728            "iter.has_file(\"Cargo.toml\") and iter.is_dir",
729            Path::new("pkg"),
730            true,
731            &index,
732        ));
733        assert!(!check_iter(
734            "iter.has_file(\"BUILD\") or iter.has_file(\"BUILD.bazel\")",
735            Path::new("pkg"),
736            true,
737            &index,
738        ));
739    }
740
741    #[test]
742    fn parse_rejects_call_on_non_iter_namespace() {
743        let e = parse("facts.something(\"x\")").unwrap_err();
744        let WhenError::Parse { message, .. } = e else {
745            panic!("expected parse error, got {e:?}");
746        };
747        assert!(
748            message.contains("only available on `iter`"),
749            "msg: {message}"
750        );
751    }
752
753    #[test]
754    fn parse_rejects_unknown_iter_method() {
755        let e = parse("iter.bogus(\"x\")").unwrap_err();
756        let WhenError::Parse { message, .. } = e else {
757            panic!("expected parse error, got {e:?}");
758        };
759        assert!(message.contains("unknown iter method"), "msg: {message}");
760    }
761
762    #[test]
763    fn evaluate_rejects_has_file_with_non_string_arg() {
764        let (facts, vars) = env();
765        let index = FileIndex::default();
766        let expr = parse("iter.has_file(42)").unwrap();
767        let err = expr
768            .evaluate(&WhenEnv {
769                facts: &facts,
770                vars: &vars,
771                iter: Some(IterEnv {
772                    path: Path::new("p"),
773                    is_dir: true,
774                    index: &index,
775                }),
776                env: None,
777            })
778            .unwrap_err();
779        let WhenError::Eval(msg) = err else {
780            panic!("expected eval error");
781        };
782        assert!(msg.contains("must be a string"), "msg: {msg}");
783    }
784}