1use crate::types::DecisionPayload;
4use serde_json::Value;
5
6pub fn is_decision(payload: &Value) -> bool {
8 payload
9 .get("tags")
10 .and_then(|v| v.as_array())
11 .map(|arr| arr.iter().any(|t| t.as_str() == Some("decision")))
12 .unwrap_or(false)
13}
14
15pub fn is_session_digest(payload: &Value) -> bool {
17 payload
18 .get("tags")
19 .and_then(|v| v.as_array())
20 .map(|arr| arr.iter().any(|t| t.as_str() == Some("session_digest")))
21 .unwrap_or(false)
22}
23
24pub fn extract_decision(payload: &Value) -> Option<DecisionPayload> {
28 if let Some(d) = payload.get("decision") {
30 let key = d.get("key").and_then(|v| v.as_str())?.to_string();
31 let value = d.get("value").and_then(|v| v.as_str())?.to_string();
32 let reason = d
33 .get("reason")
34 .and_then(|v| v.as_str())
35 .filter(|r| !r.is_empty())
36 .map(|r| r.to_string());
37 let scope = d
38 .get("scope")
39 .and_then(|v| v.as_str())
40 .and_then(|s| s.parse().ok());
41 let authority = d
43 .get("authority")
44 .and_then(|v| v.as_str())
45 .map(|s| s.to_string());
46 let affected_paths: Option<Vec<String>> = d
47 .get("affected_paths")
48 .and_then(|v| serde_json::from_value(v.clone()).ok());
49 let tags: Option<Vec<String>> = d
50 .get("tags")
51 .and_then(|v| serde_json::from_value(v.clone()).ok());
52 let review_after = d
53 .get("review_after")
54 .and_then(|v| v.as_str())
55 .map(|s| s.to_string());
56 let reversibility = d
57 .get("reversibility")
58 .and_then(|v| v.as_str())
59 .map(|s| s.to_string());
60 let village_id = d
61 .get("village_id")
62 .and_then(|v| v.as_str())
63 .map(|s| s.to_string());
64 return Some(DecisionPayload {
65 key,
66 value,
67 reason,
68 scope,
69 authority,
70 affected_paths,
71 tags,
72 review_after,
73 reversibility,
74 village_id,
75 });
76 }
77 let text = payload.get("text").and_then(|v| v.as_str())?;
79 let (key, rest) = text.split_once(": ")?;
80 let (value, reason) = match rest.split_once(" \u{2014} ") {
81 Some((v, r)) => (v.to_string(), Some(r.to_string())),
82 None => (rest.to_string(), None),
83 };
84 Some(DecisionPayload {
85 key: key.to_string(),
86 value,
87 reason,
88 scope: None,
89 authority: None,
90 affected_paths: None,
91 tags: None,
92 review_after: None,
93 reversibility: None,
94 village_id: None,
95 })
96}
97
98pub fn extract_domain(key: &str) -> String {
100 key.split('.').next().unwrap_or(key).to_string()
101}
102
103#[cfg(test)]
104mod tests {
105 use super::*;
106
107 #[test]
108 fn is_decision_with_tag() {
109 let payload = serde_json::json!({
110 "tags": ["decision"],
111 "text": "db.engine: postgres"
112 });
113 assert!(is_decision(&payload));
114 }
115
116 #[test]
117 fn is_decision_without_tag() {
118 let payload = serde_json::json!({
119 "tags": ["session"],
120 "text": "some note"
121 });
122 assert!(!is_decision(&payload));
123 }
124
125 #[test]
126 fn is_decision_no_tags() {
127 let payload = serde_json::json!({"text": "hello"});
128 assert!(!is_decision(&payload));
129 }
130
131 #[test]
132 fn extract_structured_with_reason() {
133 let payload = serde_json::json!({
134 "tags": ["decision"],
135 "text": "db.engine: postgres — embedded, zero-config",
136 "decision": {"key": "db.engine", "value": "postgres", "reason": "embedded, zero-config"}
137 });
138 let dp = extract_decision(&payload).unwrap();
139 assert_eq!(dp.key, "db.engine");
140 assert_eq!(dp.value, "postgres");
141 assert_eq!(dp.reason.as_deref(), Some("embedded, zero-config"));
142 }
143
144 #[test]
145 fn extract_structured_without_reason() {
146 let payload = serde_json::json!({
147 "tags": ["decision"],
148 "decision": {"key": "auth.method", "value": "JWT"}
149 });
150 let dp = extract_decision(&payload).unwrap();
151 assert_eq!(dp.key, "auth.method");
152 assert_eq!(dp.value, "JWT");
153 assert!(dp.reason.is_none());
154 }
155
156 #[test]
157 fn extract_structured_empty_reason_becomes_none() {
158 let payload = serde_json::json!({
159 "tags": ["decision"],
160 "decision": {"key": "k", "value": "v", "reason": ""}
161 });
162 let dp = extract_decision(&payload).unwrap();
163 assert!(dp.reason.is_none());
164 }
165
166 #[test]
167 fn extract_text_fallback_with_reason() {
168 let payload = serde_json::json!({
169 "tags": ["decision"],
170 "text": "db.engine: postgres \u{2014} embedded"
171 });
172 let dp = extract_decision(&payload).unwrap();
173 assert_eq!(dp.key, "db.engine");
174 assert_eq!(dp.value, "postgres");
175 assert_eq!(dp.reason.as_deref(), Some("embedded"));
176 }
177
178 #[test]
179 fn extract_text_fallback_without_reason() {
180 let payload = serde_json::json!({
181 "tags": ["decision"],
182 "text": "auth.method: JWT"
183 });
184 let dp = extract_decision(&payload).unwrap();
185 assert_eq!(dp.key, "auth.method");
186 assert_eq!(dp.value, "JWT");
187 assert!(dp.reason.is_none());
188 }
189
190 #[test]
191 fn extract_no_decision_returns_none() {
192 let payload = serde_json::json!({"text": "just a note"});
193 assert!(extract_decision(&payload).is_none());
194 }
195
196 #[test]
197 fn extract_structured_missing_key_returns_none() {
198 let payload = serde_json::json!({
199 "decision": {"value": "v"}
200 });
201 assert!(extract_decision(&payload).is_none());
202 }
203
204 #[test]
205 fn is_session_digest_with_tag() {
206 let payload = serde_json::json!({
207 "tags": ["session_digest"],
208 "text": "Session abc: 42 tool calls"
209 });
210 assert!(is_session_digest(&payload));
211 }
212
213 #[test]
214 fn is_session_digest_without_tag() {
215 let payload = serde_json::json!({
216 "tags": ["decision"],
217 "text": "db.engine: postgres"
218 });
219 assert!(!is_session_digest(&payload));
220 }
221
222 #[test]
223 fn domain_dotted_key() {
224 assert_eq!(extract_domain("db.engine"), "db");
225 }
226
227 #[test]
228 fn domain_no_dot() {
229 assert_eq!(extract_domain("auth"), "auth");
230 }
231
232 #[test]
233 fn domain_multi_dot() {
234 assert_eq!(extract_domain("api.v2.style"), "api");
235 }
236}