1use std::fs;
10use std::io::Write as _;
11use std::path::PathBuf;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14const ROTATE_AT: u64 = 1_048_576;
17
18#[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
33pub fn record(op: &str, status: &str, dur_ms: u128) {
35 if !info_enabled(std::env::var("RUST_LOG").ok().as_deref()) {
36 return;
37 }
38 let Some(root) = state_root() else { return };
39 write_record(&root, op, status, dur_ms);
40}
41
42fn write_record(root: &std::path::Path, op: &str, status: &str, dur_ms: u128) {
45 let path = root.join("release-kit.log");
46 if fs::metadata(&path).is_ok_and(|meta| meta.len() > ROTATE_AT) {
47 let _ = fs::rename(&path, root.join("release-kit.log.1"));
48 }
49 let line = format!(
50 "ts={} level=info target=rk op={op} status={status} dur_ms={dur_ms}\n",
51 now_utc()
52 );
53 let _ = fs::create_dir_all(root);
54 let _ = fs::OpenOptions::new()
55 .create(true)
56 .append(true)
57 .open(&path)
58 .and_then(|mut file| file.write_all(line.as_bytes()));
59}
60
61fn info_enabled(rust_log: Option<&str>) -> bool {
64 let Some(spec) = rust_log else { return true };
65 let mut enabled = true;
66 for directive in spec.split(',') {
67 let (target, level) = directive
68 .split_once('=')
69 .map_or((None, directive), |(target, level)| (Some(target), level));
70 if target.is_some_and(|t| {
71 let t = t.trim();
72 t != "rk" && t != "release_kit"
73 }) {
74 continue;
75 }
76 match level.trim().to_ascii_lowercase().as_str() {
77 "off" | "error" | "warn" => enabled = false,
78 "info" | "debug" | "trace" => enabled = true,
79 _ => {}
80 }
81 }
82 enabled
83}
84
85#[must_use]
87pub fn now_utc() -> String {
88 let secs = SystemTime::now()
89 .duration_since(UNIX_EPOCH)
90 .map_or(0, |elapsed| elapsed.as_secs());
91 rfc3339(i64::try_from(secs).unwrap_or(0))
92}
93
94fn rfc3339(epoch_secs: i64) -> String {
96 let days = epoch_secs.div_euclid(86_400);
97 let in_day = epoch_secs.rem_euclid(86_400);
98 let (year, month, day) = ymd_from_days(days);
99 format!(
100 "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z",
101 in_day / 3600,
102 in_day % 3600 / 60,
103 in_day % 60
104 )
105}
106
107const fn ymd_from_days(days: i64) -> (i64, u32, u32) {
109 let shifted = days + 719_468;
110 let era = if shifted >= 0 {
111 shifted
112 } else {
113 shifted - 146_096
114 } / 146_097;
115 let day_of_era = shifted - era * 146_097;
116 let year_of_era =
117 (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365;
118 let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100);
119 let month_point = (5 * day_of_year + 2) / 153;
120 let day = day_of_year - (153 * month_point + 2) / 5 + 1;
121 let month = if month_point < 10 {
122 month_point + 3
123 } else {
124 month_point - 9
125 };
126 let year = year_of_era + era * 400 + if month <= 2 { 1 } else { 0 };
127 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
128 (year, month as u32, day as u32)
129}
130
131#[cfg(test)]
132mod tests {
133 #![allow(clippy::expect_used)]
134
135 use super::{info_enabled, rfc3339, write_record};
136
137 #[test]
138 fn the_timestamp_matches_known_epochs() {
139 assert_eq!(rfc3339(0), "1970-01-01T00:00:00Z");
140 assert_eq!(rfc3339(1_793_289_600), "2026-10-29T16:00:00Z");
142 assert_eq!(rfc3339(1_709_164_800), "2024-02-29T00:00:00Z");
144 }
145
146 #[test]
147 fn rust_log_filters_info_records() {
148 assert!(info_enabled(None));
149 assert!(info_enabled(Some("info")));
150 assert!(info_enabled(Some("debug")));
151 assert!(!info_enabled(Some("off")));
152 assert!(!info_enabled(Some("warn")));
153 assert!(!info_enabled(Some("error")));
154 assert!(info_enabled(Some("other_crate=off")));
155 assert!(!info_enabled(Some("rk=warn")));
156 assert!(info_enabled(Some("warn,rk=info")));
157 }
158
159 #[test]
160 fn a_record_appends_one_logfmt_line() {
161 let dir = tempfile::tempdir().expect("a scratch dir exists");
162 write_record(dir.path(), "init", "ok", 12);
163 write_record(dir.path(), "skill", "state-drift", 3);
164 let text =
165 std::fs::read_to_string(dir.path().join("release-kit.log")).expect("the log reads");
166 let lines: Vec<&str> = text.lines().collect();
167 assert_eq!(lines.len(), 2);
168 assert!(
169 lines[0].contains("level=info target=rk op=init status=ok dur_ms=12"),
170 "{}",
171 lines[0]
172 );
173 assert!(lines[0].starts_with("ts="), "{}", lines[0]);
174 }
175}