1use serde::{Deserialize, Serialize};
17
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(rename_all = "snake_case")]
20pub enum HuddleInvocationKind {
21 Contested,
23 Sensitive,
25 HighRisk,
27 AiAssisted,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
32#[serde(rename_all = "snake_case")]
33pub enum HuddleUrgency {
34 Routine,
35 Elevated,
36 Urgent,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
46pub struct HuddleInvocation {
47 pub id: String,
48 pub subject_id: String,
49 pub kind: HuddleInvocationKind,
50 pub urgency: HuddleUrgency,
51 pub triggers: Vec<String>,
52 pub rationale: String,
53 pub correlation_id: String,
54 pub reviewer: String,
55 #[serde(default, skip_serializing_if = "is_null")]
56 pub domain_context: serde_json::Value,
57}
58
59fn is_null(v: &serde_json::Value) -> bool {
60 v.is_null()
61}
62
63impl HuddleInvocation {
64 pub fn new(
65 subject_id: impl Into<String>,
66 kind: HuddleInvocationKind,
67 urgency: HuddleUrgency,
68 correlation_id: impl Into<String>,
69 ) -> Self {
70 let subject = subject_id.into();
71 Self {
72 id: format!("huddle:{subject}"),
73 subject_id: subject,
74 kind,
75 urgency,
76 triggers: Vec::new(),
77 rationale: String::new(),
78 correlation_id: correlation_id.into(),
79 reviewer: String::new(),
80 domain_context: serde_json::Value::Null,
81 }
82 }
83
84 #[must_use]
85 pub fn with_id(mut self, id: impl Into<String>) -> Self {
86 self.id = id.into();
87 self
88 }
89
90 #[must_use]
91 pub fn with_triggers(mut self, triggers: Vec<String>) -> Self {
92 self.triggers = triggers;
93 self
94 }
95
96 #[must_use]
97 pub fn with_rationale(mut self, rationale: impl Into<String>) -> Self {
98 self.rationale = rationale.into();
99 self
100 }
101
102 #[must_use]
103 pub fn with_reviewer(mut self, reviewer: impl Into<String>) -> Self {
104 self.reviewer = reviewer.into();
105 self
106 }
107
108 #[must_use]
109 pub fn with_domain_context(mut self, context: serde_json::Value) -> Self {
110 self.domain_context = context;
111 self
112 }
113}
114
115#[cfg(test)]
116mod tests {
117 use super::*;
118
119 #[test]
120 fn new_sets_default_id_and_empty_fields() {
121 let inv = HuddleInvocation::new(
122 "story-001",
123 HuddleInvocationKind::Contested,
124 HuddleUrgency::Elevated,
125 "corr-1",
126 );
127 assert_eq!(inv.id, "huddle:story-001");
128 assert_eq!(inv.subject_id, "story-001");
129 assert_eq!(inv.kind, HuddleInvocationKind::Contested);
130 assert_eq!(inv.urgency, HuddleUrgency::Elevated);
131 assert_eq!(inv.correlation_id, "corr-1");
132 assert!(inv.triggers.is_empty());
133 assert!(inv.rationale.is_empty());
134 assert!(inv.reviewer.is_empty());
135 assert!(inv.domain_context.is_null());
136 }
137
138 #[test]
139 fn builder_chain_sets_fields() {
140 let inv = HuddleInvocation::new(
141 "story-001",
142 HuddleInvocationKind::Sensitive,
143 HuddleUrgency::Urgent,
144 "corr-1",
145 )
146 .with_id("huddle:custom")
147 .with_triggers(vec!["sensitive-subject".into(), "policy/x".into()])
148 .with_rationale("sensitive keyword in title")
149 .with_reviewer("organism-huddle")
150 .with_domain_context(serde_json::json!({"tag": "newspaper"}));
151 assert_eq!(inv.id, "huddle:custom");
152 assert_eq!(inv.triggers.len(), 2);
153 assert_eq!(inv.rationale, "sensitive keyword in title");
154 assert_eq!(inv.reviewer, "organism-huddle");
155 assert_eq!(inv.domain_context["tag"], "newspaper");
156 }
157
158 #[test]
159 fn serde_round_trip_uses_snake_case() {
160 let inv = HuddleInvocation::new(
161 "story-001",
162 HuddleInvocationKind::AiAssisted,
163 HuddleUrgency::Routine,
164 "corr-1",
165 );
166 let json = serde_json::to_string(&inv).expect("serialize");
167 assert!(json.contains("\"kind\":\"ai_assisted\""), "got {json}");
168 assert!(json.contains("\"urgency\":\"routine\""), "got {json}");
169 let back: HuddleInvocation = serde_json::from_str(&json).expect("deserialize");
170 assert_eq!(back, inv);
171 }
172
173 #[test]
174 fn null_domain_context_is_omitted_from_serialization() {
175 let inv = HuddleInvocation::new(
176 "story-001",
177 HuddleInvocationKind::HighRisk,
178 HuddleUrgency::Elevated,
179 "corr-1",
180 );
181 let json = serde_json::to_string(&inv).expect("serialize");
182 assert!(!json.contains("domain_context"), "got {json}");
183 }
184
185 #[test]
186 fn populated_domain_context_round_trips() {
187 let inv = HuddleInvocation::new(
188 "story-001",
189 HuddleInvocationKind::HighRisk,
190 HuddleUrgency::Elevated,
191 "corr-1",
192 )
193 .with_domain_context(serde_json::json!({"adversarial_triggers": ["foo", "bar"]}));
194 let json = serde_json::to_string(&inv).expect("serialize");
195 let back: HuddleInvocation = serde_json::from_str(&json).expect("deserialize");
196 assert_eq!(back.domain_context["adversarial_triggers"][0], "foo");
197 assert_eq!(back, inv);
198 }
199
200 #[test]
201 fn all_kind_variants_serialize() {
202 for kind in [
203 HuddleInvocationKind::Contested,
204 HuddleInvocationKind::Sensitive,
205 HuddleInvocationKind::HighRisk,
206 HuddleInvocationKind::AiAssisted,
207 ] {
208 let json = serde_json::to_string(&kind).expect("serialize");
209 let back: HuddleInvocationKind = serde_json::from_str(&json).expect("deserialize");
210 assert_eq!(kind, back);
211 }
212 }
213
214 #[test]
215 fn all_urgency_variants_serialize() {
216 for urgency in [
217 HuddleUrgency::Routine,
218 HuddleUrgency::Elevated,
219 HuddleUrgency::Urgent,
220 ] {
221 let json = serde_json::to_string(&urgency).expect("serialize");
222 let back: HuddleUrgency = serde_json::from_str(&json).expect("deserialize");
223 assert_eq!(urgency, back);
224 }
225 }
226}