atproto utils for zig zat.dev
atproto sdk zig
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251//! 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 microsecondsconst 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}