//! Runtime configuration: API keys, cache directory, resolved data paths. //! //! File-as-struct: the file *is* the `Config` type. Callers reach it via //! `const Config = @import("Config.zig");` and use it anywhere a struct //! type is expected (`Config.fromEnv(alloc)`, `var c: Config = ...`, //! `Config.ResolvedPath`, `Config.default_portfolio_filename`, etc.). //! //! Configuration sources, in priority order: //! 1. Process environment variables //! 2. `.env` file in the current working directory //! 3. `.env` file in `$ZFIN_HOME` //! //! Cache directory defaults follow XDG: `$ZFIN_CACHE_DIR` > `$XDG_CACHE_HOME/zfin` //! > `$HOME/.cache/zfin`. const std = @import("std"); const EnvMap = std.StringHashMap([]const u8); /// Default filename for the portfolio file when no explicit -p/--portfolio /// is provided. Looked up via `resolveUserFile` (cwd → ZFIN_HOME). Every /// command that loads a portfolio should fall back to this so behavior /// stays consistent. pub const default_portfolio_filename = "portfolio.srf"; /// Default filename for the watchlist file. Same resolution rules as /// `default_portfolio_filename`. pub const default_watchlist_filename = "watchlist.srf"; // ── Fields ─────────────────────────────────────────────────── twelvedata_key: ?[]const u8 = null, polygon_key: ?[]const u8 = null, finnhub_key: ?[]const u8 = null, alphavantage_key: ?[]const u8 = null, tiingo_key: ?[]const u8 = null, openfigi_key: ?[]const u8 = null, /// URL of a zfin-server instance for lazy cache sync (e.g. "https://zfin.lerch.org") server_url: ?[]const u8 = null, cache_dir: []const u8, cache_dir_owned: bool = false, // true when cache_dir was allocated via path.join zfin_home: ?[]const u8 = null, allocator: ?std.mem.Allocator = null, /// Raw .env file contents (keys/values in env_map point into this). env_buf: ?[]const u8 = null, /// Parsed KEY=VALUE pairs from .env file. env_map: ?EnvMap = null, /// Strings allocated by resolve() from process environment variables. env_owned: std.ArrayList([]const u8) = .empty, // ── Construction / teardown ────────────────────────────────── pub fn fromEnv(allocator: std.mem.Allocator) @This() { var self = @This(){ .cache_dir = undefined, .allocator = allocator, }; // Try loading .env file from the current working directory self.env_buf = std.fs.cwd().readFileAlloc(allocator, ".env", 4096) catch null; if (self.env_buf) |buf| { self.env_map = parseEnvFile(allocator, buf); } self.zfin_home = self.resolve("ZFIN_HOME"); // Try loading .env file from ZFIN_HOME as well (cwd .env takes priority) if (self.env_buf == null) { if (self.zfin_home) |home| { const env_path = std.fs.path.join(allocator, &.{ home, ".env" }) catch null; if (env_path) |p| { defer allocator.free(p); self.env_buf = std.fs.cwd().readFileAlloc(allocator, p, 4096) catch null; if (self.env_buf) |buf| { self.env_map = parseEnvFile(allocator, buf); } } } } self.twelvedata_key = self.resolve("TWELVEDATA_API_KEY"); self.polygon_key = self.resolve("POLYGON_API_KEY"); self.finnhub_key = self.resolve("FINNHUB_API_KEY"); self.alphavantage_key = self.resolve("ALPHAVANTAGE_API_KEY"); self.tiingo_key = self.resolve("TIINGO_API_KEY"); self.openfigi_key = self.resolve("OPENFIGI_API_KEY"); self.server_url = self.resolve("ZFIN_SERVER"); const env_cache = self.resolve("ZFIN_CACHE_DIR"); self.cache_dir = env_cache orelse blk: { self.cache_dir_owned = true; // XDG Base Directory: $XDG_CACHE_HOME/zfin, falling back to $HOME/.cache/zfin const xdg = self.resolve("XDG_CACHE_HOME"); const base = xdg orelse fallback: { const home = self.resolve("HOME") orelse "/tmp"; break :fallback std.fs.path.join(allocator, &.{ home, ".cache" }) catch @panic("OOM"); }; const base_allocated = xdg == null; defer if (base_allocated) allocator.free(base); break :blk std.fs.path.join(allocator, &.{ base, "zfin" }) catch @panic("OOM"); }; return self; } pub fn deinit(self: *@This()) void { if (self.allocator) |a| { if (self.env_map) |*m| { var map = m.*; map.deinit(); } if (self.env_buf) |buf| a.free(buf); for (self.env_owned.items) |s| a.free(s); self.env_owned.deinit(a); if (self.cache_dir_owned) { a.free(self.cache_dir); } } } // ── File resolution ────────────────────────────────────────── pub const ResolvedPath = struct { path: []const u8, owned: bool, pub fn deinit(self: ResolvedPath, allocator: std.mem.Allocator) void { if (self.owned) allocator.free(self.path); } }; /// Resolve a user file, trying cwd first then ZFIN_HOME. /// Returns the path to use; caller must call `deinit()` on the result. pub fn resolveUserFile(self: @This(), allocator: std.mem.Allocator, rel_path: []const u8) ?ResolvedPath { if (std.fs.cwd().access(rel_path, .{})) |_| { return .{ .path = rel_path, .owned = false }; } else |_| {} if (self.zfin_home) |home| { const full = std.fs.path.join(allocator, &.{ home, rel_path }) catch return null; if (std.fs.cwd().access(full, .{})) |_| { return .{ .path = full, .owned = true }; } else |_| { allocator.free(full); } } return null; } // ── Queries ────────────────────────────────────────────────── pub fn hasAnyKey(self: @This()) bool { return self.twelvedata_key != null or self.polygon_key != null or self.finnhub_key != null or self.alphavantage_key != null or self.tiingo_key != null; } // ── Internals ──────────────────────────────────────────────── /// Look up a key: process environment first, then .env file fallback. fn resolve(self: *@This(), key: []const u8) ?[]const u8 { if (self.allocator) |a| { if (std.process.getEnvVarOwned(a, key)) |v| { self.env_owned.append(a, v) catch {}; return v; } else |_| {} } if (self.env_map) |m| return m.get(key); return null; } /// Parse all KEY=VALUE pairs from .env content into a HashMap. /// Values are slices into the original buffer (no extra allocations per entry). fn parseEnvFile(allocator: std.mem.Allocator, data: []const u8) ?EnvMap { var map = EnvMap.init(allocator); var iter = std.mem.splitScalar(u8, data, '\n'); while (iter.next()) |line| { const trimmed = std.mem.trim(u8, line, &std.ascii.whitespace); if (trimmed.len == 0 or trimmed[0] == '#') continue; if (std.mem.indexOfScalar(u8, trimmed, '=')) |eq| { const k = std.mem.trim(u8, trimmed[0..eq], &std.ascii.whitespace); const v = std.mem.trim(u8, trimmed[eq + 1 ..], &std.ascii.whitespace); map.put(k, v) catch return null; } } return map; } // ── Tests ──────────────────────────────────────────────────── const testing = std.testing; test "default_portfolio_filename / default_watchlist_filename are the expected literals" { // Guards against accidental rename; these constants are referenced by // every command that loads a portfolio or watchlist, and changing them // silently would break user setups. try testing.expectEqualStrings("portfolio.srf", default_portfolio_filename); try testing.expectEqualStrings("watchlist.srf", default_watchlist_filename); } test "parseEnvFile: basic KEY=VALUE pairs" { const data = "FOO=bar\nBAZ=qux\n"; var map = parseEnvFile(testing.allocator, data).?; defer map.deinit(); try testing.expectEqualStrings("bar", map.get("FOO").?); try testing.expectEqualStrings("qux", map.get("BAZ").?); try testing.expect(map.get("MISSING") == null); } test "parseEnvFile: trims whitespace around key and value" { const data = " SPACED_KEY = spaced value \n"; var map = parseEnvFile(testing.allocator, data).?; defer map.deinit(); try testing.expectEqualStrings("spaced value", map.get("SPACED_KEY").?); } test "parseEnvFile: ignores comments and blank lines" { const data = \\# this is a comment \\ \\FOO=bar \\# another comment \\ \\BAZ=qux \\ ; var map = parseEnvFile(testing.allocator, data).?; defer map.deinit(); try testing.expectEqual(@as(usize, 2), map.count()); try testing.expectEqualStrings("bar", map.get("FOO").?); try testing.expectEqualStrings("qux", map.get("BAZ").?); } test "parseEnvFile: lines without '=' are skipped" { const data = \\NOT_A_PAIR \\FOO=bar \\ALSO NOT A PAIR \\BAZ=qux \\ ; var map = parseEnvFile(testing.allocator, data).?; defer map.deinit(); try testing.expectEqual(@as(usize, 2), map.count()); try testing.expectEqualStrings("bar", map.get("FOO").?); try testing.expectEqualStrings("qux", map.get("BAZ").?); } test "parseEnvFile: value containing '=' keeps everything after the first '='" { const data = "URL=https://example.com/path?q=1&r=2\n"; var map = parseEnvFile(testing.allocator, data).?; defer map.deinit(); try testing.expectEqualStrings("https://example.com/path?q=1&r=2", map.get("URL").?); } test "parseEnvFile: empty input yields empty map" { var map = parseEnvFile(testing.allocator, "").?; defer map.deinit(); try testing.expectEqual(@as(usize, 0), map.count()); } test "hasAnyKey: false when all keys are null" { const c: @This() = .{ .cache_dir = "/tmp" }; try testing.expect(!c.hasAnyKey()); } test "hasAnyKey: true when any single provider key is set" { // Each key should independently flip the result to true. Iterating // through each variant catches a future field addition that forgets // to update hasAnyKey(). const KeyField = enum { tiingo, twelvedata, polygon, finnhub, alphavantage }; for ([_]KeyField{ .tiingo, .twelvedata, .polygon, .finnhub, .alphavantage }) |which| { var c: @This() = .{ .cache_dir = "/tmp" }; switch (which) { .tiingo => c.tiingo_key = "abc", .twelvedata => c.twelvedata_key = "abc", .polygon => c.polygon_key = "abc", .finnhub => c.finnhub_key = "abc", .alphavantage => c.alphavantage_key = "abc", } try testing.expect(c.hasAnyKey()); } } test "hasAnyKey: openfigi / server_url don't count as provider keys" { // hasAnyKey is about candle/quote providers; OpenFIGI is CUSIP lookup // and server_url is the sync server. Neither should flip hasAnyKey. var c: @This() = .{ .cache_dir = "/tmp" }; c.openfigi_key = "fg"; c.server_url = "https://example.com"; try testing.expect(!c.hasAnyKey()); } test "ResolvedPath.deinit: frees when owned, no-op when not owned" { const allocator = testing.allocator; // Not-owned: a static literal must NOT be freed. The testing allocator // would panic if we tried to free a non-allocation — success here is // the test returning normally. const rp_static: ResolvedPath = .{ .path = "portfolio.srf", .owned = false }; rp_static.deinit(allocator); // Owned: allocator-allocated path is freed by deinit. const owned = try allocator.dupe(u8, "/tmp/portfolio.srf"); const rp_owned: ResolvedPath = .{ .path = owned, .owned = true }; rp_owned.deinit(allocator); // (If this leaked, the test allocator would fail the test.) } test "resolve: env_map fallback when allocator is null (skips process env)" { // Setting allocator=null disables the getEnvVarOwned branch, so the // lookup must come from env_map alone. This exercises the .env-only // code path without depending on host environment variables. var map = EnvMap.init(testing.allocator); defer map.deinit(); try map.put("FOO", "from-env-file"); var c: @This() = .{ .cache_dir = "/tmp", .env_map = map, .allocator = null, }; try testing.expectEqualStrings("from-env-file", c.resolve("FOO").?); try testing.expect(c.resolve("MISSING") == null); } test "resolve: allocator=null and env_map=null returns null" { var c: @This() = .{ .cache_dir = "/tmp" }; try testing.expect(c.resolve("ANYTHING") == null); }