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 (
126 "completion_tokens",
127 Value::Number(a.completion_tokens as f64),
128 ),
129 ("model", Value::String(a.model.clone())),
130 ("prompt_tokens", Value::Number(a.prompt_tokens as f64)),
131 ("provider", Value::String(a.provider.clone())),
132 ])
133}
134
135#[derive(Debug, Clone)]
138pub enum Frame {
139 Sources { sources_flat: Vec<SourceRow> },
141 AnswerToken { text: String },
143 Validation {
145 ok: bool,
146 warnings: Vec<ValidationWarning>,
147 audit: AuditSummary,
148 },
149 Error { code: u16, message: String },
154}
155
156pub mod event {
159 pub const SOURCES: &str = "sources";
160 pub const ANSWER_TOKEN: &str = "answer_token";
161 pub const VALIDATION: &str = "validation";
162 pub const ERROR: &str = "error";
163}
164
165impl Frame {
166 fn event_name(&self) -> &'static str {
167 match self {
168 Frame::Sources { .. } => event::SOURCES,
169 Frame::AnswerToken { .. } => event::ANSWER_TOKEN,
170 Frame::Validation { .. } => event::VALIDATION,
171 Frame::Error { .. } => event::ERROR,
172 }
173 }
174
175 fn payload_json(&self) -> String {
176 let value = match self {
177 Frame::Sources { sources_flat } => obj(&[(
178 "sources_flat",
179 Value::Array(sources_flat.iter().map(source_row_value).collect()),
180 )]),
181 Frame::AnswerToken { text } => obj(&[("text", Value::String(text.clone()))]),
182 Frame::Validation {
183 ok,
184 warnings,
185 audit,
186 } => obj(&[
187 ("audit", audit_value(audit)),
188 ("ok", Value::Bool(*ok)),
189 (
190 "warnings",
191 Value::Array(warnings.iter().map(warning_value).collect()),
192 ),
193 ]),
194 Frame::Error { code, message } => obj(&[
195 ("code", Value::Number(*code as f64)),
196 ("message", Value::String(message.clone())),
197 ]),
198 };
199 value.to_string_compact()
200 }
201}
202
203pub fn encode(frame: &Frame) -> String {
211 let event = frame.event_name();
212 let payload = frame.payload_json();
213
214 let mut out = String::with_capacity(event.len() + payload.len() + 16);
217 out.push_str("event: ");
218 out.push_str(event);
219 out.push('\n');
220
221 for line in payload.split('\n') {
225 out.push_str("data: ");
226 out.push_str(line);
227 out.push('\n');
228 }
229
230 out.push('\n');
231 out
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 fn audit_fixture() -> AuditSummary {
239 AuditSummary {
240 provider: "openai".to_string(),
241 model: "gpt-4o-mini".to_string(),
242 prompt_tokens: 123,
243 completion_tokens: 45,
244 cache_hit: false,
245 }
246 }
247
248 #[test]
249 fn event_names_pinned() {
250 assert_eq!(event::SOURCES, "sources");
251 assert_eq!(event::ANSWER_TOKEN, "answer_token");
252 assert_eq!(event::VALIDATION, "validation");
253 assert_eq!(event::ERROR, "error");
254 }
255
256 #[test]
257 fn encodes_sources_frame_with_event_and_terminator() {
258 let frame = Frame::Sources {
259 sources_flat: vec![SourceRow {
260 urn: "urn:reddb:row:1".to_string(),
261 payload: "{\"k\":\"v\"}".to_string(),
262 }],
263 };
264 let out = encode(&frame);
265 assert!(out.starts_with("event: sources\n"));
266 assert!(out.ends_with("\n\n"));
267 assert!(out.contains("data: {"));
268 assert!(out.contains("\"urn\":\"urn:reddb:row:1\""));
269 }
270
271 #[test]
272 fn encodes_answer_token_frame_with_text_field() {
273 let frame = Frame::AnswerToken {
274 text: "hello".to_string(),
275 };
276 let out = encode(&frame);
277 assert_eq!(out, "event: answer_token\ndata: {\"text\":\"hello\"}\n\n");
278 }
279
280 #[test]
281 fn answer_token_escapes_quotes_and_backslashes() {
282 let frame = Frame::AnswerToken {
283 text: "a\"b\\c".to_string(),
284 };
285 let out = encode(&frame);
286 assert!(out.contains(r#"\"b\\c"#));
288 assert!(out.ends_with("\n\n"));
289 }
290
291 #[test]
292 fn encodes_validation_frame_with_full_shape() {
293 let frame = Frame::Validation {
294 ok: true,
295 warnings: vec![],
296 audit: audit_fixture(),
297 };
298 let out = encode(&frame);
299 assert!(out.starts_with("event: validation\n"));
300 assert!(out.contains("\"ok\":true"));
301 assert!(out.contains("\"prompt_tokens\":123"));
302 assert!(out.contains("\"cache_hit\":false"));
303 assert!(out.ends_with("\n\n"));
304 }
305
306 #[test]
307 fn validation_carries_warnings_array() {
308 let frame = Frame::Validation {
309 ok: false,
310 warnings: vec![
311 ValidationWarning {
312 kind: "out_of_range".to_string(),
313 detail: "[^9] but only 3 sources".to_string(),
314 },
315 ValidationWarning {
316 kind: "mode_fallback".to_string(),
317 detail: "ollama".to_string(),
318 },
319 ],
320 audit: audit_fixture(),
321 };
322 let out = encode(&frame);
323 assert!(out.contains("\"kind\":\"out_of_range\""));
324 assert!(out.contains("\"kind\":\"mode_fallback\""));
325 assert!(out.contains("\"ok\":false"));
328 }
329
330 #[test]
331 fn encodes_error_frame_with_code() {
332 let frame = Frame::Error {
333 code: 413,
334 message: "max_prompt_tokens exceeded".to_string(),
335 };
336 let out = encode(&frame);
337 assert_eq!(
338 out,
339 "event: error\ndata: {\"code\":413,\"message\":\"max_prompt_tokens exceeded\"}\n\n"
340 );
341 }
342
343 #[test]
344 fn error_frame_handles_504_timeout() {
345 let frame = Frame::Error {
348 code: 504,
349 message: "timeout_ms exceeded".to_string(),
350 };
351 let out = encode(&frame);
352 assert!(out.contains("\"code\":504"));
353 }
354
355 #[test]
356 fn multiline_payload_splits_across_data_lines() {
357 let frame = Frame::AnswerToken {
361 text: "line1\nline2".to_string(),
362 };
363 let out = encode(&frame);
364 assert_eq!(
368 out,
369 "event: answer_token\ndata: {\"text\":\"line1\\nline2\"}\n\n"
370 );
371 }
372
373 #[test]
374 fn encoder_splits_on_literal_newlines_in_payload() {
375 let mut out = String::new();
380 out.push_str("event: x\n");
381 for line in "a\nb\nc".split('\n') {
382 out.push_str("data: ");
383 out.push_str(line);
384 out.push('\n');
385 }
386 out.push('\n');
387 assert_eq!(out, "event: x\ndata: a\ndata: b\ndata: c\n\n");
388 }
389
390 #[test]
391 fn frame_terminator_is_double_newline() {
392 for frame in [
395 Frame::Sources {
396 sources_flat: vec![],
397 },
398 Frame::AnswerToken {
399 text: String::new(),
400 },
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!(out.ends_with("\n\n"), "frame missing terminator: {:?}", out);
413 assert!(!out.ends_with("\n\n\n"));
416 }
417 }
418
419 #[test]
420 fn sources_frame_with_empty_list_is_well_formed() {
421 let frame = Frame::Sources {
422 sources_flat: vec![],
423 };
424 let out = encode(&frame);
425 assert_eq!(out, "event: sources\ndata: {\"sources_flat\":[]}\n\n");
426 }
427
428 #[test]
429 fn answer_token_with_empty_text_is_well_formed() {
430 let frame = Frame::AnswerToken {
434 text: String::new(),
435 };
436 let out = encode(&frame);
437 assert_eq!(out, "event: answer_token\ndata: {\"text\":\"\"}\n\n");
438 }
439
440 #[test]
441 fn encoding_is_deterministic_across_calls() {
442 let frame = Frame::Validation {
443 ok: true,
444 warnings: vec![ValidationWarning {
445 kind: "k".to_string(),
446 detail: "d".to_string(),
447 }],
448 audit: audit_fixture(),
449 };
450 let a = encode(&frame);
451 let b = encode(&frame);
452 assert_eq!(a, b);
453 }
454
455 #[test]
456 fn event_name_matches_pinned_constants() {
457 assert_eq!(
458 Frame::Sources {
459 sources_flat: vec![]
460 }
461 .event_name(),
462 event::SOURCES
463 );
464 assert_eq!(
465 Frame::AnswerToken {
466 text: String::new()
467 }
468 .event_name(),
469 event::ANSWER_TOKEN
470 );
471 assert_eq!(
472 Frame::Validation {
473 ok: true,
474 warnings: vec![],
475 audit: audit_fixture(),
476 }
477 .event_name(),
478 event::VALIDATION
479 );
480 assert_eq!(
481 Frame::Error {
482 code: 0,
483 message: String::new(),
484 }
485 .event_name(),
486 event::ERROR
487 );
488 }
489
490 #[test]
491 fn unicode_in_token_text_passes_through() {
492 let frame = Frame::AnswerToken {
493 text: "olá 🌍".to_string(),
494 };
495 let out = encode(&frame);
496 assert!(out.contains("olá 🌍"));
498 assert!(out.ends_with("\n\n"));
499 }
500}