From 249b5ec2c428710f250cd2e4d19cf2cb7af62823 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Fri, 17 Apr 2026 12:19:19 -0700 Subject: [PATCH] introduce ZFIN_HOME --- AGENTS.md | 2 +- src/commands/audit.zig | 11 +++++++++++ src/config.zig | 44 ++++++++++++++++++++++++++++++++++++++++++ src/main.zig | 40 +++++++++++++++++++++++++++++++------- src/tui.zig | 18 ++++++++++++----- 5 files changed, 102 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index d5120a2..7d00871 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 0a98e24..a69dba7 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -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 ] diff --git a/src/config.zig b/src/config.zig index 7a3a41d..bb70535 100644 --- a/src/config.zig +++ b/src/config.zig @@ -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 diff --git a/src/main.zig b/src/main.zig index 9e78bee..7904397 100644 --- a/src/main.zig +++ b/src/main.zig @@ -39,7 +39,7 @@ const usage = \\ --ntm 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 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"); diff --git a/src/tui.zig b/src/tui.zig index 18cb345..f31bee5 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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);