From c938da2e3cb1d339e7d06cb97a64357be3a1bddf Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Tue, 21 Apr 2026 12:08:16 -0700 Subject: [PATCH] refactor config/add default files --- .pre-commit-config.yaml | 2 +- AGENTS.md | 2 +- src/Config.zig | 332 ++++++++++++++++++++++++++++++++++++++++ src/config.zig | 156 ------------------- src/root.zig | 2 +- src/service.zig | 2 +- 6 files changed, 336 insertions(+), 160 deletions(-) create mode 100644 src/Config.zig delete mode 100644 src/config.zig diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c4ac86a..a63f172 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: - id: test name: Run zig build test entry: zig - args: ["build", "coverage", "-Dcoverage-threshold=40"] + args: ["build", "coverage", "-Dcoverage-threshold=49"] language: system types: [file] pass_filenames: false diff --git a/AGENTS.md b/AGENTS.md index 7d00871..608ae5c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ zig build # build the zfin binary (output: zig-out/bin/zfin) zig build test # run all tests (single binary, discovers all tests via refAllDeclsRecursive) zig build run -- # build and run CLI zig build docs # generate library documentation -zig build coverage -Dcoverage-threshold=40 # run tests with kcov coverage (Linux only) +zig build coverage -Dcoverage-threshold=49 # run tests with kcov coverage (Linux only) ``` **Tooling** (managed via `.mise.toml`): diff --git a/src/Config.zig b/src/Config.zig new file mode 100644 index 0000000..52f0d79 --- /dev/null +++ b/src/Config.zig @@ -0,0 +1,332 @@ +//! 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); +} diff --git a/src/config.zig b/src/config.zig deleted file mode 100644 index bb70535..0000000 --- a/src/config.zig +++ /dev/null @@ -1,156 +0,0 @@ -const std = @import("std"); - -const EnvMap = std.StringHashMap([]const u8); - -pub const Config = struct { - 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, - - pub fn fromEnv(allocator: std.mem.Allocator) Config { - var self = Config{ - .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: *Config) 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); - } - } - } - - 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: Config, 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; - } - - pub fn hasAnyKey(self: Config) 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; - } - - /// Look up a key: process environment first, then .env file fallback. - fn resolve(self: *Config, 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; -} diff --git a/src/root.zig b/src/root.zig index 7266fa0..ef826c4 100644 --- a/src/root.zig +++ b/src/root.zig @@ -60,7 +60,7 @@ pub const Quote = @import("models/quote.zig").Quote; // ── Infrastructure ─────────────────────────────────────────── /// Runtime configuration loaded from environment / .env file (API keys, paths). -pub const Config = @import("config.zig").Config; +pub const Config = @import("Config.zig"); // ── Cache ──────────────────────────────────────────────────── diff --git a/src/service.zig b/src/service.zig index b0db727..4b1dbe3 100644 --- a/src/service.zig +++ b/src/service.zig @@ -17,7 +17,7 @@ const OptionsChain = @import("models/option.zig").OptionsChain; const EarningsEvent = @import("models/earnings.zig").EarningsEvent; const Quote = @import("models/quote.zig").Quote; const EtfProfile = @import("models/etf_profile.zig").EtfProfile; -const Config = @import("config.zig").Config; +const Config = @import("Config.zig"); const cache = @import("cache/store.zig"); const srf = @import("srf"); const analysis = @import("analytics/analysis.zig");