//! atproto datetime syntax — https://atproto.com/specs/lexicon#datetime //! //! the pinned atproto profile of RFC 3339 — stricter than the RFC, and the //! spec text pins each divergence: //! - upper-case 'T' and 'Z' required ("lower-case is not allowed") //! - timezone required; "-00:00" ("negative zero") specifically disallowed //! - "Datetimes before year zero ... are specifically disallowed" — enforced //! on the NORMALIZED instant, so an offset cannot smuggle one in //! - fraction: "arbitrary fractional precision digits are allowed"; parsed //! at microsecond precision (truncation is the spec's own acknowledged //! "loss-of-precision" round-trip ambiguity) //! - second 60 (leap second) is not in the profile's grammar and is //! rejected. general RFC 3339 parsers differ here (rust's jiff clamps //! :60 to the last instant of :59; chrono represents-but-ignores) — a //! deliberate divergence from them, matching the interop-fixture-passing //! validator this type replaces. //! validated against the atproto interop fixtures (interop_tests.zig runs //! Datetime.parse over datetime_syntax_valid / _invalid / _parse_invalid). //! //! this type exists because the string↔instant conversion kept being //! hand-rolled: two byte-identical formatRfc3339s in stream, a 45-line //! parser in the jetstream SDK example. parse and format live here, next //! to the rest of the atproto syntax family (Tid, Did, Nsid, Rkey). const std = @import("std"); pub const Datetime = struct { /// microseconds since the unix epoch, UTC. negative = before 1970. micros: i64, /// canonical rendering length: "YYYY-MM-DDTHH:MM:SS.ffffffZ" pub const string_len = 27; pub fn fromMicros(micros: i64) Datetime { return .{ .micros = micros }; } /// parse the full atproto datetime profile, applying any UTC offset. /// null on syntactic or semantic invalidity (month 0, Feb 30, hour 25, /// "-00:00", instants before year 0). fractional digits beyond /// microseconds are truncated. pub fn parse(s: []const u8) ?Datetime { if (s.len < "0000-01-01T00:00:00Z".len or s.len > 64) return null; const year = parse4(s, 0) orelse return null; if (s[4] != '-') return null; const month = parse2(s, 5) orelse return null; if (s[7] != '-') return null; const day = parse2(s, 8) orelse return null; if (s[10] != 'T') return null; const hour = parse2(s, 11) orelse return null; if (s[13] != ':') return null; const minute = parse2(s, 14) orelse return null; if (s[16] != ':') return null; const second = parse2(s, 17) orelse return null; if (month < 1 or month > 12) return null; if (day < 1 or day > daysInMonth(year, month)) return null; if (hour > 23 or minute > 59 or second > 59) return null; var pos: usize = 19; var frac_us: i64 = 0; if (pos < s.len and s[pos] == '.') { pos += 1; const frac_start = pos; var scale: i64 = 100_000; while (pos < s.len and s[pos] >= '0' and s[pos] <= '9') : (pos += 1) { if (scale > 0) { frac_us += @as(i64, s[pos] - '0') * scale; scale = @divTrunc(scale, 10); } } const frac_len = pos - frac_start; if (frac_len == 0 or frac_len > 20) return null; } if (pos >= s.len) return null; var offset_minutes: i64 = 0; if (s[pos] == 'Z') { if (pos + 1 != s.len) return null; } else { if (s[pos] != '+' and s[pos] != '-') return null; const tz_sign: i64 = if (s[pos] == '-') -1 else 1; if (pos + 6 != s.len) return null; const tz_hour = parse2(s, pos + 1) orelse return null; if (s[pos + 3] != ':') return null; const tz_minute = parse2(s, pos + 4) orelse return null; if (tz_hour > 23 or tz_minute > 59) return null; // RFC 3339 §4.3: "-00:00" means "offset unknown" and the // atproto profile rejects it if (tz_sign < 0 and tz_hour == 0 and tz_minute == 0) return null; offset_minutes = tz_sign * (@as(i64, tz_hour) * 60 + tz_minute); } const days = daysFromCivil(@as(i64, year), @intCast(month), @intCast(day)); const local_micros = days * std.time.us_per_day + (@as(i64, hour) * 3600 + @as(i64, minute) * 60 + @as(i64, second)) * std.time.us_per_s + frac_us; const utc_micros = local_micros - offset_minutes * std.time.us_per_min; // the profile's floor: nothing normalizes before 0000-01-01T00:00:00Z // (this generalizes the fixtures' "normalizes to a negative time" case) if (utc_micros < min_micros) return null; return .{ .micros = utc_micros }; } /// canonical atproto form, microsecond precision, always 'Z': /// "1985-04-12T23:20:50.123456Z". buf must hold string_len bytes. pub fn toString(self: Datetime, buf: []u8) []const u8 { const days = @divFloor(self.micros, std.time.us_per_day); const day_us = self.micros - days * std.time.us_per_day; const civil = civilFromDays(days); const day_s: u64 = @intCast(@divTrunc(day_us, std.time.us_per_s)); const frac: u64 = @intCast(@mod(day_us, std.time.us_per_s)); return std.fmt.bufPrint(buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}.{d:0>6}Z", .{ @as(u64, @intCast(civil.year)), civil.month, civil.day, day_s / 3600, (day_s % 3600) / 60, day_s % 60, frac, }) catch unreachable; } }; /// 0000-01-01T00:00:00Z in unix-epoch microseconds const min_micros: i64 = daysFromCivil(0, 1, 1) * std.time.us_per_day; fn parse2(s: []const u8, at: usize) ?u32 { const a = s[at]; const b = s[at + 1]; if (a < '0' or a > '9' or b < '0' or b > '9') return null; return @as(u32, a - '0') * 10 + (b - '0'); } fn parse4(s: []const u8, at: usize) ?u32 { const hi = parse2(s, at) orelse return null; const lo = parse2(s, at + 2) orelse return null; return hi * 100 + lo; } fn isLeap(year: u32) bool { return (year % 4 == 0 and year % 100 != 0) or year % 400 == 0; } fn daysInMonth(year: u32, month: u32) u32 { return switch (month) { 1, 3, 5, 7, 8, 10, 12 => 31, 4, 6, 9, 11 => 30, 2 => if (isLeap(year)) @as(u32, 29) else 28, else => 0, }; } /// Howard Hinnant's days_from_civil: days since 1970-01-01 for a proleptic /// Gregorian date. exact over the whole 0000..9999 profile, unlike /// std.time.epoch which is unsigned and 1970-anchored. fn daysFromCivil(y0: i64, m: i64, d: i64) i64 { const y = if (m <= 2) y0 - 1 else y0; const era = @divFloor(y, 400); const yoe = y - era * 400; // [0, 399] const mp = @mod(m + 9, 12); // Mar=0 .. Feb=11 const doy = @divFloor(153 * mp + 2, 5) + d - 1; // [0, 365] const doe = yoe * 365 + @divFloor(yoe, 4) - @divFloor(yoe, 100) + doy; // [0, 146096] return era * 146097 + doe - 719468; } const Civil = struct { year: i64, month: u32, day: u32 }; /// Hinnant's civil_from_days: inverse of daysFromCivil. fn civilFromDays(z0: i64) Civil { const z = z0 + 719468; const era = @divFloor(z, 146097); const doe = z - era * 146097; // [0, 146096] const yoe = @divFloor(doe - @divFloor(doe, 1460) + @divFloor(doe, 36524) - @divFloor(doe, 146096), 365); // [0, 399] const y = yoe + era * 400; const doy = doe - (365 * yoe + @divFloor(yoe, 4) - @divFloor(yoe, 100)); // [0, 365] const mp = @divFloor(5 * doy + 2, 153); // [0, 11] const d = doy - @divFloor(153 * mp + 2, 5) + 1; // [1, 31] const m = if (mp < 10) mp + 3 else mp - 9; // [1, 12] return .{ .year = if (m <= 2) y + 1 else y, .month = @intCast(m), .day = @intCast(d) }; } // === tests === // the atproto interop fixtures run against Datetime.parse in // interop_tests.zig; these pin the numeric semantics fixtures can't. const testing = std.testing; test "parse and toString round-trip the canonical form" { const dt = Datetime.parse("1985-04-12T23:20:50.123456Z").?; try testing.expectEqual(@as(i64, 482196050123456), dt.micros); var buf: [Datetime.string_len]u8 = undefined; try testing.expectEqualStrings("1985-04-12T23:20:50.123456Z", dt.toString(&buf)); } test "offsets normalize to UTC" { const utc = Datetime.parse("1985-04-12T23:20:50.123Z").?; const minus7 = Datetime.parse("1985-04-12T16:20:50.123-07:00").?; const plus145 = Datetime.parse("1985-04-13T01:05:50.123+01:45").?; try testing.expectEqual(utc.micros, minus7.micros); try testing.expectEqual(utc.micros, plus145.micros); } test "fraction truncates past microseconds; short fractions scale" { try testing.expectEqual( Datetime.parse("1985-04-12T23:20:50.123456Z").?.micros, Datetime.parse("1985-04-12T23:20:50.1234567891Z").?.micros, ); try testing.expectEqual( @as(i64, 482196050100000), Datetime.parse("1985-04-12T23:20:50.1Z").?.micros, ); } test "epoch identities and pre-1970 instants" { try testing.expectEqual(@as(i64, 0), Datetime.parse("1970-01-01T00:00:00Z").?.micros); const pre = Datetime.parse("1969-12-31T23:59:59.000001Z").?; try testing.expectEqual(@as(i64, -999_999), pre.micros); var buf: [Datetime.string_len]u8 = undefined; try testing.expectEqualStrings("1969-12-31T23:59:59.000001Z", pre.toString(&buf)); } test "the year-zero floor is an instant rule, not a string rule" { try testing.expect(Datetime.parse("0000-01-01T00:00:00Z") != null); try testing.expect(Datetime.parse("0000-01-01T00:00:00+00:00") != null); // anything that NORMALIZES before the floor is out, not just the // fixtures' exact case try testing.expect(Datetime.parse("0000-01-01T00:00:00+01:00") == null); try testing.expect(Datetime.parse("0000-01-01T00:30:00+01:00") == null); } test "rejects the profile's forbidden shapes" { try testing.expect(Datetime.parse("1985-04-12T23:20:50.123-00:00") == null); // unknown offset try testing.expect(Datetime.parse("1985-04-12T23:20:61.123Z") == null); // semantic try testing.expect(Datetime.parse("1985-02-30T00:00:00Z") == null); // Feb 30 try testing.expect(Datetime.parse("1985-04-12 23:20:50.123Z") == null); // space separator try testing.expect(Datetime.parse("1985-04-12T23:20:50.Z") == null); // empty fraction try testing.expect(Datetime.parse("1985-04-12T23:20:50") == null); // no timezone } test "leap handling matches the calendar both directions" { const feb29 = Datetime.parse("2024-02-29T12:00:00Z").?; var buf: [Datetime.string_len]u8 = undefined; try testing.expectEqualStrings("2024-02-29T12:00:00.000000Z", feb29.toString(&buf)); try testing.expect(Datetime.parse("2023-02-29T12:00:00Z") == null); try testing.expect(Datetime.parse("1900-02-29T12:00:00Z") == null); // century non-leap try testing.expect(Datetime.parse("2000-02-29T12:00:00Z") != null); // 400-year leap }