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        crate::atomic::atomic_write(path, json.as_bytes())
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 take_first_n_exactly_len_returns_all() {
366        // Kills: `replace > with >=` in take_first_n.
367        // n == len must return Some (all messages), NOT None.
368        // With `>= `, `n >= len` would be true and return None incorrectly.
369        let msgs = vec![
370            Message::user("a".to_string()),
371            Message::user("b".to_string()),
372        ];
373        let file = TranscriptFile::new(msgs, 2, None);
374        let result = file
375            .take_first_n(2)
376            .expect("n == len should return all messages");
377        assert_eq!(result.len(), 2, "must return all 2 messages when n == len");
378    }
379
380    #[test]
381    fn pretty_tail_shows_skipped_notice_and_tail() {
382        let messages = vec![
383            Message::system("first".to_string()),
384            Message::user("second".to_string()),
385            Message::assistant("third".to_string()),
386            Message::system("fourth".to_string()),
387            Message::user("fifth".to_string()),
388        ];
389        let file = TranscriptFile::new(messages, 5, None);
390        let output = file.pretty_tail(2);
391
392        // Should contain skipped notice
393        assert!(output.contains("...skipped 3 earlier messages"));
394        // Should contain header
395        assert!(output.contains("=== transcript (5 messages) ==="));
396        // Should contain the last 2 messages (indices 3 and 4)
397        assert!(output.contains("--- [3] System ---"));
398        assert!(output.contains("fourth"));
399        assert!(output.contains("--- [4] User ---"));
400        assert!(output.contains("fifth"));
401        // Should NOT contain the first 3 messages
402        assert!(!output.contains("--- [0] System ---"));
403        assert!(!output.contains("first"));
404        assert!(!output.contains("--- [1] User ---"));
405        assert!(!output.contains("second"));
406        assert!(!output.contains("--- [2] Assistant ---"));
407        assert!(!output.contains("third"));
408    }
409
410    #[test]
411    fn pretty_tail_equals_pretty_when_n_large() {
412        let messages = vec![
413            Message::system("one".to_string()),
414            Message::user("two".to_string()),
415        ];
416        let file = TranscriptFile::new(messages, 2, Some("model".to_string()));
417
418        // n >= total should be equivalent to pretty()
419        let tail_5 = file.pretty_tail(5);
420        let full = file.pretty();
421        assert_eq!(tail_5, full);
422
423        // n == total should also be equivalent
424        let tail_2 = file.pretty_tail(2);
425        assert_eq!(tail_2, full);
426    }
427
428    #[test]
429    fn pretty_head_shows_skipped_notice_and_head() {
430        let messages = vec![
431            Message::system("first".to_string()),
432            Message::user("second".to_string()),
433            Message::assistant("third".to_string()),
434            Message::system("fourth".to_string()),
435            Message::user("fifth".to_string()),
436        ];
437        let file = TranscriptFile::new(messages, 5, None);
438        let output = file.pretty_head(2);
439
440        // Should contain header
441        assert!(output.contains("=== transcript (5 messages) ==="));
442        // Should contain skipped notice at the end
443        assert!(output.contains("... skipping 3 later messages"));
444        // Should contain the first 2 messages (indices 0 and 1)
445        assert!(output.contains("--- [0] System ---"));
446        assert!(output.contains("first"));
447        assert!(output.contains("--- [1] User ---"));
448        assert!(output.contains("second"));
449        // Should NOT contain the last 3 messages
450        assert!(!output.contains("--- [2] Assistant ---"));
451        assert!(!output.contains("third"));
452        assert!(!output.contains("--- [3] System ---"));
453        assert!(!output.contains("fourth"));
454        assert!(!output.contains("--- [4] User ---"));
455        assert!(!output.contains("fifth"));
456    }
457
458    #[test]
459    fn pretty_head_equals_pretty_when_n_large() {
460        let messages = vec![
461            Message::system("one".to_string()),
462            Message::user("two".to_string()),
463        ];
464        let file = TranscriptFile::new(messages, 2, Some("model".to_string()));
465
466        // n >= total should be equivalent to pretty()
467        let head_5 = file.pretty_head(5);
468        let full = file.pretty();
469        assert_eq!(head_5, full);
470
471        // n == total should also be equivalent
472        let head_2 = file.pretty_head(2);
473        assert_eq!(head_2, full);
474    }
475
476    // -----------------------------------------------------------------------
477    // pretty(): empty-content and newline-handling edge cases
478    // (kills delete-! mutants on is_empty() and ends_with('\n'))
479    // -----------------------------------------------------------------------
480
481    #[test]
482    fn pretty_empty_content_produces_no_content_line() {
483        // Message with empty content must not produce a content line
484        let file = TranscriptFile::new(vec![Message::user(String::new())], 1, None);
485        let output = file.pretty();
486        // The header for the message is present
487        assert!(output.contains("--- [0] User ---"));
488        // But there must be no empty "content" output between the header and the next blank line
489        // The output must NOT have two consecutive blank lines (which would happen if empty content
490        // is printed as an empty string + newline).
491        let lines: Vec<&str> = output.lines().collect();
492        let msg_line = lines
493            .iter()
494            .position(|l| l.contains("--- [0] User ---"))
495            .unwrap();
496        // The line immediately after the header should be empty (the separator blank line)
497        // i.e. no content was emitted between the header and the end of the block.
498        let after_header = lines.get(msg_line + 1).copied().unwrap_or("");
499        assert!(
500            after_header.is_empty(),
501            "empty content must not emit a content line; got: '{after_header}'"
502        );
503    }
504
505    #[test]
506    fn pretty_content_with_trailing_newline_not_doubled() {
507        // Content already ending with '\n' must NOT get an extra '\n' before the block separator.
508        // The block separator adds one '\n' always, so the sequence should be:
509        //   "line\n" + block_sep "\n"  = "line\n\n"  (2 newlines, not 3)
510        let file = TranscriptFile::new(vec![Message::user("line\n".to_string())], 1, None);
511        let output = file.pretty();
512        assert!(
513            output.contains("line\n"),
514            "original newline must be preserved"
515        );
516        assert!(
517            !output.contains("line\n\n\n"),
518            "triple newline must not appear: content already ends with newline, only block separator should follow"
519        );
520    }
521
522    #[test]
523    fn pretty_content_without_trailing_newline_gets_one() {
524        // Content without trailing '\n': the function adds one, then the block separator adds another.
525        // With mutant (delete !): no newline is added, so "no-newline" would be followed only by
526        // the block separator → the message wouldn't end with "no-newline\n\n" but "no-newline\n".
527        let file = TranscriptFile::new(vec![Message::user("no-newline".to_string())], 1, None);
528        let output = file.pretty();
529        // Both the added newline AND the block separator must follow the content
530        assert!(
531            output.contains("no-newline\n\n"),
532            "content without trailing newline must have added newline + block separator"
533        );
534    }
535
536    // Same edge cases for pretty_tail
537
538    #[test]
539    fn pretty_tail_content_with_trailing_newline_not_doubled() {
540        let messages = vec![
541            Message::user("old".to_string()),
542            Message::user("line\n".to_string()),
543        ];
544        let file = TranscriptFile::new(messages, 2, None);
545        let output = file.pretty_tail(1);
546        assert!(
547            !output.contains("line\n\n\n"),
548            "pretty_tail must not triple-newline already-terminated content"
549        );
550    }
551
552    #[test]
553    fn pretty_tail_empty_content_produces_no_content_line() {
554        let messages = vec![
555            Message::user("old".to_string()),
556            Message::user(String::new()),
557        ];
558        let file = TranscriptFile::new(messages, 2, None);
559        let output = file.pretty_tail(1);
560        let lines: Vec<&str> = output.lines().collect();
561        let msg_line = lines
562            .iter()
563            .position(|l| l.contains("--- [1] User ---"))
564            .unwrap();
565        let after = lines.get(msg_line + 1).copied().unwrap_or("");
566        assert!(
567            after.is_empty(),
568            "empty content in pretty_tail must not emit a content line; got: '{after}'"
569        );
570    }
571
572    // Same edge cases for pretty_head
573
574    #[test]
575    fn pretty_head_content_with_trailing_newline_not_doubled() {
576        let messages = vec![
577            Message::user("line\n".to_string()),
578            Message::user("new".to_string()),
579        ];
580        let file = TranscriptFile::new(messages, 2, None);
581        let output = file.pretty_head(1);
582        assert!(
583            !output.contains("line\n\n\n"),
584            "pretty_head must not triple-newline already-terminated content"
585        );
586    }
587
588    #[test]
589    fn pretty_head_empty_content_produces_no_content_line() {
590        let messages = vec![
591            Message::user(String::new()),
592            Message::user("new".to_string()),
593        ];
594        let file = TranscriptFile::new(messages, 2, None);
595        let output = file.pretty_head(1);
596        let lines: Vec<&str> = output.lines().collect();
597        let msg_line = lines
598            .iter()
599            .position(|l| l.contains("--- [0] User ---"))
600            .unwrap();
601        let after = lines.get(msg_line + 1).copied().unwrap_or("");
602        assert!(
603            after.is_empty(),
604            "empty content in pretty_head must not emit a content line; got: '{after}'"
605        );
606    }
607
608    // -----------------------------------------------------------------------
609    // epoch_day_to_ymd: known-date tests (kills arithmetic mutants)
610    // -----------------------------------------------------------------------
611
612    #[test]
613    fn epoch_day_to_ymd_unix_epoch() {
614        // Day 0 = 1970-01-01
615        assert_eq!(epoch_day_to_ymd(0), (1970, 1, 1));
616    }
617
618    #[test]
619    fn epoch_day_to_ymd_known_dates() {
620        // 2024-01-01 = day 19723 from epoch (2024-1970)*365 + leap days
621        assert_eq!(epoch_day_to_ymd(19723), (2024, 1, 1));
622        // 2000-03-01 is a known leap-year boundary date
623        // Days from 1970-01-01 to 2000-03-01 = 11017
624        assert_eq!(epoch_day_to_ymd(11017), (2000, 3, 1));
625        // 1970-12-31 = day 364
626        assert_eq!(epoch_day_to_ymd(364), (1970, 12, 31));
627        // 1971-01-01 = day 365
628        assert_eq!(epoch_day_to_ymd(365), (1971, 1, 1));
629    }
630
631    /// Kills: `replace + with -` at line 202:33 AND `replace - with +` at line 202:47.
632    ///
633    /// Unix day 47541 = 2100-03-01; at this point `doe = 36524` exactly (first day
634    /// where `doe / 36524 = 1`).  Both mutations flip the century-correction term
635    /// in the `yoe` formula, shifting the result by 2 years in opposite directions:
636    /// mutant 202:33 produces year 2099; mutant 202:47 produces year 2101.
637    #[test]
638    fn epoch_day_to_ymd_century_correction() {
639        // 2100-03-01: first day after the "no leap year in 2100" boundary.
640        assert_eq!(epoch_day_to_ymd(47541), (2100, 3, 1));
641    }
642
643    /// Kills: `replace - with +` at line 202:47 (second subtraction in yoe formula).
644    ///
645    /// `yoe = (doe - doe/1460 + doe/36524 - doe/146_096) / 365`
646    /// The term `doe / 146_096` is 0 for most dates, so mutations there appear
647    /// identical.  It equals 1 only when `doe = 146_096` — the very last day of a
648    /// 400-year era.  Day 157_113 = 2400-02-29 has era=5, doe=146_096.
649    ///
650    /// Original: yoe = (146_096 − 100 + 3 − 1) / 365 = 399 → year 2399 → adj +1 → 2400.
651    /// Mutant  : yoe = (146_096 − 100 + 3 + 1) / 365 = 400 → year 2400 → adj +1 → 2401.
652    #[test]
653    fn epoch_day_to_ymd_last_day_of_era() {
654        // 2400-02-29: last day of the 400-year era ending in 2400.
655        // 2400-01-01 = day 10957 + 146_097 = 157_054; +31 (Jan) +28 = 157_113.
656        assert_eq!(epoch_day_to_ymd(157_113), (2400, 2, 29));
657    }
658
659    /// Kills: `replace - with +` and `replace - with /` at line 200:40.
660    ///
661    /// Unix day -719528 is proleptic Gregorian year 0, January 1.
662    /// z = -60 < 0, so the `else { z - 146_096 }` branch runs for the era calculation.
663    /// Both mutations replace `-` in `z - 146_096`:
664    ///   `+ → +`: era becomes (−60 + 146096) / 146097 = 0 instead of -1 → huge doe overflow.
665    ///   `÷ → /`: era becomes (−60 / 146096) = 0 instead of -1 → same overflow.
666    #[test]
667    fn epoch_day_to_ymd_negative_epoch_day_pre_ce() {
668        // Year 0 Jan 1 = Unix day -719528 (proleptic Gregorian year 0 = 1 BC).
669        assert_eq!(epoch_day_to_ymd(-719528), (0, 1, 1));
670    }
671
672    /// Kills: `replace - with /` at line 207:46.
673    ///
674    /// In the month mapping `if mp < 10 { mp + 3 } else { mp - 9 }`, the mutation
675    /// changes `mp - 9` to `mp / 9`.  For mp=11 (February): `11/9 = 1` (January!)
676    /// but `11 - 9 = 2` (February).  A February date catches the mutation.
677    #[test]
678    fn epoch_day_to_ymd_february() {
679        // 2024-02-01 = 2024-01-01 + 31 = day 19723 + 31 = 19754
680        assert_eq!(epoch_day_to_ymd(19754), (2024, 2, 1));
681        // 2024-02-29 = leap day
682        assert_eq!(epoch_day_to_ymd(19782), (2024, 2, 29));
683    }
684
685    /// Kills:
686    ///   `replace % with / at 189:27` — sec_of_day becomes the day count (huge value)
687    ///   `replace / with * at 190:53` — minutes formula becomes `(n*60)%60 = 0` always
688    ///
689    /// By comparing the time component of `chrono_lite_now()` against the system clock
690    /// we verify the arithmetic produces the correct h/m/s, not an arbitrarily wrong one.
691    #[test]
692    fn chrono_lite_now_time_matches_system_clock() {
693        use std::time::{SystemTime, UNIX_EPOCH};
694
695        let before_secs = SystemTime::now()
696            .duration_since(UNIX_EPOCH)
697            .map(|d| d.as_secs())
698            .unwrap_or(0);
699
700        let ts = chrono_lite_now();
701
702        let after_secs = SystemTime::now()
703            .duration_since(UNIX_EPOCH)
704            .map(|d| d.as_secs())
705            .unwrap_or(0);
706
707        let h: u64 = ts[11..13].parse().expect("hours in timestamp");
708        let m: u64 = ts[14..16].parse().expect("minutes in timestamp");
709        let s: u64 = ts[17..19].parse().expect("seconds in timestamp");
710
711        let sec_of_ts = h * 3600 + m * 60 + s;
712        let sec_before = before_secs % 86_400;
713        let sec_after = after_secs % 86_400;
714
715        assert!(
716            sec_of_ts >= sec_before && sec_of_ts <= sec_after + 1,
717            "chrono_lite_now() time component {h:02}:{m:02}:{s:02} (sec={sec_of_ts}) must \
718             fall within system-clock window [{sec_before},{sec_after}]; ts={ts}"
719        );
720    }
721
722    /// Kills: `replace TranscriptFile::messages -> &[Message] with Vec::leak(Vec::new())`.
723    ///
724    /// The mutation replaces the body with an empty leaked vec, so the accessor
725    /// always returns `&[]`.  Calling `.messages()` and checking the content
726    /// pins the correct implementation.
727    #[test]
728    fn messages_accessor_returns_correct_slice() {
729        let expected = vec![
730            Message::user("hello".to_string()),
731            Message::assistant("world".to_string()),
732        ];
733        let file = TranscriptFile::new(expected.clone(), 1, None);
734        let slice = file.messages();
735        assert_eq!(slice.len(), 2, "messages() must return all messages");
736        assert_eq!(slice[0].content, "hello");
737        assert_eq!(slice[1].content, "world");
738    }
739
740    #[test]
741    fn chrono_lite_now_contains_correct_year_and_format() {
742        let ts = chrono_lite_now();
743        // Must be 20 chars: "YYYY-MM-DDTHH:MM:SSZ"
744        assert_eq!(ts.len(), 20, "timestamp must be 20 chars: {ts}");
745        // Year must be >= 2024 (this test won't be run before then)
746        let year: u32 = ts[..4].parse().expect("first 4 chars must be year digits");
747        assert!(year >= 2024, "year must be >= 2024, got {year}");
748        assert!(ts.ends_with('Z'));
749        // Structure delimiters
750        assert_eq!(&ts[4..5], "-");
751        assert_eq!(&ts[7..8], "-");
752        assert_eq!(&ts[10..11], "T");
753    }
754
755    /// Kills: `replace / with %` at line 188:20 (`secs / 86_400`).
756    ///
757    /// With the mutant, `day = secs % 86_400` gives the seconds-within-day
758    /// (0 to 86399) instead of the day-since-epoch (~20000+).  The resulting
759    /// date string would be somewhere in 1970–2206 rather than the actual
760    /// current date, so comparing the date portion against the system clock
761    /// reliably catches the mutation.
762    #[test]
763    fn chrono_lite_now_date_matches_system_clock() {
764        use std::time::{SystemTime, UNIX_EPOCH};
765
766        let before = SystemTime::now()
767            .duration_since(UNIX_EPOCH)
768            .map(|d| d.as_secs())
769            .unwrap_or(0);
770
771        let ts = chrono_lite_now();
772
773        let after = SystemTime::now()
774            .duration_since(UNIX_EPOCH)
775            .map(|d| d.as_secs())
776            .unwrap_or(0);
777
778        let date_ts = &ts[..10];
779
780        let (yb, mob, db) = epoch_day_to_ymd((before / 86_400) as i64);
781        let (ya, moa, da) = epoch_day_to_ymd((after / 86_400) as i64);
782        let date_before = format!("{yb:04}-{mob:02}-{db:02}");
783        let date_after = format!("{ya:04}-{moa:02}-{da:02}");
784
785        assert!(
786            date_ts == date_before || date_ts == date_after,
787            "chrono_lite_now() date '{date_ts}' must match system date \
788             '{date_before}' (or '{date_after}' at day boundary); ts={ts}"
789        );
790    }
791}