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().create(true).append(true).open(path)?;
59    f.write_all(line.as_bytes())
60}
61
62/// Load the history, most recent first. A missing/unreadable log yields an
63/// empty vec with a read error (callers treat that as "nothing to show").
64pub fn load() -> io::Result<Vec<HistoryEntry>> {
65    load_from(&log_path())
66}
67
68/// Read and parse `path`, newest first (used by [`load`] and tests).
69fn load_from(path: &Path) -> io::Result<Vec<HistoryEntry>> {
70    let content = fs::read_to_string(path)?;
71    let mut entries: Vec<HistoryEntry> = content.lines().filter_map(parse_line).collect();
72    entries.reverse(); // newest last
73    Ok(entries)
74}
75
76/// Parse a single tab-separated line into an entry (or `None` if malformed).
77fn parse_line(line: &str) -> Option<HistoryEntry> {
78    let raw = line.trim_end_matches('\r');
79    if raw.trim().is_empty() {
80        return None;
81    }
82    let mut it = raw.splitn(4, '\t');
83    let timestamp = it.next()?.to_string();
84    let name = it.next()?.to_string();
85    let action = it.next()?.to_string();
86    let detail = it.next().unwrap_or("").to_string();
87    Some(HistoryEntry {
88        timestamp,
89        name,
90        action,
91        detail,
92    })
93}
94
95fn timestamp() -> String {
96    let secs = SystemTime::now()
97        .duration_since(UNIX_EPOCH)
98        .map(|d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX))
99        .unwrap_or(0);
100    format_rfc3339(secs)
101}
102
103/// Format a Unix epoch seconds as RFC3339 UTC (`YYYY-MM-DDTHH:MM:SSZ`) using
104/// the civil-from-days algorithm so the log stays readable without `chrono`.
105fn format_rfc3339(secs: i64) -> String {
106    const DAY: i64 = 86_400;
107    let days = secs.div_euclid(DAY);
108    let rem = secs.rem_euclid(DAY);
109    let (y, m, d) = civil_from_days(days);
110    let hh = rem / 3600;
111    let mm = (rem % 3600) / 60;
112    let ss = rem % 60;
113    format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
114}
115
116/// Howard Hinnant's civil-from-days (days since epoch → (year, month, day)).
117/// All intermediate values stay in `i64`; only the provably-in-range day and
118/// month are narrowed to `u32`.
119fn civil_from_days(days: i64) -> (i64, u32, u32) {
120    let z = days + 719_468;
121    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
122    let doe = z - era * 146_097; // [0, 146096]
123    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399]
124    let y = yoe + era * 400;
125    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
126    let mp = (5 * doy + 2) / 153; // [0, 11]
127    let d = u32::try_from(doy - (153 * mp + 2) / 5 + 1).unwrap_or(1); // [1, 31]
128    let m = u32::try_from(if mp < 10 { mp + 3 } else { mp - 9 }).unwrap_or(1); // [1, 12]
129    (if m <= 2 { y + 1 } else { y }, m, d)
130}
131
132#[cfg(test)]
133mod tests {
134    use super::*;
135
136    #[test]
137    fn rfc3339_formats_known_epochs() {
138        assert_eq!(format_rfc3339(0), "1970-01-01T00:00:00Z");
139        assert_eq!(format_rfc3339(86_400), "1970-01-02T00:00:00Z");
140        assert_eq!(format_rfc3339(1_000_000_000), "2001-09-09T01:46:40Z");
141        // Before the epoch round-trips into 1969 (div_euclid/rem_euclid).
142        assert_eq!(format_rfc3339(-1), "1969-12-31T23:59:59Z");
143    }
144
145    #[test]
146    fn round_trip_records_and_loads_preserving_order() {
147        let dir = std::env::temp_dir().join(format!("podbox-history-{}", std::process::id()));
148        let path = dir.join("history.log");
149        let _ = fs::remove_dir_all(&dir);
150        fs::create_dir_all(&dir).unwrap();
151
152        record_to(&path, "alpha", "build", "rebuilt after stable pull").unwrap();
153        record_to(&path, "beta", "start", "").unwrap();
154        record_to(&path, "alpha", "enable", "quadlet installed").unwrap();
155
156        let entries = load_from(&path).unwrap();
157        // Newest first.
158        assert_eq!(entries.len(), 3);
159        assert_eq!(entries[0].action, "enable");
160        assert_eq!(entries[0].name, "alpha");
161        assert_eq!(entries[0].detail, "quadlet installed");
162        assert_eq!(entries[1].action, "start");
163        assert_eq!(entries[1].detail, "");
164        assert_eq!(entries[2].action, "build");
165        // Every timestamp is RFC3339 UTC.
166        for e in &entries {
167            assert!(e.timestamp.ends_with('Z'), "{}", e.timestamp);
168        }
169
170        let _ = fs::remove_dir_all(&dir);
171    }
172
173    #[test]
174    fn malformed_and_blank_lines_are_skipped() {
175        let file = std::env::temp_dir().join("podbox-history-parse.log");
176        fs::write(
177            &file,
178            "garbage-line\n\n\t\n2026-08-26T03:00:00Z\tfoo\tbuild\tx\tyzw\n",
179        )
180        .unwrap();
181        let entries = load_from(&file).unwrap();
182        // The trailing line parses with detail `x\tyzw` (detail keeps tabs);
183        // garbage / blank / whitespace-only lines yield nothing.
184        assert_eq!(entries.len(), 1);
185        assert_eq!(entries[0].timestamp, "2026-08-26T03:00:00Z");
186        assert_eq!(entries[0].name, "foo");
187        assert_eq!(entries[0].action, "build");
188        assert_eq!(entries[0].detail, "x\tyzw");
189        let _ = fs::remove_file(&file);
190    }
191}