const std = @import("std"); /// Structure that holds environment variables loaded from .env file pub const DotEnv = struct { map: std.StringHashMap([]const u8), allocator: std.mem.Allocator, /// Loads the .env file from the given path and returns a DotEnv instance pub fn load(allocator: std.mem.Allocator, path: []const u8) !DotEnv { var map = std.StringHashMap([]const u8).init(allocator); const file = std.fs.cwd().openFile(path, .{}) catch |err| switch (err) { error.FileNotFound => return error.DotEnvFileNotFound, else => return err, }; defer file.close(); const max_size = 10 * 1024 * 1024; // 10MB max const content = try file.readToEndAlloc(allocator, max_size); defer allocator.free(content); var lines = std.mem.splitScalar(u8, content, '\n'); while (lines.next()) |raw_line| { var line = std.mem.trim(u8, raw_line, " \t\r\n"); // skip empty lines and comments if (line.len == 0 or line[0] == '#') continue; // find the first '=' character if (std.mem.indexOfScalar(u8, line, '=')) |eq_pos| { const key = std.mem.trim(u8, line[0..eq_pos], " \t"); var value = std.mem.trim(u8, line[eq_pos + 1 ..], " \t\r\n"); // optionally remove surrounding single or double quotes if (value.len >= 2 and ((value[0] == '"' and value[value.len - 1] == '"') or (value[0] == '\'' and value[value.len - 1] == '\''))) { value = value[1 .. value.len - 1]; } // Duplicate key and value to own them in the map const key_dup = try allocator.dupe(u8, key); errdefer allocator.free(key_dup); const value_dup = try allocator.dupe(u8, value); errdefer allocator.free(value_dup); try map.put(key_dup, value_dup); } } return DotEnv{ .map = map, .allocator = allocator, }; } /// Frees all allocated memory (keys and values) pub fn deinit(self: *DotEnv) void { var it = self.map.iterator(); while (it.next()) |entry| { self.allocator.free(entry.key_ptr.*); self.allocator.free(entry.value_ptr.*); } self.map.deinit(); } /// Returns the value for key, or null if not present pub fn get(self: DotEnv, key: []const u8) ?[]const u8 { return self.map.get(key); } /// Returns the value for key, or a default value if not present pub fn getOr(self: DotEnv, key: []const u8, default: []const u8) []const u8 { return self.get(key) orelse default; } /// Returns the value for a key, or errors if the required variable is missing pub fn require(self: DotEnv, key: []const u8) ![]const u8 { return self.get(key) orelse error.MissingRequiredEnvVar; } }; pub const Error = error{ DotEnvFileNotFound, MissingRequiredEnvVar }; // ╔══════════════════════════════════════════════════════════╗ // ║ UNIT TESTS ║ // ╚══════════════════════════════════════════════════════════╝ test "load .env file - basic functionality" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); const content = \\API_KEY=12345 \\DEBUG=true \\NAME=zig tester \\#COMMENT=ignored \\ SPACED = value with spaces \\QUOTED="quoted value" \\EMPTY= ; try tmp.dir.writeFile(.{ .data = content, .sub_path = "test.env" }); const tmp_path = try tmp.dir.realpathAlloc(allocator, "."); defer allocator.free(tmp_path); const full_path = try std.fs.path.join(allocator, &[_][]const u8{ tmp_path, "test.env", }); defer allocator.free(full_path); var env = try DotEnv.load(allocator, full_path); defer env.deinit(); try std.testing.expectEqualStrings("12345", env.require("API_KEY") catch unreachable); try std.testing.expectEqualStrings("true", env.get("DEBUG").?); try std.testing.expectEqualStrings("zig tester", env.get("NAME").?); try std.testing.expectEqualStrings("value with spaces", env.get("SPACED").?); try std.testing.expectEqualStrings("quoted value", env.get("QUOTED").?); try std.testing.expect(env.get("COMMENT") == null); try std.testing.expectEqualStrings("", env.get("EMPTY").?); } test "require() errors on missing required var" { const allocator = std.testing.allocator; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); try tmp.dir.writeFile(.{ .data = "OTHER=123", .sub_path = "empty.env" }); const tmp_path = try tmp.dir.realpathAlloc(allocator, "."); defer allocator.free(tmp_path); const full_path = try std.fs.path.join(allocator, &[_][]const u8{ tmp_path, "empty.env" }); defer allocator.free(full_path); var env = try DotEnv.load(allocator, full_path); defer env.deinit(); try std.testing.expectError(error.MissingRequiredEnvVar, env.require("MISSING")); } test "missing file should return error" { const allocator = std.testing.allocator; try std.testing.expectError(error.DotEnvFileNotFound, DotEnv.load(allocator, "this-file-not-exists.env")); } test "getOr() returns default on missing key" { const allocator = std.testing.allocator; var env: DotEnv = DotEnv{ .allocator = allocator, .map = std.StringHashMap([]const u8).init(allocator) }; try std.testing.expectEqualStrings("default-value", env.getOr("NOT EXIST", "default-value")); try std.testing.expect(env.get("NOT EXIST") == null); }