Skip to main content

doge_runtime/stdlib/
nap.rs

1//! `nap` — time and clocks. A wall clock (`now`) and a monotonic clock (`mono`),
2//! both reported as Float seconds; a guarded sleep (`rest`); and whole-second UTC
3//! date formatting/parsing (`stamp`/`parse`) in ISO-8601. The calendar conversion
4//! is the public-domain `days_from_civil`/`civil_from_days` integer algorithm, so
5//! `nap` needs no third-party dependency. Every fallible member returns a
6//! catchable `DogeError` — a bad sleep duration or a malformed timestamp never
7//! panics.
8
9use std::sync::OnceLock;
10use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
11
12use crate::error::{DogeError, DogeResult};
13use crate::stdlib::str_arg;
14use crate::value::Value;
15
16/// Seconds in a day, the pivot between a date and a wall-clock time-of-day.
17const SECS_PER_DAY: i64 = 86_400;
18
19/// The origin the monotonic clock measures from, captured on first use. Shared
20/// process-wide (like `env`'s argument slot) so every `nap.mono()` — on any
21/// thread — reports seconds since the same instant.
22static MONO_ORIGIN: OnceLock<Instant> = OnceLock::new();
23
24/// A numeric argument as `f64`, or a catchable type error naming the member.
25/// Shared by `rest`/`stamp`, which both accept an Int or a Float.
26fn numeric(fname: &str, v: &Value) -> DogeResult<f64> {
27    match v {
28        Value::Int(n) => Ok(*n as f64),
29        Value::Float(f) => Ok(*f),
30        _ => Err(DogeError::type_error(format!(
31            "nap.{fname} needs a number, got {}",
32            v.describe()
33        ))),
34    }
35}
36
37/// `nap.now()` — seconds since the Unix epoch (UTC), with sub-second precision.
38/// Never fails: a system clock set before the epoch reads back as a negative
39/// number rather than an error.
40pub fn nap_now() -> DogeResult {
41    let secs = match SystemTime::now().duration_since(UNIX_EPOCH) {
42        Ok(elapsed) => elapsed.as_secs_f64(),
43        Err(before) => -before.duration().as_secs_f64(),
44    };
45    Ok(Value::Float(secs))
46}
47
48/// `nap.mono()` — seconds from a fixed process origin, for measuring elapsed time.
49/// Only differences between two readings are meaningful; the origin itself is
50/// arbitrary. Monotonic, so it never jumps when the wall clock is adjusted.
51pub fn nap_mono() -> DogeResult {
52    let origin = MONO_ORIGIN.get_or_init(Instant::now);
53    Ok(Value::Float(origin.elapsed().as_secs_f64()))
54}
55
56/// `nap.rest(seconds)` — block for `seconds` (Int or Float). A negative,
57/// non-finite, or absurdly large duration is a catchable `ValueError` rather than
58/// the panic `Duration::from_secs_f64` would raise on such an input.
59pub fn nap_rest(seconds: &Value) -> DogeResult {
60    let secs = numeric("rest", seconds)?;
61    if !secs.is_finite() || secs < 0.0 {
62        return Err(DogeError::value_error(format!(
63            "cannot rest for {secs} seconds — the duration must be finite and not negative"
64        )));
65    }
66    if secs > Duration::MAX.as_secs_f64() {
67        return Err(DogeError::value_error(
68            "cannot rest that long — the duration is out of range",
69        ));
70    }
71    std::thread::sleep(Duration::from_secs_f64(secs));
72    Ok(Value::None)
73}
74
75/// `nap.stamp(secs)` — the ISO-8601 UTC string `"YYYY-MM-DDTHH:MM:SSZ"` for a unix
76/// timestamp (Int or Float seconds, truncated to a whole second toward negative
77/// infinity). A timestamp outside the representable Int range is a catchable
78/// `ValueError`.
79pub fn nap_stamp(secs: &Value) -> DogeResult {
80    let secs = numeric("stamp", secs)?;
81    if !secs.is_finite() || secs < i64::MIN as f64 || secs >= i64::MAX as f64 {
82        return Err(DogeError::value_error(
83            "that timestamp is outside the representable range",
84        ));
85    }
86    let secs = secs.floor() as i64;
87    let days = secs.div_euclid(SECS_PER_DAY);
88    let time_of_day = secs.rem_euclid(SECS_PER_DAY);
89    let (year, month, day) = civil_from_days(days);
90    let hour = time_of_day / 3600;
91    let minute = (time_of_day % 3600) / 60;
92    let second = time_of_day % 60;
93    Ok(Value::str(format!(
94        "{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z"
95    )))
96}
97
98/// `nap.parse(text)` — unix seconds (as a Float) for an ISO-8601 UTC timestamp
99/// `"YYYY-MM-DDTHH:MM:SSZ"`. The trailing `Z` is optional. Anything that is not a
100/// well-formed, in-range UTC timestamp is a catchable `ValueError` with a hint.
101pub fn nap_parse(text: &Value) -> DogeResult {
102    let text = str_arg("nap", "parse", text)?;
103    let secs = parse_iso8601(text).ok_or_else(|| {
104        DogeError::value_error(format!(
105            "cannot read \"{text}\" as a timestamp — expected \"YYYY-MM-DDTHH:MM:SSZ\""
106        ))
107    })?;
108    Ok(Value::Float(secs as f64))
109}
110
111/// Parse an ISO-8601 UTC timestamp to whole unix seconds, or `None` when the shape
112/// or any field is invalid. Strict on layout (fixed widths, `-`/`:` separators, a
113/// `T` between date and time) but tolerant of a missing trailing `Z`.
114fn parse_iso8601(text: &str) -> Option<i64> {
115    let text = text.strip_suffix('Z').unwrap_or(text);
116    let (date, time) = text.split_once('T')?;
117
118    let mut date_parts = date.splitn(3, '-');
119    let year: i64 = signed_field(date_parts.next()?)?;
120    let month: i64 = field(date_parts.next()?)?;
121    let day: i64 = field(date_parts.next()?)?;
122    if date_parts.next().is_some() {
123        return None;
124    }
125
126    let mut time_parts = time.splitn(3, ':');
127    let hour: i64 = field(time_parts.next()?)?;
128    let minute: i64 = field(time_parts.next()?)?;
129    let second: i64 = field(time_parts.next()?)?;
130    if time_parts.next().is_some() {
131        return None;
132    }
133
134    if !(1..=12).contains(&month)
135        || !(1..=31).contains(&day)
136        || !(0..=23).contains(&hour)
137        || !(0..=59).contains(&minute)
138        || !(0..=59).contains(&second)
139    {
140        return None;
141    }
142
143    let days = days_from_civil(year, month, day);
144    Some(days * SECS_PER_DAY + hour * 3600 + minute * 60 + second)
145}
146
147/// A non-negative fixed-width numeric field (all ASCII digits), or `None`. Rejects
148/// signs and whitespace, so `" 3"`, `"+3"`, and `"-3"` never slip through.
149fn field(s: &str) -> Option<i64> {
150    if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
151        return None;
152    }
153    s.parse().ok()
154}
155
156/// The year field, which may carry a leading `-` for years before 1 BCE-ish. Any
157/// other sign or stray character is rejected.
158fn signed_field(s: &str) -> Option<i64> {
159    match s.strip_prefix('-') {
160        Some(rest) => field(rest).map(|n| -n),
161        None => field(s),
162    }
163}
164
165/// Days since the Unix epoch (1970-01-01) for a proleptic-Gregorian civil date.
166/// Howard Hinnant's public-domain `days_from_civil`; exact integer math, valid far
167/// beyond any range a script will use.
168fn days_from_civil(year: i64, month: i64, day: i64) -> i64 {
169    let y = if month <= 2 { year - 1 } else { year };
170    let era = if y >= 0 { y } else { y - 399 } / 400;
171    let yoe = y - era * 400;
172    let doy = (153 * (if month > 2 { month - 3 } else { month + 9 }) + 2) / 5 + day - 1;
173    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
174    era * 146_097 + doe - 719_468
175}
176
177/// The civil date `(year, month, day)` for a count of days since the Unix epoch.
178/// The inverse of [`days_from_civil`] (Howard Hinnant's `civil_from_days`).
179fn civil_from_days(days: i64) -> (i64, i64, i64) {
180    let z = days + 719_468;
181    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
182    let doe = z - era * 146_097;
183    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
184    let year = yoe + era * 400;
185    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
186    let mp = (5 * doy + 2) / 153;
187    let day = doy - (153 * mp + 2) / 5 + 1;
188    let month = if mp < 10 { mp + 3 } else { mp - 9 };
189    (if month <= 2 { year + 1 } else { year }, month, day)
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::error::ErrorKind;
196
197    fn stamp(secs: i64) -> String {
198        match nap_stamp(&Value::Int(secs)).unwrap() {
199            Value::Str(s) => s.to_string(),
200            other => panic!("expected a Str, got {other:?}"),
201        }
202    }
203
204    fn parse(text: &str) -> f64 {
205        match nap_parse(&Value::str(text)).unwrap() {
206            Value::Float(f) => f,
207            other => panic!("expected a Float, got {other:?}"),
208        }
209    }
210
211    #[test]
212    fn stamp_formats_known_timestamps() {
213        assert_eq!(stamp(0), "1970-01-01T00:00:00Z");
214        assert_eq!(stamp(946_684_800), "2000-01-01T00:00:00Z");
215        assert_eq!(stamp(1_609_459_199), "2020-12-31T23:59:59Z");
216    }
217
218    #[test]
219    fn stamp_handles_pre_epoch_dates() {
220        assert_eq!(stamp(-1), "1969-12-31T23:59:59Z");
221        assert_eq!(stamp(-SECS_PER_DAY), "1969-12-31T00:00:00Z");
222    }
223
224    #[test]
225    fn parse_is_the_inverse_of_stamp() {
226        for secs in [0, 946_684_800, 1_609_459_199, -1, -SECS_PER_DAY] {
227            assert_eq!(parse(&stamp(secs)), secs as f64);
228        }
229    }
230
231    #[test]
232    fn parse_tolerates_a_missing_z() {
233        assert_eq!(parse("2000-01-01T00:00:00"), 946_684_800.0);
234    }
235
236    #[test]
237    fn parse_rejects_malformed_input() {
238        for bad in [
239            "not a date",
240            "2000-13-01T00:00:00Z",
241            "2000-01-32T00:00:00Z",
242            "2000-01-01T24:00:00Z",
243            "2000-01-01 00:00:00Z",
244            "2000/01/01T00:00:00Z",
245            "2000-01-01T00:00:00+02:00",
246            "",
247        ] {
248            assert_eq!(
249                nap_parse(&Value::str(bad)).unwrap_err().kind,
250                ErrorKind::ValueError,
251                "{bad:?} should be a ValueError"
252            );
253        }
254    }
255
256    #[test]
257    fn rest_rejects_bad_durations() {
258        assert_eq!(
259            nap_rest(&Value::Int(-1)).unwrap_err().kind,
260            ErrorKind::ValueError
261        );
262        assert_eq!(
263            nap_rest(&Value::Float(f64::NAN)).unwrap_err().kind,
264            ErrorKind::ValueError
265        );
266        assert_eq!(
267            nap_rest(&Value::Float(f64::INFINITY)).unwrap_err().kind,
268            ErrorKind::ValueError
269        );
270    }
271
272    #[test]
273    fn rest_zero_returns_none() {
274        assert!(matches!(nap_rest(&Value::Int(0)).unwrap(), Value::None));
275    }
276
277    #[test]
278    fn mono_never_goes_backwards() {
279        let a = match nap_mono().unwrap() {
280            Value::Float(f) => f,
281            other => panic!("expected a Float, got {other:?}"),
282        };
283        let b = match nap_mono().unwrap() {
284            Value::Float(f) => f,
285            other => panic!("expected a Float, got {other:?}"),
286        };
287        assert!(b >= a);
288    }
289
290    #[test]
291    fn now_is_after_the_epoch() {
292        match nap_now().unwrap() {
293            Value::Float(f) => assert!(f > 0.0),
294            other => panic!("expected a Float, got {other:?}"),
295        }
296    }
297
298    #[test]
299    fn stamp_out_of_range_is_value_error() {
300        assert_eq!(
301            nap_stamp(&Value::Float(f64::INFINITY)).unwrap_err().kind,
302            ErrorKind::ValueError
303        );
304    }
305
306    #[test]
307    fn bad_arg_types_are_type_errors() {
308        assert_eq!(
309            nap_rest(&Value::str("soon")).unwrap_err().kind,
310            ErrorKind::TypeError
311        );
312        assert_eq!(
313            nap_stamp(&Value::str("soon")).unwrap_err().kind,
314            ErrorKind::TypeError
315        );
316        assert_eq!(
317            nap_parse(&Value::Int(0)).unwrap_err().kind,
318            ErrorKind::TypeError
319        );
320    }
321}