Skip to main content

jmap_server/
helpers.rs

1//! Shared helper utilities for JMAP method handlers.
2
3use jmap_types::{Id, JmapError};
4use serde_json::Value;
5
6/// Serialize any [`serde::Serialize`] type to a [`serde_json::Value`],
7/// mapping serialization errors to [`JmapError::server_fail`].
8pub fn ser<T: serde::Serialize>(val: T) -> Result<serde_json::Value, JmapError> {
9    serde_json::to_value(val).map_err(|e| JmapError::server_fail(e.to_string()))
10}
11
12/// Convert a slice of [`Id`]s to a JSON `notFound` value.
13///
14/// Returns `Value::Null` when the slice is empty (the `notFound` field will be
15/// JSON `null`, which RFC 8620 §5.1 specifies when no ids are not-found), or
16/// `Value::Array` of string ids when non-empty.
17pub fn not_found_json(ids: &[Id]) -> Value {
18    if ids.is_empty() {
19        Value::Null
20    } else {
21        Value::Array(
22            ids.iter()
23                .map(|id| Value::String(id.as_ref().to_owned()))
24                .collect(),
25        )
26    }
27}
28
29/// Extract `accountId` from a JMAP method arguments object.
30pub fn extract_account_id(args: &Value) -> Result<Id, JmapError> {
31    match args.get("accountId").and_then(|v| v.as_str()) {
32        Some(s) => Ok(Id::from(s)),
33        None => Err(JmapError::invalid_arguments("accountId is required")),
34    }
35}
36
37/// Return the current UTC instant formatted as an RFC 3339 string.
38///
39/// Uses `std::time::SystemTime` so no external dependency is needed.
40pub fn now_utc_string() -> String {
41    use std::time::{SystemTime, UNIX_EPOCH};
42    let secs = SystemTime::now()
43        .duration_since(UNIX_EPOCH)
44        // unwrap_or_default: if the system clock is before Unix epoch (container
45        // start-up clock drift), returns 0 seconds and formats as 1970-01-01T00:00:00Z.
46        // This is a known limitation — callers should not rely on this string being
47        // accurate during the first few seconds of a container boot.
48        .unwrap_or_default()
49        .as_secs() as i64;
50
51    let s = secs % 60;
52    let m = (secs / 60) % 60;
53    let h = (secs / 3600) % 24;
54    let days = secs / 86400;
55    let (year, month, day) = civil_from_days(days);
56
57    format!("{year:04}-{month:02}-{day:02}T{h:02}:{m:02}:{s:02}Z")
58}
59
60/// Convert a count of days since the Unix epoch (1970-01-01) to a proleptic
61/// Gregorian (year, month, day) triple.
62///
63/// Algorithm by Howard Hinnant (public domain):
64/// <https://howardhinnant.github.io/date_algorithms.html>
65fn civil_from_days(z: i64) -> (i32, u8, u8) {
66    let z = z + 719_468;
67    let era: i64 = if z >= 0 { z } else { z - 146_096 } / 146_097;
68    let doe = z - era * 146_097; // [0, 146096]
69    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; // [0, 399]
70    let y = yoe + era * 400;
71    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365]
72    let mp = (5 * doy + 2) / 153; // [0, 11]
73    let d = doy - (153 * mp + 2) / 5 + 1; // [1, 31]
74    let mo = if mp < 10 { mp + 3 } else { mp - 9 }; // [1, 12]
75    let yr = if mo <= 2 { y + 1 } else { y };
76    (yr as i32, mo as u8, d as u8)
77}
78
79#[cfg(test)]
80mod tests {
81    use super::{civil_from_days, now_utc_string};
82
83    /// Test vectors derived independently with Python's `datetime.date` module.
84    /// `days` is the count of days since 1970-01-01.
85    #[test]
86    fn civil_from_days_known_dates() {
87        let cases: &[(i64, (i32, u8, u8))] = &[
88            (0, (1970, 1, 1)),       // Unix epoch
89            (365, (1971, 1, 1)),     // one year later (1970 is not a leap year)
90            (10957, (2000, 1, 1)),   // Y2K
91            (11016, (2000, 2, 29)),  // leap day in a century-divisible leap year
92            (11017, (2000, 3, 1)),   // day after the leap day (era boundary in algorithm)
93            (19358, (2023, 1, 1)),   // a recent non-leap year start
94            (19722, (2023, 12, 31)), // end of 2023
95            (19782, (2024, 2, 29)),  // leap day in 2024
96            (19783, (2024, 3, 1)),   // day after 2024 leap day
97        ];
98
99        for &(days, expected) in cases {
100            assert_eq!(
101                civil_from_days(days),
102                expected,
103                "civil_from_days({days}) mismatch"
104            );
105        }
106    }
107
108    #[test]
109    fn now_utc_string_format() {
110        let s = now_utc_string();
111        // Must match YYYY-MM-DDTHH:MM:SSZ
112        assert_eq!(s.len(), 20, "unexpected length: {s}");
113        assert!(s.ends_with('Z'), "must end with Z: {s}");
114        assert_eq!(&s[4..5], "-", "missing year-month separator: {s}");
115        assert_eq!(&s[7..8], "-", "missing month-day separator: {s}");
116        assert_eq!(&s[10..11], "T", "missing date-time separator: {s}");
117        assert_eq!(&s[13..14], ":", "missing hour-minute separator: {s}");
118        assert_eq!(&s[16..17], ":", "missing minute-second separator: {s}");
119        assert!(
120            s.starts_with("20"),
121            "year should start with 20 in 21st century: {s}"
122        );
123    }
124}