1use std::fs;
23use std::io::Write;
24use std::path::Path;
25use std::time::{SystemTime, UNIX_EPOCH};
26
27const MAGIC: &[u8; 4] = b"TML1";
28const ENTRY_HEADER: usize = 8 + 1 + 1 + 1 + 1 + 4; pub const MAX_TEXT_LEN: usize = 4096;
30
31pub const STATUS_NORMAL: u8 = 3;
33pub const STATUS_OPEN: u8 = 0;
34pub const STATUS_RESOLVED: u8 = 1;
35pub const STATUS_ARCHIVED: u8 = 2;
36
37#[derive(Clone, Debug)]
39pub struct TimelineEntry {
40 pub ts_ms: u64,
41 pub layer_id: u8,
42 pub importance: u8,
43 pub depth: u8,
44 pub status: u8,
45 pub text: String,
46}
47
48pub fn append_entry(path: &Path, entry: &TimelineEntry) -> Result<(), String> {
51 let needs_magic = !path.exists() || fs::metadata(path).map(|m| m.len() == 0).unwrap_or(true);
52
53 let mut file = fs::OpenOptions::new()
54 .create(true)
55 .append(true)
56 .open(path)
57 .map_err(|e| format!("open timeline: {}", e))?;
58
59 if needs_magic {
60 file.write_all(MAGIC)
61 .map_err(|e| format!("write timeline magic: {}", e))?;
62 }
63
64 let text_bytes = entry.text.as_bytes();
65 let len = text_bytes.len().min(MAX_TEXT_LEN);
66
67 file.write_all(&entry.ts_ms.to_le_bytes())
68 .map_err(|e| format!("write ts: {}", e))?;
69 file.write_all(&[entry.layer_id])
70 .map_err(|e| format!("write layer: {}", e))?;
71 file.write_all(&[entry.importance])
72 .map_err(|e| format!("write importance: {}", e))?;
73 file.write_all(&[entry.depth])
74 .map_err(|e| format!("write depth: {}", e))?;
75 file.write_all(&[entry.status])
76 .map_err(|e| format!("write status: {}", e))?;
77 file.write_all(&(len as u32).to_le_bytes())
78 .map_err(|e| format!("write len: {}", e))?;
79 file.write_all(&text_bytes[..len])
80 .map_err(|e| format!("write text: {}", e))?;
81
82 Ok(())
83}
84
85pub fn read_all(path: &Path) -> Vec<TimelineEntry> {
88 if !path.exists() {
89 return Vec::new();
90 }
91 let data = match fs::read(path) {
92 Ok(d) => d,
93 Err(_) => return Vec::new(),
94 };
95 if data.len() < 4 || &data[0..4] != MAGIC {
96 return Vec::new();
97 }
98
99 let mut entries = Vec::new();
100 let mut pos = 4;
101
102 while pos + ENTRY_HEADER <= data.len() {
103 let ts_ms = u64::from_le_bytes([
104 data[pos],
105 data[pos + 1],
106 data[pos + 2],
107 data[pos + 3],
108 data[pos + 4],
109 data[pos + 5],
110 data[pos + 6],
111 data[pos + 7],
112 ]);
113 let layer_id = data[pos + 8];
114 let importance = data[pos + 9];
115 let depth = data[pos + 10];
116 let status = data[pos + 11];
117 let len = u32::from_le_bytes([
118 data[pos + 12],
119 data[pos + 13],
120 data[pos + 14],
121 data[pos + 15],
122 ]) as usize;
123 pos += ENTRY_HEADER;
124
125 if pos + len > data.len() {
126 break;
127 }
128 let text = String::from_utf8_lossy(&data[pos..pos + len]).to_string();
129 pos += len;
130
131 entries.push(TimelineEntry {
132 ts_ms,
133 layer_id,
134 importance,
135 depth,
136 status,
137 text,
138 });
139 }
140
141 entries
142}
143
144#[derive(Clone, Debug, PartialEq)]
146pub enum TimeWindow {
147 Today,
149 Yesterday,
151 LastDays(u64),
153 Since(u64),
155 LastSession,
158 All,
160}
161
162impl TimeWindow {
163 pub fn parse(s: &str) -> Result<Self, String> {
167 let s = s.trim().to_lowercase();
168 match s.as_str() {
169 "today" => Ok(TimeWindow::Today),
170 "yesterday" => Ok(TimeWindow::Yesterday),
171 "last_session" => Ok(TimeWindow::LastSession),
172 "all" => Ok(TimeWindow::All),
173 other if other.starts_with("last_") && other.ends_with("_days") => {
174 let n: u64 = other[5..other.len() - 5]
175 .parse()
176 .map_err(|_| format!("invalid window: {}", s))?;
177 if n == 0 || n > 365 {
178 return Err(format!("window days out of range: {}", n));
179 }
180 Ok(TimeWindow::LastDays(n))
181 }
182 other if other.starts_with("since:") => {
183 let v = &other[6..];
184 if let Ok(ms) = v.parse::<u64>() {
185 Ok(TimeWindow::Since(ms))
186 } else {
187 let ms = parse_iso8601_to_epoch_ms(v)
189 .ok_or_else(|| format!("invalid since date: {}", v))?;
190 Ok(TimeWindow::Since(ms))
191 }
192 }
193 _ => Err(format!(
194 "unknown window: {} (use today|yesterday|last_N_days|since:<date>|last_session|all)",
195 s
196 )),
197 }
198 }
199
200 pub fn to_range(&self, now_ms: u64) -> (u64, u64) {
204 const DAY_MS: u64 = 86_400_000;
205 match self {
206 TimeWindow::Today => {
207 let start = now_ms - (now_ms % DAY_MS);
208 (start, now_ms)
209 }
210 TimeWindow::Yesterday => {
211 let today_start = now_ms - (now_ms % DAY_MS);
212 (today_start - DAY_MS, today_start)
213 }
214 TimeWindow::LastDays(n) => {
215 let n = (*n).max(1);
216 let start = now_ms - n * DAY_MS;
217 (start, now_ms)
218 }
219 TimeWindow::Since(ms) => (*ms, u64::MAX),
220 TimeWindow::LastSession => (0, u64::MAX),
221 TimeWindow::All => (0, u64::MAX),
222 }
223 }
224}
225
226pub fn filter(entries: &[TimelineEntry], window: &TimeWindow) -> Vec<TimelineEntry> {
230 filter_at(entries, window, now_epoch_ms())
231}
232
233pub fn filter_at(
235 entries: &[TimelineEntry],
236 window: &TimeWindow,
237 now_ms: u64,
238) -> Vec<TimelineEntry> {
239 if entries.is_empty() {
240 return Vec::new();
241 }
242 match window {
243 TimeWindow::LastSession => {
244 let mut last_gap_pos = 0usize;
246 let mut last_gap_ms: u64 = 0;
247 for i in 1..entries.len() {
248 let gap = entries[i].ts_ms.saturating_sub(entries[i - 1].ts_ms);
249 if gap > last_gap_ms {
250 last_gap_ms = gap;
251 last_gap_pos = i;
252 }
253 }
254 const SIX_H_MS: u64 = 6 * 3_600_000;
256 if last_gap_ms < SIX_H_MS {
257 entries.to_vec()
258 } else {
259 entries[last_gap_pos..].to_vec()
260 }
261 }
262 _ => {
263 let (start, end) = window.to_range(now_ms);
264 entries
265 .iter()
266 .filter(|e| e.ts_ms >= start && e.ts_ms <= end)
267 .cloned()
268 .collect()
269 }
270 }
271}
272
273pub fn now_epoch_ms() -> u64 {
275 SystemTime::now()
276 .duration_since(UNIX_EPOCH)
277 .unwrap_or_default()
278 .as_millis() as u64
279}
280
281pub fn format_ts(ms: u64) -> String {
284 let secs = ms / 1000;
285 let total_days = secs / 86400;
286 let mut y = 1970u64;
287 let mut remaining = total_days;
288 loop {
289 let diy = if is_leap(y) { 366 } else { 365 };
290 if remaining < diy {
291 break;
292 }
293 remaining -= diy;
294 y += 1;
295 }
296 let leap = is_leap(y);
297 let mdays = [
298 31u64,
299 if leap { 29 } else { 28 },
300 31,
301 30,
302 31,
303 30,
304 31,
305 31,
306 30,
307 31,
308 30,
309 31,
310 ];
311 let mut mo = 1u64;
312 for &md in &mdays {
313 if remaining < md {
314 break;
315 }
316 remaining -= md;
317 mo += 1;
318 }
319 let day = remaining + 1;
320 let secs_in_day = secs % 86400;
321 let h = secs_in_day / 3600;
322 let m = (secs_in_day % 3600) / 60;
323 format!("{:04}-{:02}-{:02} {:02}:{:02}", y, mo, day, h, m)
324}
325
326fn is_leap(y: u64) -> bool {
327 (y.is_multiple_of(4) && !y.is_multiple_of(100)) || y.is_multiple_of(400)
328}
329
330pub fn parse_iso8601_to_epoch_ms(s: &str) -> Option<u64> {
337 let s = s.trim().replace(' ', "T");
338 let mut parts = s.split('T');
339 let date_part = parts.next()?;
340 let time_part = parts.next();
341
342 let date: Vec<u64> = date_part
343 .split('-')
344 .filter_map(|p| p.parse().ok())
345 .collect();
346 if date.len() != 3 {
347 return None;
348 }
349 let (y, mo, d) = (date[0], date[1], date[2]);
350 if !(1970..=3000).contains(&y) || !(1..=12).contains(&mo) || !(1..=31).contains(&d) {
351 return None;
352 }
353
354 let (mut h, mut mi, mut sec) = (0u64, 0u64, 0u64);
355 if let Some(t) = time_part {
356 let tps: Vec<u64> = t.split(':').filter_map(|p| p.parse().ok()).collect();
357 if !tps.is_empty() {
358 h = tps[0];
359 }
360 if tps.len() >= 2 {
361 mi = tps[1];
362 }
363 if tps.len() >= 3 {
364 sec = tps[2];
365 }
366 }
367 if h > 23 || mi > 59 || sec > 60 {
368 return None;
369 }
370
371 let mut days: u64 = 0;
373 for yr in 1970..y {
374 days += if is_leap(yr) { 366 } else { 365 };
375 }
376 let leap = is_leap(y);
377 let mdays = [
378 31u64,
379 if leap { 29 } else { 28 },
380 31,
381 30,
382 31,
383 30,
384 31,
385 31,
386 30,
387 31,
388 30,
389 31,
390 ];
391 for &dm in mdays.iter().take((mo - 1) as usize) {
392 days += dm;
393 }
394 days += d - 1;
395
396 Some(days * 86_400_000 + h * 3_600_000 + mi * 60_000 + sec * 1000)
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402
403 fn entry(text: &str, ts_ms: u64, status: u8) -> TimelineEntry {
404 TimelineEntry {
405 ts_ms,
406 layer_id: 1,
407 importance: 5,
408 depth: 4,
409 status,
410 text: text.to_string(),
411 }
412 }
413
414 #[test]
415 fn test_append_read_roundtrip() {
416 let dir = tempfile::tempdir().unwrap();
417 let p = dir.path().join("timeline.bin");
418 append_entry(&p, &entry("first", 1_700_000_000_000, STATUS_NORMAL)).unwrap();
419 append_entry(&p, &entry("second", 1_700_001_000_000, STATUS_OPEN)).unwrap();
420 let got = read_all(&p);
421 assert_eq!(got.len(), 2);
422 assert_eq!(got[0].text, "first");
423 assert_eq!(got[1].status, STATUS_OPEN);
424 }
425
426 #[test]
427 fn test_window_parse() {
428 assert_eq!(TimeWindow::parse("today").unwrap(), TimeWindow::Today);
429 assert_eq!(
430 TimeWindow::parse("last_3_days").unwrap(),
431 TimeWindow::LastDays(3)
432 );
433 assert_eq!(
434 TimeWindow::parse("last_session").unwrap(),
435 TimeWindow::LastSession
436 );
437 assert!(TimeWindow::parse("nonsense").is_err());
438 assert!(TimeWindow::parse("last_0_days").is_err());
439 assert!(TimeWindow::parse("last_1000_days").is_err());
440 }
441
442 #[test]
443 fn test_filter_today() {
444 let now = 1_700_000_000_000u64; let today_start = now - (now % 86_400_000);
446 let entries = vec![
447 entry("yesterday_late", today_start - 3_600_000, STATUS_NORMAL),
448 entry("today_early", today_start + 1_000, STATUS_NORMAL),
449 entry("now", now, STATUS_NORMAL),
450 ];
451 let f = filter_at(&entries, &TimeWindow::Today, now);
452 assert_eq!(f.len(), 2);
453 assert_eq!(f[0].text, "today_early");
454 }
455
456 #[test]
457 fn test_filter_last_session() {
458 let entries = vec![
460 entry("old", 1_700_000_000_000, STATUS_NORMAL),
461 entry("older", 1_700_000_000_000 - 10 * 3_600_000, STATUS_NORMAL),
462 entry("recent", 1_700_000_000_000 + 1000, STATUS_NORMAL),
464 ];
465 let f = filter(&entries, &TimeWindow::LastSession);
466 assert_eq!(f.len(), 1);
467 assert_eq!(f[0].text, "recent");
468 }
469
470 #[test]
471 fn test_iso8601_parse() {
472 let ms = parse_iso8601_to_epoch_ms("2026-06-22T10:30:00").unwrap();
473 assert!(ms > 1_700_000_000_000);
475 assert!(ms < 2_000_000_000_000);
476 }
477
478 #[test]
479 fn test_format_ts() {
480 let s = format_ts(1_700_000_000_000);
482 assert_eq!(s, "2023-11-14 22:13");
483 }
484
485 #[test]
486 fn test_window_last_days_range() {
487 let now = 1_700_000_000_000u64;
488 let (start, end) = TimeWindow::LastDays(2).to_range(now);
489 assert!(start < now);
490 assert_eq!(end, now);
491 assert!(now - start <= 2 * 86_400_000);
492 }
493}