1use std::fs;
13use std::io::{self, Write};
14use std::path::{Path, PathBuf};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use serde::Serialize;
18
19#[derive(Debug, Clone, Serialize)]
21pub struct HistoryEntry {
22 pub timestamp: String,
24 pub name: String,
26 pub action: String,
28 pub detail: String,
30}
31
32const LOG_FILE: &str = "history.log";
33
34pub fn log_path() -> PathBuf {
36 dirs::state_dir()
37 .unwrap_or_else(|| PathBuf::from("~/.local/state"))
38 .join("podbox")
39 .join(LOG_FILE)
40}
41
42pub fn record(name: &str, action: &str, detail: &str) -> io::Result<()> {
44 let path = log_path();
45 if let Some(parent) = path.parent() {
46 fs::create_dir_all(parent)?;
47 }
48 record_to(&path, name, action, detail)
49}
50
51fn record_to(path: &Path, name: &str, action: &str, detail: &str) -> io::Result<()> {
53 let line = if detail.is_empty() {
54 format!("{}\t{}\t{}\n", timestamp(), name, action)
55 } else {
56 format!("{}\t{}\t{}\t{}\n", timestamp(), name, action, detail)
57 };
58 let mut f = fs::OpenOptions::new()
59 .create(true)
60 .append(true)
61 .open(path)?;
62 f.write_all(line.as_bytes())
63}
64
65pub fn load() -> io::Result<Vec<HistoryEntry>> {
68 load_from(&log_path())
69}
70
71fn load_from(path: &Path) -> io::Result<Vec<HistoryEntry>> {
73 let content = fs::read_to_string(path)?;
74 let mut entries: Vec<HistoryEntry> = content.lines().filter_map(parse_line).collect();
75 entries.reverse(); Ok(entries)
77}
78
79fn parse_line(line: &str) -> Option<HistoryEntry> {
81 let raw = line.trim_end_matches('\r');
82 if raw.trim().is_empty() {
83 return None;
84 }
85 let mut it = raw.splitn(4, '\t');
86 let timestamp = it.next()?.to_string();
87 let name = it.next()?.to_string();
88 let action = it.next()?.to_string();
89 let detail = it.next().unwrap_or("").to_string();
90 Some(HistoryEntry {
91 timestamp,
92 name,
93 action,
94 detail,
95 })
96}
97
98fn timestamp() -> String {
99 let secs = SystemTime::now()
100 .duration_since(UNIX_EPOCH)
101 .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
102 .unwrap_or(0);
103 format_rfc3339(secs)
104}
105
106fn format_rfc3339(secs: i64) -> String {
109 const DAY: i64 = 86_400;
110 let days = secs.div_euclid(DAY);
111 let rem = secs.rem_euclid(DAY);
112 let (y, m, d) = civil_from_days(days);
113 let hh = rem / 3600;
114 let mm = (rem % 3600) / 60;
115 let ss = rem % 60;
116 format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
117}
118
119fn civil_from_days(days: i64) -> (i64, u32, u32) {
123 let z = days + 719_468;
124 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
125 let doe = z - era * 146_097; let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; let y = yoe + era * 400;
128 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); let mp = (5 * doy + 2) / 153; let d = u32::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1); let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1); (if m <= 2 { y + 1 } else { y }, m, d)
133}
134
135#[cfg(test)]
136mod tests {
137 use super::*;
138
139 #[test]
140 fn rfc3339_formats_known_epochs() {
141 assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z");
142 assert_eq!(format_rfc3339(86_400), "1970-01-02T00:00:00Z");
143 assert_eq!(format_rfc3339(1_000_000_000), "2001-09-09T01:46:40Z");
144 assert_eq!(format_rfc3339(-1), "1969-12-31T23:59:59Z");
146 }
147
148 #[test]
149 fn round_trip_records_and_loads_preserving_order() {
150 let dir = std::env::temp_dir().join(format!("podbox-history-{}", std::process::id()));
151 let path = dir.join("history.log");
152 let _ = fs::remove_dir_all(&dir);
153 fs::create_dir_all(&dir).unwrap();
154
155 record_to(&path, "alpha", "build", "rebuilt after stable pull").unwrap();
156 record_to(&path, "beta", "start", "").unwrap();
157 record_to(&path, "alpha", "enable", "quadlet installed").unwrap();
158
159 let entries = load_from(&path).unwrap();
160 assert_eq!(entries.len(), 3);
162 assert_eq!(entries[0].action, "enable");
163 assert_eq!(entries[0].name, "alpha");
164 assert_eq!(entries[0].detail, "quadlet installed");
165 assert_eq!(entries[1].action, "start");
166 assert_eq!(entries[1].detail, "");
167 assert_eq!(entries[2].action, "build");
168 for e in &entries {
170 assert!(e.timestamp.ends_with('Z'), "{}", e.timestamp);
171 }
172
173 let _ = fs::remove_dir_all(&dir);
174 }
175
176 #[test]
177 fn malformed_and_blank_lines_are_skipped() {
178 let file = std::env::temp_dir().join("podbox-history-parse.log");
179 fs::write(
180 &file,
181 "garbage-line\n\n\t\n2026-08-26T03:00:00Z\tfoo\tbuild\tx\tyzw\n",
182 )
183 .unwrap();
184 let entries = load_from(&file).unwrap();
185 assert_eq!(entries.len(), 1);
188 assert_eq!(entries[0].timestamp, "2026-08-26T03:00:00Z");
189 assert_eq!(entries[0].name, "foo");
190 assert_eq!(entries[0].action, "build");
191 assert_eq!(entries[0].detail, "x\tyzw");
192 let _ = fs::remove_file(&file);
193 }
194}