Skip to main content

edda_core/
decision.rs

1//! Centralized decision event helpers: detection, extraction, domain parsing.
2
3use crate::types::DecisionPayload;
4use serde_json::Value;
5
6/// Check if a payload represents a decision event (note with "decision" tag).
7pub 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
15/// Check if a payload represents a session digest event (note with "session_digest" tag).
16pub 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
24/// Extract `DecisionPayload` from an event payload.
25/// Prefers structured `payload.decision` object, falls back to text parse
26/// for backward compatibility with legacy events.
27pub fn extract_decision(payload: &Value) -> Option<DecisionPayload> {
28    // Structured path: payload.decision.{key, value, reason}
29    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        // V10 fields — all Option, gracefully default to None if missing
42        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    // Text fallback: "key: value — reason"
78    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
98/// Extract domain from a decision key: `"db.engine"` → `"db"`.
99pub 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}