// SPDX-FileCopyrightText: © 2026 Jeffrey C. Ollie // SPDX-License-Identifier: MIT //! The user's own clock: what a commit's time reads where they are. //! //! git hands over each date as seconds since the epoch and the author's //! offset, and the conversion to local time is done here, with //! zig-datetime and the IANA timezone database, rather than by git — so //! that a date is a number grebe can compare and format as it likes, not a //! string fixed when it was read. //! //! The zone is the one `TZ` names, as the C library reads it: unset for //! the machine's own (`/etc/localtime`), empty for UTC, a name under //! `TZDIR`, a path, or a POSIX rule. Anything that cannot be read is UTC, //! with a warning, rather than a failure to start. //! //! Windows has no zone files. The machine's own zone is the one Windows is //! set to, by its IANA name, and a zone `TZ` names is looked up by name; //! the data for both is the copy of the database built into grebe. const std = @import("std"); const Allocator = std.mem.Allocator; const datetime = @import("datetime"); const tzdb = datetime.tzdb; const builtin = @import("builtin"); const Clock = @This(); const log = std.log.scoped(.clock); zone: union(enum) { tz: datetime.TimeZone, rule: datetime.posixtz.Posix, utc, }, /// The zone `env` says the user is in. `env` must outlive the clock, since /// a rule borrows its text. pub fn load(gpa: Allocator, io: std.Io, env: *const std.process.Environ.Map) Clock { return loadTz(gpa, io, env.get("TZ"), env.get("TZDIR")) catch |err| { log.warn("reading the time zone: {t}; showing times in UTC", .{err}); return .{ .zone = .utc }; }; } fn loadTz(gpa: Allocator, io: std.Io, tz: ?[]const u8, tzdir: ?[]const u8) !Clock { if (builtin.os.tag == .windows) return loadWindows(tz); switch (tzdb.system.resolveTz(tz)) { .local => return .{ .zone = .{ .tz = try tzdb.system.loadLocal(io, gpa, null) } }, .path => |path| return .{ .zone = .{ .tz = try tzdb.system.loadLocal(io, gpa, path) } }, .utc => return .{ .zone = .utc }, .rule => |rule| return .{ .zone = .{ .rule = try datetime.posixtz.parse(rule) } }, .name => |name| { const zone = if (tzdir) |dir| tzdb.system.load(io, gpa, dir, name) else blk: { for (tzdb.system.search_directories) |dir| { break :blk tzdb.system.load(io, gpa, dir, name) catch continue; } break :blk error.FileNotFound; }; if (zone) |z| return .{ .zone = .{ .tz = z } } else |err| switch (err) { // Not a zone after all, so a rule that looks like a name. error.FileNotFound => return .{ .zone = .{ .rule = try datetime.posixtz.parse(name) } }, else => return err, } }, } } fn loadWindows(tz: ?[]const u8) !Clock { switch (tzdb.system.resolveTz(tz)) { .local => return .{ .zone = .{ .tz = try tzdb.windows.loadLocal() } }, .utc => return .{ .zone = .utc }, .rule => |rule| return .{ .zone = .{ .rule = try datetime.posixtz.parse(rule) } }, .name => |name| if (try tzdb.embedded.load(name)) |z| return .{ .zone = .{ .tz = z } } else return .{ .zone = .{ .rule = try datetime.posixtz.parse(name) } }, .path => return error.FileNotFound, } } pub fn deinit(c: *Clock, gpa: Allocator) void { switch (c.zone) { .tz => |*z| z.deinit(gpa), else => {}, } c.* = undefined; } /// What the user's clock read at `time`, seconds since the epoch. pub fn local(c: *const Clock, time: i64) datetime.DateTime { switch (c.zone) { .tz => |z| return z.atTimestamp(time), .rule => |r| return shifted(time, r.typeAt(time).offset), .utc => return shifted(time, 0), } } /// `time` as a clock `offset` seconds east of UTC reads it. pub fn shifted(time: i64, offset: i32) datetime.DateTime { const instant: datetime.Instant = .fromNanoTimeStamp(@as(i128, time + offset) * std.time.ns_per_s); var dt = instant.asDateTime(); dt.offset = offset; return dt; } /// `time` on the user's clock, to the minute, as the commit list shows it: /// `2026-10-25 13:29`. pub fn short(c: *const Clock, time: i64, buf: *[32]u8) []const u8 { var w: std.Io.Writer = .fixed(buf); c.local(time).format("YYYY-MM-DD HH:mm", &w) catch return ""; return w.buffered(); } const testing = std.testing; test "times read in UTC" { const c: Clock = .{ .zone = .utc }; var buf: [32]u8 = undefined; try testing.expectEqualStrings("1970-01-01 00:00", c.short(0, &buf)); try testing.expectEqualStrings("2026-09-21 14:13", c.short(1790000000, &buf)); // Before the epoch, as imported histories can be. try testing.expectEqualStrings("1969-12-31 00:00", c.short(-86400, &buf)); } test "times read under a POSIX rule" { const c = try loadTz(testing.allocator, testing.io, "EST5EDT,M3.2.0,M11.1.0", null); var buf: [32]u8 = undefined; // In September, daylight saving time: four hours behind. try testing.expectEqualStrings("2026-09-21 10:13", c.short(1790000000, &buf)); // In January, five. try testing.expectEqualStrings("2026-01-01 07:00", c.short(1767268800, &buf)); } test "an empty TZ is UTC" { const c = try loadTz(testing.allocator, testing.io, "", null); try testing.expect(c.zone == .utc); } test "a clock at a fixed offset" { var buf: [32]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); try shifted(1790000000, -5 * 3600).format("YYYY-MM-DD HH:mm", &w); try testing.expectEqualStrings("2026-09-21 09:13", w.buffered()); }