zfin/src/Config.zig
Emil Lerch fad9be6ce8
All checks were successful
Generic zig build / build (push) Successful in 2m20s
Generic zig build / deploy (push) Successful in 27s
upgrade to zig 0.16.0
IO-as-an-interface refactor across the codebase. The big shifts:
- std.io → std.Io, std.fs → std.Io.Dir/File, std.process.Child → spawn/run.
- Juicy Main: pub fn main(init: std.process.Init) gives gpa, io, arena,
  environ_map up front. main.zig + the build/ scripts use it directly.
- Threading io through everywhere that touches the outside world (HTTP,
  files, stderr, sleep, terminal detection). Functions taking `io` now
  announce side effects at the call site — the smell is the feature.
- date math takes `as_of: Date`, not `today: Date`. Caller resolves
  `--as-of` flag vs wall-clock at the boundary; the function operates
  on whatever date it's given. Every "today" parameter renamed and
  the as_of: ?Date + today: Date pattern collapsed.
- now_s: i64 (or before_s/after_s pairs) for sub-second metadata
  fields like snapshot captured_at, audit cadence, formatAge/fmtTimeAgo.
  Also pure and testable.
- legitimate Timestamp.now callers (cache TTL math, FetchResult
  timestamps, rate limiter, per-frame TUI "now" captures) gain
  `// wall-clock required: ...` comments justifying the read.

Test discovery: replaced the local refAllDeclsRecursive with bare
std.testing.refAllDecls(@This()). Sema-pulling main.zig's top-level
decls reaches every test file transitively through the import graph;
no explicit _ = @import(...) lines needed.

Cleanup along the way:
- Dropped DataService.allocator()/io() accessor methods; renamed the
  fields to drop the base_ prefix. Callers use self.allocator and
  self.io directly.
- Dropped now-vestigial io parameters from buildSnapshot,
  analyzePortfolio, compareSchwabSummary, compareAccounts,
  buildPortfolioData, divs.display, quote.display, parsePortfolioOpts,
  aggregateLiveStocks, renderEarningsLines, capitalGainsIndicator,
  aggregateDripLots, printLotRow, portfolio.display, printSnapNote.
- Dropped the unused contributions.computeAttribution date-form
  wrapper (only computeAttributionSpec is called).
- formatAge/fmtTimeAgo take (before_s, after_s) instead of io and
  reading the clock internally.
- parseProjectionsConfig uses an internal stack-buffer
  FixedBufferAllocator instead of an allocator parameter.
- ThreadSafeAllocator wrappers in cache concurrency tests dropped
  (0.16's DebugAllocator is thread-safe by default).
- analyzePortfolio bug surfaced by the rename: snapshot.zig was
  passing wall-clock today instead of as_of, mis-valuing cash/CDs
  for historical backfills.

83 new unit tests added due to removal of IO, bringing coverage from 58%
-> 64%
2026-05-09 22:40:33 -07:00

329 lines
13 KiB
Zig

//! 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,
fmp_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 (fallback when process env is missing a key).
env_map: ?EnvMap = null,
/// Process-level environment variable map (from Juicy Main). First-priority
/// lookup source before falling back to the .env file.
environ_map: ?*const std.process.Environ.Map = null,
// ── Construction / teardown ──────────────────────────────────
pub fn fromEnv(io: std.Io, allocator: std.mem.Allocator, environ_map: *const std.process.Environ.Map) @This() {
var self = @This(){
.cache_dir = undefined,
.allocator = allocator,
.environ_map = environ_map,
};
// Try loading .env file from the current working directory
self.env_buf = std.Io.Dir.cwd().readFileAlloc(io, ".env", allocator, .limited(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.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(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.fmp_key = self.resolve("FMP_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);
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(), io: std.Io, allocator: std.mem.Allocator, rel_path: []const u8) ?ResolvedPath {
if (std.Io.Dir.cwd().access(io, 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.Io.Dir.cwd().access(io, 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.fmp_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.environ_map) |em| {
if (em.get(key)) |v| return v;
}
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, fmp, alphavantage };
for ([_]KeyField{ .tiingo, .twelvedata, .polygon, .fmp, .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",
.fmp => c.fmp_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 environ_map is null (skips process env)" {
// Setting environ_map=null disables the process-env 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: environ_map=null and env_map=null returns null" {
var c: @This() = .{ .cache_dir = "/tmp" };
try testing.expect(c.resolve("ANYTHING") == null);
}