handle multiple portfolio paths - default file name portfolio*.srf

This commit is contained in:
Emil Lerch 2026-05-21 14:04:59 -07:00
parent a06a4a8852
commit e319313583
Signed by: lobo
GPG key ID: A7B62D657EF764F8
11 changed files with 1289 additions and 140 deletions

View file

@ -29,7 +29,7 @@ repos:
- id: test
name: Run zig build test
entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=69"]
args: ["build", "coverage", "-Dcoverage-threshold=71"]
language: system
types: [file]
pass_filenames: false

View file

@ -17,11 +17,14 @@ 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 pattern for the portfolio file when no explicit -p/--portfolio
/// is provided. Looked up via `resolveUserFiles` (cwd ZFIN_HOME). The
/// `*` is intentional multiple files matching `portfolio*.srf` are all
/// loaded and union-merged. A user with just one `portfolio.srf` is
/// unaffected (the glob still matches that single file). 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`.
@ -147,6 +150,190 @@ pub fn resolveUserFile(self: @This(), io: std.Io, allocator: std.mem.Allocator,
return null;
}
/// Returns true if `pattern` contains glob metacharacters (`*`, `?`).
/// Callers use this to decide between literal-name resolution
/// (`resolveUserFile`) and glob expansion (`resolveUserFiles`).
///
/// Brackets (`[...]`) are NOT treated as glob characters because
/// `globMatch` doesn't support them. If we claimed `[` was a glob
/// metachar, a pattern like `foo[a-z].srf` would route to the glob
/// path, fail to match anything (the matcher treats `[` as a literal),
/// and silently drop the pattern. Treating `[` as literal keeps
/// behavior predictable: a literal-with-`[` falls through to the
/// "literal file path" code path and gets a useful "Cannot read"
/// error if the file doesn't exist.
pub fn isGlobPattern(pattern: []const u8) bool {
for (pattern) |c| {
if (c == '*' or c == '?') return true;
}
return false;
}
/// Match a filename against a glob pattern. Supports `*` (any run of
/// chars, including empty) and `?` (exactly one char). Brackets and
/// `**` are not supported `[` is matched as a literal character.
/// Match is anchored at both ends.
pub fn globMatch(pattern: []const u8, name: []const u8) bool {
return globMatchInner(pattern, name);
}
fn globMatchInner(pattern: []const u8, name: []const u8) bool {
var pi: usize = 0;
var ni: usize = 0;
// Backtrack state for `*`: `star_pi` is the index of the `*` in
// the pattern; `match_ni` is the position in `name` we backtracked
// to plus the chars matched so far.
var star_pi: ?usize = null;
var match_ni: usize = 0;
while (ni < name.len) {
if (pi < pattern.len) {
const pc = pattern[pi];
if (pc == '?') {
pi += 1;
ni += 1;
continue;
}
if (pc == '*') {
star_pi = pi;
match_ni = ni;
pi += 1;
continue;
}
if (pc == name[ni]) {
pi += 1;
ni += 1;
continue;
}
}
// Mismatch (or pattern exhausted) backtrack to last `*` if any.
if (star_pi) |sp| {
pi = sp + 1;
match_ni += 1;
ni = match_ni;
continue;
}
return false;
}
// Eat trailing `*`s.
while (pi < pattern.len and pattern[pi] == '*') : (pi += 1) {}
return pi == pattern.len;
}
/// A list of resolved paths returned by `resolveUserFiles`. Holds the
/// allocator and a contiguous slice of `ResolvedPath`. Caller must
/// `deinit()` once finished.
pub const ResolvedPaths = struct {
paths: []const ResolvedPath,
allocator: std.mem.Allocator,
pub fn deinit(self: ResolvedPaths) void {
for (self.paths) |rp| rp.deinit(self.allocator);
self.allocator.free(self.paths);
}
/// Convenience: return just the path strings as a slice. Slices
/// point into the `paths[i].path` fields, so they live as long as
/// the `ResolvedPaths` does.
pub fn paths_slice(self: ResolvedPaths, allocator: std.mem.Allocator) ![]const []const u8 {
const out = try allocator.alloc([]const u8, self.paths.len);
for (self.paths, 0..) |rp, i| out[i] = rp.path;
return out;
}
};
/// Resolve a portfolio-like path that may contain glob metacharacters.
///
/// Resolution rules:
/// - If `pattern` has no glob metachar, behaves like `resolveUserFile`
/// (try cwd, then ZFIN_HOME); returns 0 or 1 path.
/// - If `pattern` has a glob metachar, expand against cwd; if cwd
/// yielded zero matches, expand against ZFIN_HOME. Never mixes.
/// Results are sorted lexicographically for deterministic ordering.
/// - Returns an empty slice (not null) when the pattern has no
/// matches anywhere.
///
/// Caller must call `deinit()` on the returned `ResolvedPaths`.
pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator, pattern: []const u8) !ResolvedPaths {
if (!isGlobPattern(pattern)) {
// Literal-path fast path: reuse the singular resolver.
if (self.resolveUserFile(io, allocator, pattern)) |r| {
const arr = try allocator.alloc(ResolvedPath, 1);
arr[0] = r;
return .{ .paths = arr, .allocator = allocator };
}
return .{ .paths = &.{}, .allocator = allocator };
}
// Glob path. Try cwd first.
if (try expandGlob(io, allocator, ".", pattern, .cwd_relative)) |matches| {
if (matches.len > 0) return .{ .paths = matches, .allocator = allocator };
allocator.free(matches);
}
// Fall back to ZFIN_HOME.
if (self.zfin_home) |home| {
if (try expandGlob(io, allocator, home, pattern, .home_relative)) |matches| {
return .{ .paths = matches, .allocator = allocator };
}
}
return .{ .paths = &.{}, .allocator = allocator };
}
/// Where the directory we're scanning lives. Determines whether
/// resulting paths are bare filenames (cwd) or fully joined
/// (ZFIN_HOME), which in turn drives `ResolvedPath.owned`.
const GlobDirKind = enum { cwd_relative, home_relative };
/// Expand `pattern` against the directory at `dir_path`. Returns a
/// sorted slice of `ResolvedPath`, or null if the directory can't be
/// opened. Empty slice (non-null) means "directory exists, zero matches".
fn expandGlob(
io: std.Io,
allocator: std.mem.Allocator,
dir_path: []const u8,
pattern: []const u8,
kind: GlobDirKind,
) !?[]ResolvedPath {
var dir = std.Io.Dir.cwd().openDir(io, dir_path, .{ .iterate = true }) catch return null;
defer dir.close(io);
var matches: std.ArrayList(ResolvedPath) = .empty;
errdefer {
for (matches.items) |rp| rp.deinit(allocator);
matches.deinit(allocator);
}
var it = dir.iterate();
while (try it.next(io)) |entry| {
if (entry.kind != .file) continue;
if (!globMatch(pattern, entry.name)) continue;
const rp: ResolvedPath = switch (kind) {
.cwd_relative => .{
.path = try allocator.dupe(u8, entry.name),
.owned = true,
},
.home_relative => .{
.path = try std.fs.path.join(allocator, &.{ dir_path, entry.name }),
.owned = true,
},
};
try matches.append(allocator, rp);
}
const out = try matches.toOwnedSlice(allocator);
// Sort the resolved paths by their path string for determinism.
const PathLess = struct {
fn lt(_: void, a: ResolvedPath, b: ResolvedPath) bool {
return std.mem.lessThan(u8, a.path, b.path);
}
};
std.mem.sort(ResolvedPath, out, {}, PathLess.lt);
return out;
}
// Queries
pub fn hasAnyKey(self: @This()) bool {
@ -192,8 +379,10 @@ 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);
// silently would break user setups. The portfolio default is a glob
// intentional, so users with multiple portfolio_*.srf files get them
// all loaded by default.
try testing.expectEqualStrings("portfolio*.srf", default_portfolio_filename);
try testing.expectEqualStrings("watchlist.srf", default_watchlist_filename);
}
@ -327,3 +516,167 @@ test "resolve: environ_map=null and env_map=null returns null" {
var c: @This() = .{ .cache_dir = "/tmp" };
try testing.expect(c.resolve("ANYTHING") == null);
}
// Glob tests
test "isGlobPattern: detects metacharacters" {
try testing.expect(isGlobPattern("portfolio*.srf"));
try testing.expect(isGlobPattern("portfolio_?.srf"));
try testing.expect(!isGlobPattern("portfolio.srf"));
try testing.expect(!isGlobPattern(""));
try testing.expect(!isGlobPattern("foo/bar.srf"));
// Brackets are NOT treated as a glob char see doc-comment.
// A literal filename containing `[` falls through to the
// literal-path resolver, not the glob path.
try testing.expect(!isGlobPattern("foo[abc].srf"));
}
test "globMatch: literal match" {
try testing.expect(globMatch("portfolio.srf", "portfolio.srf"));
try testing.expect(!globMatch("portfolio.srf", "portfolio_a.srf"));
try testing.expect(!globMatch("portfolio.srf", "portfolio.SRF"));
}
test "globMatch: simple star" {
try testing.expect(globMatch("portfolio*.srf", "portfolio.srf"));
try testing.expect(globMatch("portfolio*.srf", "portfolio_main.srf"));
try testing.expect(globMatch("portfolio*.srf", "portfolio_mom.srf"));
try testing.expect(!globMatch("portfolio*.srf", "watchlist.srf"));
try testing.expect(!globMatch("portfolio*.srf", "portfolio.txt"));
}
test "globMatch: leading and trailing stars" {
try testing.expect(globMatch("*.srf", "portfolio.srf"));
try testing.expect(globMatch("*.srf", "a.srf"));
try testing.expect(!globMatch("*.srf", "a.txt"));
try testing.expect(globMatch("*", "anything"));
try testing.expect(globMatch("*", ""));
try testing.expect(globMatch("portfolio*", "portfolio"));
try testing.expect(globMatch("portfolio*", "portfolio_x"));
}
test "globMatch: question mark matches exactly one char" {
try testing.expect(globMatch("?.srf", "a.srf"));
try testing.expect(!globMatch("?.srf", "ab.srf"));
try testing.expect(!globMatch("?.srf", ".srf"));
try testing.expect(globMatch("portfolio_?.srf", "portfolio_1.srf"));
try testing.expect(!globMatch("portfolio_?.srf", "portfolio_12.srf"));
}
test "globMatch: multiple stars" {
try testing.expect(globMatch("*foo*", "foo"));
try testing.expect(globMatch("*foo*", "afoob"));
try testing.expect(globMatch("*foo*", "abfoocd"));
try testing.expect(!globMatch("*foo*", "fo"));
try testing.expect(globMatch("a*b*c", "abc"));
try testing.expect(globMatch("a*b*c", "axxbyyc"));
try testing.expect(!globMatch("a*b*c", "abd"));
}
test "globMatch: edge cases" {
try testing.expect(globMatch("", ""));
try testing.expect(!globMatch("", "x"));
try testing.expect(!globMatch("x", ""));
try testing.expect(globMatch("**", ""));
try testing.expect(globMatch("**", "anything"));
}
test "resolveUserFiles: literal name resolves to single path (or empty)" {
const allocator = testing.allocator;
const io = std.testing.io;
// No env / no zfin_home literal name not in cwd empty result.
const c: @This() = .{ .cache_dir = "/tmp" };
var result = try c.resolveUserFiles(io, allocator, "definitely-does-not-exist-zfin.srf");
defer result.deinit();
try testing.expectEqual(@as(usize, 0), result.paths.len);
}
test "resolveUserFiles: glob pattern, no matches" {
const allocator = testing.allocator;
const io = std.testing.io;
// Use a pattern guaranteed not to match anything in cwd.
const c: @This() = .{ .cache_dir = "/tmp" };
var result = try c.resolveUserFiles(io, allocator, "zfin-nope-*.srf-xyz");
defer result.deinit();
try testing.expectEqual(@as(usize, 0), result.paths.len);
}
test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" {
const allocator = testing.allocator;
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
// Use a pattern unlikely to match anything in the project's cwd
// resolveUserFiles tries cwd first and we want to exercise the
// zfin_home fallback path.
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_b.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_a.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "watchlist.srf", .data = "x" });
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]);
defer allocator.free(dir_path);
const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path };
var result = try c.resolveUserFiles(io, allocator, "zfintest_pf*.srf");
defer result.deinit();
try testing.expectEqual(@as(usize, 3), result.paths.len);
// Sorted: zfintest_pf.srf, zfintest_pf_a.srf, zfintest_pf_b.srf
try testing.expect(std.mem.endsWith(u8, result.paths[0].path, "zfintest_pf.srf"));
try testing.expect(std.mem.endsWith(u8, result.paths[1].path, "zfintest_pf_a.srf"));
try testing.expect(std.mem.endsWith(u8, result.paths[2].path, "zfintest_pf_b.srf"));
}
test "resolveUserFiles: glob in cwd takes precedence over zfin_home" {
const allocator = testing.allocator;
const io = std.testing.io;
// The "cwd takes precedence" rule is implicit in resolveUserFiles:
// it tries cwd first via expandGlob, and only falls back to
// zfin_home if cwd had zero matches. Rather than mutate the
// process cwd from a test (risky under parallel runners), we
// verify the equivalent behavior: if zfin_home has matches but
// cwd has none, we get the home matches; if cwd has matches, we
// never touch zfin_home.
//
// Cwd-no-match-home-yes case: covered by
// "resolveUserFiles: glob expansion in zfin_home, sorted ...".
//
// For the cwd-has-match path we exercise expandGlob directly
// against a tmpDir, since that's the same code path
// resolveUserFiles uses for cwd.
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_a.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "other.txt", .data = "x" });
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = dir_path_buf[0..dir_path_len];
const matches_opt = try expandGlob(io, allocator, dir_path, "portfolio_*.srf", .home_relative);
try testing.expect(matches_opt != null);
const matches = matches_opt.?;
defer {
for (matches) |rp| rp.deinit(allocator);
allocator.free(matches);
}
try testing.expectEqual(@as(usize, 2), matches.len);
try testing.expect(std.mem.endsWith(u8, matches[0].path, "portfolio_a.srf"));
try testing.expect(std.mem.endsWith(u8, matches[1].path, "portfolio_b.srf"));
}
test "expandGlob: missing directory returns null" {
const allocator = testing.allocator;
const io = std.testing.io;
const result = try expandGlob(io, allocator, "/zfin-test-no-such-dir-xyz", "*.srf", .home_relative);
try testing.expect(result == null);
}

View file

@ -45,16 +45,13 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
const color = ctx.color;
const as_of = ctx.today;
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(allocator);
const file_path = pf.path;
var loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return;
var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
defer loaded.deinit(allocator);
const portfolio = loaded.portfolio;
const positions = loaded.positions;
const syms = loaded.syms;
const anchor_path = loaded.anchor();
// Refresh per-symbol prices via the parallel loader so analysis
// works on TTL-fresh data by default. Previously this read
@ -83,9 +80,11 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
};
defer pf_data.deinit(allocator);
// Load classification metadata
const dir_end = if (std.mem.lastIndexOfScalar(u8, file_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{file_path[0..dir_end]}) catch return;
// Load classification metadata. Sibling files (metadata.srf,
// accounts.srf) live next to the anchor portfolio file, even
// when multiple portfolio_*.srf files are loaded.
const dir_end = if (std.mem.lastIndexOfScalar(u8, anchor_path, std.fs.path.sep)) |idx| idx + 1 else 0;
const meta_path = std.fmt.allocPrint(allocator, "{s}metadata.srf", .{anchor_path[0..dir_end]}) catch return;
defer allocator.free(meta_path);
const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, allocator, .limited(1024 * 1024)) catch {
@ -100,8 +99,9 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
};
defer cm.deinit();
// Load account tax type metadata (optional)
var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(file_path);
// Load account tax type metadata (optional). Anchor-derived path
// is correct: accounts.srf is one-per-portfolio-set.
var acct_map_opt: ?zfin.analysis.AccountMap = svc.loadAccountMap(anchor_path);
defer if (acct_map_opt) |*am| am.deinit();
var result = zfin.analysis.analyzePortfolio(
@ -127,7 +127,13 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
portfolio.totalCdFaceValue(as_of),
);
try display(result, split.stock_pct, split.bond_pct, pf_data.summary.total_value, file_path, color, out);
var label_buf: [512]u8 = undefined;
const display_label: []const u8 = if (loaded.paths.len > 1)
std.fmt.bufPrint(&label_buf, "{s} (+{d} more)", .{ anchor_path, loaded.paths.len - 1 }) catch anchor_path
else
anchor_path;
try display(result, split.stock_pct, split.bond_pct, pf_data.summary.total_value, display_label, color, out);
}
pub fn display(result: zfin.analysis.AnalysisResult, stock_pct: f64, bond_pct: f64, total_value: f64, file_path: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -2312,33 +2312,29 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void {
const as_of = ctx.today;
const now_s = ctx.now_s;
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(allocator);
const portfolio_path = pf.path;
const fidelity_csv = parsed.fidelity_csv;
const schwab_csv = parsed.schwab_csv;
const schwab_summary = parsed.schwab_summary;
const verbose = parsed.verbose;
const stale_days = parsed.stale_days;
// Flagless mode: run portfolio hygiene check
// Flagless mode: run portfolio hygiene check (single-file
// semantics git blame, commit SHAs, etc.). Resolve paths
// just to find the anchor; we don't need the merged view.
if (fidelity_csv == null and schwab_csv == null and !schwab_summary) {
return runHygieneCheck(io, allocator, svc, portfolio_path, stale_days, verbose, as_of, now_s, color, ctx.globals.refresh_policy, out);
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(allocator);
return runHygieneCheck(io, allocator, svc, pf.path, stale_days, verbose, as_of, now_s, color, ctx.globals.refresh_policy, out);
}
// Load portfolio
const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch {
try cli.stderrPrint(io, "Error: Cannot read portfolio file\n");
return;
};
defer allocator.free(pf_data);
var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch {
try cli.stderrPrint(io, "Error: Cannot parse portfolio file\n");
return;
};
defer portfolio.deinit();
// Reconciliation modes (--fidelity / --schwab / --schwab-summary):
// load the union of all portfolio files so the comparison sees
// every lot the user holds, even if they're split across multiple
// portfolio_*.srf files.
var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
defer loaded.deinit(allocator);
const portfolio = loaded.portfolio;
const portfolio_path = loaded.anchor();
// Load accounts.srf
var account_map = svc.loadAccountMap(portfolio_path) orelse {

View file

@ -351,9 +351,29 @@ pub fn loadPortfolioPrices(
// Portfolio loading
/// Result of loading and parsing a portfolio file. Caller must call deinit().
/// Result of loading and parsing one or more portfolio files. The
/// returned `portfolio` holds the union of all lots across every
/// resolved file; `positions` and `syms` are computed against that
/// merged view. Caller must call deinit().
pub const LoadedPortfolio = struct {
file_data: []const u8,
/// Resolved paths the lots came from, sorted lexicographically
/// (by `Config.resolveUserFiles`). `paths[0]` is the *anchor*
/// path used for sibling-file derivation (`accounts.srf`,
/// `metadata.srf`, `transaction_log.srf`, history dir).
/// Display labels typically render `paths[0]` plus
/// "(+N more)" when `paths.len > 1`. Owned.
paths: []const []const u8,
/// Optional `ResolvedPaths` handle for the same set of paths.
/// When the loader resolved patterns through `RunCtx`, the
/// `Config.ResolvedPaths` is captured here so `deinit()` can
/// release the owned path strings. When the loader was given
/// pre-resolved paths directly (test path, snapshot fallback),
/// this is null and the `paths` slice is shallow-copied bytes
/// the caller still owns.
resolved_paths: ?zfin.Config.ResolvedPaths,
/// Raw bytes of every file we read. One entry per portfolio
/// file. Owned.
file_datas: []const []const u8,
portfolio: zfin.Portfolio,
positions: []const zfin.Position,
syms: []const []const u8,
@ -362,47 +382,241 @@ pub const LoadedPortfolio = struct {
allocator.free(self.syms);
allocator.free(self.positions);
self.portfolio.deinit();
allocator.free(self.file_data);
for (self.file_datas) |d| allocator.free(d);
allocator.free(self.file_datas);
// Path-string ownership: `resolved_paths` (if present) owns
// the underlying path strings. The `paths` slice is the
// borrowed view; free only its outer storage.
allocator.free(self.paths);
if (self.resolved_paths) |rp| rp.deinit();
}
/// Convenience: returns `paths[0]`, the first / anchor path.
/// Sibling-file derivation (accounts.srf, metadata.srf, etc.)
/// hangs off this directory.
pub fn anchor(self: LoadedPortfolio) []const u8 {
return self.paths[0];
}
};
/// Read, deserialize, and extract positions + symbols from a portfolio file.
/// Returns null (with stderr message) on read/parse errors.
pub fn loadPortfolio(io: std.Io, allocator: std.mem.Allocator, file_path: []const u8, as_of: zfin.Date) ?LoadedPortfolio {
const file_data = std.Io.Dir.cwd().readFileAlloc(io, file_path, allocator, .limited(10 * 1024 * 1024)) catch {
stderrPrint(io, "Error: Cannot read portfolio file\n") catch {};
/// Resolve `-p`/`--portfolio` patterns through `ctx`, then load the
/// union of all matched portfolio files. The one-stop loader for
/// CLI commands: returns `null` (with a stderr message already
/// printed) on any error path, including pattern resolution failure,
/// no-files-found, mixed-directory rejection, read errors, and parse
/// errors.
///
/// Caller must `deinit(allocator)` the returned `LoadedPortfolio`.
///
/// The resolved paths are attached to the returned struct, so callers
/// don't need to call `ctx.resolvePortfolioPaths()` separately. Use
/// `loaded.anchor()` for sibling-file derivation; iterate
/// `loaded.paths` if the command genuinely needs the per-file list.
pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio {
const io = ctx.io;
const allocator = ctx.allocator;
var resolved = ctx.resolvePortfolioPaths() catch |err| switch (err) {
error.MixedPortfolioDirs => {
stderrPrint(io, "Error: portfolio files resolved to multiple directories.\n") catch {};
stderrPrint(io, " Sibling files (accounts.srf, metadata.srf, transaction_log.srf) live\n") catch {};
stderrPrint(io, " next to the portfolio, so all portfolio files must share a directory.\n") catch {};
return null;
},
else => {
stderrPrint(io, "Error: failed to resolve portfolio path(s)\n") catch {};
return null;
},
};
if (resolved.paths.len == 0) {
resolved.deinit();
stderrPrint(io, "Error: no portfolio file found (looked for portfolio*.srf in cwd → ZFIN_HOME)\n") catch {};
return null;
}
// Snapshot the path-string view as our own owned slice. Backing
// strings stay live as long as `resolved` does we hand both
// off to LoadedPortfolio which owns the deinit chain.
const paths_owned = allocator.dupe([]const u8, resolved.paths) catch {
resolved.deinit();
return null;
};
return loadFromPaths(io, allocator, paths_owned, resolved.inner, as_of);
}
/// Lower-level loader: caller has already resolved the path list and
/// owns the path strings. Used by tests and any internal call site
/// that needs to bypass `RunCtx` resolution. Strings inside `paths`
/// are NOT freed by `LoadedPortfolio.deinit` caller retains
/// ownership of them. The slice `paths` itself IS freed by deinit
/// (the LoadedPortfolio takes ownership of just the slice).
///
/// For most callers, prefer `loadPortfolio(ctx, as_of)` instead.
pub fn loadPortfolioFromPaths(io: std.Io, allocator: std.mem.Allocator, paths: []const []const u8, as_of: zfin.Date) ?LoadedPortfolio {
if (paths.len == 0) {
stderrPrint(io, "Error: No portfolio file found\n") catch {};
return null;
}
// Dupe the slice so deinit can free it without touching the
// caller's storage. Path strings remain caller-owned and are
// borrowed by the returned struct (resolved_paths = null
// signals "no Config.ResolvedPaths to deinit").
const paths_owned = allocator.dupe([]const u8, paths) catch return null;
return loadFromPaths(io, allocator, paths_owned, null, as_of);
}
/// Internal: load+merge given a pre-resolved paths slice. The slice
/// `paths_owned` is taken (will be freed by `LoadedPortfolio.deinit`).
/// `resolved_paths_opt` is the optional `Config.ResolvedPaths` to
/// hand off ownership of the path strings to the returned struct;
/// when null, path strings are caller-owned.
fn loadFromPaths(
io: std.Io,
allocator: std.mem.Allocator,
paths_owned: []const []const u8,
resolved_paths_opt: ?zfin.Config.ResolvedPaths,
as_of: zfin.Date,
) ?LoadedPortfolio {
// On any error after this point we must free the slice we just
// took ownership of, plus deinit the `resolved_paths_opt` so the
// path strings aren't leaked.
var error_cleanup_armed = true;
defer if (error_cleanup_armed) {
allocator.free(paths_owned);
if (resolved_paths_opt) |rp| rp.deinit();
};
// Read every file up front; bail on first error.
var file_datas: std.ArrayList([]const u8) = .empty;
errdefer {
for (file_datas.items) |d| allocator.free(d);
file_datas.deinit(allocator);
}
for (paths_owned) |p| {
const data = std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(10 * 1024 * 1024)) catch {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read portfolio file: {s}\n", .{p}) catch "Error: Cannot read portfolio file\n";
stderrPrint(io, msg) catch {};
for (file_datas.items) |d| allocator.free(d);
file_datas.deinit(allocator);
return null;
};
file_datas.append(allocator, data) catch {
allocator.free(data);
for (file_datas.items) |d| allocator.free(d);
file_datas.deinit(allocator);
return null;
};
}
// Deserialize each into an owned Portfolio, then merge their
// lot slices into a single combined slice. We can't simply
// concat the underlying slices because each Portfolio expects
// to free its own lots in `deinit()`; instead, we steal each
// Portfolio's lots[] (string fields are already dupe'd into
// `allocator`) and free only the empty Portfolio struct.
var merged: std.ArrayList(zfin.Lot) = .empty;
errdefer {
for (merged.items) |lot| {
allocator.free(lot.symbol);
if (lot.note) |n| allocator.free(n);
if (lot.account) |a| allocator.free(a);
if (lot.ticker) |t| allocator.free(t);
if (lot.underlying) |u| allocator.free(u);
}
merged.deinit(allocator);
}
for (file_datas.items, 0..) |data, idx| {
var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch {
var msg_buf: [512]u8 = undefined;
const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse portfolio file: {s}\n", .{paths_owned[idx]}) catch "Error: Cannot parse portfolio file\n";
stderrPrint(io, msg) catch {};
for (merged.items) |lot| {
allocator.free(lot.symbol);
if (lot.note) |n| allocator.free(n);
if (lot.account) |a| allocator.free(a);
if (lot.ticker) |t| allocator.free(t);
if (lot.underlying) |u| allocator.free(u);
}
merged.deinit(allocator);
for (file_datas.items) |d| allocator.free(d);
file_datas.deinit(allocator);
return null;
};
for (portfolio.lots) |lot| {
merged.append(allocator, lot) catch {
portfolio.deinit();
for (merged.items) |existing| {
allocator.free(existing.symbol);
if (existing.note) |n| allocator.free(n);
if (existing.account) |a| allocator.free(a);
if (existing.ticker) |t| allocator.free(t);
if (existing.underlying) |u| allocator.free(u);
}
merged.deinit(allocator);
for (file_datas.items) |d| allocator.free(d);
file_datas.deinit(allocator);
return null;
};
}
// Free the now-empty Portfolio's lots slice without freeing
// the per-lot strings they were transferred to `merged`.
allocator.free(portfolio.lots);
}
const merged_slice = merged.toOwnedSlice(allocator) catch {
for (file_datas.items) |d| allocator.free(d);
file_datas.deinit(allocator);
return null;
};
var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch {
allocator.free(file_data);
stderrPrint(io, "Error: Cannot parse portfolio file\n") catch {};
return null;
var combined: zfin.Portfolio = .{
.lots = merged_slice,
.allocator = allocator,
};
const positions = portfolio.positions(as_of, allocator) catch {
portfolio.deinit();
allocator.free(file_data);
const positions = combined.positions(as_of, allocator) catch {
combined.deinit();
for (file_datas.items) |d| allocator.free(d);
file_datas.deinit(allocator);
stderrPrint(io, "Error: Cannot compute positions\n") catch {};
return null;
};
const syms = portfolio.stockSymbols(allocator) catch {
const syms = combined.stockSymbols(allocator) catch {
allocator.free(positions);
portfolio.deinit();
allocator.free(file_data);
combined.deinit();
for (file_datas.items) |d| allocator.free(d);
file_datas.deinit(allocator);
stderrPrint(io, "Error: Cannot get stock symbols\n") catch {};
return null;
};
const file_datas_owned = file_datas.toOwnedSlice(allocator) catch {
allocator.free(syms);
allocator.free(positions);
combined.deinit();
return null;
};
error_cleanup_armed = false;
return .{
.file_data = file_data,
.portfolio = portfolio,
.paths = paths_owned,
.resolved_paths = resolved_paths_opt,
.file_datas = file_datas_owned,
.portfolio = combined,
.positions = positions,
.syms = syms,
};
}
/// Convenience for tests: load a single portfolio file by path.
/// Wraps `loadPortfolioFromPaths` with a one-element slice.
pub fn loadPortfolioFromFile(io: std.Io, allocator: std.mem.Allocator, file_path: []const u8, as_of: zfin.Date) ?LoadedPortfolio {
const paths = [_][]const u8{file_path};
return loadPortfolioFromPaths(io, allocator, &paths, as_of);
}
// Portfolio data pipeline
/// Result of the shared portfolio data pipeline. Caller must call deinit().
@ -1135,13 +1349,13 @@ test "fmtAsOfParseError: no trailing newline" {
// loadPortfolio / buildPortfolioData tests
test "loadPortfolio: missing file returns null" {
test "loadPortfolioFromFile: missing file returns null" {
const io = std.testing.io;
const result = loadPortfolio(io, std.testing.allocator, "/nonexistent/portfolio-never-exists.srf", zfin.Date.fromYmd(2026, 5, 8));
const result = loadPortfolioFromFile(io, std.testing.allocator, "/nonexistent/portfolio-never-exists.srf", zfin.Date.fromYmd(2026, 5, 8));
try std.testing.expect(result == null);
}
test "loadPortfolio: malformed file returns null" {
test "loadPortfolioFromFile: malformed file returns null" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@ -1153,11 +1367,11 @@ test "loadPortfolio: malformed file returns null" {
const path = try std.fs.path.join(std.testing.allocator, &.{ path_buf[0..dir_len], "bad.srf" });
defer std.testing.allocator.free(path);
const result = loadPortfolio(io, std.testing.allocator, path, zfin.Date.fromYmd(2026, 5, 8));
const result = loadPortfolioFromFile(io, std.testing.allocator, path, zfin.Date.fromYmd(2026, 5, 8));
try std.testing.expect(result == null);
}
test "loadPortfolio: happy path returns LoadedPortfolio with positions and syms" {
test "loadPortfolioFromFile: happy path returns LoadedPortfolio with positions and syms" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@ -1175,7 +1389,7 @@ test "loadPortfolio: happy path returns LoadedPortfolio with positions and syms"
const path = try std.fs.path.join(std.testing.allocator, &.{ path_buf[0..dir_len], "portfolio.srf" });
defer std.testing.allocator.free(path);
var loaded = loadPortfolio(io, std.testing.allocator, path, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult;
var loaded = loadPortfolioFromFile(io, std.testing.allocator, path, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult;
defer loaded.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
@ -1183,7 +1397,7 @@ test "loadPortfolio: happy path returns LoadedPortfolio with positions and syms"
try std.testing.expectEqual(@as(usize, 2), loaded.syms.len);
}
test "loadPortfolio: today value flows through to position computation" {
test "loadPortfolioFromFile: today value flows through to position computation" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
@ -1204,20 +1418,122 @@ test "loadPortfolio: today value flows through to position computation" {
defer std.testing.allocator.free(path);
// today before open_date position exists but no open shares
var loaded_before = loadPortfolio(io, std.testing.allocator, path, zfin.Date.fromYmd(2024, 1, 1)) orelse return error.TestUnexpectedResult;
var loaded_before = loadPortfolioFromFile(io, std.testing.allocator, path, zfin.Date.fromYmd(2024, 1, 1)) orelse return error.TestUnexpectedResult;
defer loaded_before.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 1), loaded_before.positions.len);
try std.testing.expectApproxEqAbs(@as(f64, 0), loaded_before.positions[0].shares, 0.01);
try std.testing.expectEqual(@as(u32, 0), loaded_before.positions[0].open_lots);
// today after open_date 100 shares open
var loaded_after = loadPortfolio(io, std.testing.allocator, path, zfin.Date.fromYmd(2025, 1, 1)) orelse return error.TestUnexpectedResult;
var loaded_after = loadPortfolioFromFile(io, std.testing.allocator, path, zfin.Date.fromYmd(2025, 1, 1)) orelse return error.TestUnexpectedResult;
defer loaded_after.deinit(std.testing.allocator);
try std.testing.expectEqual(@as(usize, 1), loaded_after.positions.len);
try std.testing.expectApproxEqAbs(@as(f64, 100), loaded_after.positions[0].shares, 0.01);
try std.testing.expectEqual(@as(u32, 1), loaded_after.positions[0].open_lots);
}
test "loadPortfolioFromPaths: empty path slice returns null" {
const io = std.testing.io;
const empty: []const []const u8 = &.{};
const result = loadPortfolioFromPaths(io, std.testing.allocator, empty, zfin.Date.fromYmd(2026, 5, 8));
try std.testing.expect(result == null);
}
test "loadPortfolioFromPaths: union-merges lots from two files" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const data1 =
\\#!srfv1
\\symbol::AAPL,shares:num:100,open_date::2024-01-15,open_price:num:150.00
\\symbol::MSFT,shares:num:50,open_date::2024-02-20,open_price:num:300.00
\\
;
const data2 =
\\#!srfv1
\\symbol::GOOG,shares:num:25,open_date::2024-03-10,open_price:num:140.00
\\
;
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = data1 });
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = data2 });
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir = path_buf[0..dir_len];
const p1 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio.srf" });
defer std.testing.allocator.free(p1);
const p2 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio_b.srf" });
defer std.testing.allocator.free(p2);
const paths = [_][]const u8{ p1, p2 };
var loaded = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult;
defer loaded.deinit(std.testing.allocator);
// Combined view: 3 lots from across 2 files, 3 positions, 3 stock syms.
try std.testing.expectEqual(@as(usize, 3), loaded.portfolio.lots.len);
try std.testing.expectEqual(@as(usize, 3), loaded.positions.len);
try std.testing.expectEqual(@as(usize, 3), loaded.syms.len);
try std.testing.expectEqual(@as(usize, 2), loaded.file_datas.len);
}
test "loadPortfolioFromPaths: bails on first unreadable file without leaking" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const ok =
\\#!srfv1
\\symbol::AAPL,shares:num:1,open_date::2024-01-01,open_price:num:1
\\
;
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = ok });
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir = path_buf[0..dir_len];
const p1 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio.srf" });
defer std.testing.allocator.free(p1);
const p_bad = try std.fs.path.join(std.testing.allocator, &.{ dir, "does_not_exist.srf" });
defer std.testing.allocator.free(p_bad);
const paths = [_][]const u8{ p1, p_bad };
const result = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2026, 5, 8));
try std.testing.expect(result == null);
}
test "loadPortfolioFromPaths: bails on parse error in second file without leaking" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
const ok =
\\#!srfv1
\\symbol::AAPL,shares:num:1,open_date::2024-01-01,open_price:num:1
\\
;
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = ok });
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = "not srf format at all\n" });
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
const dir = path_buf[0..dir_len];
const p1 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio.srf" });
defer std.testing.allocator.free(p1);
const p2 = try std.fs.path.join(std.testing.allocator, &.{ dir, "portfolio_b.srf" });
defer std.testing.allocator.free(p2);
const paths = [_][]const u8{ p1, p2 };
const result = loadPortfolioFromPaths(io, std.testing.allocator, &paths, zfin.Date.fromYmd(2026, 5, 8));
// "not srf format" is a single bad record in current parser; it
// may either parse to zero lots or fail outright. Either way,
// no leak is the load-bearing assertion.
if (result) |r| {
var mut = r;
defer mut.deinit(std.testing.allocator);
}
}
test "buildPortfolioData: empty positions returns NoAllocations" {
const config = zfin.Config{ .cache_dir = "/tmp" };
var svc = zfin.DataService.init(std.testing.io, std.testing.allocator, config);

View file

@ -596,7 +596,7 @@ const LiveSide = struct {
color: bool,
refresh: framework.RefreshPolicy,
) !LiveSide {
var loaded_pf = cli.loadPortfolio(io, allocator, portfolio_path, as_of) orelse return error.PortfolioLoadFailed;
var loaded_pf = cli.loadPortfolioFromFile(io, allocator, portfolio_path, as_of) orelse return error.PortfolioLoadFailed;
errdefer loaded_pf.deinit(allocator);
if (loaded_pf.portfolio.lots.len == 0) {
@ -1343,6 +1343,7 @@ fn runArgs(
color: bool,
out: *std.Io.Writer,
) !void {
const patterns = [_][]const u8{pf_path};
var ctx: framework.RunCtx = .{
.io = io,
.allocator = allocator,
@ -1350,7 +1351,7 @@ fn runArgs(
.environ_map = undefined,
.config = .{ .cache_dir = "" },
.svc = svc,
.globals = .{ .portfolio_path = pf_path },
.globals = .{ .portfolio_patterns = &patterns },
.today = today,
.now_s = 0,
.color = color,

View file

@ -193,11 +193,14 @@ pub const RefreshPolicy = enum { auto, force, never };
/// Parsed global options. Shared between `parseGlobals` (in main.zig)
/// and `RunCtx`. Lives here so `command_modules` entries can reach
/// fields like `globals.portfolio_path` without depending on main.zig.
/// fields like `globals.portfolio_patterns` without depending on main.zig.
pub const Globals = struct {
no_color: bool = false,
/// Explicit portfolio path from -p/--portfolio (raw, null if not set).
portfolio_path: ?[]const u8 = null,
/// Explicit portfolio patterns from `-p`/`--portfolio` (raw, may
/// contain glob metacharacters). Each `-p VALUE` adds one entry.
/// Empty means "use the default pattern" (`portfolio*.srf`).
/// Resolution and union-merge happens in `RunCtx.resolvePortfolioPaths`.
portfolio_patterns: []const []const u8 = &.{},
/// Explicit watchlist path from -w/--watchlist (raw, null if not set).
watchlist_path: ?[]const u8 = null,
/// Cache-freshness policy from `--refresh-data=<value>`.
@ -247,21 +250,49 @@ pub const RunCtx = struct {
color: bool,
out: *std.Io.Writer,
/// Resolve the portfolio path argument (from `-p`/`--portfolio`
/// or the default `portfolio.srf` filename) through cwd ZFIN_HOME.
/// Returns the path the caller should open. Caller must free
/// `resolved.deinit(allocator)` if non-null. Allocations come
/// from the arena.
pub fn resolvePortfolioPath(self: *RunCtx) ResolvedPath {
return resolveUserPath(
/// Resolve the portfolio pattern(s) (from `-p`/`--portfolio` or
/// the default `portfolio*.srf` pattern) through cwd ZFIN_HOME.
/// Returns the union of all matched files; an empty list if no
/// patterns matched anywhere.
///
/// Caller MUST `result.deinit()` to release the per-path
/// allocations (paths and their containing slice). Allocations
/// come from the arena allocator.
pub fn resolvePortfolioPaths(self: *RunCtx) !ResolvedPaths {
return resolvePortfolioPathsImpl(
self.io,
self.allocator,
self.config,
self.globals.portfolio_path,
zfin.Config.default_portfolio_filename,
self.globals.portfolio_patterns,
);
}
/// Single-path convenience: returns the *first* resolved
/// portfolio path. Used by sibling-file derivation
/// (`accounts.srf`, `metadata.srf`, `transaction_log.srf`,
/// `history/`) these files always live next to the first
/// portfolio file. Returns the default pattern's first match
/// when -p is not set; falls through to a literal default if
/// nothing matched.
pub fn resolvePortfolioPath(self: *RunCtx) ResolvedPath {
var paths = self.resolvePortfolioPaths() catch {
return .{ .path = zfin.Config.default_portfolio_filename, .resolved = null };
};
if (paths.inner.paths.len == 0) {
paths.deinit();
return .{ .path = zfin.Config.default_portfolio_filename, .resolved = null };
}
// Hand off the first inner ResolvedPath; release the rest.
// We can't reuse paths.deinit() because that would free
// the path we want to keep.
const keep = paths.inner.paths[0];
for (paths.inner.paths[1..]) |rp| rp.deinit(self.allocator);
self.allocator.free(paths.inner.paths);
// The convenience view (`paths.paths`) is allocated separately.
self.allocator.free(paths.paths);
return .{ .path = keep.path, .resolved = keep };
}
/// Same as `resolvePortfolioPath` but for the watchlist file.
pub fn resolveWatchlistPath(self: *RunCtx) ResolvedPath {
return resolveUserPath(
@ -283,6 +314,115 @@ pub const ResolvedPath = struct {
}
};
/// Multi-path resolution result returned by `resolvePortfolioPaths`.
/// Wraps a `Config.ResolvedPaths` plus a borrowed slice of the
/// `[]const u8` paths for ergonomic iteration. Caller must
/// `deinit()` to release everything.
pub const ResolvedPaths = struct {
inner: zfin.Config.ResolvedPaths,
/// Convenience view: the path strings, one per resolved file.
/// Slices borrow from `inner.paths[i].path`. Allocated in the
/// same allocator as `inner`.
paths: []const []const u8,
pub fn deinit(self: ResolvedPaths) void {
self.inner.allocator.free(self.paths);
self.inner.deinit();
}
};
fn resolvePortfolioPathsImpl(
io: std.Io,
allocator: std.mem.Allocator,
config: zfin.Config,
patterns: []const []const u8,
) !ResolvedPaths {
if (patterns.len == 0) {
// Default pattern.
const inner = try config.resolveUserFiles(io, allocator, zfin.Config.default_portfolio_filename);
const paths = try inner.paths_slice(allocator);
return .{ .inner = inner, .paths = paths };
}
// Multi-pattern: resolve each, concatenate, de-duplicate by path.
// For non-glob patterns that resolve to nothing, we still surface
// the literal pattern so the caller can produce a useful "Cannot
// read portfolio file: <path>" error. Mirrors the legacy
// single-path behavior where an explicit -p VALUE was always
// returned, present-on-disk or not.
var all_paths: std.ArrayList(zfin.Config.ResolvedPath) = .empty;
errdefer {
for (all_paths.items) |rp| rp.deinit(allocator);
all_paths.deinit(allocator);
}
for (patterns) |pat| {
const part = try config.resolveUserFiles(io, allocator, pat);
var consumed: usize = 0;
for (part.paths) |rp| {
var dup = false;
for (all_paths.items) |existing| {
if (std.mem.eql(u8, existing.path, rp.path)) {
dup = true;
break;
}
}
if (dup) {
rp.deinit(allocator);
} else {
try all_paths.append(allocator, rp);
}
consumed += 1;
}
// Free only the outer slice; paths were transferred above.
allocator.free(part.paths);
// Explicit-but-not-found: if `pat` is a literal (not a glob)
// and resolved to zero matches, surface it as a literal
// ResolvedPath so the caller's "cannot read" error message
// names the right file. Globs that match nothing are
// dropped silently (the user knows they passed a glob).
if (consumed == 0 and !zfin.Config.isGlobPattern(pat)) {
// Avoid duplicating if a previous iteration already added
// this literal path.
var dup = false;
for (all_paths.items) |existing| {
if (std.mem.eql(u8, existing.path, pat)) {
dup = true;
break;
}
}
if (!dup) {
const path_copy = try allocator.dupe(u8, pat);
try all_paths.append(allocator, .{ .path = path_copy, .owned = true });
}
}
}
// Mixed-directory check: sibling files (accounts.srf, metadata.srf,
// transaction_log.srf) are derived from the *first* portfolio file's
// directory. If patterns resolved to files spanning more than one
// directory, sibling-file lookup would silently use only the first
// directory's siblings confusing and almost certainly not what
// the user wants. Error out and tell the user to consolidate.
//
// The errdefer above handles cleanup; we just need to surface the
// error and let it run. (Don't free in-line that would be a
// double-free.)
if (all_paths.items.len > 1) {
const first_dir = std.fs.path.dirnamePosix(all_paths.items[0].path) orelse ".";
for (all_paths.items[1..]) |rp| {
const this_dir = std.fs.path.dirnamePosix(rp.path) orelse ".";
if (!std.mem.eql(u8, first_dir, this_dir)) {
return error.MixedPortfolioDirs;
}
}
}
const owned = try all_paths.toOwnedSlice(allocator);
const inner: zfin.Config.ResolvedPaths = .{ .paths = owned, .allocator = allocator };
const path_view = try inner.paths_slice(allocator);
return .{ .inner = inner, .paths = path_view };
}
fn resolveUserPath(
io: std.Io,
allocator: std.mem.Allocator,
@ -501,7 +641,7 @@ test "RefreshPolicy: auto is the default variant" {
test "Globals: zero-init defaults" {
const g: Globals = .{};
try testing.expect(!g.no_color);
try testing.expect(g.portfolio_path == null);
try testing.expectEqual(@as(usize, 0), g.portfolio_patterns.len);
try testing.expect(g.watchlist_path == null);
try testing.expectEqual(RefreshPolicy.auto, g.refresh_policy);
}
@ -656,3 +796,139 @@ test "printGroupedUsage: omits empty groups" {
// registry) does NOT.
try testing.expect(std.mem.indexOf(u8, out, "Per-symbol lookups:") == null);
}
// resolvePortfolioPaths tests
test "resolvePortfolioPathsImpl: empty patterns falls back to default glob" {
// With no zfin_home configured, resolveUserFiles for the default
// pattern returns 0 matches in a clean tmp dir. The Impl returns
// an empty resolved list (not an error) so callers can produce
// "no portfolio file found" themselves.
const config: zfin.Config = .{ .cache_dir = "/tmp" };
const empty: []const []const u8 = &.{};
var result = try resolvePortfolioPathsImpl(std.testing.io, testing.allocator, config, empty);
defer result.deinit();
// 0 or 1 match depending on whether the test runner's cwd has a
// portfolio*.srf file. We just check it doesn't crash and the
// shape is consistent.
try testing.expectEqual(result.inner.paths.len, result.paths.len);
}
test "resolvePortfolioPathsImpl: literal not-found is preserved as a literal" {
// The legacy single-path API returned an explicit -p value even
// when it didn't exist on disk, so the caller could produce a
// "Cannot read: <path>" error naming the right file. We mirror
// that for the multi-path API.
const config: zfin.Config = .{ .cache_dir = "/tmp" };
const patterns = [_][]const u8{"/zfin-test-no-such-portfolio.srf"};
var result = try resolvePortfolioPathsImpl(std.testing.io, testing.allocator, config, &patterns);
defer result.deinit();
try testing.expectEqual(@as(usize, 1), result.paths.len);
try testing.expectEqualStrings("/zfin-test-no-such-portfolio.srf", result.paths[0]);
}
test "resolvePortfolioPathsImpl: glob with no matches resolves to empty" {
// Globs that match nothing are dropped silently the user
// typed a glob, they know it might match zero files.
const config: zfin.Config = .{ .cache_dir = "/tmp" };
const patterns = [_][]const u8{"zfin-test-nope-*.srf-xyz"};
var result = try resolvePortfolioPathsImpl(std.testing.io, testing.allocator, config, &patterns);
defer result.deinit();
try testing.expectEqual(@as(usize, 0), result.paths.len);
}
test "resolvePortfolioPathsImpl: two patterns matching same dir union-merge" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_main.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_mom.srf", .data = "x" });
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = dir_path_buf[0..dir_path_len];
const main_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_main.srf" });
defer testing.allocator.free(main_path);
const mom_path = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_mom.srf" });
defer testing.allocator.free(mom_path);
const patterns = [_][]const u8{ main_path, mom_path };
const config: zfin.Config = .{ .cache_dir = "/tmp" };
var result = try resolvePortfolioPathsImpl(io, testing.allocator, config, &patterns);
defer result.deinit();
try testing.expectEqual(@as(usize, 2), result.paths.len);
}
test "resolvePortfolioPathsImpl: duplicate pattern de-dups" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio.srf", .data = "x" });
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = dir_path_buf[0..dir_path_len];
const p = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio.srf" });
defer testing.allocator.free(p);
const patterns = [_][]const u8{ p, p };
const config: zfin.Config = .{ .cache_dir = "/tmp" };
var result = try resolvePortfolioPathsImpl(io, testing.allocator, config, &patterns);
defer result.deinit();
// Same path passed twice 1 entry.
try testing.expectEqual(@as(usize, 1), result.paths.len);
}
test "resolvePortfolioPathsImpl: mixed directories error" {
const io = std.testing.io;
var tmp_a = std.testing.tmpDir(.{});
defer tmp_a.cleanup();
var tmp_b = std.testing.tmpDir(.{});
defer tmp_b.cleanup();
try tmp_a.dir.writeFile(io, .{ .sub_path = "portfolio_a.srf", .data = "x" });
try tmp_b.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = "x" });
var dir_a_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_a_len = try tmp_a.dir.realPath(io, &dir_a_buf);
var dir_b_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_b_len = try tmp_b.dir.realPath(io, &dir_b_buf);
const path_a = try std.fs.path.join(testing.allocator, &.{ dir_a_buf[0..dir_a_len], "portfolio_a.srf" });
defer testing.allocator.free(path_a);
const path_b = try std.fs.path.join(testing.allocator, &.{ dir_b_buf[0..dir_b_len], "portfolio_b.srf" });
defer testing.allocator.free(path_b);
const patterns = [_][]const u8{ path_a, path_b };
const config: zfin.Config = .{ .cache_dir = "/tmp" };
try testing.expectError(error.MixedPortfolioDirs, resolvePortfolioPathsImpl(io, testing.allocator, config, &patterns));
}
test "resolvePortfolioPathsImpl: same dir different patterns OK (no mixed-dir error)" {
const io = std.testing.io;
var tmp = std.testing.tmpDir(.{});
defer tmp.cleanup();
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_a.srf", .data = "x" });
try tmp.dir.writeFile(io, .{ .sub_path = "portfolio_b.srf", .data = "x" });
var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined;
const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf);
const dir_path = dir_path_buf[0..dir_path_len];
const path_a = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_a.srf" });
defer testing.allocator.free(path_a);
const path_b = try std.fs.path.join(testing.allocator, &.{ dir_path, "portfolio_b.srf" });
defer testing.allocator.free(path_b);
const patterns = [_][]const u8{ path_a, path_b };
const config: zfin.Config = .{ .cache_dir = "/tmp" };
var result = try resolvePortfolioPathsImpl(io, testing.allocator, config, &patterns);
defer result.deinit();
try testing.expectEqual(@as(usize, 2), result.paths.len);
}

View file

@ -56,22 +56,22 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
const color = ctx.color;
const as_of = ctx.today;
const pf = ctx.resolvePortfolioPath();
defer pf.deinit(allocator);
const file_path = pf.path;
const wl = ctx.resolveWatchlistPath();
defer wl.deinit(allocator);
const watchlist_path: ?[]const u8 =
if (ctx.globals.watchlist_path != null or wl.resolved != null) wl.path else null;
// Load portfolio from SRF file
var loaded = cli.loadPortfolio(io, allocator, file_path, as_of) orelse return;
// Resolve patterns + load (and union-merge) every matched
// portfolio file in one shot. The returned LoadedPortfolio
// carries the resolved paths so we can derive an anchor and
// a multi-file display label without a second resolver call.
var loaded = cli.loadPortfolio(ctx, as_of) orelse return;
defer loaded.deinit(allocator);
const portfolio = loaded.portfolio;
const positions = loaded.positions;
const syms = loaded.syms;
const anchor_path = loaded.anchor();
if (portfolio.lots.len == 0) {
try cli.stderrPrint(io, "Portfolio is empty.\n");
@ -182,11 +182,20 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void {
}
}
// Build a display label: anchor file plus "(+N more)" when
// multiple portfolio files contributed lots. Keeps the existing
// single-file UX unchanged while making multi-file mode visible.
var label_buf: [512]u8 = undefined;
const display_label: []const u8 = if (loaded.paths.len > 1)
std.fmt.bufPrint(&label_buf, "{s} (+{d} more)", .{ anchor_path, loaded.paths.len - 1 }) catch anchor_path
else
anchor_path;
try display(
allocator,
out,
color,
file_path,
display_label,
&portfolio,
positions,
&pf_data,

View file

@ -418,7 +418,7 @@ pub fn runBands(
// the composition we ALWAYS want today's mix (the only
// composition we know). Pass `today` for the cash/CD
// computation.
live_loaded = cli.loadPortfolio(io, allocator, file_path, opts.today) orelse return;
live_loaded = cli.loadPortfolioFromFile(io, allocator, file_path, opts.today) orelse return;
const lp = &live_loaded.?;
// Route through the shared loader so `--refresh-data`
@ -458,7 +458,7 @@ pub fn runBands(
);
}
} else {
live_loaded = cli.loadPortfolio(io, allocator, file_path, opts.as_of) orelse return;
live_loaded = cli.loadPortfolioFromFile(io, allocator, file_path, opts.as_of) orelse return;
const lp = &live_loaded.?;
// Route through the shared loader so `--refresh-data`
@ -1192,7 +1192,7 @@ pub fn computeKeyComparison(
}
// Live "now" side mirrors `run()`'s live path.
var loaded = cli.loadPortfolio(io, allocator, file_path, opts.now_date) orelse {
var loaded = cli.loadPortfolioFromFile(io, allocator, file_path, opts.now_date) orelse {
then_snap.deinit(allocator);
return error.PortfolioLoadFailed;
};

View file

@ -70,10 +70,15 @@ const usage_footer =
\\ of TTL freshness
\\ never serve cache contents only;
\\ no provider calls (offline mode)
\\ -p, --portfolio <FILE> Portfolio file (default: portfolio.srf;
\\ cwd → ZFIN_HOME). metadata.srf and
\\ accounts.srf are loaded from the same
\\ directory as the resolved portfolio.
\\ -p, --portfolio <PATTERN> Portfolio file or glob pattern (repeatable;
\\ default: portfolio*.srf in cwd → ZFIN_HOME).
\\ Quote globs to prevent shell expansion:
\\ -p 'portfolio_*.srf'
\\ Or repeat the flag for multiple files:
\\ -p portfolio.srf -p portfolio_mom.srf
\\ metadata.srf and accounts.srf are loaded from
\\ the same directory as the first resolved
\\ portfolio file.
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
\\
\\Interactive command options:
@ -136,8 +141,11 @@ fn writeUsage(out: *std.Io.Writer) !void {
/// Parsed global options. Paths are raw (not yet resolved through ZFIN_HOME).
const Globals = struct {
no_color: bool = false,
/// Explicit portfolio path from -p/--portfolio (raw, null if not set).
portfolio_path: ?[]const u8 = null,
/// Explicit portfolio patterns from `-p`/`--portfolio` (raw, may
/// contain glob metacharacters). Each `-p VALUE` appends one entry;
/// resolution and union-merge happens later in `RunCtx`. Empty
/// (len == 0) means "use the default pattern".
portfolio_patterns: []const []const u8 = &.{},
/// Explicit watchlist path from -w/--watchlist (raw, null if not set).
watchlist_path: ?[]const u8 = null,
/// Cache freshness policy from `--refresh-data=<value>`.
@ -153,13 +161,57 @@ const GlobalParseError = error{
UnknownGlobalFlag,
/// `--refresh-data=<value>` got something other than auto/force/never.
InvalidRefreshDataValue,
/// Multiple `.srf` files appeared as a single -p argument, almost
/// certainly because the shell expanded an unquoted glob. We
/// surface this as a dedicated error so the user gets a friendly
/// "quote your glob OR repeat -p" message.
UnquotedGlobLikely,
OutOfMemory,
};
/// Heuristic: returns true if `cursor` in args points at one or more
/// contiguous `.srf` files that are NOT prefixed by a `-p`/`--portfolio`
/// flag, suggesting the user typed `-p portfolio_*.srf` and the shell
/// expanded the glob into space-separated args.
///
/// Conservative: only fires when at least one extra `.srf` file
/// appears, AND no flags or non-srf tokens are interleaved before
/// the next subcommand-shaped token. False positives here would be
/// more annoying than the original "unknown command: portfolio_2.srf"
/// error, so the bar is high.
fn looksLikeUnquotedGlob(args: []const []const u8, cursor: usize) bool {
var i = cursor;
var srf_count: usize = 0;
while (i < args.len) : (i += 1) {
const a = args[i];
if (a.len == 0) return false;
// A flag-shaped token (starts with -) means we've left the
// suspicious run; only "all srf files until the end (or another
// -p)" counts as the unquoted-glob shape.
if (a[0] == '-') return srf_count > 0;
if (std.mem.endsWith(u8, a, ".srf")) {
srf_count += 1;
continue;
}
// Non-srf positional token (probably a subcommand). Stop the
// run; if we already saw an srf file, that's the glob shape.
return srf_count > 0;
}
return srf_count > 0;
}
/// Parse global flags from args[1..] up to the first non-flag (subcommand)
/// token. Errors if a pre-subcommand token starts with '-' but isn't a
/// recognized global, or if a value-taking flag is missing its value.
fn parseGlobals(args: []const []const u8) GlobalParseError!Globals {
///
/// Allocator is used only for the `portfolio_patterns` slice. Caller
/// owns the slice on success and must free it with `freeGlobals` (or
/// rely on arena cleanup).
fn parseGlobals(allocator: std.mem.Allocator, args: []const []const u8) GlobalParseError!Globals {
var g: Globals = .{ .cursor = 1 };
var patterns: std.ArrayList([]const u8) = .empty;
errdefer patterns.deinit(allocator);
var i: usize = 1;
while (i < args.len) {
const a = args[i];
@ -172,7 +224,14 @@ fn parseGlobals(args: []const []const u8) GlobalParseError!Globals {
}
if (std.mem.eql(u8, a, "-p") or std.mem.eql(u8, a, "--portfolio")) {
if (i + 1 >= args.len) return error.MissingValue;
g.portfolio_path = args[i + 1];
try patterns.append(allocator, args[i + 1]);
// Detect the unquoted-glob shape: we just consumed `-p VALUE`,
// and the next args are more `.srf` files with no flag in
// between. That's almost always the shell expanding `-p
// portfolio_*.srf` into multiple args.
if (looksLikeUnquotedGlob(args, i + 2)) {
return error.UnquotedGlobLikely;
}
i += 2;
continue;
}
@ -215,33 +274,10 @@ fn parseGlobals(args: []const []const u8) GlobalParseError!Globals {
return error.UnknownGlobalFlag;
}
g.cursor = i;
g.portfolio_patterns = try patterns.toOwnedSlice(allocator);
return g;
}
/// Resolve a portfolio-like path. If `explicit` is non-null the user supplied
/// it explicitly; we still run resolveUserFile to allow bare filenames to
/// resolve through cwd ZFIN_HOME. If null, use the given default filename
/// and run through resolveUserFile.
fn resolveUserPath(
io: std.Io,
allocator: std.mem.Allocator,
config: zfin.Config,
explicit: ?[]const u8,
default_name: []const u8,
) struct { path: []const u8, resolved: ?zfin.Config.ResolvedPath } {
if (explicit) |p| {
// Try resolveUserFile so bare names like "foo.srf" fall back to ZFIN_HOME.
if (config.resolveUserFile(io, allocator, p)) |r| {
return .{ .path = r.path, .resolved = r };
}
return .{ .path = p, .resolved = null };
}
if (config.resolveUserFile(io, allocator, default_name)) |r| {
return .{ .path = r.path, .resolved = r };
}
return .{ .path = default_name, .resolved = null };
}
pub fn main(init: std.process.Init) !u8 {
return runCli(init) catch |err| switch (err) {
// Downstream pipe closed (e.g., `zfin earnings AAPL | head`). Zig's
@ -288,8 +324,11 @@ fn runCli(init: std.process.Init) !u8 {
return 0;
}
// Parse global flags.
const globals = parseGlobals(args) catch |err| {
// Parse global flags. We allocate the patterns slice up-front
// (before the per-command arena exists) using the gpa, since
// parseGlobals needs to handle errors before we'd want to spin
// up an arena. Freed at the bottom of runCli.
const globals = parseGlobals(gpa_alloc, args) catch |err| {
switch (err) {
error.MissingValue => try cli.stderrPrint(io, "Error: global flag is missing its value\n"),
error.UnknownGlobalFlag => {
@ -300,9 +339,22 @@ fn runCli(init: std.process.Init) !u8 {
try cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n");
},
error.InvalidRefreshDataValue => try cli.stderrPrint(io, "Error: --refresh-data=<value> requires one of: auto, force, never.\n"),
error.UnquotedGlobLikely => {
try cli.stderrPrint(io,
\\Error: -p was given a single value followed by additional .srf files.
\\This usually means your shell expanded a glob before zfin saw it.
\\
\\Try one of:
\\ -p 'portfolio_*.srf' (quote to prevent shell expansion)
\\ -p portfolio_1.srf -p portfolio_2.srf (repeat the flag)
\\
);
},
error.OutOfMemory => return err,
}
return 1;
};
defer gpa_alloc.free(globals.portfolio_patterns);
if (globals.cursor >= args.len) {
try cli.stderrPrint(io, "Error: missing command.\nRun 'zfin help' for usage.\n");
@ -355,7 +407,16 @@ fn runCli(init: std.process.Init) !u8 {
var tui_config = zfin.Config.fromEnv(io, gpa_alloc, init.environ_map);
defer tui_config.deinit();
try out.flush();
tui.run(io, gpa_alloc, tui_config, globals.portfolio_path, globals.watchlist_path, cmd_args, today) catch |err| switch (err) {
// TUI today is single-portfolio. Pass the first explicit pattern
// (if any) through; tui.run resolves it the same way it always
// has. Multi-portfolio plumbing for the TUI is a follow-up;
// until then, users with multiple portfolio_*.srf files get the
// first match (sorted lexicographically) inside the TUI.
const tui_portfolio_path: ?[]const u8 = if (globals.portfolio_patterns.len > 0)
globals.portfolio_patterns[0]
else
null;
tui.run(io, gpa_alloc, tui_config, tui_portfolio_path, globals.watchlist_path, cmd_args, today) catch |err| switch (err) {
// tui.run already printed an actionable stderr message
// for invalid CLI args; surface as exit 1 without a
// panic / stack trace.
@ -413,7 +474,7 @@ fn runCli(init: std.process.Init) !u8 {
.svc = &svc,
.globals = .{
.no_color = globals.no_color,
.portfolio_path = globals.portfolio_path,
.portfolio_patterns = globals.portfolio_patterns,
.watchlist_path = globals.watchlist_path,
.refresh_policy = globals.refresh_policy,
},
@ -481,44 +542,56 @@ fn globalOffender(args: []const []const u8) ?[]const u8 {
// Tests
test "parseGlobals: no flags, subcommand only" {
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "portfolio" };
const g = try parseGlobals(&argv);
const g = try parseGlobals(allocator, &argv);
defer allocator.free(g.portfolio_patterns);
try std.testing.expectEqual(@as(usize, 1), g.cursor);
try std.testing.expectEqual(false, g.no_color);
try std.testing.expect(g.portfolio_path == null);
try std.testing.expectEqual(@as(usize, 0), g.portfolio_patterns.len);
try std.testing.expect(g.watchlist_path == null);
}
test "parseGlobals: --no-color, -p, -w then subcommand" {
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "--no-color", "-p", "foo.srf", "-w", "wl.srf", "analysis" };
const g = try parseGlobals(&argv);
const g = try parseGlobals(allocator, &argv);
defer allocator.free(g.portfolio_patterns);
try std.testing.expectEqual(@as(usize, 6), g.cursor);
try std.testing.expectEqual(true, g.no_color);
try std.testing.expectEqualStrings("foo.srf", g.portfolio_path.?);
try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
try std.testing.expectEqualStrings("foo.srf", g.portfolio_patterns[0]);
try std.testing.expectEqualStrings("wl.srf", g.watchlist_path.?);
}
test "parseGlobals: long forms" {
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "--portfolio", "foo.srf", "--watchlist", "wl.srf", "portfolio" };
const g = try parseGlobals(&argv);
const g = try parseGlobals(allocator, &argv);
defer allocator.free(g.portfolio_patterns);
try std.testing.expectEqual(@as(usize, 5), g.cursor);
try std.testing.expectEqualStrings("foo.srf", g.portfolio_path.?);
try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
try std.testing.expectEqualStrings("foo.srf", g.portfolio_patterns[0]);
try std.testing.expectEqualStrings("wl.srf", g.watchlist_path.?);
}
test "parseGlobals: unknown flag errors" {
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "--bogus", "quote", "AAPL" };
try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(&argv));
try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(allocator, &argv));
}
test "parseGlobals: flag missing value errors" {
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "-p" };
try std.testing.expectError(error.MissingValue, parseGlobals(&argv));
try std.testing.expectError(error.MissingValue, parseGlobals(allocator, &argv));
}
test "parseGlobals: --help stops scanning" {
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "--help" };
const g = try parseGlobals(&argv);
const g = try parseGlobals(allocator, &argv);
defer allocator.free(g.portfolio_patterns);
try std.testing.expectEqual(@as(usize, 1), g.cursor);
}
@ -526,8 +599,99 @@ test "parseGlobals: subcommand-local flag NOT consumed as global" {
// `--refresh` is a portfolio-command flag; should stop global scanning
// when it appears before the subcommand (even though that's not the
// intended usage, make sure the error is "unknown global").
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "--refresh", "portfolio" };
try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(&argv));
try std.testing.expectError(error.UnknownGlobalFlag, parseGlobals(allocator, &argv));
}
test "parseGlobals: -p repeated builds slice in argument order" {
// Multi-portfolio support: each `-p VALUE` appends. Order is
// preserved so users can reason about precedence (though Feature
// A union-merges the resolved set, the patterns themselves are
// ordered).
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "-p", "main.srf", "-p", "mom.srf", "portfolio" };
const g = try parseGlobals(allocator, &argv);
defer allocator.free(g.portfolio_patterns);
try std.testing.expectEqual(@as(usize, 2), g.portfolio_patterns.len);
try std.testing.expectEqualStrings("main.srf", g.portfolio_patterns[0]);
try std.testing.expectEqualStrings("mom.srf", g.portfolio_patterns[1]);
}
test "parseGlobals: -p with glob pattern (single value, quoted by shell)" {
// The user typed -p 'portfolio_*.srf' and the shell preserved the
// quotes; we get a single pattern arg with literal '*' in it.
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "-p", "portfolio_*.srf", "portfolio" };
const g = try parseGlobals(allocator, &argv);
defer allocator.free(g.portfolio_patterns);
try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
try std.testing.expectEqualStrings("portfolio_*.srf", g.portfolio_patterns[0]);
}
test "parseGlobals: unquoted-glob detector fires on multiple .srf args" {
// The user typed `-p portfolio_*.srf` without quotes; zsh expanded
// the glob into multiple bareword args. We surface a dedicated
// error so the user gets a friendly fix-it message.
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "-p", "portfolio_1.srf", "portfolio_2.srf", "portfolio" };
try std.testing.expectError(error.UnquotedGlobLikely, parseGlobals(allocator, &argv));
}
test "parseGlobals: unquoted-glob detector ignores legitimate -p + subcommand" {
// `-p main.srf snapshot` is fine: one .srf file, then a
// recognized-shape subcommand. Detector must NOT fire.
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "-p", "main.srf", "snapshot" };
const g = try parseGlobals(allocator, &argv);
defer allocator.free(g.portfolio_patterns);
try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
try std.testing.expectEqualStrings("main.srf", g.portfolio_patterns[0]);
}
test "parseGlobals: unquoted-glob detector handles trailing args ending the argv" {
// `-p a.srf b.srf c.srf` (with no subcommand following). The
// looksLikeUnquotedGlob loop hits end-of-argv with srf_count > 0
// and reports the suspicious shape.
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "-p", "a.srf", "b.srf", "c.srf" };
try std.testing.expectError(error.UnquotedGlobLikely, parseGlobals(allocator, &argv));
}
test "parseGlobals: unquoted-glob detector does NOT fire when only one .srf follows" {
// Just `-p something.srf` then a subcommand single-srf shape,
// no detection. Critical: future maintainers might tighten the
// heuristic and accidentally start firing here.
const allocator = std.testing.allocator;
const argv = [_][]const u8{ "zfin", "-p", "main.srf", "compare" };
const g = try parseGlobals(allocator, &argv);
defer allocator.free(g.portfolio_patterns);
try std.testing.expectEqual(@as(usize, 1), g.portfolio_patterns.len);
}
test "looksLikeUnquotedGlob: empty cursor yields false" {
const args = [_][]const u8{ "zfin", "-p", "main.srf" };
try std.testing.expect(!looksLikeUnquotedGlob(&args, args.len));
}
test "looksLikeUnquotedGlob: stops at flag-shaped token" {
// `-p a.srf -p b.srf` the second -p halts the scan after zero
// .srf files in the run, so the detector returns false.
const args = [_][]const u8{ "zfin", "-p", "a.srf", "-p", "b.srf" };
try std.testing.expect(!looksLikeUnquotedGlob(&args, 3));
}
test "looksLikeUnquotedGlob: srf followed by non-srf positional returns true" {
// `-p a.srf b.srf compare` a.srf is the consumed -p value, then
// b.srf is the suspicious extra. The non-srf "compare" arrives
// after we've already counted b.srf, so the detector fires.
const args = [_][]const u8{ "zfin", "-p", "a.srf", "b.srf", "compare" };
try std.testing.expect(looksLikeUnquotedGlob(&args, 3));
}
test "looksLikeUnquotedGlob: empty arg returns false" {
const args = [_][]const u8{ "zfin", "-p", "a.srf", "" };
try std.testing.expect(!looksLikeUnquotedGlob(&args, 3));
}
// Single test binary: all source is in one module (file imports, no module

View file

@ -2136,9 +2136,37 @@ pub fn run(
var resolved_pf: ?zfin.Config.ResolvedPath = null;
defer if (resolved_pf) |r| r.deinit(allocator);
if (portfolio_path == null and !has_explicit_symbol) {
if (config.resolveUserFile(io, allocator, zfin.Config.default_portfolio_filename)) |r| {
resolved_pf = r;
portfolio_path = r.path;
// The default portfolio pattern may be a glob (`portfolio*.srf`).
// Resolve via the multi-file API and take the first match
// multi-portfolio plumbing for the TUI is a follow-up; for
// now we pick the lexicographically-first hit so users with
// a single `portfolio.srf` keep their existing behavior.
var multi = config.resolveUserFiles(io, allocator, zfin.Config.default_portfolio_filename) catch zfin.Config.ResolvedPaths{ .paths = &.{}, .allocator = allocator };
defer multi.deinit();
if (multi.paths.len > 0) {
// Move the first ResolvedPath out of `multi`. Dupe its
// path to detach from `multi`'s allocator-tied storage.
const first = multi.paths[0];
const path_copy = allocator.dupe(u8, first.path) catch null;
if (path_copy) |pc| {
resolved_pf = .{ .path = pc, .owned = true };
portfolio_path = pc;
}
}
} else if (portfolio_path) |raw| {
// User passed -p; if the value contains a glob, expand it
// and pick the first match. Plain paths fall through unchanged.
if (zfin.Config.isGlobPattern(raw)) {
var multi = config.resolveUserFiles(io, allocator, raw) catch zfin.Config.ResolvedPaths{ .paths = &.{}, .allocator = allocator };
defer multi.deinit();
if (multi.paths.len > 0) {
const first = multi.paths[0];
const path_copy = allocator.dupe(u8, first.path) catch null;
if (path_copy) |pc| {
resolved_pf = .{ .path = pc, .owned = true };
portfolio_path = pc;
}
}
}
}