soothe_client/
card_projection.rs1use 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#[derive(Debug, Clone)]
13pub struct ParsedCardFrame {
14 pub wire_type: String,
16 pub card: Option<Map<String, Value>>,
18 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
33pub 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#[derive(Debug, Default)]
94pub struct CardProjection {
95 cards: Map<String, Value>,
96 order: Vec<String>,
97 replaying: bool,
98}
99
100impl CardProjection {
101 pub fn new() -> Self {
103 Self::default()
104 }
105
106 pub fn replaying(&self) -> bool {
108 self.replaying
109 }
110
111 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 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 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}