Skip to main content

soothe_client/
card_projection.rs

1//! In-memory apply helpers for daemon `soothe.card.*` frames.
2
3use serde_json::{Map, Value};
4
5use crate::events::{
6    EVENT_CARD_CREATED, EVENT_CARD_FINALIZED, EVENT_CARD_REPLAY_BEGIN, EVENT_CARD_REPLAY_END,
7    EVENT_CARD_UPDATED,
8};
9use crate::protocol::as_str;
10
11/// Parsed custom-mode card frame.
12#[derive(Debug, Clone)]
13pub struct ParsedCardFrame {
14    /// Wire type (`soothe.card.*`).
15    pub wire_type: String,
16    /// Full card on create, otherwise `None`.
17    pub card: Option<Map<String, Value>>,
18    /// Patch for update/finalize.
19    pub patch: Map<String, Value>,
20}
21
22fn is_card_mutation_type(t: &str) -> bool {
23    matches!(
24        t,
25        EVENT_CARD_CREATED
26            | EVENT_CARD_UPDATED
27            | EVENT_CARD_FINALIZED
28            | EVENT_CARD_REPLAY_BEGIN
29            | EVENT_CARD_REPLAY_END
30    )
31}
32
33/// Parse a custom-mode card frame, or `None` if not a card frame.
34pub fn parse_card_custom_payload(data: &Value) -> Option<ParsedCardFrame> {
35    let obj = data.as_object()?;
36    let wire_type = as_str(obj.get("type").unwrap_or(&Value::Null))
37        .trim()
38        .to_string();
39    if !is_card_mutation_type(&wire_type) {
40        return None;
41    }
42    if wire_type == EVENT_CARD_REPLAY_BEGIN || wire_type == EVENT_CARD_REPLAY_END {
43        return Some(ParsedCardFrame {
44            wire_type,
45            card: None,
46            patch: Map::new(),
47        });
48    }
49    let mut payload = match obj.get("data").and_then(|v| v.as_object()) {
50        Some(m) => m.clone(),
51        None => Map::new(),
52    };
53    let mut card_id = as_str(obj.get("card_id").unwrap_or(&Value::Null))
54        .trim()
55        .to_string();
56    if card_id.is_empty() {
57        card_id = as_str(payload.get("id").unwrap_or(&Value::Null))
58            .trim()
59            .to_string();
60    }
61    if wire_type == EVENT_CARD_CREATED {
62        if payload.get("type").is_none() || payload.get("content").is_none() {
63            return None;
64        }
65        if as_str(payload.get("id").unwrap_or(&Value::Null))
66            .trim()
67            .is_empty()
68            && !card_id.is_empty()
69        {
70            payload.insert("id".into(), Value::String(card_id));
71        }
72        return Some(ParsedCardFrame {
73            wire_type,
74            card: Some(payload),
75            patch: Map::new(),
76        });
77    }
78    if as_str(payload.get("id").unwrap_or(&Value::Null))
79        .trim()
80        .is_empty()
81        && !card_id.is_empty()
82    {
83        payload.insert("id".into(), Value::String(card_id));
84    }
85    Some(ParsedCardFrame {
86        wire_type,
87        card: None,
88        patch: payload,
89    })
90}
91
92/// In-memory `card_id` → wire dict map driven by `soothe.card.*` frames.
93#[derive(Debug, Default)]
94pub struct CardProjection {
95    cards: Map<String, Value>,
96    order: Vec<String>,
97    replaying: bool,
98}
99
100impl CardProjection {
101    /// Construct an empty projection.
102    pub fn new() -> Self {
103        Self::default()
104    }
105
106    /// True while between `replay.begin` and `replay.end`.
107    pub fn replaying(&self) -> bool {
108        self.replaying
109    }
110
111    /// Cards in insertion order.
112    pub fn snapshot(&self) -> Vec<Map<String, Value>> {
113        self.order
114            .iter()
115            .filter_map(|id| self.cards.get(id).and_then(|v| v.as_object()).cloned())
116            .collect()
117    }
118
119    /// One card by id.
120    pub fn get(&self, card_id: &str) -> Option<&Map<String, Value>> {
121        self.cards.get(card_id).and_then(|v| v.as_object())
122    }
123
124    /// Apply one custom-mode card payload. Returns true when handled.
125    pub fn apply(&mut self, data: &Value) -> bool {
126        let Some(parsed) = parse_card_custom_payload(data) else {
127            return false;
128        };
129        match parsed.wire_type.as_str() {
130            EVENT_CARD_REPLAY_BEGIN => {
131                self.replaying = true;
132                self.cards.clear();
133                self.order.clear();
134                true
135            }
136            EVENT_CARD_REPLAY_END => {
137                self.replaying = false;
138                true
139            }
140            EVENT_CARD_CREATED => {
141                let Some(card) = parsed.card else {
142                    return true;
143                };
144                let id = as_str(card.get("id").unwrap_or(&Value::Null))
145                    .trim()
146                    .to_string();
147                if id.is_empty() {
148                    return true;
149                }
150                if !self.cards.contains_key(&id) {
151                    self.order.push(id.clone());
152                }
153                self.cards.insert(id, Value::Object(card));
154                true
155            }
156            EVENT_CARD_UPDATED | EVENT_CARD_FINALIZED => {
157                let id = as_str(parsed.patch.get("id").unwrap_or(&Value::Null))
158                    .trim()
159                    .to_string();
160                if id.is_empty() {
161                    return true;
162                }
163                let Some(existing) = self.cards.get(&id).and_then(|v| v.as_object()).cloned()
164                else {
165                    return true;
166                };
167                let mut next = existing;
168                for (k, v) in parsed.patch {
169                    if k == "id" || k == "type" {
170                        continue;
171                    }
172                    next.insert(k, v);
173                }
174                self.cards.insert(id, Value::Object(next));
175                true
176            }
177            _ => true,
178        }
179    }
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185    use serde_json::json;
186
187    #[test]
188    fn rejects_legacy_bare_card_types() {
189        assert!(parse_card_custom_payload(&json!({
190            "type": "card.created",
191            "data": {"id": "x", "type": "user", "content": "hi"}
192        }))
193        .is_none());
194    }
195
196    #[test]
197    fn applies_soothe_card_frames() {
198        let mut proj = CardProjection::new();
199        assert!(proj.apply(&json!({
200            "type": EVENT_CARD_CREATED,
201            "card_id": "a1",
202            "data": {"id": "a1", "type": "assistant", "content": "hel"}
203        })));
204        assert_eq!(
205            as_str(proj.get("a1").unwrap().get("content").unwrap()),
206            "hel"
207        );
208        assert!(proj.apply(&json!({
209            "type": EVENT_CARD_UPDATED,
210            "card_id": "a1",
211            "data": {"content": "hello"}
212        })));
213        assert_eq!(
214            as_str(proj.get("a1").unwrap().get("content").unwrap()),
215            "hello"
216        );
217        assert!(proj.apply(&json!({
218            "type": EVENT_CARD_FINALIZED,
219            "card_id": "a1",
220            "data": {}
221        })));
222    }
223
224    #[test]
225    fn peel_labels_match_live_wire_names() {
226        use crate::stream_terminal::{is_turn_progress_chunk, stale_pending_frame_label};
227
228        assert!(is_turn_progress_chunk(
229            "custom",
230            &json!({"type": EVENT_CARD_CREATED})
231        ));
232        assert!(is_turn_progress_chunk(
233            "custom",
234            &json!({"type": EVENT_CARD_UPDATED})
235        ));
236        assert_eq!(
237            stale_pending_frame_label(&json!({
238                "type": "next",
239                "payload": {
240                    "mode": EVENT_CARD_REPLAY_BEGIN,
241                    "data": {"type": EVENT_CARD_REPLAY_BEGIN}
242                }
243            })),
244            Some(EVENT_CARD_REPLAY_BEGIN.to_string())
245        );
246    }
247}