Skip to main content

podbox/
history.rs

1//! Append-only action history for podbox containers.
2//!
3//! Recorded at the *success* points of the lifecycle commands so
4//! `podbox history` can answer "what did I do to `<name>`, and when?".
5//! Writes are best-effort: recording must never break the command that
6//! triggered it, so callers discard the [`record`] error. The log lives in the
7//! XDG state dir at `~/.local/state/podbox/history.log`.
8//!
9//! Line format (one event per line, tab-separated):
10//! `TIMESTAMP\tNAME\tACTION\tDETAIL`
11
12use std::fs;
13use std::io::{self, Write};
14use std::path::{Path, PathBuf};
15use std::time::{SystemTime, UNIX_EPOCH};
16
17use serde::Serialize;
18
19/// A single recorded action.
20#[derive(Debug, Clone, Serialize)]
21pub struct HistoryEntry {
22    /// UTC timestamp in RFC3339 (seconds resolution).
23    pub timestamp: String,
24    /// The container name the action targeted.
25    pub name: String,
26    /// Action verb, e.g. "create", "build", "start".
27    pub action: String,
28    /// Free-form detail (empty when there is none).
29    pub detail: String,
30}
31
32const LOG_FILE: &str = "history.log";
33
34/// Path to the history log: `<state>/podbox/history.log`.
35pub 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
42/// Record an action by appending to the log (best-effort).
43pub 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
51/// Append one event to `path` (used by [`record`] and by unit tests).
52fn 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
65/// Load the history, most recent first. A missing/unreadable log yields an
66/// empty vec with a read error (callers treat that as "nothing to show").
67pub fn load() -> io::Result<Vec<HistoryEntry>> {
68    load_from(&log_path())
69}
70
71/// Read and parse `path`, newest first (used by [`load`] and tests).
72fn 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(); // newest last
76    Ok(entries)
77}
78
79/// Parse a single tab-separated line into an entry (or `None` if malformed).
80fn 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
106/// Format a Unix epoch seconds as RFC3339 UTC (`YYYY-MM-DDTHH:MM:SSZ`) using
107/// the civil-from-days algorithm so the log stays readable without `chrono`.
108fn 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
119/// Howard Hinnant's civil-from-days (days since epoch → (year, month, day)).
120/// All intermediate values stay in `i64`; only the provably-in-range day and
121/// month are narrowed to `u32`.
122fn 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; // [0, 146096]
126    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
127    let y = yoe + era * 400;
128    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
129    let mp = (5 * doy + 2) / 153; // [0, 11]
130    let d = u32::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1); // [1, 31]
131    let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1); // [1, 12]
132    (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        // Before the epoch round-trips into 1969 (div_euclid/rem_euclid).
145        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        // Newest first.
161        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        // Every timestamp is RFC3339 UTC.
169        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        // The trailing line parses with detail `x\tyzw` (detail keeps tabs);
186        // garbage / blank / whitespace-only lines yield nothing.
187        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}