From 65bb84e6d5b0189a405a34e9f2b0d20400fcac52 Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Sat, 23 May 2026 08:44:46 -0700 Subject: [PATCH] single code path for portfolio loading --- src/commands/common.zig | 97 ++++++++++++++++++++++++++++++++-- src/commands/framework.zig | 43 +++++++++------ src/main.zig | 15 +++--- src/tui.zig | 101 ++++++++++++++++++------------------ src/tui/analysis_tab.zig | 2 +- src/tui/history_tab.zig | 4 +- src/tui/portfolio_tab.zig | 42 ++++++++++----- src/tui/projections_tab.zig | 4 +- 8 files changed, 209 insertions(+), 99 deletions(-) diff --git a/src/commands/common.zig b/src/commands/common.zig index 6332f57..56b972c 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -412,11 +412,41 @@ pub const LoadedPortfolio = struct { /// 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. +/// +/// 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 { - const io = ctx.io; - const allocator = ctx.allocator; + return loadPortfolioFromConfig( + 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 => { 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 {}; @@ -434,12 +464,16 @@ pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio 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. + // strings stay live as long as `resolved.inner` does — we + // 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 { resolved.deinit(); return null; }; + allocator.free(resolved.paths); 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" { const config = zfin.Config{ .cache_dir = "/tmp" }; var svc = zfin.DataService.init(std.testing.io, std.testing.allocator, config); diff --git a/src/commands/framework.zig b/src/commands/framework.zig index d1a9479..55328f5 100644 --- a/src/commands/framework.zig +++ b/src/commands/framework.zig @@ -259,7 +259,7 @@ pub const RunCtx = struct { /// allocations (paths and their containing slice). Allocations /// come from the arena allocator. pub fn resolvePortfolioPaths(self: *RunCtx) !ResolvedPaths { - return resolvePortfolioPathsImpl( + return resolvePatterns( self.io, self.allocator, 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, allocator: std.mem.Allocator, config: zfin.Config, @@ -799,14 +810,14 @@ test "printGroupedUsage: omits empty groups" { // ── 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 // 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); + var result = try resolvePatterns(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 @@ -814,30 +825,30 @@ test "resolvePortfolioPathsImpl: empty patterns falls back to default glob" { 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 // when it didn't exist on disk, so the caller could produce a // "Cannot read: " error naming the right file. We mirror // that for the multi-path API. const config: zfin.Config = .{ .cache_dir = "/tmp" }; const patterns = [_][]const u8{"/zfin-test-no-such-portfolio.srf"}; - var result = try resolvePortfolioPathsImpl(std.testing.io, testing.allocator, config, &patterns); + var result = try resolvePatterns(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" { +test "resolvePatterns: 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); + var result = try resolvePatterns(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" { +test "resolvePatterns: two patterns matching same dir union-merge" { const io = std.testing.io; var tmp = std.testing.tmpDir(.{}); 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 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(); 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; var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -877,13 +888,13 @@ test "resolvePortfolioPathsImpl: duplicate pattern de-dups" { const patterns = [_][]const u8{ p, p }; 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(); // Same path passed twice → 1 entry. try testing.expectEqual(@as(usize, 1), result.paths.len); } -test "resolvePortfolioPathsImpl: mixed directories error" { +test "resolvePatterns: mixed directories error" { const io = std.testing.io; var tmp_a = std.testing.tmpDir(.{}); @@ -906,10 +917,10 @@ test "resolvePortfolioPathsImpl: mixed directories error" { 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)); + 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; var tmp = std.testing.tmpDir(.{}); 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 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(); try testing.expectEqual(@as(usize, 2), result.paths.len); } diff --git a/src/main.zig b/src/main.zig index ac4b807..bcb53ab 100644 --- a/src/main.zig +++ b/src/main.zig @@ -409,15 +409,12 @@ fn runCli(init: std.process.Init) !u8 { defer tui_config.deinit(); try out.flush(); // 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) { + // Multi-portfolio is now wired all the way through to the + // TUI: pass the raw `-p` pattern slice and let the TUI's + // loader resolve + union-merge the same way the CLI does. + // This is the load-bearing fix for "CLI and TUI report + // different totals" — there's exactly one code path now. + tui.run(io, gpa_alloc, tui_config, globals.portfolio_patterns, 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. diff --git a/src/tui.zig b/src/tui.zig index e551032..9e9a0ee 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -511,7 +511,17 @@ pub const App = struct { 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_path: ?[]const u8 = null, 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 { 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); } @@ -1511,12 +1522,25 @@ pub const App = struct { tab_modules.history.tab.deinit(&self.states.history, self); tab_modules.projections.tab.deinit(&self.states.projections, 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 { 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 ────────────────────────────────────────────────── 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, allocator: std.mem.Allocator, config: zfin.Config, - global_portfolio_path: ?[]const u8, + portfolio_patterns: []const []const u8, global_watchlist_path: ?[]const u8, args: []const []const u8, today: zfin.Date, ) !void { - var portfolio_path: ?[]const u8 = global_portfolio_path; const watchlist_path: ?[]const u8 = global_watchlist_path; var symbol: []const u8 = ""; 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: { const home_opt = if (config.environ_map) |em| em.get("HOME") else null; const home = home_opt orelse break :blk keybinds.defaults(); @@ -2238,7 +2224,6 @@ pub fn run( .svc = svc, .keymap = keymap, .theme = loaded_theme, - .portfolio_path = portfolio_path, .symbol = symbol, .has_explicit_symbol = has_explicit_symbol, .chart_config = chart_config, @@ -2248,13 +2233,29 @@ pub fn run( // in `App.deinitData`. try tab_modules.history.tab.init(&app_inst.states.history, app_inst); - if (portfolio_path) |path| { - const file_data = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch null; - if (file_data) |d| { - defer allocator.free(d); - if (zfin.cache.deserializePortfolio(allocator, d)) |pf| { - app_inst.portfolio.file = pf; - } else |_| {} + // Load the portfolio. Goes through the same loader the CLI + // uses, so the TUI sees the same merged view (every matching + // `portfolio*.srf` in the resolved directory). The + // LoadedPortfolio's path slice + ResolvedPaths handle move + // into the App so deinit ownership stays consistent. + if (!has_explicit_symbol) { + 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); } } diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index f60bfd2..cde7519 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -112,7 +112,7 @@ fn loadData(state: *State, app: *App) void { // Load classification metadata file if (state.classification_map == null) { // 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" 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; diff --git a/src/tui/history_tab.zig b/src/tui/history_tab.zig index 5f60edf..7cc01a7 100644 --- a/src/tui/history_tab.zig +++ b/src/tui/history_tab.zig @@ -354,7 +354,7 @@ pub fn loadData(state: *State, app: *App) void { state.loaded = true; freeLoaded(state, app); - const portfolio_path = app.portfolio_path orelse { + const portfolio_path = app.anchorPath() orelse { app.setStatus("History tab requires a loaded portfolio"); return; }; @@ -640,7 +640,7 @@ fn buildCompareFromSelections(state: *State, app: *App, sel_a: usize, sel_b: usi var resources: CompareResources = .{}; 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"); return error.PortfolioLoadFailed; }; diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index a301125..bbb8d66 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -1666,32 +1666,46 @@ pub fn buildWelcomeScreenLines( /// Reload portfolio file from disk without re-fetching prices. /// 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 { // Save the account filter name before freeing the old portfolio. // account_filter is an owned copy so it survives the portfolio free, // but account_list entries borrow from the portfolio and will dangle. state.account_list.clearRetainingCapacity(); - // Re-read the portfolio file + // Re-read the portfolio file(s) if (app.portfolio.file) |*pf| pf.deinit(); 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 { - 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 { + + if (app.portfolio_paths.len == 0) { app.setStatus("No portfolio file to reload"); 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) tui.freeWatchlist(app.allocator, app.watchlist); app.watchlist = null; diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index d35069a..149544d 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -431,7 +431,7 @@ pub fn loadData(state: *State, app: *App) void { state.loaded = true; freeLoaded(state, app); - const portfolio_path = app.portfolio_path orelse { + const portfolio_path = app.anchorPath() orelse { app.setStatus("Projections tab requires a loaded portfolio"); return; }; @@ -740,7 +740,7 @@ fn ensureBacktestLoaded(state: *State, app: *App) void { /// for the current portfolio, returning null when no portfolio is /// loaded. Caller owns the returned slice. 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; defer app.allocator.free(hist_dir); return std.fs.path.join(app.allocator, &.{ hist_dir, "imported_values.srf" }) catch null;