introduce ZFIN_HOME

This commit is contained in:
Emil Lerch 2026-04-17 12:19:19 -07:00
parent d9ce5b7082
commit 249b5ec2c4
Signed by: lobo
GPG key ID: A7B62D657EF764F8
5 changed files with 102 additions and 13 deletions

View file

@ -139,7 +139,7 @@ Tests use `std.testing.allocator` (which detects leaks) and are structured as un
- **The `color` parameter flows through everything.** CLI commands accept a `color: bool` parameter. Don't use ANSI escapes unconditionally — always gate on the `color` flag.
- **Portfolio auto-detection.** Both CLI and TUI auto-load `portfolio.srf` from cwd if no explicit path is given. `watchlist.srf` is similarly auto-detected.
- **Portfolio auto-detection.** Both CLI and TUI auto-load `portfolio.srf` from cwd if no explicit path is given. If not found in cwd, falls back to `$ZFIN_HOME/portfolio.srf`. `watchlist.srf` and `.env` follow the same cascade. `metadata.srf` and `accounts.srf` are loaded from the same directory as the resolved portfolio file.
- **Server sync is optional.** The `ZFIN_SERVER` env var enables parallel cache syncing from a remote zfin-server instance. All server sync code silently no-ops when the URL is null.

View file

@ -1173,6 +1173,7 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const [
var schwab_csv: ?[]const u8 = null;
var schwab_summary = false;
var portfolio_path: []const u8 = "portfolio.srf";
var explicit_portfolio = false;
var i: usize = 0;
while (i < args.len) : (i += 1) {
@ -1187,11 +1188,21 @@ pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, args: []const [
} else if ((std.mem.eql(u8, args[i], "--portfolio") or std.mem.eql(u8, args[i], "-p")) and i + 1 < args.len) {
i += 1;
portfolio_path = args[i];
explicit_portfolio = true;
} else if (std.mem.eql(u8, args[i], "--no-color")) {
// handled globally
}
}
var resolved_pf: ?zfin.Config.ResolvedPath = null;
defer if (resolved_pf) |r| r.deinit(allocator);
if (!explicit_portfolio) {
if (svc.config.resolveUserFile(allocator, portfolio_path)) |r| {
resolved_pf = r;
portfolio_path = r.path;
}
}
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
try cli.stderrPrint(
\\Usage: zfin audit [options] [-p <portfolio.srf>]

View file

@ -13,6 +13,7 @@ pub const Config = struct {
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,
@ -33,6 +34,22 @@ pub const Config = struct {
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");
@ -73,6 +90,33 @@ pub const Config = struct {
}
}
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

View file

@ -39,7 +39,7 @@ const usage =
\\ --ntm <N> Show +/- N strikes near the money (default: 8)
\\
\\Portfolio command options:
\\ If no file is given, defaults to portfolio.srf in the current directory.
\\ If no file is given, searches current directory then ZFIN_HOME.
\\ -w, --watchlist <FILE> Watchlist file
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
\\
@ -52,7 +52,7 @@ const usage =
\\Analysis command:
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
\\ from the same directory as the portfolio file.
\\ If no file is given, defaults to portfolio.srf in the current directory.
\\ If no file is given, searches current directory then ZFIN_HOME.
\\
\\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
@ -61,6 +61,7 @@ const usage =
\\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles)
\\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional)
\\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin)
\\ ZFIN_HOME User file directory (portfolio, watchlist, .env)
\\ NO_COLOR Disable colored output (https://no-color.org)
\\
;
@ -182,26 +183,41 @@ pub fn main() !u8 {
} else if (std.mem.eql(u8, command, "portfolio")) {
// Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf)
var watchlist_path: ?[]const u8 = null;
var explicit_watchlist = false;
var force_refresh = false;
var file_path: []const u8 = "portfolio.srf";
var explicit_file = false;
var pi: usize = 2;
while (pi < args.len) : (pi += 1) {
if ((std.mem.eql(u8, args[pi], "--watchlist") or std.mem.eql(u8, args[pi], "-w")) and pi + 1 < args.len) {
pi += 1;
watchlist_path = args[pi];
explicit_watchlist = true;
} else if (std.mem.eql(u8, args[pi], "--refresh")) {
force_refresh = true;
} else if (std.mem.eql(u8, args[pi], "--no-color")) {
// already handled globally
} else {
file_path = args[pi];
explicit_file = true;
}
}
// Auto-detect watchlist.srf in cwd if not explicitly provided (same as TUI)
if (watchlist_path == null) {
if (std.fs.cwd().access("watchlist.srf", .{})) |_| {
watchlist_path = "watchlist.srf";
} else |_| {}
// Resolve default file paths via ZFIN_HOME when not explicitly provided
var resolved_pf: ?zfin.Config.ResolvedPath = null;
defer if (resolved_pf) |r| r.deinit(allocator);
if (!explicit_file) {
if (config.resolveUserFile(allocator, file_path)) |r| {
resolved_pf = r;
file_path = r.path;
}
}
var resolved_wl: ?zfin.Config.ResolvedPath = null;
defer if (resolved_wl) |r| r.deinit(allocator);
if (!explicit_watchlist and watchlist_path == null) {
if (config.resolveUserFile(allocator, "watchlist.srf")) |r| {
resolved_wl = r;
watchlist_path = r.path;
}
}
try commands.portfolio.run(allocator, &svc, file_path, watchlist_path, force_refresh, color, out);
} else if (std.mem.eql(u8, command, "lookup")) {
@ -227,12 +243,22 @@ pub fn main() !u8 {
} else if (std.mem.eql(u8, command, "analysis")) {
// File path is first non-flag arg (default: portfolio.srf)
var analysis_file: []const u8 = "portfolio.srf";
var explicit_analysis = false;
for (args[2..]) |arg| {
if (!std.mem.startsWith(u8, arg, "--")) {
analysis_file = arg;
explicit_analysis = true;
break;
}
}
var resolved_af: ?zfin.Config.ResolvedPath = null;
defer if (resolved_af) |r| r.deinit(allocator);
if (!explicit_analysis) {
if (config.resolveUserFile(allocator, analysis_file)) |r| {
resolved_af = r;
analysis_file = r.path;
}
}
try commands.analysis.run(allocator, &svc, analysis_file, color, out);
} else {
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");

View file

@ -2043,10 +2043,13 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
}
}
var resolved_pf: ?zfin.Config.ResolvedPath = null;
defer if (resolved_pf) |r| r.deinit(allocator);
if (portfolio_path == null and !has_explicit_symbol) {
if (std.fs.cwd().access("portfolio.srf", .{})) |_| {
portfolio_path = "portfolio.srf";
} else |_| {}
if (config.resolveUserFile(allocator, "portfolio.srf")) |r| {
resolved_pf = r;
portfolio_path = r.path;
}
}
var keymap = blk: {
@ -2097,10 +2100,15 @@ pub fn run(allocator: std.mem.Allocator, config: zfin.Config, args: []const []co
}
}
var resolved_wl: ?zfin.Config.ResolvedPath = null;
defer if (resolved_wl) |r| r.deinit(allocator);
if (!skip_watchlist) {
const wl_path = watchlist_path orelse blk: {
std.fs.cwd().access("watchlist.srf", .{}) catch break :blk null;
break :blk @as(?[]const u8, "watchlist.srf");
if (config.resolveUserFile(allocator, "watchlist.srf")) |r| {
resolved_wl = r;
break :blk @as(?[]const u8, r.path);
}
break :blk null;
};
if (wl_path) |path| {
app_inst.watchlist = loadWatchlist(allocator, path);