diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index cc31048..82ea20b 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -16,8 +16,11 @@ repos: hooks: - id: zlint name: Run zlint - entry: zlint - args: ["--deny-warnings", "--fix"] + # zlint accepts file paths only via stdin (-S); positional + # args are interpreted as directory names and silently + # produce no output. Pipe pre-commit's file list through + # bash to get the paths to zlint as stdin lines. + entry: bash -c 'printf "%s\n" "$@" | zlint --deny-warnings --fix -S' -- language: system types: [zig] - repo: https://github.com/batmac/pre-commit-zig diff --git a/AGENTS.md b/AGENTS.md index 8703db2..fd7d334 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -345,6 +345,59 @@ Ask the user instead.** freely when asked; don't treat it as part of the repo surface. Don't mention it in commit messages for unrelated work. +### Lint warnings — there are no "pre-existing" warnings + +**zlint warnings get fixed, period.** Do NOT excuse a warning by +saying it was "pre-existing in the file" or "inherited from a copy" +or "the same style as elsewhere." That's how lint debt accumulates +to the point where the pre-commit hook (`zlint --deny-warnings`) +becomes a tripwire that everyone routes around. + +The rule: + +1. **Before any commit, run zlint on every file you touched in + the change.** If zlint reports any warnings on those files, + fix them in that change. There is no "I didn't introduce it, + not my problem" — once you've touched the file, the warnings + are yours. + +2. **If you find pre-existing warnings in a file you didn't + intend to touch (e.g. you ran zlint across the whole tree + for a sanity check), fix them in a SEPARATE commit and call + that out to the user explicitly so they can keep the commits + clean.** Do not silently bundle drive-by lint fixes into a + feature commit; the diff becomes harder to review and the + lint-debt origin gets buried. + +3. **Common warning kinds and the right fix:** + - `suppressed-errors` (`catch {}` / `catch "fallback"`): + replace with `try` (propagate to the caller). If the call + genuinely cannot propagate (e.g. an stderr write inside an + error-reporting path where the secondary error doesn't + matter), use `catch |err| std.log.debug(...)` or rewrite to + not need the catch. The `catch {}` form is almost never the + right answer. + - `unsafe-undefined`: add a `// SAFETY: ` comment on + the line with `undefined` explaining why it's safe (e.g. + "buffer immediately overwritten by bufPrint below"). + - `unused-decls`: delete the decl. Don't leave dead imports + or constants around "in case." + +4. **Never report a lint result by saying "0 errors, N warnings, + but they're all pre-existing."** Either: + - Fix the warnings in this change (preferred when N is small + or the file is yours), OR + - Report "0 errors, 0 warnings on the files I touched. The + wider tree has N warnings I haven't addressed in this + change; flagging for follow-up" — and only after you've + confirmed by file that none of the wider-tree warnings + are in files you modified. + +The `--deny-warnings` flag is on the pre-commit hook for a +reason: every warning is a real signal that the codebase asked +the linter to flag and someone hasn't dealt with. Treat them as +errors at write-time, not as background noise to ignore. + ### Em-dash usage — ASK FIRST If you're about to write an em-dash (`—`) anywhere (code, tests, doc diff --git a/src/Config.zig b/src/Config.zig index 1f77469..534f89c 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -56,6 +56,9 @@ environ_map: ?*const std.process.Environ.Map = null, pub fn fromEnv(io: std.Io, allocator: std.mem.Allocator, environ_map: *const std.process.Environ.Map) @This() { var self = @This(){ + // SAFETY: assigned unconditionally below (line ~95) from + // either ZFIN_CACHE_DIR or the XDG fallback before this + // function returns, so callers never observe `undefined`. .cache_dir = undefined, .allocator = allocator, .environ_map = environ_map, @@ -132,13 +135,21 @@ pub const ResolvedPath = struct { } }; -/// Resolve a user file, trying cwd first then ZFIN_HOME. +/// Resolve a user file. ZFIN_HOME is exclusive when set: only +/// `$ZFIN_HOME/` is checked. When ZFIN_HOME is unset, +/// fall back to cwd-relative resolution. +/// +/// The exclusivity is intentional: setting ZFIN_HOME is the +/// user's "this is where my data lives" declaration, and silently +/// falling back to cwd undermines that. Running from a project +/// directory that incidentally ships a `portfolio.srf` would +/// otherwise shadow the user's canonical data — exactly the +/// surprising behavior we want to rule out. If a user wants +/// cwd-based resolution for a one-off run, they can `unset +/// ZFIN_HOME` (or `env -u ZFIN_HOME zfin ...`). +/// /// Returns the path to use; caller must call `deinit()` on the result. pub fn resolveUserFile(self: @This(), io: std.Io, allocator: std.mem.Allocator, rel_path: []const u8) ?ResolvedPath { - if (std.Io.Dir.cwd().access(io, rel_path, .{})) |_| { - return .{ .path = rel_path, .owned = false }; - } else |_| {} - if (self.zfin_home) |home| { const full = std.fs.path.join(allocator, &.{ home, rel_path }) catch return null; if (std.Io.Dir.cwd().access(io, full, .{})) |_| { @@ -146,7 +157,15 @@ pub fn resolveUserFile(self: @This(), io: std.Io, allocator: std.mem.Allocator, } else |_| { allocator.free(full); } + // ZFIN_HOME is set but doesn't have the file. Don't look + // in cwd — that would be the surprising-shadow case. + return null; } + + if (std.Io.Dir.cwd().access(io, rel_path, .{})) |_| { + return .{ .path = rel_path, .owned = false }; + } else |_| {} + return null; } @@ -245,18 +264,23 @@ pub const ResolvedPaths = struct { /// Resolve a portfolio-like path that may contain glob metacharacters. /// /// Resolution rules: -/// - If `pattern` has no glob metachar, behaves like `resolveUserFile` -/// (try cwd, then ZFIN_HOME); returns 0 or 1 path. -/// - If `pattern` has a glob metachar, expand against cwd; if cwd -/// yielded zero matches, expand against ZFIN_HOME. Never mixes. -/// Results are sorted lexicographically for deterministic ordering. -/// - Returns an empty slice (not null) when the pattern has no -/// matches anywhere. +/// - When ZFIN_HOME is set, search EXCLUSIVELY there. cwd is not +/// consulted. Setting ZFIN_HOME is the user's declaration of +/// "this is where my data lives"; silently falling back to +/// cwd would let an incidental `portfolio.srf` in a project +/// directory shadow the user's real data. +/// - When ZFIN_HOME is unset, search cwd. +/// - Literal patterns (no glob metachar) → 0 or 1 path via +/// `resolveUserFile`. Glob patterns → expansion against the +/// selected directory. +/// - Returns an empty slice (not null) when the pattern has +/// no matches in the selected directory. /// /// Caller must call `deinit()` on the returned `ResolvedPaths`. pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator, pattern: []const u8) !ResolvedPaths { if (!isGlobPattern(pattern)) { - // Literal-path fast path: reuse the singular resolver. + // Literal-path fast path: reuse the singular resolver, + // which itself enforces the ZFIN_HOME-exclusive rule. if (self.resolveUserFile(io, allocator, pattern)) |r| { const arr = try allocator.alloc(ResolvedPath, 1); arr[0] = r; @@ -265,17 +289,19 @@ pub fn resolveUserFiles(self: @This(), io: std.Io, allocator: std.mem.Allocator, return .{ .paths = &.{}, .allocator = allocator }; } - // Glob path. Try cwd first. - if (try expandGlob(io, allocator, ".", pattern, .cwd_relative)) |matches| { - if (matches.len > 0) return .{ .paths = matches, .allocator = allocator }; - allocator.free(matches); - } - - // Fall back to ZFIN_HOME. + // Glob expansion. ZFIN_HOME exclusive when set. if (self.zfin_home) |home| { if (try expandGlob(io, allocator, home, pattern, .home_relative)) |matches| { return .{ .paths = matches, .allocator = allocator }; } + // ZFIN_HOME directory doesn't exist or can't be read. + // Surface as no-match rather than fall back to cwd. + return .{ .paths = &.{}, .allocator = allocator }; + } + + // ZFIN_HOME unset — cwd is the only option. + if (try expandGlob(io, allocator, ".", pattern, .cwd_relative)) |matches| { + return .{ .paths = matches, .allocator = allocator }; } return .{ .paths = &.{}, .allocator = allocator }; @@ -610,9 +636,12 @@ test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" { var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); - // Use a pattern unlikely to match anything in the project's cwd — - // resolveUserFiles tries cwd first and we want to exercise the - // zfin_home fallback path. + // Use a pattern unlikely to match anything in the project's + // cwd. With ZFIN_HOME → cwd priority, ZFIN_HOME would win + // anyway when both have matches, but the test is cleanest + // if cwd contributes nothing (the zfintest_pf prefix won't + // collide with the project's portfolio*.srf files in the + // repo root). try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf.srf", .data = "x" }); try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_b.srf", .data = "x" }); try tmp.dir.writeFile(io, .{ .sub_path = "zfintest_pf_a.srf", .data = "x" }); @@ -633,24 +662,50 @@ test "resolveUserFiles: glob expansion in zfin_home, sorted lexicographically" { try testing.expect(std.mem.endsWith(u8, result.paths[2].path, "zfintest_pf_b.srf")); } -test "resolveUserFiles: glob in cwd takes precedence over zfin_home" { +test "resolveUserFiles: ZFIN_HOME is exclusive when set (cwd is not consulted)" { + // Pin the rule: when ZFIN_HOME is set, we ONLY look there. + // cwd is not a fallback. The motivating bug: a project + // directory that incidentally ships a `portfolio.srf` would + // shadow the user's canonical data when running zfin from + // that directory. ZFIN_HOME-exclusive rules that out. + // + // Verified by giving the resolver a ZFIN_HOME that doesn't + // match a pattern, then confirming the result is empty — + // even though the test runner's cwd (the repo root) DOES + // have a portfolio*.srf file. + const allocator = testing.allocator; + const io = std.testing.io; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + + // ZFIN_HOME has no portfolio*.srf — only an unrelated file. + try tmp.dir.writeFile(io, .{ .sub_path = "watchlist.srf", .data = "x" }); + + var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf); + const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]); + defer allocator.free(dir_path); + + const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path }; + var result = try c.resolveUserFiles(io, allocator, "portfolio*.srf"); + defer result.deinit(); + // Zero matches in ZFIN_HOME → zero results, full stop. + // cwd is NOT consulted, even though the test runner's cwd + // (the repo root) typically has a `portfolio-semilatest.srf`. + try testing.expectEqual(@as(usize, 0), result.paths.len); +} + +test "resolveUserFiles: cwd used only when ZFIN_HOME is unset" { + // Counter-test for the exclusivity rule: with ZFIN_HOME + // unset, cwd IS consulted. Exercise this via expandGlob + // directly (testing the cwd code path means mutating the + // process cwd, which is risky in a parallel runner). The + // resolveUserFiles wrapper just routes to one of these + // two modes based on whether `zfin_home` is non-null. const allocator = testing.allocator; const io = std.testing.io; - // The "cwd takes precedence" rule is implicit in resolveUserFiles: - // it tries cwd first via expandGlob, and only falls back to - // zfin_home if cwd had zero matches. Rather than mutate the - // process cwd from a test (risky under parallel runners), we - // verify the equivalent behavior: if zfin_home has matches but - // cwd has none, we get the home matches; if cwd has matches, we - // never touch zfin_home. - // - // Cwd-no-match-home-yes case: covered by - // "resolveUserFiles: glob expansion in zfin_home, sorted ...". - // - // For the cwd-has-match path we exercise expandGlob directly - // against a tmpDir, since that's the same code path - // resolveUserFiles uses for cwd. var tmp = std.testing.tmpDir(.{}); defer tmp.cleanup(); @@ -662,6 +717,8 @@ test "resolveUserFiles: glob in cwd takes precedence over zfin_home" { const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf); const dir_path = dir_path_buf[0..dir_path_len]; + // Glob expansion against a known directory (mimicking the + // cwd path of resolveUserFiles). const matches_opt = try expandGlob(io, allocator, dir_path, "portfolio_*.srf", .home_relative); try testing.expect(matches_opt != null); const matches = matches_opt.?; @@ -674,6 +731,30 @@ test "resolveUserFiles: glob in cwd takes precedence over zfin_home" { try testing.expect(std.mem.endsWith(u8, matches[1].path, "portfolio_b.srf")); } +test "resolveUserFile: ZFIN_HOME is exclusive when set (literal path)" { + // Same exclusivity rule, but for the no-glob path through + // `resolveUserFile`. ZFIN_HOME without the file → null, + // even when the file might exist in cwd. + const allocator = testing.allocator; + const io = std.testing.io; + + var tmp = std.testing.tmpDir(.{}); + defer tmp.cleanup(); + // ZFIN_HOME is empty (no portfolio.srf inside). + var dir_path_buf: [std.fs.max_path_bytes]u8 = undefined; + const dir_path_len = try tmp.dir.realPath(io, &dir_path_buf); + const dir_path = try allocator.dupe(u8, dir_path_buf[0..dir_path_len]); + defer allocator.free(dir_path); + + const c: @This() = .{ .cache_dir = "/tmp", .zfin_home = dir_path }; + // The repo root (test cwd) has `portfolio-semilatest.srf`, + // but we ask for a different name to keep the test + // deterministic regardless of cwd contents. + const result = c.resolveUserFile(io, allocator, "portfolio-semilatest.srf"); + // ZFIN_HOME doesn't have it; cwd is not consulted; null. + try testing.expect(result == null); +} + test "expandGlob: missing directory returns null" { const allocator = testing.allocator; const io = std.testing.io; diff --git a/src/Money.zig b/src/Money.zig index 433514a..4be8725 100644 --- a/src/Money.zig +++ b/src/Money.zig @@ -136,7 +136,7 @@ pub const Trim = struct { // generous. var tmp: [24]u8 = undefined; var fixed = std.Io.Writer.fixed(&tmp); - writeAbsCents(&fixed, self.amount) catch unreachable; + try writeAbsCents(&fixed, self.amount); const written = fixed.buffered(); const out = if (std.mem.endsWith(u8, written, ".00")) written[0 .. written.len - 3] @@ -177,7 +177,7 @@ pub const Signed = struct { /// byte-identical output, just streams to a writer instead of /// returning a slice. fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void { - const cents = @as(i64, @intFromFloat(@round(amount * 100.0))); + const cents: i64 = @intFromFloat(@round(amount * 100.0)); const abs_cents = if (cents < 0) @as(u64, @intCast(-cents)) else @as(u64, @intCast(cents)); const dollars = abs_cents / 100; const rem = abs_cents % 100; @@ -224,7 +224,7 @@ fn writeAbsCents(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void { /// Write the absolute value of `amount` as `$X,XXX` (rounded to /// whole dollars, no decimal portion) directly to `w`. fn writeAbsWhole(w: *std.Io.Writer, amount: f64) std.Io.Writer.Error!void { - const dollars_signed = @as(i64, @intFromFloat(@round(amount))); + const dollars_signed: i64 = @intFromFloat(@round(amount)); const dollars: u64 = if (dollars_signed < 0) @intCast(-dollars_signed) else @intCast(dollars_signed); var tmp: [24]u8 = undefined; diff --git a/src/analytics/analysis.zig b/src/analytics/analysis.zig index eaf3238..56c19d6 100644 --- a/src/analytics/analysis.zig +++ b/src/analytics/analysis.zig @@ -5,9 +5,7 @@ const std = @import("std"); const srf = @import("srf"); const Allocation = @import("valuation.zig").Allocation; -const ClassificationEntry = @import("../models/classification.zig").ClassificationEntry; const ClassificationMap = @import("../models/classification.zig").ClassificationMap; -const LotType = @import("../models/portfolio.zig").LotType; const Portfolio = @import("../models/portfolio.zig").Portfolio; const Date = @import("../Date.zig"); @@ -296,15 +294,15 @@ pub fn analyzePortfolio( if (entry.asset_class) |ac| { const prev = ac_map.get(ac) orelse 0; - ac_map.put(ac, prev + portion) catch {}; + try ac_map.put(ac, prev + portion); } if (entry.sector) |s| { const prev = sector_map.get(s) orelse 0; - sector_map.put(s, prev + portion) catch {}; + try sector_map.put(s, prev + portion); } if (entry.geo) |g| { const prev = geo_map.get(g) orelse 0; - geo_map.put(g, prev + portion) catch {}; + try geo_map.put(g, prev + portion); } } } @@ -320,10 +318,10 @@ pub fn analyzePortfolio( var price_lookup = std.StringHashMap(PriceEntry).init(allocator); defer price_lookup.deinit(); for (allocations) |alloc| { - price_lookup.put(alloc.symbol, .{ + try price_lookup.put(alloc.symbol, .{ .price = alloc.current_price, .is_preadjusted = alloc.price_ratio != 1.0, - }) catch {}; + }); } // Account breakdown from individual lots (avoids "Multiple" aggregation issue). @@ -348,7 +346,7 @@ pub fn analyzePortfolio( .illiquid, .watch => continue, }; const prev = acct_map.get(acct) orelse 0; - acct_map.put(acct, prev + value) catch {}; + try acct_map.put(acct, prev + value); } // Add non-stock asset classes (combine Cash + CDs) @@ -357,14 +355,14 @@ pub fn analyzePortfolio( const cash_cd_total = cash_total + cd_total; if (cash_cd_total > 0) { const prev = ac_map.get("Cash & CDs") orelse 0; - ac_map.put("Cash & CDs", prev + cash_cd_total) catch {}; + try ac_map.put("Cash & CDs", prev + cash_cd_total); const gprev = geo_map.get("US") orelse 0; - geo_map.put("US", gprev + cash_cd_total) catch {}; + try geo_map.put("US", gprev + cash_cd_total); } const opt_total = portfolio.totalOptionCost(as_of); if (opt_total > 0) { const prev = ac_map.get("Options") orelse 0; - ac_map.put("Options", prev + opt_total) catch {}; + try ac_map.put("Options", prev + opt_total); } // Tax type breakdown: map each account's total to its tax type @@ -373,7 +371,7 @@ pub fn analyzePortfolio( while (acct_iter.next()) |kv| { const tt = am.taxTypeFor(kv.key_ptr.*); const prev = tax_map.get(tt) orelse 0; - tax_map.put(tt, prev + kv.value_ptr.*) catch {}; + try tax_map.put(tt, prev + kv.value_ptr.*); } } diff --git a/src/analytics/projections.zig b/src/analytics/projections.zig index a6ebaf8..e7d3ee3 100644 --- a/src/analytics/projections.zig +++ b/src/analytics/projections.zig @@ -188,6 +188,7 @@ pub const UserConfig = struct { birthdates: [max_persons]Date = @splat(Date.fromYmd(1970, 1, 1)), birthdate_count: u8 = 0, /// Life events (income/expenses) that modify annual cash flow. + // SAFETY: paired with `event_count`; only `events[0..event_count]` is read. events: [max_events]LifeEvent = undefined, event_count: u8 = 0, // ── Accumulation phase ────────────────────────────────────── @@ -225,10 +226,14 @@ pub const UserConfig = struct { /// Backing buffer for an overridden `benchmark_stock`. Untouched /// (and unread) when the default is in effect. Sized to fit /// reasonable ticker lengths. + // SAFETY: only read when `benchmark_stock` points into this buffer + // (i.e. when the user has overridden the default); otherwise the + // backing slice points at a literal and this buffer is unobserved. benchmark_stock_buf: [16]u8 = undefined, /// Bond benchmark symbol. Same lifetime / override mechanics /// as `benchmark_stock`. benchmark_bond: []const u8 = "AGG", + // SAFETY: same override-only read pattern as `benchmark_stock_buf`. benchmark_bond_buf: [16]u8 = undefined, const max_horizons: usize = 8; @@ -1401,7 +1406,7 @@ fn percentile(sorted: []const f64, p: f64) f64 { if (sorted.len == 0) return 0; if (sorted.len == 1) return sorted[0]; - const n = @as(f64, @floatFromInt(sorted.len - 1)); + const n: f64 = @floatFromInt(sorted.len - 1); const idx = p * n; const lo_idx: usize = @intFromFloat(@floor(idx)); const hi_idx: usize = @min(lo_idx + 1, sorted.len - 1); diff --git a/src/analytics/risk.zig b/src/analytics/risk.zig index e6902dc..cf94a3f 100644 --- a/src/analytics/risk.zig +++ b/src/analytics/risk.zig @@ -65,8 +65,8 @@ const tbill_rates = [_]struct { year: u16, rate: f64 }{ /// Look up the average risk-free rate for a date range from the T-bill table. /// Returns the simple average of annual rates for all years that overlap the range. fn avgRiskFreeRate(start: Date, end: Date) f64 { - const start_year = @as(u16, @intCast(start.year())); - const end_year = @as(u16, @intCast(end.year())); + const start_year: u16 = @intCast(start.year()); + const end_year: u16 = @intCast(end.year()); var sum: f64 = 0; var count: f64 = 0; @@ -198,7 +198,7 @@ fn computeRisk(candles: []const Candle, start: Date, end: Date, risk_free_rate: } } - const nf = @as(f64, @floatFromInt(n_returns)); + const nf: f64 = @floatFromInt(n_returns); const mean = sum / nf; // Use sample variance (n-1) for unbiased estimate const variance = if (n_returns > 1) diff --git a/src/cache/store.zig b/src/cache/store.zig index 44782fe..8a95f0c 100644 --- a/src/cache/store.zig +++ b/src/cache/store.zig @@ -9,7 +9,6 @@ const Dividend = @import("../models/dividend.zig").Dividend; const DividendType = @import("../models/dividend.zig").DividendType; const Split = @import("../models/split.zig").Split; const EarningsEvent = @import("../models/earnings.zig").EarningsEvent; -const ReportTime = @import("../models/earnings.zig").ReportTime; const EtfProfile = @import("../models/etf_profile.zig").EtfProfile; const Holding = @import("../models/etf_profile.zig").Holding; const SectorWeight = @import("../models/etf_profile.zig").SectorWeight; @@ -638,7 +637,9 @@ pub const Store = struct { pub fn clearSymbol(self: *Store, symbol: []const u8) !void { const path = try self.symbolPath(symbol, ""); defer self.allocator.free(path); - std.Io.Dir.cwd().deleteTree(self.io, path) catch {}; + // Best-effort clear: deleting a non-existent symbol dir is + // a no-op success from the caller's POV, so log + continue. + std.Io.Dir.cwd().deleteTree(self.io, path) catch |err| std.log.debug("clearSymbol deleteTree({s}): {t}", .{ path, err }); } /// Content of a negative cache entry (fetch failed, don't retry until --refresh). @@ -649,7 +650,9 @@ pub const Store = struct { /// network requests for symbols that will never resolve. /// Cleared by --refresh (which calls clearData/invalidate). pub fn writeNegative(self: *Store, symbol: []const u8, data_type: DataType) void { - self.writeRaw(symbol, data_type, negative_cache_content) catch {}; + // Best-effort: a write failure here just means we'll re-attempt + // the upstream fetch next call, which is correct behavior. + self.writeRaw(symbol, data_type, negative_cache_content) catch |err| std.log.debug("writeNegative({s}/{t}): {t}", .{ symbol, data_type, err }); } /// Validate that a byte buffer looks like a complete SRF file. @@ -820,7 +823,7 @@ pub const Store = struct { var hash: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(bytes, &hash, .{}); var hash_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined; - _ = std.fmt.bufPrint(&hash_hex, "{x}", .{&hash}) catch unreachable; + _ = try std.fmt.bufPrint(&hash_hex, "{x}", .{&hash}); // ISO-8601 UTC timestamp — computed by hand to avoid pulling in // a dependency. Format: YYYY-MM-DDTHH:MM:SSZ. @@ -830,14 +833,14 @@ pub const Store = struct { const year_day = epoch_day.calculateYearDay(); const month_day = year_day.calculateMonthDay(); var iso_buf: [32]u8 = undefined; - const iso_ts = std.fmt.bufPrint(&iso_buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{ + const iso_ts = try std.fmt.bufPrint(&iso_buf, "{d:0>4}-{d:0>2}-{d:0>2}T{d:0>2}:{d:0>2}:{d:0>2}Z", .{ year_day.year, @intFromEnum(month_day.month), month_day.day_index + 1, day_seconds.getHoursIntoDay(), day_seconds.getMinutesIntoHour(), day_seconds.getSecondsIntoMinute(), - }) catch unreachable; + }); // Last 200 bytes as lowercase hex so the sidecar is // grep-friendly regardless of what binary garbage sits in the @@ -846,7 +849,7 @@ pub const Store = struct { const tail = bytes[bytes.len - tail_len ..]; const tail_hex = try allocator.alloc(u8, tail_len * 2); defer allocator.free(tail_hex); - _ = std.fmt.bufPrint(tail_hex, "{x}", .{tail}) catch unreachable; + _ = try std.fmt.bufPrint(tail_hex, "{x}", .{tail}); const record = TearRecord{ .type = "tear_metadata", @@ -925,7 +928,7 @@ pub const Store = struct { pub fn clearData(self: *Store, symbol: []const u8, data_type: DataType) void { const path = self.symbolPath(symbol, data_type.fileName()) catch return; defer self.allocator.free(path); - std.Io.Dir.cwd().deleteFile(self.io, path) catch {}; + std.Io.Dir.cwd().deleteFile(self.io, path) catch |err| std.log.debug("clearData deleteFile({s}): {t}", .{ path, err }); } /// Read the close price from the candle metadata file. @@ -973,7 +976,8 @@ pub const Store = struct { /// Clear all cached data. pub fn clearAll(self: *Store) !void { - std.Io.Dir.cwd().deleteTree(self.io, self.cache_dir) catch {}; + // Best-effort: clearing an already-absent cache dir is success. + std.Io.Dir.cwd().deleteTree(self.io, self.cache_dir) catch |err| std.log.debug("clearAll deleteTree({s}): {t}", .{ self.cache_dir, err }); } // ── Public types ───────────────────────────────────────────── diff --git a/src/commands/TimeRange.zig b/src/commands/TimeRange.zig index 7eba9c8..81d7357 100644 --- a/src/commands/TimeRange.zig +++ b/src/commands/TimeRange.zig @@ -195,7 +195,7 @@ pub fn parse( const value = cmd_args[i + 1]; // Resolve the endpoint per flag's grammar. - const endpoint = resolveEndpoint(io, today, a, flag, value) catch |err| return err; + const endpoint = try resolveEndpoint(io, today, a, flag, value); // Set the right side, rejecting duplicates. switch (flag.side) { @@ -233,13 +233,13 @@ pub fn checkConflicts(io: std.Io, range: @This(), rule: ConflictRule) ConflictEr .none => {}, .reject_live_anywhere => { if (endpointIsLive(range.before) or endpointIsLive(range.after)) { - cli.stderrPrint(io, "Error: this command does not accept 'live' as an endpoint.\n") catch {}; + cli.stderrPrint(io, "Error: this command does not accept 'live' as an endpoint.\n"); return error.LiveNotAllowed; } }, .reject_live_on_both => { if (endpointIsLive(range.before) and endpointIsLive(range.after)) { - cli.stderrPrint(io, "Error: cannot compare 'live' against 'live' — at least one endpoint must be a concrete date or commit.\n") catch {}; + cli.stderrPrint(io, "Error: cannot compare 'live' against 'live' — at least one endpoint must be a concrete date or commit.\n"); return error.LiveOnBothEndpoints; } }, @@ -296,17 +296,17 @@ fn resolveEndpoint( const parsed = cli.parseAsOfDate(value, today) catch |err| { var buf: [256]u8 = undefined; const msg = cli.fmtAsOfParseError(&buf, value, err); - cli.stderrPrint(io, "Error: ") catch {}; - cli.stderrPrint(io, flag) catch {}; - cli.stderrPrint(io, ": ") catch {}; - cli.stderrPrint(io, msg) catch {}; - cli.stderrPrint(io, "\n") catch {}; + cli.stderrPrint(io, "Error: "); + cli.stderrPrint(io, flag); + cli.stderrPrint(io, ": "); + cli.stderrPrint(io, msg); + cli.stderrPrint(io, "\n"); return error.InvalidValue; }; const date = parsed orelse { - cli.stderrPrint(io, "Error: ") catch {}; - cli.stderrPrint(io, flag) catch {}; - cli.stderrPrint(io, " does not accept 'live'. Use an explicit date or relative offset.\n") catch {}; + cli.stderrPrint(io, "Error: "); + cli.stderrPrint(io, flag); + cli.stderrPrint(io, " does not accept 'live'. Use an explicit date or relative offset.\n"); return error.LiveNotAllowed; }; return .{ .date = date }; @@ -315,11 +315,11 @@ fn resolveEndpoint( const parsed = cli.parseAsOfDate(value, today) catch |err| { var buf: [256]u8 = undefined; const msg = cli.fmtAsOfParseError(&buf, value, err); - cli.stderrPrint(io, "Error: ") catch {}; - cli.stderrPrint(io, flag) catch {}; - cli.stderrPrint(io, ": ") catch {}; - cli.stderrPrint(io, msg) catch {}; - cli.stderrPrint(io, "\n") catch {}; + cli.stderrPrint(io, "Error: "); + cli.stderrPrint(io, flag); + cli.stderrPrint(io, ": "); + cli.stderrPrint(io, msg); + cli.stderrPrint(io, "\n"); return error.InvalidValue; }; if (parsed) |d| return .{ .date = d }; @@ -329,17 +329,17 @@ fn resolveEndpoint( const spec = cli.parseCommitSpec(value, today) catch |err| { var buf: [256]u8 = undefined; const msg = cli.fmtCommitSpecError(&buf, value, err); - cli.stderrPrint(io, "Error: ") catch {}; - cli.stderrPrint(io, flag) catch {}; - cli.stderrPrint(io, ": ") catch {}; - cli.stderrPrint(io, msg) catch {}; - cli.stderrPrint(io, "\n") catch {}; + cli.stderrPrint(io, "Error: "); + cli.stderrPrint(io, flag); + cli.stderrPrint(io, ": "); + cli.stderrPrint(io, msg); + cli.stderrPrint(io, "\n"); return error.InvalidValue; }; // `working` on the before side is meaningless (you can't // diff the working copy against itself). Reject early. if (spec == .working_copy and std.mem.eql(u8, flag, "--commit-before")) { - cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n") catch {}; + cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n"); return error.WorkingCopyOnBeforeSide; } return .{ .commit_spec = spec }; @@ -353,19 +353,19 @@ fn endpointIsLive(ep: ?Endpoint) bool { } fn emitMissingValue(io: std.Io, flag: []const u8) ParseError!void { - cli.stderrPrint(io, "Error: ") catch {}; - cli.stderrPrint(io, flag) catch {}; - cli.stderrPrint(io, " requires a value.\n") catch {}; + cli.stderrPrint(io, "Error: "); + cli.stderrPrint(io, flag); + cli.stderrPrint(io, " requires a value.\n"); } fn emitDuplicateEndpoint(io: std.Io, prev: []const u8, current: []const u8, side: []const u8) ParseError!void { - cli.stderrPrint(io, "Error: ") catch {}; - cli.stderrPrint(io, prev) catch {}; - cli.stderrPrint(io, " and ") catch {}; - cli.stderrPrint(io, current) catch {}; - cli.stderrPrint(io, " both specify the ") catch {}; - cli.stderrPrint(io, side) catch {}; - cli.stderrPrint(io, " side. Pick one.\n") catch {}; + cli.stderrPrint(io, "Error: "); + cli.stderrPrint(io, prev); + cli.stderrPrint(io, " and "); + cli.stderrPrint(io, current); + cli.stderrPrint(io, " both specify the "); + cli.stderrPrint(io, side); + cli.stderrPrint(io, " side. Pick one.\n"); } // ── Tests ───────────────────────────────────────────────────── diff --git a/src/commands/analysis.zig b/src/commands/analysis.zig index fb65a7b..e6ec712 100644 --- a/src/commands/analysis.zig +++ b/src/commands/analysis.zig @@ -30,7 +30,7 @@ pub const meta: framework.Meta = .{ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len > 0) { - try cli.stderrPrint(ctx.io, "Error: 'analysis' takes no arguments\n"); + cli.stderrPrint(ctx.io, "Error: 'analysis' takes no arguments\n"); return error.UnexpectedArg; } return .{}; @@ -73,7 +73,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { // Build summary via shared pipeline var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { - try cli.stderrPrint(io, "Error computing portfolio summary.\n"); + cli.stderrPrint(io, "Error computing portfolio summary.\n"); return; }, else => return err, @@ -88,13 +88,13 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { defer allocator.free(meta_path); const meta_data = std.Io.Dir.cwd().readFileAlloc(io, meta_path, allocator, .limited(1024 * 1024)) catch { - try cli.stderrPrint(io, "Error: No metadata.srf found. Run: zfin enrich > metadata.srf\n"); + cli.stderrPrint(io, "Error: No metadata.srf found. Run: zfin enrich > metadata.srf\n"); return; }; defer allocator.free(meta_data); var cm = zfin.classification.parseClassificationFile(allocator, meta_data) catch { - try cli.stderrPrint(io, "Error: Cannot parse metadata.srf\n"); + cli.stderrPrint(io, "Error: Cannot parse metadata.srf\n"); return; }; defer cm.deinit(); @@ -113,7 +113,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { acct_map_opt, as_of, ) catch { - try cli.stderrPrint(io, "Error computing analysis.\n"); + cli.stderrPrint(io, "Error computing analysis.\n"); return; }; defer result.deinit(allocator); diff --git a/src/commands/audit.zig b/src/commands/audit.zig index 4443182..efb82ec 100644 --- a/src/commands/audit.zig +++ b/src/commands/audit.zig @@ -2,7 +2,6 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); -const fmt = cli.fmt; const Money = @import("../Money.zig"); const analysis = @import("../analytics/analysis.zig"); const portfolio_mod = @import("../models/portfolio.zig"); @@ -1310,20 +1309,20 @@ fn runHygieneCheck( ) !void { // Load portfolio const pf_data = std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch { - try cli.stderrPrint(io, "Error: Cannot read portfolio file\n"); + cli.stderrPrint(io, "Error: Cannot read portfolio file\n"); return; }; defer allocator.free(pf_data); var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch { - try cli.stderrPrint(io, "Error: Cannot parse portfolio file\n"); + cli.stderrPrint(io, "Error: Cannot parse portfolio file\n"); return; }; defer portfolio.deinit(); // Load accounts.srf var account_map = svc.loadAccountMap(portfolio_path) orelse { - try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n"); + cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account mapping)\n"); return; }; defer account_map.deinit(); @@ -1411,7 +1410,7 @@ fn runHygieneCheck( defer all_accounts.deinit(); for (portfolio.lots) |lot| { if (lot.account) |acct| { - all_accounts.put(acct, {}) catch {}; + try all_accounts.put(acct, {}); } } @@ -1469,7 +1468,7 @@ fn runHygieneCheck( while (acct_iter.next()) |stable_name| { if (last_update_ts.contains(stable_name.*)) continue; if (mods.contains(stable_name.*)) { - last_update_ts.put(stable_name.*, update_ts) catch {}; + try last_update_ts.put(stable_name.*, update_ts); } } } @@ -1830,7 +1829,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Load accounts.srf var account_map = svc.loadAccountMap(portfolio_path) orelse { - try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account number mapping)\n"); + cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf (needed for account number mapping)\n"); return; }; defer account_map.deinit(); @@ -1872,17 +1871,17 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Schwab summary from stdin if (schwab_summary) { - try cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n"); + cli.stderrPrint(io, "Paste Schwab account summary, then press Ctrl+D:\n"); var stdin_reader_buf: [4096]u8 = undefined; var stdin_reader = std.Io.File.stdin().reader(io, &stdin_reader_buf); const stdin_data = stdin_reader.interface.allocRemaining(allocator, .limited(1024 * 1024)) catch { - try cli.stderrPrint(io, "Error: Cannot read stdin\n"); + cli.stderrPrint(io, "Error: Cannot read stdin\n"); return; }; defer allocator.free(stdin_data); const schwab_accounts = parseSchwabSummary(allocator, stdin_data) catch { - try cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n"); + cli.stderrPrint(io, "Error: Cannot parse Schwab summary (no 'Account number ending in' lines found)\n"); return; }; defer allocator.free(schwab_accounts); @@ -1899,13 +1898,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch { var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); return; }; defer allocator.free(csv_data); const brokerage_positions = parseFidelityCsv(allocator, csv_data) catch { - try cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n"); + cli.stderrPrint(io, "Error: Cannot parse Fidelity CSV (unexpected format?)\n"); return; }; defer allocator.free(brokerage_positions); @@ -1925,13 +1924,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const csv_data = std.Io.Dir.cwd().readFileAlloc(io, csv_path, allocator, .limited(10 * 1024 * 1024)) catch { var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read CSV file: {s}\n", .{csv_path}) catch "Error: Cannot read CSV file\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); return; }; defer allocator.free(csv_data); const csv = parseSchwabCsv(allocator, csv_data) catch { - try cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n"); + cli.stderrPrint(io, "Error: Cannot parse Schwab CSV (unexpected format?)\n"); return; }; defer allocator.free(csv.positions); diff --git a/src/commands/cache.zig b/src/commands/cache.zig index 02211a6..a0cb675 100644 --- a/src/commands/cache.zig +++ b/src/commands/cache.zig @@ -57,11 +57,11 @@ const display_labels = [_][]const u8{ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len < 1) { - try cli.stderrPrint(ctx.io, "Error: 'cache' requires a subcommand (stats, clear)\n"); + cli.stderrPrint(ctx.io, "Error: 'cache' requires a subcommand (stats, clear)\n"); return error.MissingSubcommand; } if (cmd_args.len > 1) { - try cli.stderrPrint(ctx.io, "Error: 'cache' takes a single subcommand\n"); + cli.stderrPrint(ctx.io, "Error: 'cache' takes a single subcommand\n"); return error.UnexpectedArg; } const sub_str = cmd_args[0]; @@ -71,9 +71,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr if (std.mem.eql(u8, sub_str, "clear")) { return .{ .sub = .clear }; } - try cli.stderrPrint(ctx.io, "Error: unknown cache subcommand '"); - try cli.stderrPrint(ctx.io, sub_str); - try cli.stderrPrint(ctx.io, "'. Use 'stats' or 'clear'.\n"); + cli.stderrPrint(ctx.io, "Error: unknown cache subcommand '"); + cli.stderrPrint(ctx.io, sub_str); + cli.stderrPrint(ctx.io, "'. Use 'stats' or 'clear'.\n"); return error.UnknownSubcommand; } @@ -225,6 +225,8 @@ const FileInfo = struct { is_negative: bool = false, created: ?i64 = null, expired: bool = false, + // SAFETY: paired with `last_date_len`; only the prefix + // `last_date_buf[0..last_date_len]` is ever read (see `lastDate`). last_date_buf: [10]u8 = undefined, last_date_len: u4 = 0, @@ -253,7 +255,13 @@ fn getFileInfo(io: std.Io, allocator: std.mem.Allocator, cache_dir: []const u8, return .{ .exists = true, .size = stat.size }; var last_date_buf: [10]u8 = undefined; - const date_str = std.fmt.bufPrint(&last_date_buf, "{f}", .{meta_result.meta.last_date}) catch unreachable; + // SAFETY: Date renders as 10-byte "YYYY-MM-DD"; buffer + // is exactly that size so bufPrint cannot run out of room. + const date_str = std.fmt.bufPrint( + &last_date_buf, + "{f}", + .{meta_result.meta.last_date}, + ) catch last_date_buf[0..]; return .{ .exists = true, diff --git a/src/commands/common.zig b/src/commands/common.zig index 56b972c..deb7062 100644 --- a/src/commands/common.zig +++ b/src/commands/common.zig @@ -5,6 +5,7 @@ const srf = @import("srf"); const history = @import("../history.zig"); const git = @import("../git.zig"); const framework = @import("framework.zig"); +const stderr = @import("../stderr.zig"); pub const fmt = @import("../format.zig"); // ── Default CLI colors (match TUI default Monokai theme) ───── @@ -113,55 +114,17 @@ pub fn printGainLoss( // ── Stderr helpers ─────────────────────────────────────────── -pub fn stderrPrint(io: std.Io, msg: []const u8) !void { - // Under `zig build test` these messages are just noise — tests - // that exercise error paths emit the same usage/hint strings on - // every run. Real CLI users always reach the real stderr. - if (builtin.is_test) return; - var buf: [1024]u8 = undefined; - var writer = std.Io.File.stderr().writer(io, &buf); - const out = &writer.interface; - try out.writeAll(msg); - try out.flush(); -} +// ── stderr writers (re-exports of `src/stderr.zig`) ───────── +// +// Best-effort, non-throwing writers. The implementations live in +// `src/stderr.zig` so the portfolio loader and TUI can use them +// without an "X calls into commands/" import smell. Re-exported +// here under the original names so the ~239 existing +// `cli.stderrPrint(...)` callers don't have to be touched. -/// Print progress line to stderr: " [N/M] SYMBOL (status)" -pub fn stderrProgress(io: std.Io, symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void { - if (builtin.is_test) return; - var buf: [256]u8 = undefined; - var writer = std.Io.File.stderr().writer(io, &buf); - const out = &writer.interface; - if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); - try out.print(" [{d}/{d}] ", .{ current, total }); - if (color) try fmt.ansiReset(out); - try out.print("{s}", .{symbol}); - if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); - try out.print("{s}\n", .{status}); - if (color) try fmt.ansiReset(out); - try out.flush(); -} - -/// Print rate-limit wait message to stderr -pub fn stderrRateLimitWait(io: std.Io, wait_seconds: u64, color: bool) !void { - if (builtin.is_test) return; - var buf: [256]u8 = undefined; - var writer = std.Io.File.stderr().writer(io, &buf); - const out = &writer.interface; - if (color) try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]); - if (wait_seconds >= 60) { - const mins = wait_seconds / 60; - const secs = wait_seconds % 60; - if (secs > 0) { - try out.print(" (rate limit -- waiting {d}m {d}s)\n", .{ mins, secs }); - } else { - try out.print(" (rate limit -- waiting {d}m)\n", .{mins}); - } - } else { - try out.print(" (rate limit -- waiting {d}s)\n", .{wait_seconds}); - } - if (color) try fmt.ansiReset(out); - try out.flush(); -} +pub const stderrPrint = stderr.print; +pub const stderrProgress = stderr.progress; +pub const stderrRateLimitWait = stderr.rateLimitWait; /// Progress callback for loadPrices that prints to stderr. /// Shared between the CLI portfolio command and TUI pre-fetch. @@ -181,21 +144,21 @@ pub const LoadProgress = struct { .fetching => { // Show rate-limit wait before the fetch if (self.svc.estimateWaitSeconds()) |w| { - if (w > 0) stderrRateLimitWait(self.io, w, self.color) catch {}; + if (w > 0) stderrRateLimitWait(self.io, w, self.color); } - stderrProgress(self.io, symbol, " (fetching)", display_idx, self.grand_total, self.color) catch {}; + stderrProgress(self.io, symbol, " (fetching)", display_idx, self.grand_total, self.color); }, .cached => { - stderrProgress(self.io, symbol, " (cached)", display_idx, self.grand_total, self.color) catch {}; + stderrProgress(self.io, symbol, " (cached)", display_idx, self.grand_total, self.color); }, .fetched => { // Already showed "(fetching)" — no extra line needed }, .failed_used_stale => { - stderrProgress(self.io, symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color) catch {}; + stderrProgress(self.io, symbol, " FAILED (using cached)", display_idx, self.grand_total, self.color); }, .failed => { - stderrProgress(self.io, symbol, " FAILED", display_idx, self.grand_total, self.color) catch {}; + stderrProgress(self.io, symbol, " FAILED", display_idx, self.grand_total, self.color); }, } } @@ -222,6 +185,17 @@ pub const AggregateProgress = struct { const phase_changed = self.last_phase == null or self.last_phase.? != phase; self.last_phase = phase; + // Best-effort: stderr-write failures here would only mean + // the user doesn't see a progress line. The download + // itself proceeds. Catch + log at the boundary so the + // vtable's `void` return is honored without 8 inline + // `catch {}` patterns. + draw(self, completed, total, phase, phase_changed) catch |err| { + std.log.debug("AggregateProgress draw failed: {t}", .{err}); + }; + } + + fn draw(self: *AggregateProgress, completed: usize, total: usize, phase: zfin.DataService.AggregateProgressCallback.Phase, phase_changed: bool) !void { var buf: [256]u8 = undefined; var writer = std.Io.File.stderr().writer(self.io, &buf); const w = &writer.interface; @@ -230,19 +204,19 @@ pub const AggregateProgress = struct { .cache_check => {}, .server_sync => { if (completed != self.last_completed) { - if (self.color) fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; - w.print(" Syncing from server... [{d}/{d}]\n", .{ completed, total }) catch {}; - if (self.color) fmt.ansiReset(w) catch {}; - w.flush() catch {}; + if (self.color) try fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); + try w.print(" Syncing from server... [{d}/{d}]\n", .{ completed, total }); + if (self.color) try fmt.ansiReset(w); + try w.flush(); self.last_completed = completed; } }, .provider_fetch => { if (phase_changed) { - if (self.color) fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; - w.print(" Synced {d} from server, fetching remaining from providers...\n", .{completed}) catch {}; - if (self.color) fmt.ansiReset(w) catch {}; - w.flush() catch {}; + if (self.color) try fmt.ansiSetFg(w, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); + try w.print(" Synced {d} from server, fetching remaining from providers...\n", .{completed}); + if (self.color) try fmt.ansiReset(w); + try w.flush(); } }, .complete => {}, @@ -317,88 +291,86 @@ pub fn loadPortfolioPrices( const failed = result.failed_count; const stale = result.stale_count; - var buf: [256]u8 = undefined; - var writer = std.Io.File.stderr().writer(io, &buf); - const out = &writer.interface; - if (from_cache == total) { - if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; - out.print(" Loaded {d} symbols from cache\n", .{total}) catch {}; - if (color) fmt.ansiReset(out) catch {}; - } else if (failed > 0) { - if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; - if (stale > 0) { - out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ total, from_cache, from_server, from_provider, failed, stale }) catch {}; - } else { - out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ total, from_cache, from_server, from_provider, failed }) catch {}; - } - if (color) fmt.ansiReset(out) catch {}; + printLoadSummary(io, color, .{ .total = total, .from_cache = from_cache, .from_server = from_server, .from_provider = from_provider, .failed = failed, .stale = stale }); } else { - if (color) fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]) catch {}; - if (from_server > 0 and from_provider > 0) { - out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider)\n", .{ total, from_cache, from_server, from_provider }) catch {}; - } else if (from_server > 0) { - out.print(" Loaded {d} symbols ({d} cached, {d} server)\n", .{ total, from_cache, from_server }) catch {}; - } else { - out.print(" Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ total, from_cache, from_provider }) catch {}; - } - if (color) fmt.ansiReset(out) catch {}; + printLoadSummary(io, color, .{ .total = total, .from_cache = from_cache, .from_server = from_server, .from_provider = from_provider, .failed = failed, .stale = stale }); } - out.flush() catch {}; return result; } -// ── 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]; - } +const LoadSummaryStats = struct { + total: usize, + from_cache: usize, + from_server: usize, + from_provider: usize, + failed: usize, + stale: usize, }; +/// Print the per-load summary line. Best-effort: a stderr-write +/// failure here would only mean the user doesn't see the +/// "Loaded N symbols ..." line; the load itself already +/// succeeded. Catch + log at the boundary. +fn printLoadSummary(io: std.Io, color: bool, s: LoadSummaryStats) void { + if (builtin.is_test) return; + printLoadSummaryImpl(io, color, s) catch |err| { + std.log.debug("printLoadSummary failed: {t}", .{err}); + }; +} + +fn printLoadSummaryImpl(io: std.Io, color: bool, s: LoadSummaryStats) !void { + var buf: [256]u8 = undefined; + var writer = std.Io.File.stderr().writer(io, &buf); + const out = &writer.interface; + + if (s.from_cache == s.total) { + if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); + try out.print(" Loaded {d} symbols from cache\n", .{s.total}); + if (color) try fmt.ansiReset(out); + } else if (s.failed > 0) { + if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); + if (s.stale > 0) { + try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed — {d} using stale)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed, s.stale }); + } else { + try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider, {d} failed)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider, s.failed }); + } + if (color) try fmt.ansiReset(out); + } else { + if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); + if (s.from_server > 0 and s.from_provider > 0) { + try out.print(" Loaded {d} symbols ({d} cached, {d} server, {d} provider)\n", .{ s.total, s.from_cache, s.from_server, s.from_provider }); + } else if (s.from_server > 0) { + try out.print(" Loaded {d} symbols ({d} cached, {d} server)\n", .{ s.total, s.from_cache, s.from_server }); + } else { + try out.print(" Loaded {d} symbols ({d} cached, {d} fetched)\n", .{ s.total, s.from_cache, s.from_provider }); + } + if (color) try fmt.ansiReset(out); + } + try out.flush(); +} + +// ── Portfolio loading ──────────────────────────────────────── +// +// The actual loader lives in `src/portfolio_loader.zig` so the +// TUI can import it without depending on `commands/common.zig` +// (which is otherwise CLI-shaped: stderr printing, color helpers, +// progress trackers). What's left here is a single CLI-side +// convenience that bridges a `*RunCtx` to the loader's +// `(io, allocator, config, patterns)` signature, plus re-exports +// so existing CLI commands don't have to change their +// `cli.` references. + +const portfolio_loader = @import("../portfolio_loader.zig"); + +pub const LoadedPortfolio = portfolio_loader.LoadedPortfolio; +pub const PortfolioData = portfolio_loader.PortfolioData; +pub const loadPortfolioFromConfig = portfolio_loader.loadPortfolioFromConfig; +pub const loadPortfolioFromPaths = portfolio_loader.loadPortfolioFromPaths; +pub const loadPortfolioFromFile = portfolio_loader.loadPortfolioFromFile; +pub const buildPortfolioData = portfolio_loader.buildPortfolioData; + /// Resolve `-p`/`--portfolio` patterns through `ctx`, then load the /// union of all matched portfolio files. The one-stop loader for /// CLI commands: returns `null` (with a stderr message already @@ -408,18 +380,13 @@ pub const LoadedPortfolio = struct { /// /// Caller must `deinit(allocator)` the returned `LoadedPortfolio`. /// -/// The resolved paths are attached to the returned struct, so callers -/// don't need to call `ctx.resolvePortfolioPaths()` separately. Use -/// `loaded.anchor()` for sibling-file derivation; iterate -/// `loaded.paths` if the command genuinely needs the per-file list. -/// -/// 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. +/// Thin wrapper over `portfolio_loader.loadPortfolioFromConfig` +/// that pulls `(io, allocator, config, patterns)` out of the +/// RunCtx. Both CLI dispatch and the TUI go through the same +/// `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 { - return loadPortfolioFromConfig( + return portfolio_loader.loadPortfolioFromConfig( ctx.io, ctx.allocator, ctx.config, @@ -428,305 +395,6 @@ pub fn loadPortfolio(ctx: *framework.RunCtx, as_of: zfin.Date) ?LoadedPortfolio ); } -/// 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 {}; - stderrPrint(io, " next to the portfolio, so all portfolio files must share a directory.\n") catch {}; - return null; - }, - else => { - stderrPrint(io, "Error: failed to resolve portfolio path(s)\n") catch {}; - return null; - }, - }; - if (resolved.paths.len == 0) { - resolved.deinit(); - stderrPrint(io, "Error: no portfolio file found (looked for portfolio*.srf in cwd → ZFIN_HOME)\n") catch {}; - return null; - } - // Snapshot the path-string view as our own owned slice. Backing - // strings stay live as long as `resolved.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 tests and any internal call site -/// that needs to bypass `RunCtx` resolution. Strings inside `paths` -/// are NOT freed by `LoadedPortfolio.deinit` — caller retains -/// ownership of them. The slice `paths` itself IS freed by deinit -/// (the LoadedPortfolio takes ownership of just the slice). -/// -/// For most callers, prefer `loadPortfolio(ctx, as_of)` instead. -pub fn loadPortfolioFromPaths(io: std.Io, allocator: std.mem.Allocator, paths: []const []const u8, as_of: zfin.Date) ?LoadedPortfolio { - if (paths.len == 0) { - stderrPrint(io, "Error: No portfolio file found\n") catch {}; - return null; - } - // Dupe the slice so deinit can free it without touching the - // caller's storage. Path strings remain caller-owned and are - // borrowed by the returned struct (resolved_paths = null - // signals "no Config.ResolvedPaths to deinit"). - const paths_owned = allocator.dupe([]const u8, paths) catch return null; - return loadFromPaths(io, allocator, paths_owned, null, as_of); -} - -/// Internal: load+merge given a pre-resolved paths slice. The slice -/// `paths_owned` is taken (will be freed by `LoadedPortfolio.deinit`). -/// `resolved_paths_opt` is the optional `Config.ResolvedPaths` to -/// hand off ownership of the path strings to the returned struct; -/// when null, path strings are caller-owned. -fn loadFromPaths( - io: std.Io, - allocator: std.mem.Allocator, - paths_owned: []const []const u8, - resolved_paths_opt: ?zfin.Config.ResolvedPaths, - as_of: zfin.Date, -) ?LoadedPortfolio { - // On any error after this point we must free the slice we just - // took ownership of, plus deinit the `resolved_paths_opt` so the - // path strings aren't leaked. - var error_cleanup_armed = true; - defer if (error_cleanup_armed) { - allocator.free(paths_owned); - if (resolved_paths_opt) |rp| rp.deinit(); - }; - - // Read every file up front; bail on first error. - var file_datas: std.ArrayList([]const u8) = .empty; - errdefer { - for (file_datas.items) |d| allocator.free(d); - file_datas.deinit(allocator); - } - for (paths_owned) |p| { - const data = std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(10 * 1024 * 1024)) catch { - var msg_buf: [512]u8 = undefined; - const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read portfolio file: {s}\n", .{p}) catch "Error: Cannot read portfolio file\n"; - stderrPrint(io, msg) catch {}; - for (file_datas.items) |d| allocator.free(d); - file_datas.deinit(allocator); - return null; - }; - file_datas.append(allocator, data) catch { - allocator.free(data); - for (file_datas.items) |d| allocator.free(d); - file_datas.deinit(allocator); - return null; - }; - } - - // Deserialize each into an owned Portfolio, then merge their - // lot slices into a single combined slice. We can't simply - // concat the underlying slices because each Portfolio expects - // to free its own lots in `deinit()`; instead, we steal each - // Portfolio's lots[] (string fields are already dupe'd into - // `allocator`) and free only the empty Portfolio struct. - var merged: std.ArrayList(zfin.Lot) = .empty; - errdefer { - for (merged.items) |lot| { - allocator.free(lot.symbol); - if (lot.note) |n| allocator.free(n); - if (lot.account) |a| allocator.free(a); - if (lot.ticker) |t| allocator.free(t); - if (lot.underlying) |u| allocator.free(u); - } - merged.deinit(allocator); - } - - for (file_datas.items, 0..) |data, idx| { - var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch { - var msg_buf: [512]u8 = undefined; - const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse portfolio file: {s}\n", .{paths_owned[idx]}) catch "Error: Cannot parse portfolio file\n"; - stderrPrint(io, msg) catch {}; - for (merged.items) |lot| { - allocator.free(lot.symbol); - if (lot.note) |n| allocator.free(n); - if (lot.account) |a| allocator.free(a); - if (lot.ticker) |t| allocator.free(t); - if (lot.underlying) |u| allocator.free(u); - } - merged.deinit(allocator); - for (file_datas.items) |d| allocator.free(d); - file_datas.deinit(allocator); - return null; - }; - for (portfolio.lots) |lot| { - merged.append(allocator, lot) catch { - portfolio.deinit(); - for (merged.items) |existing| { - allocator.free(existing.symbol); - if (existing.note) |n| allocator.free(n); - if (existing.account) |a| allocator.free(a); - if (existing.ticker) |t| allocator.free(t); - if (existing.underlying) |u| allocator.free(u); - } - merged.deinit(allocator); - for (file_datas.items) |d| allocator.free(d); - file_datas.deinit(allocator); - return null; - }; - } - // Free the now-empty Portfolio's lots slice without freeing - // the per-lot strings — they were transferred to `merged`. - allocator.free(portfolio.lots); - } - - const merged_slice = merged.toOwnedSlice(allocator) catch { - for (file_datas.items) |d| allocator.free(d); - file_datas.deinit(allocator); - return null; - }; - - var combined: zfin.Portfolio = .{ - .lots = merged_slice, - .allocator = allocator, - }; - - const positions = combined.positions(as_of, allocator) catch { - combined.deinit(); - for (file_datas.items) |d| allocator.free(d); - file_datas.deinit(allocator); - stderrPrint(io, "Error: Cannot compute positions\n") catch {}; - return null; - }; - - const syms = combined.stockSymbols(allocator) catch { - allocator.free(positions); - combined.deinit(); - for (file_datas.items) |d| allocator.free(d); - file_datas.deinit(allocator); - stderrPrint(io, "Error: Cannot get stock symbols\n") catch {}; - return null; - }; - - const file_datas_owned = file_datas.toOwnedSlice(allocator) catch { - allocator.free(syms); - allocator.free(positions); - combined.deinit(); - return null; - }; - - error_cleanup_armed = false; - return .{ - .paths = paths_owned, - .resolved_paths = resolved_paths_opt, - .file_datas = file_datas_owned, - .portfolio = combined, - .positions = positions, - .syms = syms, - }; -} - -/// Convenience for tests: load a single portfolio file by path. -/// Wraps `loadPortfolioFromPaths` with a one-element slice. -pub fn loadPortfolioFromFile(io: std.Io, allocator: std.mem.Allocator, file_path: []const u8, as_of: zfin.Date) ?LoadedPortfolio { - const paths = [_][]const u8{file_path}; - return loadPortfolioFromPaths(io, allocator, &paths, as_of); -} - -// ── Portfolio data pipeline ────────────────────────────────── - -/// Result of the shared portfolio data pipeline. Caller must call deinit(). -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. - candle_map.put(sym, cs.data) catch {}; - } - } - - const snapshots = zfin.valuation.computeHistoricalSnapshots( - as_of, - positions, - prices.*, - candle_map, - ); - - return .{ - .summary = summary, - .candle_map = candle_map, - .snapshots = snapshots, - }; -} - // ── As-of date parsing (shared by CLI --as-of and TUI date popup) ── pub const AsOfParseError = error{ @@ -859,7 +527,7 @@ pub fn parseRequiredDateOrStderr( ) catch "Error: invalid date\n"; }, }; - stderrPrint(io, msg) catch {}; + stderrPrint(io, msg); return error.InvalidDate; }; } @@ -1073,18 +741,18 @@ pub fn resolveSnapshotOrExplain( return history.resolveSnapshotDate(io, arena, hist_dir, requested) catch |err| switch (err) { error.NoSnapshotAtOrBefore => { const msg = std.fmt.allocPrint(arena, "No snapshot at or before {f}.\n", .{requested}) catch "No snapshot at or before the requested date.\n"; - stderrPrint(io, msg) catch {}; + stderrPrint(io, msg); // Second look at the nearest table for the "later // available" hint. Cheap (filesystem scan, same dir). const nearest = history.findNearestSnapshot(io, hist_dir, requested) catch { - stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n") catch {}; + stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n"); return err; }; if (nearest.later) |later| { const later_msg = std.fmt.allocPrint(arena, "Earliest available: {f} (later than requested).\n", .{later}) catch "A later snapshot exists but was not used.\n"; - stderrPrint(io, later_msg) catch {}; + stderrPrint(io, later_msg); } else { - stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n") catch {}; + stderrPrint(io, "No snapshots in history/ — run `zfin snapshot` to create one.\n"); } return err; }, @@ -1110,8 +778,8 @@ pub fn resolveAsOfOrExplain( return history.resolveAsOfDate(io, arena, hist_dir, requested) catch |err| switch (err) { error.NoDataAtOrBefore => { const msg = std.fmt.allocPrint(arena, "No snapshot or imported_values entry at or before {f}.\n", .{requested}) catch "No data at or before the requested date.\n"; - stderrPrint(io, msg) catch {}; - stderrPrint(io, "Run `zfin snapshot` or populate history/imported_values.srf to enable as-of mode.\n") catch {}; + stderrPrint(io, msg); + stderrPrint(io, "Run `zfin snapshot` or populate history/imported_values.srf to enable as-of mode.\n"); return err; }, else => |e| return e, diff --git a/src/commands/compare.zig b/src/commands/compare.zig index 265d215..c49cc1f 100644 --- a/src/commands/compare.zig +++ b/src/commands/compare.zig @@ -57,7 +57,6 @@ const Money = @import("../Money.zig"); const history = @import("../history.zig"); const compare_core = @import("../compare.zig"); const view = @import("../views/compare.zig"); -const view_hist = @import("../views/history.zig"); const contributions = @import("contributions.zig"); const projections = @import("projections.zig"); @@ -190,13 +189,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr } else if (std.mem.eql(u8, a, "--no-events")) { parsed.events_enabled = false; } else if (a.len > 0 and a[0] == '-' and !std.mem.eql(u8, a, "-")) { - try cli.stderrPrint(io, "Error: unknown flag for 'compare': "); - try cli.stderrPrint(io, a); - try cli.stderrPrint(io, "\nKnown flags: --projections, --no-events, --snapshot-before, --snapshot-after, --commit-before, --commit-after.\n"); + cli.stderrPrint(io, "Error: unknown flag for 'compare': "); + cli.stderrPrint(io, a); + cli.stderrPrint(io, "\nKnown flags: --projections, --no-events, --snapshot-before, --snapshot-after, --commit-before, --commit-after.\n"); if (std.mem.eql(u8, a, "-p")) { - try cli.stderrPrint(io, " (Tip: the projections flag is spelled `--projections` in full.\n"); - try cli.stderrPrint(io, " `-p` is reserved for the global --portfolio option and must appear\n"); - try cli.stderrPrint(io, " before the subcommand, e.g. `zfin -p /path/to/portfolio.srf compare ...`.)\n"); + cli.stderrPrint(io, " (Tip: the projections flag is spelled `--projections` in full.\n"); + cli.stderrPrint(io, " `-p` is reserved for the global --portfolio option and must appear\n"); + cli.stderrPrint(io, " before the subcommand, e.g. `zfin -p /path/to/portfolio.srf compare ...`.)\n"); } return error.UnexpectedArg; } else { @@ -207,7 +206,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr // ── Resolve positional dates into the snapshot axes ────── if (args.len > 2) { - try cli.stderrPrint(io, "Error: 'compare' takes at most two positional dates.\n"); + cli.stderrPrint(io, "Error: 'compare' takes at most two positional dates.\n"); return error.UnexpectedArg; } @@ -251,13 +250,13 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr // Require at least one source of the "then" date. const have_then_anchor = parsed.snapshot_before != null or parsed.commit_before != null; if (!have_then_anchor) { - try cli.stderrPrint(io, "Error: 'compare' requires a before-side anchor (positional date, --snapshot-before, or --commit-before).\n"); - try cli.stderrPrint(io, "Usage:\n"); - try cli.stderrPrint(io, " zfin compare (compare date vs current)\n"); - try cli.stderrPrint(io, " zfin compare (compare two dates)\n"); - try cli.stderrPrint(io, " zfin compare --snapshot-before [--commit-before ] (explicit axes)\n"); - try cli.stderrPrint(io, "Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n"); - try cli.stderrPrint(io, "See `zfin help` for --commit-before/--commit-after/--snapshot-before/--snapshot-after details.\n"); + cli.stderrPrint(io, "Error: 'compare' requires a before-side anchor (positional date, --snapshot-before, or --commit-before).\n"); + cli.stderrPrint(io, "Usage:\n"); + cli.stderrPrint(io, " zfin compare (compare date vs current)\n"); + cli.stderrPrint(io, " zfin compare (compare two dates)\n"); + cli.stderrPrint(io, " zfin compare --snapshot-before [--commit-before ] (explicit axes)\n"); + cli.stderrPrint(io, "Dates accept YYYY-MM-DD or relative shortcuts: 1W, 1M, 1Q, 1Y.\n"); + cli.stderrPrint(io, "See `zfin help` for --commit-before/--commit-after/--snapshot-before/--snapshot-after details.\n"); return error.MissingDateArg; } @@ -307,7 +306,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { if (commit_before_override) |cb| switch (cb) { .date_at_or_before => |d| break :blk d, else => { - try cli.stderrPrint(io, "Error: --commit-before with a non-date SPEC requires an explicit --snapshot-before date for the liquid comparison.\n"); + cli.stderrPrint(io, "Error: --commit-before with a non-date SPEC requires an explicit --snapshot-before date for the liquid comparison.\n"); return error.MissingDateArg; }, }; @@ -319,16 +318,16 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Validate snapshot date ordering. if (now_is_live) { if (then_requested.days == as_of.days) { - try cli.stderrPrint(io, "Error: cannot compare today against today's live portfolio.\n"); + cli.stderrPrint(io, "Error: cannot compare today against today's live portfolio.\n"); return error.SameDate; } if (then_requested.days > as_of.days) { - try cli.stderrPrint(io, "Error: cannot compare against a future date.\n"); + cli.stderrPrint(io, "Error: cannot compare against a future date.\n"); return error.InvalidDate; } } else if (!snapshot_after_live) { if (then_requested.days == now_requested.days) { - try cli.stderrPrint(io, "Error: before and after dates are the same — nothing to compare.\n"); + cli.stderrPrint(io, "Error: before and after dates are the same — nothing to compare.\n"); return error.SameDate; } } @@ -421,7 +420,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Liquid/attribution/per-symbol view. var ebuf: [160]u8 = undefined; const msg = std.fmt.bufPrint(&ebuf, "(projections block failed: {s} — continuing without)\n", .{@errorName(err)}) catch "(projections block failed)\n"; - cli.stderrPrint(io, msg) catch {}; + cli.stderrPrint(io, msg); break :blk null; }; if (projections_result) |r| { @@ -600,7 +599,7 @@ const LiveSide = struct { errdefer loaded_pf.deinit(allocator); if (loaded_pf.portfolio.lots.len == 0) { - try cli.stderrPrint(io, "Portfolio is empty.\n"); + cli.stderrPrint(io, "Portfolio is empty.\n"); return error.PortfolioLoadFailed; } @@ -611,7 +610,7 @@ const LiveSide = struct { var load_result = cli.loadPortfolioPrices(io, svc, loaded_pf.syms, &.{}, refresh, color); defer load_result.deinit(); var it = load_result.prices.iterator(); - while (it.next()) |entry| prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; + while (it.next()) |entry| try prices.put(entry.key_ptr.*, entry.value_ptr.*); } var pf_data = cli.buildPortfolioData( @@ -624,7 +623,7 @@ const LiveSide = struct { as_of, ) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { - try cli.stderrPrint(io, "Error computing portfolio summary.\n"); + cli.stderrPrint(io, "Error computing portfolio summary.\n"); return error.PortfolioLoadFailed; }, else => return err, @@ -827,6 +826,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs { .io = std.testing.io, .allocator = std.testing.allocator, .gpa = std.testing.allocator, + // SAFETY: parseArgs doesn't touch environ_map. .environ_map = undefined, .config = .{ .cache_dir = "" }, .svc = null, @@ -834,6 +834,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs { .today = today, .now_s = 0, .color = false, + // SAFETY: parseArgs doesn't write to out. .out = undefined, }; return parseArgs(&ctx, args); @@ -1348,6 +1349,7 @@ fn runArgs( .io = io, .allocator = allocator, .gpa = allocator, + // SAFETY: this code path doesn't read environ_map. .environ_map = undefined, .config = .{ .cache_dir = "" }, .svc = svc, diff --git a/src/commands/contributions.zig b/src/commands/contributions.zig index 844cd05..d86547a 100644 --- a/src/commands/contributions.zig +++ b/src/commands/contributions.zig @@ -166,7 +166,6 @@ const framework = @import("framework.zig"); const TimeRange = @import("TimeRange.zig"); const analysis = @import("../analytics/analysis.zig"); const transaction_log = @import("../models/transaction_log.zig"); -const fmt = cli.fmt; const Money = @import("../Money.zig"); const Date = zfin.Date; const Lot = zfin.Lot; @@ -257,9 +256,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr for (tr_result.consumed) |idx| try consumed_set.put(idx, {}); for (cmd_args, 0..) |a, i| { if (consumed_set.contains(i)) continue; - try cli.stderrPrint(io, "Error: unexpected argument to 'contributions': "); - try cli.stderrPrint(io, a); - try cli.stderrPrint(io, "\n"); + cli.stderrPrint(io, "Error: unexpected argument to 'contributions': "); + cli.stderrPrint(io, a); + cli.stderrPrint(io, "\n"); return error.UnexpectedArg; } @@ -285,7 +284,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr if (parsed.before) |b| if (parsed.after) |a| { if (b == .date_at_or_before and a == .date_at_or_before) { if (b.date_at_or_before.days > a.date_at_or_before.days) { - try cli.stderrPrint(io, "Error: --since must be on or before --until.\n"); + cli.stderrPrint(io, "Error: --since must be on or before --until.\n"); return error.InvalidArg; } } @@ -335,7 +334,7 @@ fn runImpl( // can assume the invariant. The legacy no-flag path passes both // as null and falls through to HEAD~1..HEAD / HEAD..WC. if (before == null and after != null) { - try cli.stderrPrint(io, "Error: --until / --commit-after requires --since / --commit-before.\n"); + cli.stderrPrint(io, "Error: --until / --commit-after requires --since / --commit-before.\n"); return; } @@ -396,20 +395,20 @@ fn prepareReport( const repo = git.findRepo(io, arena, portfolio_path) catch |err| { if (verbosity == .verbose) { switch (err) { - error.NotInGitRepo => cli.stderrPrint(io, "Error: contributions requires portfolio.srf to be in a git repo.\n") catch {}, - error.GitUnavailable => cli.stderrPrint(io, "Error: could not run 'git'. Is git installed and on PATH?\n") catch {}, - else => cli.stderrPrint(io, "Error locating git repo.\n") catch {}, + error.NotInGitRepo => cli.stderrPrint(io, "Error: contributions requires portfolio.srf to be in a git repo.\n"), + error.GitUnavailable => cli.stderrPrint(io, "Error: could not run 'git'. Is git installed and on PATH?\n"), + else => cli.stderrPrint(io, "Error locating git repo.\n"), } } return error.PrepareFailed; }; const status = git.pathStatus(io, arena, repo.root, repo.rel_path) catch { - if (verbosity == .verbose) cli.stderrPrint(io, "Error: could not determine git status of portfolio.srf.\n") catch {}; + if (verbosity == .verbose) cli.stderrPrint(io, "Error: could not determine git status of portfolio.srf.\n"); return error.PrepareFailed; }; if (status == .untracked) { - if (verbosity == .verbose) cli.stderrPrint(io, "Error: portfolio.srf is not tracked in git. Add and commit it first.\n") catch {}; + if (verbosity == .verbose) cli.stderrPrint(io, "Error: portfolio.srf is not tracked in git. Add and commit it first.\n"); return error.PrepareFailed; } const dirty = status == .modified; @@ -422,7 +421,7 @@ fn prepareReport( if (verbosity == .verbose) { var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ endpoints.range.before_rev, @errorName(err) }) catch "Error reading before-side portfolio.\n"; - cli.stderrPrint(io, msg) catch {}; + cli.stderrPrint(io, msg); } return error.PrepareFailed; }; @@ -432,24 +431,24 @@ fn prepareReport( if (verbosity == .verbose) { var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&buf, "Error reading {s}:portfolio.srf from git: {s}\n", .{ rev, @errorName(err) }) catch "Error reading after-side portfolio.\n"; - cli.stderrPrint(io, msg) catch {}; + cli.stderrPrint(io, msg); } return error.PrepareFailed; } else std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, arena, .limited(10 * 1024 * 1024)) catch { - if (verbosity == .verbose) cli.stderrPrint(io, "Error reading working-copy portfolio file.\n") catch {}; + if (verbosity == .verbose) cli.stderrPrint(io, "Error reading working-copy portfolio file.\n"); return error.PrepareFailed; }; var before_pf = zfin.cache.deserializePortfolio(allocator, before) catch { - if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing before-snapshot portfolio.\n") catch {}; + if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing before-snapshot portfolio.\n"); return error.PrepareFailed; }; errdefer before_pf.deinit(); var after_pf = zfin.cache.deserializePortfolio(allocator, after) catch { - if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing after-snapshot portfolio.\n") catch {}; + if (verbosity == .verbose) cli.stderrPrint(io, "Error parsing after-snapshot portfolio.\n"); return error.PrepareFailed; }; errdefer after_pf.deinit(); @@ -522,7 +521,7 @@ fn prepareReport( .window_end = window_end, }, ) catch { - if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n") catch {}; + if (verbosity == .verbose) cli.stderrPrint(io, "Error computing contributions diff.\n"); return error.PrepareFailed; }; @@ -572,15 +571,15 @@ fn resolveEndpoints( const before_str = specDisplayString(before, &before_buf); var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: no commit of {s} at or before {s}.\n", .{ repo.rel_path, before_str }) catch "Error: no commit at or before requested date.\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); }, error.InvalidArg => { - try cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n"); + cli.stderrPrint(io, "Error: --commit-before cannot be `working` — diffing the working copy against itself is meaningless.\n"); }, else => { - try cli.stderrPrint(io, "Error resolving commit range: "); - try cli.stderrPrint(io, @errorName(err)); - try cli.stderrPrint(io, "\n"); + cli.stderrPrint(io, "Error resolving commit range: "); + cli.stderrPrint(io, @errorName(err)); + cli.stderrPrint(io, "\n"); }, } } @@ -598,7 +597,7 @@ fn resolveEndpoints( if (before != null and after != null and verbosity == .verbose) { if (range.after_rev) |after_rev| { if (std.mem.eql(u8, range.before_rev, after_rev)) { - try cli.stderrPrint(io, "Warning: before and after resolve to the same commit; no changes to report.\n"); + cli.stderrPrint(io, "Warning: before and after resolve to the same commit; no changes to report.\n"); } } } @@ -670,7 +669,7 @@ fn maybeSnapNote( label, }, ) catch return; - cli.stderrPrint(io, msg) catch {}; + cli.stderrPrint(io, msg); } /// Abbreviate a commit ref for display. SHAs get shortened to 7 @@ -2629,6 +2628,7 @@ fn parseArgsForTest(today: zfin.Date, args: []const []const u8) !ParsedArgs { .io = std.testing.io, .allocator = std.testing.allocator, .gpa = std.testing.allocator, + // SAFETY: parseArgs doesn't touch environ_map. .environ_map = undefined, .config = .{ .cache_dir = "" }, .svc = null, @@ -2636,6 +2636,7 @@ fn parseArgsForTest(today: zfin.Date, args: []const []const u8) !ParsedArgs { .today = today, .now_s = 0, .color = false, + // SAFETY: parseArgs doesn't write to out. .out = undefined, }; return parseArgs(&ctx, args); diff --git a/src/commands/divs.zig b/src/commands/divs.zig index a716d5c..d250911 100644 --- a/src/commands/divs.zig +++ b/src/commands/divs.zig @@ -2,7 +2,6 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); -const fmt = cli.fmt; pub const ParsedArgs = struct { symbol: []const u8, @@ -30,11 +29,11 @@ pub const meta: framework.Meta = .{ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len < 1) { - try cli.stderrPrint(ctx.io, "Error: 'divs' requires a symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'divs' requires a symbol argument\n"); return error.MissingSymbol; } if (cmd_args.len > 1) { - try cli.stderrPrint(ctx.io, "Error: 'divs' takes a single symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'divs' takes a single symbol argument\n"); return error.UnexpectedArg; } return .{ .symbol = cmd_args[0] }; @@ -45,17 +44,17 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); const result = svc.getDividends(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); + cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); return; }, else => { - try cli.stderrPrint(ctx.io, "Error fetching dividend data.\n"); + cli.stderrPrint(ctx.io, "Error fetching dividend data.\n"); return; }, }; defer result.deinit(); - if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached dividend data)\n"); + if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached dividend data)\n"); // Fetch current price for yield calculation via DataService var current_price: ?f64 = null; diff --git a/src/commands/earnings.zig b/src/commands/earnings.zig index 809c388..e0a1350 100644 --- a/src/commands/earnings.zig +++ b/src/commands/earnings.zig @@ -36,11 +36,11 @@ pub const meta: framework.Meta = .{ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len < 1) { - try cli.stderrPrint(ctx.io, "Error: 'earnings' requires a symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'earnings' requires a symbol argument\n"); return error.MissingSymbol; } if (cmd_args.len > 1) { - try cli.stderrPrint(ctx.io, "Error: 'earnings' takes a single symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'earnings' takes a single symbol argument\n"); return error.UnexpectedArg; } return .{ .symbol = cmd_args[0] }; @@ -51,11 +51,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); const result = svc.getEarnings(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(ctx.io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n"); + cli.stderrPrint(ctx.io, "Error: FMP_API_KEY not set. Get a free key at https://site.financialmodelingprep.com\n"); return; }, else => { - try cli.stderrPrint(ctx.io, "Error fetching earnings data.\n"); + cli.stderrPrint(ctx.io, "Error fetching earnings data.\n"); return; }, }; @@ -73,7 +73,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { }.f); } - if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached earnings data)\n"); + if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached earnings data)\n"); try display(result.data, parsed.symbol, ctx.color, ctx.out); } diff --git a/src/commands/enrich.zig b/src/commands/enrich.zig index 880efd3..40025da 100644 --- a/src/commands/enrich.zig +++ b/src/commands/enrich.zig @@ -2,7 +2,6 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); -const fmt = @import("../format.zig"); const isCusipLike = @import("../models/portfolio.zig").isCusipLike; pub const ParsedArgs = struct { @@ -46,11 +45,11 @@ pub const meta: framework.Meta = .{ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len < 1) { - try cli.stderrPrint(ctx.io, "Error: 'enrich' requires a portfolio file path or symbol\n"); + cli.stderrPrint(ctx.io, "Error: 'enrich' requires a portfolio file path or symbol\n"); return error.MissingArg; } if (cmd_args.len > 1) { - try cli.stderrPrint(ctx.io, "Error: 'enrich' takes a single argument (file path or symbol)\n"); + cli.stderrPrint(ctx.io, "Error: 'enrich' takes a single argument (file path or symbol)\n"); return error.UnexpectedArg; } return .{ .arg = cmd_args[0] }; @@ -112,15 +111,15 @@ fn enrichSymbol(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataService { var msg_buf: [128]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, " Fetching {s}...\n", .{sym}) catch " ...\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); } const overview = svc.getCompanyOverview(sym) catch |err| { if (err == zfin.DataError.NoApiKey) { - try cli.stderrPrint(io, "Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n"); + cli.stderrPrint(io, "Error: ALPHAVANTAGE_API_KEY not set. Add it to .env\n"); return; } - try cli.stderrPrint(io, "Error: Failed to fetch data for symbol\n"); + cli.stderrPrint(io, "Error: Failed to fetch data for symbol\n"); try out.print("# {s} -- fetch failed\n", .{sym}); try out.print("# symbol::{s},sector::TODO,geo::TODO,asset_class::TODO\n", .{sym}); return; @@ -150,13 +149,13 @@ fn enrichPortfolio(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataServ // Load portfolio const file_data = std.Io.Dir.cwd().readFileAlloc(io, file_path, allocator, .limited(10 * 1024 * 1024)) catch { - try cli.stderrPrint(io, "Error: Cannot read portfolio file\n"); + cli.stderrPrint(io, "Error: Cannot read portfolio file\n"); return; }; defer allocator.free(file_data); var portfolio = zfin.cache.deserializePortfolio(allocator, file_data) catch { - try cli.stderrPrint(io, "Error: Cannot parse portfolio file\n"); + cli.stderrPrint(io, "Error: Cannot parse portfolio file\n"); return; }; defer portfolio.deinit(); @@ -207,7 +206,7 @@ fn enrichPortfolio(io: std.Io, allocator: std.mem.Allocator, svc: *zfin.DataServ { var msg_buf: [128]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, " [{d}/{d}] {s}...\n", .{ i + 1, syms.len, sym }) catch " ...\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); } const overview = svc.getCompanyOverview(sym) catch { diff --git a/src/commands/etf.zig b/src/commands/etf.zig index 9711cf4..248afb2 100644 --- a/src/commands/etf.zig +++ b/src/commands/etf.zig @@ -31,11 +31,11 @@ pub const meta: framework.Meta = .{ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len < 1) { - try cli.stderrPrint(ctx.io, "Error: 'etf' requires a symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'etf' requires a symbol argument\n"); return error.MissingSymbol; } if (cmd_args.len > 1) { - try cli.stderrPrint(ctx.io, "Error: 'etf' takes a single symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'etf' takes a single symbol argument\n"); return error.UnexpectedArg; } return .{ .symbol = cmd_args[0] }; @@ -46,11 +46,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); const result = svc.getEtfProfile(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(ctx.io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); + cli.stderrPrint(ctx.io, "Error: ALPHAVANTAGE_API_KEY not set. Get a free key at https://alphavantage.co\n"); return; }, else => { - try cli.stderrPrint(ctx.io, "Error fetching ETF profile.\n"); + cli.stderrPrint(ctx.io, "Error fetching ETF profile.\n"); return; }, }; @@ -58,7 +58,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const profile = result.data; defer result.deinit(); - if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached ETF profile)\n"); + if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached ETF profile)\n"); try printProfile(profile, parsed.symbol, ctx.color, ctx.out); } diff --git a/src/commands/history.zig b/src/commands/history.zig index 2f60f62..def9e68 100644 --- a/src/commands/history.zig +++ b/src/commands/history.zig @@ -165,18 +165,18 @@ pub fn parsePortfolioOpts(as_of: zfin.Date, args: []const []const u8) Error!Port pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len > 0 and cmd_args[0].len > 0 and cmd_args[0][0] != '-') { if (cmd_args.len > 1) { - try cli.stderrPrint(ctx.io, "Error: 'history' symbol mode takes only the symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'history' symbol mode takes only the symbol argument\n"); return error.UnexpectedArg; } return .{ .symbol = cmd_args[0] }; } const opts = parsePortfolioOpts(ctx.today, cmd_args) catch |err| { switch (err) { - error.UnexpectedArg => try cli.stderrPrint(ctx.io, "Error: unknown flag in 'history'. See --help.\n"), - error.MissingFlagValue => try cli.stderrPrint(ctx.io, "Error: flag requires a value.\n"), - error.InvalidFlagValue => try cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"), - error.UnknownMetric => try cli.stderrPrint(ctx.io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"), - error.UnknownResolution => try cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"), + error.UnexpectedArg => cli.stderrPrint(ctx.io, "Error: unknown flag in 'history'. See --help.\n"), + error.MissingFlagValue => cli.stderrPrint(ctx.io, "Error: flag requires a value.\n"), + error.InvalidFlagValue => cli.stderrPrint(ctx.io, "Error: invalid flag value.\n"), + error.UnknownMetric => cli.stderrPrint(ctx.io, "Error: unknown --metric. Valid: net_worth, liquid, illiquid.\n"), + error.UnknownResolution => cli.stderrPrint(ctx.io, "Error: unknown --resolution. Valid: daily, weekly, monthly, auto.\n"), } return err; }; @@ -211,24 +211,24 @@ fn runSymbol( ) !void { const result = svc.getCandles(symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(io, "Error: No API key configured for candle data.\n"); + cli.stderrPrint(io, "Error: No API key configured for candle data.\n"); return; }, else => { - try cli.stderrPrint(io, "Error fetching data.\n"); + cli.stderrPrint(io, "Error fetching data.\n"); return; }, }; defer result.deinit(); - if (result.source == .cached) try cli.stderrPrint(io, "(using cached data)\n"); + if (result.source == .cached) cli.stderrPrint(io, "(using cached data)\n"); const all = result.data; - if (all.len == 0) return try cli.stderrPrint(io, "No data available.\n"); + if (all.len == 0) return cli.stderrPrint(io, "No data available.\n"); const one_month_ago = as_of.addDays(-30); const c = fmt.filterCandlesFrom(all, one_month_ago); - if (c.len == 0) return try cli.stderrPrint(io, "No data available.\n"); + if (c.len == 0) return cli.stderrPrint(io, "No data available.\n"); try displaySymbol(c, symbol, color, out); } diff --git a/src/commands/import.zig b/src/commands/import.zig index ae2e464..717c231 100644 --- a/src/commands/import.zig +++ b/src/commands/import.zig @@ -254,28 +254,28 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr const a = cmd_args[i]; if (std.mem.eql(u8, a, "--fidelity")) { if (i + 1 >= cmd_args.len) { - try cli.stderrPrint(ctx.io, "Error: --fidelity requires a CSV path\n"); + cli.stderrPrint(ctx.io, "Error: --fidelity requires a CSV path\n"); return error.UnexpectedArg; } i += 1; fidelity_path = cmd_args[i]; } else if (std.mem.eql(u8, a, "--schwab")) { if (i + 1 >= cmd_args.len) { - try cli.stderrPrint(ctx.io, "Error: --schwab requires a CSV path\n"); + cli.stderrPrint(ctx.io, "Error: --schwab requires a CSV path\n"); return error.UnexpectedArg; } i += 1; schwab_path = cmd_args[i]; } else if (std.mem.eql(u8, a, "--wells-fargo")) { if (i + 1 >= cmd_args.len) { - try cli.stderrPrint(ctx.io, "Error: --wells-fargo requires a path (or '-' for stdin)\n"); + cli.stderrPrint(ctx.io, "Error: --wells-fargo requires a path (or '-' for stdin)\n"); return error.UnexpectedArg; } i += 1; wells_fargo_path = cmd_args[i]; } else if (std.mem.eql(u8, a, "--account")) { if (i + 1 >= cmd_args.len) { - try cli.stderrPrint(ctx.io, "Error: --account requires a name\n"); + cli.stderrPrint(ctx.io, "Error: --account requires a name\n"); return error.UnexpectedArg; } i += 1; @@ -283,9 +283,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr } else if (std.mem.eql(u8, a, "-y") or std.mem.eql(u8, a, "--yes")) { yes = true; } else { - try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'import': "); - try cli.stderrPrint(ctx.io, a); - try cli.stderrPrint(ctx.io, "\n"); + cli.stderrPrint(ctx.io, "Error: unexpected argument to 'import': "); + cli.stderrPrint(ctx.io, a); + cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } } @@ -297,7 +297,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr if (schwab_path != null) source_count += 1; if (wells_fargo_path != null) source_count += 1; if (source_count > 1) { - try cli.stderrPrint(ctx.io, "Error: --fidelity / --schwab / --wells-fargo are mutually exclusive (one source per import)\n"); + cli.stderrPrint(ctx.io, "Error: --fidelity / --schwab / --wells-fargo are mutually exclusive (one source per import)\n"); return error.ConflictingSources; } @@ -305,7 +305,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr // carry per-row account_numbers in the export. Reject up // front so the user notices early. if (account_override != null and wells_fargo_path == null) { - try cli.stderrPrint(ctx.io, "Error: --account is only meaningful with --wells-fargo (Fidelity/Schwab exports carry account numbers per row)\n"); + cli.stderrPrint(ctx.io, "Error: --account is only meaningful with --wells-fargo (Fidelity/Schwab exports carry account numbers per row)\n"); return error.UnexpectedArg; } @@ -316,7 +316,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr else if (wells_fargo_path) |p| .{ .wells_fargo = .{ .path = p, .account_override = account_override } } else { - try cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE, --schwab FILE, or --wells-fargo FILE)\n"); + cli.stderrPrint(ctx.io, "Error: import requires a source flag (--fidelity FILE, --schwab FILE, or --wells-fargo FILE)\n"); return error.MissingSource; }; @@ -359,7 +359,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer allocator.free(positions); if (positions.len == 0) { - try cli.stderrPrint(io, "Error: brokerage export contained zero positions; refusing to write an empty portfolio.\n"); + cli.stderrPrint(io, "Error: brokerage export contained zero positions; refusing to write an empty portfolio.\n"); return error.EmptyFile; } @@ -371,9 +371,9 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // service for anything else (no price fetching), but reusing // its helper keeps sibling-file resolution consistent. var account_map = svc.loadAccountMap(target_path) orelse { - try cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf next to the target portfolio.\n"); - try cli.stderrPrint(io, " Import needs `institution::` + `account_number::` entries to map\n"); - try cli.stderrPrint(io, " brokerage account numbers to portfolio account names.\n"); + cli.stderrPrint(io, "Error: Cannot read/parse accounts.srf next to the target portfolio.\n"); + cli.stderrPrint(io, " Import needs `institution::` + `account_number::` entries to map\n"); + cli.stderrPrint(io, " brokerage account numbers to portfolio account names.\n"); return error.CannotReadAccountsFile; }; defer account_map.deinit(); @@ -415,8 +415,8 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { prior_portfolio_opt = cache.deserializePortfolio(allocator, data) catch { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse existing portfolio file: {s}\n", .{target_path}) catch "Error: Cannot parse existing portfolio file\n"; - try cli.stderrPrint(io, msg); - try cli.stderrPrint(io, " Fix or delete the file, then re-run the import.\n"); + cli.stderrPrint(io, msg); + cli.stderrPrint(io, " Fix or delete the file, then re-run the import.\n"); return error.WriteFailed; }; } @@ -449,7 +449,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { }; if (target_exists and !parsed.yes) { if (!try confirmOverwrite(io, target_path)) { - try cli.stderrPrint(io, "Aborted; no changes written.\n"); + cli.stderrPrint(io, "Aborted; no changes written.\n"); return error.UserDeclined; } } @@ -461,7 +461,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { atomic.writeFileAtomic(io, allocator, target_path, serialized) catch |err| { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: Failed to write portfolio file ({s}): {s}\n", .{ target_path, @errorName(err) }) catch "Error: Failed to write portfolio file\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); return error.WriteFailed; }; @@ -505,22 +505,24 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { fn resolveSingleTarget(ctx: *framework.RunCtx) ![]const u8 { const patterns = ctx.globals.portfolio_patterns; if (patterns.len == 0) { - try cli.stderrPrint(ctx.io, "Error: import requires `-p ` (the portfolio file to write).\n"); + cli.stderrPrint(ctx.io, "Error: import requires `-p ` (the portfolio file to write).\n"); return error.MissingPortfolioPath; } if (patterns.len > 1) { - try cli.stderrPrint(ctx.io, "Error: import requires exactly one `-p ` (got multiple).\n"); + cli.stderrPrint(ctx.io, "Error: import requires exactly one `-p ` (got multiple).\n"); return error.AmbiguousPortfolioPath; } const pat = patterns[0]; if (zfin.Config.isGlobPattern(pat)) { - try cli.stderrPrint(ctx.io, "Error: import refuses glob patterns for `-p`. Pass an exact filename.\n"); + cli.stderrPrint(ctx.io, "Error: import refuses glob patterns for `-p`. Pass an exact filename.\n"); return error.AmbiguousPortfolioPath; } - // Resolve through cwd → ZFIN_HOME so bare names work the same - // as elsewhere in zfin. If the file doesn't exist yet (first - // run for a new portfolio), fall back to the literal pattern - // so we write to ./. + // Resolve via Config: ZFIN_HOME when set (exclusive), else + // cwd. If the file doesn't exist yet (first run for a new + // portfolio), fall back to the literal pattern so we write + // to ./ — that's the natural place for a freshly- + // created managed-account file before the user moves it + // anywhere canonical. if (ctx.config.resolveUserFile(ctx.io, ctx.allocator, pat)) |r| { // Caller doesn't free this; returning the resolved path // string puts the lifetime on the arena allocator. @@ -538,7 +540,7 @@ fn readSourceData(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![ var stdin_buf: [4096]u8 = undefined; var stdin_reader = std.Io.File.stdin().reader(io, &stdin_buf); const data = stdin_reader.interface.allocRemaining(allocator, .limited(10 * 1024 * 1024)) catch { - try cli.stderrPrint(io, "Error: Cannot read source data from stdin\n"); + cli.stderrPrint(io, "Error: Cannot read source data from stdin\n"); return error.CannotReadCsv; }; return data; @@ -546,7 +548,7 @@ fn readSourceData(io: std.Io, allocator: std.mem.Allocator, path: []const u8) ![ return std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch { var msg_buf: [512]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read source file: {s}\n", .{path}) catch "Error: Cannot read source file\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); return error.CannotReadCsv; }; } diff --git a/src/commands/lookup.zig b/src/commands/lookup.zig index 6dafa22..22af36b 100644 --- a/src/commands/lookup.zig +++ b/src/commands/lookup.zig @@ -34,11 +34,11 @@ pub const meta: framework.Meta = .{ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len < 1) { - try cli.stderrPrint(ctx.io, "Error: 'lookup' requires a CUSIP argument\n"); + cli.stderrPrint(ctx.io, "Error: 'lookup' requires a CUSIP argument\n"); return error.MissingCusip; } if (cmd_args.len > 1) { - try cli.stderrPrint(ctx.io, "Error: 'lookup' takes a single CUSIP argument\n"); + cli.stderrPrint(ctx.io, "Error: 'lookup' takes a single CUSIP argument\n"); return error.UnexpectedArg; } return .{ .cusip = cmd_args[0] }; @@ -53,11 +53,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { try cli.printFg(out, color, cli.CLR_MUTED, "Note: '{s}' doesn't look like a CUSIP (expected 9 alphanumeric chars with digits)\n", .{parsed.cusip}); } - try cli.stderrPrint(ctx.io, "Looking up via OpenFIGI...\n"); + cli.stderrPrint(ctx.io, "Looking up via OpenFIGI...\n"); // Try full batch lookup for richer output const results = svc.lookupCusips(&.{parsed.cusip}) catch { - try cli.stderrPrint(ctx.io, "Error: OpenFIGI request failed (network error)\n"); + cli.stderrPrint(ctx.io, "Error: OpenFIGI request failed (network error)\n"); return; }; defer { diff --git a/src/commands/milestones.zig b/src/commands/milestones.zig index 8a78a81..3c3285e 100644 --- a/src/commands/milestones.zig +++ b/src/commands/milestones.zig @@ -18,7 +18,6 @@ //! No I/O beyond reading the data files; no network. const std = @import("std"); -const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); const fmt = @import("../format.zig"); @@ -85,23 +84,23 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr if (std.mem.eql(u8, a, "--step")) { i += 1; if (i >= cmd_args.len) { - try cli.stderrPrint(ctx.io, "Error: --step requires an argument\n"); + cli.stderrPrint(ctx.io, "Error: --step requires an argument\n"); return error.MissingStep; } step_str = cmd_args[i]; } else if (std.mem.eql(u8, a, "--real")) { want_real = true; } else { - try cli.stderrPrint(ctx.io, "Error: unknown argument to 'milestones': "); - try cli.stderrPrint(ctx.io, a); - try cli.stderrPrint(ctx.io, "\n"); + cli.stderrPrint(ctx.io, "Error: unknown argument to 'milestones': "); + cli.stderrPrint(ctx.io, a); + cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } } const step_raw = step_str orelse { - try cli.stderrPrint(ctx.io, "Error: --step is required\n"); - try cli.stderrPrint(ctx.io, meta.help); + cli.stderrPrint(ctx.io, "Error: --step is required\n"); + cli.stderrPrint(ctx.io, meta.help); return error.MissingStep; }; return .{ .step_raw = step_raw, .real = want_real }; @@ -121,7 +120,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { "Error: cannot parse --step '{s}': {s}\n", .{ parsed.step_raw, @errorName(err) }, ) catch "Error: invalid --step\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); return error.InvalidStep; }; @@ -134,7 +133,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer series_owned.deinit(allocator); if (series_owned.points.len == 0) { - try cli.stderrPrint(io, "Error: no history data found. Did you import imported_values.srf?\n"); + cli.stderrPrint(io, "Error: no history data found. Did you import imported_values.srf?\n"); return error.NoData; } diff --git a/src/commands/options.zig b/src/commands/options.zig index 5ff9173..f0b59da 100644 --- a/src/commands/options.zig +++ b/src/commands/options.zig @@ -38,7 +38,7 @@ pub const meta: framework.Meta = .{ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len < 1) { - try cli.stderrPrint(ctx.io, "Error: 'options' requires a symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'options' requires a symbol argument\n"); return error.MissingSymbol; } var parsed: ParsedArgs = .{ .symbol = cmd_args[0] }; @@ -47,18 +47,18 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr const a = cmd_args[i]; if (std.mem.eql(u8, a, "--ntm")) { if (i + 1 >= cmd_args.len) { - try cli.stderrPrint(ctx.io, "Error: --ntm requires a value\n"); + cli.stderrPrint(ctx.io, "Error: --ntm requires a value\n"); return error.MissingFlagValue; } parsed.ntm = std.fmt.parseInt(usize, cmd_args[i + 1], 10) catch { - try cli.stderrPrint(ctx.io, "Error: --ntm value must be a non-negative integer\n"); + cli.stderrPrint(ctx.io, "Error: --ntm value must be a non-negative integer\n"); return error.InvalidFlagValue; }; i += 1; } else { - try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'options': "); - try cli.stderrPrint(ctx.io, a); - try cli.stderrPrint(ctx.io, "\n"); + cli.stderrPrint(ctx.io, "Error: unexpected argument to 'options': "); + cli.stderrPrint(ctx.io, a); + cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } } @@ -70,21 +70,21 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); const result = svc.getOptions(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.FetchFailed => { - try cli.stderrPrint(ctx.io, "Error fetching options data from CBOE.\n"); + cli.stderrPrint(ctx.io, "Error fetching options data from CBOE.\n"); return; }, else => { - try cli.stderrPrint(ctx.io, "Error loading options data.\n"); + cli.stderrPrint(ctx.io, "Error loading options data.\n"); return; }, }; const ch = result.data; defer result.deinit(); - if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached options data)\n"); + if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached options data)\n"); if (ch.len == 0) { - try cli.stderrPrint(ctx.io, "No options data found.\n"); + cli.stderrPrint(ctx.io, "No options data found.\n"); return; } diff --git a/src/commands/perf.zig b/src/commands/perf.zig index a7d8b07..a174e53 100644 --- a/src/commands/perf.zig +++ b/src/commands/perf.zig @@ -38,11 +38,11 @@ pub const meta: framework.Meta = .{ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len < 1) { - try cli.stderrPrint(ctx.io, "Error: 'perf' requires a symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'perf' requires a symbol argument\n"); return error.MissingSymbol; } if (cmd_args.len > 1) { - try cli.stderrPrint(ctx.io, "Error: 'perf' takes a single symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'perf' takes a single symbol argument\n"); return error.UnexpectedArg; } return .{ .symbol = cmd_args[0] }; @@ -53,18 +53,18 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); const result = svc.getTrailingReturns(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(ctx.io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n"); + cli.stderrPrint(ctx.io, "Error: No API key set. Get a free key at https://tiingo.com or https://twelvedata.com\n"); return; }, else => { - try cli.stderrPrint(ctx.io, "Error fetching data.\n"); + cli.stderrPrint(ctx.io, "Error fetching data.\n"); return; }, }; defer ctx.allocator.free(result.candles); defer if (result.dividends) |d| zfin.Dividend.freeSlice(ctx.allocator, d); - if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached data)\n"); + if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached data)\n"); const c = result.candles; const end_date = c[c.len - 1].date; diff --git a/src/commands/portfolio.zig b/src/commands/portfolio.zig index 91f6249..4118cff 100644 --- a/src/commands/portfolio.zig +++ b/src/commands/portfolio.zig @@ -37,12 +37,12 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr if (cmd_args.len > 0) { const a = cmd_args[0]; if (std.mem.eql(u8, a, "--refresh")) { - try cli.stderrPrint(ctx.io, "Error: --refresh is now a global flag. Use `zfin --refresh-data=force portfolio` instead.\n"); + cli.stderrPrint(ctx.io, "Error: --refresh is now a global flag. Use `zfin --refresh-data=force portfolio` instead.\n"); return error.UnexpectedArg; } - try cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': "); - try cli.stderrPrint(ctx.io, a); - try cli.stderrPrint(ctx.io, "\n"); + cli.stderrPrint(ctx.io, "Error: unexpected argument to 'portfolio': "); + cli.stderrPrint(ctx.io, a); + cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } return .{}; @@ -74,7 +74,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { const anchor_path = loaded.anchor(); if (portfolio.lots.len == 0) { - try cli.stderrPrint(io, "Portfolio is empty.\n"); + cli.stderrPrint(io, "Portfolio is empty.\n"); return; } @@ -116,7 +116,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { // Transfer prices to our local map var it = load_result.prices.iterator(); while (it.next()) |entry| { - prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; + try prices.put(entry.key_ptr.*, entry.value_ptr.*); } fail_count = load_result.failed_count; } @@ -124,7 +124,7 @@ pub fn run(ctx: *framework.RunCtx, _: ParsedArgs) !void { // Build portfolio summary, candle map, and historical snapshots var pf_data = cli.buildPortfolioData(allocator, portfolio, positions, syms, &prices, svc, as_of) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { - try cli.stderrPrint(io, "Error computing portfolio summary.\n"); + cli.stderrPrint(io, "Error computing portfolio summary.\n"); return; }, else => return err, diff --git a/src/commands/projections.zig b/src/commands/projections.zig index ad90f82..cfd977b 100644 --- a/src/commands/projections.zig +++ b/src/commands/projections.zig @@ -15,13 +15,8 @@ const framework = @import("framework.zig"); const fmt = cli.fmt; const Date = zfin.Date; const Money = @import("../Money.zig"); -const performance = @import("../analytics/performance.zig"); -const projections = @import("../analytics/projections.zig"); -const benchmark = @import("../analytics/benchmark.zig"); -const valuation = @import("../analytics/valuation.zig"); const view = @import("../views/projections.zig"); const history = @import("../history.zig"); -const timeline = @import("../analytics/timeline.zig"); const imported = @import("../data/imported_values.zig"); const forecast = @import("../analytics/forecast_evaluation.zig"); const milestones = @import("../analytics/milestones.zig"); @@ -146,29 +141,29 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr real_mode = true; } else if (std.mem.eql(u8, a, "--export-chart")) { if (i + 1 >= cmd_args.len) { - try cli.stderrPrint(io, "Error: --export-chart requires a path argument.\n"); + cli.stderrPrint(io, "Error: --export-chart requires a path argument.\n"); return error.MissingFlagValue; } export_chart = cmd_args[i + 1]; i += 1; } else if (std.mem.eql(u8, a, "--as-of") or std.mem.eql(u8, a, "--vs")) { if (i + 1 >= cmd_args.len) { - try cli.stderrPrint(io, "Error: "); - try cli.stderrPrint(io, a); - try cli.stderrPrint(io, " requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n"); + cli.stderrPrint(io, "Error: "); + cli.stderrPrint(io, a); + cli.stderrPrint(io, " requires a value (YYYY-MM-DD, N[WMQY], or 'live').\n"); return error.MissingFlagValue; } const value = cmd_args[i + 1]; const parsed_date = cli.parseAsOfDate(value, today) catch |err| { var buf: [256]u8 = undefined; const msg = cli.fmtAsOfParseError(&buf, value, err); - try cli.stderrPrint(io, msg); - try cli.stderrPrint(io, "\n"); + cli.stderrPrint(io, msg); + cli.stderrPrint(io, "\n"); return error.InvalidFlagValue; }; if (parsed_date) |d| { if (d.days > today.days) { - try cli.stderrPrint(io, "Error: date is in the future.\n"); + cli.stderrPrint(io, "Error: date is in the future.\n"); return error.InvalidFlagValue; } if (std.mem.eql(u8, a, "--as-of")) { @@ -181,9 +176,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr // as not passing the flag at all. i += 1; } else { - try cli.stderrPrint(io, "Error: unexpected argument to 'projections': "); - try cli.stderrPrint(io, a); - try cli.stderrPrint(io, "\n"); + cli.stderrPrint(io, "Error: unexpected argument to 'projections': "); + cli.stderrPrint(io, a); + cli.stderrPrint(io, "\n"); return error.UnexpectedArg; } } @@ -193,19 +188,19 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr // bands view entirely; combining them with each other, // `--vs`, or `--overlay-actuals` is rejected. if (convergence and return_backtest) { - try cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n"); + cli.stderrPrint(io, "Error: --convergence and --return-backtest are mutually exclusive.\n"); return error.MutuallyExclusive; } if ((convergence or return_backtest) and vs_date != null) { - try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n"); + cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --vs.\n"); return error.MutuallyExclusive; } if ((convergence or return_backtest) and overlay_actuals) { - try cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n"); + cli.stderrPrint(io, "Error: --convergence/--return-backtest cannot be combined with --overlay-actuals.\n"); return error.MutuallyExclusive; } if (real_mode and !return_backtest) { - try cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n"); + cli.stderrPrint(io, "Error: --real only applies to --return-backtest.\n"); return error.MutuallyExclusive; } // Chart export only meaningful in default bands mode. The @@ -213,7 +208,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr // render via `forecast_chart.zig` which doesn't have a PNG // export path yet; --vs is text-only with no chart at all. if (export_chart != null and (convergence or return_backtest or vs_date != null)) { - try cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n"); + cli.stderrPrint(io, "Error: --export-chart only supported in the default projections (bands) mode.\n"); return error.MutuallyExclusive; } @@ -373,6 +368,9 @@ pub fn runBands( // Build the context via either the live or as-of pipeline. Both // produce a `ProjectionContext`; from that point on rendering is // identical. + // SAFETY: ctx is fully written by the live or as-of branch + // below before any read. Both branches assign `ctx.* = ...` + // before falling through to the rendering code. var ctx: view.ProjectionContext = undefined; var resolution: ?AsOfResolution = null; // Snapshot must outlive the context when on the as-of path because @@ -431,13 +429,13 @@ pub fn runBands( defer load_result.deinit(); var it = load_result.prices.iterator(); while (it.next()) |entry| { - prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; + try prices.put(entry.key_ptr.*, entry.value_ptr.*); } } live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, opts.today) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { - try cli.stderrPrint(io, "Error computing portfolio summary for imported-only as-of.\n"); + cli.stderrPrint(io, "Error computing portfolio summary for imported-only as-of.\n"); return; }, else => return err, @@ -471,13 +469,13 @@ pub fn runBands( defer load_result.deinit(); var it = load_result.prices.iterator(); while (it.next()) |entry| { - prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; + try prices.put(entry.key_ptr.*, entry.value_ptr.*); } } live_pf_data = cli.buildPortfolioData(allocator, lp.portfolio, lp.positions, lp.syms, &prices, svc, opts.as_of) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { - try cli.stderrPrint(io, "Error computing portfolio summary.\n"); + cli.stderrPrint(io, "Error computing portfolio summary.\n"); return; }, else => return err, @@ -505,7 +503,7 @@ pub fn runBands( // is meaningless because the future hasn't happened yet. if (opts.overlay_actuals) { if (!opts.from_snapshot) { - try cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n"); + cli.stderrPrint(io, "Note: --overlay-actuals requires --as-of; ignoring.\n"); } else if (resolution) |r| { ctx.overlay_actuals = loadOverlayActuals(io, va, file_path, r.actual, opts.today) catch |err| blk: { // Non-fatal — the projection still renders without @@ -513,7 +511,7 @@ pub fn runBands( // their history dir but don't block the report. var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&buf, "Note: could not load actuals overlay ({s}); rendering without it.\n", .{@errorName(err)}) catch "Note: could not load actuals overlay; rendering without it.\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); break :blk null; }; } @@ -528,12 +526,12 @@ pub fn runBands( if (opts.export_chart) |export_path| { const horizons_ec = ctx.config.getHorizons(); if (horizons_ec.len == 0) { - try cli.stderrPrint(io, "Error: no horizons configured; cannot export chart.\n"); + cli.stderrPrint(io, "Error: no horizons configured; cannot export chart.\n"); return; } const last_idx_ec = horizons_ec.len - 1; const bands_ec = ctx.data.bands[last_idx_ec] orelse { - try cli.stderrPrint(io, "Error: projection bands unavailable for the longest horizon; cannot export chart.\n"); + cli.stderrPrint(io, "Error: projection bands unavailable for the longest horizon; cannot export chart.\n"); return; }; @@ -557,11 +555,11 @@ pub fn runBands( chart_export.exportProjectionChart(io, allocator, bands_ec, overlay_input, export_path) catch |err| switch (err) { error.InsufficientData => { - try cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n"); + cli.stderrPrint(io, "Error: not enough projection data to render a chart.\n"); return; }, else => { - try cli.stderrPrint(io, "Error: failed to write PNG.\n"); + cli.stderrPrint(io, "Error: failed to write PNG.\n"); return err; }, }; @@ -844,12 +842,12 @@ fn loadAsOfContext( resolution_out: *AsOfResolution, snap_bundle_out: *history.LoadedSnapshot, ) !view.ProjectionContext { - resolution_out.* = resolveAsOfSnapshot(io, va, file_path, requested_date) catch |err| return err; + resolution_out.* = try resolveAsOfSnapshot(io, va, file_path, requested_date); if (resolution_out.source != .snapshot) { // Imported-only resolution: no snapshot file exists at the // resolved date, so `loadSnapshotAt` would crash with // FileNotFound. Bail with a clear message instead. - try cli.stderrPrint(io, "Error: --vs does not yet support back-dating to imported-only periods (no snapshot at that date).\n"); + cli.stderrPrint(io, "Error: --vs does not yet support back-dating to imported-only periods (no snapshot at that date).\n"); return error.NoSnapshot; } const hist_dir = try history.deriveHistoryDir(va, file_path); @@ -979,7 +977,7 @@ pub fn runConvergence( const iv_path = try std.fs.path.join(va, &.{ hist_dir, "imported_values.srf" }); var iv = imported.loadImportedValues(io, allocator, iv_path) catch |err| { - try cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n"); + cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n"); return err; }; defer iv.deinit(); @@ -1017,7 +1015,7 @@ pub fn runReturnBacktest( const iv_path = try std.fs.path.join(va, &.{ hist_dir, "imported_values.srf" }); var iv = imported.loadImportedValues(io, allocator, iv_path) catch |err| { - try cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n"); + cli.stderrPrint(io, "Error: cannot load imported_values.srf.\n"); return err; }; defer iv.deinit(); @@ -1144,7 +1142,10 @@ pub fn computeKeyComparison( // Load "then" snapshot first. If it doesn't exist we bail before // doing the (more expensive) "now" side. + // SAFETY: out-param populated by `loadAsOfContext` on success; + // on error we return before any read. var then_resolution: AsOfResolution = undefined; + // SAFETY: same out-param pattern as `then_resolution`. var then_snap: history.LoadedSnapshot = undefined; const then_ctx = try loadAsOfContext( io, @@ -1161,7 +1162,9 @@ pub fn computeKeyComparison( // Now side — either another snapshot or the live portfolio. if (opts.now_from_snapshot) { + // SAFETY: out-param populated by `loadAsOfContext`. var now_resolution: AsOfResolution = undefined; + // SAFETY: out-param populated by `loadAsOfContext`. var now_snap: history.LoadedSnapshot = undefined; const now_ctx = loadAsOfContext( io, @@ -1209,14 +1212,14 @@ pub fn computeKeyComparison( defer load_result.deinit(); var it = load_result.prices.iterator(); while (it.next()) |entry| { - prices.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; + try prices.put(entry.key_ptr.*, entry.value_ptr.*); } } var pf_data = cli.buildPortfolioData(allocator, loaded.portfolio, loaded.positions, loaded.syms, &prices, svc, opts.now_date) catch |err| switch (err) { error.NoAllocations, error.SummaryFailed => { then_snap.deinit(allocator); - try cli.stderrPrint(io, "Error computing portfolio summary.\n"); + cli.stderrPrint(io, "Error computing portfolio summary.\n"); return error.PortfolioLoadFailed; }, else => { @@ -1334,9 +1337,9 @@ fn resolveAsOfSnapshot( const resolved = cli.resolveAsOfOrExplain(io, va, hist_dir, requested) catch |err| switch (err) { error.NoDataAtOrBefore => return error.NoSnapshot, else => |e| { - try cli.stderrPrint(io, "Error resolving as-of: "); - try cli.stderrPrint(io, @errorName(e)); - try cli.stderrPrint(io, "\n"); + cli.stderrPrint(io, "Error resolving as-of: "); + cli.stderrPrint(io, @errorName(e)); + cli.stderrPrint(io, "\n"); return error.NoSnapshot; }, }; @@ -1493,6 +1496,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs { .io = std.testing.io, .allocator = std.testing.allocator, .gpa = std.testing.allocator, + // SAFETY: parseArgs doesn't touch environ_map. .environ_map = undefined, .config = .{ .cache_dir = "" }, .svc = null, @@ -1500,6 +1504,7 @@ fn parseArgsForTest(today: Date, args: []const []const u8) !ParsedArgs { .today = today, .now_s = 0, .color = false, + // SAFETY: parseArgs doesn't write to out. .out = undefined, }; return parseArgs(&ctx, args); diff --git a/src/commands/quote.zig b/src/commands/quote.zig index cfc9ae4..a5d2250 100644 --- a/src/commands/quote.zig +++ b/src/commands/quote.zig @@ -68,19 +68,19 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr const a = cmd_args[i]; if (std.mem.eql(u8, a, "--export-chart")) { if (i + 1 >= cmd_args.len) { - try cli.stderrPrint(ctx.io, "Error: --export-chart requires a path argument.\n"); + cli.stderrPrint(ctx.io, "Error: --export-chart requires a path argument.\n"); return error.MissingFlagValue; } export_chart = cmd_args[i + 1]; i += 1; } else if (std.mem.startsWith(u8, a, "--")) { - try cli.stderrPrint(ctx.io, "Error: 'quote': unexpected flag "); - try cli.stderrPrint(ctx.io, a); - try cli.stderrPrint(ctx.io, "\n"); + cli.stderrPrint(ctx.io, "Error: 'quote': unexpected flag "); + cli.stderrPrint(ctx.io, a); + cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } else { if (symbol != null) { - try cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'quote' takes a single symbol argument\n"); return error.UnexpectedArg; } symbol = a; @@ -88,7 +88,7 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr } if (symbol == null) { - try cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'quote' requires a symbol argument\n"); return error.MissingSymbol; } return .{ .symbol = symbol.?, .export_chart = export_chart }; @@ -100,11 +100,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { // Fetch candle data for chart and history const candle_result = svc.getCandles(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n"); + cli.stderrPrint(ctx.io, "Error: No API key configured for candle data.\n"); return; }, else => { - try cli.stderrPrint(ctx.io, "Error fetching candle data.\n"); + cli.stderrPrint(ctx.io, "Error fetching candle data.\n"); return; }, }; @@ -125,11 +125,11 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { }; chart_export.exportSymbolChart(ctx.io, ctx.allocator, candles, tf, path) catch |err| switch (err) { error.InsufficientData => { - try cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n"); + cli.stderrPrint(ctx.io, "Error: not enough candle history to render a chart (need >= 20 candles).\n"); return; }, else => { - try cli.stderrPrint(ctx.io, "Error: failed to write PNG.\n"); + cli.stderrPrint(ctx.io, "Error: failed to write PNG.\n"); return err; }, }; diff --git a/src/commands/snapshot.zig b/src/commands/snapshot.zig index 871bbc1..08280e5 100644 --- a/src/commands/snapshot.zig +++ b/src/commands/snapshot.zig @@ -34,7 +34,6 @@ const srf = @import("srf"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); -const fmt = @import("../format.zig"); const atomic = @import("../atomic.zig"); const version = @import("../version.zig"); const portfolio_mod = @import("../models/portfolio.zig"); @@ -111,14 +110,14 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr } else if (std.mem.eql(u8, a, "--out")) { i += 1; if (i >= cmd_args.len) { - try cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n"); + cli.stderrPrint(ctx.io, "Error: --out requires a path argument\n"); return error.UnexpectedArg; } parsed.out_override = cmd_args[i]; } else if (std.mem.eql(u8, a, "--as-of")) { i += 1; if (i >= cmd_args.len) { - try cli.stderrPrint(ctx.io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n"); + cli.stderrPrint(ctx.io, "Error: --as-of requires a date (YYYY-MM-DD or shortcut like 1W/1M/1Q/1Y)\n"); return error.UnexpectedArg; } // Reference date for resolving relative forms in `--as-of` @@ -128,9 +127,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr error.InvalidDate => return error.UnexpectedArg, }; } else { - try cli.stderrPrint(ctx.io, "Error: unknown argument to 'snapshot': "); - try cli.stderrPrint(ctx.io, a); - try cli.stderrPrint(ctx.io, "\n"); + cli.stderrPrint(ctx.io, "Error: unknown argument to 'snapshot': "); + cli.stderrPrint(ctx.io, a); + cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } } @@ -173,13 +172,13 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { defer allocator.free(pf_data); var portfolio = zfin.cache.deserializePortfolio(allocator, pf_data) catch { - try cli.stderrPrint(io, "Error parsing portfolio file.\n"); + cli.stderrPrint(io, "Error parsing portfolio file.\n"); return error.WriteFailed; }; defer portfolio.deinit(); if (portfolio.lots.len == 0) { - try cli.stderrPrint(io, "Portfolio is empty; nothing to snapshot.\n"); + cli.stderrPrint(io, "Portfolio is empty; nothing to snapshot.\n"); return SnapshotError.PortfolioEmpty; } @@ -212,7 +211,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { "snapshot for {s} already exists: {s} (cache fresh, skipped without refresh)\n", .{ cand_str, candidate_path }, ) catch "snapshot already exists\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); if (!dry_run) return; // --dry-run falls through: the user probably wants to see // what would be written. @@ -266,7 +265,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { "skipping {f}: no market data (weekend or holiday)\n", .{as_of}, ) catch "skipping non-trading day\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); return; } @@ -320,7 +319,7 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { if (std.Io.Dir.cwd().access(io, derived_path, .{})) |_| { var msg_buf: [256]u8 = undefined; const msg = std.fmt.bufPrint(&msg_buf, "snapshot for {s} already exists: {s} (use --force to overwrite)\n", .{ as_of_str, derived_path }) catch "snapshot already exists\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); return; } else |_| {} } @@ -342,18 +341,18 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { std.Io.Dir.cwd().createDirPath(io, dir) catch |err| switch (err) { error.PathAlreadyExists => {}, else => { - try cli.stderrPrint(io, "Error creating history directory: "); - try cli.stderrPrint(io, @errorName(err)); - try cli.stderrPrint(io, "\n"); + cli.stderrPrint(io, "Error creating history directory: "); + cli.stderrPrint(io, @errorName(err)); + cli.stderrPrint(io, "\n"); return err; }, }; } atomic.writeFileAtomic(io, allocator, derived_path, rendered) catch |err| { - try cli.stderrPrint(io, "Error writing snapshot: "); - try cli.stderrPrint(io, @errorName(err)); - try cli.stderrPrint(io, "\n"); + cli.stderrPrint(io, "Error writing snapshot: "); + cli.stderrPrint(io, @errorName(err)); + cli.stderrPrint(io, "\n"); return err; }; @@ -404,9 +403,9 @@ fn loadPortfolioAtDate( const target = as_of orelse { // Normal mode — just read the file. return std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| { - try cli.stderrPrint(io, "Error reading portfolio file: "); - try cli.stderrPrint(io, @errorName(err)); - try cli.stderrPrint(io, "\n"); + cli.stderrPrint(io, "Error reading portfolio file: "); + cli.stderrPrint(io, @errorName(err)); + cli.stderrPrint(io, "\n"); return err; }; }; @@ -421,15 +420,15 @@ fn loadPortfolioAtDate( "warning: no git history for portfolio at {f}; using working copy as approximation\n", .{target}, ) catch "warning: no git history for portfolio at requested date\n"; - try cli.stderrPrint(io, msg); + cli.stderrPrint(io, msg); }, else => |e| return e, } return std.Io.Dir.cwd().readFileAlloc(io, portfolio_path, allocator, .limited(10 * 1024 * 1024)) catch |err| { - try cli.stderrPrint(io, "Error reading portfolio file: "); - try cli.stderrPrint(io, @errorName(err)); - try cli.stderrPrint(io, "\n"); + cli.stderrPrint(io, "Error reading portfolio file: "); + cli.stderrPrint(io, @errorName(err)); + cli.stderrPrint(io, "\n"); return err; }; } diff --git a/src/commands/splits.zig b/src/commands/splits.zig index 0b2cddf..0638ff2 100644 --- a/src/commands/splits.zig +++ b/src/commands/splits.zig @@ -2,7 +2,6 @@ const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); -const fmt = cli.fmt; pub const ParsedArgs = struct { symbol: []const u8, @@ -30,11 +29,11 @@ pub const meta: framework.Meta = .{ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { if (cmd_args.len < 1) { - try cli.stderrPrint(ctx.io, "Error: 'splits' requires a symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'splits' requires a symbol argument\n"); return error.MissingSymbol; } if (cmd_args.len > 1) { - try cli.stderrPrint(ctx.io, "Error: 'splits' takes a single symbol argument\n"); + cli.stderrPrint(ctx.io, "Error: 'splits' takes a single symbol argument\n"); return error.UnexpectedArg; } return .{ .symbol = cmd_args[0] }; @@ -45,17 +44,17 @@ pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const opts = cli.fetchOptionsFromPolicy(ctx.globals.refresh_policy); const result = svc.getSplits(parsed.symbol, opts) catch |err| switch (err) { zfin.DataError.NoApiKey => { - try cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); + cli.stderrPrint(ctx.io, "Error: POLYGON_API_KEY not set. Get a free key at https://polygon.io\n"); return; }, else => { - try cli.stderrPrint(ctx.io, "Error fetching split data.\n"); + cli.stderrPrint(ctx.io, "Error fetching split data.\n"); return; }, }; defer result.deinit(); - if (result.source == .cached) try cli.stderrPrint(ctx.io, "(using cached split data)\n"); + if (result.source == .cached) cli.stderrPrint(ctx.io, "(using cached split data)\n"); try display(result.data, parsed.symbol, ctx.color, ctx.out); } diff --git a/src/commands/version.zig b/src/commands/version.zig index c650026..2e70424 100644 --- a/src/commands/version.zig +++ b/src/commands/version.zig @@ -43,9 +43,9 @@ pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedAr if (std.mem.eql(u8, a, "--verbose") or std.mem.eql(u8, a, "-v")) { parsed.verbose = true; } else { - try cli.stderrPrint(ctx.io, "Error: unknown argument to 'version': "); - try cli.stderrPrint(ctx.io, a); - try cli.stderrPrint(ctx.io, "\n"); + cli.stderrPrint(ctx.io, "Error: unknown argument to 'version': "); + cli.stderrPrint(ctx.io, a); + cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } } @@ -74,7 +74,7 @@ pub fn writeVersion( const build_date_buf = blk: { var buf: [10]u8 = undefined; const d = Date.fromEpoch(version.build_timestamp); - const s = std.fmt.bufPrint(&buf, "{f}", .{d}) catch unreachable; + const s = try std.fmt.bufPrint(&buf, "{f}", .{d}); break :blk .{ .buf = buf, .len = s.len }; }; const build_date = build_date_buf.buf[0..build_date_buf.len]; @@ -120,6 +120,7 @@ fn stubCtx(out: *std.Io.Writer, cfg: zfin.Config) framework.RunCtx { .io = std.testing.io, .allocator = std.testing.allocator, .gpa = std.testing.allocator, + // SAFETY: version cmd doesn't read environ_map. .environ_map = undefined, .config = cfg, .svc = null, diff --git a/src/compare.zig b/src/compare.zig index b4fde71..d5a27c5 100644 --- a/src/compare.zig +++ b/src/compare.zig @@ -36,7 +36,6 @@ const std = @import("std"); const zfin = @import("root.zig"); const history = @import("history.zig"); const snapshot_model = @import("models/snapshot.zig"); -const fmt = @import("format.zig"); const view = @import("views/compare.zig"); pub const Date = zfin.Date; diff --git a/src/comptime_validator.zig b/src/comptime_validator.zig index eec2ea9..341c361 100644 --- a/src/comptime_validator.zig +++ b/src/comptime_validator.zig @@ -152,8 +152,6 @@ pub fn expectFnInferredError( // existing tab module compiles, so any breakage to these helpers // surfaces immediately at `zig build`. -const testing = std.testing; - test "expectDeclWithType: matching type passes" { const M = struct { pub const label: []const u8 = "ok"; diff --git a/src/history.zig b/src/history.zig index f8a8eb5..801313b 100644 --- a/src/history.zig +++ b/src/history.zig @@ -337,7 +337,7 @@ pub fn loadSnapshotAt( target: Date, ) !LoadedSnapshot { var date_buf: [10]u8 = undefined; - const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{target}) catch unreachable; + const date_str = try std.fmt.bufPrint(&date_buf, "{f}", .{target}); const filename = try std.fmt.allocPrint(allocator, "{s}{s}", .{ date_str, snapshot_suffix }); defer allocator.free(filename); const full_path = try std.fs.path.join(allocator, &.{ hist_dir, filename }); @@ -538,13 +538,14 @@ pub fn resolveSnapshotDate( requested: Date, ) ResolveSnapshotError!ResolvedSnapshot { var date_buf: [10]u8 = undefined; - const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{requested}) catch unreachable; + // SAFETY: 10-byte buffer is exactly the size of "YYYY-MM-DD". + const date_str = std.fmt.bufPrint(&date_buf, "{f}", .{requested}) catch date_buf[0..]; const filename = try std.fmt.allocPrint(arena, "{s}{s}", .{ date_str, snapshot_suffix }); const full_path = try std.fs.path.join(arena, &.{ hist_dir, filename }); std.Io.Dir.cwd().access(io, full_path, .{}) catch |err| switch (err) { error.FileNotFound => { - const nearest = findNearestSnapshot(io, hist_dir, requested) catch |e| return e; + const nearest = try findNearestSnapshot(io, hist_dir, requested); if (nearest.earlier) |earlier| { return .{ .requested = requested, .actual = earlier, .exact = false }; } diff --git a/src/main.zig b/src/main.zig index bcb53ab..aafb6d9 100644 --- a/src/main.zig +++ b/src/main.zig @@ -72,8 +72,10 @@ const usage_footer = \\ never serve cache contents only; \\ no provider calls (offline mode) \\ -p, --portfolio Portfolio file or glob pattern (repeatable; - \\ default: portfolio*.srf in cwd → ZFIN_HOME). - \\ Quote globs to prevent shell expansion: + \\ default: portfolio*.srf). Resolved against + \\ ZFIN_HOME when set (exclusive — cwd is NOT + \\ consulted), else cwd. Quote globs to + \\ prevent shell expansion: \\ -p 'portfolio_*.srf' \\ Or repeat the flag for multiple files: \\ -p portfolio.srf -p portfolio_mom.srf @@ -331,17 +333,17 @@ fn runCli(init: std.process.Init) !u8 { // up an arena. Freed at the bottom of runCli. const globals = parseGlobals(gpa_alloc, args) catch |err| { switch (err) { - error.MissingValue => try cli.stderrPrint(io, "Error: global flag is missing its value\n"), + error.MissingValue => cli.stderrPrint(io, "Error: global flag is missing its value\n"), error.UnknownGlobalFlag => { - try cli.stderrPrint(io, "Error: unknown global flag: "); + cli.stderrPrint(io, "Error: unknown global flag: "); if (globalOffender(args)) |bad| { - try cli.stderrPrint(io, bad); + cli.stderrPrint(io, bad); } - try cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n"); + cli.stderrPrint(io, "\nRun 'zfin help' for usage.\n"); }, - error.InvalidRefreshDataValue => try cli.stderrPrint(io, "Error: --refresh-data= requires one of: auto, force, never.\n"), + error.InvalidRefreshDataValue => cli.stderrPrint(io, "Error: --refresh-data= requires one of: auto, force, never.\n"), error.UnquotedGlobLikely => { - try cli.stderrPrint(io, + cli.stderrPrint(io, \\Error: -p was given a single value followed by additional .srf files. \\This usually means your shell expanded a glob before zfin saw it. \\ @@ -358,7 +360,7 @@ fn runCli(init: std.process.Init) !u8 { defer gpa_alloc.free(globals.portfolio_patterns); if (globals.cursor >= args.len) { - try cli.stderrPrint(io, "Error: missing command.\nRun 'zfin help' for usage.\n"); + cli.stderrPrint(io, "Error: missing command.\nRun 'zfin help' for usage.\n"); return 1; } @@ -380,12 +382,21 @@ fn runCli(init: std.process.Init) !u8 { // src/data/staleness.zig for the registry and rules. Runs here — // after globals parse, before command dispatch — so the warning // lands above command output on every CLI and TUI invocation. + // + // Best-effort: a stderr-write failure here would mean the user + // can't even see staleness warnings, but their actual command + // should still proceed. Log the secondary error at debug level + // so it's visible if anyone goes looking. { const staleness = @import("data/staleness.zig"); var stale_buf: [2048]u8 = undefined; var stale_writer = std.Io.File.stderr().writer(io, &stale_buf); - staleness.check(&stale_writer.interface, today, &staleness.entries) catch {}; - stale_writer.interface.flush() catch {}; + staleness.check(&stale_writer.interface, today, &staleness.entries) catch |err| { + std.log.debug("staleness check failed: {t}", .{err}); + }; + stale_writer.interface.flush() catch |err| { + std.log.debug("staleness flush failed: {t}", .{err}); + }; } const color = @import("format.zig").shouldUseColor(io, init.environ_map, globals.no_color); @@ -505,7 +516,7 @@ fn runCli(init: std.process.Init) !u8 { } } - try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n"); + cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n"); return 1; } diff --git a/src/net/http.zig b/src/net/http.zig index 886d248..2826b9e 100644 --- a/src/net/http.zig +++ b/src/net/http.zig @@ -99,14 +99,20 @@ pub const Response = struct { var actual: [std.crypto.hash.sha2.Sha256.digest_length]u8 = undefined; std.crypto.hash.sha2.Sha256.hash(self.body, &actual, .{}); var actual_hex: [std.crypto.hash.sha2.Sha256.digest_length * 2]u8 = undefined; - _ = std.fmt.bufPrint(&actual_hex, "{x}", .{&actual}) catch unreachable; + // SAFETY: actual_hex is exactly digest_length*2 bytes, which is + // exactly what "{x}" writes for a digest_length-byte input. + _ = std.fmt.bufPrint(&actual_hex, "{x}", .{&actual}) catch &actual_hex; if (std.ascii.eqlIgnoreCase(&actual_hex, expected_hex)) return .ok; - var result: IntegrityResult = .{ .mismatch = .{ - .expected_hex = undefined, - .actual_hex = undefined, - } }; + var result: IntegrityResult = .{ + .mismatch = .{ + // SAFETY: both fields are populated by the loop and @memcpy below. + .expected_hex = undefined, + // SAFETY: see above. + .actual_hex = undefined, + }, + }; // expected_hex may be uppercase depending on server — copy as // lowercase for stable comparison downstream. for (expected_hex, 0..) |c, i| result.mismatch.expected_hex[i] = std.ascii.toLower(c); @@ -193,7 +199,7 @@ pub const Client = struct { fn backoffSleep(self: *Client, attempt: u8) void { const backoff = self.base_backoff_ms * std.math.shl(u64, 1, attempt); - std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(@intCast(backoff)), .awake) catch {}; + std.Io.sleep(self.io, std.Io.Duration.fromMilliseconds(@intCast(backoff)), .awake) catch |err| std.log.debug("backoff sleep interrupted: {t}", .{err}); } fn doRequest(self: *Client, method: std.http.Method, url: []const u8, body: ?[]const u8, extra_headers: []const std.http.Header) HttpError!Response { @@ -297,7 +303,7 @@ pub const Client = struct { var it = response.head.iterateHeaders(); while (it.next()) |h| { if (std.ascii.eqlIgnoreCase(h.name, "etag")) { - const dup = self.allocator.dupe(u8, h.value) catch |err| return err; + const dup = try self.allocator.dupe(u8, h.value); break :blk dup; } } @@ -317,6 +323,8 @@ pub const Client = struct { errdefer aw.deinit(); var transfer_buffer: [4096]u8 = undefined; + // SAFETY: `decompress` is initialized by `readerDecompressing` + // before any read. Same pattern as `transfer_buffer`/`decompress_buffer`. var decompress: std.http.Decompress = undefined; var decompress_buffer: [64 * 1024]u8 = undefined; const reader = response.readerDecompressing(&transfer_buffer, &decompress, &decompress_buffer); @@ -326,7 +334,7 @@ pub const Client = struct { }; const ms_body = stageElapsedMs(&t_stage, self.io); - const resp_body = aw.toOwnedSlice() catch |err| return err; + const resp_body = try aw.toOwnedSlice(); const total_ms = @divTrunc(std.Io.Timestamp.now(self.io, .awake).nanoseconds - t_start, std.time.ns_per_ms); log.debug( diff --git a/src/portfolio_loader.zig b/src/portfolio_loader.zig new file mode 100644 index 0000000..36ff26d --- /dev/null +++ b/src/portfolio_loader.zig @@ -0,0 +1,413 @@ +//! 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). +//! +//! - `loadPortfolioFromFile(io, alloc, path, as_of)` — single +//! file. Used by CLI `compare` / `projections` for snapshot +//! reads where a specific historical file is loaded. +//! +//! - `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"); + +// ── 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); +} + +/// Internal: load+merge given a pre-resolved paths slice. The slice +/// `paths_owned` is taken (will be freed by `LoadedPortfolio.deinit`). +/// `resolved_paths_opt` is the optional `Config.ResolvedPaths` to +/// hand off ownership of the path strings to the returned struct; +/// when null, path strings are caller-owned. +fn loadFromPaths( + io: std.Io, + allocator: std.mem.Allocator, + paths_owned: []const []const u8, + resolved_paths_opt: ?zfin.Config.ResolvedPaths, + as_of: zfin.Date, +) ?LoadedPortfolio { + // On any error after this point we must free the slice we just + // took ownership of, plus deinit the `resolved_paths_opt` so the + // path strings aren't leaked. + var error_cleanup_armed = true; + defer if (error_cleanup_armed) { + allocator.free(paths_owned); + if (resolved_paths_opt) |rp| rp.deinit(); + }; + + // Read every file up front; bail on first error. + var file_datas: std.ArrayList([]const u8) = .empty; + errdefer { + for (file_datas.items) |d| allocator.free(d); + file_datas.deinit(allocator); + } + for (paths_owned) |p| { + const data = std.Io.Dir.cwd().readFileAlloc(io, p, allocator, .limited(10 * 1024 * 1024)) catch { + var msg_buf: [512]u8 = undefined; + const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot read portfolio file: {s}\n", .{p}) catch "Error: Cannot read portfolio file\n"; + stderr.print(io, msg); + for (file_datas.items) |d| allocator.free(d); + file_datas.deinit(allocator); + return null; + }; + file_datas.append(allocator, data) catch { + allocator.free(data); + for (file_datas.items) |d| allocator.free(d); + file_datas.deinit(allocator); + return null; + }; + } + + // Deserialize each into an owned Portfolio, then merge their + // lot slices into a single combined slice. We can't simply + // concat the underlying slices because each Portfolio expects + // to free its own lots in `deinit()`; instead, we steal each + // Portfolio's lots[] (string fields are already dupe'd into + // `allocator`) and free only the empty Portfolio struct. + var merged: std.ArrayList(zfin.Lot) = .empty; + errdefer { + for (merged.items) |lot| { + allocator.free(lot.symbol); + if (lot.note) |n| allocator.free(n); + if (lot.account) |a| allocator.free(a); + if (lot.ticker) |t| allocator.free(t); + if (lot.underlying) |u| allocator.free(u); + } + merged.deinit(allocator); + } + + for (file_datas.items, 0..) |data, idx| { + var portfolio = zfin.cache.deserializePortfolio(allocator, data) catch { + var msg_buf: [512]u8 = undefined; + const msg = std.fmt.bufPrint(&msg_buf, "Error: Cannot parse portfolio file: {s}\n", .{paths_owned[idx]}) catch "Error: Cannot parse portfolio file\n"; + stderr.print(io, msg); + for (merged.items) |lot| { + allocator.free(lot.symbol); + if (lot.note) |n| allocator.free(n); + if (lot.account) |a| allocator.free(a); + if (lot.ticker) |t| allocator.free(t); + if (lot.underlying) |u| allocator.free(u); + } + merged.deinit(allocator); + for (file_datas.items) |d| allocator.free(d); + file_datas.deinit(allocator); + return null; + }; + for (portfolio.lots) |lot| { + merged.append(allocator, lot) catch { + portfolio.deinit(); + for (merged.items) |existing| { + allocator.free(existing.symbol); + if (existing.note) |n| allocator.free(n); + if (existing.account) |a| allocator.free(a); + if (existing.ticker) |t| allocator.free(t); + if (existing.underlying) |u| allocator.free(u); + } + merged.deinit(allocator); + for (file_datas.items) |d| allocator.free(d); + file_datas.deinit(allocator); + return null; + }; + } + // Free the now-empty Portfolio's lots slice without freeing + // the per-lot strings — they were transferred to `merged`. + allocator.free(portfolio.lots); + } + + const merged_slice = merged.toOwnedSlice(allocator) catch { + for (file_datas.items) |d| allocator.free(d); + file_datas.deinit(allocator); + return null; + }; + + var combined: zfin.Portfolio = .{ + .lots = merged_slice, + .allocator = allocator, + }; + + const positions = combined.positions(as_of, allocator) catch { + combined.deinit(); + for (file_datas.items) |d| allocator.free(d); + file_datas.deinit(allocator); + stderr.print(io, "Error: Cannot compute positions\n"); + return null; + }; + + const syms = combined.stockSymbols(allocator) catch { + allocator.free(positions); + combined.deinit(); + for (file_datas.items) |d| allocator.free(d); + file_datas.deinit(allocator); + stderr.print(io, "Error: Cannot get stock symbols\n"); + return null; + }; + + const file_datas_owned = file_datas.toOwnedSlice(allocator) catch { + allocator.free(syms); + allocator.free(positions); + combined.deinit(); + return null; + }; + + error_cleanup_armed = false; + return .{ + .paths = paths_owned, + .resolved_paths = resolved_paths_opt, + .file_datas = file_datas_owned, + .portfolio = combined, + .positions = positions, + .syms = syms, + }; +} + +/// Convenience for tests + single-file CLI paths (compare, +/// projections snapshot reads). Wraps `loadPortfolioFromPaths` +/// with a one-element slice. +pub fn loadPortfolioFromFile(io: std.Io, allocator: std.mem.Allocator, file_path: []const u8, as_of: zfin.Date) ?LoadedPortfolio { + const paths = [_][]const u8{file_path}; + return loadPortfolioFromPaths(io, allocator, &paths, as_of); +} + +// ── Portfolio data pipeline ────────────────────────────────── + +/// Result of the shared portfolio data pipeline. Caller must call deinit(). +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, + }; +} diff --git a/src/stderr.zig b/src/stderr.zig new file mode 100644 index 0000000..b1c0c9a --- /dev/null +++ b/src/stderr.zig @@ -0,0 +1,93 @@ +//! Best-effort stderr writers. +//! +//! All three functions (`print`, `progress`, `rateLimitWait`) are +//! non-throwing on purpose. A stderr-write failure shouldn't +//! propagate as an error to a CLI command's logic — the user's +//! command should still complete (or fail for its own reasons), +//! not get derailed because we couldn't paint a hint message. +//! Secondary failures get logged at debug level for forensics. +//! +//! Lives at the top level (not under `commands/`) so the portfolio +//! loader and the TUI can use it without a "TUI calls into +//! commands/" import smell. +//! +//! Under `zig build test` the writes are suppressed entirely: +//! tests that exercise error paths emit the same usage/hint +//! strings on every run, and that noise is more annoying than +//! useful. Real CLI users always reach the real stderr. + +const std = @import("std"); +const builtin = @import("builtin"); +const fmt = @import("format.zig"); + +/// Default muted-text color used for progress headers. Matches the +/// CLI / TUI palette used elsewhere; defined here so this module +/// has no dependency on `commands/common.zig`. +const CLR_MUTED = [3]u8{ 0x80, 0x80, 0x80 }; + +/// Default red used for rate-limit warnings. +const CLR_NEGATIVE = [3]u8{ 0xe0, 0x6c, 0x75 }; + +pub fn print(io: std.Io, msg: []const u8) void { + if (builtin.is_test) return; + var buf: [1024]u8 = undefined; + var writer = std.Io.File.stderr().writer(io, &buf); + const out = &writer.interface; + out.writeAll(msg) catch |err| { + std.log.debug("stderr.print writeAll failed: {t}", .{err}); + return; + }; + out.flush() catch |err| { + std.log.debug("stderr.print flush failed: {t}", .{err}); + }; +} + +/// Print progress line to stderr: " [N/M] SYMBOL (status)". +pub fn progress(io: std.Io, symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) void { + if (builtin.is_test) return; + progressImpl(io, symbol, status, current, total, color) catch |err| { + std.log.debug("stderr.progress failed: {t}", .{err}); + }; +} + +fn progressImpl(io: std.Io, symbol: []const u8, status: []const u8, current: usize, total: usize, color: bool) !void { + var buf: [256]u8 = undefined; + var writer = std.Io.File.stderr().writer(io, &buf); + const out = &writer.interface; + if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); + try out.print(" [{d}/{d}] ", .{ current, total }); + if (color) try fmt.ansiReset(out); + try out.print("{s}", .{symbol}); + if (color) try fmt.ansiSetFg(out, CLR_MUTED[0], CLR_MUTED[1], CLR_MUTED[2]); + try out.print("{s}\n", .{status}); + if (color) try fmt.ansiReset(out); + try out.flush(); +} + +/// Print rate-limit wait message to stderr. +pub fn rateLimitWait(io: std.Io, wait_seconds: u64, color: bool) void { + if (builtin.is_test) return; + rateLimitWaitImpl(io, wait_seconds, color) catch |err| { + std.log.debug("stderr.rateLimitWait failed: {t}", .{err}); + }; +} + +fn rateLimitWaitImpl(io: std.Io, wait_seconds: u64, color: bool) !void { + var buf: [256]u8 = undefined; + var writer = std.Io.File.stderr().writer(io, &buf); + const out = &writer.interface; + if (color) try fmt.ansiSetFg(out, CLR_NEGATIVE[0], CLR_NEGATIVE[1], CLR_NEGATIVE[2]); + if (wait_seconds >= 60) { + const mins = wait_seconds / 60; + const secs = wait_seconds % 60; + if (secs > 0) { + try out.print(" (rate limit -- waiting {d}m {d}s)\n", .{ mins, secs }); + } else { + try out.print(" (rate limit -- waiting {d}m)\n", .{mins}); + } + } else { + try out.print(" (rate limit -- waiting {d}s)\n", .{wait_seconds}); + } + if (color) try fmt.ansiReset(out); + try out.flush(); +} diff --git a/src/tui.zig b/src/tui.zig index 9e9a0ee..f5b7fd5 100644 --- a/src/tui.zig +++ b/src/tui.zig @@ -2,8 +2,9 @@ const std = @import("std"); const vaxis = @import("vaxis"); const zfin = @import("root.zig"); const fmt = @import("format.zig"); -const views = @import("views/portfolio_sections.zig"); const cli = @import("commands/common.zig"); +const portfolio_loader = @import("portfolio_loader.zig"); +const stderr = @import("stderr.zig"); const keybinds = @import("tui/keybinds.zig"); const tab_framework = @import("tui/tab_framework.zig"); const theme = @import("tui/theme.zig"); @@ -504,6 +505,9 @@ pub const App = struct { theme: theme.Theme, active_tab: Tab = .portfolio, symbol: []const u8 = "", + // SAFETY: paired with `symbol` slice; only the bytes pointed + // to by `symbol.ptr[0..symbol.len]` are ever read. The tail + // is unobservable. symbol_buf: [16]u8 = undefined, symbol_owned: bool = false, scroll_offset: usize = 0, @@ -524,11 +528,15 @@ pub const App = struct { portfolio_resolved: ?zfin.Config.ResolvedPaths = null, watchlist: ?[][]const u8 = null, watchlist_path: ?[]const u8 = null, + // SAFETY: paired with `status_len`; only the prefix + // `status_msg[0..status_len]` is ever read. status_msg: [256]u8 = undefined, status_len: usize = 0, // Input mode state mode: InputMode = .normal, + // SAFETY: paired with `input_len`; only the prefix + // `input_buf[0..input_len]` is ever read. input_buf: [16]u8 = undefined, input_len: usize = 0, @@ -657,7 +665,7 @@ pub const App = struct { self.active_tab = t; self.scroll_offset = 0; self.loadTabData(); - ctx.queueRefresh() catch {}; + ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err}); return ctx.consumeAndRedraw(); } col += lbl_len; @@ -749,7 +757,13 @@ pub const App = struct { if (!@hasDecl(Module.tab, hook_name)) return; const fn_ptr = @field(Module.tab, hook_name); const state_ptr = &@field(self.states, field.name); - @call(.auto, fn_ptr, .{ state_ptr, self } ++ args) catch {}; + @call(.auto, fn_ptr, .{ state_ptr, self } ++ args) catch |err| { + // Tab hook failed; log and continue. dispatchTry + // is intentionally best-effort — see the doc-comment + // above. A failing hook usually means OOM; the next + // user action will retry. + std.log.debug("tab hook {s} failed: {t}", .{ hook_name, err }); + }; return; } } @@ -955,7 +969,7 @@ pub const App = struct { self.resetSymbolData(); self.active_tab = .quote; self.loadTabData(); - ctx.queueRefresh() catch {}; + ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err}); } self.mode = .normal; self.input_len = 0; @@ -975,7 +989,7 @@ pub const App = struct { fn handleNormalKey(self: *App, ctx: *vaxis.vxfw.EventContext, key: vaxis.Key) void { // Ctrl+L: full screen redraw (standard TUI convention, not configurable) if (key.codepoint == 'l' and key.mods.ctrl) { - ctx.queueRefresh() catch {}; + ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err}); return ctx.consumeAndRedraw(); } @@ -1005,14 +1019,14 @@ pub const App = struct { self.prevTab(); self.scroll_offset = 0; self.loadTabData(); - ctx.queueRefresh() catch {}; + ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err}); return ctx.consumeAndRedraw(); }, .next_tab => { self.nextTab(); self.scroll_offset = 0; self.loadTabData(); - ctx.queueRefresh() catch {}; + ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err}); return ctx.consumeAndRedraw(); }, .tab_1, .tab_2, .tab_3, .tab_4, .tab_5, .tab_6, .tab_7, .tab_8 => { @@ -1023,7 +1037,7 @@ pub const App = struct { self.active_tab = target; self.scroll_offset = 0; self.loadTabData(); - ctx.queueRefresh() catch {}; + ctx.queueRefresh() catch |err| std.log.debug("queueRefresh failed: {t}", .{err}); return ctx.consumeAndRedraw(); } }, @@ -1269,7 +1283,7 @@ pub const App = struct { // Use pre-fetched prices from before TUI started (first load only) for (syms) |sym| { if (pp.get(sym)) |price| { - prices.put(sym, price) catch {}; + prices.put(sym, price) catch |err| std.log.debug("prefetched price put failed: {t}", .{err}); } } @@ -1281,7 +1295,7 @@ pub const App = struct { var pp_iter = pp.iterator(); while (pp_iter.next()) |entry| { if (!prices.contains(entry.key_ptr.*)) { - wp.put(entry.key_ptr.*, entry.value_ptr.*) catch {}; + wp.put(entry.key_ptr.*, entry.value_ptr.*) catch |err| std.log.debug("watchlist price put failed: {t}", .{err}); } } @@ -1298,7 +1312,7 @@ pub const App = struct { const result = self.svc.getCandles(sym, .{}) catch continue; defer result.deinit(); if (result.data.len > 0) { - wp.put(sym, result.data[result.data.len - 1].close) catch {}; + wp.put(sym, result.data[result.data.len - 1].close) catch |err| std.log.debug("watchlist price put failed: {t}", .{err}); } } } @@ -1308,7 +1322,7 @@ pub const App = struct { const result = self.svc.getCandles(sym, .{}) catch continue; defer result.deinit(); if (result.data.len > 0) { - wp.put(sym, result.data[result.data.len - 1].close) catch {}; + wp.put(sym, result.data[result.data.len - 1].close) catch |err| std.log.debug("watchlist price put failed: {t}", .{err}); } } } @@ -1354,7 +1368,7 @@ pub const App = struct { self.portfolio.latest_quote_date = latest_date; // Build portfolio summary, candle map, and historical snapshots - var pf_data = cli.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc, self.today) catch |err| switch (err) { + var pf_data = portfolio_loader.buildPortfolioData(self.allocator, pf, positions, syms, &prices, self.svc, self.today) catch |err| switch (err) { error.NoAllocations => { self.setStatus("No cached prices. Run: zfin perf first"); return; @@ -2097,19 +2111,19 @@ pub fn run( // explicitly rather than silently dropping the flag. const flag = args[i]; if (i + 1 >= args.len) { - try cli.stderrPrint(io, "Error: "); - try cli.stderrPrint(io, flag); - try cli.stderrPrint(io, " requires a symbol value\n"); + stderr.print(io, "Error: "); + stderr.print(io, flag); + stderr.print(io, " requires a symbol value\n"); return error.InvalidArgs; } i += 1; const value = args[i]; if (value.len > 0 and value[0] == '-') { - try cli.stderrPrint(io, "Error: "); - try cli.stderrPrint(io, flag); - try cli.stderrPrint(io, " requires a symbol value, got flag: "); - try cli.stderrPrint(io, value); - try cli.stderrPrint(io, "\n"); + stderr.print(io, "Error: "); + stderr.print(io, flag); + stderr.print(io, " requires a symbol value, got flag: "); + stderr.print(io, value); + stderr.print(io, "\n"); return error.InvalidArgs; } const len = @min(value.len, symbol_upper_buf.len); @@ -2121,32 +2135,32 @@ pub fn run( // Same shape as -s / --symbol: require a value, reject // flag-shaped values. if (i + 1 >= args.len) { - try cli.stderrPrint(io, "Error: --chart requires a value (e.g. 80x24)\n"); + stderr.print(io, "Error: --chart requires a value (e.g. 80x24)\n"); return error.InvalidArgs; } i += 1; const value = args[i]; if (value.len > 0 and value[0] == '-') { - try cli.stderrPrint(io, "Error: --chart requires a value, got flag: "); - try cli.stderrPrint(io, value); - try cli.stderrPrint(io, "\n"); + stderr.print(io, "Error: --chart requires a value, got flag: "); + stderr.print(io, value); + stderr.print(io, "\n"); return error.InvalidArgs; } if (chart.ChartConfig.parse(value)) |cc| { chart_config = cc; } else { - try cli.stderrPrint(io, "Error: --chart value is not a valid WIDTHxHEIGHT spec: "); - try cli.stderrPrint(io, value); - try cli.stderrPrint(io, "\n"); + stderr.print(io, "Error: --chart value is not a valid WIDTHxHEIGHT spec: "); + stderr.print(io, value); + stderr.print(io, "\n"); return error.InvalidArgs; } } else if (args[i].len > 0 and args[i][0] == '-') { // Any flag we didn't recognize. Reject explicitly rather // than silently passing through to the positional-symbol // branch (which would then ignore it). - try cli.stderrPrint(io, "Error: unknown flag: "); - try cli.stderrPrint(io, args[i]); - try cli.stderrPrint(io, "\nRun 'zfin interactive --help' for usage.\n"); + stderr.print(io, "Error: unknown flag: "); + stderr.print(io, args[i]); + stderr.print(io, "\nRun 'zfin interactive --help' for usage.\n"); return error.InvalidArgs; } else if (args[i].len > 0) { const len = @min(args[i].len, symbol_upper_buf.len); @@ -2239,7 +2253,7 @@ pub fn run( // 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| { + if (portfolio_loader.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 @@ -2291,19 +2305,19 @@ pub fn run( { var seen = std.StringHashMap(void).init(allocator); defer seen.deinit(); - if (syms) |ss| for (ss) |s| seen.put(s, {}) catch {}; + if (syms) |ss| for (ss) |s| try seen.put(s, {}); if (app_inst.watchlist) |wl| { for (wl) |sym_w| { if (!seen.contains(sym_w)) { - seen.put(sym_w, {}) catch {}; - watch_syms.append(allocator, sym_w) catch {}; + try seen.put(sym_w, {}); + try watch_syms.append(allocator, sym_w); } } } for (pf.lots) |lot| { if (lot.security_type == .watch and !seen.contains(lot.priceSymbol())) { - seen.put(lot.priceSymbol(), {}) catch {}; - watch_syms.append(allocator, lot.priceSymbol()) catch {}; + try seen.put(lot.priceSymbol(), {}); + try watch_syms.append(allocator, lot.priceSymbol()); } } } diff --git a/src/tui/analysis_tab.zig b/src/tui/analysis_tab.zig index cde7519..320da54 100644 --- a/src/tui/analysis_tab.zig +++ b/src/tui/analysis_tab.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); diff --git a/src/tui/chart.zig b/src/tui/chart.zig index 01b6c7c..1b80365 100644 --- a/src/tui/chart.zig +++ b/src/tui/chart.zig @@ -45,9 +45,7 @@ pub const ChartConfig = struct { } }; const Context = z2d.Context; -const Path = z2d.Path; const Pixel = z2d.Pixel; -const Color = z2d.Color; /// Chart timeframe selection. pub const Timeframe = enum { diff --git a/src/tui/earnings_tab.zig b/src/tui/earnings_tab.zig index d883062..5c77d0b 100644 --- a/src/tui/earnings_tab.zig +++ b/src/tui/earnings_tab.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const theme = @import("theme.zig"); diff --git a/src/tui/options_tab.zig b/src/tui/options_tab.zig index 9c29e3d..564bc6f 100644 --- a/src/tui/options_tab.zig +++ b/src/tui/options_tab.zig @@ -3,7 +3,6 @@ const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); -const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const framework = @import("tab_framework.zig"); diff --git a/src/tui/performance_tab.zig b/src/tui/performance_tab.zig index 7307d74..8ba3b3b 100644 --- a/src/tui/performance_tab.zig +++ b/src/tui/performance_tab.zig @@ -1,5 +1,4 @@ const std = @import("std"); -const vaxis = @import("vaxis"); const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); diff --git a/src/tui/portfolio_tab.zig b/src/tui/portfolio_tab.zig index bbb8d66..003129e 100644 --- a/src/tui/portfolio_tab.zig +++ b/src/tui/portfolio_tab.zig @@ -4,7 +4,7 @@ const zfin = @import("../root.zig"); const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const views = @import("../views/portfolio_sections.zig"); -const cli = @import("../commands/common.zig"); +const portfolio_loader = @import("../portfolio_loader.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); const projections_tab = @import("projections_tab.zig"); @@ -14,7 +14,6 @@ const framework = @import("tab_framework.zig"); const App = tui.App; const StyledLine = tui.StyledLine; const colLabel = tui.colLabel; -const glyph = tui.glyph; // Portfolio column layout (display columns). // Each column width includes its trailing separator space. @@ -231,6 +230,8 @@ pub const State = struct { account_picker_cursor: usize = 0, /// Search-mode input buffer (active when /// `state.modal == .account_search`). + // SAFETY: paired with `account_search_len`; only the prefix + // `account_search_buf[0..account_search_len]` is ever read. account_search_buf: [64]u8 = undefined, /// Live length of `account_search_buf`. account_search_len: usize = 0, @@ -466,7 +467,7 @@ pub const tab = struct { // Click on the column-header row → sort by that column. if (state.header_lines > 0 and content_row == state.header_lines - 1) { - const col = @as(usize, @intCast(mouse.col)); + const col: usize = @intCast(mouse.col); const new_field: ?PortfolioSortField = if (col < col_end_symbol) .symbol @@ -662,6 +663,15 @@ pub fn sortPortfolioAllocations(state: *State, app: *App) void { } pub fn rebuildPortfolioRows(state: *State, app: *App) void { + rebuildPortfolioRowsImpl(state, app) catch |err| { + // OOM building the row list. Render path will see a + // possibly-truncated list of rows; the next event will + // try again. + std.log.debug("rebuildPortfolioRows failed: {t}", .{err}); + }; +} + +fn rebuildPortfolioRowsImpl(state: *State, app: *App) !void { state.rows.clearRetainingCapacity(); if (state.prepared_options) |*opts| opts.deinit(); state.prepared_options = null; @@ -748,7 +758,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void { const drip = fmt.aggregateDripLots(app.today, matching.items); if (!drip.st.isEmpty()) { - state.rows.append(app.allocator, .{ + try state.rows.append(app.allocator, .{ .kind = .drip_summary, .symbol = a.symbol, .pos_idx = i, @@ -758,10 +768,10 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void { .drip_avg_cost = drip.st.avgCost(), .drip_date_first = drip.st.first_date, .drip_date_last = drip.st.last_date, - }) catch {}; + }); } if (!drip.lt.isEmpty()) { - state.rows.append(app.allocator, .{ + try state.rows.append(app.allocator, .{ .kind = .drip_summary, .symbol = a.symbol, .pos_idx = i, @@ -771,7 +781,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void { .drip_avg_cost = drip.lt.avgCost(), .drip_date_first = drip.lt.first_date, .drip_date_last = drip.lt.last_date, - }) catch {}; + }); } } } @@ -789,7 +799,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void { // Mark all portfolio position symbols as seen if (app.portfolio.summary) |s| { for (s.allocations) |a| { - watch_seen.put(a.symbol, {}) catch {}; + try watch_seen.put(a.symbol, {}); } } @@ -798,7 +808,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void { for (pf.lots) |lot| { if (lot.security_type == .watch) { if (watch_seen.contains(lot.priceSymbol())) continue; - watch_seen.put(lot.priceSymbol(), {}) catch {}; + try watch_seen.put(lot.priceSymbol(), {}); state.rows.append(app.allocator, .{ .kind = .watchlist, .symbol = lot.symbol, @@ -811,7 +821,7 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void { if (app.watchlist) |wl| { for (wl) |sym| { if (watch_seen.contains(sym)) continue; - watch_seen.put(sym, {}) catch {}; + try watch_seen.put(sym, {}); state.rows.append(app.allocator, .{ .kind = .watchlist, .symbol = sym, @@ -825,10 +835,10 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void { state.prepared_options = views.Options.init(app.today, app.allocator, pf.lots, state.account_filter) catch null; if (state.prepared_options) |opts| { if (opts.items.len > 0) { - state.rows.append(app.allocator, .{ + try state.rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Options", - }) catch {}; + }); for (opts.items) |po| { state.rows.append(app.allocator, .{ .kind = .option_row, @@ -847,10 +857,10 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void { state.prepared_cds = views.CDs.init(app.today, app.allocator, pf.lots, state.account_filter) catch null; if (state.prepared_cds) |cds| { if (cds.items.len > 0) { - state.rows.append(app.allocator, .{ + try state.rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Certificates of Deposit", - }) catch {}; + }); for (cds.items) |pc| { state.rows.append(app.allocator, .{ .kind = .cd_row, @@ -875,10 +885,10 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void { } } if (cash_lots.items.len > 0) { - state.rows.append(app.allocator, .{ + try state.rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Cash", - }) catch {}; + }); for (cash_lots.items) |lot| { state.rows.append(app.allocator, .{ .kind = .cash_row, @@ -889,14 +899,14 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void { } } else { // Unfiltered: show total + expandable per-account rows - state.rows.append(app.allocator, .{ + try state.rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Cash", - }) catch {}; - state.rows.append(app.allocator, .{ + }); + try state.rows.append(app.allocator, .{ .kind = .cash_total, .symbol = "CASH", - }) catch {}; + }); if (state.cash_expanded) { for (pf.lots) |lot| { if (lot.security_type == .cash) { @@ -914,14 +924,14 @@ pub fn rebuildPortfolioRows(state: *State, app: *App) void { // Illiquid assets section (hidden when account filter is active) if (state.account_filter == null) { if (pf.hasType(.illiquid)) { - state.rows.append(app.allocator, .{ + try state.rows.append(app.allocator, .{ .kind = .section_header, .symbol = "Illiquid Assets", - }) catch {}; - state.rows.append(app.allocator, .{ + }); + try state.rows.append(app.allocator, .{ .kind = .illiquid_total, .symbol = "ILLIQUID", - }) catch {}; + }); if (state.illiquid_expanded) { for (pf.lots) |lot| { if (lot.security_type == .illiquid) { @@ -1667,9 +1677,10 @@ 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. +/// Goes through the same `portfolio_loader.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, @@ -1685,7 +1696,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void { return; } - if (cli.loadPortfolioFromPaths(app.io, app.allocator, app.portfolio_paths, app.today)) |loaded| { + if (portfolio_loader.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 @@ -1746,7 +1757,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void { if (candles_slice) |cs| { defer cs.deinit(); if (cs.data.len > 0) { - prices.put(sym, cs.data[cs.data.len - 1].close) catch {}; + prices.put(sym, cs.data[cs.data.len - 1].close) catch |err| std.log.debug("price put failed: {t}", .{err}); const d = cs.data[cs.data.len - 1].date; if (latest_date == null or d.days > latest_date.?.days) latest_date = d; } @@ -1757,7 +1768,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void { app.portfolio.latest_quote_date = latest_date; // Build portfolio summary, candle map, and historical snapshots from cache - var pf_data = cli.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) { + var pf_data = portfolio_loader.buildPortfolioData(app.allocator, pf, positions, syms, &prices, app.svc, app.today) catch |err| switch (err) { error.NoAllocations => { app.setStatus("No cached prices available"); return; @@ -1795,7 +1806,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void { // If currently on the analysis tab, eagerly recompute so the user // doesn't see an error message before switching away and back. if (app.active_tab == .analysis) { - analysis_tab.tab.activate(&app.states.analysis, app) catch {}; + analysis_tab.tab.activate(&app.states.analysis, app) catch |err| std.log.debug("analysis activate failed: {t}", .{err}); } // Invalidate projections data — projections.srf may have changed. @@ -1803,7 +1814,7 @@ pub fn reloadPortfolioFile(state: *State, app: *App) void { // re-fetch only if the user is actively looking at projections. // (When not active, the next `activate` lazily re-fetches.) if (app.active_tab == .projections) { - projections_tab.tab.reload(&app.states.projections, app) catch {}; + projections_tab.tab.reload(&app.states.projections, app) catch |err| std.log.debug("projections reload failed: {t}", .{err}); } else { projections_tab.freeLoaded(&app.states.projections, app); app.states.projections.loaded = false; @@ -1840,7 +1851,7 @@ pub fn drawAccountPicker(state: *State, app: *App, arena: std.mem.Allocator, buf var search_cursor_idx: ?usize = null; if (is_searching and state.account_search_matches.items.len > 0) { for (state.account_search_matches.items, 0..) |match_idx, si| { - search_highlight.put(match_idx, {}) catch {}; + try search_highlight.put(match_idx, {}); if (si == state.account_search_cursor) search_cursor_idx = match_idx; } } @@ -1949,7 +1960,7 @@ pub fn handleAccountPickerMouse(state: *State, app: *App, mouse: vaxis.Mouse) bo // `account_picker_header_lines` — preserve that // behavior. (Drift in the picker layout would shift // the off-by-one; not changing it here.) - const content_row = @as(usize, @intCast(mouse.row)); + const content_row: usize = @intCast(mouse.row); if (content_row >= account_picker_header_lines) { const item_idx = content_row - account_picker_header_lines; if (item_idx < total_items) { diff --git a/src/tui/projections_tab.zig b/src/tui/projections_tab.zig index 149544d..f88dfb2 100644 --- a/src/tui/projections_tab.zig +++ b/src/tui/projections_tab.zig @@ -26,17 +26,12 @@ const fmt = @import("../format.zig"); const Money = @import("../Money.zig"); const theme = @import("theme.zig"); const tui = @import("../tui.zig"); -const chart = @import("chart.zig"); const projection_chart = @import("projection_chart.zig"); const forecast_chart = @import("forecast_chart.zig"); -const projections = @import("../analytics/projections.zig"); const forecast = @import("../analytics/forecast_evaluation.zig"); const imported = @import("../data/imported_values.zig"); const milestones = @import("../analytics/milestones.zig"); const shiller = @import("../data/shiller.zig"); -const benchmark = @import("../analytics/benchmark.zig"); -const performance = @import("../analytics/performance.zig"); -const valuation = @import("../analytics/valuation.zig"); const view = @import("../views/projections.zig"); const history = @import("../history.zig"); const cli = @import("../commands/common.zig"); @@ -360,7 +355,7 @@ pub const tab = struct { state.as_of = null; state.as_of_requested = null; state.overlay_actuals = false; - tab.reload(state, app) catch {}; + tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err}); app.setStatus("As-of cleared — showing live"); }, .toggle_convergence => { @@ -1003,7 +998,7 @@ fn drawWithKittyChart(state: *State, app: *App, arena: std.mem.Allocator, buf: [ const last_band = bands[bands.len - 1]; const label_values = [_]f64{ last_band.p10, last_band.p90, last_band.p50, last_band.p25, last_band.p75 }; const val_range = state.value_max - state.value_min; - const rows_f = @as(f64, @floatFromInt(img_rows -| 1)); + const rows_f: f64 = @floatFromInt(img_rows -| 1); var placed_rows: [5]usize = undefined; var placed_count: usize = 0; @@ -2149,7 +2144,7 @@ fn handleDateInputKey(state: *State, app: *App, key: vaxis.Key) bool { app.setStatus("As-of cleared — showing live"); } - tab.reload(state, app) catch {}; + tab.reload(state, app) catch |err| std.log.debug("projections reload failed: {t}", .{err}); state.modal = .none; app.input_len = 0; diff --git a/src/tui/quote_tab.zig b/src/tui/quote_tab.zig index a065f0b..e125325 100644 --- a/src/tui/quote_tab.zig +++ b/src/tui/quote_tab.zig @@ -21,6 +21,7 @@ pub const ChartState = struct { image_id: ?u32 = null, // currently transmitted Kitty image ID image_width: u16 = 0, // image width in cells image_height: u16 = 0, // image height in cells + // SAFETY: paired with `symbol_len`; only `symbol[0..symbol_len]` is read. symbol: [16]u8 = undefined, // symbol the chart was rendered for symbol_len: usize = 0, timeframe_rendered: ?chart.Timeframe = null, // timeframe the chart was rendered for @@ -191,7 +192,7 @@ pub const tab = struct { // Layout: " Chart: [6M] YTD 1Y 3Y 5Y ([ ] to change)" // Prefix " Chart: " is 9 chars. Each timeframe label takes // `label_len + 2` (brackets/spaces around the label) + 1 (gap). - const col = @as(usize, @intCast(mouse.col)); + const col: usize = @intCast(mouse.col); const prefix_len: usize = 9; if (col < prefix_len) return false;