diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7d50b07..cc31048 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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 diff --git a/src/Config.zig b/src/Config.zig index c46cc40..1f77469 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -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); +} diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index 70ab36f..fb65a7b 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -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 { diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 4cacde0..e076fc3 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -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 { diff --git a/src/commands/common.zig b/src/commands/common.zig index 02c3cc5..6332f57 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -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); diff --git a/src/commands/compare.zig b/src/commands/compare.zig index c6a6080..265d215 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -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, diff --git a/src/commands/framework.zig b/src/commands/framework.zig index 00f1ea6..d1a9479 100644 --- a/src/commands/framework.zig +++ b/src/commands/framework.zig @@ -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=`. @@ -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: " 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: " 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); +} diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 5326651..91f6249 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -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, diff --git a/src/commands/projections.zig b/src/commands/projections.zig index 043362c..ad90f82 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -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; }; diff --git a/src/main.zig b/src/main.zig index f9e50f3..569ed2b 100644 --- a/src/main.zig +++ b/src/main.zig @@ -70,10 +70,15 @@ const usage_footer = \\ of TTL freshness \\ never serve cache contents only; \\ no provider calls (offline mode) - \\ -p, --portfolio 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 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 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=`. @@ -153,13 +161,57 @@ const GlobalParseError = error{ UnknownGlobalFlag, /// `--refresh-data=` 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= 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 diff --git a/src/tui.zig b/src/tui.zig index a0e1647..e551032 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -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; + } + } } }