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 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 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 take_first_n_exactly_len_returns_all() {
366 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 assert!(output.contains("...skipped 3 earlier messages"));
394 assert!(output.contains("=== transcript (5 messages) ==="));
396 assert!(output.contains("--- [3] System ---"));
398 assert!(output.contains("fourth"));
399 assert!(output.contains("--- [4] User ---"));
400 assert!(output.contains("fifth"));
401 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 let tail_5 = file.pretty_tail(5);
420 let full = file.pretty();
421 assert_eq!(tail_5, full);
422
423 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 assert!(output.contains("=== transcript (5 messages) ==="));
442 assert!(output.contains("... skipping 3 later messages"));
444 assert!(output.contains("--- [0] System ---"));
446 assert!(output.contains("first"));
447 assert!(output.contains("--- [1] User ---"));
448 assert!(output.contains("second"));
449 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 let head_5 = file.pretty_head(5);
468 let full = file.pretty();
469 assert_eq!(head_5, full);
470
471 let head_2 = file.pretty_head(2);
473 assert_eq!(head_2, full);
474 }
475
476 #[test]
482 fn pretty_empty_content_produces_no_content_line() {
483 let file = TranscriptFile::new(vec![Message::user(String::new())], 1, None);
485 let output = file.pretty();
486 assert!(output.contains("--- [0] User ---"));
488 let lines: Vec<&str> = output.lines().collect();
492 let msg_line = lines
493 .iter()
494 .position(|l| l.contains("--- [0] User ---"))
495 .unwrap();
496 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 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 let file = TranscriptFile::new(vec![Message::user("no-newline".to_string())], 1, None);
528 let output = file.pretty();
529 assert!(
531 output.contains("no-newline\n\n"),
532 "content without trailing newline must have added newline + block separator"
533 );
534 }
535
536 #[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 #[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 #[test]
613 fn epoch_day_to_ymd_unix_epoch() {
614 assert_eq!(epoch_day_to_ymd(0), (1970, 1, 1));
616 }
617
618 #[test]
619 fn epoch_day_to_ymd_known_dates() {
620 assert_eq!(epoch_day_to_ymd(19723), (2024, 1, 1));
622 assert_eq!(epoch_day_to_ymd(11017), (2000, 3, 1));
625 assert_eq!(epoch_day_to_ymd(364), (1970, 12, 31));
627 assert_eq!(epoch_day_to_ymd(365), (1971, 1, 1));
629 }
630
631 #[test]
638 fn epoch_day_to_ymd_century_correction() {
639 assert_eq!(epoch_day_to_ymd(47541), (2100, 3, 1));
641 }
642
643 #[test]
653 fn epoch_day_to_ymd_last_day_of_era() {
654 assert_eq!(epoch_day_to_ymd(157_113), (2400, 2, 29));
657 }
658
659 #[test]
667 fn epoch_day_to_ymd_negative_epoch_day_pre_ce() {
668 assert_eq!(epoch_day_to_ymd(-719528), (0, 1, 1));
670 }
671
672 #[test]
678 fn epoch_day_to_ymd_february() {
679 assert_eq!(epoch_day_to_ymd(19754), (2024, 2, 1));
681 assert_eq!(epoch_day_to_ymd(19782), (2024, 2, 29));
683 }
684
685 #[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 #[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 assert_eq!(ts.len(), 20, "timestamp must be 20 chars: {ts}");
745 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 assert_eq!(&ts[4..5], "-");
751 assert_eq!(&ts[7..8], "-");
752 assert_eq!(&ts[10..11], "T");
753 }
754
755 #[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}