1use crate::types::Level;
28
29#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
36pub struct FieldPredicate {
37 pub path: String,
39 pub value: serde_json::Value,
41}
42
43#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
74pub struct TaskEventsRuleFilter {
75 pub target: Option<String>,
80 pub min_level: Option<Level>,
83 pub field_filters: Vec<FieldPredicate>,
85}
86
87impl FieldPredicate {
88 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 pub fn matches_fields(&self, fields: &serde_json::Value) -> bool {
120 self.field_filters.iter().all(|fp| fp.matches(fields))
121 }
122
123 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 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 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
178#[serde(rename_all = "snake_case")]
179pub enum ScopeSpec {
180 CurrentTaskRun,
183 Service(String),
185}
186
187impl Default for ScopeSpec {
188 fn default() -> Self {
189 ScopeSpec::CurrentTaskRun
190 }
191}
192
193#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
230pub struct EventsRuleFilter {
231 #[serde(default)]
233 pub scope: ScopeSpec,
234 pub target: Option<String>,
236 pub min_level: Option<Level>,
238 pub field_filters: Vec<FieldPredicate>,
240}
241
242impl EventsRuleFilter {
243 pub fn matches_fields(&self, fields: &serde_json::Value) -> bool {
245 self.field_filters.iter().all(|fp| fp.matches(fields))
246 }
247
248 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 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 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 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#[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
339fn 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
353fn 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#[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 #[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 let ok = serde_json::json!({"error": {"code": "E0308"}, "file": {"path": "src/lib.rs"}});
510 assert!(f.matches_fields(&ok));
511 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 #[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 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 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 assert!(f.matches_fields(&serde_json::json!({})));
602 }
603}