single code path for portfolio loading
This commit is contained in:
parent
e6728e9507
commit
415123f6a6
8 changed files with 209 additions and 99 deletions
|
|
@ -412,11 +412,41 @@ pub const LoadedPortfolio = struct {
|
||||||
/// don't need to call `ctx.resolvePortfolioPaths()` separately. Use
|
/// don't need to call `ctx.resolvePortfolioPaths()` separately. Use
|
||||||
/// `loaded.anchor()` for sibling-file derivation; iterate
|
/// `loaded.anchor()` for sibling-file derivation; iterate
|
||||||
/// `loaded.paths` if the command genuinely needs the per-file list.
|
/// `loaded.paths` if the command genuinely needs the per-file list.
|
||||||
|
///
|
||||||
|
/// Thin wrapper over `loadPortfolioFromConfig` that pulls
|
||||||
|
/// `(io, allocator, config, patterns)` out of the RunCtx. Both CLI
|
||||||
|
/// dispatch and the TUI go through `loadPortfolioFromConfig`, so
|
||||||
|
/// the resulting `LoadedPortfolio` is byte-identical regardless of
|
||||||
|
/// which surface invoked it.
|
||||||
pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio {
|
pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio {
|
||||||
const io = ctx.io;
|
return loadPortfolioFromConfig(
|
||||||
const allocator = ctx.allocator;
|
ctx.io,
|
||||||
|
ctx.allocator,
|
||||||
|
ctx.config,
|
||||||
|
ctx.globals.portfolio_patterns,
|
||||||
|
as_of,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
var resolved = ctx.resolvePortfolioPaths() catch |err| switch (err) {
|
/// Resolve `patterns` against `config` (cwd → ZFIN_HOME), then load
|
||||||
|
/// the union of all matched portfolio files. The TUI uses this
|
||||||
|
/// directly (no `RunCtx`); CLI commands go through
|
||||||
|
/// `loadPortfolio(ctx, ...)` which is a thin wrapper.
|
||||||
|
///
|
||||||
|
/// `patterns` is the user-supplied `-p` slice; pass an empty slice
|
||||||
|
/// (`&.{}`) for the default `portfolio*.srf` behavior.
|
||||||
|
///
|
||||||
|
/// Returns `null` on any error path (a stderr message has already
|
||||||
|
/// been printed). Caller must `deinit(allocator)` the returned
|
||||||
|
/// struct.
|
||||||
|
pub fn loadPortfolioFromConfig(
|
||||||
|
io: std.Io,
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
config: zfin.Config,
|
||||||
|
patterns: []const []const u8,
|
||||||
|
as_of: zfin.Date,
|
||||||
|
) ?LoadedPortfolio {
|
||||||
|
var resolved = framework.resolvePatterns(io, allocator, config, patterns) catch |err| switch (err) {
|
||||||
error.MixedPortfolioDirs => {
|
error.MixedPortfolioDirs => {
|
||||||
stderrPrint(io, "Error: portfolio files resolved to multiple directories.\n") catch {};
|
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, " Sibling files (accounts.srf, metadata.srf, transaction_log.srf) live\n") catch {};
|
||||||
|
|
@ -434,12 +464,16 @@ pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
// Snapshot the path-string view as our own owned slice. Backing
|
// Snapshot the path-string view as our own owned slice. Backing
|
||||||
// strings stay live as long as `resolved` does — we hand both
|
// strings stay live as long as `resolved.inner` does — we
|
||||||
// off to LoadedPortfolio which owns the deinit chain.
|
// hand `inner` off to LoadedPortfolio (it'll be freed by
|
||||||
|
// `LoadedPortfolio.deinit`). The framework-level `resolved.paths`
|
||||||
|
// view slice is allocator-owned but redundant after the dupe;
|
||||||
|
// free it before discarding the wrapper.
|
||||||
const paths_owned = allocator.dupe([]const u8, resolved.paths) catch {
|
const paths_owned = allocator.dupe([]const u8, resolved.paths) catch {
|
||||||
resolved.deinit();
|
resolved.deinit();
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
allocator.free(resolved.paths);
|
||||||
return loadFromPaths(io, allocator, paths_owned, resolved.inner, as_of);
|
return loadFromPaths(io, allocator, paths_owned, resolved.inner, as_of);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1534,6 +1568,59 @@ test "loadPortfolioFromPaths: bails on parse error in second file without leakin
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "loadPortfolioFromConfig: same merged result as the CLI sees, callable without RunCtx" {
|
||||||
|
// Pins the load-bearing CLI/TUI parity property: both
|
||||||
|
// surfaces go through `loadPortfolioFromConfig`, so the
|
||||||
|
// merged Portfolio is bit-for-bit the same regardless of
|
||||||
|
// who's calling. Without this, the TUI's pre-unification
|
||||||
|
// single-file load drifted from the CLI's multi-file load
|
||||||
|
// and reported different totals — the bug that motivated
|
||||||
|
// the unification.
|
||||||
|
const io = std.testing.io;
|
||||||
|
const allocator = std.testing.allocator;
|
||||||
|
var tmp = std.testing.tmpDir(.{});
|
||||||
|
defer tmp.cleanup();
|
||||||
|
|
||||||
|
// Use a `zfintest_pf*.srf` pattern instead of the default
|
||||||
|
// `portfolio*.srf` so the test runner's cwd (the repo root,
|
||||||
|
// which has a real `portfolio-semilatest.srf`) doesn't
|
||||||
|
// shadow our tmp dir via the cwd-first resolution rule.
|
||||||
|
try tmp.dir.writeFile(io, .{
|
||||||
|
.sub_path = "zfintest_pf.srf",
|
||||||
|
.data =
|
||||||
|
\\#!srfv1
|
||||||
|
\\symbol::AAPL,shares:num:100,open_date::2024-01-15,open_price:num:150.00
|
||||||
|
\\
|
||||||
|
,
|
||||||
|
});
|
||||||
|
try tmp.dir.writeFile(io, .{
|
||||||
|
.sub_path = "zfintest_pf_extra.srf",
|
||||||
|
.data =
|
||||||
|
\\#!srfv1
|
||||||
|
\\symbol::MSFT,shares:num:50,open_date::2024-02-20,open_price:num:300.00
|
||||||
|
\\
|
||||||
|
,
|
||||||
|
});
|
||||||
|
|
||||||
|
var path_buf: [std.fs.max_path_bytes]u8 = undefined;
|
||||||
|
const dir_len = try tmp.dir.realPathFile(io, ".", &path_buf);
|
||||||
|
const dir = try allocator.dupe(u8, path_buf[0..dir_len]);
|
||||||
|
defer allocator.free(dir);
|
||||||
|
|
||||||
|
const config: zfin.Config = .{ .cache_dir = "/tmp", .zfin_home = dir };
|
||||||
|
const pat = "zfintest_pf*.srf";
|
||||||
|
const patterns = [_][]const u8{pat};
|
||||||
|
|
||||||
|
var loaded = loadPortfolioFromConfig(io, allocator, config, &patterns, zfin.Date.fromYmd(2026, 5, 8)) orelse return error.TestUnexpectedResult;
|
||||||
|
defer loaded.deinit(allocator);
|
||||||
|
|
||||||
|
// Both files contributed → 2 lots in the merged portfolio.
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len);
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), loaded.paths.len);
|
||||||
|
// Anchor is the lex-first match → zfintest_pf.srf (not _extra).
|
||||||
|
try std.testing.expect(std.mem.endsWith(u8, loaded.anchor(), "zfintest_pf.srf"));
|
||||||
|
}
|
||||||
|
|
||||||
test "buildPortfolioData: empty positions returns NoAllocations" {
|
test "buildPortfolioData: empty positions returns NoAllocations" {
|
||||||
const config = zfin.Config{ .cache_dir = "/tmp" };
|
const config = zfin.Config{ .cache_dir = "/tmp" };
|
||||||
var svc = zfin.DataService.init(std.testing.io, std.testing.allocator, config);
|
var svc = zfin.DataService.init(std.testing.io, std.testing.allocator, config);
|
||||||
|
|
|
||||||
|
|
@ -259,7 +259,7 @@ pub const RunCtx = struct {
|
||||||
/// allocations (paths and their containing slice). Allocations
|
/// allocations (paths and their containing slice). Allocations
|
||||||
/// come from the arena allocator.
|
/// come from the arena allocator.
|
||||||
pub fn resolvePortfolioPaths(self: *RunCtx) !ResolvedPaths {
|
pub fn resolvePortfolioPaths(self: *RunCtx) !ResolvedPaths {
|
||||||
return resolvePortfolioPathsImpl(
|
return resolvePatterns(
|
||||||
self.io,
|
self.io,
|
||||||
self.allocator,
|
self.allocator,
|
||||||
self.config,
|
self.config,
|
||||||
|
|
@ -331,7 +331,18 @@ pub const ResolvedPaths = struct {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
fn resolvePortfolioPathsImpl(
|
/// Resolve portfolio path `patterns` against the config (cwd then
|
||||||
|
/// ZFIN_HOME) and return the union of all matched files. Same
|
||||||
|
/// semantics as `RunCtx.resolvePortfolioPaths` but takes the inputs
|
||||||
|
/// directly so non-CLI callers (the TUI, tests) can use the same
|
||||||
|
/// resolution logic without constructing a `RunCtx`.
|
||||||
|
///
|
||||||
|
/// `patterns` may be empty, in which case the default
|
||||||
|
/// `Config.default_portfolio_filename` glob is used.
|
||||||
|
///
|
||||||
|
/// Caller MUST `result.deinit()` to release the per-path
|
||||||
|
/// allocations (paths and their containing slice).
|
||||||
|
pub fn resolvePatterns(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
config: zfin.Config,
|
config: zfin.Config,
|
||||||
|
|
@ -799,14 +810,14 @@ test "printGroupedUsage: omits empty groups" {
|
||||||
|
|
||||||
// ── resolvePortfolioPaths tests ───────────────────────────────
|
// ── resolvePortfolioPaths tests ───────────────────────────────
|
||||||
|
|
||||||
test "resolvePortfolioPathsImpl: empty patterns falls back to default glob" {
|
test "resolvePatterns: empty patterns falls back to default glob" {
|
||||||
// With no zfin_home configured, resolveUserFiles for the default
|
// With no zfin_home configured, resolveUserFiles for the default
|
||||||
// pattern returns 0 matches in a clean tmp dir. The Impl returns
|
// pattern returns 0 matches in a clean tmp dir. The Impl returns
|
||||||
// an empty resolved list (not an error) so callers can produce
|
// an empty resolved list (not an error) so callers can produce
|
||||||
// "no portfolio file found" themselves.
|
// "no portfolio file found" themselves.
|
||||||
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
||||||
const empty: []const []const u8 = &.{};
|
const empty: []const []const u8 = &.{};
|
||||||
var result = try resolvePortfolioPathsImpl(std.testing.io, testing.allocator, config, empty);
|
var result = try resolvePatterns(std.testing.io, testing.allocator, config, empty);
|
||||||
defer result.deinit();
|
defer result.deinit();
|
||||||
// 0 or 1 match depending on whether the test runner's cwd has a
|
// 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
|
// portfolio*.srf file. We just check it doesn't crash and the
|
||||||
|
|
@ -814,30 +825,30 @@ test "resolvePortfolioPathsImpl: empty patterns falls back to default glob" {
|
||||||
try testing.expectEqual(result.inner.paths.len, result.paths.len);
|
try testing.expectEqual(result.inner.paths.len, result.paths.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "resolvePortfolioPathsImpl: literal not-found is preserved as a literal" {
|
test "resolvePatterns: literal not-found is preserved as a literal" {
|
||||||
// The legacy single-path API returned an explicit -p value even
|
// The legacy single-path API returned an explicit -p value even
|
||||||
// when it didn't exist on disk, so the caller could produce a
|
// when it didn't exist on disk, so the caller could produce a
|
||||||
// "Cannot read: <path>" error naming the right file. We mirror
|
// "Cannot read: <path>" error naming the right file. We mirror
|
||||||
// that for the multi-path API.
|
// that for the multi-path API.
|
||||||
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
||||||
const patterns = [_][]const u8{"/zfin-test-no-such-portfolio.srf"};
|
const patterns = [_][]const u8{"/zfin-test-no-such-portfolio.srf"};
|
||||||
var result = try resolvePortfolioPathsImpl(std.testing.io, testing.allocator, config, &patterns);
|
var result = try resolvePatterns(std.testing.io, testing.allocator, config, &patterns);
|
||||||
defer result.deinit();
|
defer result.deinit();
|
||||||
try testing.expectEqual(@as(usize, 1), result.paths.len);
|
try testing.expectEqual(@as(usize, 1), result.paths.len);
|
||||||
try testing.expectEqualStrings("/zfin-test-no-such-portfolio.srf", result.paths[0]);
|
try testing.expectEqualStrings("/zfin-test-no-such-portfolio.srf", result.paths[0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "resolvePortfolioPathsImpl: glob with no matches resolves to empty" {
|
test "resolvePatterns: glob with no matches resolves to empty" {
|
||||||
// Globs that match nothing are dropped silently — the user
|
// Globs that match nothing are dropped silently — the user
|
||||||
// typed a glob, they know it might match zero files.
|
// typed a glob, they know it might match zero files.
|
||||||
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
||||||
const patterns = [_][]const u8{"zfin-test-nope-*.srf-xyz"};
|
const patterns = [_][]const u8{"zfin-test-nope-*.srf-xyz"};
|
||||||
var result = try resolvePortfolioPathsImpl(std.testing.io, testing.allocator, config, &patterns);
|
var result = try resolvePatterns(std.testing.io, testing.allocator, config, &patterns);
|
||||||
defer result.deinit();
|
defer result.deinit();
|
||||||
try testing.expectEqual(@as(usize, 0), result.paths.len);
|
try testing.expectEqual(@as(usize, 0), result.paths.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "resolvePortfolioPathsImpl: two patterns matching same dir union-merge" {
|
test "resolvePatterns: two patterns matching same dir union-merge" {
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
defer tmp.cleanup();
|
defer tmp.cleanup();
|
||||||
|
|
@ -856,12 +867,12 @@ test "resolvePortfolioPathsImpl: two patterns matching same dir union-merge" {
|
||||||
|
|
||||||
const patterns = [_][]const u8{ main_path, mom_path };
|
const patterns = [_][]const u8{ main_path, mom_path };
|
||||||
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
||||||
var result = try resolvePortfolioPathsImpl(io, testing.allocator, config, &patterns);
|
var result = try resolvePatterns(io, testing.allocator, config, &patterns);
|
||||||
defer result.deinit();
|
defer result.deinit();
|
||||||
try testing.expectEqual(@as(usize, 2), result.paths.len);
|
try testing.expectEqual(@as(usize, 2), result.paths.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "resolvePortfolioPathsImpl: duplicate pattern de-dups" {
|
test "resolvePatterns: duplicate pattern de-dups" {
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
defer tmp.cleanup();
|
defer tmp.cleanup();
|
||||||
|
|
@ -877,13 +888,13 @@ test "resolvePortfolioPathsImpl: duplicate pattern de-dups" {
|
||||||
|
|
||||||
const patterns = [_][]const u8{ p, p };
|
const patterns = [_][]const u8{ p, p };
|
||||||
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
||||||
var result = try resolvePortfolioPathsImpl(io, testing.allocator, config, &patterns);
|
var result = try resolvePatterns(io, testing.allocator, config, &patterns);
|
||||||
defer result.deinit();
|
defer result.deinit();
|
||||||
// Same path passed twice → 1 entry.
|
// Same path passed twice → 1 entry.
|
||||||
try testing.expectEqual(@as(usize, 1), result.paths.len);
|
try testing.expectEqual(@as(usize, 1), result.paths.len);
|
||||||
}
|
}
|
||||||
|
|
||||||
test "resolvePortfolioPathsImpl: mixed directories error" {
|
test "resolvePatterns: mixed directories error" {
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
|
|
||||||
var tmp_a = std.testing.tmpDir(.{});
|
var tmp_a = std.testing.tmpDir(.{});
|
||||||
|
|
@ -906,10 +917,10 @@ test "resolvePortfolioPathsImpl: mixed directories error" {
|
||||||
|
|
||||||
const patterns = [_][]const u8{ path_a, path_b };
|
const patterns = [_][]const u8{ path_a, path_b };
|
||||||
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
||||||
try testing.expectError(error.MixedPortfolioDirs, resolvePortfolioPathsImpl(io, testing.allocator, config, &patterns));
|
try testing.expectError(error.MixedPortfolioDirs, resolvePatterns(io, testing.allocator, config, &patterns));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "resolvePortfolioPathsImpl: same dir different patterns OK (no mixed-dir error)" {
|
test "resolvePatterns: same dir different patterns OK (no mixed-dir error)" {
|
||||||
const io = std.testing.io;
|
const io = std.testing.io;
|
||||||
var tmp = std.testing.tmpDir(.{});
|
var tmp = std.testing.tmpDir(.{});
|
||||||
defer tmp.cleanup();
|
defer tmp.cleanup();
|
||||||
|
|
@ -928,7 +939,7 @@ test "resolvePortfolioPathsImpl: same dir different patterns OK (no mixed-dir er
|
||||||
|
|
||||||
const patterns = [_][]const u8{ path_a, path_b };
|
const patterns = [_][]const u8{ path_a, path_b };
|
||||||
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
const config: zfin.Config = .{ .cache_dir = "/tmp" };
|
||||||
var result = try resolvePortfolioPathsImpl(io, testing.allocator, config, &patterns);
|
var result = try resolvePatterns(io, testing.allocator, config, &patterns);
|
||||||
defer result.deinit();
|
defer result.deinit();
|
||||||
try testing.expectEqual(@as(usize, 2), result.paths.len);
|
try testing.expectEqual(@as(usize, 2), result.paths.len);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
15
src/main.zig
15
src/main.zig
|
|
@ -409,15 +409,12 @@ fn runCli(init: std.process.Init) !u8 {
|
||||||
defer tui_config.deinit();
|
defer tui_config.deinit();
|
||||||
try out.flush();
|
try out.flush();
|
||||||
// TUI today is single-portfolio. Pass the first explicit pattern
|
// TUI today is single-portfolio. Pass the first explicit pattern
|
||||||
// (if any) through; tui.run resolves it the same way it always
|
// Multi-portfolio is now wired all the way through to the
|
||||||
// has. Multi-portfolio plumbing for the TUI is a follow-up;
|
// TUI: pass the raw `-p` pattern slice and let the TUI's
|
||||||
// until then, users with multiple portfolio_*.srf files get the
|
// loader resolve + union-merge the same way the CLI does.
|
||||||
// first match (sorted lexicographically) inside the TUI.
|
// This is the load-bearing fix for "CLI and TUI report
|
||||||
const tui_portfolio_path: ?[]const u8 = if (globals.portfolio_patterns.len > 0)
|
// different totals" — there's exactly one code path now.
|
||||||
globals.portfolio_patterns[0]
|
tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, globals.watchlist_path, cmd_args, today) catch |err| switch (err) {
|
||||||
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
|
// tui.run already printed an actionable stderr message
|
||||||
// for invalid CLI args; surface as exit 1 without a
|
// for invalid CLI args; surface as exit 1 without a
|
||||||
// panic / stack trace.
|
// panic / stack trace.
|
||||||
|
|
|
||||||
101
src/tui.zig
101
src/tui.zig
|
|
@ -511,7 +511,17 @@ pub const App = struct {
|
||||||
|
|
||||||
has_explicit_symbol: bool = false, // true if -s was used
|
has_explicit_symbol: bool = false, // true if -s was used
|
||||||
|
|
||||||
portfolio_path: ?[]const u8 = null,
|
/// Resolved portfolio file paths (the union of `-p` patterns
|
||||||
|
/// after globbing). Empty when no portfolio loaded. The first
|
||||||
|
/// element is the *anchor* used for sibling-file derivation
|
||||||
|
/// (`accounts.srf`, history dir, etc.); use `anchorPath()` for
|
||||||
|
/// that. Owned by the TUI; freed in `deinitData`.
|
||||||
|
portfolio_paths: []const []const u8 = &.{},
|
||||||
|
/// `Config.ResolvedPaths` backing `portfolio_paths`. Holds the
|
||||||
|
/// path strings; `portfolio_paths` is a borrowed view.
|
||||||
|
/// Optional so a future code path can hand off a pre-resolved
|
||||||
|
/// path slice without going through Config.
|
||||||
|
portfolio_resolved: ?zfin.Config.ResolvedPaths = null,
|
||||||
watchlist: ?[][]const u8 = null,
|
watchlist: ?[][]const u8 = null,
|
||||||
watchlist_path: ?[]const u8 = null,
|
watchlist_path: ?[]const u8 = null,
|
||||||
status_msg: [256]u8 = undefined,
|
status_msg: [256]u8 = undefined,
|
||||||
|
|
@ -954,10 +964,11 @@ pub const App = struct {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Load accounts.srf if not already loaded. Derives path from portfolio_path.
|
/// Load accounts.srf if not already loaded. Derives path from
|
||||||
|
/// the portfolio anchor (first resolved path).
|
||||||
pub fn ensureAccountMap(self: *App) void {
|
pub fn ensureAccountMap(self: *App) void {
|
||||||
if (self.portfolio.account_map != null) return;
|
if (self.portfolio.account_map != null) return;
|
||||||
const ppath = self.portfolio_path orelse return;
|
const ppath = self.anchorPath() orelse return;
|
||||||
self.portfolio.account_map = self.svc.loadAccountMap(ppath);
|
self.portfolio.account_map = self.svc.loadAccountMap(ppath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1511,12 +1522,25 @@ pub const App = struct {
|
||||||
tab_modules.history.tab.deinit(&self.states.history, self);
|
tab_modules.history.tab.deinit(&self.states.history, self);
|
||||||
tab_modules.projections.tab.deinit(&self.states.projections, self);
|
tab_modules.projections.tab.deinit(&self.states.projections, self);
|
||||||
tab_modules.quote.tab.deinit(&self.states.quote, self);
|
tab_modules.quote.tab.deinit(&self.states.quote, self);
|
||||||
|
if (self.portfolio_resolved) |rp| rp.deinit();
|
||||||
|
if (self.portfolio_paths.len > 0) self.allocator.free(self.portfolio_paths);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reloadPortfolioFile(self: *App) void {
|
fn reloadPortfolioFile(self: *App) void {
|
||||||
tab_modules.portfolio.reloadPortfolioFile(&self.states.portfolio, self);
|
tab_modules.portfolio.reloadPortfolioFile(&self.states.portfolio, self);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// First (anchor) portfolio path, used for sibling-file
|
||||||
|
/// derivation (`accounts.srf`, `metadata.srf`,
|
||||||
|
/// `transaction_log.srf`, history dir). Returns null when
|
||||||
|
/// no portfolio is loaded. Mirrors `LoadedPortfolio.anchor`
|
||||||
|
/// on the CLI side; the two surfaces compute it the same way
|
||||||
|
/// because they share the same loader.
|
||||||
|
pub fn anchorPath(self: *const App) ?[]const u8 {
|
||||||
|
if (self.portfolio_paths.len == 0) return null;
|
||||||
|
return self.portfolio_paths[0];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Drawing ──────────────────────────────────────────────────
|
// ── Drawing ──────────────────────────────────────────────────
|
||||||
|
|
||||||
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vaxis.vxfw.DrawContext) std.mem.Allocator.Error!vaxis.vxfw.Surface {
|
fn typeErasedDrawFn(ptr: *anyopaque, ctx: vaxis.vxfw.DrawContext) std.mem.Allocator.Error!vaxis.vxfw.Surface {
|
||||||
|
|
@ -2048,12 +2072,11 @@ pub fn run(
|
||||||
io: std.Io,
|
io: std.Io,
|
||||||
allocator: std.mem.Allocator,
|
allocator: std.mem.Allocator,
|
||||||
config: zfin.Config,
|
config: zfin.Config,
|
||||||
global_portfolio_path: ?[]const u8,
|
portfolio_patterns: []const []const u8,
|
||||||
global_watchlist_path: ?[]const u8,
|
global_watchlist_path: ?[]const u8,
|
||||||
args: []const []const u8,
|
args: []const []const u8,
|
||||||
today: zfin.Date,
|
today: zfin.Date,
|
||||||
) !void {
|
) !void {
|
||||||
var portfolio_path: ?[]const u8 = global_portfolio_path;
|
|
||||||
const watchlist_path: ?[]const u8 = global_watchlist_path;
|
const watchlist_path: ?[]const u8 = global_watchlist_path;
|
||||||
var symbol: []const u8 = "";
|
var symbol: []const u8 = "";
|
||||||
var symbol_upper_buf: [32]u8 = undefined;
|
var symbol_upper_buf: [32]u8 = undefined;
|
||||||
|
|
@ -2133,43 +2156,6 @@ 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) {
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
var keymap = blk: {
|
var keymap = blk: {
|
||||||
const home_opt = if (config.environ_map) |em| em.get("HOME") else null;
|
const home_opt = if (config.environ_map) |em| em.get("HOME") else null;
|
||||||
const home = home_opt orelse break :blk keybinds.defaults();
|
const home = home_opt orelse break :blk keybinds.defaults();
|
||||||
|
|
@ -2238,7 +2224,6 @@ pub fn run(
|
||||||
.svc = svc,
|
.svc = svc,
|
||||||
.keymap = keymap,
|
.keymap = keymap,
|
||||||
.theme = loaded_theme,
|
.theme = loaded_theme,
|
||||||
.portfolio_path = portfolio_path,
|
|
||||||
.symbol = symbol,
|
.symbol = symbol,
|
||||||
.has_explicit_symbol = has_explicit_symbol,
|
.has_explicit_symbol = has_explicit_symbol,
|
||||||
.chart_config = chart_config,
|
.chart_config = chart_config,
|
||||||
|
|
@ -2248,13 +2233,29 @@ pub fn run(
|
||||||
// in `App.deinitData`.
|
// in `App.deinitData`.
|
||||||
try tab_modules.history.tab.init(&app_inst.states.history, app_inst);
|
try tab_modules.history.tab.init(&app_inst.states.history, app_inst);
|
||||||
|
|
||||||
if (portfolio_path) |path| {
|
// Load the portfolio. Goes through the same loader the CLI
|
||||||
const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch null;
|
// uses, so the TUI sees the same merged view (every matching
|
||||||
if (file_data) |d| {
|
// `portfolio*.srf` in the resolved directory). The
|
||||||
defer allocator.free(d);
|
// LoadedPortfolio's path slice + ResolvedPaths handle move
|
||||||
if (zfin.cache.deserializePortfolio(allocator, d)) |pf| {
|
// into the App so deinit ownership stays consistent.
|
||||||
app_inst.portfolio.file = pf;
|
if (!has_explicit_symbol) {
|
||||||
} else |_| {}
|
if (cli.loadPortfolioFromConfig(io, allocator, config, portfolio_patterns, today)) |loaded| {
|
||||||
|
// We only need the merged Portfolio + the path slice
|
||||||
|
// for this surface. Discard the auxiliary
|
||||||
|
// file_datas/positions/syms — the TUI recomputes
|
||||||
|
// those on its own from app.portfolio.file once
|
||||||
|
// prices are loaded.
|
||||||
|
app_inst.portfolio.file = loaded.portfolio;
|
||||||
|
app_inst.portfolio_paths = loaded.paths;
|
||||||
|
app_inst.portfolio_resolved = loaded.resolved_paths;
|
||||||
|
// Free the file-data buffers and computed slices we
|
||||||
|
// don't keep. (See LoadedPortfolio.deinit; we mirror
|
||||||
|
// its cleanup but skip the parts we just took
|
||||||
|
// ownership of.)
|
||||||
|
allocator.free(loaded.syms);
|
||||||
|
allocator.free(loaded.positions);
|
||||||
|
for (loaded.file_datas) |d| allocator.free(d);
|
||||||
|
allocator.free(loaded.file_datas);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,7 +112,7 @@ fn loadData(state: *State, app: *App) void {
|
||||||
// Load classification metadata file
|
// Load classification metadata file
|
||||||
if (state.classification_map == null) {
|
if (state.classification_map == null) {
|
||||||
// Look for metadata.srf next to the portfolio file
|
// Look for metadata.srf next to the portfolio file
|
||||||
if (app.portfolio_path) |ppath| {
|
if (app.anchorPath()) |ppath| {
|
||||||
// Derive metadata path: same directory as portfolio, named "metadata.srf"
|
// Derive metadata path: same directory as portfolio, named "metadata.srf"
|
||||||
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
|
const dir_end = if (std.mem.lastIndexOfScalar(u8, ppath, std.fs.path.sep)) |idx| idx + 1 else 0;
|
||||||
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
|
const meta_path = std.fmt.allocPrint(app.allocator, "{s}metadata.srf", .{ppath[0..dir_end]}) catch return;
|
||||||
|
|
|
||||||
|
|
@ -354,7 +354,7 @@ pub fn loadData(state: *State, app: *App) void {
|
||||||
state.loaded = true;
|
state.loaded = true;
|
||||||
freeLoaded(state, app);
|
freeLoaded(state, app);
|
||||||
|
|
||||||
const portfolio_path = app.portfolio_path orelse {
|
const portfolio_path = app.anchorPath() orelse {
|
||||||
app.setStatus("History tab requires a loaded portfolio");
|
app.setStatus("History tab requires a loaded portfolio");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -640,7 +640,7 @@ fn buildCompareFromSelections(state: *State, app: *App, sel_a: usize, sel_b: usi
|
||||||
var resources: CompareResources = .{};
|
var resources: CompareResources = .{};
|
||||||
errdefer resources.deinit(app.allocator);
|
errdefer resources.deinit(app.allocator);
|
||||||
|
|
||||||
const portfolio_path = app.portfolio_path orelse {
|
const portfolio_path = app.anchorPath() orelse {
|
||||||
app.setStatus("No portfolio loaded — can't build compare");
|
app.setStatus("No portfolio loaded — can't build compare");
|
||||||
return error.PortfolioLoadFailed;
|
return error.PortfolioLoadFailed;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1666,32 +1666,46 @@ pub fn buildWelcomeScreenLines(
|
||||||
|
|
||||||
/// Reload portfolio file from disk without re-fetching prices.
|
/// Reload portfolio file from disk without re-fetching prices.
|
||||||
/// Uses cached candle data to recompute summary.
|
/// Uses cached candle data to recompute summary.
|
||||||
|
///
|
||||||
|
/// Goes through the same `cli.loadPortfolioFromPaths` the initial
|
||||||
|
/// load uses, so a manual reload sees the merged view of every
|
||||||
|
/// `portfolio*.srf` in the resolved directory — same as the CLI.
|
||||||
pub fn reloadPortfolioFile(state: *State, app: *App) void {
|
pub fn reloadPortfolioFile(state: *State, app: *App) void {
|
||||||
// Save the account filter name before freeing the old portfolio.
|
// Save the account filter name before freeing the old portfolio.
|
||||||
// account_filter is an owned copy so it survives the portfolio free,
|
// account_filter is an owned copy so it survives the portfolio free,
|
||||||
// but account_list entries borrow from the portfolio and will dangle.
|
// but account_list entries borrow from the portfolio and will dangle.
|
||||||
state.account_list.clearRetainingCapacity();
|
state.account_list.clearRetainingCapacity();
|
||||||
|
|
||||||
// Re-read the portfolio file
|
// Re-read the portfolio file(s)
|
||||||
if (app.portfolio.file) |*pf| pf.deinit();
|
if (app.portfolio.file) |*pf| pf.deinit();
|
||||||
app.portfolio.file = null;
|
app.portfolio.file = null;
|
||||||
if (app.portfolio_path) |path| {
|
|
||||||
const file_data = std.Io.Dir.cwd().readFileAlloc(app.io, path, app.allocator, .limited(10 * 1024 * 1024)) catch {
|
if (app.portfolio_paths.len == 0) {
|
||||||
app.setStatus("Error reading portfolio file");
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
defer app.allocator.free(file_data);
|
|
||||||
if (zfin.cache.deserializePortfolio(app.allocator, file_data)) |pf| {
|
|
||||||
app.portfolio.file = pf;
|
|
||||||
} else |_| {
|
|
||||||
app.setStatus("Error parsing portfolio file");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
app.setStatus("No portfolio file to reload");
|
app.setStatus("No portfolio file to reload");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (cli.loadPortfolioFromPaths(app.io, app.allocator, app.portfolio_paths, app.today)) |loaded| {
|
||||||
|
// Take the merged Portfolio; discard the auxiliary slices
|
||||||
|
// we don't keep on App. Note we deliberately don't replace
|
||||||
|
// `portfolio_paths` here — those still come from the
|
||||||
|
// initial resolution. If new portfolio files appear, the
|
||||||
|
// user can restart the TUI to pick them up.
|
||||||
|
app.portfolio.file = loaded.portfolio;
|
||||||
|
app.allocator.free(loaded.syms);
|
||||||
|
app.allocator.free(loaded.positions);
|
||||||
|
for (loaded.file_datas) |d| app.allocator.free(d);
|
||||||
|
app.allocator.free(loaded.file_datas);
|
||||||
|
// The path slice + ResolvedPaths the loader allocated for
|
||||||
|
// its own LoadedPortfolio are NOT what App stores. Free
|
||||||
|
// them; App's `portfolio_paths` stays put.
|
||||||
|
app.allocator.free(loaded.paths);
|
||||||
|
if (loaded.resolved_paths) |rp| rp.deinit();
|
||||||
|
} else {
|
||||||
|
app.setStatus("Error reloading portfolio file");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Reload watchlist file too (if separate)
|
// Reload watchlist file too (if separate)
|
||||||
tui.freeWatchlist(app.allocator, app.watchlist);
|
tui.freeWatchlist(app.allocator, app.watchlist);
|
||||||
app.watchlist = null;
|
app.watchlist = null;
|
||||||
|
|
|
||||||
|
|
@ -431,7 +431,7 @@ pub fn loadData(state: *State, app: *App) void {
|
||||||
state.loaded = true;
|
state.loaded = true;
|
||||||
freeLoaded(state, app);
|
freeLoaded(state, app);
|
||||||
|
|
||||||
const portfolio_path = app.portfolio_path orelse {
|
const portfolio_path = app.anchorPath() orelse {
|
||||||
app.setStatus("Projections tab requires a loaded portfolio");
|
app.setStatus("Projections tab requires a loaded portfolio");
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
@ -740,7 +740,7 @@ fn ensureBacktestLoaded(state: *State, app: *App) void {
|
||||||
/// for the current portfolio, returning null when no portfolio is
|
/// for the current portfolio, returning null when no portfolio is
|
||||||
/// loaded. Caller owns the returned slice.
|
/// loaded. Caller owns the returned slice.
|
||||||
fn importedValuesPath(app: *App) ?[]u8 {
|
fn importedValuesPath(app: *App) ?[]u8 {
|
||||||
const ppath = app.portfolio_path orelse return null;
|
const ppath = app.anchorPath() orelse return null;
|
||||||
const hist_dir = history.deriveHistoryDir(app.allocator, ppath) catch return null;
|
const hist_dir = history.deriveHistoryDir(app.allocator, ppath) catch return null;
|
||||||
defer app.allocator.free(hist_dir);
|
defer app.allocator.free(hist_dir);
|
||||||
return std.fs.path.join(app.allocator, &.{ hist_dir, "imported_values.srf" }) catch null;
|
return std.fs.path.join(app.allocator, &.{ hist_dir, "imported_values.srf" }) catch null;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue