1use crate::types::Level;
20
21#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
28pub struct FieldPredicate {
29 pub path: String,
31 pub value: serde_json::Value,
33}
34
35#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
66pub struct TaskEventsRuleFilter {
67 pub target: Option<String>,
72 pub min_level: Option<Level>,
75 pub field_filters: Vec<FieldPredicate>,
77}
78
79impl FieldPredicate {
80 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 pub fn matches_fields(&self, fields: &serde_json::Value) -> bool {
112 self.field_filters.iter().all(|fp| fp.matches(fields))
113 }
114
115 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 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 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
170#[serde(rename_all = "snake_case")]
171pub enum ScopeSpec {
172 CurrentTaskRun,
175 Service(String),
177}
178
179impl Default for ScopeSpec {
180 fn default() -> Self {
181 ScopeSpec::CurrentTaskRun
182 }
183}
184
185#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
222pub struct EventsRuleFilter {
223 #[serde(default)]
225 pub scope: ScopeSpec,
226 pub target: Option<String>,
228 pub min_level: Option<Level>,
230 pub field_filters: Vec<FieldPredicate>,
232}
233
234impl EventsRuleFilter {
235 pub fn matches_fields(&self, fields: &serde_json::Value) -> bool {
237 self.field_filters.iter().all(|fp| fp.matches(fields))
238 }
239
240 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 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 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 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#[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
331fn 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
345fn 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#[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 #[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 let ok = serde_json::json!({"error": {"code": "E0308"}, "file": {"path": "src/lib.rs"}});
502 assert!(f.matches_fields(&ok));
503 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 #[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 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 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 assert!(f.matches_fields(&serde_json::json!({})));
594 }
595}