1use serde_json::{Map, Value};
8
9pub fn forget(hash: &str) -> String {
12 format!("FORGET {hash}")
13}
14
15pub fn add(grain_type: &str, fields: &Map<String, Value>) -> String {
17 format!("ADD {grain_type} {}", Value::Object(fields.clone()))
18}
19
20pub fn supersede(target_hash: &str, grain_type: &str, fields: &Map<String, Value>) -> String {
22 format!(
23 "SUPERSEDE {target_hash} WITH {grain_type} {}",
24 Value::Object(fields.clone())
25 )
26}
27
28pub fn batch(statements: &[String]) -> String {
30 statements.join("\n")
31}
32
33pub fn parse_own_supersede(
39 line: &str,
40) -> Option<(String, String, Map<String, Value>)> {
41 let rest = line.trim().strip_prefix("SUPERSEDE ")?;
42 let (target, after) = rest.split_once(" WITH ")?;
43 let (grain_type, json) = after.trim().split_once(' ')?;
44 match serde_json::from_str(json.trim()).ok()? {
45 Value::Object(fields) => {
46 Some((target.trim().to_string(), grain_type.to_string(), fields))
47 }
48 _ => None,
49 }
50}
51
52pub fn contains_forget(cal: &str) -> bool {
55 any_line_keyword(cal, &["FORGET"])
56}
57
58pub fn contains_destructive(cal: &str) -> bool {
65 any_line_keyword(cal, &["FORGET", "PURGE"])
66}
67
68fn any_line_keyword(cal: &str, keywords: &[&str]) -> bool {
70 cal.lines().any(|l| {
71 let kw = l
72 .trim_start()
73 .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
74 .next()
75 .unwrap_or("");
76 keywords.iter().any(|k| kw.eq_ignore_ascii_case(k))
77 })
78}
79
80#[cfg(test)]
81mod tests {
82 use super::*;
83 use serde_json::json;
84
85 #[test]
86 fn forget_is_detected() {
87 assert!(contains_forget("FORGET sha256:abc"));
88 assert!(contains_forget("ADD fact {}\nforget sha256:x"));
89 assert!(!contains_forget("ADD fact {}\nSUPERSEDE a WITH fact {}"));
90 }
91
92 #[test]
93 fn destructive_covers_purge_and_forget_subject() {
94 assert!(contains_destructive("FORGET sha256:abc"));
95 assert!(contains_destructive(r#"FORGET SUBJECT "pat" BECAUSE "gdpr""#));
96 assert!(contains_destructive(r#"PURGE OLDER THAN 90d BECAUSE "retention""#));
97 assert!(contains_destructive("ADD fact {}\n purge older than 30d because \"x\""));
98 assert!(!contains_destructive("ADD fact {}\nSUPERSEDE a WITH fact {}"));
99 assert!(!contains_destructive(r#"RECALL facts WHERE subject = "purge""#));
101 }
102
103 #[test]
104 fn add_and_supersede_shapes() {
105 let mut f = Map::new();
106 f.insert("subject".into(), json!("acme"));
107 assert!(add("fact", &f).starts_with("ADD fact {"));
108 assert!(supersede("sha256:x", "fact", &f).starts_with("SUPERSEDE sha256:x WITH fact {"));
109 }
110
111 #[test]
112 fn parse_own_supersede_round_trips_the_writer() {
113 let mut f = Map::new();
114 f.insert("subject".into(), json!("acme"));
115 f.insert("object".into(), json!("Enterprise"));
116 let line = supersede("sha256:abc", "fact", &f);
117 let (target, gtype, fields) = parse_own_supersede(&line).expect("round-trip");
118 assert_eq!(target, "sha256:abc");
119 assert_eq!(gtype, "fact");
120 assert_eq!(fields, f);
121 }
122
123 #[test]
124 fn parse_own_supersede_rejects_other_shapes() {
125 assert!(parse_own_supersede("ADD fact {}").is_none());
126 assert!(parse_own_supersede("SUPERSEDE x WITH fact").is_none(), "no json");
127 assert!(parse_own_supersede("SUPERSEDE x WITH fact []").is_none(), "non-object json");
128 assert!(parse_own_supersede("FORGET x").is_none());
129 }
130}