Skip to main content

release_kit/
applog.rs

1//! The application log: one rotating file for the whole binary.
2//!
3//! One-line logfmt records at the XDG state root, filtered by `RUST_LOG`
4//! because the ecosystem already owns that variable, rotated by size. The
5//! file is the only default destination — the terminal never sees log
6//! records — and a log write that fails costs nothing but the record: a
7//! diagnostic surface must never fail the command it observes.
8
9use std::fs;
10use std::io::Write as _;
11use std::path::PathBuf;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14/// Rotate when the log grows past this many bytes; one previous file is
15/// kept as `release-kit.log.1`.
16const ROTATE_AT: u64 = 1_048_576;
17
18/// The state directory every run-shaped artifact lives under:
19/// `${XDG_STATE_HOME:-$HOME/.local/state}/release-kit`.
20#[must_use]
21pub fn state_root() -> Option<PathBuf> {
22    let base = std::env::var_os("XDG_STATE_HOME")
23        .filter(|v| !v.is_empty())
24        .map(PathBuf::from)
25        .or_else(|| {
26            std::env::var_os("HOME")
27                .filter(|v| !v.is_empty())
28                .map(|home| PathBuf::from(home).join(".local/state"))
29        })?;
30    Some(base.join("release-kit"))
31}
32
33/// The cache directory a disposable answer lives under:
34/// `${XDG_CACHE_HOME:-$HOME/.cache}/release-kit`.
35#[must_use]
36pub fn cache_root() -> Option<PathBuf> {
37    let base = std::env::var_os("XDG_CACHE_HOME")
38        .filter(|v| !v.is_empty())
39        .map(PathBuf::from)
40        .or_else(|| {
41            std::env::var_os("HOME")
42                .filter(|v| !v.is_empty())
43                .map(|home| PathBuf::from(home).join(".cache"))
44        })?;
45    Some(base.join("release-kit"))
46}
47
48/// Append one info record for a finished command, best effort.
49pub fn record(op: &str, status: &str, dur_ms: u128) {
50    if !info_enabled(std::env::var("RUST_LOG").ok().as_deref()) {
51        return;
52    }
53    let Some(root) = state_root() else { return };
54    write_record(&root, op, status, dur_ms);
55}
56
57/// Append the record under `root`, rotating first when the file is past
58/// the size bound. Every failure is swallowed: the log is best effort.
59fn write_record(root: &std::path::Path, op: &str, status: &str, dur_ms: u128) {
60    let path = root.join("release-kit.log");
61    if fs::metadata(&path).is_ok_and(|meta| meta.len() > ROTATE_AT) {
62        let _ = fs::rename(&path, root.join("release-kit.log.1"));
63    }
64    let line = format!(
65        "ts={} level=info target=rk op={op} status={status} dur_ms={dur_ms}\n",
66        now_utc()
67    );
68    let _ = fs::create_dir_all(root);
69    let _ = fs::OpenOptions::new()
70        .create(true)
71        .append(true)
72        .open(&path)
73        .and_then(|mut file| file.write_all(line.as_bytes()));
74}
75
76/// Whether `RUST_LOG` allows info records: the last directive naming this
77/// binary or naming no target decides, and no variable means info.
78fn info_enabled(rust_log: Option<&str>) -> bool {
79    let Some(spec) = rust_log else { return true };
80    let mut enabled = true;
81    for directive in spec.split(',') {
82        let (target, level) = directive
83            .split_once('=')
84            .map_or((None, directive), |(target, level)| (Some(target), level));
85        if target.is_some_and(|t| {
86            let t = t.trim();
87            t != "rk" && t != "release_kit"
88        }) {
89            continue;
90        }
91        match level.trim().to_ascii_lowercase().as_str() {
92            "off" | "error" | "warn" => enabled = false,
93            "info" | "debug" | "trace" => enabled = true,
94            _ => {}
95        }
96    }
97    enabled
98}
99
100/// Wall-clock UTC now, RFC 3339 to the second.
101#[must_use]
102pub fn now_utc() -> String {
103    let secs = SystemTime::now()
104        .duration_since(UNIX_EPOCH)
105        .map_or(0, |elapsed| elapsed.as_secs());
106    rfc3339(i64::try_from(secs).unwrap_or(0))
107}
108
109/// Format seconds since the epoch as RFC 3339 UTC.
110fn rfc3339(epoch_secs: i64) -> String {
111    let days = epoch_secs.div_euclid(86_400);
112    let in_day = epoch_secs.rem_euclid(86_400);
113    let (year, month, day) = ymd_from_days(days);
114    format!(
115        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
116        in_day / 3600,
117        in_day % 3600 / 60,
118        in_day % 60
119    )
120}
121
122/// Civil date from days since the epoch, by the standard era arithmetic.
123const fn ymd_from_days(days: i64) -> (i64, u32, u32) {
124    let shifted = days + 719_468;
125    let era = if shifted >= 0 {
126        shifted
127    } else {
128        shifted - 146_096
129    } / 146_097;
130    let day_of_era = shifted - era * 146_097;
131    let year_of_era =
132        (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
133    let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
134    let month_point = (5 * day_of_year + 2) / 153;
135    let day = day_of_year - (153 * month_point + 2) / 5 + 1;
136    let month = if month_point < 10 {
137        month_point + 3
138    } else {
139        month_point - 9
140    };
141    let year = year_of_era + era * 400 + if month <= 2 { 1 } else { 0 };
142    #[allow(
143        clippy::cast_possible_truncation,
144        clippy::cast_sign_loss,
145        reason = "the civil-from-days algorithm bounds month to 1 through 12 and day to 1 through 31, so neither cast truncates and neither value is negative"
146    )]
147    (year, month as u32, day as u32)
148}
149
150#[cfg(test)]
151mod tests {
152    use super::{info_enabled, rfc3339, write_record};
153
154    #[test]
155    fn the_timestamp_matches_known_epochs() {
156        assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
157        // date -u -d @1793289600: 2026-10-29 16:00:00 UTC.
158        assert_eq!(rfc3339(1_793_289_600), "2026-10-29T16:00:00Z");
159        // A leap day: date -u -d @1709164800 is 2024-02-29.
160        assert_eq!(rfc3339(1_709_164_800), "2024-02-29T00:00:00Z");
161    }
162
163    #[test]
164    fn rust_log_filters_info_records() {
165        assert!(info_enabled(None));
166        assert!(info_enabled(Some("info")));
167        assert!(info_enabled(Some("debug")));
168        assert!(!info_enabled(Some("off")));
169        assert!(!info_enabled(Some("warn")));
170        assert!(!info_enabled(Some("error")));
171        assert!(info_enabled(Some("other_crate=off")));
172        assert!(!info_enabled(Some("rk=warn")));
173        assert!(info_enabled(Some("warn,rk=info")));
174    }
175
176    #[test]
177    fn a_record_appends_one_logfmt_line() {
178        let dir = tempfile::tempdir().expect("a scratch dir exists");
179        write_record(dir.path(), "init", "ok", 12);
180        write_record(dir.path(), "skill", "state-drift", 3);
181        let text =
182            std::fs::read_to_string(dir.path().join("release-kit.log")).expect("the log reads");
183        let lines: Vec<&str> = text.lines().collect();
184        assert_eq!(lines.len(), 2);
185        assert!(
186            lines[0].contains("level=info target=rk op=init status=ok dur_ms=12"),
187            "{}",
188            lines[0]
189        );
190        assert!(lines[0].starts_with("ts="), "{}", lines[0]);
191    }
192}