diff --git a/README.md b/README.md index f545dc7..763ac6d 100644 --- a/README.md +++ b/README.md @@ -113,6 +113,34 @@ match = { body_contains = "URGENT" } action = { type = "http_post", url = "https://hooks.example.test/alerts", headers = { "content-type" = "application/json" }, body_template = "{\"content\":\"{{title}}: {{body}}\"}" } ``` +## Compound matching + +Flat `match` tables AND every entry. For OR or negation, compose conditions +with `all_of`, `any_of`, and `not` — arbitrarily nestable, with leaf keys +keeping exactly the semantics above: + +```toml +[[rites]] +name = "page-on-critical" +source = "iris" +match = { any_of = [{ severity = "critical" }, { body_contains = "URGENT" }] } +action = { type = "http_post", url = "https://hooks.example.test/pages" } + +[[rites]] +name = "real-prs-only" +source = "github" +match = { all_of = [{ event_type = "pull_request" }, { not = { draft = true } }] } +action = { type = "http_post", url = "https://hooks.example.test/reviews" } +``` + +`all_of` and `any_of` take arrays of condition tables and reject empty +arrays; `not` takes a single condition table. A table mixing operators with +other keys — or using two operators as siblings — is rejected at config load +with an actionable error. A scalar under an operator-named key (e.g. +`not = "draft"`) is an ordinary metadata lookup, exactly as before: the +operator only applies to table-shaped values, so existing configurations +never change meaning. + ## Development ```bash diff --git a/crates/rite-core/src/lib.rs b/crates/rite-core/src/lib.rs index a5badac..1807dbb 100644 --- a/crates/rite-core/src/lib.rs +++ b/crates/rite-core/src/lib.rs @@ -97,13 +97,153 @@ pub struct RiteHandler { /// Source ID this handler accepts. pub source: String, /// Fields to match against event attributes or metadata. - #[serde(rename = "match", default)] + /// + /// Malformed compound forms (empty `all_of`/`any_of`, a `not` without + /// exactly one child, operators mixed with leaves, or sibling + /// operators) are rejected at deserialization time, so every load + /// path — not only callers that also run the server's validate step — + /// fails loudly instead of installing a handler that can never match. + #[serde( + rename = "match", + default, + deserialize_with = "deserialize_validated_matcher" + )] pub matcher: BTreeMap, /// Action performed after a match. pub action: RiteAction, } +impl RiteHandler { + /// Convert the flat TOML matcher map into a match condition tree. + /// + /// Operator keys (`all_of`, `any_of`, `not`) are recognized only when + /// their TOML value is an inline table (compound form) or, for `not`, + /// exactly that; a scalar value under an operator-named key keeps its + /// legacy meaning as an ordinary metadata-lookup leaf, so existing + /// configurations never change semantics. + /// + /// # Errors + /// + /// Returns a [`RiteError::Config`] when the table mixes operators with + /// other keys, or when `all_of`/`any_of` is empty, `not` is not a + /// single-child table, or any nested operand repeats a violation. + pub fn condition_tree(&self) -> Result { + Self::tree_from_map(&self.matcher) + } + + fn tree_from_map(map: &BTreeMap) -> Result { + // Operator keys are only operators when their TOML shape matches the + // grammar: all_of/any_of take an array of condition tables, not takes + // a single condition table. A scalar under an operator-named key is a + // legacy metadata-lookup leaf and keeps today's semantics verbatim. + let operators: Vec<&str> = ["all_of", "any_of", "not"] + .into_iter() + .filter(|&op| map.get(op).is_some_and(|v| v.is_operator_shaped(op))) + .collect(); + if operators.is_empty() { + return Ok(MatchCondition::All( + map.iter() + .map(|(k, v)| MatchCondition::Leaf(k.clone(), v.clone())) + .collect(), + )); + } + if map.len() != operators.len() { + return Err(RiteError::Config(format!( + "match table mixes operators ({}) with ordinary leaf keys; move leaves inside the compound form", + operators.join(", ") + ))); + } + if operators.len() > 1 { + return Err(RiteError::Config(format!( + "match table uses multiple operators ({}) at the same level; nest them instead", + operators.join(", ") + ))); + } + match operators[0] { + "all_of" => Self::children_of(map, "all_of", false), + "any_of" => Self::children_of(map, "any_of", false), + _ => Self::children_of(map, "not", true), + } + } + + fn children_of( + map: &BTreeMap, + op: &str, + single: bool, + ) -> Result { + let value = &map[op]; + if single { + let MatchValue::Table(child) = value else { + return Err(RiteError::Config( + "match operator 'not' requires an inline table holding exactly one child condition" + .into(), + )); + }; + if child.is_empty() { + return Err(RiteError::Config( + "match operator 'not' requires exactly one child condition, found 0".into(), + )); + } + if child.len() > 1 + && !child + .keys() + .any(|k| Self::is_operator_key(k) && child[k].is_operator_shaped(k)) + { + return Err(RiteError::Config(format!( + "match operator 'not' requires exactly one child condition, found {}", + child.len() + ))); + } + return Ok(MatchCondition::Not(Box::new(Self::tree_from_map(child)?))); + } + let MatchValue::Array(children) = value else { + return Err(RiteError::Config(format!( + "match operator '{op}' requires an array of condition tables" + ))); + }; + if children.is_empty() { + return Err(RiteError::Config(format!( + "match operator '{op}' requires at least one child condition" + ))); + } + let parts = children + .iter() + .map(Self::tree_from_map) + .collect::>>()?; + Ok(if op == "all_of" { + MatchCondition::All(parts) + } else { + MatchCondition::Any(parts) + }) + } + + fn is_operator_key(key: &str) -> bool { + matches!(key, "all_of" | "any_of" | "not") + } +} + +/// Deserialize a handler's match table, rejecting malformed compound forms. +/// +/// This makes the load-time rejection guarantee structural: no code path +/// can obtain a `RiteHandler` whose compound table is malformed, instead of +/// relying on every caller running validation afterwards. +fn deserialize_validated_matcher<'de, D>( + deserializer: D, +) -> std::result::Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let map = BTreeMap::::deserialize(deserializer)?; + RiteHandler::tree_from_map(&map) + .map(|_| map) + .map_err(serde::de::Error::custom) +} + /// A scalar value used to match an event field or metadata item. +/// +/// The `Table` and `Array` variants only appear inside compound +/// (`all_of`/`any_of`/`not`) match tables; legacy flat matchers keep their +/// scalar shapes verbatim. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(untagged)] pub enum MatchValue { @@ -113,6 +253,10 @@ pub enum MatchValue { Integer(i64), /// Boolean value. Boolean(bool), + /// Inline table value (nested compound condition). + Table(BTreeMap), + /// Array of inline tables (children of `all_of`/`any_of`). + Array(Vec>), } impl MatchValue { @@ -121,8 +265,82 @@ impl MatchValue { Self::String(expected) => value.as_str() == Some(expected), Self::Integer(expected) => value.as_i64() == Some(*expected), Self::Boolean(expected) => value.as_bool() == Some(*expected), + Self::Table(_) | Self::Array(_) => false, } } + + /// Whether this value has the TOML shape the operator `op` requires. + fn is_operator_shaped(&self, op: &str) -> bool { + match op { + "not" => matches!(self, Self::Table(_)), + _ => matches!(self, Self::Array(_)), + } + } +} + +/// A node in a handler's match condition tree. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MatchCondition { + /// Every child condition must hold (operator `all_of`, or a flat legacy table). + All(Vec), + /// At least one child condition must hold (operator `any_of`). + Any(Vec), + /// The sole child condition must not hold (operator `not`). + Not(Box), + /// A single field-or-metadata equality/substring leaf. + Leaf(String, MatchValue), +} + +impl MatchCondition { + /// Evaluate this condition against an event's matchable fields. + #[must_use] + pub fn matches(&self, event: &RiteEvent) -> bool { + match self { + Self::All(children) => children.iter().all(|c| c.matches(event)), + Self::Any(children) => children.iter().any(|c| c.matches(event)), + Self::Not(inner) => !inner.matches(event), + Self::Leaf(field, expected) => leaf_matches(field, expected, event), + } + } +} + +/// Evaluate a single leaf condition (field or metadata) against an event. +fn leaf_matches(field: &str, expected: &MatchValue, event: &RiteEvent) -> bool { + match field { + "event_type" => expected.matches_json(&Value::String(event.event_type.clone())), + "action" => expected.matches_json(&Value::String(event.action.clone().unwrap_or_default())), + "severity" => { + serde_json::to_value(event.severity).is_ok_and(|value| expected.matches_json(&value)) + } + "body_contains" => match expected { + MatchValue::String(needle) => event + .body + .as_ref() + .is_some_and(|body| body.contains(needle)), + _ => false, + }, + key => event + .metadata + .get(key) + .is_some_and(|value| expected.matches_json(value)), + } +} + +impl RiteHandler { + /// Returns whether this handler matches an event. + /// + /// The source guard is deliberately outside the condition tree: a match + /// is only ever considered within the handler's own source. + #[must_use] + pub fn matches(&self, event: &RiteEvent) -> bool { + self.source == event.source + && match self.condition_tree() { + Ok(tree) => tree.matches(event), + // Unreachable in practice: load-time validation rejects + // malformed compound tables before any handler runs. + Err(_) => false, + } + } } /// A supported handler action. @@ -207,35 +425,6 @@ fn json_value_text(value: &Value) -> String { } } -impl RiteHandler { - /// Returns whether this handler matches an event. - #[must_use] - pub fn matches(&self, event: &RiteEvent) -> bool { - self.source == event.source - && self - .matcher - .iter() - .all(|(field, expected)| match field.as_str() { - "event_type" => expected.matches_json(&Value::String(event.event_type.clone())), - "action" => expected - .matches_json(&Value::String(event.action.clone().unwrap_or_default())), - "severity" => serde_json::to_value(event.severity) - .is_ok_and(|value| expected.matches_json(&value)), - "body_contains" => match expected { - MatchValue::String(needle) => event - .body - .as_ref() - .is_some_and(|body| body.contains(needle)), - _ => false, - }, - key => event - .metadata - .get(key) - .is_some_and(|value| expected.matches_json(value)), - }) - } -} - #[cfg(test)] mod tests { use super::*; @@ -331,4 +520,200 @@ action = { type = "http_post", url = "https://example.test/hook" }"#, } if headers.is_empty() )); } + + fn event( + source: &str, + event_type: &str, + severity: Severity, + body: Option<&str>, + metadata: BTreeMap, + ) -> RiteEvent { + RiteEvent { + source: source.into(), + event_type: event_type.into(), + action: None, + timestamp: Utc::now(), + severity, + title: event_type.into(), + body: body.map(Into::into), + metadata, + } + } + + const ACTION: &str = "action = { type = \"http_post\", url = \"https://example.test/hook\" }"; + + #[test] + fn compound_any_of_matches_exactly_critical_or_urgent() { + let handler: RiteHandler = toml::from_str(&format!( + "name = \"page\"\nsource = \"iris\"\nmatch = {{ any_of = [{{ severity = \"critical\" }}, {{ body_contains = \"URGENT\" }}] }}\n{ACTION}" + )) + .expect("compound handler parses"); + + let critical = event("iris", "alert", Severity::Critical, None, BTreeMap::new()); + let urgent = event( + "iris", + "alert", + Severity::Info, + Some("URGENT: deploy"), + BTreeMap::new(), + ); + let quiet = event( + "iris", + "alert", + Severity::Info, + Some("all fine"), + BTreeMap::new(), + ); + + assert!(handler.matches(&critical)); + assert!(handler.matches(&urgent)); + assert!(!handler.matches(&quiet)); + } + + #[test] + fn compound_not_inside_all_of_excludes() { + let handler: RiteHandler = toml::from_str(&format!( + "name = \"real-prs\"\nsource = \"github\"\nmatch = {{ all_of = [{{ event_type = \"pull_request\" }}, {{ not = {{ draft = true }} }}] }}\n{ACTION}" + )) + .expect("compound handler parses"); + + let draft = event( + "github", + "pull_request", + Severity::Info, + None, + BTreeMap::from([("draft".into(), Value::Bool(true))]), + ); + let ready = event( + "github", + "pull_request", + Severity::Info, + None, + BTreeMap::from([("draft".into(), Value::Bool(false))]), + ); + let ready_push = event("github", "push", Severity::Info, None, BTreeMap::new()); + + assert!(!handler.matches(&draft)); // not(draft) excludes drafts + assert!(handler.matches(&ready)); + assert!(!handler.matches(&ready_push)); // event_type guard fails + } + + #[test] + fn compound_nesting_arbitrary_depth() { + // all_of( any_of( not(severity=info), severity=critical ), event_type=alert ) + let handler: RiteHandler = toml::from_str(&format!( + "name = \"deep\"\nsource = \"iris\"\nmatch = {{ all_of = [{{ any_of = [{{ not = {{ severity = \"info\" }} }}, {{ severity = \"critical\" }}] }}, {{ event_type = \"alert\" }}] }}\n{ACTION}" + )) + .expect("nested handler parses"); + + let critical_alert = event("iris", "alert", Severity::Critical, None, BTreeMap::new()); + let warning_alert = event("iris", "alert", Severity::Warning, None, BTreeMap::new()); + let info_alert = event("iris", "alert", Severity::Info, None, BTreeMap::new()); + let warning_chat = event("iris", "chat", Severity::Warning, None, BTreeMap::new()); + + assert!(handler.matches(&critical_alert)); + assert!(handler.matches(&warning_alert)); // not(info) holds under any_of + assert!(!handler.matches(&info_alert)); + assert!(!handler.matches(&warning_chat)); // event_type=alert fails + } + + #[test] + fn compound_flat_form_parses_to_all_of_leaves() { + let handler: RiteHandler = toml::from_str(&format!( + "name = \"flat\"\nsource = \"github\"\nmatch = {{ event_type = \"push\", draft = false }}\n{ACTION}" + )) + .expect("flat handler parses"); + + let tree = handler.condition_tree().expect("flat table is valid"); + let MatchCondition::All(children) = tree else { + panic!("flat table must parse to All"); + }; + assert_eq!(children.len(), 2); + } + + #[test] + fn scalar_operator_named_keys_stay_legacy_leaves() { + // A scalar under an operator-named key is a metadata lookup, not an + // operator — legacy configs using those exact key names keep working. + let handler: RiteHandler = toml::from_str(&format!( + "name = \"legacy-named\"\nsource = \"iris\"\nmatch = {{ not = \"a-value\", all_of = \"another\", event_type = \"text\" }}\n{ACTION}" + )) + .expect("legacy scalar handler parses"); + + let tree = handler.condition_tree().expect("scalars are leaves"); + let MatchCondition::All(children) = tree else { + panic!("scalar operator-named keys must parse to All"); + }; + assert_eq!(children.len(), 3); + + let matching = event( + "iris", + "text", + Severity::Info, + None, + BTreeMap::from([ + ("not".into(), Value::String("a-value".into())), + ("all_of".into(), Value::String("another".into())), + ]), + ); + assert!(handler.matches(&matching)); + } + + #[test] + fn compound_rejects_empty_children_and_misshapen_not() { + // Malformed compound tables are rejected at deserialization time — + // no load path can ever observe them. + let empty_all = toml::from_str::(&format!( + "name = \"bad\"\nsource = \"iris\"\nmatch = {{ all_of = [] }}\n{ACTION}" + )) + .expect_err("empty all_of rejected at load"); + assert!(empty_all.to_string().contains("at least one child")); + + let empty_any = toml::from_str::(&format!( + "name = \"bad\"\nsource = \"iris\"\nmatch = {{ any_of = [] }}\n{ACTION}" + )) + .expect_err("empty any_of rejected at load"); + assert!(empty_any.to_string().contains("at least one child")); + + let fat_not = toml::from_str::(&format!( + "name = \"bad\"\nsource = \"iris\"\nmatch = {{ not = {{ severity = \"info\", event_type = \"chat\" }} }}\n{ACTION}" + )) + .expect_err("multi-child not rejected at load"); + assert!(fat_not.to_string().contains("exactly one child")); + + let empty_not = toml::from_str::(&format!( + "name = \"bad\"\nsource = \"iris\"\nmatch = {{ not = {{}} }}\n{ACTION}" + )) + .expect_err("empty not rejected at load"); + assert!(empty_not.to_string().contains("exactly one child")); + } + + #[test] + fn compound_rejects_mixed_operators_and_leaves() { + let mixed = toml::from_str::(&format!( + "name = \"bad\"\nsource = \"iris\"\nmatch = {{ any_of = [{{ severity = \"critical\" }}], event_type = \"chat\" }}\n{ACTION}" + )) + .expect_err("mixed operator+leaf rejected at load"); + assert!(mixed.to_string().contains("mixes operators")); + + let siblings = toml::from_str::(&format!( + "name = \"bad\"\nsource = \"iris\"\nmatch = {{ all_of = [{{ event_type = \"a\" }}], any_of = [{{ event_type = \"b\" }}] }}\n{ACTION}" + )) + .expect_err("sibling operators rejected at load"); + assert!(siblings.to_string().contains("multiple operators")); + } + + #[test] + fn programmatic_matcher_corruption_is_still_rejected() { + // Deserialization covers every TOML path; condition_tree() still + // rejects handlers mutated programmatically after construction. + let mut handler = toml::from_str::(&format!( + "name = \"prog\"\nsource = \"iris\"\nmatch = {{ event_type = \"text\" }}\n{ACTION}" + )) + .expect("valid handler parses"); + handler + .matcher + .insert("all_of".into(), MatchValue::Array(Vec::new())); + assert!(handler.condition_tree().is_err()); + } } diff --git a/crates/rite-server/src/lib.rs b/crates/rite-server/src/lib.rs index 738ad08..bba42f9 100644 --- a/crates/rite-server/src/lib.rs +++ b/crates/rite-server/src/lib.rs @@ -180,6 +180,15 @@ pub fn validate(config: &RiteConfig) -> Vec { let mut names = std::collections::BTreeSet::new(); for handler in &config.rites { + if let Err(error) = handler.condition_tree() { + diagnostics.push(Diagnostic { + level: DiagnosticLevel::Error, + message: format!( + "handler '{}' has an invalid match table: {error}", + handler.name + ), + }); + } if !configured_sources.contains(handler.source.as_str()) { diagnostics.push(Diagnostic { level: DiagnosticLevel::Error, @@ -712,4 +721,69 @@ mod tests { .expect("response"); assert_eq!(response.status(), StatusCode::ACCEPTED); } + + #[test] + fn example_and_readme_configs_load_and_validate() { + let readme = include_str!("../../../README.md"); + let example = include_str!("../../../rite.example.toml"); + for (label, text) in [("README.md", readme), ("rite.example.toml", example)] { + for fence in text.split("```toml").skip(1) { + let toml_text = fence.split("```").next().unwrap_or_default(); + if !toml_text.contains("[[rites]]") { + continue; + } + let config = load_config(toml_text) + .unwrap_or_else(|e| panic!("{label} fence failed to parse: {e}\n{toml_text}")); + for handler in &config.rites { + handler.condition_tree().unwrap_or_else(|e| { + panic!("{label} fence has invalid match: {e}\n{toml_text}") + }); + } + // Config fences are intentionally partial (no sources), so + // unknown-source errors are expected; match-table errors are + // not. + let config_errors = validate(&config) + .into_iter() + .filter(|d| { + d.level == DiagnosticLevel::Error && !d.message.contains("unknown source") + }) + .count(); + assert_eq!( + config_errors, 0, + "{label} fence produced validation errors:\n{toml_text}" + ); + } + } + } + + #[test] + fn validate_rejects_malformed_compound_match_tables() { + // Malformed compound tables are rejected at load_config itself + // (deserialization-time validation), which is stronger than a + // post-load diagnostic: no caller can ever obtain them. + let base = "[sources.iris]\nenabled = true\nbase_url = \"http://iris.test\"\n\n"; + for (label, matcher) in [ + ("empty all_of", "match = { all_of = [] }"), + ("empty any_of", "match = { any_of = [] }"), + ( + "multi-child not", + "match = { not = { severity = \"info\", event_type = \"chat\" } }", + ), + ( + "operator mixed with leaf", + "match = { any_of = [{ severity = \"critical\" }], event_type = \"chat\" }", + ), + ] { + let text = format!( + "{base}[[rites]]\nname = \"bad-{label}\"\nsource = \"iris\"\n{matcher}\naction = {{ type = \"http_post\", url = \"https://example.test/hook\" }}" + ); + let err = load_config(&text) + .err() + .unwrap_or_else(|| panic!("{label} must be rejected at load")); + assert!( + err.to_string().contains("match"), + "{label} error must name the match table: {err}" + ); + } + } }