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-out/
coverage/
# Zig 0.16 stores fetched packages in-tree rather than the global cache
zig-pkg/

View file

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

View file

@ -55,16 +55,15 @@ pub fn build(b: *std.Build) void {
}),
});
sunriset.addIncludePath(b.path("libs/sunriset"));
sunriset.addCSourceFiles(.{
sunriset.root_module.addIncludePath(b.path("libs/sunriset"));
sunriset.root_module.addCSourceFiles(.{
.root = b.path("libs/sunriset"),
.files = &.{
"sunriset.c",
},
.flags = &.{ "-D_DEFAULT_SOURCE", "-DSUNRISET_NO_MAIN" },
});
sunriset.linkLibC();
sunriset.linkSystemLibrary("m");
sunriset.root_module.linkSystemLibrary("m", .{});
// Build phoon as a static library
const phoon = b.addLibrary(.{
@ -77,8 +76,8 @@ pub fn build(b: *std.Build) void {
}),
});
phoon.addIncludePath(b.path("libs/phoon_14Aug2014"));
phoon.addCSourceFiles(.{
phoon.root_module.addIncludePath(b.path("libs/phoon_14Aug2014"));
phoon.root_module.addCSourceFiles(.{
.root = b.path("libs/phoon_14Aug2014"),
.files = &.{
"astro.c",
@ -86,8 +85,7 @@ pub fn build(b: *std.Build) void {
},
.flags = &.{ "-std=c99", "-D_DEFAULT_SOURCE" },
});
phoon.linkLibC();
phoon.linkSystemLibrary("m");
phoon.root_module.linkSystemLibrary("m", .{});
// Build libmaxminddb as a static library
const maxminddb = b.addLibrary(.{
@ -106,14 +104,26 @@ pub fn build(b: *std.Build) void {
.include_path = "maxminddb_config.h",
}, .{
.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.addIncludePath(maxminddb_upstream.path("include"));
maxminddb.addIncludePath(maxminddb_upstream.path("src"));
maxminddb.root_module.addConfigHeader(maxminddb_config);
maxminddb.root_module.addIncludePath(maxminddb_upstream.path("include"));
maxminddb.root_module.addIncludePath(maxminddb_upstream.path("src"));
maxminddb.addCSourceFiles(.{
maxminddb.root_module.addCSourceFiles(.{
.root = maxminddb_upstream.path(""),
.files = &.{
"src/data-pool.c",
@ -128,6 +138,8 @@ pub fn build(b: *std.Build) void {
.root_source_file = b.path("src/main.zig"),
.target = target,
.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("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 {
for (libs) |lib| compile.linkLibrary(lib);
compile.linkLibC();
// Zig 0.16: linkLibrary moved to Module, and libc linkage is a module
// 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",
.dependencies = .{
.httpz = .{
.url = "git+https://github.com/karlseguin/http.zig/#1c0ec3751c53e276d5e0c42b6d5481e72d9d1971",
.hash = "httpz-0.0.0-PNVzrEktBwCzPoiua-S8LAYo2tILqczm3tSpneEzLQ9L",
.url = "git+https://github.com/karlseguin/http.zig?ref=master#c22672f820280ccd17c77d3894e431c49b46061d",
.hash = "httpz-0.0.0-PNVzrMPnCAAWJqhMEZR8gDZeg1CQObCEMTEf8zDh-u-j",
},
.maxminddb = .{
.url = "https://github.com/maxmind/libmaxminddb/archive/refs/tags/1.11.0.tar.gz",
@ -15,14 +15,14 @@
.hash = "N-V-__8AAL8tFgMfL4Y2FQTRciAyueOiE5K5PPV3gI3eanes",
},
.zeit = .{
.url = "git+https://github.com/rockorager/zeit?ref=zig-0.15#7ac64d72dbfb1a4ad549102e7d4e232a687d32d8",
.hash = "zeit-0.6.0-5I6bk36tAgATpSl9wjFmRPMqYN2Mn0JQHgIcRNcqDpJA",
.url = "git+https://github.com/rockorager/zeit?ref=v0.9.0#b1c1c2fcbc71fd7799a316bbcf0ff88d06d80ccc",
.hash = "zeit-0.9.0-5I6bk2m9AgBSMH8-L6rYJkwuQAyhXplnfxnvTSGzVHUR",
},
.phoon = .{ .path = "libs/phoon_14Aug2014" },
.sunriset = .{ .path = "libs/sunriset" },
.ghostty = .{
.url = "git+https://github.com/ghostty-org/ghostty#ec2912dbafe50cc32b786d2327dcd0213c83ecc6",
.hash = "ghostty-1.3.0-dev-5UdBC_y2RASwYWn5fjn71WsP-arlg8wSICLc0rYiozdf",
.url = "git+https://github.com/ghostty-org/ghostty#ccb08f35f683d6087786dda8e793e911ef1a2f8a",
.hash = "ghostty-1.3.2-dev-5UdBC9KHOwUAvF5QKNA6pbo-dV0WE-F9l5NtaT0mhpmu",
},
.zigimg = .{
.url = "git+https://github.com/zigimg/zigimg#9714df09f76891323c7fdbbbf23a17b79024fffb",
@ -35,10 +35,11 @@
.nerd_fonts_symbols_only = .{
.url = "https://deps.files.ghostty.org/NerdFontsSymbolsOnly-3.4.0.tar.gz",
.hash = "N-V-__8AAMVLTABmYkLqhZPLXnMl-KyN38R8UVYqGrxqO26s",
.lazy = true,
},
},
.fingerprint = 0x710c2b57e81aa678,
.minimum_zig_version = "0.15.2",
.minimum_zig_version = "0.16.0",
.paths = .{
"build.zig",
"build.zig.zon",

View file

@ -148,11 +148,9 @@ fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void {
_ = options;
const check: *Coverage = @fieldParentPtr("step", step);
const allocator = step.owner.allocator;
const io = step.owner.graph.io;
const file = try std.fs.cwd().openFile(check.json_path, .{});
defer file.close();
const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
const content = try std.Io.Dir.cwd().readFileAlloc(io, check.json_path, allocator, .limited(10 * 1024 * 1024));
defer allocator.free(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;
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;
if (step.owner.verbose or check.verbose) {
const files = coverage.files;

View file

@ -11,16 +11,19 @@ pub const Options = struct {
/// Get git version information by reading .git files directly
pub fn getVersion(b: *Build, options: Options) []const u8 {
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
const build_root = findBuildRoot(allocator) catch return "unknown";
const build_root = findBuildRoot(allocator, io) catch return "unknown";
defer allocator.free(build_root);
// Read .git/HEAD relative to build root
const head_path = std.fmt.allocPrint(allocator, "{s}/.git/HEAD", .{build_root}) catch return "unknown";
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";
};
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
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";
defer allocator.free(ref_file);
const ref_fd = std.fs.openFileAbsolute(ref_file, .{}) catch return "unknown";
defer ref_fd.close();
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_data = std.Io.Dir.cwd().readFileAlloc(io, ref_file, allocator, .limited(1024)) catch
return "unknown";
defer allocator.free(ref_data);
const ref_trimmed = std.mem.trim(u8, ref_data, &std.ascii.whitespace);
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:
// 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) {
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";
}
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;
const start_cwd = try std.fs.cwd().realpath(".", &buf);
var cwd: []const u8 = start_cwd;
// `realPath` now returns the length written rather than a slice.
const start_len = try std.Io.Dir.cwd().realPath(io, &buf);
var cwd: []const u8 = buf[0..start_len];
while (true) {
// Check if build.zig exists in current directory
var dir = std.fs.openDirAbsolute(cwd, .{}) catch break;
defer dir.close();
var dir = std.Io.Dir.openDirAbsolute(io, cwd, .{}) catch break;
defer dir.close(io);
dir.access("build.zig", .{}) catch {
dir.access(io, "build.zig", .{}) catch {
// build.zig not found, try parent
const parent = std.fs.path.dirname(cwd) orelse break;
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;
}
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
const index_path = try std.fs.path.join(allocator, &[_][]const u8{ build_root, ".git", "index" });
defer allocator.free(index_path);
const index_stat = std.fs.cwd().statFile(index_path) catch return error.CannotDetermineDirty;
const index_mtime = index_stat.mtime;
const index_stat = std.Io.Dir.cwd().statFile(io, index_path, .{}) catch return error.CannotDetermineDirty;
// 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
const ignore_path = try std.fs.path.join(allocator, &[_][]const u8{ build_root, ".gitignore" });
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, "");
defer allocator.free(ignore_data);
// 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;
defer dir.close();
var dir = std.Io.Dir.openDirAbsolute(io, build_root, .{ .iterate = true }) catch return error.CannotDetermineDirty;
defer dir.close(io);
var walker = dir.walk(allocator) catch return error.CannotDetermineDirty;
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;
// Always ignore .git/
@ -129,8 +132,8 @@ fn isDirty(allocator: std.mem.Allocator, build_root: []const u8) !bool {
}
if (ignored) continue;
const stat = entry.dir.statFile(entry.basename) catch continue;
if (stat.mtime > index_mtime) {
const stat = entry.dir.statFile(io, entry.basename, .{}) catch continue;
if (stat.mtime.nanoseconds > index_mtime) {
return true;
}
}

View file

@ -1,11 +1,11 @@
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
pub fn main(init: std.process.Init) !void {
const allocator = init.gpa;
const io = init.io;
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
const args = try init.minimal.args.toSlice(allocator);
defer allocator.free(args);
if (args.len != 3) return error.InvalidArgs;
@ -13,20 +13,20 @@ pub fn main() !void {
const arch_name = args[2];
// 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;
};
// This might be better checking whether it's executable and >= 7MB, but
// for now, we'll do a simple exists check
if (stat != null) return;
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;
try stdout.writeAll("Determining latest kcov version\n");
try stdout.flush();
var client = std.http.Client{ .allocator = allocator };
var client = std.http.Client{ .allocator = allocator, .io = io };
defer client.deinit();
// Get redirect to find latest version
@ -58,17 +58,20 @@ pub fn main() !void {
defer allocator.free(binary_url);
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 => {},
else => return e,
};
const uri = try std.Uri.parse(binary_url);
const file = try std.fs.cwd().createFile(kcov_path, .{ .mode = 0o755 });
defer file.close();
const file = try std.Io.Dir.cwd().createFile(io, kcov_path, .{});
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 writer = file.writer(&buffer);
var writer = file.writer(io, &buffer);
const result = try client.fetch(.{
.location = .{ .uri = uri },
.response_writer = &writer.interface,

View file

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

View file

@ -34,10 +34,11 @@ ip2location_cache_file: []const u8,
/// Cache file for ipwho.is lookups
ipwhois_cache_file: []const u8,
pub fn load(allocator: std.mem.Allocator) !Config {
var env = try std.process.getEnvMap(allocator);
defer env.deinit();
/// Loads configuration from the process environment.
///
/// 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
const home = env.get("HOME") orelse "/tmp";
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 {
allocator.free(self.listen_host);
allocator.free(self.cache_dir);
@ -101,7 +113,7 @@ pub fn deinit(self: Config, allocator: std.mem.Allocator) void {
test "config loads defaults" {
const allocator = std.testing.allocator;
const cfg = try Config.load(allocator);
const cfg = try Config.loadForTest(allocator);
defer cfg.deinit(allocator);
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);
allocator: std.mem.Allocator,
/// Zig 0.16 requires an explicit `Io` for filesystem access.
io: std.Io,
lru: Lru,
/// Cache directory for L2 persistent cache
cache_dir: ?[]const u8,
@ -15,9 +17,9 @@ pub const Config = struct {
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|
std.fs.makeDirAbsolute(d) catch |err| {
std.Io.Dir.cwd().createDirPath(io, d) catch |err| {
if (err != error.PathAlreadyExists) return err;
};
@ -26,7 +28,8 @@ pub fn init(allocator: std.mem.Allocator, config: Config) !*Cache {
cache.* = Cache{
.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,
};
@ -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 {
const now = std.time.milliTimestamp();
const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
const expires = now + @as(i64, @intCast(ttl_seconds * 1000));
// 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 data has expired, the file will be deleted
fn loadFromFilePath(self: *Cache, file_path: []const u8) !CacheEntry {
const file = try std.fs.cwd().openFile(file_path, .{});
defer file.close();
const file = try std.Io.Dir.cwd().openFile(self.io, file_path, .{});
defer file.close(self.io);
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 cached = try deserialize(self.allocator, reader);
errdefer cached.deinit(self.allocator);
// Check if expired
const now = std.time.milliTimestamp();
const now = std.Io.Timestamp.now(self.io, .real).toMilliseconds();
if (cached.expires <= now) {
// We're expired, delete expired file
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 });
defer self.allocator.free(file_path);
const file = try std.fs.cwd().createFile(file_path, .{});
defer file.close();
const file = try std.Io.Dir.cwd().createFile(self.io, file_path, .{});
defer file.close(self.io);
var buffer: [4096]u8 = undefined;
var file_writer = file.writer(&buffer);
var file_writer = file.writer(self.io, &buffer);
const writer = &file_writer.interface;
try serialize(writer, key, value, expires);
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 {
if (self.cache_dir == null) return error.NoCacheDir;
var dir = try std.fs.cwd().openDir(self.cache_dir.?, .{ .iterate = true });
defer dir.close();
var dir = try std.Io.Dir.cwd().openDir(self.io, self.cache_dir.?, .{ .iterate = true });
defer dir.close(self.io);
var it = dir.iterate();
while (try it.next()) |entry| {
while (try it.next(self.io)) |entry| {
if (entry.kind != .file) continue;
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");
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 });
};
}
@ -264,9 +267,11 @@ test "L1/L2 cache flow" {
defer tmp_dir.cleanup();
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();
// Put item in cache

15
src/cache/Lru.zig vendored
View file

@ -3,6 +3,8 @@ const std = @import("std");
const Lru = @This();
allocator: std.mem.Allocator,
/// Zig 0.16 reads the clock through `Io` rather than `std.time`.
io: std.Io,
map: std.StringHashMap(Entry),
max_entries: usize,
evict_fn: ?*const fn (ctx: *anyopaque, key: []const u8) void = null,
@ -14,9 +16,10 @@ const Entry = struct {
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 .{
.allocator = allocator,
.io = io,
.map = std.StringHashMap(Entry).init(allocator),
.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 {
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) {
self.remove(key);
return null;
@ -120,7 +123,7 @@ pub fn iterator(self: *Lru) Iterator {
}
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();
try lru.put("key1", "value1", 9999999999999);
@ -128,7 +131,7 @@ test "LRU basic operations" {
}
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();
try lru.put("key1", "value1", 9999999999999);
@ -139,11 +142,11 @@ test "LRU eviction" {
}
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();
// 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;
try lru.put("key1", "value1", past_expires);

View file

@ -5,7 +5,11 @@ const RateLimiter = @This();
allocator: std.mem.Allocator,
buckets: std.StringHashMap(TokenBucket),
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 {
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{
.allocator = allocator,
.buckets = std.StringHashMap(TokenBucket).init(allocator),
.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.
/// Returns true if the request should be accepted, false if rate limited.
pub fn shouldAcceptRequest(self: *RateLimiter, ip: []const u8) bool {
self.mutex.lock();
defer self.mutex.unlock();
// A canceled lock acquisition is treated as "reject": failing closed is the
// 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;
if (!result.found_existing) {
@ -83,7 +90,7 @@ pub fn deinit(self: *RateLimiter) void {
}
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,
.refill_rate = 1,
.refill_interval_ms = 1000,
@ -97,7 +104,7 @@ test "rate limiter allows requests within capacity" {
}
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,
.refill_rate = 1,
.refill_interval_ms = 1000,
@ -113,7 +120,7 @@ test "rate limiter blocks after capacity exhausted" {
}
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,
.refill_rate = 5,
.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"));
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"));
}
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,
.refill_rate = 1,
.refill_interval_ms = 1000,

View file

@ -39,6 +39,7 @@ pub const Context = struct {
pub fn init(
allocator: std.mem.Allocator,
io: std.Io,
host: []const u8,
port: u16,
options: handler.HandleWeatherOptions,
@ -51,9 +52,10 @@ pub fn init(
.rate_limiter = rate_limiter,
};
var httpz_server = try httpz.Server(*Context).init(allocator, .{
.address = host,
.port = port,
// httpz takes the `Io` first under Zig 0.16, and its listen address is now
// a parsed `Io.net.IpAddress` union rather than a host string plus port.
var httpz_server = try httpz.Server(*Context).init(io, allocator, .{
.address = .{ .ip = try std.Io.net.IpAddress.parse(host, port) },
}, ctx);
// 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 {
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();
}
@ -133,28 +141,28 @@ pub const MockHarness = struct {
const Cache = @import("../cache/Cache.zig");
pub fn init(allocator: std.mem.Allocator) !MockHarness {
const config = try Config.load(allocator);
const config = try Config.loadForTest(allocator);
errdefer config.deinit(allocator);
if (build_options.download_geoip) {
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);
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;
errdefer geoip.deinit();
var geocache = try allocator.create(GeoCache);
errdefer allocator.destroy(geocache);
geocache.* = try GeoCache.init(allocator, null);
geocache.* = try GeoCache.init(allocator, std.testing.io, null);
errdefer geocache.deinit();
const resolver = try allocator.create(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);
errdefer allocator.destroy(mock);
@ -190,7 +198,7 @@ pub const MockHarness = struct {
// Add wildcard response for tests
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,
.cache_dir = config.cache_dir,
});
@ -208,6 +216,7 @@ pub const MockHarness = struct {
.provider = mock.provider(cache),
.resolver = resolver,
.geoip = geoip,
.io = std.testing.io,
},
};
}
@ -280,7 +289,7 @@ test "handleWeather: client IP only" {
defer ht.deinit();
// 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("/");

View file

@ -20,6 +20,8 @@ pub const HandleWeatherOptions = struct {
provider: WeatherProvider,
resolver: *Resolver,
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
@ -97,6 +99,9 @@ fn handleWeatherInternal(
client_ip: []const u8,
) !void {
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
const is_png = if (comptime build_options.enable_png)
@ -183,7 +188,7 @@ fn handleWeatherInternal(
const png_writer = &png_writer_impl;
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];
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;
}
try renderWeatherData(res.writer(), weather, params, render_options);
try renderWeatherData(res.writer(), weather, params, render_options, now_unix_s);
}
fn renderWeatherData(
@ -216,6 +221,9 @@ fn renderWeatherData(
weather: types.WeatherData,
params: QueryParams,
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 {
if (params.format) |fmt| {
if (std.mem.eql(u8, fmt, "1")) {
@ -229,11 +237,11 @@ fn renderWeatherData(
} else if (std.mem.eql(u8, fmt, "j1")) {
try Json.render(writer, weather);
} 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")) {
try V2.render(writer, weather, render_options.use_imperial);
} 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 {
try Formatted.render(writer, weather, render_options);

View file

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

View file

@ -11,6 +11,24 @@ const c = @cImport({
const GeoIP = @This();
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) {
ip2location: *Ip2location,
ipwhois: *IpWhoIs,
@ -40,7 +58,7 @@ mmdb: *c.MMDB_s,
fallback_client: FallbackClient,
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);
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: {
const client = try allocator.create(Ip2location);
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(
"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 },
@ -65,7 +83,7 @@ pub fn init(allocator: std.mem.Allocator, db_path: []const u8, config: Config) !
.ipwhois => blk: {
const client = try allocator.create(IpWhoIs);
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});
break :blk .{ .ipwhois = client };
},
@ -229,25 +247,25 @@ test "MMDB functions are callable" {
}
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);
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);
}
test "isUSIp detects US IPs" {
const allocator = std.testing.allocator;
const config = try Config.load(allocator);
const config = try Config.loadForTest(allocator);
defer config.deinit(allocator);
const build_options = @import("build_options");
const db_path = config.geolite_path;
if (build_options.download_geoip) {
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;
defer geoip.deinit();
@ -260,17 +278,17 @@ test "isUSIp detects US IPs" {
}
test "lookup works" {
const allocator = std.testing.allocator;
const config = try Config.load(allocator);
const config = try Config.loadForTest(allocator);
defer config.deinit(allocator);
const build_options = @import("build_options");
const db_path = config.geolite_path;
if (build_options.download_geoip) {
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;
defer geoip.deinit();

View file

@ -1,20 +1,20 @@
const std = @import("std");
const log = std.log.scoped(.geolite2);
pub fn ensureDatabase(allocator: std.mem.Allocator, path: []const u8) !void {
std.fs.cwd().access(path, .{}) catch {
pub fn ensureDatabase(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !void {
std.Io.Dir.cwd().access(io, path, .{}) catch {
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", .{});
return;
};
}
fn downloadDatabase(allocator: std.mem.Allocator, path: []const u8) !void {
const latest_url = try getLatestReleaseUrl(allocator);
fn downloadDatabase(allocator: std.mem.Allocator, io: std.Io, path: []const u8) !void {
const latest_url = try getLatestReleaseUrl(allocator, io);
defer allocator.free(latest_url);
var client: std.http.Client = .{ .allocator = allocator };
var client: std.http.Client = .{ .allocator = allocator, .io = io };
defer client.deinit();
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
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, .{});
defer file.close();
try file.writeAll(response_buf[0..writer.end]);
try std.Io.Dir.cwd().writeFile(io, .{
.sub_path = path,
.data = response_buf[0..writer.end],
});
}
fn getLatestReleaseUrl(allocator: std.mem.Allocator) ![]const u8 {
var client: std.http.Client = .{ .allocator = allocator };
fn getLatestReleaseUrl(allocator: std.mem.Allocator, io: std.Io) ![]const u8 {
var client: std.http.Client = .{ .allocator = allocator, .io = io };
defer client.deinit();
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);
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,
http_client: std.http.Client,
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);
errdefer allocator.destroy(cache);
cache.* = try .init(allocator, cache_path);
cache.* = try .init(allocator, io, cache_path);
return .{
.allocator = allocator,
.io = io,
.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,
};
}
@ -31,15 +36,32 @@ pub fn deinit(self: *Self) void {
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 {
// Parse IP to u128 for cache lookup
const addr = std.net.Address.parseIp(ip_str, 0) catch return null;
const ip_u128: u128 = switch (addr.any.family) {
std.posix.AF.INET => @as(u128, @intCast(std.mem.readInt(u32, @ptrCast(&addr.in.sa.addr), .big))),
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;
const parsed = packIp(ip_str) orelse return null;
const ip_u128 = parsed.key;
const family = parsed.family;
// Check cache first
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});
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(.{
.location = .{ .url = w.buffered() },
.method = .GET,
@ -142,13 +164,15 @@ inline fn getString(obj: std.json.ObjectMap, key: []const u8) []const u8 {
pub const Cache = struct {
allocator: Allocator,
io: std.Io,
path: []const u8,
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{
.allocator = allocator,
.io = io,
.path = try allocator.dupe(u8, path),
.entries = std.AutoHashMap(u128, Location).init(allocator),
.file = null,
@ -156,17 +180,17 @@ pub const Cache = struct {
errdefer allocator.free(cache.path);
// 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;
try cache.load();
} else |err| switch (err) {
error.FileNotFound => {
// Create new cache file
const dir = std.fs.path.dirname(path) orelse return error.InvalidPath;
try std.fs.cwd().makePath(dir);
cache.file = try std.fs.createFileAbsolute(path, .{ .read = true });
try std.Io.Dir.cwd().createDirPath(io, dir);
cache.file = try std.Io.Dir.createFileAbsolute(io, path, .{ .read = true });
// Write header
try cache.file.?.writeAll("#Ip2location:v2\n");
try cache.file.?.writePositionalAll(io, "#Ip2location:v2\n", 0);
},
else => return err,
}
@ -175,7 +199,7 @@ pub const Cache = struct {
}
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();
while (it.next()) |loc| {
self.allocator.free(loc.name);
@ -186,10 +210,18 @@ pub const Cache = struct {
fn load(self: *Cache) !void {
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;
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);
var lines = std.mem.splitScalar(u8, content, '\n');
@ -198,9 +230,9 @@ pub const Cache = struct {
if (lines.next()) |first_line| {
if (!std.mem.eql(u8, first_line, "#Ip2location:v2")) {
log.warn("Cache file missing or invalid header, discarding", .{});
file.close();
file.close(self.io);
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 });
};
return;
@ -233,13 +265,10 @@ pub const Cache = struct {
const lon = try std.fmt.parseFloat(f64, lon_str);
// Try parsing as IP address first, fall back to u128
const ip_u128 = if (std.net.Address.parseIp(ip_str, 0)) |addr| blk: {
break :blk switch (addr.any.family) {
std.posix.AF.INET => @as(u128, @intCast(std.mem.readInt(u32, @ptrCast(&addr.in.sa.addr), .big))),
std.posix.AF.INET6 => std.mem.readInt(u128, @ptrCast(&addr.in6.sa.addr), .big),
else => return error.InvalidIpFamily,
};
} else |_| try std.fmt.parseInt(u128, ip_str, 10);
const ip_u128 = if (packIp(ip_str)) |parsed|
parsed.key
else
try std.fmt.parseInt(u128, ip_str, 10);
const name_copy = try allocator.dupe(u8, name);
return .{
@ -271,7 +300,8 @@ pub const Cache = struct {
// Append to file: ip,lat,lon,name
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
var buf: [64]u8 = undefined;
const ip_str = if (family == 4)
@ -299,7 +329,7 @@ pub const Cache = struct {
loc.name,
});
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 Allocator = std.mem.Allocator;
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 log = std.log.scoped(.ipwhois);
allocator: Allocator,
/// Zig 0.16 requires an explicit `Io` for filesystem and HTTP work.
io: std.Io,
http_client: std.http.Client,
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);
errdefer allocator.destroy(cache);
cache.* = try .init(allocator, cache_path);
cache.* = try .init(allocator, io, cache_path);
return .{
.allocator = allocator,
.http_client = std.http.Client{ .allocator = allocator },
.io = io,
.http_client = std.http.Client{ .allocator = allocator, .io = io },
.cache = cache,
};
}
@ -30,13 +34,9 @@ pub fn deinit(self: *Self) void {
pub fn lookup(self: *Self, ip_str: []const u8) ?Location {
// Parse IP to u128 for cache lookup
const addr = std.net.Address.parseIp(ip_str, 0) catch return null;
const ip_u128: u128 = switch (addr.any.family) {
std.posix.AF.INET => @as(u128, @intCast(std.mem.readInt(u32, @ptrCast(&addr.in.sa.addr), .big))),
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;
const parsed = Ip2location.packIp(ip_str) orelse return null;
const ip_u128 = parsed.key;
const family = parsed.family;
// Check cache first
if (self.cache.get(ip_u128)) |result|

View file

@ -80,13 +80,16 @@ pub const LocationType = enum {
/// has a permanent cache
pub const Resolver = struct {
allocator: std.mem.Allocator,
/// Zig 0.16 requires an explicit `Io` for DNS lookups and HTTP requests.
io: std.Io,
geoip: ?*GeoIp,
geocache: *GeoCache,
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 .{
.allocator = allocator,
.io = io,
.geoip = geoip,
.geocache = geocache,
.airports = airports,
@ -124,22 +127,28 @@ pub const Resolver = struct {
}
fn resolveDomain(self: *Resolver, domain: []const u8) !Location {
// Use std.net to resolve domain to IP
const addr_list = std.net.getAddressList(self.allocator, domain, 0) catch {
return error.LocationNotFound;
};
defer addr_list.deinit();
// Zig 0.16 replaced `std.net.getAddressList` with a queue-based lookup
// on `Io.net.HostName`. A capacity of 16 is documented as sufficient to
// avoid blocking, and `lookup` closes the queue when it finishes.
const host_name = std.Io.net.HostName.init(domain) catch return error.LocationNotFound;
if (addr_list.addrs.len == 0) {
return error.LocationNotFound;
}
var results: [16]std.Io.net.HostName.LookupResult = undefined;
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
const addr = addr_list.addrs[0];
var buf: [64]u8 = undefined;
const ip_str = try std.fmt.bufPrint(&buf, "{f}", .{addr});
// Take the first address; `canonical_name` entries are not addresses.
while (queue.getOne(self.io)) |result| {
switch (result) {
.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 {
@ -164,7 +173,7 @@ pub const Resolver = struct {
);
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();
const uri = try std.Uri.parse(url);
@ -285,9 +294,9 @@ test "detect location type" {
test "resolver init" {
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();
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.airports == null);
}
@ -295,23 +304,23 @@ test "resolver init" {
test "resolve IP address with GeoIP" {
const allocator = std.testing.allocator;
const Config = @import("../Config.zig");
const config = try Config.load(allocator);
const config = try Config.loadForTest(allocator);
defer config.deinit(allocator);
const build_options = @import("build_options");
if (build_options.download_geoip) {
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;
defer geoip.deinit();
var geocache = try GeoCache.init(allocator, null);
var geocache = try GeoCache.init(allocator, std.testing.io, null);
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
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 version = @import("build_options").version;
pub fn main() !u8 {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
/// Zig 0.16 entry point: the runtime supplies allocators, the `Io`
/// implementation, and the environment map rather than us constructing them.
pub fn main(init: std.process.Init) !u8 {
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);
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", .{});
}
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;
return err;
};
defer metno.deinit();
// 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
var geoip = GeoIp.init(
allocator,
io,
cfg.geolite_path,
cfg,
) catch |err| {
@ -50,7 +52,7 @@ pub fn main() !u8 {
defer geoip.deinit();
// 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();
// Initialize airports database
@ -58,25 +60,26 @@ pub fn main() !u8 {
defer airports_db.deinit();
// 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,
.cache_dir = cfg.cache_dir,
});
defer cache.deinit();
var rate_limiter = try RateLimiter.init(allocator, .{
var rate_limiter = try RateLimiter.init(allocator, io, .{
.capacity = 300,
.refill_rate = 5,
.refill_interval_ms = 200,
});
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),
.resolver = &resolver,
.geoip = &geoip,
.io = io,
}, &rate_limiter);
// 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 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;
while (i < 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 });
},
'm' => {
const now = try nowAt(weather.coords);
const now = try nowAt(weather.coords, now_unix_s);
const moon = Moon.getPhase(now);
try writer.writeAll(moon.emoji());
},
'M' => {
const now = try nowAt(weather.coords);
const now = try nowAt(weather.coords, now_unix_s);
const moon = Moon.getPhase(now);
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
// should actually be ok. Unix timestamp is always UTC,
// so we convert to local
const now = try nowAt(weather.coords);
const now = try nowAt(weather.coords, now_unix_s);
const astro = Astronomical.init(
weather.coords.latitude,
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)
(try zeit.Time.fromISO8601("2026-01-09")).instant()
else
try zeit.instant(.{});
zeit.instant(.{ .unix_timestamp = now_unix_s }, &zeit.utc);
const offset = TimeZoneOffsets.getTimezoneOffset(coords);
const new = if (offset >= 0)
try now.add(.{ .minutes = @abs(offset) })
@ -133,6 +135,9 @@ const test_weather = types.WeatherData{
.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" {
const allocator = std.testing.allocator;
@ -158,7 +163,7 @@ test "render custom format with location and temp" {
var output_buf: [1024]u8 = undefined;
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];
@ -191,7 +196,7 @@ test "render custom format with newline" {
var output_buf: [1024]u8 = undefined;
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];
@ -223,7 +228,7 @@ test "render custom format with humidity and pressure" {
var output_buf: [1024]u8 = undefined;
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];
@ -256,7 +261,7 @@ test "render custom format with imperial units" {
var output_buf: [1024]u8 = undefined;
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];
@ -269,7 +274,7 @@ test "render custom format with feels like temp" {
var output_buf: [1024]u8 = undefined;
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];
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 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];
try std.testing.expectEqualStrings("🌗", output);
@ -289,7 +294,7 @@ test "render custom format with moon day" {
var output_buf: [1024]u8 = undefined;
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];
try std.testing.expectEqualStrings("21", output);
@ -302,7 +307,7 @@ test "render custom format with astronomical dawn" {
var output_buf: [1024]u8 = undefined;
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];
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 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];
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 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];
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 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];
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 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];
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 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];
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";
try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit });
} 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 });
},
@ -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";
try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit });
} 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 });
},
@ -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";
try w.print("{s} {d:.0} {s}\n", .{ art[3], visibility, vis_unit });
} 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 });
},
@ -217,24 +217,28 @@ fn renderForecastDay(w: *std.Io.Writer, day: types.ForecastDay, options: RenderO
// 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 };
var date_stream = std.io.fixedBufferStream(&date_str);
try date_time.gofmt(date_stream.writer(), "Mon 2 Jan");
const date_len = date_stream.pos;
// 0.16 removed fixedBufferStream; `Io.Writer.fixed` is the replacement
// and tracks the written length in `end`.
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("{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}\n", .{std.mem.trimRight(u8, art[2], " ")});
try w.print("{s}\n", .{std.mem.trimRight(u8, art[3], " ")});
try w.print("{s}\n", .{std.mem.trimRight(u8, art[4], " ")});
try w.print("{s}\n", .{std.mem.trimEnd(u8, art[2], " ")});
try w.print("{s}\n", .{std.mem.trimEnd(u8, art[3], " ")});
try w.print("{s}\n", .{std.mem.trimEnd(u8, art[4], " ")});
return;
}
// 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 };
var date_stream = std.io.fixedBufferStream(&date_str);
try date_time.gofmt(date_stream.writer(), "Mon _2 Jan");
const date_len = date_stream.pos;
// 0.16 removed fixedBufferStream; `Io.Writer.fixed` is the replacement
// and tracks the written length in `end`.
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) {
try w.writeAll(" ┌─────────────┐\n");
@ -759,7 +763,7 @@ fn testArt(data: types.WeatherData) !void {
format,
);
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.log.err(
"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_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];

View file

@ -3,7 +3,10 @@ const types = @import("../weather/types.zig");
const Moon = @import("../Moon.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
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
// Moon phase - use current time for simplicity
const timestamp = std.time.timestamp();
const moon = Moon.getPhase(timestamp);
const moon = Moon.getPhase(now_unix_s);
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 });
@ -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" {
const allocator = std.testing.allocator;
@ -145,7 +150,7 @@ test "prometheus format includes required metrics" {
var output_buf: [8192]u8 = undefined;
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];
@ -182,7 +187,7 @@ test "prometheus format has proper help comments" {
var output_buf: [4096]u8 = undefined;
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];

View file

@ -60,29 +60,41 @@ const weather_code_entries = [_]MetNoOpenWeatherEntry{
.{ "snowshowers", .snow_shower },
.{ "snowshowersandthunder", .thunderstorm },
// zig fmt: on
};
};
const WeatherCodeMap = std.StaticStringMap(types.WeatherCode);
const weather_code_map = WeatherCodeMap.initComptime(weather_code_entries);
allocator: std.mem.Allocator,
/// Zig 0.16 requires an explicit `Io` for HTTP work.
io: std.Io,
identifying_email: []const u8,
pub fn init(allocator: std.mem.Allocator, identifying_email: ?[]const u8) !MetNo {
const email = identifying_email orelse blk: {
const env_email = std.process.getEnvVarOwned(allocator, "METNO_TOS_IDENTIFYING_EMAIL") catch |err| {
if (err == error.EnvironmentVariableNotFound) {
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;
}
return err;
/// Zig 0.16 removed `std.process.getEnvVarOwned`; the environment map is now
/// threaded in from `main`. `Map.get` returns a borrowed slice, so the address
/// is duplicated unconditionally to keep `identifying_email` uniformly owned by
/// this struct (previously an explicitly-passed address would have been freed
/// out from under the caller by `deinit`).
pub fn init(
allocator: std.mem.Allocator,
io: std.Io,
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{
.allocator = allocator,
.io = io,
.identifying_email = email,
};
}
@ -110,7 +122,7 @@ fn fetchRaw(ptr: *anyopaque, allocator: std.mem.Allocator, coords: Coordinates)
defer self.allocator.free(url);
// 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();
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
if (current_date) |prev_date| {
if (day_temps.items.len > 0) {
var max_temp: f32 = day_temps.items[0];
var min_temp: f32 = day_temps.items[0];
for (day_temps.items) |t| {
if (t > max_temp) max_temp = t;
if (t < min_temp) min_temp = t;
}
var max_temp: f32 = day_temps.items[0];
var min_temp: f32 = day_temps.items[0];
for (day_temps.items) |t| {
if (t > max_temp) max_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
const hourly_slice = try day_all_hours.toOwnedSlice(allocator);
// Return all hourly forecasts - let the renderer decide which to display
const hourly_slice = try day_all_hours.toOwnedSlice(allocator);
try days.append(allocator, .{
.date = prev_date,
.max_temp_c = max_temp,
.min_temp_c = min_temp,
.condition = try allocator.dupe(u8, symbolCodeToCondition(symbol)),
.weather_code = symbolCodeToWeatherCode(symbol),
.hourly = hourly_slice,
});
try days.append(allocator, .{
.date = prev_date,
.max_temp_c = max_temp,
.min_temp_c = min_temp,
.condition = try allocator.dupe(u8, symbolCodeToCondition(symbol)),
.weather_code = symbolCodeToWeatherCode(symbol),
.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);
defer allocator.free(raw);
// TTL: 1000-2000 seconds (16-33 minutes) to avoid thundering herd
const ttl = 1000 + std.crypto.random.intRangeAtMost(u64, 0, 1000);
// TTL: 1000-2000 seconds (16-33 minutes) to avoid thundering herd.
// 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);
// Parse and return
@ -50,7 +54,7 @@ test "provider fetch" {
const Mock = @import("Mock.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();
var mock = try Mock.init(std.testing.allocator);