332 lines
13 KiB
Zig
332 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,
|
|
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);
|
|
}
|