refactor config/add default files

This commit is contained in:
Emil Lerch 2026-04-21 12:08:16 -07:00
parent a7abc5f5d7
commit c938da2e3c
Signed by: lobo
GPG key ID: A7B62D657EF764F8
6 changed files with 336 additions and 160 deletions

View file

@ -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

View file

@ -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 -- <args> # 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`):

332
src/Config.zig Normal file
View file

@ -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);
}

View file

@ -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;
}

View file

@ -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

View file

@ -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");