1use chrono::{DateTime, Utc};
5
6#[derive(Debug, Clone, Copy, PartialEq, Eq)]
7pub enum EntryKind {
8 Prompt,
10 System,
12 Command,
14 Warn,
16 Alert,
18}
19
20#[derive(Debug, Clone)]
21pub struct LogEntry {
22 pub at: DateTime<Utc>,
23 pub kind: EntryKind,
24 pub text: String,
25 pub stream_id: Option<String>,
30}
31
32impl LogEntry {
33 #[must_use]
34 pub fn new(kind: EntryKind, text: impl Into<String>) -> Self {
35 Self {
36 at: Utc::now(),
37 kind,
38 text: text.into(),
39 stream_id: None,
40 }
41 }
42
43 #[must_use]
44 pub fn at(mut self, ts: DateTime<Utc>) -> Self {
45 self.at = ts;
46 self
47 }
48
49 #[must_use]
56 pub fn streaming(mut self, id: impl Into<String>) -> Self {
57 self.stream_id = Some(id.into());
58 self
59 }
60}
61
62#[derive(Debug, Default)]
63pub struct ConversationLog {
64 entries: Vec<LogEntry>,
65 cap: usize,
66}
67
68impl ConversationLog {
69 #[must_use]
72 pub const fn with_capacity(cap: usize) -> Self {
73 Self {
74 entries: Vec::new(),
75 cap,
76 }
77 }
78
79 pub fn push(&mut self, entry: LogEntry) {
80 self.entries.push(entry);
81 if self.cap > 0 && self.entries.len() > self.cap {
82 let drop_n = self.entries.len() - self.cap;
85 self.entries.drain(..drop_n);
86 }
87 }
88
89 #[must_use]
90 pub fn tail(&self, n: usize) -> &[LogEntry] {
91 let len = self.entries.len();
92 let start = len.saturating_sub(n);
93 &self.entries[start..]
94 }
95
96 #[must_use]
100 pub fn entries(&self) -> &[LogEntry] {
101 &self.entries
102 }
103
104 pub fn last_mut(&mut self) -> Option<&mut LogEntry> {
108 self.entries.last_mut()
109 }
110
111 pub fn extend_last(&mut self, id: &str, more: &str) -> bool {
121 let Some(last) = self.entries.last_mut() else {
122 return false;
123 };
124 if last.stream_id.as_deref() != Some(id) {
125 return false;
126 }
127 last.text.push_str(more);
128 true
129 }
130
131 #[must_use]
132 pub fn len(&self) -> usize {
133 self.entries.len()
134 }
135
136 #[must_use]
137 pub fn is_empty(&self) -> bool {
138 self.entries.is_empty()
139 }
140}
141
142#[cfg(test)]
143mod tests {
144 use super::{ConversationLog, EntryKind, LogEntry};
145
146 #[test]
147 fn tail_returns_last_n() {
148 let mut log = ConversationLog::with_capacity(0);
149 for i in 0..5 {
150 log.push(LogEntry::new(EntryKind::System, format!("{i}")));
151 }
152 let tail: Vec<&str> = log.tail(3).iter().map(|e| e.text.as_str()).collect();
153 assert_eq!(tail, ["2", "3", "4"]);
154 }
155
156 #[test]
157 fn capacity_trims_oldest() {
158 let mut log = ConversationLog::with_capacity(3);
159 for i in 0..5 {
160 log.push(LogEntry::new(EntryKind::System, format!("{i}")));
161 }
162 assert_eq!(log.len(), 3);
163 let tail: Vec<&str> = log.tail(10).iter().map(|e| e.text.as_str()).collect();
164 assert_eq!(tail, ["2", "3", "4"]);
165 }
166
167 #[test]
168 fn extend_last_appends_to_streaming_entry() {
169 let mut log = ConversationLog::with_capacity(0);
170 log.push(LogEntry::new(EntryKind::System, "hello").streaming("sid-1"));
171 assert!(log.extend_last("sid-1", ", world"));
172 assert_eq!(log.tail(1)[0].text, "hello, world");
173 }
174
175 #[test]
176 fn extend_last_refuses_mismatched_id() {
177 let mut log = ConversationLog::with_capacity(0);
178 log.push(LogEntry::new(EntryKind::System, "partial").streaming("sid-1"));
179 assert!(
180 !log.extend_last("sid-2", " nope"),
181 "mismatched id must not silently append"
182 );
183 assert_eq!(log.tail(1)[0].text, "partial");
184 }
185
186 #[test]
187 fn extend_last_refuses_finalized_entry() {
188 let mut log = ConversationLog::with_capacity(0);
189 log.push(LogEntry::new(EntryKind::System, "final"));
190 assert!(
191 !log.extend_last("any", " more"),
192 "finalized entry (no stream_id) must reject appends"
193 );
194 }
195}