use serde::{Deserialize, Serialize};
use std::path::Path;
use crate::message::Message;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptMeta {
pub saved_at: String,
pub steps: usize,
pub model: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TranscriptFile {
pub meta: TranscriptMeta,
pub messages: Vec<Message>,
}
impl TranscriptFile {
pub fn new(messages: Vec<Message>, steps: usize, model: Option<String>) -> Self {
let saved_at = chrono_lite_now();
Self {
meta: TranscriptMeta {
saved_at,
steps,
model,
},
messages,
}
}
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string_pretty(self)
}
pub fn write_to(&self, path: &Path) -> std::io::Result<()> {
let json = self
.to_json()
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
crate::atomic::atomic_write(path, json.as_bytes())
}
pub fn read_from(path: &Path) -> std::io::Result<Self> {
let bytes = std::fs::read(path)?;
serde_json::from_slice(&bytes)
.map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
}
pub fn pretty(&self) -> String {
let mut out = String::new();
out.push_str(&format!(
"=== transcript ({} messages) ===\n",
self.messages.len()
));
out.push_str(&format!(
"saved_at: {}\nsteps: {}\nmodel: {}\n\n",
self.meta.saved_at,
self.meta.steps,
self.meta.model.as_deref().unwrap_or("(unknown)"),
));
for (i, msg) in self.messages.iter().enumerate() {
out.push_str(&format!("--- [{}] {:?} ---\n", i, msg.role));
if !msg.content.is_empty() {
out.push_str(&msg.content);
if !msg.content.ends_with('\n') {
out.push('\n');
}
}
out.push('\n');
}
out
}
pub fn pretty_tail(&self, n: usize) -> String {
let total = self.messages.len();
if n >= total {
return self.pretty();
}
let skipped = total - n;
let mut out = String::new();
out.push_str(&format!("...skipped {} earlier messages\n\n", skipped));
out.push_str(&format!(
"=== transcript ({} messages) ===\n",
self.messages.len()
));
out.push_str(&format!(
"saved_at: {}\nsteps: {}\nmodel: {}\n\n",
self.meta.saved_at,
self.meta.steps,
self.meta.model.as_deref().unwrap_or("(unknown)"),
));
let start_idx = total - n;
for (i, msg) in self.messages.iter().enumerate().skip(start_idx) {
out.push_str(&format!("--- [{}] {:?} ---\n", i, msg.role));
if !msg.content.is_empty() {
out.push_str(&msg.content);
if !msg.content.ends_with('\n') {
out.push('\n');
}
}
out.push('\n');
}
out
}
pub fn pretty_head(&self, n: usize) -> String {
let total = self.messages.len();
if n >= total {
return self.pretty();
}
let skipped = total - n;
let mut out = String::new();
out.push_str(&format!(
"=== transcript ({} messages) ===\n",
self.messages.len()
));
out.push_str(&format!(
"saved_at: {}\nsteps: {}\nmodel: {}\n\n",
self.meta.saved_at,
self.meta.steps,
self.meta.model.as_deref().unwrap_or("(unknown)"),
));
for (i, msg) in self.messages.iter().enumerate().take(n) {
out.push_str(&format!("--- [{}] {:?} ---\n", i, msg.role));
if !msg.content.is_empty() {
out.push_str(&msg.content);
if !msg.content.ends_with('\n') {
out.push('\n');
}
}
}
out.push_str(&format!("\n... skipping {} later messages\n", skipped));
out
}
pub fn take_first_n(&self, n: usize) -> Option<&[Message]> {
if n > self.messages.len() {
None
} else {
Some(&self.messages[..n])
}
}
pub fn messages(&self) -> &[Message] {
&self.messages
}
}
fn chrono_lite_now() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let day = secs / 86_400;
let sec_of_day = secs % 86_400;
let (h, m, s) = (sec_of_day / 3600, (sec_of_day / 60) % 60, sec_of_day % 60);
let (y, mo, d) = epoch_day_to_ymd(day as i64);
format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z")
}
fn epoch_day_to_ymd(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
let doe = (z - era * 146_097) as u64;
let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365;
let y = yoe as i64 + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
let y = if m <= 2 { y + 1 } else { y };
(y, m, d)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::message::Message;
#[test]
fn roundtrip_preserves_messages_and_meta() {
let messages = vec![
Message::system("You are a helpful assistant.".to_string()),
Message::user("Hello".to_string()),
Message::assistant("Hi there!".to_string()),
];
let file = TranscriptFile::new(messages.clone(), 3, Some("test-model".to_string()));
let json = file.to_json().unwrap();
let restored: TranscriptFile = serde_json::from_str(&json).unwrap();
assert_eq!(restored.messages.len(), messages.len());
assert_eq!(restored.meta.steps, 3);
assert_eq!(restored.meta.model.as_deref(), Some("test-model"));
}
#[test]
fn write_then_read_via_tempfile() {
let messages = vec![Message::user("test".to_string())];
let file = TranscriptFile::new(messages.clone(), 1, None);
let temp_dir = std::env::temp_dir();
let path = temp_dir.join(format!("test_transcript_{}.json", std::process::id()));
file.write_to(&path).unwrap();
let restored = TranscriptFile::read_from(&path).unwrap();
assert_eq!(restored.messages.len(), 1);
assert_eq!(restored.meta.steps, 1);
assert!(restored.meta.model.is_none());
std::fs::remove_file(&path).ok();
}
#[test]
fn timestamp_format_is_iso_8601_basic() {
let file = TranscriptFile::new(vec![], 0, None);
let ts = &file.meta.saved_at;
assert!(
ts.len() == 20,
"timestamp length should be 20, got {}",
ts.len()
);
assert!(
ts.chars()
.next()
.map(|c| c.is_ascii_digit())
.unwrap_or(false),
"should start with digit: {}",
ts
);
assert!(ts.ends_with('Z'), "should end with Z: {}", ts);
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], "T");
assert_eq!(&ts[13..14], ":");
assert_eq!(&ts[16..17], ":");
}
#[test]
fn meta_model_is_optional() {
let file_with_model = TranscriptFile::new(vec![], 1, Some("model".to_string()));
let file_without_model = TranscriptFile::new(vec![], 1, None);
assert!(file_with_model.meta.model.is_some());
assert!(file_without_model.meta.model.is_none());
let json1 = file_with_model.to_json().unwrap();
let json2 = file_without_model.to_json().unwrap();
let restored1: TranscriptFile = serde_json::from_str(&json1).unwrap();
let restored2: TranscriptFile = serde_json::from_str(&json2).unwrap();
assert!(restored1.meta.model.is_some());
assert!(restored2.meta.model.is_none());
}
#[test]
fn pretty_includes_header_and_meta() {
let file = TranscriptFile::new(
vec![Message::user("hello".to_string())],
5,
Some("gpt-4".to_string()),
);
let output = file.pretty();
assert!(output.contains("=== transcript (1 messages) ==="));
assert!(output.contains("saved_at:"));
assert!(output.contains("steps: 5"));
assert!(output.contains("model: gpt-4"));
}
#[test]
fn pretty_renders_each_message_with_index_and_role() {
let messages = vec![
Message::system("sys".to_string()),
Message::user("usr".to_string()),
Message::assistant("asst".to_string()),
];
let file = TranscriptFile::new(messages, 3, None);
let output = file.pretty();
assert!(output.contains("--- [0] System ---"));
assert!(output.contains("--- [1] User ---"));
assert!(output.contains("--- [2] Assistant ---"));
}
#[test]
fn pretty_handles_empty_content_gracefully() {
let file = TranscriptFile::new(vec![Message::user(String::new())], 1, None);
let output = file.pretty();
assert!(output.contains("--- [0] User ---"));
}
#[test]
fn take_first_n_returns_prefix() {
let messages = vec![
Message::system("sys".to_string()),
Message::user("usr".to_string()),
Message::assistant("asst".to_string()),
];
let file = TranscriptFile::new(messages, 3, None);
let prefix = file.take_first_n(2).expect("n=2 fits");
assert_eq!(prefix.len(), 2);
assert_eq!(prefix[0].content, "sys");
assert_eq!(prefix[1].content, "usr");
}
#[test]
fn take_first_n_zero_returns_empty_slice() {
let file = TranscriptFile::new(vec![Message::user("x".to_string())], 1, None);
let prefix = file.take_first_n(0).expect("n=0 always valid");
assert!(prefix.is_empty());
}
#[test]
fn take_first_n_too_large_returns_none() {
let file = TranscriptFile::new(vec![Message::user("only".to_string())], 1, None);
assert!(file.take_first_n(2).is_none());
}
#[test]
fn take_first_n_exactly_len_returns_all() {
let msgs = vec![
Message::user("a".to_string()),
Message::user("b".to_string()),
];
let file = TranscriptFile::new(msgs, 2, None);
let result = file
.take_first_n(2)
.expect("n == len should return all messages");
assert_eq!(result.len(), 2, "must return all 2 messages when n == len");
}
#[test]
fn pretty_tail_shows_skipped_notice_and_tail() {
let messages = vec![
Message::system("first".to_string()),
Message::user("second".to_string()),
Message::assistant("third".to_string()),
Message::system("fourth".to_string()),
Message::user("fifth".to_string()),
];
let file = TranscriptFile::new(messages, 5, None);
let output = file.pretty_tail(2);
assert!(output.contains("...skipped 3 earlier messages"));
assert!(output.contains("=== transcript (5 messages) ==="));
assert!(output.contains("--- [3] System ---"));
assert!(output.contains("fourth"));
assert!(output.contains("--- [4] User ---"));
assert!(output.contains("fifth"));
assert!(!output.contains("--- [0] System ---"));
assert!(!output.contains("first"));
assert!(!output.contains("--- [1] User ---"));
assert!(!output.contains("second"));
assert!(!output.contains("--- [2] Assistant ---"));
assert!(!output.contains("third"));
}
#[test]
fn pretty_tail_equals_pretty_when_n_large() {
let messages = vec![
Message::system("one".to_string()),
Message::user("two".to_string()),
];
let file = TranscriptFile::new(messages, 2, Some("model".to_string()));
let tail_5 = file.pretty_tail(5);
let full = file.pretty();
assert_eq!(tail_5, full);
let tail_2 = file.pretty_tail(2);
assert_eq!(tail_2, full);
}
#[test]
fn pretty_head_shows_skipped_notice_and_head() {
let messages = vec![
Message::system("first".to_string()),
Message::user("second".to_string()),
Message::assistant("third".to_string()),
Message::system("fourth".to_string()),
Message::user("fifth".to_string()),
];
let file = TranscriptFile::new(messages, 5, None);
let output = file.pretty_head(2);
assert!(output.contains("=== transcript (5 messages) ==="));
assert!(output.contains("... skipping 3 later messages"));
assert!(output.contains("--- [0] System ---"));
assert!(output.contains("first"));
assert!(output.contains("--- [1] User ---"));
assert!(output.contains("second"));
assert!(!output.contains("--- [2] Assistant ---"));
assert!(!output.contains("third"));
assert!(!output.contains("--- [3] System ---"));
assert!(!output.contains("fourth"));
assert!(!output.contains("--- [4] User ---"));
assert!(!output.contains("fifth"));
}
#[test]
fn pretty_head_equals_pretty_when_n_large() {
let messages = vec![
Message::system("one".to_string()),
Message::user("two".to_string()),
];
let file = TranscriptFile::new(messages, 2, Some("model".to_string()));
let head_5 = file.pretty_head(5);
let full = file.pretty();
assert_eq!(head_5, full);
let head_2 = file.pretty_head(2);
assert_eq!(head_2, full);
}
#[test]
fn pretty_empty_content_produces_no_content_line() {
let file = TranscriptFile::new(vec![Message::user(String::new())], 1, None);
let output = file.pretty();
assert!(output.contains("--- [0] User ---"));
let lines: Vec<&str> = output.lines().collect();
let msg_line = lines
.iter()
.position(|l| l.contains("--- [0] User ---"))
.unwrap();
let after_header = lines.get(msg_line + 1).copied().unwrap_or("");
assert!(
after_header.is_empty(),
"empty content must not emit a content line; got: '{after_header}'"
);
}
#[test]
fn pretty_content_with_trailing_newline_not_doubled() {
let file = TranscriptFile::new(vec![Message::user("line\n".to_string())], 1, None);
let output = file.pretty();
assert!(
output.contains("line\n"),
"original newline must be preserved"
);
assert!(
!output.contains("line\n\n\n"),
"triple newline must not appear: content already ends with newline, only block separator should follow"
);
}
#[test]
fn pretty_content_without_trailing_newline_gets_one() {
let file = TranscriptFile::new(vec![Message::user("no-newline".to_string())], 1, None);
let output = file.pretty();
assert!(
output.contains("no-newline\n\n"),
"content without trailing newline must have added newline + block separator"
);
}
#[test]
fn pretty_tail_content_with_trailing_newline_not_doubled() {
let messages = vec![
Message::user("old".to_string()),
Message::user("line\n".to_string()),
];
let file = TranscriptFile::new(messages, 2, None);
let output = file.pretty_tail(1);
assert!(
!output.contains("line\n\n\n"),
"pretty_tail must not triple-newline already-terminated content"
);
}
#[test]
fn pretty_tail_empty_content_produces_no_content_line() {
let messages = vec![
Message::user("old".to_string()),
Message::user(String::new()),
];
let file = TranscriptFile::new(messages, 2, None);
let output = file.pretty_tail(1);
let lines: Vec<&str> = output.lines().collect();
let msg_line = lines
.iter()
.position(|l| l.contains("--- [1] User ---"))
.unwrap();
let after = lines.get(msg_line + 1).copied().unwrap_or("");
assert!(
after.is_empty(),
"empty content in pretty_tail must not emit a content line; got: '{after}'"
);
}
#[test]
fn pretty_head_content_with_trailing_newline_not_doubled() {
let messages = vec![
Message::user("line\n".to_string()),
Message::user("new".to_string()),
];
let file = TranscriptFile::new(messages, 2, None);
let output = file.pretty_head(1);
assert!(
!output.contains("line\n\n\n"),
"pretty_head must not triple-newline already-terminated content"
);
}
#[test]
fn pretty_head_empty_content_produces_no_content_line() {
let messages = vec![
Message::user(String::new()),
Message::user("new".to_string()),
];
let file = TranscriptFile::new(messages, 2, None);
let output = file.pretty_head(1);
let lines: Vec<&str> = output.lines().collect();
let msg_line = lines
.iter()
.position(|l| l.contains("--- [0] User ---"))
.unwrap();
let after = lines.get(msg_line + 1).copied().unwrap_or("");
assert!(
after.is_empty(),
"empty content in pretty_head must not emit a content line; got: '{after}'"
);
}
#[test]
fn epoch_day_to_ymd_unix_epoch() {
assert_eq!(epoch_day_to_ymd(0), (1970, 1, 1));
}
#[test]
fn epoch_day_to_ymd_known_dates() {
assert_eq!(epoch_day_to_ymd(19723), (2024, 1, 1));
assert_eq!(epoch_day_to_ymd(11017), (2000, 3, 1));
assert_eq!(epoch_day_to_ymd(364), (1970, 12, 31));
assert_eq!(epoch_day_to_ymd(365), (1971, 1, 1));
}
#[test]
fn epoch_day_to_ymd_century_correction() {
assert_eq!(epoch_day_to_ymd(47541), (2100, 3, 1));
}
#[test]
fn epoch_day_to_ymd_last_day_of_era() {
assert_eq!(epoch_day_to_ymd(157_113), (2400, 2, 29));
}
#[test]
fn epoch_day_to_ymd_negative_epoch_day_pre_ce() {
assert_eq!(epoch_day_to_ymd(-719528), (0, 1, 1));
}
#[test]
fn epoch_day_to_ymd_february() {
assert_eq!(epoch_day_to_ymd(19754), (2024, 2, 1));
assert_eq!(epoch_day_to_ymd(19782), (2024, 2, 29));
}
#[test]
fn chrono_lite_now_time_matches_system_clock() {
use std::time::{SystemTime, UNIX_EPOCH};
let before_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let ts = chrono_lite_now();
let after_secs = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let h: u64 = ts[11..13].parse().expect("hours in timestamp");
let m: u64 = ts[14..16].parse().expect("minutes in timestamp");
let s: u64 = ts[17..19].parse().expect("seconds in timestamp");
let sec_of_ts = h * 3600 + m * 60 + s;
let sec_before = before_secs % 86_400;
let sec_after = after_secs % 86_400;
assert!(
sec_of_ts >= sec_before && sec_of_ts <= sec_after + 1,
"chrono_lite_now() time component {h:02}:{m:02}:{s:02} (sec={sec_of_ts}) must \
fall within system-clock window [{sec_before},{sec_after}]; ts={ts}"
);
}
#[test]
fn messages_accessor_returns_correct_slice() {
let expected = vec![
Message::user("hello".to_string()),
Message::assistant("world".to_string()),
];
let file = TranscriptFile::new(expected.clone(), 1, None);
let slice = file.messages();
assert_eq!(slice.len(), 2, "messages() must return all messages");
assert_eq!(slice[0].content, "hello");
assert_eq!(slice[1].content, "world");
}
#[test]
fn chrono_lite_now_contains_correct_year_and_format() {
let ts = chrono_lite_now();
assert_eq!(ts.len(), 20, "timestamp must be 20 chars: {ts}");
let year: u32 = ts[..4].parse().expect("first 4 chars must be year digits");
assert!(year >= 2024, "year must be >= 2024, got {year}");
assert!(ts.ends_with('Z'));
assert_eq!(&ts[4..5], "-");
assert_eq!(&ts[7..8], "-");
assert_eq!(&ts[10..11], "T");
}
#[test]
fn chrono_lite_now_date_matches_system_clock() {
use std::time::{SystemTime, UNIX_EPOCH};
let before = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let ts = chrono_lite_now();
let after = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let date_ts = &ts[..10];
let (yb, mob, db) = epoch_day_to_ymd((before / 86_400) as i64);
let (ya, moa, da) = epoch_day_to_ymd((after / 86_400) as i64);
let date_before = format!("{yb:04}-{mob:02}-{db:02}");
let date_after = format!("{ya:04}-{moa:02}-{da:02}");
assert!(
date_ts == date_before || date_ts == date_after,
"chrono_lite_now() date '{date_ts}' must match system date \
'{date_before}' (or '{date_after}' at day boundary); ts={ts}"
);
}
}