upgrade to zig 0.16.0/fix latent MMDB C interop issue

This commit is contained in:
Emil Lerch 2026-08-04 16:06:14 -07:00
parent c9f3681460
commit f9919d9521
Signed by: lobo
GPG key ID: A7B62D657EF764F8
26 changed files with 464 additions and 308 deletions

2
.gitignore vendored
View file

@ -2,3 +2,5 @@
.zig-cache/ .zig-cache/
zig-out/ zig-out/
coverage/ coverage/
# Zig 0.16 stores fetched packages in-tree rather than the global cache
zig-pkg/

View file

@ -1,5 +1,5 @@
[tools] [tools]
prek = "0.3.1" prek = "0.3.1"
"ubi:DonIsaac/zlint" = "0.7.9" "ubi:DonIsaac/zlint" = "0.7.9"
zig = "0.15.2" zig = "0.16.0"
zls = "0.15.1" zls = "0.16.0"

View file

@ -55,16 +55,15 @@ pub fn build(b: *std.Build) void {
}), }),
}); });
sunriset.addIncludePath(b.path("libs/sunriset")); sunriset.root_module.addIncludePath(b.path("libs/sunriset"));
sunriset.addCSourceFiles(.{ sunriset.root_module.addCSourceFiles(.{
.root = b.path("libs/sunriset"), .root = b.path("libs/sunriset"),
.files = &.{ .files = &.{
"sunriset.c", "sunriset.c",
}, },
.flags = &.{ "-D_DEFAULT_SOURCE", "-DSUNRISET_NO_MAIN" }, .flags = &.{ "-D_DEFAULT_SOURCE", "-DSUNRISET_NO_MAIN" },
}); });
sunriset.linkLibC(); sunriset.root_module.linkSystemLibrary("m", .{});
sunriset.linkSystemLibrary("m");
// Build phoon as a static library // Build phoon as a static library
const phoon = b.addLibrary(.{ const phoon = b.addLibrary(.{
@ -77,8 +76,8 @@ pub fn build(b: *std.Build) void {
}), }),
}); });
phoon.addIncludePath(b.path("libs/phoon_14Aug2014")); phoon.root_module.addIncludePath(b.path("libs/phoon_14Aug2014"));
phoon.addCSourceFiles(.{ phoon.root_module.addCSourceFiles(.{
.root = b.path("libs/phoon_14Aug2014"), .root = b.path("libs/phoon_14Aug2014"),
.files = &.{ .files = &.{
"astro.c", "astro.c",
@ -86,8 +85,7 @@ pub fn build(b: *std.Build) void {
}, },
.flags = &.{ "-std=c99", "-D_DEFAULT_SOURCE" }, .flags = &.{ "-std=c99", "-D_DEFAULT_SOURCE" },
}); });
phoon.linkLibC(); phoon.root_module.linkSystemLibrary("m", .{});
phoon.linkSystemLibrary("m");
// Build libmaxminddb as a static library // Build libmaxminddb as a static library
const maxminddb = b.addLibrary(.{ const maxminddb = b.addLibrary(.{
@ -106,14 +104,26 @@ pub fn build(b: *std.Build) void {
.include_path = "maxminddb_config.h", .include_path = "maxminddb_config.h",
}, .{ }, .{
.PACKAGE_VERSION = "1.11.0", .PACKAGE_VERSION = "1.11.0",
.MMDB_UINT128_USING_MODE = 1, // Represent mmdb_uint128_t as a 16-byte array rather than
// `unsigned int __attribute__((__mode__(TI)))`.
//
// translate-c cannot represent the `mode(TI)` attribute, so with
// MMDB_UINT128_USING_MODE Zig computed sizeof(MMDB_entry_data_s) == 32
// / alignof == 8 while clang used 48 / 16. Every MMDB_get_value call
// then wrote `entry_data->offset` (at C offset 32) past the end of the
// 32-byte variable Zig had reserved on the stack, corrupting whatever
// happened to be adjacent.
//
// With a byte array both sides agree (40 / 8). Nothing here reads
// uint128-typed database fields, so this only affects layout.
.MMDB_UINT128_IS_BYTE_ARRAY = 1,
}); });
maxminddb.addConfigHeader(maxminddb_config); maxminddb.root_module.addConfigHeader(maxminddb_config);
maxminddb.addIncludePath(maxminddb_upstream.path("include")); maxminddb.root_module.addIncludePath(maxminddb_upstream.path("include"));
maxminddb.addIncludePath(maxminddb_upstream.path("src")); maxminddb.root_module.addIncludePath(maxminddb_upstream.path("src"));
maxminddb.addCSourceFiles(.{ maxminddb.root_module.addCSourceFiles(.{
.root = maxminddb_upstream.path(""), .root = maxminddb_upstream.path(""),
.files = &.{ .files = &.{
"src/data-pool.c", "src/data-pool.c",
@ -128,6 +138,8 @@ pub fn build(b: *std.Build) void {
.root_source_file = b.path("src/main.zig"), .root_source_file = b.path("src/main.zig"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
// Zig 0.16 moved libc linkage onto the module; `Compile.linkLibC` is gone.
.link_libc = true,
}); });
root_module.addImport("httpz", httpz.module("httpz")); root_module.addImport("httpz", httpz.module("httpz"));
root_module.addImport("zeit", zeit.module("zeit")); root_module.addImport("zeit", zeit.module("zeit"));
@ -192,6 +204,7 @@ pub fn build(b: *std.Build) void {
} }
fn configureCompilationUnit(compile: *std.Build.Step.Compile, libs: []const *std.Build.Step.Compile) void { fn configureCompilationUnit(compile: *std.Build.Step.Compile, libs: []const *std.Build.Step.Compile) void {
for (libs) |lib| compile.linkLibrary(lib); // Zig 0.16: linkLibrary moved to Module, and libc linkage is a module
compile.linkLibC(); // option set where `root_module` is created.
for (libs) |lib| compile.root_module.linkLibrary(lib);
} }

View file

@ -3,8 +3,8 @@
.version = "0.1.0", .version = "0.1.0",
.dependencies = .{ .dependencies = .{
.httpz = .{ .httpz = .{
.url = "git+https://github.com/karlseguin/http.zig/#1c0ec3751c53e276d5e0c42b6d5481e72d9d1971", .url = "git+https://github.com/karlseguin/http.zig?ref=master#c22672f820280ccd17c77d3894e431c49b46061d",
.hash = "httpz-0.0.0-PNVzrEktBwCzPoiua-S8LAYo2tILqczm3tSpneEzLQ9L", .hash = "httpz-0.0.0-PNVzrMPnCAAWJqhMEZR8gDZeg1CQObCEMTEf8zDh-u-j",
}, },
.maxminddb = .{ .maxminddb = .{
.url = "https://github.com/maxmind/libmaxminddb/archive/refs/tags/1.11.0.tar.gz", .url = "https://github.com/maxmind/libmaxminddb/archive/refs/tags/1.11.0.tar.gz",
@ -15,14 +15,14 @@
.hash = "N-V-__8AAL8tFgMfL4Y2FQTRciAyueOiE5K5PPV3gI3eanes", .hash = "N-V-__8AAL8tFgMfL4Y2FQTRciAyueOiE5K5PPV3gI3eanes",
}, },
.zeit = .{ .zeit = .{
.url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#7ac64d72dbfb1a4ad549102e7d4e232a687d32d8", .url = "git+https://github.com/rockorager/zeit?ref=v0.9.0#b1c1c2fcbc71fd7799a316bbcf0ff88d06d80ccc",
.hash = "zeit-0.6.0-5I6bk36tAgATpSl9wjFmRPMqYN2Mn0JQHgIcRNcqDpJA", .hash = "zeit-0.9.0-5I6bk2m9AgBSMH8-L6rYJkwuQAyhXplnfxnvTSGzVHUR",
}, },
.phoon = .{ .path = "libs/phoon_14Aug2014" }, .phoon = .{ .path = "libs/phoon_14Aug2014" },
.sunriset = .{ .path = "libs/sunriset" }, .sunriset = .{ .path = "libs/sunriset" },
.ghostty = .{ .ghostty = .{
.url = "git+https://github.com/ghostty-org/ghostty#ec2912dbafe50cc32b786d2327dcd0213c83ecc6", .url = "git+https://github.com/ghostty-org/ghostty#ccb08f35f683d6087786dda8e793e911ef1a2f8a",
.hash = "ghostty-1.3.0-dev-5UdBC_y2RASwYWn5fjn71WsP-arlg8wSICLc0rYiozdf", .hash = "ghostty-1.3.2-dev-5UdBC9KHOwUAvF5QKNA6pbo-dV0WE-F9l5NtaT0mhpmu",
}, },
.zigimg = .{ .zigimg = .{
.url = "git+https://github.com/zigimg/zigimg#9714df09f76891323c7fdbbbf23a17b79024fffb", .url = "git+https://github.com/zigimg/zigimg#9714df09f76891323c7fdbbbf23a17b79024fffb",
@ -35,10 +35,11 @@
.nerd_fonts_symbols_only = .{ .nerd_fonts_symbols_only = .{
.url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz", .url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz",
.hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s", .hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s",
.lazy = true,
}, },
}, },
.fingerprint = 0x710c2b57e81aa678, .fingerprint = 0x710c2b57e81aa678,
.minimum_zig_version = "0.15.2", .minimum_zig_version = "0.16.0",
.paths = .{ .paths = .{
"build.zig", "build.zig",
"build.zig.zon", "build.zig.zon",

View file

@ -148,11 +148,9 @@ fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void {
_ = options; _ = options;
const check: *Coverage = @fieldParentPtr("step", step); const check: *Coverage = @fieldParentPtr("step", step);
const allocator = step.owner.allocator; const allocator = step.owner.allocator;
const io = step.owner.graph.io;
const file = try std.fs.cwd().openFile(check.json_path, .{}); const content = try std.Io.Dir.cwd().readFileAlloc(io, check.json_path, allocator, .limited(10 * 1024 * 1024));
defer file.close();
const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
defer allocator.free(content); defer allocator.free(content);
const json = try std.json.parseFromSlice(CoverageReport, allocator, content, .{}); const json = try std.json.parseFromSlice(CoverageReport, allocator, content, .{});
@ -160,7 +158,7 @@ fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void {
const coverage = json.value; const coverage = json.value;
var stdout_buffer: [1024]u8 = undefined; var stdout_buffer: [1024]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer);
const stdout = &stdout_writer.interface; const stdout = &stdout_writer.interface;
if (step.owner.verbose or check.verbose) { if (step.owner.verbose or check.verbose) {
const files = coverage.files; const files = coverage.files;

View file

@ -11,16 +11,19 @@ pub const Options = struct {
/// Get git version information by reading .git files directly /// Get git version information by reading .git files directly
pub fn getVersion(b: *Build, options: Options) []const u8 { pub fn getVersion(b: *Build, options: Options) []const u8 {
const allocator = b.allocator; const allocator = b.allocator;
// Zig 0.16 moved the filesystem behind an explicit `Io`; build scripts get
// theirs from the build graph.
const io = b.graph.io;
// Find build root by looking for build.zig // Find build root by looking for build.zig
const build_root = findBuildRoot(allocator) catch return "unknown"; const build_root = findBuildRoot(allocator, io) catch return "unknown";
defer allocator.free(build_root); defer allocator.free(build_root);
// Read .git/HEAD relative to build root // Read .git/HEAD relative to build root
const head_path = std.fmt.allocPrint(allocator, "{s}/.git/HEAD", .{build_root}) catch return "unknown"; const head_path = std.fmt.allocPrint(allocator, "{s}/.git/HEAD", .{build_root}) catch return "unknown";
defer allocator.free(head_path); defer allocator.free(head_path);
const head_data = std.fs.cwd().readFileAlloc(allocator, head_path, 1024) catch { const head_data = std.Io.Dir.cwd().readFileAlloc(io, head_path, allocator, .limited(1024)) catch {
return "not under version control"; return "not under version control";
}; };
defer allocator.free(head_data); defer allocator.free(head_data);
@ -29,16 +32,13 @@ pub fn getVersion(b: *Build, options: Options) []const u8 {
// Parse HEAD - either "ref: refs/heads/branch" or direct hash // Parse HEAD - either "ref: refs/heads/branch" or direct hash
const hash_owned = if (std.mem.startsWith(u8, head_trimmed, "ref: ")) blk: { const hash_owned = if (std.mem.startsWith(u8, head_trimmed, "ref: ")) blk: {
const ref_path_rel = std.mem.trimLeft(u8, head_trimmed[5..], &std.ascii.whitespace); const ref_path_rel = std.mem.trimStart(u8, head_trimmed[5..], &std.ascii.whitespace);
const ref_file = std.fmt.allocPrint(allocator, "{s}/.git/{s}", .{ build_root, ref_path_rel }) catch return "unknown"; const ref_file = std.fmt.allocPrint(allocator, "{s}/.git/{s}", .{ build_root, ref_path_rel }) catch return "unknown";
defer allocator.free(ref_file); defer allocator.free(ref_file);
const ref_fd = std.fs.openFileAbsolute(ref_file, .{}) catch return "unknown"; const ref_data = std.Io.Dir.cwd().readFileAlloc(io, ref_file, allocator, .limited(1024)) catch
defer ref_fd.close(); return "unknown";
defer allocator.free(ref_data);
var ref_buf: [1024]u8 = undefined;
const bytes_read = ref_fd.readAll(&ref_buf) catch return "unknown";
const ref_data = ref_buf[0..bytes_read];
const ref_trimmed = std.mem.trim(u8, ref_data, &std.ascii.whitespace); const ref_trimmed = std.mem.trim(u8, ref_data, &std.ascii.whitespace);
break :blk allocator.dupe(u8, ref_trimmed) catch return "unknown"; break :blk allocator.dupe(u8, ref_trimmed) catch return "unknown";
@ -53,7 +53,7 @@ pub fn getVersion(b: *Build, options: Options) []const u8 {
// Check if dirty using simple heuristic: // Check if dirty using simple heuristic:
// If any .zig files are newer than .git/index, mark as dirty // If any .zig files are newer than .git/index, mark as dirty
const is_dirty = isDirty(allocator, build_root) catch return "unknown"; const is_dirty = isDirty(allocator, io, build_root) catch return "unknown";
if (is_dirty) { if (is_dirty) {
return std.fmt.allocPrint(allocator, "{s}{s}", .{ short_hash, options.dirty_flag }) catch return "unknown"; return std.fmt.allocPrint(allocator, "{s}{s}", .{ short_hash, options.dirty_flag }) catch return "unknown";
@ -62,17 +62,18 @@ pub fn getVersion(b: *Build, options: Options) []const u8 {
return allocator.dupe(u8, short_hash) catch return "unknown"; return allocator.dupe(u8, short_hash) catch return "unknown";
} }
fn findBuildRoot(allocator: std.mem.Allocator) ![]const u8 { fn findBuildRoot(allocator: std.mem.Allocator, io: std.Io) ![]const u8 {
var buf: [std.fs.max_path_bytes]u8 = undefined; var buf: [std.fs.max_path_bytes]u8 = undefined;
const start_cwd = try std.fs.cwd().realpath(".", &buf); // `realPath` now returns the length written rather than a slice.
var cwd: []const u8 = start_cwd; const start_len = try std.Io.Dir.cwd().realPath(io, &buf);
var cwd: []const u8 = buf[0..start_len];
while (true) { while (true) {
// Check if build.zig exists in current directory // Check if build.zig exists in current directory
var dir = std.fs.openDirAbsolute(cwd, .{}) catch break; var dir = std.Io.Dir.openDirAbsolute(io, cwd, .{}) catch break;
defer dir.close(); defer dir.close(io);
dir.access("build.zig", .{}) catch { dir.access(io, "build.zig", .{}) catch {
// build.zig not found, try parent // build.zig not found, try parent
const parent = std.fs.path.dirname(cwd) orelse break; const parent = std.fs.path.dirname(cwd) orelse break;
if (std.mem.eql(u8, parent, cwd)) break; // Reached root if (std.mem.eql(u8, parent, cwd)) break; // Reached root
@ -86,29 +87,31 @@ fn findBuildRoot(allocator: std.mem.Allocator) ![]const u8 {
return error.BuildRootNotFound; return error.BuildRootNotFound;
} }
fn isDirty(allocator: std.mem.Allocator, build_root: []const u8) !bool { fn isDirty(allocator: std.mem.Allocator, io: std.Io, build_root: []const u8) !bool {
// Get .git/index mtime // Get .git/index mtime
const index_path = try std.fs.path.join(allocator, &[_][]const u8{ build_root, ".git", "index" }); const index_path = try std.fs.path.join(allocator, &[_][]const u8{ build_root, ".git", "index" });
defer allocator.free(index_path); defer allocator.free(index_path);
const index_stat = std.fs.cwd().statFile(index_path) catch return error.CannotDetermineDirty; const index_stat = std.Io.Dir.cwd().statFile(io, index_path, .{}) catch return error.CannotDetermineDirty;
const index_mtime = index_stat.mtime; // Zig 0.16 models mtime as `Io.Timestamp`; compare the raw nanoseconds so
// we keep the precision the old integer mtime had.
const index_mtime = index_stat.mtime.nanoseconds;
// Read .gitignore // Read .gitignore
const ignore_path = try std.fs.path.join(allocator, &[_][]const u8{ build_root, ".gitignore" }); const ignore_path = try std.fs.path.join(allocator, &[_][]const u8{ build_root, ".gitignore" });
defer allocator.free(ignore_path); defer allocator.free(ignore_path);
const ignore_data = std.fs.cwd().readFileAlloc(allocator, ignore_path, 1024 * 1024) catch const ignore_data = std.Io.Dir.cwd().readFileAlloc(io, ignore_path, allocator, .limited(1024 * 1024)) catch
try allocator.dupe(u8, ""); try allocator.dupe(u8, "");
defer allocator.free(ignore_data); defer allocator.free(ignore_data);
// Walk source files in build root and check if any are newer // Walk source files in build root and check if any are newer
var dir = std.fs.openDirAbsolute(build_root, .{ .iterate = true }) catch return error.CannotDetermineDirty; var dir = std.Io.Dir.openDirAbsolute(io, build_root, .{ .iterate = true }) catch return error.CannotDetermineDirty;
defer dir.close(); defer dir.close(io);
var walker = dir.walk(allocator) catch return error.CannotDetermineDirty; var walker = dir.walk(allocator) catch return error.CannotDetermineDirty;
defer walker.deinit(); defer walker.deinit();
while (walker.next() catch return error.CannotDetermineDirty) |entry| { while (walker.next(io) catch return error.CannotDetermineDirty) |entry| {
if (entry.kind != .file) continue; if (entry.kind != .file) continue;
// Always ignore .git/ // Always ignore .git/
@ -129,8 +132,8 @@ fn isDirty(allocator: std.mem.Allocator, build_root: []const u8) !bool {
} }
if (ignored) continue; if (ignored) continue;
const stat = entry.dir.statFile(entry.basename) catch continue; const stat = entry.dir.statFile(io, entry.basename, .{}) catch continue;
if (stat.mtime > index_mtime) { if (stat.mtime.nanoseconds > index_mtime) {
return true; return true;
} }
} }

View file

@ -1,11 +1,11 @@
const std = @import("std"); const std = @import("std");
pub fn main() !void { pub fn main(init: std.process.Init) !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; const allocator = init.gpa;
const allocator = gpa.allocator(); const io = init.io;
const args = try std.process.argsAlloc(allocator); const args = try init.minimal.args.toSlice(allocator);
defer std.process.argsFree(allocator, args); defer allocator.free(args);
if (args.len != 3) return error.InvalidArgs; if (args.len != 3) return error.InvalidArgs;
@ -13,20 +13,20 @@ pub fn main() !void {
const arch_name = args[2]; const arch_name = args[2];
// Check to see if file exists. If it does, we have nothing more to do // Check to see if file exists. If it does, we have nothing more to do
const stat = std.fs.cwd().statFile(kcov_path) catch |err| blk: { const stat = std.Io.Dir.cwd().statFile(io, kcov_path, .{}) catch |err| blk: {
if (err == error.FileNotFound) break :blk null else return err; if (err == error.FileNotFound) break :blk null else return err;
}; };
// This might be better checking whether it's executable and >= 7MB, but // This might be better checking whether it's executable and >= 7MB, but
// for now, we'll do a simple exists check // for now, we'll do a simple exists check
if (stat != null) return; if (stat != null) return;
var stdout_buffer: [1024]u8 = undefined; var stdout_buffer: [1024]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer); var stdout_writer = std.Io.File.stdout().writer(io, &stdout_buffer);
const stdout = &stdout_writer.interface; const stdout = &stdout_writer.interface;
try stdout.writeAll("Determining latest kcov version\n"); try stdout.writeAll("Determining latest kcov version\n");
try stdout.flush(); try stdout.flush();
var client = std.http.Client{ .allocator = allocator }; var client = std.http.Client{ .allocator = allocator, .io = io };
defer client.deinit(); defer client.deinit();
// Get redirect to find latest version // Get redirect to find latest version
@ -58,17 +58,20 @@ pub fn main() !void {
defer allocator.free(binary_url); defer allocator.free(binary_url);
const cache_dir = std.fs.path.dirname(kcov_path) orelse return error.InvalidPath; const cache_dir = std.fs.path.dirname(kcov_path) orelse return error.InvalidPath;
std.fs.cwd().makeDir(cache_dir) catch |e| switch (e) { std.Io.Dir.cwd().createDir(io, cache_dir, .default_dir) catch |e| switch (e) {
error.PathAlreadyExists => {}, error.PathAlreadyExists => {},
else => return e, else => return e,
}; };
const uri = try std.Uri.parse(binary_url); const uri = try std.Uri.parse(binary_url);
const file = try std.fs.cwd().createFile(kcov_path, .{ .mode = 0o755 }); const file = try std.Io.Dir.cwd().createFile(io, kcov_path, .{});
defer file.close(); defer file.close(io);
// Zig 0.16 dropped `CreateFileOptions.mode`; permissions are set separately.
// kcov is executed by the coverage step, so it needs the executable bit.
try file.setPermissions(io, .executable_file);
var buffer: [8192]u8 = undefined; var buffer: [8192]u8 = undefined;
var writer = file.writer(&buffer); var writer = file.writer(io, &buffer);
const result = try client.fetch(.{ const result = try client.fetch(.{
.location = .{ .uri = uri }, .location = .{ .uri = uri },
.response_writer = &writer.interface, .response_writer = &writer.interface,

View file

@ -133,8 +133,9 @@ pub const Time = struct {
/// ///
/// Note: year,month,date = calendar date, 1801-2099 only. /// Note: year,month,date = calendar date, 1801-2099 only.
pub fn init(latitude: f64, longitude: f64, timestamp: i64) Astronomical { pub fn init(latitude: f64, longitude: f64, timestamp: i64) Astronomical {
const instant = zeit.instant(.{ .source = .{ .unix_timestamp = timestamp } }) catch // zeit 0.9 takes the source directly plus an explicit timezone, and no
@panic("This can't happen"); // longer returns an error for a fixed timestamp.
const instant = zeit.instant(.{ .unix_timestamp = timestamp }, &zeit.utc);
const time = instant.time(); const time = instant.time();
const year: c_int = @intCast(time.year); const year: c_int = @intCast(time.year);

View file

@ -34,10 +34,11 @@ ip2location_cache_file: []const u8,
/// Cache file for ipwho.is lookups /// Cache file for ipwho.is lookups
ipwhois_cache_file: []const u8, ipwhois_cache_file: []const u8,
pub fn load(allocator: std.mem.Allocator) !Config { /// Loads configuration from the process environment.
var env = try std.process.getEnvMap(allocator); ///
defer env.deinit(); /// Zig 0.16 removed `std.process.getEnvMap`; the environment map is now
/// supplied by the runtime to `main` and threaded in by the caller.
pub fn load(allocator: std.mem.Allocator, env: *const std.process.Environ.Map) !Config {
// Get XDG_CACHE_HOME or default to ~/.cache // Get XDG_CACHE_HOME or default to ~/.cache
const home = env.get("HOME") orelse "/tmp"; const home = env.get("HOME") orelse "/tmp";
const xdg_cache = env.get("XDG_CACHE_HOME") orelse const xdg_cache = env.get("XDG_CACHE_HOME") orelse
@ -89,6 +90,17 @@ pub fn load(allocator: std.mem.Allocator) !Config {
}; };
} }
/// Loads configuration from an empty environment, for tests.
///
/// Zig 0.16 removed any way to reach the process environment outside of `main`,
/// and tests are better off not depending on ambient variables anyway: this
/// keeps them reproducible regardless of the developer's shell.
pub fn loadForTest(allocator: std.mem.Allocator) !Config {
var env: std.process.Environ.Map = .init(allocator);
defer env.deinit();
return load(allocator, &env);
}
pub fn deinit(self: Config, allocator: std.mem.Allocator) void { pub fn deinit(self: Config, allocator: std.mem.Allocator) void {
allocator.free(self.listen_host); allocator.free(self.listen_host);
allocator.free(self.cache_dir); allocator.free(self.cache_dir);
@ -101,7 +113,7 @@ pub fn deinit(self: Config, allocator: std.mem.Allocator) void {
test "config loads defaults" { test "config loads defaults" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const cfg = try Config.load(allocator); const cfg = try Config.loadForTest(allocator);
defer cfg.deinit(allocator); defer cfg.deinit(allocator);
try std.testing.expectEqualStrings("0.0.0.0", cfg.listen_host); try std.testing.expectEqualStrings("0.0.0.0", cfg.listen_host);

39
src/cache/Cache.zig vendored
View file

@ -6,6 +6,8 @@ const Cache = @This();
const log = std.log.scoped(.cache); const log = std.log.scoped(.cache);
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
/// Zig 0.16 requires an explicit `Io` for filesystem access.
io: std.Io,
lru: Lru, lru: Lru,
/// Cache directory for L2 persistent cache /// Cache directory for L2 persistent cache
cache_dir: ?[]const u8, cache_dir: ?[]const u8,
@ -15,9 +17,9 @@ pub const Config = struct {
cache_dir: ?[]const u8, cache_dir: ?[]const u8,
}; };
pub fn init(allocator: std.mem.Allocator, config: Config) !*Cache { pub fn init(allocator: std.mem.Allocator, io: std.Io, config: Config) !*Cache {
if (config.cache_dir) |d| if (config.cache_dir) |d|
std.fs.makeDirAbsolute(d) catch |err| { std.Io.Dir.cwd().createDirPath(io, d) catch |err| {
if (err != error.PathAlreadyExists) return err; if (err != error.PathAlreadyExists) return err;
}; };
@ -26,7 +28,8 @@ pub fn init(allocator: std.mem.Allocator, config: Config) !*Cache {
cache.* = Cache{ cache.* = Cache{
.allocator = allocator, .allocator = allocator,
.lru = try Lru.init(allocator, config.max_entries), .io = io,
.lru = try Lru.init(allocator, io, config.max_entries),
.cache_dir = if (config.cache_dir) |d| try allocator.dupe(u8, d) else null, .cache_dir = if (config.cache_dir) |d| try allocator.dupe(u8, d) else null,
}; };
@ -62,7 +65,7 @@ pub fn get(self: *Cache, key: []const u8) ?[]const u8 {
} }
pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl_seconds: u64) !void { pub fn put(self: *Cache, key: []const u8, value: []const u8, ttl_seconds: u64) !void {
const now = std.time.milliTimestamp(); const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
const expires = now + @as(i64, @intCast(ttl_seconds * 1000)); const expires = now + @as(i64, @intCast(ttl_seconds * 1000));
// Write to L2 (disk) first if cache_dir is set // Write to L2 (disk) first if cache_dir is set
@ -117,18 +120,18 @@ fn loadFromFile(self: *Cache, key: []const u8) !CacheEntry {
/// if the file access fails OR if the data has expired. /// if the file access fails OR if the data has expired.
/// If the data has expired, the file will be deleted /// If the data has expired, the file will be deleted
fn loadFromFilePath(self: *Cache, file_path: []const u8) !CacheEntry { fn loadFromFilePath(self: *Cache, file_path: []const u8) !CacheEntry {
const file = try std.fs.cwd().openFile(file_path, .{}); const file = try std.Io.Dir.cwd().openFile(self.io, file_path, .{});
defer file.close(); defer file.close(self.io);
var buffer: [1 * 1024 * 1024]u8 = undefined; var buffer: [1 * 1024 * 1024]u8 = undefined;
var file_reader = file.reader(&buffer); var file_reader = file.reader(self.io, &buffer);
const reader = &file_reader.interface; const reader = &file_reader.interface;
const cached = try deserialize(self.allocator, reader); const cached = try deserialize(self.allocator, reader);
errdefer cached.deinit(self.allocator); errdefer cached.deinit(self.allocator);
// Check if expired // Check if expired
const now = std.time.milliTimestamp(); const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
if (cached.expires <= now) { if (cached.expires <= now) {
// We're expired, delete expired file // We're expired, delete expired file
self.deleteFile(cached.key); self.deleteFile(cached.key);
@ -171,11 +174,11 @@ fn saveToFile(self: *Cache, key: []const u8, value: []const u8, expires: i64) !v
const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, filename }); const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, filename });
defer self.allocator.free(file_path); defer self.allocator.free(file_path);
const file = try std.fs.cwd().createFile(file_path, .{}); const file = try std.Io.Dir.cwd().createFile(self.io, file_path, .{});
defer file.close(); defer file.close(self.io);
var buffer: [4096]u8 = undefined; var buffer: [4096]u8 = undefined;
var file_writer = file.writer(&buffer); var file_writer = file.writer(self.io, &buffer);
const writer = &file_writer.interface; const writer = &file_writer.interface;
try serialize(writer, key, value, expires); try serialize(writer, key, value, expires);
try writer.flush(); try writer.flush();
@ -184,11 +187,11 @@ fn saveToFile(self: *Cache, key: []const u8, value: []const u8, expires: i64) !v
fn loadFromDir(self: *Cache) !void { fn loadFromDir(self: *Cache) !void {
if (self.cache_dir == null) return error.NoCacheDir; if (self.cache_dir == null) return error.NoCacheDir;
var dir = try std.fs.cwd().openDir(self.cache_dir.?, .{ .iterate = true }); var dir = try std.Io.Dir.cwd().openDir(self.io, self.cache_dir.?, .{ .iterate = true });
defer dir.close(); defer dir.close(self.io);
var it = dir.iterate(); var it = dir.iterate();
while (try it.next()) |entry| { while (try it.next(self.io)) |entry| {
if (entry.kind != .file) continue; if (entry.kind != .file) continue;
const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, entry.name }); const file_path = try std.fs.path.join(self.allocator, &.{ self.cache_dir.?, entry.name });
@ -211,7 +214,7 @@ fn deleteFile(self: *Cache, key: []const u8) void {
const file_path = std.fs.path.join(self.allocator, &.{ self.cache_dir.?, filename }) catch @panic("OOM"); const file_path = std.fs.path.join(self.allocator, &.{ self.cache_dir.?, filename }) catch @panic("OOM");
defer self.allocator.free(file_path); defer self.allocator.free(file_path);
std.fs.cwd().deleteFile(file_path) catch |e| { std.Io.Dir.cwd().deleteFile(self.io, file_path) catch |e| {
log.warn("Error deleting expired cache file {s}: {}", .{ file_path, e }); log.warn("Error deleting expired cache file {s}: {}", .{ file_path, e });
}; };
} }
@ -264,9 +267,11 @@ test "L1/L2 cache flow" {
defer tmp_dir.cleanup(); defer tmp_dir.cleanup();
var path_buf: [std.fs.max_path_bytes]u8 = undefined; var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const cache_dir = try tmp_dir.dir.realpath(".", &path_buf); // 0.16: `realPath` resolves the dir itself and returns the length written.
const cache_dir_len = try tmp_dir.dir.realPath(std.testing.io, &path_buf);
const cache_dir = path_buf[0..cache_dir_len];
const cache = try Cache.init(allocator, .{ .max_entries = 10, .cache_dir = cache_dir }); const cache = try Cache.init(allocator, std.testing.io, .{ .max_entries = 10, .cache_dir = cache_dir });
defer cache.deinit(); defer cache.deinit();
// Put item in cache // Put item in cache

15
src/cache/Lru.zig vendored
View file

@ -3,6 +3,8 @@ const std = @import("std");
const Lru = @This(); const Lru = @This();
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
/// Zig 0.16 reads the clock through `Io` rather than `std.time`.
io: std.Io,
map: std.StringHashMap(Entry), map: std.StringHashMap(Entry),
max_entries: usize, max_entries: usize,
evict_fn: ?*const fn (ctx: *anyopaque, key: []const u8) void = null, evict_fn: ?*const fn (ctx: *anyopaque, key: []const u8) void = null,
@ -14,9 +16,10 @@ const Entry = struct {
access_count: u64, access_count: u64,
}; };
pub fn init(allocator: std.mem.Allocator, max_entries: usize) !Lru { pub fn init(allocator: std.mem.Allocator, io: std.Io, max_entries: usize) !Lru {
return .{ return .{
.allocator = allocator, .allocator = allocator,
.io = io,
.map = std.StringHashMap(Entry).init(allocator), .map = std.StringHashMap(Entry).init(allocator),
.max_entries = max_entries, .max_entries = max_entries,
}; };
@ -30,7 +33,7 @@ pub fn setEvictionCallback(self: *Lru, ctx: *anyopaque, callback: *const fn (ctx
pub fn get(self: *Lru, key: []const u8) ?[]const u8 { pub fn get(self: *Lru, key: []const u8) ?[]const u8 {
var entry = self.map.getPtr(key) orelse return null; var entry = self.map.getPtr(key) orelse return null;
const now = std.time.milliTimestamp(); const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
if (now > entry.expires) { if (now > entry.expires) {
self.remove(key); self.remove(key);
return null; return null;
@ -120,7 +123,7 @@ pub fn iterator(self: *Lru) Iterator {
} }
test "LRU basic operations" { test "LRU basic operations" {
var lru = try Lru.init(std.testing.allocator, 3); var lru = try Lru.init(std.testing.allocator, std.testing.io, 3);
defer lru.deinit(); defer lru.deinit();
try lru.put("key1", "value1", 9999999999999); try lru.put("key1", "value1", 9999999999999);
@ -128,7 +131,7 @@ test "LRU basic operations" {
} }
test "LRU eviction" { test "LRU eviction" {
var lru = try Lru.init(std.testing.allocator, 2); var lru = try Lru.init(std.testing.allocator, std.testing.io, 2);
defer lru.deinit(); defer lru.deinit();
try lru.put("key1", "value1", 9999999999999); try lru.put("key1", "value1", 9999999999999);
@ -139,11 +142,11 @@ test "LRU eviction" {
} }
test "LRU expired entry returns null" { test "LRU expired entry returns null" {
var lru = try Lru.init(std.testing.allocator, 10); var lru = try Lru.init(std.testing.allocator, std.testing.io, 10);
defer lru.deinit(); defer lru.deinit();
// Put item with past expiration time // Put item with past expiration time
const now = std.time.milliTimestamp(); const now = std.Io.Timestamp.now(std.testing.io, .real).toMilliseconds();
const past_expires = now - 1000; const past_expires = now - 1000;
try lru.put("key1", "value1", past_expires); try lru.put("key1", "value1", past_expires);

View file

@ -5,7 +5,11 @@ const RateLimiter = @This();
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
buckets: std.StringHashMap(TokenBucket), buckets: std.StringHashMap(TokenBucket),
config: Config, config: Config,
mutex: std.Thread.Mutex, /// Zig 0.16 replaced `std.Thread.Mutex` with `std.Io.Mutex`, whose lock is
/// cancelable and therefore needs the `Io`. Stored so the public API keeps its
/// non-erroring signature.
io: std.Io,
mutex: std.Io.Mutex,
pub const Config = struct { pub const Config = struct {
capacity: u32 = 300, capacity: u32 = 300,
@ -39,12 +43,13 @@ const TokenBucket = struct {
} }
}; };
pub fn init(allocator: std.mem.Allocator, config: Config) !RateLimiter { pub fn init(allocator: std.mem.Allocator, io: std.Io, config: Config) !RateLimiter {
return RateLimiter{ return RateLimiter{
.allocator = allocator, .allocator = allocator,
.buckets = std.StringHashMap(TokenBucket).init(allocator), .buckets = std.StringHashMap(TokenBucket).init(allocator),
.config = config, .config = config,
.mutex = .{}, .io = io,
.mutex = .init,
}; };
} }
@ -52,10 +57,12 @@ pub fn init(allocator: std.mem.Allocator, config: Config) !RateLimiter {
/// Note: Calling this function consumes a token from the bucket, even if it returns false. /// Note: Calling this function consumes a token from the bucket, even if it returns false.
/// Returns true if the request should be accepted, false if rate limited. /// Returns true if the request should be accepted, false if rate limited.
pub fn shouldAcceptRequest(self: *RateLimiter, ip: []const u8) bool { pub fn shouldAcceptRequest(self: *RateLimiter, ip: []const u8) bool {
self.mutex.lock(); // A canceled lock acquisition is treated as "reject": failing closed is the
defer self.mutex.unlock(); // safe direction for a rate limiter.
self.mutex.lock(self.io) catch return false;
defer self.mutex.unlock(self.io);
const now = std.time.milliTimestamp(); const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
const result = self.buckets.getOrPut(ip) catch return false; const result = self.buckets.getOrPut(ip) catch return false;
if (!result.found_existing) { if (!result.found_existing) {
@ -83,7 +90,7 @@ pub fn deinit(self: *RateLimiter) void {
} }
test "rate limiter allows requests within capacity" { test "rate limiter allows requests within capacity" {
var limiter = try RateLimiter.init(std.testing.allocator, .{ var limiter = try RateLimiter.init(std.testing.allocator, std.testing.io, .{
.capacity = 10, .capacity = 10,
.refill_rate = 1, .refill_rate = 1,
.refill_interval_ms = 1000, .refill_interval_ms = 1000,
@ -97,7 +104,7 @@ test "rate limiter allows requests within capacity" {
} }
test "rate limiter blocks after capacity exhausted" { test "rate limiter blocks after capacity exhausted" {
var limiter = try RateLimiter.init(std.testing.allocator, .{ var limiter = try RateLimiter.init(std.testing.allocator, std.testing.io, .{
.capacity = 5, .capacity = 5,
.refill_rate = 1, .refill_rate = 1,
.refill_interval_ms = 1000, .refill_interval_ms = 1000,
@ -113,7 +120,7 @@ test "rate limiter blocks after capacity exhausted" {
} }
test "rate limiter refills tokens over time" { test "rate limiter refills tokens over time" {
var limiter = try RateLimiter.init(std.testing.allocator, .{ var limiter = try RateLimiter.init(std.testing.allocator, std.testing.io, .{
.capacity = 10, .capacity = 10,
.refill_rate = 5, .refill_rate = 5,
.refill_interval_ms = 100, .refill_interval_ms = 100,
@ -127,13 +134,13 @@ test "rate limiter refills tokens over time" {
try std.testing.expect(!limiter.shouldAcceptRequest("1.2.3.4")); try std.testing.expect(!limiter.shouldAcceptRequest("1.2.3.4"));
std.Thread.sleep(250 * std.time.ns_per_ms); try std.Io.sleep(std.testing.io, .fromMilliseconds(250), .real);
try std.testing.expect(limiter.shouldAcceptRequest("1.2.3.4")); try std.testing.expect(limiter.shouldAcceptRequest("1.2.3.4"));
} }
test "rate limiter tracks different IPs separately" { test "rate limiter tracks different IPs separately" {
var limiter = try RateLimiter.init(std.testing.allocator, .{ var limiter = try RateLimiter.init(std.testing.allocator, std.testing.io, .{
.capacity = 2, .capacity = 2,
.refill_rate = 1, .refill_rate = 1,
.refill_interval_ms = 1000, .refill_interval_ms = 1000,

View file

@ -39,6 +39,7 @@ pub const Context = struct {
pub fn init( pub fn init(
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
io: std.Io,
host: []const u8, host: []const u8,
port: u16, port: u16,
options: handler.HandleWeatherOptions, options: handler.HandleWeatherOptions,
@ -51,9 +52,10 @@ pub fn init(
.rate_limiter = rate_limiter, .rate_limiter = rate_limiter,
}; };
var httpz_server = try httpz.Server(*Context).init(allocator, .{ // httpz takes the `Io` first under Zig 0.16, and its listen address is now
.address = host, // a parsed `Io.net.IpAddress` union rather than a host string plus port.
.port = port, var httpz_server = try httpz.Server(*Context).init(io, allocator, .{
.address = .{ .ip = try std.Io.net.IpAddress.parse(host, port) },
}, ctx); }, ctx);
// We won't use actual middleware for rate limiting here because we only have // We won't use actual middleware for rate limiting here because we only have
@ -107,7 +109,13 @@ fn rateLimitMiddleware(limiter: *RateLimiter, client_ip: []const u8, res: *httpz
} }
pub fn listen(self: *Server) !void { pub fn listen(self: *Server) !void {
log.info("wttr listening on port {d}", .{self.httpz_server.config.port.?}); // httpz's listen address is a union under Zig 0.16. `IpAddress` formats as
// "host:port" for both v4 and v6, so this reports address and port; the
// unix arm keeps this from illegally reading an inactive union field.
switch (self.httpz_server.config.address) {
.ip => |ip| log.info("wttr listening on {f}", .{ip}),
.unix => |path| log.info("wttr listening on unix socket {s}", .{path}),
}
try self.httpz_server.listen(); try self.httpz_server.listen();
} }
@ -133,28 +141,28 @@ pub const MockHarness = struct {
const Cache = @import("../cache/Cache.zig"); const Cache = @import("../cache/Cache.zig");
pub fn init(allocator: std.mem.Allocator) !MockHarness { pub fn init(allocator: std.mem.Allocator) !MockHarness {
const config = try Config.load(allocator); const config = try Config.loadForTest(allocator);
errdefer config.deinit(allocator); errdefer config.deinit(allocator);
if (build_options.download_geoip) { if (build_options.download_geoip) {
const GeoLite2 = @import("../location/GeoLite2.zig"); const GeoLite2 = @import("../location/GeoLite2.zig");
try GeoLite2.ensureDatabase(allocator, config.geolite_path); try GeoLite2.ensureDatabase(allocator, std.testing.io, config.geolite_path);
} }
const geoip = try allocator.create(GeoIp); const geoip = try allocator.create(GeoIp);
errdefer allocator.destroy(geoip); errdefer allocator.destroy(geoip);
geoip.* = GeoIp.init(allocator, config.geolite_path, config) catch geoip.* = GeoIp.init(allocator, std.testing.io, config.geolite_path, config) catch
return error.SkipZigTest; return error.SkipZigTest;
errdefer geoip.deinit(); errdefer geoip.deinit();
var geocache = try allocator.create(GeoCache); var geocache = try allocator.create(GeoCache);
errdefer allocator.destroy(geocache); errdefer allocator.destroy(geocache);
geocache.* = try GeoCache.init(allocator, null); geocache.* = try GeoCache.init(allocator, std.testing.io, null);
errdefer geocache.deinit(); errdefer geocache.deinit();
const resolver = try allocator.create(Resolver); const resolver = try allocator.create(Resolver);
errdefer allocator.destroy(resolver); errdefer allocator.destroy(resolver);
resolver.* = Resolver.init(allocator, geoip, geocache, null); resolver.* = Resolver.init(allocator, std.testing.io, geoip, geocache, null);
const mock = try allocator.create(Mock); const mock = try allocator.create(Mock);
errdefer allocator.destroy(mock); errdefer allocator.destroy(mock);
@ -190,7 +198,7 @@ pub const MockHarness = struct {
// Add wildcard response for tests // Add wildcard response for tests
try mock.responses.put(try allocator.dupe(u8, "*"), try allocator.dupe(u8, "{}")); try mock.responses.put(try allocator.dupe(u8, "*"), try allocator.dupe(u8, "{}"));
var cache = try Cache.init(allocator, .{ var cache = try Cache.init(allocator, std.testing.io, .{
.max_entries = 100, .max_entries = 100,
.cache_dir = config.cache_dir, .cache_dir = config.cache_dir,
}); });
@ -208,6 +216,7 @@ pub const MockHarness = struct {
.provider = mock.provider(cache), .provider = mock.provider(cache),
.resolver = resolver, .resolver = resolver,
.geoip = geoip, .geoip = geoip,
.io = std.testing.io,
}, },
}; };
} }
@ -280,7 +289,7 @@ test "handleWeather: client IP only" {
defer ht.deinit(); defer ht.deinit();
// Set connection address to a valid IP that will be in GeoIP database // Set connection address to a valid IP that will be in GeoIP database
ht.req.address = try std.net.Address.parseIp("73.158.64.1", 0); ht.req.address = try std.Io.net.IpAddress.parse("73.158.64.1", 0);
ht.url("/"); ht.url("/");

View file

@ -20,6 +20,8 @@ pub const HandleWeatherOptions = struct {
provider: WeatherProvider, provider: WeatherProvider,
resolver: *Resolver, resolver: *Resolver,
geoip: *@import("../location/GeoIp.zig"), geoip: *@import("../location/GeoIp.zig"),
/// Zig 0.16 reads the clock (and everything else) through `Io`.
io: std.Io,
}; };
/// Only used for shutdown route (/stop) in debug mode /// Only used for shutdown route (/stop) in debug mode
@ -97,6 +99,9 @@ fn handleWeatherInternal(
client_ip: []const u8, client_ip: []const u8,
) !void { ) !void {
const req_alloc = req.arena; const req_alloc = req.arena;
// Read the clock once per request and pass it down, so the render layer
// stays free of I/O.
const now_unix_s = std.Io.Timestamp.now(opts.io, .real).toSeconds();
// Check for PNG request // Check for PNG request
const is_png = if (comptime build_options.enable_png) const is_png = if (comptime build_options.enable_png)
@ -183,7 +188,7 @@ fn handleWeatherInternal(
const png_writer = &png_writer_impl; const png_writer = &png_writer_impl;
render_options.format = .ansi; // Force ANSI for PNG render_options.format = .ansi; // Force ANSI for PNG
try renderWeatherData(png_writer, weather, params, render_options); try renderWeatherData(png_writer, weather, params, render_options, now_unix_s);
const text_output = png_buffer[0..png_writer_impl.end]; const text_output = png_buffer[0..png_writer_impl.end];
try png_renderer.buffer.appendSlice(req_alloc, text_output); try png_renderer.buffer.appendSlice(req_alloc, text_output);
@ -208,7 +213,7 @@ fn handleWeatherInternal(
); );
res.content_type = if (render_options.format == .html) .HTML else .TEXT; res.content_type = if (render_options.format == .html) .HTML else .TEXT;
} }
try renderWeatherData(res.writer(), weather, params, render_options); try renderWeatherData(res.writer(), weather, params, render_options, now_unix_s);
} }
fn renderWeatherData( fn renderWeatherData(
@ -216,6 +221,9 @@ fn renderWeatherData(
weather: types.WeatherData, weather: types.WeatherData,
params: QueryParams, params: QueryParams,
render_options: Formatted.RenderOptions, render_options: Formatted.RenderOptions,
/// Current unix time, read once by the caller. Keeps the render path free of
/// I/O now that Zig 0.16 requires an `Io` to read the clock.
now_unix_s: i64,
) !void { ) !void {
if (params.format) |fmt| { if (params.format) |fmt| {
if (std.mem.eql(u8, fmt, "1")) { if (std.mem.eql(u8, fmt, "1")) {
@ -229,11 +237,11 @@ fn renderWeatherData(
} else if (std.mem.eql(u8, fmt, "j1")) { } else if (std.mem.eql(u8, fmt, "j1")) {
try Json.render(writer, weather); try Json.render(writer, weather);
} else if (std.mem.eql(u8, fmt, "p1")) { } else if (std.mem.eql(u8, fmt, "p1")) {
try Prometheus.render(writer, weather); try Prometheus.render(writer, weather, now_unix_s);
} else if (std.mem.eql(u8, fmt, "v2")) { } else if (std.mem.eql(u8, fmt, "v2")) {
try V2.render(writer, weather, render_options.use_imperial); try V2.render(writer, weather, render_options.use_imperial);
} else { } else {
try Custom.render(writer, weather, fmt, render_options.use_imperial); try Custom.render(writer, weather, fmt, render_options.use_imperial, now_unix_s);
} }
} else { } else {
try Formatted.render(writer, weather, render_options); try Formatted.render(writer, weather, render_options);

View file

@ -6,6 +6,8 @@ const GeoCache = @This();
const log = std.log.scoped(.geocache); const log = std.log.scoped(.geocache);
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
/// Zig 0.16 requires an explicit `Io` for filesystem access.
io: std.Io,
cache: std.StringHashMap(CachedLocation), cache: std.StringHashMap(CachedLocation),
cache_file: ?[]const u8, cache_file: ?[]const u8,
dirty: bool, dirty: bool,
@ -16,22 +18,23 @@ pub const CachedLocation = struct {
coords: Coordinates, coords: Coordinates,
}; };
pub fn init(allocator: std.mem.Allocator, cache_file: ?[]const u8) !GeoCache { pub fn init(allocator: std.mem.Allocator, io: std.Io, cache_file: ?[]const u8) !GeoCache {
var cache = std.StringHashMap(CachedLocation).init(allocator); var cache = std.StringHashMap(CachedLocation).init(allocator);
// Load from file if specified // Load from file if specified
if (cache_file) |file_path| { if (cache_file) |file_path| {
loadFromFile(allocator, &cache, file_path) catch |err| { loadFromFile(allocator, io, &cache, file_path) catch |err| {
log.warn("Failed to load geocoding cache from {s}: {}", .{ file_path, err }); log.warn("Failed to load geocoding cache from {s}: {}", .{ file_path, err });
}; };
} }
return GeoCache{ return GeoCache{
.allocator = allocator, .allocator = allocator,
.io = io,
.cache = cache, .cache = cache,
.cache_file = if (cache_file) |f| try allocator.dupe(u8, f) else null, .cache_file = if (cache_file) |f| try allocator.dupe(u8, f) else null,
.dirty = false, .dirty = false,
.last_save = std.time.milliTimestamp(), .last_save = std.Io.Timestamp.now(io, .real).toMilliseconds(),
}; };
} }
@ -73,7 +76,7 @@ pub fn saveIfNeeded(self: *GeoCache) void {
const cache_file = self.cache_file orelse return; const cache_file = self.cache_file orelse return;
const now = std.time.milliTimestamp(); const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
const elapsed_ms = now - self.last_save; const elapsed_ms = now - self.last_save;
const fifteen_minutes_ms = 15 * std.time.ms_per_min; const fifteen_minutes_ms = 15 * std.time.ms_per_min;
@ -116,11 +119,8 @@ fn load(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLocation),
} }
} }
fn loadFromFile(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLocation), file_path: []const u8) !void { fn loadFromFile(allocator: std.mem.Allocator, io: std.Io, cache: *std.StringHashMap(CachedLocation), file_path: []const u8) !void {
const file = try std.fs.cwd().openFile(file_path, .{}); const content = try std.Io.Dir.cwd().readFileAlloc(io, file_path, allocator, .limited(10 * 1024 * 1024)); // 10MB max
defer file.close();
const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); // 10MB max
defer allocator.free(content); defer allocator.free(content);
try load(allocator, cache, content); try load(allocator, cache, content);
@ -149,11 +149,11 @@ fn save(self: *GeoCache, writer: *std.Io.Writer) !void {
} }
fn saveToFile(self: *GeoCache, file_path: []const u8) !void { fn saveToFile(self: *GeoCache, file_path: []const u8) !void {
const file = try std.fs.cwd().createFile(file_path, .{}); const file = try std.Io.Dir.cwd().createFile(self.io, file_path, .{});
defer file.close(); defer file.close(self.io);
var buffer: [4096]u8 = undefined; var buffer: [4096]u8 = undefined;
var file_writer = file.writer(&buffer); var file_writer = file.writer(self.io, &buffer);
const writer = &file_writer.interface; const writer = &file_writer.interface;
try self.save(writer); try self.save(writer);
@ -162,7 +162,7 @@ fn saveToFile(self: *GeoCache, file_path: []const u8) !void {
test "GeoCache basic operations" { test "GeoCache basic operations" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
var cache = try GeoCache.init(allocator, null); var cache = try GeoCache.init(allocator, std.testing.io, null);
defer cache.deinit(); defer cache.deinit();
// Test put and get // Test put and get
@ -182,7 +182,7 @@ test "GeoCache basic operations" {
test "GeoCache miss returns null" { test "GeoCache miss returns null" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
var cache = try GeoCache.init(allocator, null); var cache = try GeoCache.init(allocator, std.testing.io, null);
defer cache.deinit(); defer cache.deinit();
const result = cache.get("NonExistent"); const result = cache.get("NonExistent");
@ -191,7 +191,7 @@ test "GeoCache miss returns null" {
test "save produces valid JSON" { test "save produces valid JSON" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
var cache = try GeoCache.init(allocator, null); var cache = try GeoCache.init(allocator, std.testing.io, null);
defer cache.deinit(); defer cache.deinit();
try cache.put("London", .{ try cache.put("London", .{
@ -245,7 +245,7 @@ test "load parses valid JSON" {
test "save and load round-trip" { test "save and load round-trip" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
var cache1 = try GeoCache.init(allocator, null); var cache1 = try GeoCache.init(allocator, std.testing.io, null);
defer cache1.deinit(); defer cache1.deinit();
try cache1.put("Berlin", .{ try cache1.put("Berlin", .{

View file

@ -11,6 +11,24 @@ const c = @cImport({
const GeoIP = @This(); const GeoIP = @This();
const log = std.log.scoped(.geoip); const log = std.log.scoped(.geoip);
// libmaxminddb writes into `MMDB_entry_data_s` values that we declare on the
// Zig stack, so Zig's view of that struct must match the C compiler's exactly.
//
// It did not: `MMDB_UINT128_USING_MODE` types `mmdb_uint128_t` as
// `unsigned int __attribute__((__mode__(TI)))`, which translate-c cannot
// represent. Zig saw 32 bytes / 8-byte alignment where clang saw 48 / 16, so
// every `MMDB_get_value` call wrote `entry_data->offset` (C offset 32) past the
// end of the variable Zig had reserved -- silent stack corruption whose blast
// radius depended on the adjacent stack slot.
//
// `build.zig` now selects `MMDB_UINT128_IS_BYTE_ARRAY`, under which both
// compilers agree. This assertion fails the build if that ever regresses,
// rather than letting the corruption return unnoticed.
comptime {
std.debug.assert(@sizeOf(c.MMDB_entry_data_s) == 40);
std.debug.assert(@alignOf(c.MMDB_entry_data_s) == 8);
}
const FallbackClient = union(enum) { const FallbackClient = union(enum) {
ip2location: *Ip2location, ip2location: *Ip2location,
ipwhois: *IpWhoIs, ipwhois: *IpWhoIs,
@ -40,7 +58,7 @@ mmdb: *c.MMDB_s,
fallback_client: FallbackClient, fallback_client: FallbackClient,
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator, db_path: []const u8, config: Config) !GeoIP { pub fn init(allocator: std.mem.Allocator, io: std.Io, db_path: []const u8, config: Config) !GeoIP {
const path_z = try std.heap.c_allocator.dupeZ(u8, db_path); const path_z = try std.heap.c_allocator.dupeZ(u8, db_path);
defer std.heap.c_allocator.free(path_z); defer std.heap.c_allocator.free(path_z);
@ -55,7 +73,7 @@ pub fn init(allocator: std.mem.Allocator, db_path: []const u8, config: Config) !
.ip2location => blk: { .ip2location => blk: {
const client = try allocator.create(Ip2location); const client = try allocator.create(Ip2location);
errdefer allocator.destroy(client); errdefer allocator.destroy(client);
client.* = try Ip2location.init(allocator, config.ip2location_api_key, config.ip2location_cache_file); client.* = try Ip2location.init(allocator, io, config.ip2location_api_key, config.ip2location_cache_file);
std.log.info( std.log.info(
"GeoIP fallback: IP2Location ({s}, cache: {s})", "GeoIP fallback: IP2Location ({s}, cache: {s})",
.{ if (config.ip2location_api_key) |_| "key provided, 50k/mo limit" else "no key, 1k/day limit", config.ip2location_cache_file }, .{ if (config.ip2location_api_key) |_| "key provided, 50k/mo limit" else "no key, 1k/day limit", config.ip2location_cache_file },
@ -65,7 +83,7 @@ pub fn init(allocator: std.mem.Allocator, db_path: []const u8, config: Config) !
.ipwhois => blk: { .ipwhois => blk: {
const client = try allocator.create(IpWhoIs); const client = try allocator.create(IpWhoIs);
errdefer allocator.destroy(client); errdefer allocator.destroy(client);
client.* = try IpWhoIs.init(allocator, config.ipwhois_cache_file); client.* = try IpWhoIs.init(allocator, io, config.ipwhois_cache_file);
std.log.info("GeoIP fallback: ipwho.is (cache: {s})", .{config.ipwhois_cache_file}); std.log.info("GeoIP fallback: ipwho.is (cache: {s})", .{config.ipwhois_cache_file});
break :blk .{ .ipwhois = client }; break :blk .{ .ipwhois = client };
}, },
@ -229,25 +247,25 @@ test "MMDB functions are callable" {
} }
test "GeoIP init with invalid path fails" { test "GeoIP init with invalid path fails" {
const config = try Config.load(std.testing.allocator); const config = try Config.loadForTest(std.testing.allocator);
defer config.deinit(std.testing.allocator); defer config.deinit(std.testing.allocator);
const result = GeoIP.init(std.testing.allocator, "/nonexistent/path.mmdb", config); const result = GeoIP.init(std.testing.allocator, std.testing.io, "/nonexistent/path.mmdb", config);
try std.testing.expectError(error.CannotOpenDatabase, result); try std.testing.expectError(error.CannotOpenDatabase, result);
} }
test "isUSIp detects US IPs" { test "isUSIp detects US IPs" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const config = try Config.load(allocator); const config = try Config.loadForTest(allocator);
defer config.deinit(allocator); defer config.deinit(allocator);
const build_options = @import("build_options"); const build_options = @import("build_options");
const db_path = config.geolite_path; const db_path = config.geolite_path;
if (build_options.download_geoip) { if (build_options.download_geoip) {
const GeoLite2 = @import("GeoLite2.zig"); const GeoLite2 = @import("GeoLite2.zig");
try GeoLite2.ensureDatabase(std.testing.allocator, db_path); try GeoLite2.ensureDatabase(std.testing.allocator, std.testing.io, db_path);
} }
var geoip = GeoIP.init(std.testing.allocator, db_path, config) catch var geoip = GeoIP.init(std.testing.allocator, std.testing.io, db_path, config) catch
return error.SkipZigTest; return error.SkipZigTest;
defer geoip.deinit(); defer geoip.deinit();
@ -260,17 +278,17 @@ test "isUSIp detects US IPs" {
} }
test "lookup works" { test "lookup works" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const config = try Config.load(allocator); const config = try Config.loadForTest(allocator);
defer config.deinit(allocator); defer config.deinit(allocator);
const build_options = @import("build_options"); const build_options = @import("build_options");
const db_path = config.geolite_path; const db_path = config.geolite_path;
if (build_options.download_geoip) { if (build_options.download_geoip) {
const GeoLite2 = @import("GeoLite2.zig"); const GeoLite2 = @import("GeoLite2.zig");
try GeoLite2.ensureDatabase(std.testing.allocator, db_path); try GeoLite2.ensureDatabase(std.testing.allocator, std.testing.io, db_path);
} }
var geoip = GeoIP.init(std.testing.allocator, db_path, config) catch var geoip = GeoIP.init(std.testing.allocator, std.testing.io, db_path, config) catch
return error.SkipZigTest; return error.SkipZigTest;
defer geoip.deinit(); defer geoip.deinit();

View file

@ -1,20 +1,20 @@
const std = @import("std"); const std = @import("std");
const log = std.log.scoped(.geolite2); const log = std.log.scoped(.geolite2);
pub fn ensureDatabase(allocator: std.mem.Allocator, path: []const u8) !void { pub fn ensureDatabase(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !void {
std.fs.cwd().access(path, .{}) catch { std.Io.Dir.cwd().access(io, path, .{}) catch {
log.info("GeoLite2 database not found at {s}, will download", .{path}); log.info("GeoLite2 database not found at {s}, will download", .{path});
try downloadDatabase(allocator, path); try downloadDatabase(allocator, io, path);
log.info("GeoLite2 database downloaded successfully", .{}); log.info("GeoLite2 database downloaded successfully", .{});
return; return;
}; };
} }
fn downloadDatabase(allocator: std.mem.Allocator, path: []const u8) !void { fn downloadDatabase(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !void {
const latest_url = try getLatestReleaseUrl(allocator); const latest_url = try getLatestReleaseUrl(allocator, io);
defer allocator.free(latest_url); defer allocator.free(latest_url);
var client: std.http.Client = .{ .allocator = allocator }; var client: std.http.Client = .{ .allocator = allocator, .io = io };
defer client.deinit(); defer client.deinit();
const uri = try std.Uri.parse(latest_url); const uri = try std.Uri.parse(latest_url);
@ -32,16 +32,17 @@ fn downloadDatabase(allocator: std.mem.Allocator, path: []const u8) !void {
// Ensure directory exists // Ensure directory exists
if (std.fs.path.dirname(path)) |dir| { if (std.fs.path.dirname(path)) |dir| {
try std.fs.cwd().makePath(dir); try std.Io.Dir.cwd().createDirPath(io, dir);
} }
const file = try std.fs.cwd().createFile(path, .{}); try std.Io.Dir.cwd().writeFile(io, .{
defer file.close(); .sub_path = path,
try file.writeAll(response_buf[0..writer.end]); .data = response_buf[0..writer.end],
});
} }
fn getLatestReleaseUrl(allocator: std.mem.Allocator) ![]const u8 { fn getLatestReleaseUrl(allocator: std.mem.Allocator, io: std.Io) ![]const u8 {
var client: std.http.Client = .{ .allocator = allocator }; var client: std.http.Client = .{ .allocator = allocator, .io = io };
defer client.deinit(); defer client.deinit();
const api_url = "https://api.github.com/repos/P3TERX/GeoLite.mmdb/releases/latest"; const api_url = "https://api.github.com/repos/P3TERX/GeoLite.mmdb/releases/latest";

View file

@ -7,18 +7,23 @@ const Self = @This();
const log = std.log.scoped(.ip2location); const log = std.log.scoped(.ip2location);
allocator: Allocator, allocator: Allocator,
/// Zig 0.16 requires an explicit `Io` for both filesystem and HTTP work. It is
/// stored alongside the allocator so only `init` signatures change rather than
/// every method that touches a file or the network.
io: std.Io,
api_key: ?[]const u8, api_key: ?[]const u8,
http_client: std.http.Client, http_client: std.http.Client,
cache: *Cache, cache: *Cache,
pub fn init(allocator: Allocator, api_key: ?[]const u8, cache_path: []const u8) !Self { pub fn init(allocator: Allocator, io: std.Io, api_key: ?[]const u8, cache_path: []const u8) !Self {
const cache = try allocator.create(Cache); const cache = try allocator.create(Cache);
errdefer allocator.destroy(cache); errdefer allocator.destroy(cache);
cache.* = try .init(allocator, cache_path); cache.* = try .init(allocator, io, cache_path);
return .{ return .{
.allocator = allocator, .allocator = allocator,
.io = io,
.api_key = if (api_key) |k| try allocator.dupe(u8, k) else null, .api_key = if (api_key) |k| try allocator.dupe(u8, k) else null,
.http_client = std.http.Client{ .allocator = allocator }, .http_client = std.http.Client{ .allocator = allocator, .io = io },
.cache = cache, .cache = cache,
}; };
} }
@ -31,15 +36,32 @@ pub fn deinit(self: *Self) void {
self.allocator.free(k); self.allocator.free(k);
} }
/// An IP address packed into a `u128` cache key, with the address family it
/// came from (4 or 6).
pub const PackedIp = struct {
key: u128,
family: u8,
};
/// Packs a textual IP address into a `u128` cache key.
///
/// Zig 0.16 replaced `std.net.Address` with `std.Io.net.IpAddress`, which
/// exposes the address bytes directly instead of requiring casts through
/// `sockaddr`. The union is exhaustive, so there is no longer an unreachable
/// "unknown family" case to handle.
pub fn packIp(ip_str: []const u8) ?PackedIp {
const addr = std.Io.net.IpAddress.parse(ip_str, 0) catch return null;
return switch (addr) {
.ip4 => |a| .{ .key = std.mem.readInt(u32, &a.bytes, .big), .family = 4 },
.ip6 => |a| .{ .key = std.mem.readInt(u128, &a.bytes, .big), .family = 6 },
};
}
pub fn lookup(self: *Self, ip_str: []const u8) ?Location { pub fn lookup(self: *Self, ip_str: []const u8) ?Location {
// Parse IP to u128 for cache lookup // Parse IP to u128 for cache lookup
const addr = std.net.Address.parseIp(ip_str, 0) catch return null; const parsed = packIp(ip_str) orelse return null;
const ip_u128: u128 = switch (addr.any.family) { const ip_u128 = parsed.key;
std.posix.AF.INET => @as(u128, @intCast(std.mem.readInt(u32, @ptrCast(&addr.in.sa.addr), .big))), const family = parsed.family;
std.posix.AF.INET6 => std.mem.readInt(u128, @ptrCast(&addr.in6.sa.addr), .big),
else => return null,
};
const family: u8 = if (addr.any.family == std.posix.AF.INET) 4 else 6;
// Check cache first // Check cache first
if (self.cache.get(ip_u128)) |result| if (self.cache.get(ip_u128)) |result|
@ -72,7 +94,7 @@ fn fetch(self: *Self, ip_str: []const u8) !Location {
try w.print("&key={s}", .{key}); try w.print("&key={s}", .{key});
var response_buf: [4096]u8 = undefined; var response_buf: [4096]u8 = undefined;
var writer = std.io.Writer.fixed(&response_buf); var writer = std.Io.Writer.fixed(&response_buf);
const result = try self.http_client.fetch(.{ const result = try self.http_client.fetch(.{
.location = .{ .url = w.buffered() }, .location = .{ .url = w.buffered() },
.method = .GET, .method = .GET,
@ -142,13 +164,15 @@ inline fn getString(obj: std.json.ObjectMap, key: []const u8) []const u8 {
pub const Cache = struct { pub const Cache = struct {
allocator: Allocator, allocator: Allocator,
io: std.Io,
path: []const u8, path: []const u8,
entries: std.AutoHashMap(u128, Location), entries: std.AutoHashMap(u128, Location),
file: ?std.fs.File, file: ?std.Io.File,
pub fn init(allocator: Allocator, path: []const u8) !Cache { pub fn init(allocator: Allocator, io: std.Io, path: []const u8) !Cache {
var cache = Cache{ var cache = Cache{
.allocator = allocator, .allocator = allocator,
.io = io,
.path = try allocator.dupe(u8, path), .path = try allocator.dupe(u8, path),
.entries = std.AutoHashMap(u128, Location).init(allocator), .entries = std.AutoHashMap(u128, Location).init(allocator),
.file = null, .file = null,
@ -156,17 +180,17 @@ pub const Cache = struct {
errdefer allocator.free(cache.path); errdefer allocator.free(cache.path);
// Try to open existing cache file // Try to open existing cache file
if (std.fs.openFileAbsolute(path, .{ .mode = .read_write })) |file| { if (std.Io.Dir.openFileAbsolute(io, path, .{ .mode = .read_write })) |file| {
cache.file = file; cache.file = file;
try cache.load(); try cache.load();
} else |err| switch (err) { } else |err| switch (err) {
error.FileNotFound => { error.FileNotFound => {
// Create new cache file // Create new cache file
const dir = std.fs.path.dirname(path) orelse return error.InvalidPath; const dir = std.fs.path.dirname(path) orelse return error.InvalidPath;
try std.fs.cwd().makePath(dir); try std.Io.Dir.cwd().createDirPath(io, dir);
cache.file = try std.fs.createFileAbsolute(path, .{ .read = true }); cache.file = try std.Io.Dir.createFileAbsolute(io, path, .{ .read = true });
// Write header // Write header
try cache.file.?.writeAll("#Ip2location:v2\n"); try cache.file.?.writePositionalAll(io, "#Ip2location:v2\n", 0);
}, },
else => return err, else => return err,
} }
@ -175,7 +199,7 @@ pub const Cache = struct {
} }
pub fn deinit(self: *Cache) void { pub fn deinit(self: *Cache) void {
if (self.file) |f| f.close(); if (self.file) |f| f.close(self.io);
var it = self.entries.valueIterator(); var it = self.entries.valueIterator();
while (it.next()) |loc| { while (it.next()) |loc| {
self.allocator.free(loc.name); self.allocator.free(loc.name);
@ -186,10 +210,18 @@ pub const Cache = struct {
fn load(self: *Cache) !void { fn load(self: *Cache) !void {
const file = self.file orelse return; const file = self.file orelse return;
const file_size = try file.getEndPos(); const file_size = try file.length(self.io);
if (file_size == 0) return; if (file_size == 0) return;
const content = try file.readToEndAlloc(self.allocator, file_size); const content = try std.Io.Dir.cwd().readFileAlloc(
self.io,
self.path,
self.allocator,
// `Io.Limit` fails when the limit is *reached*, not merely exceeded,
// so a limit of exactly `file_size` would reject a file of that
// size. Allow one extra byte.
.limited64(file_size + 1),
);
defer self.allocator.free(content); defer self.allocator.free(content);
var lines = std.mem.splitScalar(u8, content, '\n'); var lines = std.mem.splitScalar(u8, content, '\n');
@ -198,9 +230,9 @@ pub const Cache = struct {
if (lines.next()) |first_line| { if (lines.next()) |first_line| {
if (!std.mem.eql(u8, first_line, "#Ip2location:v2")) { if (!std.mem.eql(u8, first_line, "#Ip2location:v2")) {
log.warn("Cache file missing or invalid header, discarding", .{}); log.warn("Cache file missing or invalid header, discarding", .{});
file.close(); file.close(self.io);
self.file = null; self.file = null;
std.fs.deleteFileAbsolute(self.path) catch |e| { std.Io.Dir.deleteFileAbsolute(self.io, self.path) catch |e| {
log.err("error deleting {s}: {}", .{ self.path, e }); log.err("error deleting {s}: {}", .{ self.path, e });
}; };
return; return;
@ -233,13 +265,10 @@ pub const Cache = struct {
const lon = try std.fmt.parseFloat(f64, lon_str); const lon = try std.fmt.parseFloat(f64, lon_str);
// Try parsing as IP address first, fall back to u128 // Try parsing as IP address first, fall back to u128
const ip_u128 = if (std.net.Address.parseIp(ip_str, 0)) |addr| blk: { const ip_u128 = if (packIp(ip_str)) |parsed|
break :blk switch (addr.any.family) { parsed.key
std.posix.AF.INET => @as(u128, @intCast(std.mem.readInt(u32, @ptrCast(&addr.in.sa.addr), .big))), else
std.posix.AF.INET6 => std.mem.readInt(u128, @ptrCast(&addr.in6.sa.addr), .big), try std.fmt.parseInt(u128, ip_str, 10);
else => return error.InvalidIpFamily,
};
} else |_| try std.fmt.parseInt(u128, ip_str, 10);
const name_copy = try allocator.dupe(u8, name); const name_copy = try allocator.dupe(u8, name);
return .{ return .{
@ -271,7 +300,8 @@ pub const Cache = struct {
// Append to file: ip,lat,lon,name // Append to file: ip,lat,lon,name
if (self.file) |file| { if (self.file) |file| {
try file.seekFromEnd(0); // 0.16 has no seek+write; append at the current end offset.
const end = try file.length(self.io);
// Format IP as string for file // Format IP as string for file
var buf: [64]u8 = undefined; var buf: [64]u8 = undefined;
const ip_str = if (family == 4) const ip_str = if (family == 4)
@ -299,7 +329,7 @@ pub const Cache = struct {
loc.name, loc.name,
}); });
defer self.allocator.free(line); defer self.allocator.free(line);
try file.writeAll(line); try file.writePositionalAll(self.io, line, end);
} }
} }
}; };

View file

@ -1,23 +1,27 @@
const std = @import("std"); const std = @import("std");
const Allocator = std.mem.Allocator; const Allocator = std.mem.Allocator;
const Location = @import("resolver.zig").Location; const Location = @import("resolver.zig").Location;
const Cache = @import("Ip2location.zig").Cache; const Ip2location = @import("Ip2location.zig");
const Cache = Ip2location.Cache;
const Self = @This(); const Self = @This();
const log = std.log.scoped(.ipwhois); const log = std.log.scoped(.ipwhois);
allocator: Allocator, allocator: Allocator,
/// Zig 0.16 requires an explicit `Io` for filesystem and HTTP work.
io: std.Io,
http_client: std.http.Client, http_client: std.http.Client,
cache: *Cache, cache: *Cache,
pub fn init(allocator: Allocator, cache_path: []const u8) !Self { pub fn init(allocator: Allocator, io: std.Io, cache_path: []const u8) !Self {
const cache = try allocator.create(Cache); const cache = try allocator.create(Cache);
errdefer allocator.destroy(cache); errdefer allocator.destroy(cache);
cache.* = try .init(allocator, cache_path); cache.* = try .init(allocator, io, cache_path);
return .{ return .{
.allocator = allocator, .allocator = allocator,
.http_client = std.http.Client{ .allocator = allocator }, .io = io,
.http_client = std.http.Client{ .allocator = allocator, .io = io },
.cache = cache, .cache = cache,
}; };
} }
@ -30,13 +34,9 @@ pub fn deinit(self: *Self) void {
pub fn lookup(self: *Self, ip_str: []const u8) ?Location { pub fn lookup(self: *Self, ip_str: []const u8) ?Location {
// Parse IP to u128 for cache lookup // Parse IP to u128 for cache lookup
const addr = std.net.Address.parseIp(ip_str, 0) catch return null; const parsed = Ip2location.packIp(ip_str) orelse return null;
const ip_u128: u128 = switch (addr.any.family) { const ip_u128 = parsed.key;
std.posix.AF.INET => @as(u128, @intCast(std.mem.readInt(u32, @ptrCast(&addr.in.sa.addr), .big))), const family = parsed.family;
std.posix.AF.INET6 => std.mem.readInt(u128, @ptrCast(&addr.in6.sa.addr), .big),
else => return null,
};
const family: u8 = if (addr.any.family == std.posix.AF.INET) 4 else 6;
// Check cache first // Check cache first
if (self.cache.get(ip_u128)) |result| if (self.cache.get(ip_u128)) |result|

View file

@ -80,13 +80,16 @@ pub const LocationType = enum {
/// has a permanent cache /// has a permanent cache
pub const Resolver = struct { pub const Resolver = struct {
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
/// Zig 0.16 requires an explicit `Io` for DNS lookups and HTTP requests.
io: std.Io,
geoip: ?*GeoIp, geoip: ?*GeoIp,
geocache: *GeoCache, geocache: *GeoCache,
airports: ?*Airports, airports: ?*Airports,
pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIp, geocache: *GeoCache, airports: ?*Airports) Resolver { pub fn init(allocator: std.mem.Allocator, io: std.Io, geoip: ?*GeoIp, geocache: *GeoCache, airports: ?*Airports) Resolver {
return .{ return .{
.allocator = allocator, .allocator = allocator,
.io = io,
.geoip = geoip, .geoip = geoip,
.geocache = geocache, .geocache = geocache,
.airports = airports, .airports = airports,
@ -124,22 +127,28 @@ pub const Resolver = struct {
} }
fn resolveDomain(self: *Resolver, domain: []const u8) !Location { fn resolveDomain(self: *Resolver, domain: []const u8) !Location {
// Use std.net to resolve domain to IP // Zig 0.16 replaced `std.net.getAddressList` with a queue-based lookup
const addr_list = std.net.getAddressList(self.allocator, domain, 0) catch { // on `Io.net.HostName`. A capacity of 16 is documented as sufficient to
return error.LocationNotFound; // avoid blocking, and `lookup` closes the queue when it finishes.
}; const host_name = std.Io.net.HostName.init(domain) catch return error.LocationNotFound;
defer addr_list.deinit();
if (addr_list.addrs.len == 0) { var results: [16]std.Io.net.HostName.LookupResult = undefined;
return error.LocationNotFound; var queue: std.Io.Queue(std.Io.net.HostName.LookupResult) = .init(&results);
} host_name.lookup(self.io, &queue, .{ .port = 0 }) catch return error.LocationNotFound;
// Format IP address using std.net.Address.format // Take the first address; `canonical_name` entries are not addresses.
const addr = addr_list.addrs[0]; while (queue.getOne(self.io)) |result| {
var buf: [64]u8 = undefined; switch (result) {
const ip_str = try std.fmt.bufPrint(&buf, "{f}", .{addr}); .address => |addr| {
var buf: [64]u8 = undefined;
const ip_str = try std.fmt.bufPrint(&buf, "{f}", .{addr});
return self.resolveIP(ip_str);
},
.canonical_name => continue,
}
} else |_| {}
return self.resolveIP(ip_str); return error.LocationNotFound;
} }
fn resolveGeocoded(self: *Resolver, name: []const u8) !Location { fn resolveGeocoded(self: *Resolver, name: []const u8) !Location {
@ -164,7 +173,7 @@ pub const Resolver = struct {
); );
defer self.allocator.free(url); defer self.allocator.free(url);
var client = std.http.Client{ .allocator = self.allocator }; var client = std.http.Client{ .allocator = self.allocator, .io = self.io };
defer client.deinit(); defer client.deinit();
const uri = try std.Uri.parse(url); const uri = try std.Uri.parse(url);
@ -285,9 +294,9 @@ test "detect location type" {
test "resolver init" { test "resolver init" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
var geocache = try GeoCache.init(allocator, null); var geocache = try GeoCache.init(allocator, std.testing.io, null);
defer geocache.deinit(); defer geocache.deinit();
const resolver = Resolver.init(allocator, null, &geocache, null); const resolver = Resolver.init(allocator, std.testing.io, null, &geocache, null);
try std.testing.expect(resolver.geoip == null); try std.testing.expect(resolver.geoip == null);
try std.testing.expect(resolver.airports == null); try std.testing.expect(resolver.airports == null);
} }
@ -295,23 +304,23 @@ test "resolver init" {
test "resolve IP address with GeoIP" { test "resolve IP address with GeoIP" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const Config = @import("../Config.zig"); const Config = @import("../Config.zig");
const config = try Config.load(allocator); const config = try Config.loadForTest(allocator);
defer config.deinit(allocator); defer config.deinit(allocator);
const build_options = @import("build_options"); const build_options = @import("build_options");
if (build_options.download_geoip) { if (build_options.download_geoip) {
const GeoLite2 = @import("GeoLite2.zig"); const GeoLite2 = @import("GeoLite2.zig");
try GeoLite2.ensureDatabase(allocator, config.geolite_path); try GeoLite2.ensureDatabase(allocator, std.testing.io, config.geolite_path);
} }
var geoip = GeoIp.init(allocator, config.geolite_path, config) catch var geoip = GeoIp.init(allocator, std.testing.io, config.geolite_path, config) catch
return error.SkipZigTest; return error.SkipZigTest;
defer geoip.deinit(); defer geoip.deinit();
var geocache = try GeoCache.init(allocator, null); var geocache = try GeoCache.init(allocator, std.testing.io, null);
defer geocache.deinit(); defer geocache.deinit();
var resolver = Resolver.init(allocator, &geoip, &geocache, null); var resolver = Resolver.init(allocator, std.testing.io, &geoip, &geocache, null);
// Use IP that's known to have coordinates in GeoLite2 database // Use IP that's known to have coordinates in GeoLite2 database
const test_ip = "73.158.64.1"; const test_ip = "73.158.64.1";

View file

@ -11,12 +11,13 @@ const Resolver = @import("location/resolver.zig").Resolver;
const GeoLite2 = @import("location/GeoLite2.zig"); const GeoLite2 = @import("location/GeoLite2.zig");
const version = @import("build_options").version; const version = @import("build_options").version;
pub fn main() !u8 { /// Zig 0.16 entry point: the runtime supplies allocators, the `Io`
var gpa = std.heap.GeneralPurposeAllocator(.{}){}; /// implementation, and the environment map rather than us constructing them.
defer _ = gpa.deinit(); pub fn main(init: std.process.Init) !u8 {
const allocator = gpa.allocator(); const allocator = init.gpa;
const io = init.io;
const cfg = try Config.load(allocator); const cfg = try Config.load(allocator, init.environ_map);
defer cfg.deinit(allocator); defer cfg.deinit(allocator);
std.log.info("wttr version {s} starting on {s}:{d}", .{ version, cfg.listen_host, cfg.listen_port }); std.log.info("wttr version {s} starting on {s}:{d}", .{ version, cfg.listen_host, cfg.listen_port });
@ -29,18 +30,19 @@ pub fn main() !u8 {
std.log.info("Geocache: in-memory only", .{}); std.log.info("Geocache: in-memory only", .{});
} }
var metno = MetNo.init(allocator, null) catch |err| { var metno = MetNo.init(allocator, io, init.environ_map, null) catch |err| {
if (err == MetNo.MissingIdentificationError) return 1; if (err == MetNo.MissingIdentificationError) return 1;
return err; return err;
}; };
defer metno.deinit(); defer metno.deinit();
// Ensure GeoLite2 database exists // Ensure GeoLite2 database exists
try GeoLite2.ensureDatabase(allocator, cfg.geolite_path); try GeoLite2.ensureDatabase(allocator, io, cfg.geolite_path);
// Initialize GeoIP database with configured fallback // Initialize GeoIP database with configured fallback
var geoip = GeoIp.init( var geoip = GeoIp.init(
allocator, allocator,
io,
cfg.geolite_path, cfg.geolite_path,
cfg, cfg,
) catch |err| { ) catch |err| {
@ -50,7 +52,7 @@ pub fn main() !u8 {
defer geoip.deinit(); defer geoip.deinit();
// Initialize geocoding cache // Initialize geocoding cache
var geocache = try GeoCache.init(allocator, cfg.geocache_file); var geocache = try GeoCache.init(allocator, io, cfg.geocache_file);
defer geocache.deinit(); defer geocache.deinit();
// Initialize airports database // Initialize airports database
@ -58,25 +60,26 @@ pub fn main() !u8 {
defer airports_db.deinit(); defer airports_db.deinit();
// Initialize location resolver // Initialize location resolver
var resolver = Resolver.init(allocator, &geoip, &geocache, &airports_db); var resolver = Resolver.init(allocator, io, &geoip, &geocache, &airports_db);
const cache = try Cache.init(allocator, .{ const cache = try Cache.init(allocator, io, .{
.max_entries = cfg.cache_size, .max_entries = cfg.cache_size,
.cache_dir = cfg.cache_dir, .cache_dir = cfg.cache_dir,
}); });
defer cache.deinit(); defer cache.deinit();
var rate_limiter = try RateLimiter.init(allocator, .{ var rate_limiter = try RateLimiter.init(allocator, io, .{
.capacity = 300, .capacity = 300,
.refill_rate = 5, .refill_rate = 5,
.refill_interval_ms = 200, .refill_interval_ms = 200,
}); });
defer rate_limiter.deinit(); defer rate_limiter.deinit();
var server = try Server.init(allocator, cfg.listen_host, cfg.listen_port, .{ var server = try Server.init(allocator, io, cfg.listen_host, cfg.listen_port, .{
.provider = metno.provider(cache), .provider = metno.provider(cache),
.resolver = &resolver, .resolver = &resolver,
.geoip = &geoip, .geoip = &geoip,
.io = io,
}, &rate_limiter); }, &rate_limiter);
// Only set up the server instance in debug mode // Only set up the server instance in debug mode

View file

@ -8,7 +8,9 @@ const Astronomical = @import("../Astronomical.zig");
const TimeZoneOffsets = @import("../location/timezone_offsets.zig"); const TimeZoneOffsets = @import("../location/timezone_offsets.zig");
const Coordinates = @import("../Coordinates.zig"); const Coordinates = @import("../Coordinates.zig");
pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []const u8, use_imperial: bool) !void { /// `now_unix_s` is supplied by the caller so this renderer performs no I/O;
/// Zig 0.16 requires an `Io` to read the clock.
pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []const u8, use_imperial: bool, now_unix_s: i64) !void {
var i: usize = 0; var i: usize = 0;
while (i < format.len) { while (i < format.len) {
if (format[i] == '%' and i + 1 < format.len) { if (format[i] == '%' and i + 1 < format.len) {
@ -54,12 +56,12 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []cons
try writer.print("{d:.2} {s}", .{ pressure, unit }); try writer.print("{d:.2} {s}", .{ pressure, unit });
}, },
'm' => { 'm' => {
const now = try nowAt(weather.coords); const now = try nowAt(weather.coords, now_unix_s);
const moon = Moon.getPhase(now); const moon = Moon.getPhase(now);
try writer.writeAll(moon.emoji()); try writer.writeAll(moon.emoji());
}, },
'M' => { 'M' => {
const now = try nowAt(weather.coords); const now = try nowAt(weather.coords, now_unix_s);
const moon = Moon.getPhase(now); const moon = Moon.getPhase(now);
try writer.print("{d}", .{moon.day()}); try writer.print("{d}", .{moon.day()});
}, },
@ -69,7 +71,7 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []cons
// to make sure the day is correct for this. Even a day off // to make sure the day is correct for this. Even a day off
// should actually be ok. Unix timestamp is always UTC, // should actually be ok. Unix timestamp is always UTC,
// so we convert to local // so we convert to local
const now = try nowAt(weather.coords); const now = try nowAt(weather.coords, now_unix_s);
const astro = Astronomical.init( const astro = Astronomical.init(
weather.coords.latitude, weather.coords.latitude,
weather.coords.longitude, weather.coords.longitude,
@ -99,11 +101,11 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []cons
} }
} }
fn nowAt(coords: Coordinates) !i64 { fn nowAt(coords: Coordinates, now_unix_s: i64) !i64 {
const now = if (@import("builtin").is_test) const now = if (@import("builtin").is_test)
(try zeit.Time.fromISO8601("2026-01-09")).instant() (try zeit.Time.fromISO8601("2026-01-09")).instant()
else else
try zeit.instant(.{}); zeit.instant(.{ .unix_timestamp = now_unix_s }, &zeit.utc);
const offset = TimeZoneOffsets.getTimezoneOffset(coords); const offset = TimeZoneOffsets.getTimezoneOffset(coords);
const new = if (offset >= 0) const new = if (offset >= 0)
try now.add(.{ .minutes = @abs(offset) }) try now.add(.{ .minutes = @abs(offset) })
@ -133,6 +135,9 @@ const test_weather = types.WeatherData{
.forecast = &.{}, .forecast = &.{},
}; };
/// Fixed timestamp (2026-01-09T00:00:00Z) so rendered output is deterministic.
const test_now_unix_s: i64 = 1767916800;
test "render custom format with location and temp" { test "render custom format with location and temp" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
@ -158,7 +163,7 @@ test "render custom format with location and temp" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather, "%l: %c %t", false); try render(&writer, weather, "%l: %c %t", false, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
@ -191,7 +196,7 @@ test "render custom format with newline" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather, "%l%n%C", false); try render(&writer, weather, "%l%n%C", false, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
@ -223,7 +228,7 @@ test "render custom format with humidity and pressure" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather, "Humidity: %h, Pressure: %P", false); try render(&writer, weather, "Humidity: %h, Pressure: %P", false, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
@ -256,7 +261,7 @@ test "render custom format with imperial units" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather, "%t %w %p", true); try render(&writer, weather, "%t %w %p", true, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
@ -269,7 +274,7 @@ test "render custom format with feels like temp" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather, "%f", false); try render(&writer, test_weather, "%f", false, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("+10.0°C", output); try std.testing.expectEqualStrings("+10.0°C", output);
@ -279,7 +284,7 @@ test "render custom format with moon phase" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather, "%m", false); try render(&writer, test_weather, "%m", false, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("🌗", output); try std.testing.expectEqualStrings("🌗", output);
@ -289,7 +294,7 @@ test "render custom format with moon day" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather, "%M", false); try render(&writer, test_weather, "%M", false, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("21", output); try std.testing.expectEqualStrings("21", output);
@ -302,7 +307,7 @@ test "render custom format with astronomical dawn" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather_astro, "%D", false); try render(&writer, test_weather_astro, "%D", false, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("07:12", output); try std.testing.expectEqualStrings("07:12", output);
@ -315,7 +320,7 @@ test "render custom format with astronomical sunrise" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather_astro, "%S", false); try render(&writer, test_weather_astro, "%S", false, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("07:45", output); try std.testing.expectEqualStrings("07:45", output);
@ -328,7 +333,7 @@ test "render custom format with astronomical zenith" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather_astro, "%z", false); try render(&writer, test_weather_astro, "%z", false, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("12:14", output); try std.testing.expectEqualStrings("12:14", output);
@ -341,7 +346,7 @@ test "render custom format with astronomical sunset" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather_astro, "%s", false); try render(&writer, test_weather_astro, "%s", false, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("16:44", output); try std.testing.expectEqualStrings("16:44", output);
@ -354,7 +359,7 @@ test "render custom format with astronomical dusk" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather_astro, "%d", false); try render(&writer, test_weather_astro, "%d", false, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("17:17", output); try std.testing.expectEqualStrings("17:17", output);
@ -364,7 +369,7 @@ test "render custom format with percent sign" {
var output_buf: [1024]u8 = undefined; var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather, "%%", false); try render(&writer, test_weather, "%%", false, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("%", output); try std.testing.expectEqualStrings("%", output);

View file

@ -163,7 +163,7 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re
const vis_unit = if (options.use_imperial) "mi" else "km"; const vis_unit = if (options.use_imperial) "mi" else "km";
try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit }); try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit });
} else { } else {
try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")}); try w.print("{s}\n", .{std.mem.trimEnd(u8, art[3], " ")});
} }
try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit }); try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit });
}, },
@ -180,7 +180,7 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re
const vis_unit = if (options.use_imperial) "mi" else "km"; const vis_unit = if (options.use_imperial) "mi" else "km";
try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit }); try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit });
} else { } else {
try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")}); try w.print("{s}\n", .{std.mem.trimEnd(u8, art[3], " ")});
} }
try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit }); try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit });
}, },
@ -196,7 +196,7 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re
const vis_unit = if (options.use_imperial) "mi" else "km"; const vis_unit = if (options.use_imperial) "mi" else "km";
try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit }); try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit });
} else { } else {
try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")}); try w.print("{s}\n", .{std.mem.trimEnd(u8, art[3], " ")});
} }
try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit }); try w.print("{s} {d:.1} {s}\n", .{ art[4], precip, precip_unit });
}, },
@ -217,24 +217,28 @@ fn renderForecastDay(w: *std.Io.Writer, day: types.ForecastDay, options: RenderO
// Format date using gofmt: "Mon 2 Jan" (compressed) // Format date using gofmt: "Mon 2 Jan" (compressed)
const date_time = zeit.Time{ .year = day.date.year, .month = day.date.month, .day = day.date.day }; const date_time = zeit.Time{ .year = day.date.year, .month = day.date.month, .day = day.date.day };
var date_stream = std.io.fixedBufferStream(&date_str); // 0.16 removed fixedBufferStream; `Io.Writer.fixed` is the replacement
try date_time.gofmt(date_stream.writer(), "Mon 2 Jan"); // and tracks the written length in `end`.
const date_len = date_stream.pos; var date_stream = std.Io.Writer.fixed(&date_str);
try date_time.gofmt(&date_stream, "Mon 2 Jan");
const date_len = date_stream.end;
try w.print("\n{s}\n", .{date_str[0..date_len]}); try w.print("\n{s}\n", .{date_str[0..date_len]});
try w.print("{s} {s}\n", .{ art[0], day.condition }); try w.print("{s} {s}\n", .{ art[0], day.condition });
try w.print("{s} {d:.0}{s} / {d:.0}{s}\n", .{ art[1], max_temp, temp_unit, min_temp, temp_unit }); try w.print("{s} {d:.0}{s} / {d:.0}{s}\n", .{ art[1], max_temp, temp_unit, min_temp, temp_unit });
try w.print("{s}\n", .{std.mem.trimRight(u8, art[2], " ")}); try w.print("{s}\n", .{std.mem.trimEnd(u8, art[2], " ")});
try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")}); try w.print("{s}\n", .{std.mem.trimEnd(u8, art[3], " ")});
try w.print("{s}\n", .{std.mem.trimRight(u8, art[4], " ")}); try w.print("{s}\n", .{std.mem.trimEnd(u8, art[4], " ")});
return; return;
} }
// Format date using gofmt: "Mon _2 Jan" (justified with space padding) // Format date using gofmt: "Mon _2 Jan" (justified with space padding)
const date_time = zeit.Time{ .year = day.date.year, .month = day.date.month, .day = day.date.day }; const date_time = zeit.Time{ .year = day.date.year, .month = day.date.month, .day = day.date.day };
var date_stream = std.io.fixedBufferStream(&date_str); // 0.16 removed fixedBufferStream; `Io.Writer.fixed` is the replacement
try date_time.gofmt(date_stream.writer(), "Mon _2 Jan"); // and tracks the written length in `end`.
const date_len = date_stream.pos; var date_stream = std.Io.Writer.fixed(&date_str);
try date_time.gofmt(&date_stream, "Mon _2 Jan");
const date_len = date_stream.end;
if (!options.narrow) { if (!options.narrow) {
try w.writeAll(" ┌─────────────┐\n"); try w.writeAll(" ┌─────────────┐\n");
@ -759,7 +763,7 @@ fn testArt(data: types.WeatherData) !void {
format, format,
); );
for (target, 1..) |line, i| { for (target, 1..) |line, i| {
const trimmed = std.mem.trimRight(u8, line, " "); const trimmed = std.mem.trimEnd(u8, line, " ");
std.testing.expect(std.mem.indexOf(u8, output, trimmed) != null) catch |e| { std.testing.expect(std.mem.indexOf(u8, output, trimmed) != null) catch |e| {
std.log.err( std.log.err(
"Test failure, weather code {}, format {}, line {d}. Line '{s}', Output:\n{s}\n", "Test failure, weather code {}, format {}, line {d}. Line '{s}', Output:\n{s}\n",
@ -964,7 +968,7 @@ test "temperature matches between ansi and custom format" {
var custom_buf: [1024]u8 = undefined; var custom_buf: [1024]u8 = undefined;
var custom_writer = std.Io.Writer.fixed(&custom_buf); var custom_writer = std.Io.Writer.fixed(&custom_buf);
try custom.render(&custom_writer, data, "%t", true); try custom.render(&custom_writer, data, "%t", true, 1767916800);
const output = custom_buf[0..custom_writer.end]; const output = custom_buf[0..custom_writer.end];

View file

@ -3,7 +3,10 @@ const types = @import("../weather/types.zig");
const Moon = @import("../Moon.zig"); const Moon = @import("../Moon.zig");
const utils = @import("utils.zig"); const utils = @import("utils.zig");
pub fn render(writer: *std.Io.Writer, weather: types.WeatherData) !void { /// `now_unix_s` is supplied by the caller rather than read from the clock here:
/// Zig 0.16 requires an `Io` to read time, and keeping renderers free of I/O
/// leaves them pure and directly testable.
pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, now_unix_s: i64) !void {
// Current conditions // Current conditions
try writer.print("# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius\n", .{}); try writer.print("# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius\n", .{});
@ -86,8 +89,7 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData) !void {
try writer.print("snowfall_cm{{forecast=\"{s}\"}} 0.0\n", .{forecast_label}); // Not in our data try writer.print("snowfall_cm{{forecast=\"{s}\"}} 0.0\n", .{forecast_label}); // Not in our data
// Moon phase - use current time for simplicity // Moon phase - use current time for simplicity
const timestamp = std.time.timestamp(); const moon = Moon.getPhase(now_unix_s);
const moon = Moon.getPhase(timestamp);
try writer.print("# HELP astronomy_moon_illumination Percentage of the moon illuminated\n", .{}); try writer.print("# HELP astronomy_moon_illumination Percentage of the moon illuminated\n", .{});
try writer.print("astronomy_moon_illumination{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, moon.illuminated * 100 }); try writer.print("astronomy_moon_illumination{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, moon.illuminated * 100 });
@ -109,6 +111,9 @@ pub fn render(writer: *std.Io.Writer, weather: types.WeatherData) !void {
} }
} }
/// Fixed timestamp (2026-01-09T00:00:00Z) so rendered output is deterministic.
const test_now_unix_s: i64 = 1767916800;
test "prometheus format includes required metrics" { test "prometheus format includes required metrics" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
@ -145,7 +150,7 @@ test "prometheus format includes required metrics" {
var output_buf: [8192]u8 = undefined; var output_buf: [8192]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather); try render(&writer, weather, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];
@ -182,7 +187,7 @@ test "prometheus format has proper help comments" {
var output_buf: [4096]u8 = undefined; var output_buf: [4096]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather); try render(&writer, weather, test_now_unix_s);
const output = output_buf[0..writer.end]; const output = output_buf[0..writer.end];

View file

@ -60,29 +60,41 @@ const weather_code_entries = [_]MetNoOpenWeatherEntry{
.{ "snowshowers", .snow_shower }, .{ "snowshowers", .snow_shower },
.{ "snowshowersandthunder", .thunderstorm }, .{ "snowshowersandthunder", .thunderstorm },
// zig fmt: on // zig fmt: on
}; };
const WeatherCodeMap = std.StaticStringMap(types.WeatherCode); const WeatherCodeMap = std.StaticStringMap(types.WeatherCode);
const weather_code_map = WeatherCodeMap.initComptime(weather_code_entries); const weather_code_map = WeatherCodeMap.initComptime(weather_code_entries);
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
/// Zig 0.16 requires an explicit `Io` for HTTP work.
io: std.Io,
identifying_email: []const u8, identifying_email: []const u8,
pub fn init(allocator: std.mem.Allocator, identifying_email: ?[]const u8) !MetNo { /// Zig 0.16 removed `std.process.getEnvVarOwned`; the environment map is now
const email = identifying_email orelse blk: { /// threaded in from `main`. `Map.get` returns a borrowed slice, so the address
const env_email = std.process.getEnvVarOwned(allocator, "METNO_TOS_IDENTIFYING_EMAIL") catch |err| { /// is duplicated unconditionally to keep `identifying_email` uniformly owned by
if (err == error.EnvironmentVariableNotFound) { /// this struct (previously an explicitly-passed address would have been freed
std.log.err("Met.no Terms of Service require identification. Set METNO_TOS_IDENTIFYING_EMAIL environment variable", .{}); /// out from under the caller by `deinit`).
std.log.err("See \x1b]8;;https://api.met.no/doc/TermsOfService\x1b\\https://api.met.no/doc/TermsOfService\x1b]8;;\x1b\\ for more information", .{}); pub fn init(
return MissingIdentificationError; allocator: std.mem.Allocator,
} io: std.Io,
return err; env: *const std.process.Environ.Map,
identifying_email: ?[]const u8,
) !MetNo {
const email = if (identifying_email) |provided|
try allocator.dupe(u8, provided)
else blk: {
const env_email = env.get("METNO_TOS_IDENTIFYING_EMAIL") orelse {
std.log.err("Met.no Terms of Service require identification. Set METNO_TOS_IDENTIFYING_EMAIL environment variable", .{});
std.log.err("See \x1b]8;;https://api.met.no/doc/TermsOfService\x1b\\https://api.met.no/doc/TermsOfService\x1b]8;;\x1b\\ for more information", .{});
return MissingIdentificationError;
}; };
break :blk env_email; break :blk try allocator.dupe(u8, env_email);
}; };
return MetNo{ return MetNo{
.allocator = allocator, .allocator = allocator,
.io = io,
.identifying_email = email, .identifying_email = email,
}; };
} }
@ -110,7 +122,7 @@ fn fetchRaw(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates)
defer self.allocator.free(url); defer self.allocator.free(url);
// Fetch weather data from met.no API // Fetch weather data from met.no API
var client = std.http.Client{ .allocator = self.allocator }; var client = std.http.Client{ .allocator = self.allocator, .io = self.io };
defer client.deinit(); defer client.deinit();
const uri = try std.Uri.parse(url); const uri = try std.Uri.parse(url);
@ -297,28 +309,28 @@ fn parseForecastDays(allocator: std.mem.Allocator, timeseries: []std.json.Value,
// Save previous day if exists // Save previous day if exists
if (current_date) |prev_date| { if (current_date) |prev_date| {
if (day_temps.items.len > 0) { if (day_temps.items.len > 0) {
var max_temp: f32 = day_temps.items[0]; var max_temp: f32 = day_temps.items[0];
var min_temp: f32 = day_temps.items[0]; var min_temp: f32 = day_temps.items[0];
for (day_temps.items) |t| { for (day_temps.items) |t| {
if (t > max_temp) max_temp = t; if (t > max_temp) max_temp = t;
if (t < min_temp) min_temp = t; if (t < min_temp) min_temp = t;
} }
const symbol = day_symbol orelse "clearsky_day"; const symbol = day_symbol orelse "clearsky_day";
// Return all hourly forecasts - let the renderer decide which to display // Return all hourly forecasts - let the renderer decide which to display
const hourly_slice = try day_all_hours.toOwnedSlice(allocator); const hourly_slice = try day_all_hours.toOwnedSlice(allocator);
try days.append(allocator, .{ try days.append(allocator, .{
.date = prev_date, .date = prev_date,
.max_temp_c = max_temp, .max_temp_c = max_temp,
.min_temp_c = min_temp, .min_temp_c = min_temp,
.condition = try allocator.dupe(u8, symbolCodeToCondition(symbol)), .condition = try allocator.dupe(u8, symbolCodeToCondition(symbol)),
.weather_code = symbolCodeToWeatherCode(symbol), .weather_code = symbolCodeToWeatherCode(symbol),
.hourly = hourly_slice, .hourly = hourly_slice,
}); });
if (days.items.len >= 3) break; if (days.items.len >= 3) break;
} }
} }

View file

@ -34,8 +34,12 @@ pub fn fetch(self: WeatherProvider, allocator: std.mem.Allocator, coords: Coordi
const raw = try self.vtable.fetchRaw(self.ptr, allocator, coords); const raw = try self.vtable.fetchRaw(self.ptr, allocator, coords);
defer allocator.free(raw); defer allocator.free(raw);
// TTL: 1000-2000 seconds (16-33 minutes) to avoid thundering herd // TTL: 1000-2000 seconds (16-33 minutes) to avoid thundering herd.
const ttl = 1000 + std.crypto.random.intRangeAtMost(u64, 0, 1000); // Zig 0.16 sources randomness from `Io`; `std.crypto.random` is gone. The
// modulo bias here is irrelevant for TTL jitter.
var jitter_bytes: [8]u8 = undefined;
std.Io.random(self.cache.io, &jitter_bytes);
const ttl = 1000 + std.mem.readInt(u64, &jitter_bytes, .little) % 1001;
try self.cache.put(cache_key, raw, ttl); try self.cache.put(cache_key, raw, ttl);
// Parse and return // Parse and return
@ -50,7 +54,7 @@ test "provider fetch" {
const Mock = @import("Mock.zig"); const Mock = @import("Mock.zig");
const MetNo = @import("MetNo.zig"); const MetNo = @import("MetNo.zig");
const cache = try Cache.init(std.testing.allocator, .{ .max_entries = 10, .cache_dir = null }); const cache = try Cache.init(std.testing.allocator, std.testing.io, .{ .max_entries = 10, .cache_dir = null });
defer cache.deinit(); defer cache.deinit();
var mock = try Mock.init(std.testing.allocator); var mock = try Mock.init(std.testing.allocator);