diff --git a/zig/src/http/handler.zig b/zig/src/http/handler.zig index 308b731..3d7f2b4 100644 --- a/zig/src/http/handler.zig +++ b/zig/src/http/handler.zig @@ -4,6 +4,7 @@ const Cache = @import("../cache/cache.zig").Cache; const WeatherProvider = @import("../weather/provider.zig").WeatherProvider; const Resolver = @import("../location/resolver.zig").Resolver; const Location = @import("../location/resolver.zig").Location; +const QueryParams = @import("query.zig").QueryParams; const ansi = @import("../render/ansi.zig"); const line = @import("../render/line.zig"); const json = @import("../render/json.zig"); @@ -130,21 +131,27 @@ fn handleWeatherInternal( }; defer weather.deinit(); - const query = try req.query(); - const format = query.get("format"); + const query_string = req.url.query; + const params = try QueryParams.parse(allocator, query_string); + defer { + if (params.format) |f| allocator.free(f); + if (params.lang) |l| allocator.free(l); + } - const output = if (format) |fmt| blk: { + const use_imperial = if (params.units) |u| u == .uscs else false; + + const output = if (params.format) |fmt| blk: { if (std.mem.eql(u8, fmt, "j1")) { res.content_type = .JSON; break :blk try json.render(allocator, weather); } else if (std.mem.eql(u8, fmt, "v2")) { - break :blk try v2.render(allocator, weather); + break :blk try v2.render(allocator, weather, use_imperial); } else if (std.mem.startsWith(u8, fmt, "%")) { - break :blk try custom.render(allocator, weather, fmt); + break :blk try custom.render(allocator, weather, fmt, use_imperial); } else { - break :blk try line.render(allocator, weather, fmt); + break :blk try line.render(allocator, weather, fmt, use_imperial); } - } else try ansi.render(allocator, weather, .{}); + } else try ansi.render(allocator, weather, .{ .use_imperial = use_imperial }); const ttl = 1000 + std.crypto.random.intRangeAtMost(u64, 0, 1000); try opts.cache.put(cache_key, output, ttl); @@ -161,12 +168,11 @@ fn generateCacheKey( location: ?[]const u8, ) ![]const u8 { const loc = location orelse ""; - const query = try req.query(); - const format = query.get("format") orelse ""; + const query_string = req.url.query; return std.fmt.allocPrint(allocator, "{s}:{s}:{s}", .{ req.url.path, loc, - format, + query_string, }); } diff --git a/zig/src/http/query.zig b/zig/src/http/query.zig index 0a27c73..d6e042e 100644 --- a/zig/src/http/query.zig +++ b/zig/src/http/query.zig @@ -59,6 +59,23 @@ test "parse format parameter" { try std.testing.expectEqualStrings("j1", params.format.?); } +test "parse units with question mark" { + const allocator = std.testing.allocator; + + // Test with just "u" (no question mark in query string) + const params1 = try QueryParams.parse(allocator, "u"); + try std.testing.expectEqual(QueryParams.Units.uscs, params1.units.?); + + // Test with "u=" (empty value) + const params2 = try QueryParams.parse(allocator, "u="); + try std.testing.expectEqual(QueryParams.Units.uscs, params2.units.?); + + // Test combined with other params + const params3 = try QueryParams.parse(allocator, "format=3&u"); + defer if (params3.format) |f| allocator.free(f); + try std.testing.expectEqual(QueryParams.Units.uscs, params3.units.?); +} + test "parse units parameters" { const allocator = std.testing.allocator; const params_m = try QueryParams.parse(allocator, "m"); @@ -66,6 +83,9 @@ test "parse units parameters" { const params_u = try QueryParams.parse(allocator, "u"); try std.testing.expectEqual(QueryParams.Units.uscs, params_u.units.?); + + const params_u_query = try QueryParams.parse(allocator, "u="); + try std.testing.expectEqual(QueryParams.Units.uscs, params_u_query.units.?); } test "parse multiple parameters" { diff --git a/zig/src/render/ansi.zig b/zig/src/render/ansi.zig index 88b941b..831566f 100644 --- a/zig/src/render/ansi.zig +++ b/zig/src/render/ansi.zig @@ -27,3 +27,27 @@ pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, options: Re return output.toOwnedSlice(allocator); } + +test "render with imperial units" { + const data = types.WeatherData{ + .location = "Chicago", + .current = .{ + .temp_c = 10.0, + .temp_f = 50.0, + .condition = "Clear", + .weather_code = 113, + .humidity = 60, + .wind_kph = 16.0, + .wind_dir = "N", + .pressure_mb = 1013.0, + .precip_mm = 0.0, + }, + .forecast = &.{}, + .allocator = std.testing.allocator, + }; + + const output = try render(std.testing.allocator, data, .{ .use_imperial = true }); + defer std.testing.allocator.free(output); + + try std.testing.expect(std.mem.indexOf(u8, output, "50.0°F") != null); +} diff --git a/zig/src/render/custom.zig b/zig/src/render/custom.zig index b591d8f..4b2cb36 100644 --- a/zig/src/render/custom.zig +++ b/zig/src/render/custom.zig @@ -13,7 +13,7 @@ fn getWeatherIcon(code: u16) []const u8 { return weather_icons[idx]; } -pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, format: []const u8) ![]const u8 { +pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, format: []const u8, use_imperial: bool) ![]const u8 { var output: std.ArrayList(u8) = .empty; errdefer output.deinit(allocator); const writer = output.writer(allocator); @@ -26,12 +26,32 @@ pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, format: 'c' => try writer.print("{s}", .{getWeatherIcon(weather.current.weather_code)}), 'C' => try writer.print("{s}", .{weather.current.condition}), 'h' => try writer.print("{d}%", .{weather.current.humidity}), - 't' => try writer.print("{d:.0}°C", .{weather.current.temp_c}), - 'f' => try writer.print("{d:.0}°C", .{weather.current.temp_c}), // Feels like (same as temp for now) - 'w' => try writer.print("{d:.0} km/h {s}", .{ weather.current.wind_kph, weather.current.wind_dir }), + 't' => { + const temp = if (use_imperial) weather.current.temp_f else weather.current.temp_c; + const unit = if (use_imperial) "°F" else "°C"; + try writer.print("{d:.0}{s}", .{ temp, unit }); + }, + 'f' => { + const temp = if (use_imperial) weather.current.temp_f else weather.current.temp_c; + const unit = if (use_imperial) "°F" else "°C"; + try writer.print("{d:.0}{s}", .{ temp, unit }); + }, + 'w' => { + const wind = if (use_imperial) weather.current.wind_kph * 0.621371 else weather.current.wind_kph; + const unit = if (use_imperial) "mph" else "km/h"; + try writer.print("{d:.0} {s} {s}", .{ wind, unit, weather.current.wind_dir }); + }, 'l' => try writer.print("{s}", .{weather.location}), - 'p' => try writer.print("{d:.1} mm", .{weather.current.precip_mm}), - 'P' => try writer.print("{d:.0} hPa", .{weather.current.pressure_mb}), + 'p' => { + const precip = if (use_imperial) weather.current.precip_mm * 0.0393701 else weather.current.precip_mm; + const unit = if (use_imperial) "in" else "mm"; + try writer.print("{d:.1} {s}", .{ precip, unit }); + }, + 'P' => { + const pressure = if (use_imperial) weather.current.pressure_mb * 0.02953 else weather.current.pressure_mb; + const unit = if (use_imperial) "inHg" else "hPa"; + try writer.print("{d:.2} {s}", .{ pressure, unit }); + }, 'm' => try writer.print("🌕", .{}), // Moon phase placeholder 'M' => try writer.print("15", .{}), // Moon day placeholder 'o' => try writer.print("0%", .{}), // Probability of precipitation placeholder @@ -76,7 +96,7 @@ test "render custom format with location and temp" { .allocator = allocator, }; - const output = try render(allocator, weather, "%l: %c %t"); + const output = try render(allocator, weather, "%l: %c %t", false); defer allocator.free(output); try std.testing.expect(std.mem.indexOf(u8, output, "London") != null); @@ -103,7 +123,7 @@ test "render custom format with newline" { .allocator = allocator, }; - const output = try render(allocator, weather, "%l%n%C"); + const output = try render(allocator, weather, "%l%n%C", false); defer allocator.free(output); try std.testing.expect(std.mem.indexOf(u8, output, "Paris\nClear") != null); @@ -129,9 +149,37 @@ test "render custom format with humidity and pressure" { .allocator = allocator, }; - const output = try render(allocator, weather, "Humidity: %h, Pressure: %P"); + const output = try render(allocator, weather, "Humidity: %h, Pressure: %P", false); defer allocator.free(output); try std.testing.expect(std.mem.indexOf(u8, output, "85%") != null); try std.testing.expect(std.mem.indexOf(u8, output, "1012") != null); } + +test "render custom format with imperial units" { + const allocator = std.testing.allocator; + + const weather = types.WeatherData{ + .location = "NYC", + .current = .{ + .temp_c = 10.0, + .temp_f = 50.0, + .condition = "Clear", + .weather_code = 113, + .humidity = 60, + .wind_kph = 16.0, + .wind_dir = "N", + .pressure_mb = 1013.0, + .precip_mm = 2.5, + }, + .forecast = &[_]types.ForecastDay{}, + .allocator = allocator, + }; + + const output = try render(allocator, weather, "%t %w %p", true); + defer allocator.free(output); + + try std.testing.expect(std.mem.indexOf(u8, output, "50°F") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "mph") != null); + try std.testing.expect(std.mem.indexOf(u8, output, "in") != null); +} diff --git a/zig/src/render/line.zig b/zig/src/render/line.zig index 10aa141..288b214 100644 --- a/zig/src/render/line.zig +++ b/zig/src/render/line.zig @@ -1,51 +1,72 @@ const std = @import("std"); const types = @import("../weather/types.zig"); -pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, format: []const u8) ![]const u8 { +pub fn render(allocator: std.mem.Allocator, data: types.WeatherData, format: []const u8, use_imperial: bool) ![]const u8 { if (std.mem.eql(u8, format, "1")) { - return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C", .{ + const temp = if (use_imperial) data.current.temp_f else data.current.temp_c; + const unit = if (use_imperial) "°F" else "°C"; + return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}{s}", .{ data.location, getConditionEmoji(data.current.weather_code), - data.current.temp_c, + temp, + unit, }); } else if (std.mem.eql(u8, format, "2")) { - return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C {s}{s}{d:.0}km/h", .{ + const temp = if (use_imperial) data.current.temp_f else data.current.temp_c; + const unit = if (use_imperial) "°F" else "°C"; + const wind = if (use_imperial) data.current.wind_kph * 0.621371 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}", .{ data.location, getConditionEmoji(data.current.weather_code), - data.current.temp_c, + temp, + unit, "🌬️", data.current.wind_dir, - data.current.wind_kph, + wind, + wind_unit, }); } else if (std.mem.eql(u8, format, "3")) { - return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C {s}{s}{d:.0}km/h {s}{d}%%", .{ + const temp = if (use_imperial) data.current.temp_f else data.current.temp_c; + const unit = if (use_imperial) "°F" else "°C"; + const wind = if (use_imperial) data.current.wind_kph * 0.621371 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, getConditionEmoji(data.current.weather_code), - data.current.temp_c, + temp, + unit, "🌬️", data.current.wind_dir, - data.current.wind_kph, + wind, + wind_unit, "💧", data.current.humidity, }); } else if (std.mem.eql(u8, format, "4")) { - return std.fmt.allocPrint(allocator, "{s}: {s} {d:.0}°C {s}{s}{d:.0}km/h {s}{d}%% {s}", .{ + const temp = if (use_imperial) data.current.temp_f else data.current.temp_c; + const unit = if (use_imperial) "°F" else "°C"; + const wind = if (use_imperial) data.current.wind_kph * 0.621371 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, getConditionEmoji(data.current.weather_code), - data.current.temp_c, + temp, + unit, "🌬️", data.current.wind_dir, - data.current.wind_kph, + wind, + wind_unit, "💧", data.current.humidity, "☀️", }); } else { - return renderCustom(allocator, data, format); + return renderCustom(allocator, data, format, use_imperial); } } -fn renderCustom(allocator: std.mem.Allocator, data: types.WeatherData, format: []const u8) ![]const u8 { +fn renderCustom(allocator: std.mem.Allocator, data: types.WeatherData, format: []const u8, use_imperial: bool) ![]const u8 { var output: std.ArrayList(u8) = .empty; errdefer output.deinit(allocator); @@ -57,12 +78,28 @@ fn renderCustom(allocator: std.mem.Allocator, data: types.WeatherData, format: [ 'c' => try output.appendSlice(allocator, getConditionEmoji(data.current.weather_code)), 'C' => try output.appendSlice(allocator, data.current.condition), 'h' => try output.writer(allocator).print("{d}", .{data.current.humidity}), - 't' => try output.writer(allocator).print("{d:.0}", .{data.current.temp_c}), - 'f' => try output.writer(allocator).print("{d:.0}", .{data.current.temp_c}), - 'w' => try output.writer(allocator).print("{s}{d:.0}km/h", .{ data.current.wind_dir, data.current.wind_kph }), + 't' => { + const temp = if (use_imperial) data.current.temp_f else data.current.temp_c; + try output.writer(allocator).print("{d:.0}", .{temp}); + }, + 'f' => { + const temp = if (use_imperial) data.current.temp_f else data.current.temp_c; + try output.writer(allocator).print("{d:.0}", .{temp}); + }, + 'w' => { + const wind = if (use_imperial) data.current.wind_kph * 0.621371 else data.current.wind_kph; + const wind_unit = if (use_imperial) "mph" else "km/h"; + try output.writer(allocator).print("{s}{d:.0}{s}", .{ data.current.wind_dir, wind, wind_unit }); + }, 'l' => try output.appendSlice(allocator, data.location), - 'p' => try output.writer(allocator).print("{d:.1}", .{data.current.precip_mm}), - 'P' => try output.writer(allocator).print("{d:.0}", .{data.current.pressure_mb}), + '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, '%'); @@ -112,7 +149,7 @@ test "format 1" { .allocator = std.testing.allocator, }; - const output = try render(std.testing.allocator, data, "1"); + const output = try render(std.testing.allocator, data, "1", false); defer std.testing.allocator.free(output); try std.testing.expectEqualStrings("London: ☀️ 15°C", output); @@ -136,8 +173,32 @@ test "custom format" { .allocator = std.testing.allocator, }; - const output = try render(std.testing.allocator, data, "%l: %c %t°C"); + 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" { + const data = types.WeatherData{ + .location = "Portland", + .current = .{ + .temp_c = 10.0, + .temp_f = 50.0, + .condition = "Cloudy", + .weather_code = 119, + .humidity = 70, + .wind_kph = 20.0, + .wind_dir = "SE", + .pressure_mb = 1013.0, + .precip_mm = 0.0, + }, + .forecast = &.{}, + .allocator = std.testing.allocator, + }; + + const output = try render(std.testing.allocator, data, "2", true); + defer std.testing.allocator.free(output); + + try std.testing.expectEqualStrings("Portland: ☁️ 50°F 🌬️SE12mph", output); +} diff --git a/zig/src/render/v2.zig b/zig/src/render/v2.zig index 98216ca..6099c5d 100644 --- a/zig/src/render/v2.zig +++ b/zig/src/render/v2.zig @@ -1,7 +1,7 @@ const std = @import("std"); const types = @import("../weather/types.zig"); -pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData) ![]const u8 { +pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData, use_imperial: bool) ![]const u8 { var output: std.ArrayList(u8) = .empty; errdefer output.deinit(allocator); const writer = output.writer(allocator); @@ -13,15 +13,34 @@ pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData) ![]const try writer.print(" Current conditions\n", .{}); try writer.print(" {s}\n", .{weather.current.condition}); try writer.writeAll(" 🌡️ "); - try writer.print("{d:.1}°C ({d:.1}°F)\n", .{ weather.current.temp_c, weather.current.temp_f }); + if (use_imperial) { + try writer.print("{d:.1}°F\n", .{weather.current.temp_f}); + } else { + try writer.print("{d:.1}°C ({d:.1}°F)\n", .{ weather.current.temp_c, weather.current.temp_f }); + } try writer.writeAll(" 💧 "); try writer.print("{d}%\n", .{weather.current.humidity}); try writer.writeAll(" 🌬️ "); - try writer.print("{d:.1} km/h {s}\n", .{ weather.current.wind_kph, weather.current.wind_dir }); + if (use_imperial) { + const wind_mph = weather.current.wind_kph * 0.621371; + try writer.print("{d:.1} mph {s}\n", .{ wind_mph, weather.current.wind_dir }); + } else { + try writer.print("{d:.1} km/h {s}\n", .{ weather.current.wind_kph, weather.current.wind_dir }); + } try writer.writeAll(" 🔽 "); - try writer.print("{d:.1} hPa\n", .{weather.current.pressure_mb}); + if (use_imperial) { + const pressure_inhg = weather.current.pressure_mb * 0.02953; + try writer.print("{d:.2} inHg\n", .{pressure_inhg}); + } else { + try writer.print("{d:.1} hPa\n", .{weather.current.pressure_mb}); + } try writer.writeAll(" 💦 "); - try writer.print("{d:.1} mm\n\n", .{weather.current.precip_mm}); + if (use_imperial) { + const precip_in = weather.current.precip_mm * 0.0393701; + try writer.print("{d:.2} in\n\n", .{precip_in}); + } else { + try writer.print("{d:.1} mm\n\n", .{weather.current.precip_mm}); + } // Forecast if (weather.forecast.len > 0) { @@ -29,9 +48,19 @@ pub fn render(allocator: std.mem.Allocator, weather: types.WeatherData) ![]const for (weather.forecast) |day| { try writer.print(" {s}: {s}\n", .{ day.date, day.condition }); try writer.writeAll(" ↑ "); - try writer.print("{d:.1}°C ", .{day.max_temp_c}); + if (use_imperial) { + const max_f = day.max_temp_c * 9.0 / 5.0 + 32.0; + try writer.print("{d:.1}°F ", .{max_f}); + } else { + try writer.print("{d:.1}°C ", .{day.max_temp_c}); + } try writer.writeAll("↓ "); - try writer.print("{d:.1}°C\n", .{day.min_temp_c}); + if (use_imperial) { + const min_f = day.min_temp_c * 9.0 / 5.0 + 32.0; + try writer.print("{d:.1}°F\n", .{min_f}); + } else { + try writer.print("{d:.1}°C\n", .{day.min_temp_c}); + } } } @@ -58,7 +87,7 @@ test "render v2 format" { .allocator = allocator, }; - const output = try render(allocator, weather); + const output = try render(allocator, weather, false); defer allocator.free(output); try std.testing.expect(output.len > 0); @@ -66,3 +95,31 @@ test "render v2 format" { try std.testing.expect(std.mem.indexOf(u8, output, "Current conditions") != null); try std.testing.expect(std.mem.indexOf(u8, output, "12.0°C") != null); } + +test "render v2 format with imperial units" { + const allocator = std.testing.allocator; + + const weather = types.WeatherData{ + .location = "Boston", + .current = .{ + .temp_c = 10.0, + .temp_f = 50.0, + .condition = "Clear", + .weather_code = 113, + .humidity = 65, + .wind_kph = 16.0, + .wind_dir = "N", + .pressure_mb = 1013.0, + .precip_mm = 0.0, + }, + .forecast = &[_]types.ForecastDay{}, + .allocator = allocator, + }; + + const output = try render(allocator, weather, true); + defer allocator.free(output); + + 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, "inHg") != null); +}