Compare commits

..

12 commits

20 changed files with 1255 additions and 350 deletions

View file

@ -20,6 +20,8 @@ jobs:
run: zig build --summary all run: zig build --summary all
- name: Run tests - name: Run tests
run: zig build test -Ddownload-geoip --summary all run: zig build test -Ddownload-geoip --summary all
- name: Check code coverage >= 80%
run: zig build coverage -Ddownload-geoip -Dcoverage-threshold=80 --summary all
- name: Package - name: Package
run: zig build -Dtarget="$BUILD_TARGET" -Doptimize="$BUILD_OPTIMIZATION" run: zig build -Dtarget="$BUILD_TARGET" -Doptimize="$BUILD_OPTIMIZATION"
- name: Upload - name: Upload

1
.gitignore vendored
View file

@ -1,3 +1,4 @@
*.swp *.swp
.zig-cache/ .zig-cache/
zig-out/ zig-out/
coverage/

View file

@ -2,24 +2,20 @@
Features not yet implemented in the Zig version: Features not yet implemented in the Zig version:
## 1. Prometheus Metrics Format (format=p1) ## 1. PNG Generation
- Export weather data in Prometheus metrics format
- See API_ENDPOINTS.md for format specification
## 2. PNG Generation
- Render weather reports as PNG images - Render weather reports as PNG images
- Support transparency and custom styling - Support transparency and custom styling
- Requires image rendering library integration - Requires image rendering library integration
## 3. Language/Localization ## 2. Language/Localization
- Accept-Language header parsing - Accept-Language header parsing
- lang query parameter support - lang query parameter support
- Translation of weather conditions and text (54 languages) - Translation of weather conditions and text (54 languages)
## 4. Json output ## 3. Json output
- Does not match wttr.in format - Does not match wttr.in format
## 5. Moon endpoint ## 4. Moon endpoint
- `/Moon` and `/Moon@YYYY-MM-DD` endpoints not yet implemented - `/Moon` and `/Moon@YYYY-MM-DD` endpoints not yet implemented
- Moon phase calculation is implemented and available in custom format (%m, %M) - Moon phase calculation is implemented and available in custom format (%m, %M)

View file

@ -178,8 +178,6 @@ The result will look like:
## Prometheus Metrics Output ## Prometheus Metrics Output
**Note:** Not yet implemented - see [MISSING_FEATURES.md](MISSING_FEATURES.md)
To fetch information in Prometheus format: To fetch information in Prometheus format:
```bash ```bash

View file

@ -1,5 +1,6 @@
const std = @import("std"); const std = @import("std");
const GitVersion = @import("build/GitVersion.zig"); const GitVersion = @import("build/GitVersion.zig");
const Coverage = @import("build/Coverage.zig");
pub fn build(b: *std.Build) void { pub fn build(b: *std.Build) void {
const target = b.standardTargetOptions(.{}); const target = b.standardTargetOptions(.{});
@ -100,32 +101,36 @@ pub fn build(b: *std.Build) void {
maxminddb.installHeadersDirectory(maxminddb_upstream.path("include"), "", .{}); maxminddb.installHeadersDirectory(maxminddb_upstream.path("include"), "", .{});
const version = GitVersion.getVersion(b, .{}); const version = GitVersion.getVersion(b, .{});
const download_geoip = b.option(bool, "download-geoip", "Download GeoIP database for tests") orelse false;
const build_options = b.addOptions(); const build_options = b.addOptions();
build_options.addOption([]const u8, "version", version); build_options.addOption([]const u8, "version", version);
build_options.addOption(bool, "download_geoip", download_geoip);
const exe = b.addExecutable(.{ const root_module = b.createModule(.{
.name = "wttr",
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"), .root_source_file = b.path("src/main.zig"),
.target = target, .target = target,
.optimize = optimize, .optimize = optimize,
}),
}); });
root_module.addImport("httpz", httpz.module("httpz"));
exe.root_module.addImport("httpz", httpz.module("httpz")); root_module.addImport("zeit", zeit.module("zeit"));
exe.root_module.addImport("zeit", zeit.module("zeit")); root_module.addAnonymousImport("airports.dat", .{
exe.root_module.addAnonymousImport("airports.dat", .{
.root_source_file = openflights.path("data/airports.dat"), .root_source_file = openflights.path("data/airports.dat"),
}); });
exe.root_module.addOptions("build_options", build_options); root_module.addOptions("build_options", build_options);
exe.root_module.addIncludePath(maxminddb_upstream.path("include")); root_module.addIncludePath(maxminddb_upstream.path("include"));
exe.root_module.addIncludePath(b.path("libs/phoon_14Aug2014")); root_module.addIncludePath(b.path("libs/phoon_14Aug2014"));
exe.root_module.addIncludePath(b.path("libs/sunriset")); root_module.addIncludePath(b.path("libs/sunriset"));
exe.root_module.addConfigHeader(maxminddb_config); root_module.addConfigHeader(maxminddb_config);
exe.linkLibrary(maxminddb); const libs = &[_]*std.Build.Step.Compile{
exe.linkLibrary(phoon); maxminddb,
exe.linkLibrary(sunriset); phoon,
exe.linkLibC(); sunriset,
};
const exe = b.addExecutable(.{
.name = "wttr",
.root_module = root_module,
});
configureCompilationUnit(exe, libs);
b.installArtifact(exe); b.installArtifact(exe);
@ -138,35 +143,19 @@ pub fn build(b: *std.Build) void {
const run_step = b.step("run", "Run the app"); const run_step = b.step("run", "Run the app");
run_step.dependOn(&run_cmd.step); run_step.dependOn(&run_cmd.step);
const download_geoip = b.option(bool, "download-geoip", "Download GeoIP database for tests") orelse false; const tests = b.addTest(.{ .root_module = root_module });
configureCompilationUnit(tests, libs);
const test_options = b.addOptions();
test_options.addOption(bool, "download_geoip", download_geoip);
test_options.addOption([]const u8, "version", version);
const tests = b.addTest(.{
.root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
}),
});
tests.root_module.addImport("httpz", httpz.module("httpz"));
tests.root_module.addImport("zeit", zeit.module("zeit"));
tests.root_module.addAnonymousImport("airports.dat", .{
.root_source_file = openflights.path("data/airports.dat"),
});
tests.root_module.addOptions("build_options", test_options);
tests.root_module.addIncludePath(maxminddb_upstream.path("include"));
tests.root_module.addIncludePath(b.path("libs/phoon_14Aug2014"));
tests.root_module.addIncludePath(b.path("libs/sunriset"));
tests.root_module.addConfigHeader(maxminddb_config);
tests.linkLibrary(maxminddb);
tests.linkLibrary(phoon);
tests.linkLibrary(sunriset);
tests.linkLibC();
const run_tests = b.addRunArtifact(tests); const run_tests = b.addRunArtifact(tests);
const test_step = b.step("test", "Run tests"); const test_step = b.step("test", "Run tests");
test_step.dependOn(&run_tests.step); test_step.dependOn(&run_tests.step);
// Coverage step
const cov_step = Coverage.addCoverageStep(b, root_module, exe.name);
configureCompilationUnit(cov_step.test_exe, libs);
}
fn configureCompilationUnit(compile: *std.Build.Step.Compile, libs: []const *std.Build.Step.Compile) void {
for (libs) |lib| compile.linkLibrary(lib);
compile.linkLibC();
} }

182
build/Coverage.zig Normal file
View file

@ -0,0 +1,182 @@
const builtin = @import("builtin");
const std = @import("std");
const Build = std.Build;
const Coverage = @This();
/// Adds test coverage. This will create a new test coverage executable to the
/// build graph, generated only if coverage is a target. It will create an
/// option -Dcoverage-threshold that will fail the build if the threshold is
/// not met. It will also add a step that downloads a zig fork of the kcov
/// executable into zig cache if it doesn't already exist
///
/// Because it is creating a new test executable from the root module provided,
/// if there are any linking steps being done to your test executable, those
/// must also be done to the test_exe returned by this function
pub fn addCoverageStep(b: *Build, root_module: *Build.Module, coverage_name: []const u8) *Coverage {
//verify host requirements
{
const supported = builtin.os.tag == .linux and
(builtin.cpu.arch == .x86_64 or builtin.cpu.arch == .aarch64);
if (!supported)
@panic("Coverage only supported on x86_64-linux or aarch64-linux");
}
// Add options
const coverage_threshold = b.option(u7, "coverage-threshold", "Minimum coverage percentage required") orelse 0;
const coverage_dir = b.option([]const u8, "coverage-dir", "Coverage output directory") orelse
b.pathJoin(&.{ b.build_root.path orelse ".", "coverage" });
const coverage_step = b.step("coverage", "Generate test coverage report");
// Set up kcov download
// We can't do it directly because we are sandboxed during build, but
// we can create a program and run that program. First we need the destination
// directory
const kcov = blk: {
const arch_name = switch (builtin.cpu.arch) {
.x86_64 => "x86_64",
.aarch64 => "aarch64",
else => unreachable,
};
const Algo = std.crypto.hash.sha2.Sha256;
var hasher = Algo.init(.{});
hasher.update("kcov-");
hasher.update(arch_name);
var cache_hash: [Algo.digest_length]u8 = undefined;
hasher.final(&cache_hash);
const cache_dir = b.pathJoin(&.{
b.cache_root.path.?,
"o",
b.fmt("{s}", .{std.fmt.bytesToHex(cache_hash, .lower)}),
});
const kcov_name = b.fmt("kcov-{s}", .{arch_name});
break :blk .{ .path = b.pathJoin(&.{ cache_dir, kcov_name }), .arch = arch_name };
};
// Create download and coverage build steps
return blk: {
const download_exe = b.addExecutable(.{
.name = "download-kcov",
.root_module = b.createModule(.{
.root_source_file = b.path("build/download_kcov.zig"),
.target = b.resolveTargetQuery(.{}),
}),
});
const run_download = b.addRunArtifact(download_exe);
run_download.addArg(kcov.path);
run_download.addArg(kcov.arch);
const run_coverage = b.addSystemCommand(&.{kcov.path});
const include_path = b.pathJoin(&.{ b.build_root.path.?, "src" });
run_coverage.addArgs(&.{ "--include-path", include_path });
const css_file = b.pathJoin(&.{ b.build_root.path.?, "build", "bcov.css" });
run_coverage.addArg(b.fmt("--configure=css-file={s}", .{css_file}));
run_coverage.addArg(coverage_dir);
const test_exe = b.addTest(.{
.name = coverage_name,
.root_module = root_module,
// we need to set the test exe to use llvm as the self hosted backend
// does not support the data kcov needs
.use_llvm = true,
});
run_coverage.addArtifactArg(test_exe);
run_coverage.step.dependOn(&test_exe.step);
run_coverage.step.dependOn(&run_download.step);
const json_path = b.fmt("{s}/{s}/coverage.json", .{ coverage_dir, coverage_name });
const verbose = b.option(bool, "coverage-verbose", "Show test coverage for each file") orelse false;
const check_step = create(b, test_exe, json_path, coverage_threshold, verbose);
check_step.step.dependOn(&run_coverage.step);
coverage_step.dependOn(&check_step.step);
break :blk check_step;
};
}
step: Build.Step,
json_path: []const u8,
threshold: u7,
test_exe: *std.Build.Step.Compile,
verbose: bool,
pub fn create(owner: *Build, test_exe: *std.Build.Step.Compile, xml_path: []const u8, threshold: u7, verbose: bool) *Coverage {
const check = owner.allocator.create(Coverage) catch @panic("OOM");
check.* = .{
.step = Build.Step.init(.{
.id = .custom,
.name = "check coverage",
.owner = owner,
.makeFn = make,
}),
.json_path = xml_path,
.threshold = threshold,
.test_exe = test_exe,
.verbose = verbose,
};
return check;
}
// This must be kept in step with kcov coverage.json format
const CoverageReport = struct {
percent_covered: f64,
covered_lines: usize,
total_lines: usize,
percent_low: u7,
percent_high: u7,
command: []const u8,
date: []const u8,
files: []File,
};
const File = struct {
file: []const u8,
percent_covered: f64,
covered_lines: usize,
total_lines: usize,
pub fn coverageLessThanDesc(context: []File, lhs: File, rhs: File) bool {
_ = context;
return lhs.percent_covered > rhs.percent_covered;
}
};
fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void {
_ = options;
const check: *Coverage = @fieldParentPtr("step", step);
const allocator = step.owner.allocator;
const file = try std.fs.cwd().openFile(check.json_path, .{});
defer file.close();
const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
defer allocator.free(content);
const json = try std.json.parseFromSlice(CoverageReport, allocator, content, .{});
defer json.deinit();
const coverage = json.value;
var stdout_buffer: [1024]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface;
if (step.owner.verbose or check.verbose) {
const files = coverage.files;
std.mem.sort(File, files, files, File.coverageLessThanDesc);
for (files) |f|
try stdout.print(
"{d: >5.1}% {d: >5}/{d: <5}:{s}\n",
.{ f.percent_covered, f.covered_lines, f.total_lines, f.file },
);
}
try stdout.print(
"Total test coverage: {d}% ({d}/{d})\n",
.{ coverage.percent_covered, coverage.covered_lines, coverage.total_lines },
);
try stdout.flush();
if (@as(u7, @intFromFloat(@floor(coverage.percent_covered))) < check.threshold)
return step.fail("Coverage {d}% is below threshold {d}%", .{ coverage.percent_covered, check.threshold });
}

46
build/bcov.css Normal file
View file

@ -0,0 +1,46 @@
/* Based upon the lcov CSS style, style files can be reused - Dark Theme */
body { color: #e0e0e0; background-color: #1e1e1e; }
a:link { color: #6b9aff; text-decoration: underline; }
a:visited { color: #4dbb7a; text-decoration: underline; }
a:active { color: #ff6b8a; text-decoration: underline; }
td.title { text-align: center; padding-bottom: 10px; font-size: 20pt; font-weight: bold; }
td.ruler { background-color: #4a6ba8; }
td.headerItem { text-align: right; padding-right: 6px; font-family: sans-serif; font-weight: bold; }
td.headerValue { text-align: left; color: #6b9aff; font-family: sans-serif; font-weight: bold; }
td.versionInfo { text-align: center; padding-top: 2px; }
th.headerItem { text-align: right; padding-right: 6px; font-family: sans-serif; font-weight: bold; }
th.headerValue { text-align: left; color: #6b9aff; font-family: sans-serif; font-weight: bold; }
pre.source { font-family: monospace; white-space: pre; overflow: hidden; text-overflow: ellipsis; }
span.lineNum { background-color: #5a5a2a; }
span.lineNumLegend { background-color: #5a5a2a; width: 96px; font-weight: bold ;}
span.lineCov { background-color: #2d5a2d; }
span.linePartCov { background-color: #707000; }
span.lineNoCov { background-color: #762c2c; }
span.orderNum { background-color: #5a4a2a; float: right; width:5em; text-align: left; }
span.orderNumLegend { background-color: #5a4a2a; width: 96px; font-weight: bold ;}
span.coverHits { background-color: #4a4a2a; padding-left: 3px; padding-right: 1px; text-align: right; list-style-type: none; display: inline-block; width: 5em; }
span.coverHitsLegend { background-color: #4a4a2a; width: 96px; font-weight: bold; margin: 0 auto;}
td.tableHead { text-align: center; color: #e0e0e0; background-color: #4a6ba8; font-family: sans-serif; font-size: 120%; font-weight: bold; }
td.coverFile { text-align: left; padding-left: 10px; padding-right: 20px; color: #6b9aff; font-family: monospace; background-color: #3a3a3a; }
td.coverBar { padding-left: 10px; padding-right: 10px; background-color: #3a3a3a; }
td.coverBarOutline { background-color: #4a4a4a; }
td.coverPer { text-align: left; padding-left: 10px; padding-right: 10px; font-weight: bold; background-color: #3a3a3a; color: #e0e0e0; }
td.coverPerLeftMed { text-align: left; padding-left: 10px; padding-right: 10px; background-color: #5a5a00; font-weight: bold; color: #e0e0e0; }
td.coverPerLeftLo { text-align: left; padding-left: 10px; padding-right: 10px; background-color: #5a2d2d; font-weight: bold; color: #e0e0e0; }
td.coverPerLeftHi { text-align: left; padding-left: 10px; padding-right: 10px; background-color: #2d5a2d; font-weight: bold; color: #e0e0e0; }
td.coverNum { text-align: right; padding-left: 10px; padding-right: 10px; background-color: #3a3a3a; color: #e0e0e0; }
/* Override tablesorter hover styles for dark theme */
.tablesorter-blue tbody > tr:hover > td,
.tablesorter-blue tbody > tr:hover + tr.tablesorter-childRow > td,
.tablesorter-blue tbody > tr:hover + tr.tablesorter-childRow + tr.tablesorter-childRow > td,
.tablesorter-blue tbody > tr.even:hover > td,
.tablesorter-blue tbody > tr.even:hover + tr.tablesorter-childRow > td,
.tablesorter-blue tbody > tr.even:hover + tr.tablesorter-childRow + tr.tablesorter-childRow > td {
background: #4a4a4a;
}
.tablesorter-blue tbody > tr.odd:hover > td,
.tablesorter-blue tbody > tr.odd:hover + tr.tablesorter-childRow > td,
.tablesorter-blue tbody > tr.odd:hover + tr.tablesorter-childRow + tr.tablesorter-childRow > td {
background: #4a4a4a;
}

82
build/download_kcov.zig Normal file
View file

@ -0,0 +1,82 @@
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
const allocator = gpa.allocator();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len != 3) return error.InvalidArgs;
const kcov_path = args[1];
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: {
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);
const stdout = &stdout_writer.interface;
try stdout.writeAll("Determining latest kcov version\n");
try stdout.flush();
var client = std.http.Client{ .allocator = allocator };
defer client.deinit();
// Get redirect to find latest version
const list_uri = try std.Uri.parse("https://git.lerch.org/lobo/-/packages/generic/kcov/");
var req = try client.request(.GET, list_uri, .{ .redirect_behavior = .unhandled });
defer req.deinit();
try req.sendBodiless();
var redirect_buf: [1024]u8 = undefined;
const response = try req.receiveHead(&redirect_buf);
if (response.head.status != .see_other) return error.UnexpectedResponse;
const location = response.head.location orelse return error.NoLocation;
const version_start = std.mem.lastIndexOf(u8, location, "/") orelse return error.InvalidLocation;
const version = location[version_start + 1 ..];
try stdout.print(
"Downloading kcov version {s} for {s} to {s}...",
.{ version, arch_name, kcov_path },
);
try stdout.flush();
const binary_url = try std.fmt.allocPrint(
allocator,
"https://git.lerch.org/api/packages/lobo/generic/kcov/{s}/kcov-{s}",
.{ version, arch_name },
);
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) {
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();
var buffer: [8192]u8 = undefined;
var writer = file.writer(&buffer);
const result = try client.fetch(.{
.location = .{ .uri = uri },
.response_writer = &writer.interface,
});
if (result.status != .ok) return error.DownloadFailed;
try writer.interface.flush();
try stdout.writeAll("done\n");
try stdout.flush();
}

View file

@ -28,6 +28,28 @@ pub const Phase = struct {
pub fn day(self: Phase) u8 { pub fn day(self: Phase) u8 {
return @intFromFloat(@round(self.age_days)); return @intFromFloat(@round(self.age_days));
} }
pub fn format(self: Phase, writer: *std.Io.Writer) std.Io.Writer.Error!void {
const name = if (self.phase < 0.0625)
"New Moon"
else if (self.phase < 0.1875)
"Waxing Crescent"
else if (self.phase < 0.3125)
"First Quarter"
else if (self.phase < 0.4375)
"Waxing Gibbous"
else if (self.phase < 0.5625)
"Full Moon"
else if (self.phase < 0.6875)
"Waning Gibbous"
else if (self.phase < 0.8125)
"Last Quarter"
else if (self.phase < 0.9375)
"Waning Crescent"
else
"New Moon";
try writer.print("{s}", .{name});
}
}; };
pub fn getPhase(timestamp: i64) Phase { pub fn getPhase(timestamp: i64) Phase {

View file

@ -149,3 +149,25 @@ test "parse transparency" {
const params_custom = try QueryParams.parse(allocator, "transparency=200"); const params_custom = try QueryParams.parse(allocator, "transparency=200");
try std.testing.expectEqual(@as(u8, 200), params_custom.transparency.?); try std.testing.expectEqual(@as(u8, 200), params_custom.transparency.?);
} }
test "imperial units selection logic" {
// This test documents the priority order for unit selection:
// 1. Explicit ?u or ?m parameter (highest priority)
// 2. lang=us parameter
// 3. US IP detection
// 4. Default to metric
// The actual logic is tested through integration tests
// This test just verifies the QueryParams parsing works
const allocator = std.testing.allocator;
const params_u = try QueryParams.parse(allocator, "u");
try std.testing.expect(params_u.use_imperial.?);
const params_m = try QueryParams.parse(allocator, "m");
try std.testing.expect(!params_m.use_imperial.?);
const params_lang = try QueryParams.parse(allocator, "lang=us");
defer allocator.free(params_lang.lang.?);
try std.testing.expectEqualStrings("us", params_lang.lang.?);
}

View file

@ -76,7 +76,7 @@ fn handleWeather(ctx: *Context, req: *httpz.Request, res: *httpz.Response) !void
try handler.handleWeather(&ctx.options, req, res, client_ip); try handler.handleWeather(&ctx.options, req, res, client_ip);
} }
fn getClientIp(req: *httpz.Request, buf: []u8) ![]const u8 { pub fn getClientIp(req: *httpz.Request, buf: []u8) ![]const u8 {
// Check X-Forwarded-For header first (for proxies) // Check X-Forwarded-For header first (for proxies)
if (req.header("x-forwarded-for")) |xff| { if (req.header("x-forwarded-for")) |xff| {
return parseXForwardedFor(xff); return parseXForwardedFor(xff);
@ -114,7 +114,7 @@ pub fn deinit(self: *Server) void {
self.httpz_server.deinit(); self.httpz_server.deinit();
} }
const MockHarness = struct { pub const MockHarness = struct {
allocator: std.mem.Allocator, allocator: std.mem.Allocator,
config: Config, config: Config,
geoip: *GeoIp, geoip: *GeoIp,
@ -241,6 +241,16 @@ test "handleWeather: default endpoint uses IP address" {
try handler.handleWeather(&harness.opts, ht.req, ht.res, client_ip); try handler.handleWeather(&harness.opts, ht.req, ht.res, client_ip);
try ht.expectStatus(200); try ht.expectStatus(200);
try ht.expectBody(
\\<pre>Weather report: 73.158.64.1
\\
\\<span style="color:#ffff00"> \ / </span> Clear
\\<span style="color:#ffff00"> .-. </span> <span style="color:#d7ff00">+68(+68)</span> °F
\\<span style="color:#ffff00"> ― ( ) ― </span> ↓ <span style="color:#6c6c6c">3</span> mph
\\<span style="color:#ffff00"> `-' </span> 6 mi
\\<span style="color:#ffff00"> / \ </span> 0.0 in
\\</pre>
);
} }
test "handleWeather: x-forwarded-for with multiple IPs" { test "handleWeather: x-forwarded-for with multiple IPs" {

View file

@ -8,6 +8,7 @@ const Line = @import("../render/Line.zig");
const Json = @import("../render/Json.zig"); const Json = @import("../render/Json.zig");
const V2 = @import("../render/V2.zig"); const V2 = @import("../render/V2.zig");
const Custom = @import("../render/Custom.zig"); const Custom = @import("../render/Custom.zig");
const Prometheus = @import("../render/Prometheus.zig");
const help = @import("help.zig"); const help = @import("help.zig");
const log = std.log.scoped(.handler); const log = std.log.scoped(.handler);
@ -135,34 +136,55 @@ fn handleWeatherInternal(
render_options.use_imperial = true; // this is a US IP render_options.use_imperial = true; // this is a US IP
} }
// Add coordinates header using response allocator // Add coordinates header to response
const coords_header = try std.fmt.allocPrint(res.arena, "{d:.4},{d:.4}", .{ location.coords.latitude, location.coords.longitude }); const coords_header = try std.fmt.allocPrint(res.arena, "{d:.4},{d:.4}", .{ location.coords.latitude, location.coords.longitude });
res.headers.add("X-Location-Coordinates", coords_header); res.headers.add("X-Location-Coordinates", coords_header);
res.body = blk: {
if (params.format) |fmt| { if (params.format) |fmt| {
// Anything except the json will be plain text // Anything except the json will be plain text
res.content_type = .TEXT; res.content_type = .TEXT;
if (std.mem.eql(u8, fmt, "1")) {
try Line.render(res.writer(), weather, .@"1", render_options.use_imperial);
return;
}
if (std.mem.eql(u8, fmt, "2")) {
try Line.render(res.writer(), weather, .@"2", render_options.use_imperial);
return;
}
if (std.mem.eql(u8, fmt, "3")) {
try Line.render(res.writer(), weather, .@"3", render_options.use_imperial);
return;
}
if (std.mem.eql(u8, fmt, "4")) {
try Line.render(res.writer(), weather, .@"4", render_options.use_imperial);
return;
}
if (std.mem.eql(u8, fmt, "j1")) { if (std.mem.eql(u8, fmt, "j1")) {
res.content_type = .JSON; // reset to json res.content_type = .JSON; // reset to json
break :blk try Json.render(req_alloc, weather); try Json.render(res.writer(), weather);
return;
} }
if (std.mem.eql(u8, fmt, "v2")) if (std.mem.eql(u8, fmt, "p1")) {
break :blk try V2.render(req_alloc, weather, render_options.use_imperial); try Prometheus.render(res.writer(), weather);
if (std.mem.startsWith(u8, fmt, "%")) return;
break :blk try Custom.render(req_alloc, weather, fmt, render_options.use_imperial); }
// fall back to line if we don't understand the format parameter if (std.mem.eql(u8, fmt, "v2")) {
break :blk try Line.render(req_alloc, weather, fmt, render_options.use_imperial); try V2.render(res.writer(), weather, render_options.use_imperial);
return;
}
// Everything else goes to Custom renderer
try Custom.render(res.writer(), weather, fmt, render_options.use_imperial);
} else { } else {
// No specific format selected, we'll provide Formatted output in either
// text (ansi/plain) or html
render_options.format = determineFormat(params, req.headers.get("user-agent")); render_options.format = determineFormat(params, req.headers.get("user-agent"));
log.debug( log.debug(
"Format: {}. params.ansi {}, params.text {}, user agent: {?s}", "Format: {}. params.ansi {}, params.text {}, user agent: {?s}",
.{ render_options.format, params.ansi, params.text_only, req.headers.get("user-agent") }, .{ render_options.format, params.ansi, params.text_only, req.headers.get("user-agent") },
); );
if (render_options.format != .html) res.content_type = .TEXT else res.content_type = .HTML; if (render_options.format != .html) res.content_type = .TEXT else res.content_type = .HTML;
break :blk try Formatted.render(req_alloc, weather, render_options); try Formatted.render(res.writer(), weather, render_options);
} }
};
} }
fn determineFormat(params: QueryParams, user_agent: ?[]const u8) Formatted.Format { fn determineFormat(params: QueryParams, user_agent: ?[]const u8) Formatted.Format {
@ -195,24 +217,255 @@ fn determineFormat(params: QueryParams, user_agent: ?[]const u8) Formatted.Forma
return .html; return .html;
} }
test "imperial units selection logic" { test "handler: help page" {
// This test documents the priority order for unit selection:
// 1. Explicit ?u or ?m parameter (highest priority)
// 2. lang=us parameter
// 3. US IP detection
// 4. Default to metric
// The actual logic is tested through integration tests
// This test just verifies the QueryParams parsing works
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
const params_u = try QueryParams.parse(allocator, "u"); var harness = try MockHarness.init(allocator);
try std.testing.expect(params_u.use_imperial.?); defer harness.deinit();
const params_m = try QueryParams.parse(allocator, "m"); var ht = httpz.testing.init(.{});
try std.testing.expect(!params_m.use_imperial.?); defer ht.deinit();
const params_lang = try QueryParams.parse(allocator, "lang=us"); ht.url("/:help");
defer allocator.free(params_lang.lang.?); ht.param("location", ":help");
try std.testing.expectEqualStrings("us", params_lang.lang.?);
try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1");
try ht.expectStatus(200);
}
test "handler: translation page" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/:translation");
ht.param("location", ":translation");
try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1");
try ht.expectStatus(200);
}
test "handler: favicon" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/favicon.ico");
ht.param("location", "favicon.ico");
try handleWeather(&harness.opts, ht.req, ht.res, "127.0.0.1");
try ht.expectStatus(200);
}
test "handler: format j1 (json)" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/73.158.64.1?format=j1");
ht.param("location", "73.158.64.1");
var client_ip_buf: [47]u8 = undefined;
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
try ht.expectStatus(200);
try ht.expectHeader("Content-Type", "application/json; charset=UTF-8");
try ht.expectBody(
\\{"current_condition":{"temp_C":20,"weatherCode":"clear","weatherDesc":[{"value":"Clear"}],"humidity":50,"windspeedKmph":5,"winddirDegree":0,"pressure":1013,"precipMM":0},"weather":[]}
);
}
test "handler: format p1 (prometheus)" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/73.158.64.1?format=p1");
ht.param("location", "73.158.64.1");
var client_ip_buf: [47]u8 = undefined;
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
try ht.expectStatus(200);
try ht.expectBody(
\\# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius
\\temperature_feels_like_celsius{forecast="current"} 20
\\# HELP temperature_feels_like_fahrenheit Feels Like Temperature in Fahrenheit
\\temperature_feels_like_fahrenheit{forecast="current"} 68
\\# HELP cloudcover_percentage Cloud Coverage in Percent
\\cloudcover_percentage{forecast="current"} 0
\\# HELP humidity_percentage Humidity in Percent
\\humidity_percentage{forecast="current"} 50
\\# HELP precipitation_mm Precipitation (Rainfall) in mm
\\precipitation_mm{forecast="current"} 0.0
\\# HELP pressure_hpa Air pressure in hPa
\\pressure_hpa{forecast="current"} 1013
\\# HELP temperature_celsius Temperature in Celsius
\\temperature_celsius{forecast="current"} 20
\\# HELP temperature_fahrenheit Temperature in Fahrenheit
\\temperature_fahrenheit{forecast="current"} 68
\\# HELP uv_index Ultraviolet Radiation Index
\\uv_index{forecast="current"} 0
\\# HELP visibility Visible Distance in Kilometres
\\visibility{forecast="current"} 10
\\# HELP weather_code Code to describe Weather Condition
\\weather_code{forecast="current"} 800
\\# HELP winddir_degree Wind Direction in Degree
\\winddir_degree{forecast="current"} 0
\\# HELP windspeed_kmph Wind Speed in Kilometres per Hour
\\windspeed_kmph{forecast="current"} 5
\\# HELP windspeed_mph Wind Speed in Miles per Hour
\\windspeed_mph{forecast="current"} 3.106856
\\# HELP observation_time Minutes since start of the day the observation happened
\\observation_time{forecast="current"} 0
\\# HELP weather_desc Weather Description
\\weather_desc{forecast="current", description="Clear"} 1
\\# HELP winddir_16_point Wind Direction on a 16-wind compass rose
\\winddir_16_point{forecast="current", description="N"} 1
\\
);
}
test "handler: format v2" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/73.158.64.1?format=v2");
ht.param("location", "73.158.64.1");
var client_ip_buf: [47]u8 = undefined;
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
try ht.expectStatus(200);
// Should we have 2 empty lines?
try ht.expectBody(
\\Weather report: 73.158.64.1
\\
\\ Current conditions
\\ Clear
\\ 🌡️ 20.0°C (68.0°F)
\\ 💧 50%
\\ 🌬️ 5.0 km/h N
\\ 🔽 1013.0 hPa
\\ 💦 0.0 mm
\\
\\
);
}
test "handler: format custom (%c)" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/73.158.64.1?format=%c");
ht.param("location", "73.158.64.1");
var client_ip_buf: [47]u8 = undefined;
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
try ht.expectStatus(200);
try ht.expectBody("☀️");
}
test "handler: format line 1" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/73.158.64.1?format=1");
ht.param("location", "73.158.64.1");
var client_ip_buf: [47]u8 = undefined;
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
try ht.expectStatus(200);
try ht.expectBody("☀️ +20°C");
}
test "handler: format line 2" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/73.158.64.1?format=2");
ht.param("location", "73.158.64.1");
var client_ip_buf: [47]u8 = undefined;
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
try ht.expectStatus(200);
try ht.expectBody("☀️ 🌡️+20°C 🌬↓5km/h");
}
test "handler: format line 3" {
const allocator = std.testing.allocator;
const MockHarness = @import("Server.zig").MockHarness;
var harness = try MockHarness.init(allocator);
defer harness.deinit();
var ht = httpz.testing.init(.{});
defer ht.deinit();
ht.url("/73.158.64.1?format=3");
ht.param("location", "73.158.64.1");
var client_ip_buf: [47]u8 = undefined;
const client_ip = try @import("Server.zig").getClientIp(ht.req, &client_ip_buf);
try handleWeather(&harness.opts, ht.req, ht.res, client_ip);
try ht.expectStatus(200);
try ht.expectBody("Test: ☀️ +20°C");
} }

View file

@ -8,18 +8,14 @@ const Astronomical = @import("../Astronomical.zig");
const TimeZoneOffsets = @import("../location/timezone_offsets.zig"); const TimeZoneOffsets = @import("../location/timezone_offsets.zig");
const Coordinates = @import("../Coordinates.zig"); const Coordinates = @import("../Coordinates.zig");
pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, format: []const u8, use_imperial: bool) ![]const u8 { pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, format: []const u8, use_imperial: bool) !void {
var output: std.ArrayList(u8) = .empty;
errdefer output.deinit(allocator);
const writer = output.writer(allocator);
var i: usize = 0; var i: usize = 0;
while (i < format.len) { while (i < format.len) {
if (format[i] == '%' and i + 1 < format.len) { if (format[i] == '%' and i + 1 < format.len) {
const code = format[i + 1]; const code = format[i + 1];
switch (code) { switch (code) {
'c' => try writer.print("{s}", .{emoji.getWeatherEmoji(weather.current.weather_code)}), 'c' => try writer.writeAll(emoji.getWeatherEmoji(weather.current.weather_code)),
'C' => try writer.print("{s}", .{weather.current.condition}), 'C' => try writer.writeAll(weather.current.condition),
'h' => try writer.print("{d}%", .{weather.current.humidity}), 'h' => try writer.print("{d}%", .{weather.current.humidity}),
't' => { 't' => {
const temp = if (use_imperial) weather.current.tempFahrenheit() else weather.current.temp_c; const temp = if (use_imperial) weather.current.tempFahrenheit() else weather.current.temp_c;
@ -46,7 +42,7 @@ pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, format:
const unit = if (use_imperial) "mph" else "km/h"; const unit = if (use_imperial) "mph" else "km/h";
try writer.print("{d:.0} {s} {s}", .{ wind, unit, utils.degreeToDirection(weather.current.wind_deg) }); try writer.print("{d:.0} {s} {s}", .{ wind, unit, utils.degreeToDirection(weather.current.wind_deg) });
}, },
'l' => try writer.print("{s}", .{weather.locationDisplayName()}), 'l' => try writer.writeAll(weather.locationDisplayName()),
'p' => { 'p' => {
const precip = if (use_imperial) weather.current.precip_mm * 0.0393701 else weather.current.precip_mm; const precip = if (use_imperial) weather.current.precip_mm * 0.0393701 else weather.current.precip_mm;
const unit = if (use_imperial) "in" else "mm"; const unit = if (use_imperial) "in" else "mm";
@ -60,7 +56,7 @@ pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, format:
'm' => { 'm' => {
const now = try nowAt(weather.coords); const now = try nowAt(weather.coords);
const moon = Moon.getPhase(now); const moon = Moon.getPhase(now);
try writer.print("{s}", .{moon.emoji()}); try writer.writeAll(moon.emoji());
}, },
'M' => { 'M' => {
const now = try nowAt(weather.coords); const now = try nowAt(weather.coords);
@ -101,8 +97,6 @@ pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, format:
i += 1; i += 1;
} }
} }
return output.toOwnedSlice(allocator);
} }
fn nowAt(coords: Coordinates) !i64 { fn nowAt(coords: Coordinates) !i64 {
@ -115,6 +109,27 @@ fn nowAt(coords: Coordinates) !i64 {
return new.unixTimestamp(); return new.unixTimestamp();
} }
const test_weather = types.WeatherData{
// SAFETY: allocator unused in these tests
.allocator = undefined,
.location = "Test",
.display_name = null,
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 10,
.feels_like_c = 10,
.condition = "Clear",
.weather_code = .clear,
.humidity = 50,
.wind_kph = 10,
.wind_deg = 0,
.pressure_mb = 1013,
.precip_mm = 0,
.visibility_km = 10,
},
.forecast = &.{},
};
test "render custom format with location and temp" { test "render custom format with location and temp" {
const allocator = std.testing.allocator; const allocator = std.testing.allocator;
@ -137,8 +152,12 @@ test "render custom format with location and temp" {
.allocator = allocator, .allocator = allocator,
}; };
const output = try render(allocator, weather, "%l: %c %t", false); var output_buf: [1024]u8 = undefined;
defer allocator.free(output); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather, "%l: %c %t", false);
const output = output_buf[0..writer.end];
try std.testing.expect(std.mem.indexOf(u8, output, "London") != null); try std.testing.expect(std.mem.indexOf(u8, output, "London") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "+7.0°C") != null); try std.testing.expect(std.mem.indexOf(u8, output, "+7.0°C") != null);
@ -166,8 +185,12 @@ test "render custom format with newline" {
.allocator = allocator, .allocator = allocator,
}; };
const output = try render(allocator, weather, "%l%n%C", false); var output_buf: [1024]u8 = undefined;
defer allocator.free(output); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather, "%l%n%C", false);
const output = output_buf[0..writer.end];
try std.testing.expect(std.mem.indexOf(u8, output, "Paris\nClear") != null); try std.testing.expect(std.mem.indexOf(u8, output, "Paris\nClear") != null);
} }
@ -194,8 +217,12 @@ test "render custom format with humidity and pressure" {
.allocator = allocator, .allocator = allocator,
}; };
const output = try render(allocator, weather, "Humidity: %h, Pressure: %P", false); var output_buf: [1024]u8 = undefined;
defer allocator.free(output); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather, "Humidity: %h, Pressure: %P", false);
const output = output_buf[0..writer.end];
try std.testing.expect(std.mem.indexOf(u8, output, "85%") != null); try std.testing.expect(std.mem.indexOf(u8, output, "85%") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "1012") != null); try std.testing.expect(std.mem.indexOf(u8, output, "1012") != null);
@ -223,10 +250,119 @@ test "render custom format with imperial units" {
.allocator = allocator, .allocator = allocator,
}; };
const output = try render(allocator, weather, "%t %w %p", true); var output_buf: [1024]u8 = undefined;
defer allocator.free(output); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather, "%t %w %p", true);
const output = output_buf[0..writer.end];
try std.testing.expect(std.mem.indexOf(u8, output, "+50.0°F") != null); try std.testing.expect(std.mem.indexOf(u8, output, "+50.0°F") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "mph") != null); try std.testing.expect(std.mem.indexOf(u8, output, "mph") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "in") != null); try std.testing.expect(std.mem.indexOf(u8, output, "in") != null);
} }
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);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("+10.0°C", output);
}
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);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("🌗", output);
}
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);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("21", output);
}
test "render custom format with astronomical dawn" {
var test_weather_astro = test_weather;
test_weather_astro.coords = .{ .latitude = 45.0, .longitude = -122.0 };
var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather_astro, "%D", false);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("07:12", output);
}
test "render custom format with astronomical sunrise" {
var test_weather_astro = test_weather;
test_weather_astro.coords = .{ .latitude = 45.0, .longitude = -122.0 };
var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather_astro, "%S", false);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("07:45", output);
}
test "render custom format with astronomical zenith" {
var test_weather_astro = test_weather;
test_weather_astro.coords = .{ .latitude = 45.0, .longitude = -122.0 };
var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather_astro, "%z", false);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("12:14", output);
}
test "render custom format with astronomical sunset" {
var test_weather_astro = test_weather;
test_weather_astro.coords = .{ .latitude = 45.0, .longitude = -122.0 };
var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather_astro, "%s", false);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("16:44", output);
}
test "render custom format with astronomical dusk" {
var test_weather_astro = test_weather;
test_weather_astro.coords = .{ .latitude = 45.0, .longitude = -122.0 };
var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_weather_astro, "%d", false);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("17:17", output);
}
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);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("%", output);
}

View file

@ -1,6 +1,7 @@
const std = @import("std"); const std = @import("std");
const types = @import("../weather/types.zig"); const types = @import("../weather/types.zig");
const zeit = @import("zeit"); const zeit = @import("zeit");
const utils = @import("utils.zig");
/// Select 4 hours representing morning (6am), noon (12pm), evening (6pm), night (12am) in LOCAL time /// Select 4 hours representing morning (6am), noon (12pm), evening (6pm), night (12am) in LOCAL time
/// Hours in the hourly forecast are assumed to be all on the same day, in local time /// Hours in the hourly forecast are assumed to be all on the same day, in local time
@ -46,13 +47,6 @@ fn selectHourlyForecasts(all_hours: []const types.HourlyForecast, buf: []?types.
return selected.items; return selected.items;
} }
fn degreeToArrow(deg: f32) []const u8 {
const normalized = @mod(deg + 22.5, 360.0);
const idx: usize = @intFromFloat(normalized / 45.0);
const arrows = [_][]const u8{ "", "", "", "", "", "", "", "" };
return arrows[@min(idx, 7)];
}
pub const Format = enum { pub const Format = enum {
plain_text, plain_text,
ansi, ansi,
@ -106,11 +100,8 @@ pub const RenderOptions = struct {
format: Format = .ansi, format: Format = .ansi,
}; };
pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, options: RenderOptions) ![]const u8 { pub fn render(writer: *std.Io.Writer, data: types.WeatherData, options: RenderOptions) !void {
var output = std.Io.Writer.Allocating.init(allocator); const w = writer;
defer output.deinit();
const w = &output.writer;
if (options.format == .html) try w.writeAll("<pre>"); if (options.format == .html) try w.writeAll("<pre>");
if (!options.super_quiet) if (!options.super_quiet)
try w.print( try w.print(
@ -128,8 +119,6 @@ pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, options: Re
} }
} }
if (options.format == .html) try w.writeAll("</pre>"); if (options.format == .html) try w.writeAll("</pre>");
return output.toOwnedSlice();
} }
fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: RenderOptions) !void { fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: RenderOptions) !void {
@ -151,7 +140,7 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re
.plain_text => { .plain_text => {
try w.print("{s} {s}\n", .{ art[0], current.condition }); try w.print("{s} {s}\n", .{ art[0], current.condition });
try w.print("{s} {c}{d:.0}({c}{d:.0}) {s}\n", .{ art[1], sign, abs_temp, fl_sign, abs_fl, temp_unit }); try w.print("{s} {c}{d:.0}({c}{d:.0}) {s}\n", .{ art[1], sign, abs_temp, fl_sign, abs_fl, temp_unit });
try w.print("{s} {s} {d:.0} {s}\n", .{ art[2], degreeToArrow(current.wind_deg), wind_speed, wind_unit }); try w.print("{s} {s} {d:.0} {s}\n", .{ art[2], utils.degreeToArrow(current.wind_deg), wind_speed, wind_unit });
if (current.visibility_km) |_| { if (current.visibility_km) |_| {
const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?; const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?;
const vis_unit = if (options.use_imperial) "mi" else "km"; const vis_unit = if (options.use_imperial) "mi" else "km";
@ -168,7 +157,7 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re
try w.print("{s} {s}\n", .{ art[0], current.condition }); try w.print("{s} {s}\n", .{ art[0], current.condition });
try w.print("{s} \x1b[38;5;{d}m{c}{d:.0}({c}{d:.0}){s} {s}\n", .{ art[1], temp_color_code, sign, abs_temp, fl_sign, abs_fl, reset, temp_unit }); try w.print("{s} \x1b[38;5;{d}m{c}{d:.0}({c}{d:.0}){s} {s}\n", .{ art[1], temp_color_code, sign, abs_temp, fl_sign, abs_fl, reset, temp_unit });
try w.print("{s} {s} \x1b[38;5;{d}m{d:.0}{s} {s}\n", .{ art[2], degreeToArrow(current.wind_deg), wind_color_code, wind_speed, reset, wind_unit }); try w.print("{s} {s} \x1b[38;5;{d}m{d:.0}{s} {s}\n", .{ art[2], utils.degreeToArrow(current.wind_deg), wind_color_code, wind_speed, reset, wind_unit });
if (current.visibility_km) |_| { if (current.visibility_km) |_| {
const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?; const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?;
const vis_unit = if (options.use_imperial) "mi" else "km"; const vis_unit = if (options.use_imperial) "mi" else "km";
@ -184,7 +173,7 @@ fn renderCurrent(w: *std.Io.Writer, current: types.CurrentCondition, options: Re
try w.print("{s} {s}\n", .{ art[0], current.condition }); try w.print("{s} {s}\n", .{ art[0], current.condition });
try w.print("{s} <span style=\"color:{s}\">{c}{d:.0}({c}{d:.0})</span> {s}\n", .{ art[1], temp_color, sign, abs_temp, fl_sign, abs_fl, temp_unit }); try w.print("{s} <span style=\"color:{s}\">{c}{d:.0}({c}{d:.0})</span> {s}\n", .{ art[1], temp_color, sign, abs_temp, fl_sign, abs_fl, temp_unit });
try w.print("{s} {s} <span style=\"color:{s}\">{d:.0}</span> {s}\n", .{ art[2], degreeToArrow(current.wind_deg), wind_color, wind_speed, wind_unit }); try w.print("{s} {s} <span style=\"color:{s}\">{d:.0}</span> {s}\n", .{ art[2], utils.degreeToArrow(current.wind_deg), wind_color, wind_speed, wind_unit });
if (current.visibility_km) |_| { if (current.visibility_km) |_| {
const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?; const visibility = if (options.use_imperial) current.visiblityMph().? else current.visibility_km.?;
const vis_unit = if (options.use_imperial) "mi" else "km"; const vis_unit = if (options.use_imperial) "mi" else "km";
@ -334,7 +323,7 @@ fn renderHourlyCell(w: *std.Io.Writer, hour: types.HourlyForecast, line: usize,
.wind => { .wind => {
const wind_speed = if (options.use_imperial) hour.windMph() else hour.wind_kph; const wind_speed = if (options.use_imperial) hour.windMph() else hour.wind_kph;
const wind_unit = if (options.use_imperial) "mph" else "km/h"; const wind_unit = if (options.use_imperial) "mph" else "km/h";
const arrow = degreeToArrow(hour.wind_deg); const arrow = utils.degreeToArrow(hour.wind_deg);
switch (options.format) { switch (options.format) {
.ansi => { .ansi => {
const color = windColor(hour.wind_kph); const color = windColor(hour.wind_kph);
@ -678,8 +667,12 @@ test "render with imperial units" {
.allocator = std.testing.allocator, .allocator = std.testing.allocator,
}; };
const output = try render(std.testing.allocator, data, .{ .use_imperial = true }); var output_buf: [4096]u8 = undefined;
defer std.testing.allocator.free(output); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, data, .{ .use_imperial = true });
const output = output_buf[0..writer.end];
try std.testing.expect(std.mem.indexOf(u8, output, "+50") != null); try std.testing.expect(std.mem.indexOf(u8, output, "+50") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "°F") != null); try std.testing.expect(std.mem.indexOf(u8, output, "°F") != null);
@ -737,12 +730,12 @@ test "partly cloudy weather art" {
fn testArt(data: types.WeatherData) !void { fn testArt(data: types.WeatherData) !void {
inline for (std.meta.fields(Format)) |f| { inline for (std.meta.fields(Format)) |f| {
const format: Format = @enumFromInt(f.value); const format: Format = @enumFromInt(f.value);
const output = try render( var output_buf: [8192]u8 = undefined;
std.testing.allocator, var writer = std.Io.Writer.fixed(&output_buf);
data,
.{ .format = format }, try render(&writer, data, .{ .format = format });
);
defer std.testing.allocator.free(output); const output = output_buf[0..writer.end];
const target = getWeatherArt( const target = getWeatherArt(
data.current.weather_code, data.current.weather_code,
@ -944,15 +937,23 @@ test "temperature matches between ansi and custom format" {
.allocator = std.testing.allocator, .allocator = std.testing.allocator,
}; };
const ansi_output = try render(std.testing.allocator, data, .{ .use_imperial = true }); var ansi_buf: [4096]u8 = undefined;
defer std.testing.allocator.free(ansi_output); var ansi_writer = std.Io.Writer.fixed(&ansi_buf);
const custom_output = try custom.render(std.testing.allocator, data, "%t", true); try render(&ansi_writer, data, .{ .use_imperial = true });
defer std.testing.allocator.free(custom_output);
const ansi_output = ansi_buf[0..ansi_writer.end];
var custom_buf: [1024]u8 = undefined;
var custom_writer = std.Io.Writer.fixed(&custom_buf);
try custom.render(&custom_writer, data, "%t", true);
const output = custom_buf[0..custom_writer.end];
// ANSI rounds to integer, custom shows decimal // ANSI rounds to integer, custom shows decimal
try std.testing.expect(std.mem.indexOf(u8, ansi_output, "+56") != null); try std.testing.expect(std.mem.indexOf(u8, ansi_output, "+56") != null);
try std.testing.expect(std.mem.indexOf(u8, custom_output, "55.6°F") != null); try std.testing.expect(std.mem.indexOf(u8, output, "55.6°F") != null);
} }
test "tempColor returns correct colors for temperature ranges" { test "tempColor returns correct colors for temperature ranges" {
@ -1037,8 +1038,12 @@ test "plain text format - MetNo real data" {
const weather_data = try MetNo.parse(undefined, allocator, json_data); const weather_data = try MetNo.parse(undefined, allocator, json_data);
defer weather_data.deinit(); defer weather_data.deinit();
const output = try render(allocator, weather_data, .{ .format = .plain_text, .days = 3 }); var output_buf: [8192]u8 = undefined;
defer allocator.free(output); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather_data, .{ .format = .plain_text, .days = 3 });
const output = output_buf[0..writer.end];
const expected = const expected =
\\Weather report: 47.6038,-122.3301 \\Weather report: 47.6038,-122.3301
@ -1245,8 +1250,12 @@ test "ansi format - MetNo real data - phoenix" {
const weather_data = try MetNo.parse(undefined, allocator, json_data); const weather_data = try MetNo.parse(undefined, allocator, json_data);
defer weather_data.deinit(); defer weather_data.deinit();
const output = try render(allocator, weather_data, .{ .format = .ansi, .days = 3, .use_imperial = true }); var output_buf: [16384]u8 = undefined;
defer allocator.free(output); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather_data, .{ .format = .ansi, .days = 3, .use_imperial = true });
const output = output_buf[0..writer.end];
const expected = @embedFile("../tests/metno-phoenix.ansi"); const expected = @embedFile("../tests/metno-phoenix.ansi");

View file

@ -1,7 +1,7 @@
const std = @import("std"); const std = @import("std");
const types = @import("../weather/types.zig"); const types = @import("../weather/types.zig");
pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData) ![]const u8 { pub fn render(writer: *std.Io.Writer, weather: types.WeatherData) !void {
const data = .{ const data = .{
.current_condition = .{ .current_condition = .{
.temp_C = weather.current.temp_c, .temp_C = weather.current.temp_c,
@ -16,7 +16,7 @@ pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData) ![]const
.weather = weather.forecast, .weather = weather.forecast,
}; };
return try std.fmt.allocPrint(allocator, "{f}", .{std.json.fmt(data, .{})}); try writer.print("{f}", .{std.json.fmt(data, .{})});
} }
test "render json format" { test "render json format" {
@ -41,8 +41,12 @@ test "render json format" {
.allocator = allocator, .allocator = allocator,
}; };
const output = try render(allocator, weather); var output_buf: [4096]u8 = undefined;
defer allocator.free(output); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather);
const output = output_buf[0..writer.end];
try std.testing.expect(output.len > 0); try std.testing.expect(output.len > 0);
try std.testing.expect(std.mem.indexOf(u8, output, "temp_C") != null); try std.testing.expect(std.mem.indexOf(u8, output, "temp_C") != null);

View file

@ -3,195 +3,146 @@ const types = @import("../weather/types.zig");
const emoji = @import("emoji.zig"); const emoji = @import("emoji.zig");
const utils = @import("utils.zig"); const utils = @import("utils.zig");
pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, format: []const u8, use_imperial: bool) ![]const u8 { const Format = enum(u3) {
if (std.mem.eql(u8, format, "1")) { @"1" = 1,
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c; @"2" = 2,
const unit = if (use_imperial) "°F" else "°C"; @"3" = 3,
return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s}", .{ @"4" = 4,
data.location, };
emoji.getWeatherEmoji(data.current.weather_code),
temp, pub fn render(writer: *std.Io.Writer, data: types.WeatherData, format: Format, use_imperial: bool) !void {
unit,
});
} else if (std.mem.eql(u8, format, "2")) {
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c; const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c;
const unit = if (use_imperial) "°F" else "°C"; const unit = if (use_imperial) "°F" else "°C";
const sign: []const u8 = if (temp >= 0) "+" else if (temp < 0) "-" else "";
const abs_temp = @abs(temp);
const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph; const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph;
const wind_unit = if (use_imperial) "mph" else "km/h"; const wind_unit = if (use_imperial) "mph" else "km/h";
return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s} {s}{s}{d:.0}{s}", .{
data.location, switch (format) {
.@"1" => {
try writer.print("{s} {s}{d:.0}{s}", .{
emoji.getWeatherEmoji(data.current.weather_code), emoji.getWeatherEmoji(data.current.weather_code),
temp, sign,
abs_temp,
unit, unit,
"🌬️", });
utils.degreeToDirection(data.current.wind_deg), },
.@"2" => {
try writer.print("{s} 🌡️{s}{d:.0}{s} 🌬️{s}{d:.0}{s}", .{
emoji.getWeatherEmoji(data.current.weather_code),
sign,
abs_temp,
unit,
utils.degreeToArrow(data.current.wind_deg),
wind, wind,
wind_unit, wind_unit,
}); });
} else if (std.mem.eql(u8, format, "3")) { },
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c; .@"3" => {
const unit = if (use_imperial) "°F" else "°C"; try writer.print("{s}: {s} {s}{d:.0}{s}", .{
const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph;
const wind_unit = if (use_imperial) "mph" else "km/h";
return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s} {s}{s}{d:.0}{s} {s}{d}%%", .{
data.location, data.location,
emoji.getWeatherEmoji(data.current.weather_code), emoji.getWeatherEmoji(data.current.weather_code),
temp, sign,
abs_temp,
unit, unit,
"🌬️",
utils.degreeToDirection(data.current.wind_deg),
wind,
wind_unit,
"💧",
data.current.humidity,
}); });
} else if (std.mem.eql(u8, format, "4")) { },
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c; .@"4" => {
const unit = if (use_imperial) "°F" else "°C"; try writer.print("{s}: {s} 🌡️{s}{d:.0}{s} 🌬️{s}{d:.0}{s}", .{
const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph;
const wind_unit = if (use_imperial) "mph" else "km/h";
return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s} {s}{s}{d:.0}{s} {s}{d}%% {s}", .{
data.location, data.location,
emoji.getWeatherEmoji(data.current.weather_code), emoji.getWeatherEmoji(data.current.weather_code),
temp, sign,
abs_temp,
unit, unit,
"🌬️", utils.degreeToArrow(data.current.wind_deg),
utils.degreeToDirection(data.current.wind_deg),
wind, wind,
wind_unit, wind_unit,
"💧",
data.current.humidity,
"☀️",
}); });
} else { },
return renderCustom(allocator, data, format, use_imperial);
} }
} }
fn renderCustom(allocator: std.mem.Allocator, data: types.WeatherData, format: []const u8, use_imperial: bool) ![]const u8 { const test_data = types.WeatherData{
var output: std.ArrayList(u8) = .empty; .location = "London",
errdefer output.deinit(allocator); .coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
var i: usize = 0; .temp_c = 15.0,
while (i < format.len) { .feels_like_c = 15.0,
if (format[i] == '%' and i + 1 < format.len) { .condition = "Clear",
const code = format[i + 1]; .weather_code = .clear,
switch (code) { .humidity = 65,
'c' => try output.appendSlice(allocator, emoji.getWeatherEmoji(data.current.weather_code)), .wind_kph = 10.0,
'C' => try output.appendSlice(allocator, data.current.condition), .wind_deg = 0.0,
'h' => try output.writer(allocator).print("{d}", .{data.current.humidity}), .pressure_mb = 1013.0,
't' => { .precip_mm = 0.0,
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c; .visibility_km = null,
try output.writer(allocator).print("{d:.0}", .{temp});
}, },
'f' => { .forecast = &.{},
const temp = if (use_imperial) data.current.tempFahrenheit() else data.current.temp_c; .allocator = std.testing.allocator,
try output.writer(allocator).print("{d:.0}", .{temp}); };
},
'w' => {
const wind = if (use_imperial) data.current.windMph() else data.current.wind_kph;
const wind_unit = if (use_imperial) "mph" else "km/h";
try output.writer(allocator).print("{s}{d:.0}{s}", .{ utils.degreeToDirection(data.current.wind_deg), wind, wind_unit });
},
'l' => try output.appendSlice(allocator, data.location),
'p' => {
const precip = if (use_imperial) data.current.precip_mm * 0.0393701 else data.current.precip_mm;
try output.writer(allocator).print("{d:.1}", .{precip});
},
'P' => {
const pressure = if (use_imperial) data.current.pressure_mb * 0.02953 else data.current.pressure_mb;
try output.writer(allocator).print("{d:.0}", .{pressure});
},
'%' => try output.append(allocator, '%'),
else => {
try output.append(allocator, '%');
try output.append(allocator, code);
},
}
i += 2;
} else {
try output.append(allocator, format[i]);
i += 1;
}
}
return output.toOwnedSlice(allocator);
}
test "format 1" { test "format 1" {
const data = types.WeatherData{ var output_buf: [1024]u8 = undefined;
.location = "London", var writer = std.Io.Writer.fixed(&output_buf);
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 15.0,
.feels_like_c = 15.0,
.condition = "Clear",
.weather_code = .clear,
.humidity = 65,
.wind_kph = 10.0,
.wind_deg = 0.0,
.pressure_mb = 1013.0,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
const output = try render(std.testing.allocator, data, "1", false); try render(&writer, test_data, .@"1", false);
defer std.testing.allocator.free(output);
try std.testing.expectEqualStrings("London: ☀️ 15°C", output); const output = output_buf[0..writer.end];
}
test "custom format" { try std.testing.expectEqualStrings("☀️ +15°C", output);
const data = types.WeatherData{
.location = "London",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 15.0,
.feels_like_c = 15.0,
.condition = "Clear",
.weather_code = .clear,
.humidity = 65,
.wind_kph = 10.0,
.wind_deg = 0.0,
.pressure_mb = 1013.0,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
const output = try render(std.testing.allocator, data, "%l: %c %t°C", false);
defer std.testing.allocator.free(output);
try std.testing.expectEqualStrings("London: ☀️ 15°C", output);
} }
test "format 2 with imperial units" { test "format 2 with imperial units" {
const data = types.WeatherData{ var output_buf: [1024]u8 = undefined;
.location = "Portland", var writer = std.Io.Writer.fixed(&output_buf);
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 10.0,
.feels_like_c = 10.0,
.condition = "Cloudy",
.weather_code = .clouds_overcast,
.humidity = 70,
.wind_kph = 20.0,
.wind_deg = 135.0,
.pressure_mb = 1013.0,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = &.{},
.allocator = std.testing.allocator,
};
const output = try render(std.testing.allocator, data, "2", true); try render(&writer, test_data, .@"2", true);
defer std.testing.allocator.free(output);
try std.testing.expectEqualStrings("Portland: ☁️ 50°F 🌬SE12mph", output); const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("☀️ 🌡️+59°F 🌬↓6mph", output);
}
test "format 3 with metric units" {
var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_data, .@"3", false);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("London: ☀️ +15°C", output);
}
test "format 3 with imperial units" {
var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_data, .@"3", true);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("London: ☀️ +59°F", output);
}
test "format 4 with metric units" {
var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_data, .@"4", false);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("London: ☀️ 🌡️+15°C 🌬↓10km/h", output);
}
test "format 4 with imperial units" {
var output_buf: [1024]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, test_data, .@"4", true);
const output = output_buf[0..writer.end];
try std.testing.expectEqualStrings("London: ☀️ 🌡️+59°F 🌬↓6mph", output);
} }

193
src/render/Prometheus.zig Normal file
View file

@ -0,0 +1,193 @@
const std = @import("std");
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 {
// Current conditions
try writer.print("# HELP temperature_feels_like_celsius Feels Like Temperature in Celsius\n", .{});
try writer.print("temperature_feels_like_celsius{{forecast=\"current\"}} {d}\n", .{weather.current.feels_like_c});
try writer.print("# HELP temperature_feels_like_fahrenheit Feels Like Temperature in Fahrenheit\n", .{});
try writer.print("temperature_feels_like_fahrenheit{{forecast=\"current\"}} {d}\n", .{weather.current.tempFahrenheit()});
try writer.print("# HELP cloudcover_percentage Cloud Coverage in Percent\n", .{});
try writer.print("cloudcover_percentage{{forecast=\"current\"}} 0\n", .{}); // Not in our data
try writer.print("# HELP humidity_percentage Humidity in Percent\n", .{});
try writer.print("humidity_percentage{{forecast=\"current\"}} {d}\n", .{weather.current.humidity});
try writer.print("# HELP precipitation_mm Precipitation (Rainfall) in mm\n", .{});
try writer.print("precipitation_mm{{forecast=\"current\"}} {d:.1}\n", .{weather.current.precip_mm});
try writer.print("# HELP pressure_hpa Air pressure in hPa\n", .{});
try writer.print("pressure_hpa{{forecast=\"current\"}} {d}\n", .{weather.current.pressure_mb});
try writer.print("# HELP temperature_celsius Temperature in Celsius\n", .{});
try writer.print("temperature_celsius{{forecast=\"current\"}} {d}\n", .{weather.current.temp_c});
try writer.print("# HELP temperature_fahrenheit Temperature in Fahrenheit\n", .{});
try writer.print("temperature_fahrenheit{{forecast=\"current\"}} {d}\n", .{weather.current.tempFahrenheit()});
try writer.print("# HELP uv_index Ultraviolet Radiation Index\n", .{});
try writer.print("uv_index{{forecast=\"current\"}} 0\n", .{}); // Not in our data
if (weather.current.visibility_km) |vis| {
try writer.print("# HELP visibility Visible Distance in Kilometres\n", .{});
try writer.print("visibility{{forecast=\"current\"}} {d}\n", .{vis});
}
try writer.print("# HELP weather_code Code to describe Weather Condition\n", .{});
try writer.print("weather_code{{forecast=\"current\"}} {d}\n", .{@intFromEnum(weather.current.weather_code)});
try writer.print("# HELP winddir_degree Wind Direction in Degree\n", .{});
try writer.print("winddir_degree{{forecast=\"current\"}} {d}\n", .{weather.current.wind_deg});
try writer.print("# HELP windspeed_kmph Wind Speed in Kilometres per Hour\n", .{});
try writer.print("windspeed_kmph{{forecast=\"current\"}} {d}\n", .{weather.current.wind_kph});
try writer.print("# HELP windspeed_mph Wind Speed in Miles per Hour\n", .{});
try writer.print("windspeed_mph{{forecast=\"current\"}} {d}\n", .{weather.current.windMph()});
try writer.print("# HELP observation_time Minutes since start of the day the observation happened\n", .{});
try writer.print("observation_time{{forecast=\"current\"}} 0\n", .{}); // Not tracked
try writer.print("# HELP weather_desc Weather Description\n", .{});
try writer.print("weather_desc{{forecast=\"current\", description=\"{s}\"}} 1\n", .{weather.current.condition});
try writer.print("# HELP winddir_16_point Wind Direction on a 16-wind compass rose\n", .{});
const wind_dir = utils.degreeToDirection(weather.current.wind_deg);
try writer.print("winddir_16_point{{forecast=\"current\", description=\"{s}\"}} 1\n", .{wind_dir});
// Forecast days
for (weather.forecast, 0..) |day, i| {
var buf: [16]u8 = undefined;
const forecast_label = try std.fmt.bufPrint(&buf, "{d}d", .{i});
try writer.print("uv_index{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not in our data
try writer.print("# HELP temperature_celsius_maximum Maximum Temperature in Celsius\n", .{});
try writer.print("temperature_celsius_maximum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.max_temp_c });
try writer.print("# HELP temperature_fahrenheit_maximum Maximum Temperature in Fahrenheit\n", .{});
try writer.print("temperature_fahrenheit_maximum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.maxTempFahrenheit() });
try writer.print("# HELP temperature_celsius_minimum Minimum Temperature in Celsius\n", .{});
try writer.print("temperature_celsius_minimum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.min_temp_c });
try writer.print("# HELP temperature_fahrenheit_minimum Minimum Temperature in Fahrenheit\n", .{});
try writer.print("temperature_fahrenheit_minimum{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, day.minTempFahrenheit() });
try writer.print("# HELP sun_hour Hours of sunlight\n", .{});
try writer.print("sun_hour{{forecast=\"{s}\"}} 0.0\n", .{forecast_label}); // Not calculated
try writer.print("# HELP snowfall_cm Total snowfall in cm\n", .{});
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);
try writer.print("# HELP astronomy_moon_illumination Percentage of the moon illuminated\n", .{});
try writer.print("astronomy_moon_illumination{{forecast=\"{s}\"}} {d}\n", .{ forecast_label, moon.illuminated * 100 });
try writer.print("# HELP astronomy_moon_phase Phase of the moon\n", .{});
try writer.print("astronomy_moon_phase{{forecast=\"{s}\", description=\"{f}\"}} 1\n", .{ forecast_label, moon });
try writer.print("# HELP astronomy_moonrise_min Minutes since start of the day until the moon appears above the horizon\n", .{});
try writer.print("astronomy_moonrise_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated
try writer.print("# HELP astronomy_moonset_min Minutes since start of the day until the moon disappears below the horizon\n", .{});
try writer.print("astronomy_moonset_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated
try writer.print("# HELP astronomy_sunrise_min Minutes since start of the day until the sun appears above the horizon\n", .{});
try writer.print("astronomy_sunrise_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated
try writer.print("# HELP astronomy_sunset_min Minutes since start of the day until the moon disappears below the horizon\n", .{});
try writer.print("astronomy_sunset_min{{forecast=\"{s}\"}} 0\n", .{forecast_label}); // Not calculated
}
}
test "prometheus format includes required metrics" {
const allocator = std.testing.allocator;
var forecast_days = [_]types.ForecastDay{
.{
.date = .{ .year = 2024, .month = .jan, .day = 1 },
.max_temp_c = 12.0,
.min_temp_c = 5.0,
.condition = "Partly cloudy",
.weather_code = .clouds_scattered,
.hourly = &[_]types.HourlyForecast{},
},
};
const weather = types.WeatherData{
.location = "London",
.coords = .{ .latitude = 51.5074, .longitude = -0.1278 },
.current = .{
.temp_c = 10.0,
.feels_like_c = 8.0,
.condition = "Partly cloudy",
.weather_code = .clouds_scattered,
.humidity = 75,
.wind_kph = 15.0,
.wind_deg = 225.0,
.pressure_mb = 1013.0,
.precip_mm = 0.5,
.visibility_km = 10.0,
},
.forecast = &forecast_days,
.allocator = allocator,
};
var output_buf: [8192]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather);
const output = output_buf[0..writer.end];
// Check for key metrics
try std.testing.expect(std.mem.indexOf(u8, output, "temperature_celsius{forecast=\"current\"}") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "humidity_percentage{forecast=\"current\"}") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "windspeed_kmph{forecast=\"current\"}") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "temperature_celsius_maximum{forecast=\"0d\"}") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "astronomy_moon_illumination{forecast=\"0d\"}") != null);
}
test "prometheus format has proper help comments" {
const allocator = std.testing.allocator;
const weather = types.WeatherData{
.location = "Test",
.coords = .{ .latitude = 0, .longitude = 0 },
.current = .{
.temp_c = 20.0,
.feels_like_c = 20.0,
.condition = "Clear",
.weather_code = .clear,
.humidity = 50,
.wind_kph = 10.0,
.wind_deg = 0.0,
.pressure_mb = 1000.0,
.precip_mm = 0.0,
.visibility_km = null,
},
.forecast = &[_]types.ForecastDay{},
.allocator = allocator,
};
var output_buf: [4096]u8 = undefined;
var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather);
const output = output_buf[0..writer.end];
// Check for HELP comments
try std.testing.expect(std.mem.indexOf(u8, output, "# HELP temperature_celsius Temperature in Celsius") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "# HELP humidity_percentage Humidity in Percent") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "# HELP pressure_hpa Air pressure in hPa") != null);
}

View file

@ -2,11 +2,7 @@ const std = @import("std");
const types = @import("../weather/types.zig"); const types = @import("../weather/types.zig");
const utils = @import("utils.zig"); const utils = @import("utils.zig");
pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, use_imperial: bool) ![]const u8 { pub fn render(writer: *std.Io.Writer, weather: types.WeatherData, use_imperial: bool) !void {
var output: std.ArrayList(u8) = .empty;
errdefer output.deinit(allocator);
const writer = output.writer(allocator);
// Header with location // Header with location
try writer.print("Weather report: {s}\n\n", .{weather.locationDisplayName()}); try writer.print("Weather report: {s}\n\n", .{weather.locationDisplayName()});
@ -62,8 +58,6 @@ pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, use_impe
} }
} }
} }
return output.toOwnedSlice(allocator);
} }
test "render v2 format" { test "render v2 format" {
@ -88,8 +82,12 @@ test "render v2 format" {
.allocator = allocator, .allocator = allocator,
}; };
const output = try render(allocator, weather, false); var output_buf: [2048]u8 = undefined;
defer allocator.free(output); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather, false);
const output = output_buf[0..writer.end];
try std.testing.expect(output.len > 0); try std.testing.expect(output.len > 0);
try std.testing.expect(std.mem.indexOf(u8, output, "Munich") != null); try std.testing.expect(std.mem.indexOf(u8, output, "Munich") != null);
@ -119,8 +117,12 @@ test "render v2 format with imperial units" {
.allocator = allocator, .allocator = allocator,
}; };
const output = try render(allocator, weather, true); var output_buf: [2048]u8 = undefined;
defer allocator.free(output); var writer = std.Io.Writer.fixed(&output_buf);
try render(&writer, weather, true);
const output = output_buf[0..writer.end];
try std.testing.expect(std.mem.indexOf(u8, output, "50.0°F") != null); try std.testing.expect(std.mem.indexOf(u8, output, "50.0°F") != null);
try std.testing.expect(std.mem.indexOf(u8, output, "mph") != null); try std.testing.expect(std.mem.indexOf(u8, output, "mph") != null);

View file

@ -3,11 +3,11 @@ const types = @import("../weather/types.zig");
pub fn getWeatherEmoji(code: types.WeatherCode) []const u8 { pub fn getWeatherEmoji(code: types.WeatherCode) []const u8 {
return switch (@intFromEnum(code)) { return switch (@intFromEnum(code)) {
200...232 => "⛈️", // Thunderstorm 200...232 => "⛈️", // Thunderstorm
300...321 => "🌦", // Drizzle 300...321 => "🌦", // Drizzle
500...531 => "🌧️", // Rain 500...531 => "🌧️", // Rain
600...610, 617...622 => "🌨", // Snow 600...610, 617...622 => "", // Snow
611...616 => "🌧", // Sleet 611...616 => "🌨️", // Sleet
701, 741 => "🌁", // Mist/fog. Also could be 🌫 701, 741 => "🌁", // Mist/fog. Also could be 🌫 / 🌫
800 => "☀️", // Clear 800 => "☀️", // Clear
801, 802 => "⛅️", // Few/scattered clouds 801, 802 => "⛅️", // Few/scattered clouds
803, 804 => "☁️", // Broken/overcast clouds 803, 804 => "☁️", // Broken/overcast clouds

View file

@ -7,6 +7,13 @@ pub fn degreeToDirection(deg: f32) []const u8 {
return directions[@min(idx, 7)]; return directions[@min(idx, 7)];
} }
pub fn degreeToArrow(deg: f32) []const u8 {
const normalized = @mod(deg + 22.5, 360.0);
const idx: usize = @intFromFloat(normalized / 45.0);
const arrows = [_][]const u8{ "", "", "", "", "", "", "", "" };
return arrows[@min(idx, 7)];
}
test "degreeToDirection" { test "degreeToDirection" {
try std.testing.expectEqualStrings("N", degreeToDirection(0)); try std.testing.expectEqualStrings("N", degreeToDirection(0));
try std.testing.expectEqualStrings("NE", degreeToDirection(45)); try std.testing.expectEqualStrings("NE", degreeToDirection(45));