1use serde::{Deserialize, Serialize};
8use std::path::Path;
9
10use crate::message::Message;
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct TranscriptMeta {
14 pub saved_at: String,
16 pub steps: usize,
18 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 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 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 pub fn pretty_tail(&self, n: usize) -> String {
89 let total = self.messages.len();
90 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 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 pub fn pretty_head(&self, n: usize) -> String {
126 let total = self.messages.len();
127 if n >= total {
129 return self.pretty();
130 }
131
132 let skipped = total - n;
133 let mut out = String::new();
134 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 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 out.push_str(&format!("\n... skipping {} later messages\n", skipped));
157 out
158 }
159
160 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 pub fn messages(&self) -> &[Message] {
174 &self.messages
175 }
176}
177
178fn 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 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
195fn 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 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 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 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 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 }
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 assert!(output.contains("...skipped 3 earlier messages"));
378 assert!(output.contains("=== transcript (5 messages) ==="));
380 assert!(output.contains("--- [3] System ---"));
382 assert!(output.contains("fourth"));
383 assert!(output.contains("--- [4] User ---"));
384 assert!(output.contains("fifth"));
385 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 let tail_5 = file.pretty_tail(5);
404 let full = file.pretty();
405 assert_eq!(tail_5, full);
406
407 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 assert!(output.contains("=== transcript (5 messages) ==="));
426 assert!(output.contains("... skipping 3 later messages"));
428 assert!(output.contains("--- [0] System ---"));
430 assert!(output.contains("first"));
431 assert!(output.contains("--- [1] User ---"));
432 assert!(output.contains("second"));
433 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 let head_5 = file.pretty_head(5);
452 let full = file.pretty();
453 assert_eq!(head_5, full);
454
455 let head_2 = file.pretty_head(2);
457 assert_eq!(head_2, full);
458 }
459}