diff --git a/zig/src/config.zig b/zig/src/config.zig index d14e167..f55e42d 100644 --- a/zig/src/config.zig +++ b/zig/src/config.zig @@ -6,7 +6,7 @@ pub const Config = struct { cache_size: usize, cache_dir: []const u8, geolite_path: []const u8, - geolocator_url: []const u8, + geocache_file: ?[]const u8, pub fn load(allocator: std.mem.Allocator) !Config { return Config{ @@ -23,7 +23,7 @@ pub const Config = struct { }, .cache_dir = std.process.getEnvVarOwned(allocator, "WTTR_CACHE_DIR") catch try allocator.dupe(u8, "/tmp/wttr-cache"), .geolite_path = std.process.getEnvVarOwned(allocator, "WTTR_GEOLITE_PATH") catch try allocator.dupe(u8, "./GeoLite2-City.mmdb"), - .geolocator_url = std.process.getEnvVarOwned(allocator, "WTTR_GEOLOCATOR_URL") catch try allocator.dupe(u8, "http://localhost:8004"), + .geocache_file = std.process.getEnvVarOwned(allocator, "WTTR_GEOCACHE_FILE") catch null, }; } @@ -31,7 +31,7 @@ pub const Config = struct { allocator.free(self.listen_host); allocator.free(self.cache_dir); allocator.free(self.geolite_path); - allocator.free(self.geolocator_url); + if (self.geocache_file) |f| allocator.free(f); } }; @@ -44,5 +44,5 @@ test "config loads defaults" { try std.testing.expectEqual(@as(u16, 8002), cfg.listen_port); try std.testing.expectEqual(@as(usize, 10_000), cfg.cache_size); try std.testing.expectEqualStrings("./GeoLite2-City.mmdb", cfg.geolite_path); - try std.testing.expectEqualStrings("http://localhost:8004", cfg.geolocator_url); + try std.testing.expect(cfg.geocache_file == null); } diff --git a/zig/src/location/geocache.zig b/zig/src/location/geocache.zig new file mode 100644 index 0000000..b44b478 --- /dev/null +++ b/zig/src/location/geocache.zig @@ -0,0 +1,146 @@ +const std = @import("std"); + +pub const GeoCache = struct { + allocator: std.mem.Allocator, + cache: std.StringHashMap(CachedLocation), + cache_file: ?[]const u8, + + pub const CachedLocation = struct { + name: []const u8, + latitude: f64, + longitude: f64, + }; + + pub fn init(allocator: std.mem.Allocator, cache_file: ?[]const u8) !GeoCache { + var cache = std.StringHashMap(CachedLocation).init(allocator); + + // Load from file if specified + if (cache_file) |file_path| { + loadFromFile(allocator, &cache, file_path) catch |err| { + std.log.warn("Failed to load geocoding cache from {s}: {}", .{ file_path, err }); + }; + } + + return GeoCache{ + .allocator = allocator, + .cache = cache, + .cache_file = if (cache_file) |f| try allocator.dupe(u8, f) else null, + }; + } + + pub fn deinit(self: *GeoCache) void { + // Save to file if specified + if (self.cache_file) |file_path| { + self.saveToFile(file_path) catch |err| { + std.log.warn("Failed to save geocoding cache to {s}: {}", .{ file_path, err }); + }; + } + + var it = self.cache.iterator(); + while (it.next()) |entry| { + self.allocator.free(entry.key_ptr.*); + self.allocator.free(entry.value_ptr.name); + } + self.cache.deinit(); + if (self.cache_file) |f| self.allocator.free(f); + } + + pub fn get(self: *GeoCache, query: []const u8) ?CachedLocation { + return self.cache.get(query); + } + + pub fn put(self: *GeoCache, query: []const u8, location: CachedLocation) !void { + const key = try self.allocator.dupe(u8, query); + const value = CachedLocation{ + .name = try self.allocator.dupe(u8, location.name), + .latitude = location.latitude, + .longitude = location.longitude, + }; + try self.cache.put(key, value); + } + + fn loadFromFile(allocator: std.mem.Allocator, cache: *std.StringHashMap(CachedLocation), file_path: []const u8) !void { + const file = try std.fs.cwd().openFile(file_path, .{}); + defer file.close(); + + const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024); // 10MB max + defer allocator.free(content); + + const parsed = try std.json.parseFromSlice( + std.json.Value, + allocator, + content, + .{}, + ); + defer parsed.deinit(); + + var it = parsed.value.object.iterator(); + while (it.next()) |entry| { + const obj = entry.value_ptr.object; + const key = try allocator.dupe(u8, entry.key_ptr.*); + const value = CachedLocation{ + .name = try allocator.dupe(u8, obj.get("name").?.string), + .latitude = obj.get("latitude").?.float, + .longitude = obj.get("longitude").?.float, + }; + try cache.put(key, value); + } + } + + fn saveToFile(self: *GeoCache, file_path: []const u8) !void { + const file = try std.fs.cwd().createFile(file_path, .{}); + defer file.close(); + + var buffer: [4096]u8 = undefined; + var file_writer = file.writer(&buffer); + const writer = &file_writer.interface; + + try writer.writeAll("{\n"); + + var it = self.cache.iterator(); + var first = true; + while (it.next()) |entry| { + if (!first) try writer.writeAll(",\n"); + first = false; + + try writer.print(" {any}: {any}", .{ + std.json.fmt(entry.key_ptr.*, .{}), + std.json.fmt(.{ + .name = entry.value_ptr.name, + .latitude = entry.value_ptr.latitude, + .longitude = entry.value_ptr.longitude, + }, .{}), + }); + } + + try writer.writeAll("\n}\n"); + try writer.flush(); + } +}; + +test "GeoCache basic operations" { + const allocator = std.testing.allocator; + var cache = try GeoCache.init(allocator, null); + defer cache.deinit(); + + // Test put and get + try cache.put("London", .{ + .name = "London, UK", + .latitude = 51.5074, + .longitude = -0.1278, + }); + + const result = cache.get("London"); + try std.testing.expect(result != null); + try std.testing.expectApproxEqAbs(@as(f64, 51.5074), result.?.latitude, 0.0001); + try std.testing.expectApproxEqAbs(@as(f64, -0.1278), result.?.longitude, 0.0001); +} + +test "GeoCache miss returns null" { + const allocator = std.testing.allocator; + var cache = try GeoCache.init(allocator, null); + defer cache.deinit(); + + const result = cache.get("NonExistent"); + try std.testing.expect(result == null); +} diff --git a/zig/src/location/resolver.zig b/zig/src/location/resolver.zig index f7b577e..576cef5 100644 --- a/zig/src/location/resolver.zig +++ b/zig/src/location/resolver.zig @@ -1,5 +1,6 @@ const std = @import("std"); const GeoIP = @import("geoip.zig").GeoIP; +const GeoCache = @import("geocache.zig").GeoCache; pub const Location = struct { name: []const u8, @@ -18,13 +19,13 @@ pub const LocationType = enum { pub const Resolver = struct { allocator: std.mem.Allocator, geoip: ?*GeoIP, - geolocator_url: []const u8, + geocache: *GeoCache, - pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIP, geolocator_url: []const u8) Resolver { + pub fn init(allocator: std.mem.Allocator, geoip: ?*GeoIP, geocache: *GeoCache) Resolver { return .{ .allocator = allocator, .geoip = geoip, - .geolocator_url = geolocator_url, + .geocache = geocache, }; } @@ -81,11 +82,75 @@ pub const Resolver = struct { } fn resolveGeocoded(self: *Resolver, name: []const u8) !Location { - // TODO: Call external geocoding service - // For now, return a placeholder error - _ = self; - _ = name; - return error.GeocodingNotImplemented; + // Check cache first + if (self.geocache.get(name)) |cached| { + return Location{ + .name = try self.allocator.dupe(u8, cached.name), + .latitude = cached.latitude, + .longitude = cached.longitude, + }; + } + + // Call Nominatim API + const url = try std.fmt.allocPrint( + self.allocator, + "https://nominatim.openstreetmap.org/search?q={s}&format=json&limit=1", + .{name}, + ); + defer self.allocator.free(url); + + var client = std.http.Client{ .allocator = self.allocator }; + defer client.deinit(); + + const uri = try std.Uri.parse(url); + + var response_buf: [1024 * 1024]u8 = undefined; + var writer = std.Io.Writer.fixed(&response_buf); + const result = try client.fetch(.{ + .location = .{ .uri = uri }, + .method = .GET, + .response_writer = &writer, + .extra_headers = &.{ + .{ .name = "User-Agent", .value = "wttr.in-zig/1.0" }, + }, + }); + + if (result.status != .ok) { + return error.GeocodingFailed; + } + + const response_body = response_buf[0..writer.end]; + + // Parse JSON response + const parsed = try std.json.parseFromSlice( + std.json.Value, + self.allocator, + response_body, + .{}, + ); + defer parsed.deinit(); + + if (parsed.value.array.items.len == 0) { + return error.LocationNotFound; + } + + const first = parsed.value.array.items[0]; + const display_name = first.object.get("display_name").?.string; + const lat = try std.fmt.parseFloat(f64, first.object.get("lat").?.string); + const lon = try std.fmt.parseFloat(f64, first.object.get("lon").?.string); + + // Cache the result + try self.geocache.put(name, .{ + .name = display_name, + .latitude = lat, + .longitude = lon, + }); + + return Location{ + .name = try self.allocator.dupe(u8, display_name), + .latitude = lat, + .longitude = lon, + }; } fn resolveAirport(self: *Resolver, code: []const u8) !Location { @@ -129,7 +194,8 @@ test "detect location type" { test "resolver init" { const allocator = std.testing.allocator; - const resolver = Resolver.init(allocator, null, "http://localhost:8004"); + var geocache = try GeoCache.init(allocator, null); + defer geocache.deinit(); + const resolver = Resolver.init(allocator, null, &geocache); try std.testing.expect(resolver.geoip == null); - try std.testing.expectEqualStrings("http://localhost:8004", resolver.geolocator_url); } diff --git a/zig/src/main.zig b/zig/src/main.zig index 4171934..187d8d5 100644 --- a/zig/src/main.zig +++ b/zig/src/main.zig @@ -6,6 +6,7 @@ const types = @import("weather/types.zig"); const Server = @import("http/server.zig").Server; const RateLimiter = @import("http/rate_limiter.zig").RateLimiter; const GeoIP = @import("location/geoip.zig").GeoIP; +const GeoCache = @import("location/geocache.zig").GeoCache; const Resolver = @import("location/resolver.zig").Resolver; pub const std_options: std.Options = .{ @@ -28,7 +29,11 @@ pub fn main() !void { try stdout.print("Cache size: {d}\n", .{cfg.cache_size}); try stdout.print("Cache dir: {s}\n", .{cfg.cache_dir}); try stdout.print("GeoLite2 path: {s}\n", .{cfg.geolite_path}); - try stdout.print("Geolocator URL: {s}\n", .{cfg.geolocator_url}); + if (cfg.geocache_file) |f| { + try stdout.print("Geocache file: {s}\n", .{f}); + } else { + try stdout.print("Geocache: in-memory only\n", .{}); + } try stdout.flush(); // Initialize GeoIP database @@ -39,8 +44,12 @@ pub fn main() !void { }; defer geoip.deinit(); + // Initialize geocoding cache + var geocache = try GeoCache.init(allocator, cfg.geocache_file); + defer geocache.deinit(); + // Initialize location resolver - var resolver = Resolver.init(allocator, &geoip, cfg.geolocator_url); + var resolver = Resolver.init(allocator, &geoip, &geocache); var cache = try Cache.init(allocator, .{ .max_entries = cfg.cache_size, @@ -80,5 +89,6 @@ test { _ = @import("render/v2.zig"); _ = @import("render/custom.zig"); _ = @import("location/geoip.zig"); + _ = @import("location/geocache.zig"); _ = @import("location/resolver.zig"); }