handle multiple portfolio paths - default file name portfolio*.srf
This commit is contained in:
parent
a06a4a8852
commit
e319313583
11 changed files with 1289 additions and 140 deletions
|
|
@ -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
|
||||
|
|
|
|||
367
src/Config.zig
367
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
|
|
|||
256
src/main.zig
256
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 <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
|
||||
|
|
|
|||
34
src/tui.zig
34
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue