use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TriageRule {
pub name: String,
pub when: Vec<Condition>,
#[serde(default)]
pub audience: Vec<String>,
pub summary: String,
pub deadline: super::OversightDeadline,
#[serde(default)]
pub priority: TriagePriority,
}
#[derive(
Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "kebab-case")]
pub enum TriagePriority {
Low,
#[default]
Normal,
High,
Urgent,
}
impl From<TriagePriority> for crate::core::Priority {
fn from(p: TriagePriority) -> Self {
match p {
TriagePriority::Low => Self::Low,
TriagePriority::Normal => Self::Normal,
TriagePriority::High => Self::High,
TriagePriority::Urgent => Self::Urgent,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
pub struct Condition {
pub path: String,
#[serde(flatten)]
pub predicate: Predicate,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Predicate {
Equals(Value),
#[serde(rename = "in")]
OneOf(Vec<Value>),
AtLeast(f64),
AtMost(f64),
Exists(bool),
}
impl Condition {
#[must_use]
pub fn holds(&self, answer: &Value) -> bool {
let found = answer.pointer(&self.path);
match (&self.predicate, found) {
(Predicate::Exists(expected), found) => found.is_some() == *expected,
(_, None) => false,
(Predicate::Equals(expected), Some(actual)) => actual == expected,
(Predicate::OneOf(expected), Some(actual)) => expected.contains(actual),
(Predicate::AtLeast(floor), Some(actual)) => {
actual.as_f64().is_some_and(|value| value >= *floor)
}
(Predicate::AtMost(ceiling), Some(actual)) => {
actual.as_f64().is_some_and(|value| value <= *ceiling)
}
}
}
pub fn validate(&self) -> Result<(), String> {
if self.path.is_empty() {
return Err(
"a condition path is empty — the whole answer is not a field, so name \
one with an RFC 6901 pointer such as '/deadline_status'"
.to_owned(),
);
}
if !self.path.starts_with('/') {
return Err(format!(
"'{}' is not an RFC 6901 pointer — it must begin with '/', e.g. \
'/deadline_status'",
self.path
));
}
match &self.predicate {
Predicate::OneOf(options) if options.is_empty() => Err(format!(
"'{}' has an empty `in` list, which matches nothing — a triage rule that \
can never fire is a worklist somebody believes is being filled",
self.path
)),
Predicate::AtLeast(bound) | Predicate::AtMost(bound) if !bound.is_finite() => {
Err(format!(
"'{}' compares against a bound that is not a finite number",
self.path
))
}
_ => Ok(()),
}
}
pub fn check_against(&self, schema: &Value) -> Result<(), String> {
let mut node = schema;
for token in self.path.split('/').skip(1) {
let token = token.replace("~1", "/").replace("~0", "~");
match node.get("type").and_then(Value::as_str) {
Some("object") => {
let properties = node.get("properties").and_then(Value::as_object);
match properties.and_then(|p| p.get(&token)) {
Some(next) => node = next,
None if node.get("additionalProperties") == Some(&Value::Bool(false)) => {
return Err(format!(
"'{}' names '{token}', which `spec.output.schema` does not \
declare and cannot carry (`additionalProperties: false`) — \
the rule would never fire while reading in review as an \
alert that does",
self.path
));
}
None => return Ok(()),
}
}
Some("array") => match node.get("items") {
Some(items) if token.chars().all(|c| c.is_ascii_digit()) => node = items,
_ => return Ok(()),
},
Some(scalar) => {
return Err(format!(
"'{}' reaches into '{token}' below a `{scalar}` in \
`spec.output.schema` — a scalar has no fields, so the rule would \
never fire",
self.path
));
}
None => return Ok(()),
}
}
Ok(())
}
}
impl TriageRule {
#[must_use]
pub fn matches(&self, answer: &Value) -> bool {
self.when.iter().all(|c| c.holds(answer))
}
#[must_use]
pub fn task_kind(&self) -> String {
format!("agent.triage/{}", self.name)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn condition(path: &str, predicate: Predicate) -> Condition {
Condition {
path: path.to_owned(),
predicate,
}
}
#[test]
fn every_operator_is_total_and_never_coerces() {
let answer = json!({
"deadline_status": "BREACH",
"days_left": 2,
"amount": "12",
"nested": { "flag": null }
});
assert!(condition("/deadline_status", Predicate::Equals(json!("BREACH"))).holds(&answer));
assert!(!condition("/deadline_status", Predicate::Equals(json!("OK"))).holds(&answer));
assert!(
condition(
"/deadline_status",
Predicate::OneOf(vec![json!("WARN"), json!("BREACH")])
)
.holds(&answer)
);
assert!(condition("/days_left", Predicate::AtMost(3.0)).holds(&answer));
assert!(!condition("/days_left", Predicate::AtLeast(3.0)).holds(&answer));
assert!(
!condition("/amount", Predicate::AtLeast(3.0)).holds(&answer),
"a numeric bound must not coerce the string \"12\""
);
assert!(condition("/nested/flag", Predicate::Exists(true)).holds(&answer));
assert!(condition("/nowhere", Predicate::Exists(false)).holds(&answer));
for predicate in [
Predicate::Equals(json!("BREACH")),
Predicate::OneOf(vec![json!("BREACH")]),
Predicate::AtLeast(0.0),
Predicate::AtMost(1e9),
] {
assert!(!condition("/nowhere", predicate).holds(&answer));
}
}
#[test]
fn a_pointer_is_refused_only_when_the_schema_forbids_it() {
let closed = json!({
"type": "object",
"additionalProperties": false,
"properties": {
"deadline_status": { "type": "string" },
"detail": {
"type": "object",
"additionalProperties": false,
"properties": { "days_left": { "type": "number" } }
}
}
});
assert!(
condition("/deadline_status", Predicate::Exists(true))
.check_against(&closed)
.is_ok()
);
assert!(
condition("/detail/days_left", Predicate::AtMost(1.0))
.check_against(&closed)
.is_ok()
);
let refused = condition("/deadline_stauts", Predicate::Exists(true))
.check_against(&closed)
.expect_err("a typo under a closed schema is refused");
assert!(refused.contains("additionalProperties"), "{refused}");
let scalar = condition("/deadline_status/inner", Predicate::Exists(true))
.check_against(&closed)
.expect_err("reaching below a scalar is refused");
assert!(scalar.contains("has no fields"), "{scalar}");
for permissive in [
json!({ "type": "object", "properties": { "a": { "type": "string" } } }),
json!({ "anyOf": [{ "type": "object" }] }),
json!({ "$ref": "#/definitions/answer" }),
] {
assert!(
condition("/whatever/deep", Predicate::Exists(true))
.check_against(&permissive)
.is_ok(),
"{permissive} must not be refused"
);
}
}
#[test]
fn a_rule_that_can_never_fire_is_refused() {
assert!(
condition("/status", Predicate::OneOf(Vec::new()))
.validate()
.expect_err("an empty `in` list is refused")
.contains("never fire")
);
assert!(
condition("status", Predicate::Exists(true))
.validate()
.expect_err("a pointer without a leading slash is refused")
.contains("RFC 6901")
);
assert!(
condition("/n", Predicate::AtLeast(f64::NAN))
.validate()
.expect_err("a non-finite bound is refused")
.contains("finite")
);
}
#[test]
fn the_manifest_spelling_of_a_rule_parses() {
let rule: TriageRule = serde_yaml_ng::from_str(
r#"
name: breach
summary: "a regulatory deadline was missed"
audience: [grid-operations]
priority: high
when:
- path: /deadline_status
equals: BREACH
- path: /days_left
at_most: 0
deadline: { name: triage-breach, kind: working-days, params: { n: 2 } }
"#,
)
.expect("a triage rule in manifest spelling");
assert_eq!(rule.task_kind(), "agent.triage/breach");
assert_eq!(rule.priority, TriagePriority::High);
assert!(rule.matches(&json!({ "deadline_status": "BREACH", "days_left": 0 })));
assert!(!rule.matches(&json!({ "deadline_status": "BREACH", "days_left": 3 })));
}
}