upgrade to zig 0.16.0/fix latent MMDB C interop issue
This commit is contained in:
parent
c9f3681460
commit
f9919d9521
26 changed files with 464 additions and 308 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@
|
|||
.zig-cache/
|
||||
zig-out/
|
||||
coverage/
|
||||
# Zig 0.16 stores fetched packages in-tree rather than the global cache
|
||||
zig-pkg/
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
43
build.zig
43
build.zig
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
39
src/cache/Cache.zig
vendored
|
|
@ -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
15
src/cache/Lru.zig
vendored
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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("/");
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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", .{
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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|
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
27
src/main.zig
27
src/main.zig
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue