1use crate::serde_json::{Map, Value};
67
68#[derive(Debug, Clone)]
74pub struct SourceRow {
75 pub urn: String,
76 pub payload: String,
77}
78
79#[derive(Debug, Clone)]
83pub struct ValidationWarning {
84 pub kind: String,
85 pub detail: String,
86}
87
88#[derive(Debug, Clone)]
92pub struct AuditSummary {
93 pub provider: String,
94 pub model: String,
95 pub prompt_tokens: u32,
96 pub completion_tokens: u32,
97 pub cache_hit: bool,
98}
99
100fn obj(entries: &[(&str, Value)]) -> Value {
101 let mut map = Map::new();
102 for (k, v) in entries {
103 map.insert((*k).to_string(), v.clone());
104 }
105 Value::Object(map)
106}
107
108fn source_row_value(row: &SourceRow) -> Value {
109 obj(&[
110 ("payload", Value::String(row.payload.clone())),
111 ("urn", Value::String(row.urn.clone())),
112 ])
113}
114
115fn warning_value(w: &ValidationWarning) -> Value {
116 obj(&[
117 ("detail", Value::String(w.detail.clone())),
118 ("kind", Value::String(w.kind.clone())),
119 ])
120}
121
122fn audit_value(a: &AuditSummary) -> Value {
123 obj(&[
124 ("cache_hit", Value::Bool(a.cache_hit)),
125 ("completion_tokens", Value::Number(a.completion_tokens as f64)),
126 ("model", Value::String(a.model.clone())),
127 ("prompt_tokens", Value::Number(a.prompt_tokens as f64)),
128 ("provider", Value::String(a.provider.clone())),
129 ])
130}
131
132#[derive(Debug, Clone)]
135pub enum Frame {
136 Sources {
138 sources_flat: Vec<SourceRow>,
139 },
140 AnswerToken {
142 text: String,
143 },
144 Validation {
146 ok: bool,
147 warnings: Vec<ValidationWarning>,
148 audit: AuditSummary,
149 },
150 Error {
155 code: u16,
156 message: String,
157 },
158}
159
160pub mod event {
163 pub const SOURCES: &str = "sources";
164 pub const ANSWER_TOKEN: &str = "answer_token";
165 pub const VALIDATION: &str = "validation";
166 pub const ERROR: &str = "error";
167}
168
169impl Frame {
170 fn event_name(&self) -> &'static str {
171 match self {
172 Frame::Sources { .. } => event::SOURCES,
173 Frame::AnswerToken { .. } => event::ANSWER_TOKEN,
174 Frame::Validation { .. } => event::VALIDATION,
175 Frame::Error { .. } => event::ERROR,
176 }
177 }
178
179 fn payload_json(&self) -> String {
180 let value = match self {
181 Frame::Sources { sources_flat } => obj(&[(
182 "sources_flat",
183 Value::Array(sources_flat.iter().map(source_row_value).collect()),
184 )]),
185 Frame::AnswerToken { text } => obj(&[("text", Value::String(text.clone()))]),
186 Frame::Validation {
187 ok,
188 warnings,
189 audit,
190 } => obj(&[
191 ("audit", audit_value(audit)),
192 ("ok", Value::Bool(*ok)),
193 (
194 "warnings",
195 Value::Array(warnings.iter().map(warning_value).collect()),
196 ),
197 ]),
198 Frame::Error { code, message } => obj(&[
199 ("code", Value::Number(*code as f64)),
200 ("message", Value::String(message.clone())),
201 ]),
202 };
203 value.to_string_compact()
204 }
205}
206
207pub fn encode(frame: &Frame) -> String {
215 let event = frame.event_name();
216 let payload = frame.payload_json();
217
218 let mut out = String::with_capacity(event.len() + payload.len() + 16);
221 out.push_str("event: ");
222 out.push_str(event);
223 out.push('\n');
224
225 for line in payload.split('\n') {
229 out.push_str("data: ");
230 out.push_str(line);
231 out.push('\n');
232 }
233
234 out.push('\n');
235 out
236}
237
238#[cfg(test)]
239mod tests {
240 use super::*;
241
242 fn audit_fixture() -> AuditSummary {
243 AuditSummary {
244 provider: "openai".to_string(),
245 model: "gpt-4o-mini".to_string(),
246 prompt_tokens: 123,
247 completion_tokens: 45,
248 cache_hit: false,
249 }
250 }
251
252 #[test]
253 fn event_names_pinned() {
254 assert_eq!(event::SOURCES, "sources");
255 assert_eq!(event::ANSWER_TOKEN, "answer_token");
256 assert_eq!(event::VALIDATION, "validation");
257 assert_eq!(event::ERROR, "error");
258 }
259
260 #[test]
261 fn encodes_sources_frame_with_event_and_terminator() {
262 let frame = Frame::Sources {
263 sources_flat: vec![SourceRow {
264 urn: "urn:reddb:row:1".to_string(),
265 payload: "{\"k\":\"v\"}".to_string(),
266 }],
267 };
268 let out = encode(&frame);
269 assert!(out.starts_with("event: sources\n"));
270 assert!(out.ends_with("\n\n"));
271 assert!(out.contains("data: {"));
272 assert!(out.contains("\"urn\":\"urn:reddb:row:1\""));
273 }
274
275 #[test]
276 fn encodes_answer_token_frame_with_text_field() {
277 let frame = Frame::AnswerToken {
278 text: "hello".to_string(),
279 };
280 let out = encode(&frame);
281 assert_eq!(out, "event: answer_token\ndata: {\"text\":\"hello\"}\n\n");
282 }
283
284 #[test]
285 fn answer_token_escapes_quotes_and_backslashes() {
286 let frame = Frame::AnswerToken {
287 text: "a\"b\\c".to_string(),
288 };
289 let out = encode(&frame);
290 assert!(out.contains(r#"\"b\\c"#));
292 assert!(out.ends_with("\n\n"));
293 }
294
295 #[test]
296 fn encodes_validation_frame_with_full_shape() {
297 let frame = Frame::Validation {
298 ok: true,
299 warnings: vec![],
300 audit: audit_fixture(),
301 };
302 let out = encode(&frame);
303 assert!(out.starts_with("event: validation\n"));
304 assert!(out.contains("\"ok\":true"));
305 assert!(out.contains("\"prompt_tokens\":123"));
306 assert!(out.contains("\"cache_hit\":false"));
307 assert!(out.ends_with("\n\n"));
308 }
309
310 #[test]
311 fn validation_carries_warnings_array() {
312 let frame = Frame::Validation {
313 ok: false,
314 warnings: vec![
315 ValidationWarning {
316 kind: "out_of_range".to_string(),
317 detail: "[^9] but only 3 sources".to_string(),
318 },
319 ValidationWarning {
320 kind: "mode_fallback".to_string(),
321 detail: "ollama".to_string(),
322 },
323 ],
324 audit: audit_fixture(),
325 };
326 let out = encode(&frame);
327 assert!(out.contains("\"kind\":\"out_of_range\""));
328 assert!(out.contains("\"kind\":\"mode_fallback\""));
329 assert!(out.contains("\"ok\":false"));
332 }
333
334 #[test]
335 fn encodes_error_frame_with_code() {
336 let frame = Frame::Error {
337 code: 413,
338 message: "max_prompt_tokens exceeded".to_string(),
339 };
340 let out = encode(&frame);
341 assert_eq!(
342 out,
343 "event: error\ndata: {\"code\":413,\"message\":\"max_prompt_tokens exceeded\"}\n\n"
344 );
345 }
346
347 #[test]
348 fn error_frame_handles_504_timeout() {
349 let frame = Frame::Error {
352 code: 504,
353 message: "timeout_ms exceeded".to_string(),
354 };
355 let out = encode(&frame);
356 assert!(out.contains("\"code\":504"));
357 }
358
359 #[test]
360 fn multiline_payload_splits_across_data_lines() {
361 let frame = Frame::AnswerToken {
365 text: "line1\nline2".to_string(),
366 };
367 let out = encode(&frame);
368 assert_eq!(
372 out,
373 "event: answer_token\ndata: {\"text\":\"line1\\nline2\"}\n\n"
374 );
375 }
376
377 #[test]
378 fn encoder_splits_on_literal_newlines_in_payload() {
379 let mut out = String::new();
384 out.push_str("event: x\n");
385 for line in "a\nb\nc".split('\n') {
386 out.push_str("data: ");
387 out.push_str(line);
388 out.push('\n');
389 }
390 out.push('\n');
391 assert_eq!(out, "event: x\ndata: a\ndata: b\ndata: c\n\n");
392 }
393
394 #[test]
395 fn frame_terminator_is_double_newline() {
396 for frame in [
399 Frame::Sources { sources_flat: vec![] },
400 Frame::AnswerToken { text: String::new() },
401 Frame::Validation {
402 ok: true,
403 warnings: vec![],
404 audit: audit_fixture(),
405 },
406 Frame::Error {
407 code: 500,
408 message: String::new(),
409 },
410 ] {
411 let out = encode(&frame);
412 assert!(
413 out.ends_with("\n\n"),
414 "frame missing terminator: {:?}",
415 out
416 );
417 assert!(!out.ends_with("\n\n\n"));
420 }
421 }
422
423 #[test]
424 fn sources_frame_with_empty_list_is_well_formed() {
425 let frame = Frame::Sources {
426 sources_flat: vec![],
427 };
428 let out = encode(&frame);
429 assert_eq!(out, "event: sources\ndata: {\"sources_flat\":[]}\n\n");
430 }
431
432 #[test]
433 fn answer_token_with_empty_text_is_well_formed() {
434 let frame = Frame::AnswerToken {
438 text: String::new(),
439 };
440 let out = encode(&frame);
441 assert_eq!(out, "event: answer_token\ndata: {\"text\":\"\"}\n\n");
442 }
443
444 #[test]
445 fn encoding_is_deterministic_across_calls() {
446 let frame = Frame::Validation {
447 ok: true,
448 warnings: vec![ValidationWarning {
449 kind: "k".to_string(),
450 detail: "d".to_string(),
451 }],
452 audit: audit_fixture(),
453 };
454 let a = encode(&frame);
455 let b = encode(&frame);
456 assert_eq!(a, b);
457 }
458
459 #[test]
460 fn event_name_matches_pinned_constants() {
461 assert_eq!(
462 Frame::Sources { sources_flat: vec![] }.event_name(),
463 event::SOURCES
464 );
465 assert_eq!(
466 Frame::AnswerToken { text: String::new() }.event_name(),
467 event::ANSWER_TOKEN
468 );
469 assert_eq!(
470 Frame::Validation {
471 ok: true,
472 warnings: vec![],
473 audit: audit_fixture(),
474 }
475 .event_name(),
476 event::VALIDATION
477 );
478 assert_eq!(
479 Frame::Error {
480 code: 0,
481 message: String::new(),
482 }
483 .event_name(),
484 event::ERROR
485 );
486 }
487
488 #[test]
489 fn unicode_in_token_text_passes_through() {
490 let frame = Frame::AnswerToken {
491 text: "olá 🌍".to_string(),
492 };
493 let out = encode(&frame);
494 assert!(out.contains("olá 🌍"));
496 assert!(out.ends_with("\n\n"));
497 }
498}