//! Portfolio file loading + per-portfolio data pipeline. //! //! Single home for "read N portfolio_*.srf files and merge them into //! one Portfolio for both surfaces (CLI commands, TUI App)." Both //! surfaces import this module directly; neither depends on the //! other for portfolio loading. Pre-extraction, the same logic //! lived in `commands/common.zig` and the TUI either called into //! that file (which had a "TUI calls into commands/" code smell) //! or — worse — rolled its own parallel single-file path that //! drifted from the CLI's multi-file logic. //! //! The split is meaningful in only one direction: this module knows //! about pattern resolution (via `commands/framework.resolvePatterns`) //! and the `cache` deserializer. It does NOT know about RunCtx, //! Globals, or any CLI-shape concerns. The CLI-specific //! `loadPortfolio(ctx, as_of)` convenience wrapper that bridges //! a `RunCtx` to `loadPortfolioFromConfig` lives in //! `commands/common.zig` where it belongs. //! //! ## Surface //! //! - `LoadedPortfolio` — merged Portfolio + computed positions/syms //! + the resolved path slice the lots came from. Carries an //! `anchor()` accessor for sibling-file derivation //! (`accounts.srf`, `metadata.srf`, history dir). //! //! - `loadPortfolioFromConfig(io, alloc, config, patterns, as_of)` //! — the workhorse. Resolves `-p` patterns through //! `framework.resolvePatterns`, reads + deserializes + merges, //! returns a fully-populated `LoadedPortfolio`. Used by the //! CLI (via `commands.common.loadPortfolio` wrapping it with a //! `RunCtx`) and directly by the TUI. //! //! - `loadPortfolioFromPaths(io, alloc, paths, as_of)` — caller //! has already resolved patterns; load the given files. Used by //! the TUI's reload-button path (re-uses the original resolved //! path slice without re-globbing). //! //! - `loadPortfolioFromPathsAtRev(io, alloc, paths, rev, as_of)` — //! git-historical variant: read each file at `rev` via //! `git.show` instead of from the working tree. Files that don't //! exist at the rev are silently skipped (the union just doesn't //! include those lots). Used by `zfin snapshot --as-of` to //! capture point-in-time portfolio state across a multi-file //! glob from git history. //! //! All three loaders share a single private `loadFromBytes` core //! that does the deserialize + union-merge + position-compute + //! symbol-extract pass. Tests target `loadFromBytes` directly with //! synthetic byte literals to avoid filesystem I/O. //! //! - `PortfolioData` + `buildPortfolioData(...)` — second-stage //! pipeline: turn a `LoadedPortfolio` (or its parts) plus a //! `prices` map into a `PortfolioSummary` with allocations, //! candle map, and historical snapshots. const std = @import("std"); const zfin = @import("root.zig"); const framework = @import("commands/framework.zig"); const stderr = @import("stderr.zig"); const git = @import("git.zig"); // ── Portfolio loading ──────────────────────────────────────── /// 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 { /// 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, pub fn deinit(self: *LoadedPortfolio, allocator: std.mem.Allocator) void { allocator.free(self.syms); allocator.free(self.positions); self.portfolio.deinit(); 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]; } }; /// 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 /// `commands.common.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 => { stderr.print(io, "Error: portfolio files resolved to multiple directories.\n"); stderr.print(io, " Sibling files (accounts.srf, metadata.srf, transaction_log.srf) live\n"); stderr.print(io, " next to the portfolio, so all portfolio files must share a directory.\n"); return null; }, else => { stderr.print(io, "Error: failed to resolve portfolio path(s)\n"); return null; }, }; if (resolved.paths.len == 0) { resolved.deinit(); // The error message names the searched location explicitly // so the user can verify it against their expectations. // ZFIN_HOME is exclusive when set: we never look at cwd // in that case, so the message would be misleading if it // mentioned cwd as a possibility. if (config.zfin_home) |home| { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: no portfolio file found in ZFIN_HOME ({s}). Looked for portfolio*.srf.\n", .{home}) catch "Error: no portfolio file found in ZFIN_HOME\n"; stderr.print(io, msg); } else { stderr.print(io, "Error: no portfolio file found in cwd. Looked for portfolio*.srf. (ZFIN_HOME is unset.)\n"); } return null; } // Snapshot the path-string view as our own owned slice. Backing // 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); } /// Lower-level loader: caller has already resolved the path list and /// owns the path strings. Used by the TUI's manual reload (re-loads /// the same files without re-globbing) and by tests. /// /// 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). pub fn loadPortfolioFromPaths(io: std.Io, allocator: std.mem.Allocator, paths: []const []const u8, as_of: zfin.Date) ?LoadedPortfolio { if (paths.len == 0) { stderr.print(io, "Error: No portfolio file found\n"); 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); } /// Like `loadPortfolioFromPaths`, but reads each file from a git /// revision instead of the working tree. The result is the union /// of all files at that rev, deserialized + union-merged the same /// way the live loader does. /// /// Files that don't exist at the requested rev (e.g. /// `portfolio_other.srf` was added later than `rev`) are silently /// skipped — the union just doesn't include those lots. This is the /// right behavior for `zfin snapshot --as-of ` against a /// portfolio that's been split into multiple files over time. /// /// All `paths` must live within the same git repository as the /// first path. Repository discovery uses `git.findRepo` on the /// first path; the rest derive their rel-paths by trimming the /// repo-root prefix. /// /// Returns null on: /// - empty paths slice (no portfolio file to load); /// - first path not in any git repo; /// - any deserialize / merge / position-compute failure (same as /// the working-tree loader). /// /// `git.show` failures other than `PathMissingInRev` (e.g. /// `UnknownRevision`) propagate as null; the function prints a /// stderr message describing which file/rev failed. pub fn loadPortfolioFromPathsAtRev( io: std.Io, allocator: std.mem.Allocator, paths: []const []const u8, rev: []const u8, as_of: zfin.Date, ) ?LoadedPortfolio { if (paths.len == 0) { stderr.print(io, "Error: No portfolio file found\n"); return null; } // Discover the repo from the first path. All other paths are // assumed to live in the same repo (typical for sibling // portfolio files in the same directory); we derive their // rel-paths below. const info = git.findRepo(io, allocator, paths[0]) catch { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: portfolio path not in a git repo: {s}\n", .{paths[0]}) catch "Error: portfolio path not in a git repo\n"; stderr.print(io, msg); return null; }; defer allocator.free(info.root); defer allocator.free(info.rel_path); // Read each file at `rev`. On `PathMissingInRev` we substitute // empty bytes; `loadFromBytes` knows to treat that as "file // absent at this rev, skip without parsing." Any other git // error is fatal. var datas: std.ArrayList([]const u8) = .empty; var datas_consumed = false; defer if (!datas_consumed) { for (datas.items) |d| allocator.free(d); datas.deinit(allocator); }; for (paths) |abs_path| { // Derive rel_path from this file's absolute path. The first // file's rel_path was computed by findRepo; for the rest we // strip the repo root prefix manually. realpath ensures // canonicalization (the user could pass a non-canonical // path). const real = std.Io.Dir.cwd().realPathFileAlloc(io, abs_path, allocator) catch { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: cannot resolve real path for: {s}\n", .{abs_path}) catch "Error: cannot resolve real path\n"; stderr.print(io, msg); return null; }; defer allocator.free(real); const rel = if (std.mem.startsWith(u8, real, info.root) and real.len > info.root.len) std.mem.trimStart(u8, real[info.root.len..], "/") else std.fs.path.basename(real); const data = git.show(io, allocator, info.root, rev, rel) catch |err| switch (err) { error.PathMissingInRev => empty_blk: { // File didn't exist at this rev. Substitute empty // bytes; loadFromBytes skips empty entries. break :empty_blk allocator.dupe(u8, "") catch return null; }, else => { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: git show {s}:{s}: {t}\n", .{ rev, rel, err }) catch "Error: git show failed\n"; stderr.print(io, msg); return null; }, }; datas.append(allocator, data) catch { allocator.free(data); return null; }; } const datas_owned = datas.toOwnedSlice(allocator) catch return null; datas_consumed = true; // Hand off ownership of the slice we just dupe'd from the // caller. `loadFromBytes` frees both `paths_owned` and // `datas_owned` on any failure. const paths_owned = allocator.dupe([]const u8, paths) catch { for (datas_owned) |d| allocator.free(d); allocator.free(datas_owned); return null; }; return loadFromBytes(io, allocator, paths_owned, null, datas_owned, 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 { // Read every file up front; bail on first error. var file_datas: std.ArrayList([]const u8) = .empty; 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"; stderr.print(io, msg); for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); allocator.free(paths_owned); if (resolved_paths_opt) |rp| rp.deinit(); return null; }; file_datas.append(allocator, data) catch { allocator.free(data); for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); allocator.free(paths_owned); if (resolved_paths_opt) |rp| rp.deinit(); return null; }; } const file_datas_owned = file_datas.toOwnedSlice(allocator) catch { for (file_datas.items) |d| allocator.free(d); file_datas.deinit(allocator); allocator.free(paths_owned); if (resolved_paths_opt) |rp| rp.deinit(); return null; }; // Hand off to the bytes-only path. `loadFromBytes` takes // ownership of all four slices and frees them on any error. return loadFromBytes(io, allocator, paths_owned, resolved_paths_opt, file_datas_owned, as_of); } /// Bytes-only union-merge core. Takes ownership of `paths_owned`, /// `resolved_paths_opt` (if non-null), and `file_datas_owned` (each /// element must be `allocator`-owned). Frees them on any error. /// /// Tests use this directly with synthetic byte literals; production /// callers come through `loadFromPaths` (working-tree reads) or /// `loadPortfolioFromPathsAtRev` (git-historical reads). All three /// share this single deserialize-merge-positions-syms pass. /// /// `paths_owned` and `file_datas_owned` must have the same length; /// each `file_datas_owned[i]` is the bytes loaded from /// `paths_owned[i]`. Path strings are used only for diagnostics /// (which file failed to parse) and in the returned struct. fn loadFromBytes( io: std.Io, allocator: std.mem.Allocator, paths_owned: []const []const u8, resolved_paths_opt: ?zfin.Config.ResolvedPaths, file_datas_owned: []const []const u8, as_of: zfin.Date, ) ?LoadedPortfolio { std.debug.assert(paths_owned.len == file_datas_owned.len); // Cleanup is staged: lots live on `merged` until // `toOwnedSlice`, then move into `combined` (which has its // own deinit). The two cleanup paths are mutually exclusive, // tracked by `lots_owner`. On success, neither fires. var merged: std.ArrayList(zfin.Lot) = .empty; var combined: zfin.Portfolio = .{ .lots = &.{}, .allocator = allocator }; const LotsOwner = enum { merged_list, combined_struct, none }; var lots_owner: LotsOwner = .merged_list; var success = false; defer if (!success) { switch (lots_owner) { .merged_list => { 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); }, .combined_struct => combined.deinit(), .none => {}, } for (file_datas_owned) |d| allocator.free(d); allocator.free(file_datas_owned); allocator.free(paths_owned); if (resolved_paths_opt) |rp| rp.deinit(); }; // 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. for (file_datas_owned, 0..) |data, idx| { // Empty bytes are a legitimate "file absent" signal — // `loadPortfolioFromPathsAtRev` uses this to indicate a // file that didn't exist at the requested git rev. Skip // without trying to parse. if (data.len == 0) continue; 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"; stderr.print(io, msg); return null; }; for (portfolio.lots) |lot| { merged.append(allocator, lot) catch { portfolio.deinit(); 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 return null; combined = .{ .lots = merged_slice, .allocator = allocator, }; lots_owner = .combined_struct; const positions = combined.positions(as_of, allocator) catch { stderr.print(io, "Error: Cannot compute positions\n"); return null; }; const syms = combined.stockSymbols(allocator) catch { allocator.free(positions); stderr.print(io, "Error: Cannot get stock symbols\n"); return null; }; success = true; return .{ .paths = paths_owned, .resolved_paths = resolved_paths_opt, .file_datas = file_datas_owned, .portfolio = combined, .positions = positions, .syms = syms, }; } // ── Portfolio data pipeline ────────────────────────────────── /// Result of the shared portfolio data pipeline. Caller must call deinit(). pub const PortfolioData = struct { summary: zfin.valuation.PortfolioSummary, candle_map: std.StringHashMap([]const zfin.Candle), snapshots: ?[6]zfin.valuation.HistoricalSnapshot, pub fn deinit(self: *PortfolioData, allocator: std.mem.Allocator) void { self.summary.deinit(allocator); var it = self.candle_map.valueIterator(); while (it.next()) |v| allocator.free(v.*); self.candle_map.deinit(); } }; /// Build portfolio summary, candle map, and historical snapshots from /// pre-populated prices. Shared between CLI `portfolio` command, TUI /// `loadPortfolioData`, and TUI `reloadPortfolioFile`. /// /// Callers are responsible for populating `prices` (via network fetch, /// cache read, or pre-fetched map) before calling this. /// /// Returns error.NoAllocations if the summary produces no positions /// (e.g. no cached prices available). pub fn buildPortfolioData( allocator: std.mem.Allocator, portfolio: zfin.Portfolio, positions: []const zfin.Position, syms: []const []const u8, prices: *std.StringHashMap(f64), svc: *zfin.DataService, as_of: zfin.Date, ) !PortfolioData { var manual_price_set = try zfin.valuation.buildFallbackPrices(allocator, portfolio.lots, positions, prices); defer manual_price_set.deinit(); var summary = zfin.valuation.portfolioSummary(as_of, allocator, portfolio, positions, prices.*, manual_price_set) catch return error.SummaryFailed; errdefer summary.deinit(allocator); if (summary.allocations.len == 0) { summary.deinit(allocator); return error.NoAllocations; } var candle_map = std.StringHashMap([]const zfin.Candle).init(allocator); errdefer { var it = candle_map.valueIterator(); while (it.next()) |v| allocator.free(v.*); candle_map.deinit(); } for (syms) |sym| { if (svc.getCachedCandles(sym)) |cs| { // cs.data is owned by svc.allocator, which matches the // caller's `allocator` in practice (they're wired to the // same root). Store the raw slice; PortfolioData.deinit // below frees via the caller's allocator. try candle_map.put(sym, cs.data); } } const snapshots = zfin.valuation.computeHistoricalSnapshots( as_of, positions, prices.*, candle_map, ); return .{ .summary = summary, .candle_map = candle_map, .snapshots = snapshots, }; } // ── loadFromBytes tests (in-memory; no disk I/O) ───────────── // // All tests here construct synthetic SRF byte literals and route // them through `loadFromBytes` directly. Both production callers // (working-tree `loadFromPaths` and git-historical // `loadPortfolioFromPathsAtRev`) share this core, so unit-testing // it covers the union-merge / parse-error / empty-bytes behavior // without spinning up temp filesystems or git repos. const testing = std.testing; /// Test helper: dupe a slice of byte-string literals into the /// allocator so `loadFromBytes` can free them on its own. fn dupeBytes(allocator: std.mem.Allocator, datas: []const []const u8) ![]const []const u8 { var owned = try allocator.alloc([]const u8, datas.len); errdefer { for (owned[0..]) |d| allocator.free(d); allocator.free(owned); } var filled: usize = 0; for (datas, 0..) |d, i| { owned[i] = try allocator.dupe(u8, d); filled = i + 1; } return owned; } test "loadFromBytes: union of two synthetic SRF files" { const allocator = testing.allocator; const file_a = \\#!srfv1 \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA \\ ; const file_b = \\#!srfv1 \\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth \\ ; const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" }); const datas = try dupeBytes(allocator, &.{ file_a, file_b }); var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse { try testing.expect(false); return; }; defer loaded.deinit(allocator); try testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len); // Lots are appended in file order. try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol); try testing.expectEqualStrings("MSFT", loaded.portfolio.lots[1].symbol); } test "loadFromBytes: single file with valid contents" { const allocator = testing.allocator; const file_a = \\#!srfv1 \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA \\ ; const paths = try allocator.dupe([]const u8, &.{"a.srf"}); const datas = try dupeBytes(allocator, &.{file_a}); var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse { try testing.expect(false); return; }; defer loaded.deinit(allocator); try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len); try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol); } test "loadFromBytes: empty-bytes entry is treated as absent file" { // Production use case: `loadPortfolioFromPathsAtRev` returns // empty bytes for a path that didn't exist at the requested // git rev. The union-merge silently skips it instead of // failing — matches "snapshot at a date before this file // was added" semantics. const allocator = testing.allocator; const file_a = \\#!srfv1 \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA \\ ; const file_b: []const u8 = ""; const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" }); const datas = try dupeBytes(allocator, &.{ file_a, file_b }); var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse { try testing.expect(false); return; }; defer loaded.deinit(allocator); try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len); try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol); } test "loadFromBytes: all-empty bytes returns empty portfolio" { // Production use case: snapshot at a rev that predates ALL // tracked portfolio files. No lots; no error. const allocator = testing.allocator; const paths = try allocator.dupe([]const u8, &.{ "a.srf", "b.srf" }); const datas = try dupeBytes(allocator, &.{ "", "" }); var loaded = loadFromBytes(testing.io, allocator, paths, null, datas, zfin.Date.fromYmd(2026, 5, 23)) orelse { try testing.expect(false); return; }; defer loaded.deinit(allocator); try testing.expectEqual(@as(usize, 0), loaded.portfolio.lots.len); } // ── loadPortfolioFromPathsAtRev tests (disk + git) ─────────── // // These spin up a temp git repo so they exercise the full // `git.show + skip-on-PathMissingInRev` contract end-to-end. // Kept minimal (one happy-path, one missing-file case); the // bulk of the logic is in `loadFromBytes`, covered above. /// Run a one-shot git command in `cwd` for test setup. Panics on /// failure — these tests can't proceed without a working repo. fn gitInTestRepo(allocator: std.mem.Allocator, cwd: []const u8, argv: []const []const u8) !void { const full_argv = try allocator.alloc([]const u8, argv.len + 3); defer allocator.free(full_argv); full_argv[0] = "git"; full_argv[1] = "-C"; full_argv[2] = cwd; @memcpy(full_argv[3..], argv); const result = try std.process.run(allocator, testing.io, .{ .argv = full_argv, .stdout_limit = .limited(64 * 1024), }); defer allocator.free(result.stdout); defer allocator.free(result.stderr); switch (result.term) { .exited => |code| if (code != 0) { std.debug.print("git command failed (code {d}): {s}\nstderr: {s}\n", .{ code, std.mem.join(allocator, " ", argv) catch "?", result.stderr }); return error.GitFailed; }, else => return error.GitFailed, } } test "loadPortfolioFromPathsAtRev: union of two committed files at HEAD" { const allocator = testing.allocator; // Skip if `git` isn't on PATH (CI sandbox without git). { const probe = std.process.run(allocator, testing.io, .{ .argv = &.{ "git", "--version" }, .stdout_limit = .limited(1024), }) catch return; defer allocator.free(probe.stdout); defer allocator.free(probe.stderr); } var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(testing.io, ".", &path_buf); const dir = path_buf[0..dir_len]; const file_a = \\#!srfv1 \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA \\ ; const file_b = \\#!srfv1 \\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth \\ ; try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio.srf", .data = file_a }); try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio_other.srf", .data = file_b }); try gitInTestRepo(allocator, dir, &.{ "init", "-q" }); try gitInTestRepo(allocator, dir, &.{ "config", "user.email", "test@example.com" }); try gitInTestRepo(allocator, dir, &.{ "config", "user.name", "Test" }); try gitInTestRepo(allocator, dir, &.{ "config", "commit.gpgsign", "false" }); try gitInTestRepo(allocator, dir, &.{ "add", "portfolio.srf", "portfolio_other.srf" }); try gitInTestRepo(allocator, dir, &.{ "commit", "-q", "-m", "initial" }); const p1 = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); defer allocator.free(p1); const p2 = try std.fs.path.join(allocator, &.{ dir, "portfolio_other.srf" }); defer allocator.free(p2); const paths = [_][]const u8{ p1, p2 }; var loaded = loadPortfolioFromPathsAtRev(testing.io, allocator, &paths, "HEAD", zfin.Date.fromYmd(2026, 5, 23)) orelse { try testing.expect(false); return; }; defer loaded.deinit(allocator); try testing.expectEqual(@as(usize, 2), loaded.portfolio.lots.len); } test "loadPortfolioFromPathsAtRev: file added later is silently skipped at earlier rev" { const allocator = testing.allocator; // Skip if `git` isn't on PATH. { const probe = std.process.run(allocator, testing.io, .{ .argv = &.{ "git", "--version" }, .stdout_limit = .limited(1024), }) catch return; defer allocator.free(probe.stdout); defer allocator.free(probe.stderr); } var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); var path_buf: [std.fs.max_path_bytes]u8 = undefined; const dir_len = try tmp.dir.realPathFile(testing.io, ".", &path_buf); const dir = path_buf[0..dir_len]; const file_a = \\#!srfv1 \\symbol::AAPL,shares:num:10,open_date::2024-01-15,open_price:num:150,account::Sample IRA \\ ; try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio.srf", .data = file_a }); try gitInTestRepo(allocator, dir, &.{ "init", "-q" }); try gitInTestRepo(allocator, dir, &.{ "config", "user.email", "test@example.com" }); try gitInTestRepo(allocator, dir, &.{ "config", "user.name", "Test" }); try gitInTestRepo(allocator, dir, &.{ "config", "commit.gpgsign", "false" }); try gitInTestRepo(allocator, dir, &.{ "add", "portfolio.srf" }); try gitInTestRepo(allocator, dir, &.{ "commit", "-q", "-m", "initial" }); // Capture commit 1 SHA before adding file_b. const result = try std.process.run(allocator, testing.io, .{ .argv = &.{ "git", "-C", dir, "rev-parse", "HEAD" }, .stdout_limit = .limited(1024), }); defer allocator.free(result.stdout); defer allocator.free(result.stderr); const sha = std.mem.trim(u8, result.stdout, " \t\r\n"); const sha_owned = try allocator.dupe(u8, sha); defer allocator.free(sha_owned); // Add and commit a second portfolio file. const file_b = \\#!srfv1 \\symbol::MSFT,shares:num:5,open_date::2024-02-01,open_price:num:300,account::Sample Roth \\ ; try tmp.dir.writeFile(testing.io, .{ .sub_path = "portfolio_other.srf", .data = file_b }); try gitInTestRepo(allocator, dir, &.{ "add", "portfolio_other.srf" }); try gitInTestRepo(allocator, dir, &.{ "commit", "-q", "-m", "add second" }); const p1 = try std.fs.path.join(allocator, &.{ dir, "portfolio.srf" }); defer allocator.free(p1); const p2 = try std.fs.path.join(allocator, &.{ dir, "portfolio_other.srf" }); defer allocator.free(p2); const paths = [_][]const u8{ p1, p2 }; // Load at commit 1 — second file didn't exist yet. Expect // only AAPL from portfolio.srf, no error. var loaded = loadPortfolioFromPathsAtRev(testing.io, allocator, &paths, sha_owned, zfin.Date.fromYmd(2026, 5, 23)) orelse { try testing.expect(false); return; }; defer loaded.deinit(allocator); try testing.expectEqual(@as(usize, 1), loaded.portfolio.lots.len); try testing.expectEqualStrings("AAPL", loaded.portfolio.lots[0].symbol); }