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