Skip to main content

shadow_core/agentlog/
writer.rs

1//! Streaming JSONL writer for `.agentlog` files.
2//!
3//! Records are serialized with `serde_json` (struct-field order, not
4//! canonical — SPEC §5 canonical form applies to the payload hash, not
5//! to wire representation). One `\n` is emitted after each record,
6//! including the last, so the file always ends with a newline.
7
8use std::io::Write;
9
10use crate::agentlog::Record;
11
12/// Write a single record followed by a newline.
13pub fn write_record<W: Write>(writer: &mut W, record: &Record) -> std::io::Result<()> {
14    serde_json::to_writer(&mut *writer, record)?;
15    writer.write_all(b"\n")?;
16    Ok(())
17}
18
19/// Write an iterator of records, one per line.
20///
21/// Any record whose `verify_id()` returns `false` is written anyway — this
22/// function is a pure serializer, not a validator. Use
23/// [`Record::verify_id`](crate::agentlog::Record::verify_id) before calling
24/// if you want to refuse tampered records.
25pub fn write_all<'a, W: Write, I: IntoIterator<Item = &'a Record>>(
26    writer: &mut W,
27    records: I,
28) -> std::io::Result<()> {
29    for r in records {
30        write_record(writer, r)?;
31    }
32    Ok(())
33}
34
35#[cfg(test)]
36mod tests {
37    use super::*;
38    use crate::agentlog::parser::parse_all;
39    use crate::agentlog::{Kind, Record};
40    use serde_json::json;
41    use std::io::Cursor;
42
43    fn sample(kind: Kind, payload: serde_json::Value) -> Record {
44        Record::new(kind, payload, "2026-04-21T10:00:00Z", None)
45    }
46
47    #[test]
48    fn writes_one_record_with_trailing_newline() {
49        let r = sample(Kind::ChatRequest, json!({"model": "a"}));
50        let mut buf = Vec::new();
51        write_record(&mut buf, &r).unwrap();
52        assert_eq!(buf.last(), Some(&b'\n'));
53        // Exactly one newline in the output (JSONL line separator).
54        assert_eq!(buf.iter().filter(|b| **b == b'\n').count(), 1);
55    }
56
57    #[test]
58    fn roundtrips_through_parser() {
59        let records = vec![
60            sample(Kind::Metadata, json!({"sdk": {"name": "shadow"}})),
61            sample(Kind::ChatRequest, json!({"model": "a"})),
62            sample(
63                Kind::ChatResponse,
64                json!({"model": "a", "stop_reason": "end_turn"}),
65            ),
66            sample(
67                Kind::ToolCall,
68                json!({"tool_name": "search", "tool_call_id": "t1", "arguments": {}}),
69            ),
70        ];
71        let mut buf = Vec::new();
72        write_all(&mut buf, &records).unwrap();
73        let back = parse_all(Cursor::new(buf)).unwrap();
74        assert_eq!(back, records);
75    }
76
77    #[test]
78    fn roundtrip_preserves_content_id() {
79        // If the writer ever started doing something weird to payloads
80        // (e.g. reordering keys), the id would drift. Pin this down.
81        let r = sample(Kind::ChatRequest, json!({"b": 2, "a": 1}));
82        let before = r.id.clone();
83        let mut buf = Vec::new();
84        write_record(&mut buf, &r).unwrap();
85        let back = parse_all(Cursor::new(buf)).unwrap();
86        assert_eq!(back[0].id, before);
87        assert!(back[0].verify_id());
88    }
89
90    #[test]
91    fn empty_input_writes_nothing() {
92        let mut buf = Vec::new();
93        let empty: Vec<&Record> = Vec::new();
94        write_all(&mut buf, empty).unwrap();
95        assert!(buf.is_empty());
96    }
97
98    #[test]
99    fn records_are_separated_by_exactly_one_newline() {
100        let records = vec![
101            sample(Kind::ChatRequest, json!({"n": 1})),
102            sample(Kind::ChatRequest, json!({"n": 2})),
103        ];
104        let mut buf = Vec::new();
105        write_all(&mut buf, &records).unwrap();
106        let s = std::str::from_utf8(&buf).unwrap();
107        // Two records → two newlines.
108        assert_eq!(s.matches('\n').count(), 2);
109        // No blank lines.
110        assert!(!s.contains("\n\n"));
111    }
112}