Skip to main content

task_runs/
rule_filter.rs

1//! Parsed forms of `task-events:` and `events:` gnome rule check expressions.
2//!
3//! A rule TOML file uses the `check` field to declare what event query must
4//! return *zero* results for the rule to be satisfied:
5//!
6//! ```toml
7//! check = "task-events:cargo::rustc error.code=E0308"
8//! check = "events:scope=service(noisetable-api.pdx) level=error"
9//! ```
10//!
11//! [`TaskEventsRuleFilter`] is the original parsed form for `task-events:`.
12//! [`EventsRuleFilter`] is the generalized form for both `events:` and
13//! `task-events:` (which desugars to `events:` with `scope: ScopeSpec::CurrentTaskRun`).
14//!
15//! @arch:see(.yah/docs/architecture/A036-yah-gnomes.md)
16//! @arch:see(.yah/docs/architecture/A049-yah-scryer.md)
17//! @arch:see(.yah/docs/working/yah-task-runs.md)
18
19use crate::types::Level;
20
21// ─── FieldPredicate ───────────────────────────────────────────────────────────
22
23/// One field-level equality predicate within a `task-events:` check.
24///
25/// `path` is a dot-separated key into `Event.fields` (e.g. `"error.code"`).
26/// Evaluators prepend `$.` when translating to a JSONPath filter.
27#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
28pub struct FieldPredicate {
29    /// Dot-separated field path, e.g. `"error.code"`, `"file.path"`.
30    pub path: String,
31    /// Expected value — matched for equality. String, number, or bool.
32    pub value: serde_json::Value,
33}
34
35// ─── TaskEventsRuleFilter ─────────────────────────────────────────────────────
36
37/// Parsed predicate from a `task-events:<expr>` rule `check` field.
38///
39/// Semantics: after the verify [`TaskRun`][crate::types::TaskRunMeta] completes,
40/// call `task.events` with these params. **Any match means the rule is violated.**
41///
42/// ## Grammar
43///
44/// ```text
45/// expr       := [target] (WS field-pred)*
46/// target     := word-without-'='          e.g. "cargo::rustc"
47/// field-pred := key '=' scalar-value
48/// key        := "level" | "min_level"     special: parsed as Level
49///             | dotted-path               e.g. "error.code", "file.path"
50/// scalar     := bare-number | "true" | "false" | bare-string
51/// ```
52///
53/// ## Examples
54///
55/// ```text
56/// "task-events:cargo::rustc error.code=E0308"
57///   → target: cargo::rustc, field: error.code == "E0308"
58///
59/// "task-events:clippy::warning level=error"
60///   → target: clippy::warning, min_level: Error
61///
62/// "task-events:cargo::rustc"
63///   → target: cargo::rustc, any level, any field
64/// ```
65#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
66pub struct TaskEventsRuleFilter {
67    /// Event target prefix, e.g. `"cargo::rustc"` or `"clippy::warning"`.
68    ///
69    /// When set, the evaluator passes this as the `target` param to
70    /// `task.events`, which does a prefix match. `None` means any target.
71    pub target: Option<String>,
72    /// Minimum severity to include. `None` defaults to `warn` at the
73    /// call site so rule checks don't fire on debug/trace noise.
74    pub min_level: Option<Level>,
75    /// Field equality predicates. The evaluator ANDs them: all must match.
76    pub field_filters: Vec<FieldPredicate>,
77}
78
79impl FieldPredicate {
80    /// Returns `true` when this predicate matches the given event `fields` JSON object.
81    ///
82    /// The path is traversed as dot-separated keys (e.g. `"error.code"` →
83    /// `fields["error"]["code"]`). A missing key or a type mismatch returns `false`.
84    pub fn matches(&self, fields: &serde_json::Value) -> bool {
85        let mut current = fields;
86        for key in self.path.split('.') {
87            match current.get(key) {
88                Some(v) => current = v,
89                None => return false,
90            }
91        }
92        current == &self.value
93    }
94}
95
96impl TaskEventsRuleFilter {
97    /// Returns `true` when **all** field predicates match the given event `fields`
98    /// JSON object. An empty `field_filters` list always returns `true`.
99    ///
100    /// The gnome verify pass calls this after fetching events via `task.events`
101    /// (which already pre-filters by `target` and `min_level`). The combined
102    /// protocol is:
103    ///
104    /// 1. Call `task.events(run_id, target=filter.target, min_level=...)`.
105    /// 2. For each returned event, call `filter.matches_fields(&event.fields)`.
106    /// 3. If any event passes → **rule violated**; if none pass → satisfied.
107    ///
108    /// This client-side pass is necessary because `task.events` only supports
109    /// a single `jsonpath` server-side filter; AND-ing multiple field predicates
110    /// is done here.
111    pub fn matches_fields(&self, fields: &serde_json::Value) -> bool {
112        self.field_filters.iter().all(|fp| fp.matches(fields))
113    }
114
115    /// Parse the part of a rule check string after stripping `"task-events:"`.
116    ///
117    /// Returns `Err` if any token fails to parse (e.g. malformed `key=value`).
118    pub fn parse(expr: &str) -> Result<Self, ParseError> {
119        let mut tokens = expr.split_ascii_whitespace().peekable();
120        let mut target: Option<String> = None;
121        let mut min_level: Option<Level> = None;
122        let mut field_filters: Vec<FieldPredicate> = Vec::new();
123
124        // First token: if it doesn't contain '=' it's the target.
125        if let Some(first) = tokens.peek() {
126            if !first.contains('=') {
127                target = Some(tokens.next().unwrap().to_owned());
128            }
129        }
130
131        for token in tokens {
132            let (key, val_str) = token.split_once('=').ok_or_else(|| {
133                ParseError(format!("expected key=value, got {token:?}"))
134            })?;
135            match key {
136                "level" | "min_level" => {
137                    min_level = Some(parse_level(val_str)?);
138                }
139                "" => return Err(ParseError("empty key before '='".into())),
140                _ => {
141                    field_filters.push(FieldPredicate {
142                        path: key.to_owned(),
143                        value: parse_scalar(val_str),
144                    });
145                }
146            }
147        }
148
149        Ok(Self { target, min_level, field_filters })
150    }
151
152    /// Parse a full `task-events:<expr>` string (including the prefix).
153    pub fn parse_check_field(check: &str) -> Result<Self, ParseError> {
154        let inner = check.strip_prefix("task-events:").ok_or_else(|| {
155            ParseError(format!("check field does not start with 'task-events:': {check:?}"))
156        })?;
157        Self::parse(inner)
158    }
159}
160
161// ─── ScopeSpec ────────────────────────────────────────────────────────────────
162
163/// Which event store to query when evaluating an `events:` rule check.
164///
165/// `CurrentTaskRun` is the default: the verify pass queries events from the
166/// task run that was just executed (same semantics as `task-events:`).
167/// `Service(ident)` queries scryer for long-running service emissions keyed
168/// by the given mesh identity string (e.g. `"noisetable-api.pdx"`).
169#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum ScopeSpec {
172    /// Default: use the current verify TaskRun (populated at evaluate time).
173    /// Equivalent to `task-events:` semantics.
174    CurrentTaskRun,
175    /// Named service: query scryer's service-scope events for this mesh ident.
176    Service(String),
177}
178
179impl Default for ScopeSpec {
180    fn default() -> Self {
181        ScopeSpec::CurrentTaskRun
182    }
183}
184
185// ─── EventsRuleFilter ─────────────────────────────────────────────────────────
186
187/// Parsed predicate from an `events:<expr>` (or `task-events:<expr>`) rule check.
188///
189/// Extends [`TaskEventsRuleFilter`] with an optional [`ScopeSpec`].
190/// `task-events:` is a permanent alias that desugars to
191/// `scope: ScopeSpec::CurrentTaskRun` — identical semantics, backward-compatible.
192///
193/// ## Grammar
194///
195/// ```text
196/// check      := ("events:" | "task-events:") expr
197/// expr       := [scope-clause] [target] (WS field-pred)*
198/// scope-clause := "scope=taskrun(" uuid ")"
199///              |  "scope=service(" mesh-ident ")"
200/// target     := word-without-'='           e.g. "cargo::rustc"
201/// field-pred := key '=' scalar-value
202/// key        := "level" | "min_level"      special: parsed as Level
203///             | dotted-path                e.g. "error.code", "file.path"
204/// ```
205///
206/// `scope=taskrun(...)` desugars to `CurrentTaskRun` (the explicit UUID is
207/// filled in at evaluate time — static rule files never contain literal run IDs).
208///
209/// ## Examples
210///
211/// ```text
212/// "events:cargo::rustc level=error"
213///   → scope: CurrentTaskRun, target: cargo::rustc, min_level: Error
214///
215/// "events:scope=service(noisetable-api.pdx) level=error"
216///   → scope: Service("noisetable-api.pdx"), min_level: Error
217///
218/// "task-events:cargo::rustc error.code=E0308"
219///   → scope: CurrentTaskRun (alias), target: cargo::rustc, field: error.code=="E0308"
220/// ```
221#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
222pub struct EventsRuleFilter {
223    /// Which store to query. Default is `CurrentTaskRun`.
224    #[serde(default)]
225    pub scope: ScopeSpec,
226    /// Event target prefix, e.g. `"cargo::rustc"`. `None` means any target.
227    pub target: Option<String>,
228    /// Minimum severity. `None` defaults to `warn` at the call site.
229    pub min_level: Option<Level>,
230    /// Field equality predicates — all must match (AND-ed).
231    pub field_filters: Vec<FieldPredicate>,
232}
233
234impl EventsRuleFilter {
235    /// Returns `true` when all field predicates match the given event `fields` object.
236    pub fn matches_fields(&self, fields: &serde_json::Value) -> bool {
237        self.field_filters.iter().all(|fp| fp.matches(fields))
238    }
239
240    /// Parse both `events:<expr>` and `task-events:<expr>` check strings.
241    ///
242    /// `task-events:` is a permanent alias for `events:` with `scope: CurrentTaskRun`.
243    pub fn parse_check_field(check: &str) -> Result<Self, ParseError> {
244        let inner = if let Some(rest) = check.strip_prefix("events:") {
245            rest
246        } else if let Some(rest) = check.strip_prefix("task-events:") {
247            rest
248        } else {
249            return Err(ParseError(format!(
250                "check field does not start with 'events:' or 'task-events:': {check:?}"
251            )));
252        };
253        Self::parse(inner)
254    }
255
256    fn parse(expr: &str) -> Result<Self, ParseError> {
257        let mut tokens = expr.split_ascii_whitespace().peekable();
258        let mut scope = ScopeSpec::CurrentTaskRun;
259        let mut target: Option<String> = None;
260        let mut min_level: Option<Level> = None;
261        let mut field_filters: Vec<FieldPredicate> = Vec::new();
262
263        // First token: scope= clause OR bare target (word without '=').
264        if let Some(&first) = tokens.peek() {
265            if let Some(rest) = first.strip_prefix("scope=") {
266                tokens.next();
267                scope = parse_scope_spec(rest)?;
268                // After scope clause, optional bare target follows.
269                if let Some(&next) = tokens.peek() {
270                    if !next.contains('=') {
271                        target = Some(tokens.next().unwrap().to_owned());
272                    }
273                }
274            } else if !first.contains('=') {
275                target = Some(tokens.next().unwrap().to_owned());
276            }
277        }
278
279        for token in tokens {
280            let (key, val_str) = token.split_once('=').ok_or_else(|| {
281                ParseError(format!("expected key=value, got {token:?}"))
282            })?;
283            match key {
284                "level" | "min_level" => {
285                    min_level = Some(parse_level(val_str)?);
286                }
287                "" => return Err(ParseError("empty key before '='".into())),
288                _ => {
289                    field_filters.push(FieldPredicate {
290                        path: key.to_owned(),
291                        value: parse_scalar(val_str),
292                    });
293                }
294            }
295        }
296
297        Ok(Self { scope, target, min_level, field_filters })
298    }
299}
300
301fn parse_scope_spec(s: &str) -> Result<ScopeSpec, ParseError> {
302    if let Some(inner) = s.strip_prefix("taskrun(").and_then(|s| s.strip_suffix(')')) {
303        // scope=taskrun(<uuid>) — the explicit UUID is evaluated at verify time.
304        // In static rule TOML files this always desugars to CurrentTaskRun.
305        let _ = inner;
306        Ok(ScopeSpec::CurrentTaskRun)
307    } else if let Some(inner) = s.strip_prefix("service(").and_then(|s| s.strip_suffix(')')) {
308        if inner.is_empty() {
309            return Err(ParseError("scope=service() requires a mesh ident".into()));
310        }
311        Ok(ScopeSpec::Service(inner.to_owned()))
312    } else {
313        Err(ParseError(format!(
314            "unknown scope: {s:?}; expected taskrun(<uuid>) or service(<mesh-ident>)"
315        )))
316    }
317}
318
319// ─── ParseError ───────────────────────────────────────────────────────────────
320
321#[derive(Debug, Clone, PartialEq)]
322pub struct ParseError(pub String);
323
324impl std::fmt::Display for ParseError {
325    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
326        f.write_str(&self.0)
327    }
328}
329impl std::error::Error for ParseError {}
330
331// ─── helpers ─────────────────────────────────────────────────────────────────
332
333fn parse_level(s: &str) -> Result<Level, ParseError> {
334    match s.to_ascii_lowercase().as_str() {
335        "trace" => Ok(Level::Trace),
336        "debug" => Ok(Level::Debug),
337        "info" => Ok(Level::Info),
338        "warn" | "warning" => Ok(Level::Warn),
339        "error" => Ok(Level::Error),
340        "fatal" => Ok(Level::Fatal),
341        other => Err(ParseError(format!("unknown level {other:?}"))),
342    }
343}
344
345/// Parse a scalar value: bool → bool, integer → i64, float → f64, else String.
346fn parse_scalar(s: &str) -> serde_json::Value {
347    if s == "true" { return serde_json::Value::Bool(true); }
348    if s == "false" { return serde_json::Value::Bool(false); }
349    if let Ok(n) = s.parse::<i64>() { return serde_json::json!(n); }
350    if let Ok(n) = s.parse::<f64>() { return serde_json::json!(n); }
351    serde_json::Value::String(s.to_owned())
352}
353
354// ─── Tests ────────────────────────────────────────────────────────────────────
355
356#[cfg(test)]
357mod tests {
358    use super::*;
359
360    #[test]
361    fn parse_target_only() {
362        let f = TaskEventsRuleFilter::parse("cargo::rustc").unwrap();
363        assert_eq!(f.target.as_deref(), Some("cargo::rustc"));
364        assert!(f.min_level.is_none());
365        assert!(f.field_filters.is_empty());
366    }
367
368    #[test]
369    fn parse_target_and_field() {
370        let f = TaskEventsRuleFilter::parse("cargo::rustc error.code=E0308").unwrap();
371        assert_eq!(f.target.as_deref(), Some("cargo::rustc"));
372        assert!(f.min_level.is_none());
373        assert_eq!(f.field_filters.len(), 1);
374        assert_eq!(f.field_filters[0].path, "error.code");
375        assert_eq!(f.field_filters[0].value, serde_json::Value::String("E0308".into()));
376    }
377
378    #[test]
379    fn parse_level_key() {
380        let f = TaskEventsRuleFilter::parse("clippy::warning level=error").unwrap();
381        assert_eq!(f.target.as_deref(), Some("clippy::warning"));
382        assert_eq!(f.min_level, Some(Level::Error));
383        assert!(f.field_filters.is_empty());
384    }
385
386    #[test]
387    fn parse_min_level_alias() {
388        let f = TaskEventsRuleFilter::parse("min_level=warn").unwrap();
389        assert!(f.target.is_none());
390        assert_eq!(f.min_level, Some(Level::Warn));
391    }
392
393    #[test]
394    fn parse_multiple_field_filters() {
395        let f = TaskEventsRuleFilter::parse(
396            "cargo::rustc error.code=E0308 file.path=src/lib.rs"
397        ).unwrap();
398        assert_eq!(f.field_filters.len(), 2);
399        assert_eq!(f.field_filters[0].path, "error.code");
400        assert_eq!(f.field_filters[1].path, "file.path");
401        assert_eq!(
402            f.field_filters[1].value,
403            serde_json::Value::String("src/lib.rs".into())
404        );
405    }
406
407    #[test]
408    fn parse_boolean_value() {
409        let f = TaskEventsRuleFilter::parse("build.success=false").unwrap();
410        assert!(f.target.is_none());
411        assert_eq!(f.field_filters[0].value, serde_json::Value::Bool(false));
412    }
413
414    #[test]
415    fn parse_numeric_value() {
416        let f = TaskEventsRuleFilter::parse("cargo::rustc file.line=42").unwrap();
417        assert_eq!(f.field_filters[0].value, serde_json::json!(42i64));
418    }
419
420    #[test]
421    fn parse_empty_is_unconstrained() {
422        let f = TaskEventsRuleFilter::parse("").unwrap();
423        assert!(f.target.is_none());
424        assert!(f.min_level.is_none());
425        assert!(f.field_filters.is_empty());
426    }
427
428    #[test]
429    fn parse_check_field_prefix() {
430        let f = TaskEventsRuleFilter::parse_check_field(
431            "task-events:cargo::rustc error.code=E0308"
432        ).unwrap();
433        assert_eq!(f.target.as_deref(), Some("cargo::rustc"));
434    }
435
436    #[test]
437    fn parse_check_field_wrong_prefix() {
438        assert!(TaskEventsRuleFilter::parse_check_field("ast:foo").is_err());
439    }
440
441    #[test]
442    fn parse_missing_eq_returns_err() {
443        assert!(TaskEventsRuleFilter::parse("cargo::rustc noequalssign").is_err());
444    }
445
446    #[test]
447    fn parse_level_warning_alias() {
448        let f = TaskEventsRuleFilter::parse("level=warning").unwrap();
449        assert_eq!(f.min_level, Some(Level::Warn));
450    }
451
452    #[test]
453    fn parse_unknown_level_returns_err() {
454        assert!(TaskEventsRuleFilter::parse("level=critical").is_err());
455    }
456
457    // ─── matches_fields ───────────────────────────────────────────────────────
458
459    #[test]
460    fn field_predicate_matches_present_key() {
461        let fp = FieldPredicate {
462            path: "error.code".into(),
463            value: serde_json::Value::String("E0308".into()),
464        };
465        let fields = serde_json::json!({"error": {"code": "E0308"}});
466        assert!(fp.matches(&fields));
467    }
468
469    #[test]
470    fn field_predicate_rejects_wrong_value() {
471        let fp = FieldPredicate {
472            path: "error.code".into(),
473            value: serde_json::Value::String("E0308".into()),
474        };
475        let fields = serde_json::json!({"error": {"code": "E0309"}});
476        assert!(!fp.matches(&fields));
477    }
478
479    #[test]
480    fn field_predicate_rejects_missing_key() {
481        let fp = FieldPredicate {
482            path: "error.code".into(),
483            value: serde_json::Value::String("E0308".into()),
484        };
485        let fields = serde_json::json!({"error": {}});
486        assert!(!fp.matches(&fields));
487    }
488
489    #[test]
490    fn matches_fields_empty_predicates_always_true() {
491        let f = TaskEventsRuleFilter { target: None, min_level: None, field_filters: vec![] };
492        assert!(f.matches_fields(&serde_json::json!({})));
493    }
494
495    #[test]
496    fn matches_fields_all_must_match() {
497        let f = TaskEventsRuleFilter::parse(
498            "cargo::rustc error.code=E0308 file.path=src/lib.rs"
499        ).unwrap();
500        // Both match
501        let ok = serde_json::json!({"error": {"code": "E0308"}, "file": {"path": "src/lib.rs"}});
502        assert!(f.matches_fields(&ok));
503        // One missing
504        let bad = serde_json::json!({"error": {"code": "E0308"}});
505        assert!(!f.matches_fields(&bad));
506    }
507
508    #[test]
509    fn matches_fields_boolean_predicate() {
510        let f = TaskEventsRuleFilter::parse("build.success=false").unwrap();
511        assert!(f.matches_fields(&serde_json::json!({"build": {"success": false}})));
512        assert!(!f.matches_fields(&serde_json::json!({"build": {"success": true}})));
513    }
514
515    // ─── EventsRuleFilter ─────────────────────────────────────────────────────
516
517    #[test]
518    fn events_filter_default_scope_is_current_taskrun() {
519        let f = EventsRuleFilter::parse_check_field("events:cargo::rustc level=error").unwrap();
520        assert_eq!(f.scope, ScopeSpec::CurrentTaskRun);
521        assert_eq!(f.target.as_deref(), Some("cargo::rustc"));
522        assert_eq!(f.min_level, Some(Level::Error));
523    }
524
525    #[test]
526    fn events_filter_task_events_alias() {
527        // task-events: desugars to CurrentTaskRun scope — identical semantics.
528        let alias = EventsRuleFilter::parse_check_field("task-events:cargo::rustc level=error").unwrap();
529        let canonical = EventsRuleFilter::parse_check_field("events:cargo::rustc level=error").unwrap();
530        assert_eq!(alias, canonical);
531    }
532
533    #[test]
534    fn events_filter_service_scope() {
535        let f = EventsRuleFilter::parse_check_field(
536            "events:scope=service(noisetable-api.pdx) level=error"
537        ).unwrap();
538        assert_eq!(f.scope, ScopeSpec::Service("noisetable-api.pdx".into()));
539        assert!(f.target.is_none());
540        assert_eq!(f.min_level, Some(Level::Error));
541    }
542
543    #[test]
544    fn events_filter_service_scope_with_target_and_fields() {
545        let f = EventsRuleFilter::parse_check_field(
546            "events:scope=service(api.prod) cargo::rustc error.code=E0308"
547        ).unwrap();
548        assert_eq!(f.scope, ScopeSpec::Service("api.prod".into()));
549        assert_eq!(f.target.as_deref(), Some("cargo::rustc"));
550        assert_eq!(f.field_filters.len(), 1);
551        assert_eq!(f.field_filters[0].path, "error.code");
552    }
553
554    #[test]
555    fn events_filter_taskrun_scope_desugars_to_current() {
556        // scope=taskrun(...) always desugars to CurrentTaskRun in static rules.
557        let f = EventsRuleFilter::parse_check_field(
558            "events:scope=taskrun(00000000-0000-0000-0000-000000000000)"
559        ).unwrap();
560        assert_eq!(f.scope, ScopeSpec::CurrentTaskRun);
561    }
562
563    #[test]
564    fn events_filter_service_empty_ident_is_err() {
565        assert!(EventsRuleFilter::parse_check_field("events:scope=service()").is_err());
566    }
567
568    #[test]
569    fn events_filter_unknown_scope_is_err() {
570        assert!(EventsRuleFilter::parse_check_field("events:scope=forge(abc)").is_err());
571    }
572
573    #[test]
574    fn events_filter_wrong_prefix_is_err() {
575        assert!(EventsRuleFilter::parse_check_field("ast:foo").is_err());
576    }
577
578    #[test]
579    fn events_filter_no_scope_no_target() {
580        let f = EventsRuleFilter::parse_check_field("events:").unwrap();
581        assert_eq!(f.scope, ScopeSpec::CurrentTaskRun);
582        assert!(f.target.is_none());
583        assert!(f.min_level.is_none());
584        assert!(f.field_filters.is_empty());
585    }
586
587    #[test]
588    fn events_filter_matches_fields_service_scope() {
589        let f = EventsRuleFilter::parse_check_field(
590            "events:scope=service(api.prod) level=error"
591        ).unwrap();
592        // field_filters is empty; matches_fields always true for empty predicates.
593        assert!(f.matches_fields(&serde_json::json!({})));
594    }
595}