Skip to main content

recursive/
transcript.rs

1//! Persistent on-disk format for transcripts.
2//!
3//! A `TranscriptFile` is everything you need to inspect or replay a
4//! past run: the list of messages exchanged, plus a small `meta`
5//! block describing the run.
6
7use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10use crate::message::Message;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TranscriptMeta {
14    /// ISO-8601 timestamp when the run was saved.
15    pub saved_at: String,
16    /// Number of steps the agent loop executed.
17    pub steps: usize,
18    /// Optional human label (often the model name).
19    pub model: Option<String>,
20}
21
22#[derive(Debug, Clone, Serialize, Deserialize)]
23pub struct TranscriptFile {
24    pub meta: TranscriptMeta,
25    pub messages: Vec<Message>,
26}
27
28impl TranscriptFile {
29    pub fn new(messages: Vec<Message>, steps: usize, model: Option<String>) -> Self {
30        let saved_at = chrono_lite_now();
31        Self {
32            meta: TranscriptMeta {
33                saved_at,
34                steps,
35                model,
36            },
37            messages,
38        }
39    }
40
41    /// Pretty-printed JSON. Stable enough to be diffed across runs.
42    pub fn to_json(&self) -> Result<String, serde_json::Error> {
43        serde_json::to_string_pretty(self)
44    }
45
46    pub fn write_to(&self, path: &Path) -> std::io::Result<()> {
47        let json = self
48            .to_json()
49            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
50        std::fs::write(path, json)
51    }
52
53    pub fn read_from(path: &Path) -> std::io::Result<Self> {
54        let bytes = std::fs::read(path)?;
55        serde_json::from_slice(&bytes)
56            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
57    }
58
59    /// Render the transcript as a human-readable string suitable for
60    /// piping to a pager.
61    pub fn pretty(&self) -> String {
62        let mut out = String::new();
63        out.push_str(&format!(
64            "=== transcript ({} messages) ===\n",
65            self.messages.len()
66        ));
67        out.push_str(&format!(
68            "saved_at: {}\nsteps: {}\nmodel: {}\n\n",
69            self.meta.saved_at,
70            self.meta.steps,
71            self.meta.model.as_deref().unwrap_or("(unknown)"),
72        ));
73        for (i, msg) in self.messages.iter().enumerate() {
74            out.push_str(&format!("--- [{}] {:?} ---\n", i, msg.role));
75            if !msg.content.is_empty() {
76                out.push_str(&msg.content);
77                if !msg.content.ends_with('\n') {
78                    out.push('\n');
79                }
80            }
81            out.push('\n');
82        }
83        out
84    }
85
86    /// Render the last `n` messages as a human-readable string.
87    /// If `n` is less than the total, prepends a "skipped" notice.
88    pub fn pretty_tail(&self, n: usize) -> String {
89        let total = self.messages.len();
90        // If n >= total, behave like pretty()
91        if n >= total {
92            return self.pretty();
93        }
94
95        let skipped = total - n;
96        let mut out = String::new();
97        out.push_str(&format!("...skipped {} earlier messages\n\n", skipped));
98        out.push_str(&format!(
99            "=== transcript ({} messages) ===\n",
100            self.messages.len()
101        ));
102        out.push_str(&format!(
103            "saved_at: {}\nsteps: {}\nmodel: {}\n\n",
104            self.meta.saved_at,
105            self.meta.steps,
106            self.meta.model.as_deref().unwrap_or("(unknown)"),
107        ));
108        // Only render the last n messages, but preserve their original indices
109        let start_idx = total - n;
110        for (i, msg) in self.messages.iter().enumerate().skip(start_idx) {
111            out.push_str(&format!("--- [{}] {:?} ---\n", i, msg.role));
112            if !msg.content.is_empty() {
113                out.push_str(&msg.content);
114                if !msg.content.ends_with('\n') {
115                    out.push('\n');
116                }
117            }
118            out.push('\n');
119        }
120        out
121    }
122
123    /// Render the first `n` messages as a human-readable string.
124    /// If `n` is less than the total, appends a "skipped" notice.
125    pub fn pretty_head(&self, n: usize) -> String {
126        let total = self.messages.len();
127        // If n >= total, behave like pretty()
128        if n >= total {
129            return self.pretty();
130        }
131
132        let skipped = total - n;
133        let mut out = String::new();
134        // Render the first n messages
135        out.push_str(&format!(
136            "=== transcript ({} messages) ===\n",
137            self.messages.len()
138        ));
139        out.push_str(&format!(
140            "saved_at: {}\nsteps: {}\nmodel: {}\n\n",
141            self.meta.saved_at,
142            self.meta.steps,
143            self.meta.model.as_deref().unwrap_or("(unknown)"),
144        ));
145        // Only render the first n messages (indices 0 to n-1)
146        for (i, msg) in self.messages.iter().enumerate().take(n) {
147            out.push_str(&format!("--- [{}] {:?} ---\n", i, msg.role));
148            if !msg.content.is_empty() {
149                out.push_str(&msg.content);
150                if !msg.content.ends_with('\n') {
151                    out.push('\n');
152                }
153            }
154        }
155        // Add skipped notice at the end
156        out.push_str(&format!("\n... skipping {} later messages\n", skipped));
157        out
158    }
159
160    /// Return the first `n` messages (`None` if `n` exceeds the count).
161    /// `n == 0` returns an empty slice, useful for "start fresh but
162    /// preserve nothing" callers.
163    pub fn take_first_n(&self, n: usize) -> Option<&[Message]> {
164        if n > self.messages.len() {
165            None
166        } else {
167            Some(&self.messages[..n])
168        }
169    }
170
171    /// Expose the messages slice for callers that need full access (e.g.
172    /// `replay --resume-from N` printing context before continuing).
173    pub fn messages(&self) -> &[Message] {
174        &self.messages
175    }
176}
177
178// Tiny RFC3339-ish timestamp without pulling in `chrono`. Format:
179// "YYYY-MM-DDTHH:MM:SSZ" using UTC.
180fn chrono_lite_now() -> String {
181    use std::time::{SystemTime, UNIX_EPOCH};
182    let secs = SystemTime::now()
183        .duration_since(UNIX_EPOCH)
184        .map(|d| d.as_secs())
185        .unwrap_or(0);
186    // Convert epoch secs to UTC date+time without a calendar library.
187    // Days since 1970-01-01:
188    let day = secs / 86_400;
189    let sec_of_day = secs % 86_400;
190    let (h, m, s) = (sec_of_day / 3600, (sec_of_day / 60) % 60, sec_of_day % 60);
191    let (y, mo, d) = epoch_day_to_ymd(day as i64);
192    format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
193}
194
195/// Convert "days since 1970-01-01" to (year, month, day) using the
196/// civil-from-days algorithm by Howard Hinnant. Public-domain, exact
197/// for any 64-bit day count.
198fn epoch_day_to_ymd(z: i64) -> (i64, u32, u32) {
199    let z = z + 719_468;
200    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
201    let doe = (z - era * 146_097) as u64;
202    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
203    let y = yoe as i64 + era * 400;
204    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
205    let mp = (5 * doy + 2) / 153;
206    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
207    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
208    let y = if m <= 2 { y + 1 } else { y };
209    (y, m, d)
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::message::Message;
216
217    #[test]
218    fn roundtrip_preserves_messages_and_meta() {
219        let messages = vec![
220            Message::system("You are a helpful assistant.".to_string()),
221            Message::user("Hello".to_string()),
222            Message::assistant("Hi there!".to_string()),
223        ];
224        let file = TranscriptFile::new(messages.clone(), 3, Some("test-model".to_string()));
225
226        let json = file.to_json().unwrap();
227        let restored: TranscriptFile = serde_json::from_str(&json).unwrap();
228
229        assert_eq!(restored.messages.len(), messages.len());
230        assert_eq!(restored.meta.steps, 3);
231        assert_eq!(restored.meta.model.as_deref(), Some("test-model"));
232    }
233
234    #[test]
235    fn write_then_read_via_tempfile() {
236        let messages = vec![Message::user("test".to_string())];
237        let file = TranscriptFile::new(messages.clone(), 1, None);
238
239        let temp_dir = std::env::temp_dir();
240        let path = temp_dir.join(format!("test_transcript_{}.json", std::process::id()));
241
242        file.write_to(&path).unwrap();
243
244        let restored = TranscriptFile::read_from(&path).unwrap();
245        assert_eq!(restored.messages.len(), 1);
246        assert_eq!(restored.meta.steps, 1);
247        assert!(restored.meta.model.is_none());
248
249        // Clean up
250        std::fs::remove_file(&path).ok();
251    }
252
253    #[test]
254    fn timestamp_format_is_iso_8601_basic() {
255        let file = TranscriptFile::new(vec![], 0, None);
256        let ts = &file.meta.saved_at;
257
258        // Format: YYYY-MM-DDTHH:MM:SSZ
259        assert!(
260            ts.len() == 20,
261            "timestamp length should be 20, got {}",
262            ts.len()
263        );
264        assert!(
265            ts.chars()
266                .next()
267                .map(|c| c.is_ascii_digit())
268                .unwrap_or(false),
269            "should start with digit: {}",
270            ts
271        );
272        assert!(ts.ends_with('Z'), "should end with Z: {}", ts);
273
274        // Check structure: XXXX-XX-XXTXX:XX:XXZ
275        assert_eq!(&ts[4..5], "-");
276        assert_eq!(&ts[7..8], "-");
277        assert_eq!(&ts[10..11], "T");
278        assert_eq!(&ts[13..14], ":");
279        assert_eq!(&ts[16..17], ":");
280    }
281
282    #[test]
283    fn meta_model_is_optional() {
284        let file_with_model = TranscriptFile::new(vec![], 1, Some("model".to_string()));
285        let file_without_model = TranscriptFile::new(vec![], 1, None);
286
287        assert!(file_with_model.meta.model.is_some());
288        assert!(file_without_model.meta.model.is_none());
289
290        // Roundtrip both
291        let json1 = file_with_model.to_json().unwrap();
292        let json2 = file_without_model.to_json().unwrap();
293
294        let restored1: TranscriptFile = serde_json::from_str(&json1).unwrap();
295        let restored2: TranscriptFile = serde_json::from_str(&json2).unwrap();
296
297        assert!(restored1.meta.model.is_some());
298        assert!(restored2.meta.model.is_none());
299    }
300
301    #[test]
302    fn pretty_includes_header_and_meta() {
303        let file = TranscriptFile::new(
304            vec![Message::user("hello".to_string())],
305            5,
306            Some("gpt-4".to_string()),
307        );
308        let output = file.pretty();
309        assert!(output.contains("=== transcript (1 messages) ==="));
310        assert!(output.contains("saved_at:"));
311        assert!(output.contains("steps: 5"));
312        assert!(output.contains("model: gpt-4"));
313    }
314
315    #[test]
316    fn pretty_renders_each_message_with_index_and_role() {
317        let messages = vec![
318            Message::system("sys".to_string()),
319            Message::user("usr".to_string()),
320            Message::assistant("asst".to_string()),
321        ];
322        let file = TranscriptFile::new(messages, 3, None);
323        let output = file.pretty();
324        assert!(output.contains("--- [0] System ---"));
325        assert!(output.contains("--- [1] User ---"));
326        assert!(output.contains("--- [2] Assistant ---"));
327    }
328
329    #[test]
330    fn pretty_handles_empty_content_gracefully() {
331        let file = TranscriptFile::new(vec![Message::user(String::new())], 1, None);
332        let output = file.pretty();
333        assert!(output.contains("--- [0] User ---"));
334        // No crash; section header still present
335    }
336
337    #[test]
338    fn take_first_n_returns_prefix() {
339        let messages = vec![
340            Message::system("sys".to_string()),
341            Message::user("usr".to_string()),
342            Message::assistant("asst".to_string()),
343        ];
344        let file = TranscriptFile::new(messages, 3, None);
345        let prefix = file.take_first_n(2).expect("n=2 fits");
346        assert_eq!(prefix.len(), 2);
347        assert_eq!(prefix[0].content, "sys");
348        assert_eq!(prefix[1].content, "usr");
349    }
350
351    #[test]
352    fn take_first_n_zero_returns_empty_slice() {
353        let file = TranscriptFile::new(vec![Message::user("x".to_string())], 1, None);
354        let prefix = file.take_first_n(0).expect("n=0 always valid");
355        assert!(prefix.is_empty());
356    }
357
358    #[test]
359    fn take_first_n_too_large_returns_none() {
360        let file = TranscriptFile::new(vec![Message::user("only".to_string())], 1, None);
361        assert!(file.take_first_n(2).is_none());
362    }
363
364    #[test]
365    fn pretty_tail_shows_skipped_notice_and_tail() {
366        let messages = vec![
367            Message::system("first".to_string()),
368            Message::user("second".to_string()),
369            Message::assistant("third".to_string()),
370            Message::system("fourth".to_string()),
371            Message::user("fifth".to_string()),
372        ];
373        let file = TranscriptFile::new(messages, 5, None);
374        let output = file.pretty_tail(2);
375
376        // Should contain skipped notice
377        assert!(output.contains("...skipped 3 earlier messages"));
378        // Should contain header
379        assert!(output.contains("=== transcript (5 messages) ==="));
380        // Should contain the last 2 messages (indices 3 and 4)
381        assert!(output.contains("--- [3] System ---"));
382        assert!(output.contains("fourth"));
383        assert!(output.contains("--- [4] User ---"));
384        assert!(output.contains("fifth"));
385        // Should NOT contain the first 3 messages
386        assert!(!output.contains("--- [0] System ---"));
387        assert!(!output.contains("first"));
388        assert!(!output.contains("--- [1] User ---"));
389        assert!(!output.contains("second"));
390        assert!(!output.contains("--- [2] Assistant ---"));
391        assert!(!output.contains("third"));
392    }
393
394    #[test]
395    fn pretty_tail_equals_pretty_when_n_large() {
396        let messages = vec![
397            Message::system("one".to_string()),
398            Message::user("two".to_string()),
399        ];
400        let file = TranscriptFile::new(messages, 2, Some("model".to_string()));
401
402        // n >= total should be equivalent to pretty()
403        let tail_5 = file.pretty_tail(5);
404        let full = file.pretty();
405        assert_eq!(tail_5, full);
406
407        // n == total should also be equivalent
408        let tail_2 = file.pretty_tail(2);
409        assert_eq!(tail_2, full);
410    }
411
412    #[test]
413    fn pretty_head_shows_skipped_notice_and_head() {
414        let messages = vec![
415            Message::system("first".to_string()),
416            Message::user("second".to_string()),
417            Message::assistant("third".to_string()),
418            Message::system("fourth".to_string()),
419            Message::user("fifth".to_string()),
420        ];
421        let file = TranscriptFile::new(messages, 5, None);
422        let output = file.pretty_head(2);
423
424        // Should contain header
425        assert!(output.contains("=== transcript (5 messages) ==="));
426        // Should contain skipped notice at the end
427        assert!(output.contains("... skipping 3 later messages"));
428        // Should contain the first 2 messages (indices 0 and 1)
429        assert!(output.contains("--- [0] System ---"));
430        assert!(output.contains("first"));
431        assert!(output.contains("--- [1] User ---"));
432        assert!(output.contains("second"));
433        // Should NOT contain the last 3 messages
434        assert!(!output.contains("--- [2] Assistant ---"));
435        assert!(!output.contains("third"));
436        assert!(!output.contains("--- [3] System ---"));
437        assert!(!output.contains("fourth"));
438        assert!(!output.contains("--- [4] User ---"));
439        assert!(!output.contains("fifth"));
440    }
441
442    #[test]
443    fn pretty_head_equals_pretty_when_n_large() {
444        let messages = vec![
445            Message::system("one".to_string()),
446            Message::user("two".to_string()),
447        ];
448        let file = TranscriptFile::new(messages, 2, Some("model".to_string()));
449
450        // n >= total should be equivalent to pretty()
451        let head_5 = file.pretty_head(5);
452        let full = file.pretty();
453        assert_eq!(head_5, full);
454
455        // n == total should also be equivalent
456        let head_2 = file.pretty_head(2);
457        assert_eq!(head_2, full);
458    }
459}