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