use crate::domain::graph::Node;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum Specification {
TypeIs(String),
MetadataEq {
path: String,
value: serde_json::Value,
},
Reachable {
from_node: String,
edge_kind: Option<String>,
depth: u32,
},
And(Vec<Specification>),
Or(Vec<Specification>),
Not(Box<Specification>),
}
impl Specification {
pub fn and(self, other: Specification) -> Specification {
Specification::And(vec![self, other])
}
pub fn or(self, other: Specification) -> Specification {
Specification::Or(vec![self, other])
}
pub fn is_satisfied_by(&self, node: &Node) -> bool {
match self {
Specification::TypeIs(t) => node.r#type == *t,
Specification::MetadataEq { path, value } => extract_path(&node.metadata, path)
.map(|v| v == *value)
.unwrap_or(false),
Specification::Reachable { .. } => false,
Specification::And(specs) => specs.iter().all(|s| s.is_satisfied_by(node)),
Specification::Or(specs) => specs.iter().any(|s| s.is_satisfied_by(node)),
Specification::Not(s) => !s.is_satisfied_by(node),
}
}
}
impl std::ops::Not for Specification {
type Output = Specification;
fn not(self) -> Self::Output {
Specification::Not(Box::new(self))
}
}
fn extract_path(value: &serde_json::Value, path: &str) -> Option<serde_json::Value> {
if path.is_empty() {
return Some(value.clone());
}
let mut current = value;
for key in path.split('.') {
current = current.get(key)?;
}
Some(current.clone())
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn node(id: &str, type_: &str, metadata: serde_json::Value) -> Node {
Node {
id: id.into(),
r#type: type_.into(),
sot_ref: None,
confidence: None,
applicability: None,
last_verified_at: None,
review_due: None,
version: 1,
prev_id: None,
metadata,
}
}
#[test]
fn type_is_matches_when_equal() {
let s = Specification::TypeIs("persona".into());
assert!(s.is_satisfied_by(&node("n", "persona", json!({}))));
assert!(!s.is_satisfied_by(&node("n", "channel", json!({}))));
}
#[test]
fn metadata_eq_supports_nested_path() {
let s = Specification::MetadataEq {
path: "owner.name".into(),
value: json!("alpha"),
};
let yes = node("a", "persona", json!({"owner": {"name": "alpha"}}));
let no = node("a", "persona", json!({"owner": {"name": "beta"}}));
let missing = node("a", "persona", json!({}));
assert!(s.is_satisfied_by(&yes));
assert!(!s.is_satisfied_by(&no));
assert!(!s.is_satisfied_by(&missing));
}
#[test]
fn and_short_circuits_on_false() {
let s = Specification::TypeIs("persona".into()).and(Specification::MetadataEq {
path: "owner.name".into(),
value: json!("alpha"),
});
let yes = node("a", "persona", json!({"owner": {"name": "alpha"}}));
let no_type = node("a", "channel", json!({"owner": {"name": "alpha"}}));
let no_meta = node("a", "persona", json!({"owner": {"name": "beta"}}));
assert!(s.is_satisfied_by(&yes));
assert!(!s.is_satisfied_by(&no_type));
assert!(!s.is_satisfied_by(&no_meta));
}
#[test]
fn or_unions() {
let s = Specification::TypeIs("persona".into()).or(Specification::TypeIs("channel".into()));
assert!(s.is_satisfied_by(&node("a", "persona", json!({}))));
assert!(s.is_satisfied_by(&node("a", "channel", json!({}))));
assert!(!s.is_satisfied_by(&node("a", "workflow_def", json!({}))));
}
#[test]
fn not_inverts_via_operator() {
let s = !Specification::TypeIs("persona".into());
assert!(!s.is_satisfied_by(&node("a", "persona", json!({}))));
assert!(s.is_satisfied_by(&node("a", "channel", json!({}))));
}
#[test]
fn reachable_returns_false_on_bare_node() {
let s = Specification::Reachable {
from_node: "n0".into(),
edge_kind: Some("routes_to".into()),
depth: 1,
};
assert!(!s.is_satisfied_by(&node("any", "persona", json!({}))));
}
}