Skip to main content

rightkit_qa/
util.rs

1//! Shared error type and small helpers.
2use std::fmt;
3use std::time::{Duration, SystemTime, UNIX_EPOCH};
4
5pub type Result<T> = std::result::Result<T, Error>;
6
7/// One flat error: every failure in a QA run is a message a human reads.
8#[derive(Debug, Clone)]
9pub struct Error(pub String);
10
11impl fmt::Display for Error {
12    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
13        f.write_str(&self.0)
14    }
15}
16impl std::error::Error for Error {}
17impl From<std::io::Error> for Error {
18    fn from(e: std::io::Error) -> Self {
19        Error(e.to_string())
20    }
21}
22impl From<serde_json::Error> for Error {
23    fn from(e: serde_json::Error) -> Self {
24        Error(format!("json: {e}"))
25    }
26}
27impl From<toml::de::Error> for Error {
28    fn from(e: toml::de::Error) -> Self {
29        Error(format!("toml: {e}"))
30    }
31}
32
33pub fn err<T>(msg: impl Into<String>) -> Result<T> {
34    Err(Error(msg.into()))
35}
36
37/// Current UTC time as RFC 3339 (`2026-10-10T12:34:56.789Z`; the fractional part is
38/// omitted when zero and has trailing zeros trimmed).
39pub fn now_iso() -> String {
40    let since_epoch = SystemTime::now()
41        .duration_since(UNIX_EPOCH)
42        .unwrap_or_default();
43    format_rfc3339_utc(since_epoch.as_secs(), since_epoch.subsec_nanos())
44}
45
46fn format_rfc3339_utc(unix_secs: u64, nanos: u32) -> String {
47    let days = (unix_secs / 86_400) as i64;
48    let rem = unix_secs % 86_400;
49    // Civil-from-days (proleptic Gregorian), after Howard Hinnant.
50    let z = days + 719_468;
51    let era = z.div_euclid(146_097);
52    let doe = z.rem_euclid(146_097);
53    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
54    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
55    let mp = (5 * doy + 2) / 153;
56    let day = doy - (153 * mp + 2) / 5 + 1;
57    let month = if mp < 10 { mp + 3 } else { mp - 9 };
58    let year = yoe + era * 400 + i64::from(month <= 2);
59    let mut out = format!(
60        "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}",
61        rem / 3_600,
62        rem % 3_600 / 60,
63        rem % 60
64    );
65    if nanos != 0 {
66        let frac = format!("{nanos:09}");
67        out.push('.');
68        out.push_str(frac.trim_end_matches('0'));
69    }
70    out.push('Z');
71    out
72}
73
74pub fn now_ms() -> u128 {
75    SystemTime::now()
76        .duration_since(UNIX_EPOCH)
77        .map(|d| d.as_millis())
78        .unwrap_or(0)
79}
80
81pub fn sha256_hex(bytes: &[u8]) -> String {
82    rightkit_fs::digest::sha256_bytes(bytes)
83}
84
85/// A random RFC 4122 version-4 identifier in hyphenated lowercase form (36 characters).
86pub fn new_id() -> String {
87    let mut b = [0u8; 16];
88    getrandom::fill(&mut b).expect("operating system random source unavailable");
89    b[6] = (b[6] & 0x0f) | 0x40;
90    b[8] = (b[8] & 0x3f) | 0x80;
91    let h = |r: std::ops::Range<usize>| b[r].iter().map(|x| format!("{x:02x}")).collect::<String>();
92    format!(
93        "{}-{}-{}-{}-{}",
94        h(0..4),
95        h(4..6),
96        h(6..8),
97        h(8..10),
98        h(10..16)
99    )
100}
101
102pub fn sleep_ms(ms: u64) {
103    std::thread::sleep(Duration::from_millis(ms));
104}
105
106/// Last `max` bytes of `s`, on a char boundary.
107pub fn tail(s: &str, max: usize) -> String {
108    if s.len() <= max {
109        return s.to_string();
110    }
111    let mut start = s.len() - max;
112    while !s.is_char_boundary(start) {
113        start += 1;
114    }
115    s[start..].to_string()
116}
117
118pub fn require_text(value: &str, name: &str) -> Result<String> {
119    let v = value.trim();
120    if v.is_empty() {
121        return err(format!("{name} is required"));
122    }
123    Ok(v.to_string())
124}
125
126/// `darwin` or `windows`; QA runs on those two desktops only.
127pub fn platform() -> Result<&'static str> {
128    if cfg!(target_os = "macos") {
129        Ok("darwin")
130    } else if cfg!(target_os = "windows") {
131        Ok("windows")
132    } else {
133        err(format!(
134            "rightkit-qa supports macOS and Windows, received {}",
135            std::env::consts::OS
136        ))
137    }
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn rfc3339_known_instants() {
146        assert_eq!(format_rfc3339_utc(0, 0), "1970-01-01T00:00:00Z");
147        assert_eq!(format_rfc3339_utc(951_782_400, 0), "2000-02-29T00:00:00Z");
148        assert_eq!(
149            format_rfc3339_utc(1_791_635_696, 789_000_000),
150            "2026-10-10T12:34:56.789Z"
151        );
152        assert_eq!(
153            format_rfc3339_utc(86_399, 123_456_789),
154            "1970-01-01T23:59:59.123456789Z"
155        );
156        let now = now_iso();
157        assert!(now.ends_with('Z') && now.len() >= 20, "{now}");
158    }
159
160    #[test]
161    fn new_id_is_a_version_4_uuid() {
162        let id = new_id();
163        assert_eq!(id.len(), 36);
164        let parts: Vec<&str> = id.split('-').collect();
165        assert_eq!(
166            parts.iter().map(|p| p.len()).collect::<Vec<_>>(),
167            vec![8, 4, 4, 4, 12]
168        );
169        assert!(parts[2].starts_with('4'));
170        assert!(matches!(parts[3].as_bytes()[0], b'8' | b'9' | b'a' | b'b'));
171        assert!(id.bytes().all(|c| c == b'-' || c.is_ascii_hexdigit()));
172        assert_ne!(id, new_id());
173    }
174}
175
176/// Decode standard base64 (padding optional, whitespace ignored); `None` on any other byte.
177pub fn base64_decode(s: &str) -> Option<Vec<u8>> {
178    let mut out = Vec::with_capacity(s.len() * 3 / 4);
179    let (mut acc, mut bits) = (0u32, 0u32);
180    for c in s.bytes() {
181        let v = match c {
182            b'A'..=b'Z' => c - b'A',
183            b'a'..=b'z' => c - b'a' + 26,
184            b'0'..=b'9' => c - b'0' + 52,
185            b'+' => 62,
186            b'/' => 63,
187            b'=' | b'\n' | b'\r' | b' ' => continue,
188            _ => return None,
189        } as u32;
190        acc = (acc << 6) | v;
191        bits += 6;
192        if bits >= 8 {
193            bits -= 8;
194            out.push((acc >> bits) as u8);
195            acc &= (1 << bits) - 1;
196        }
197    }
198    Some(out)
199}
200
201#[cfg(test)]
202mod base64_tests {
203    use super::base64_decode;
204    #[test]
205    fn decodes_rfc4648_vectors_and_rejects_junk() {
206        for (i, o) in [
207            ("", ""),
208            ("Zg==", "f"),
209            ("Zm8=", "fo"),
210            ("Zm9v", "foo"),
211            ("Zm9vYg==", "foob"),
212            ("Zm9vYmFy", "foobar"),
213        ] {
214            assert_eq!(base64_decode(i).unwrap(), o.as_bytes());
215        }
216        assert!(base64_decode("Zm9v!").is_none());
217    }
218}