From 51aaee9966321b0d50efacefb45931d22a1127ff Mon Sep 17 00:00:00 2001 From: Emil Lerch Date: Mon, 18 May 2026 16:05:21 -0700 Subject: [PATCH] migrate milestones to new cli framework --- src/commands/milestones.zig | 152 +++++++++++++++++++++++++----------- src/main.zig | 14 +--- 2 files changed, 109 insertions(+), 57 deletions(-) diff --git a/src/commands/milestones.zig b/src/commands/milestones.zig index b74a8bf..fec9e71 100644 --- a/src/commands/milestones.zig +++ b/src/commands/milestones.zig @@ -20,6 +20,7 @@ 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 Money = @import("../Money.zig"); const history = @import("../history.zig"); @@ -27,6 +28,45 @@ const Date = @import("../Date.zig"); const milestones = @import("../analytics/milestones.zig"); const shiller = @import("../data/shiller.zig"); +pub const ParsedArgs = struct { + /// Raw `--step` value (e.g. `"1M"`, `"2x"`). Resolved into a typed + /// step inside `run` so `parseStep`'s detailed error reporting can + /// surface to the user with the right framing. + step_raw: []const u8, + real: bool = false, +}; + +pub const meta = struct { + pub const name: []const u8 = "milestones"; + pub const group: framework.Group = .portfolio; + pub const synopsis: []const u8 = "Show portfolio threshold crossings (each $1M, doublings, etc.)"; + pub const help: []const u8 = + \\Usage: zfin milestones --step [--real] + \\ + \\Find the dates the portfolio first reached each of a + \\configured set of thresholds. Two threshold modes: + \\ + \\ Absolute dollar: 1M / 1m / 1500000 / 1.5M / 500K / 500k + \\ Relative multiplier: 2x / 2X / 1.5x + \\ + \\Rejects %, ≤0 dollar steps, ≤1.0x multipliers, NaN/Inf. + \\ + \\Options: + \\ --step Threshold step (required). + \\ --real Deflate the series to the last full Shiller + \\ year before detecting crossings (CPI-adjusted). + \\ Default is nominal. + \\ + \\Note: crossing dates are "first observed at," bounded by the + \\source series cadence (typically weekly). + \\ + ; +}; + +comptime { + framework.validateCommandModule(@This()); +} + pub const RunError = error{ UnexpectedArg, MissingStep, @@ -37,79 +77,60 @@ pub const RunError = error{ } || std.Io.Reader.Error || std.Io.Writer.Error || std.fs.File.OpenError || std.posix.RealPathError; -const usage_text = - \\Usage: zfin milestones --step [--real] - \\ - \\ --step Threshold step. Examples: - \\ 1M / 1m / 1500000 / 1.5M (absolute dollar) - \\ 500K / 500k (absolute thousands) - \\ 2x / 2X / 1.5x (relative multiplier) - \\ Rejects %, <=0, <=1.0x, NaN, Inf. - \\ --real Deflate the series to the last full Shiller year - \\ before detecting crossings (CPI-adjusted dollars). - \\ Default is nominal. - \\ -h, --help Show this help. - \\ - \\Note: crossing dates are "first observed at," bounded by the - \\source series cadence (typically weekly). - \\ -; - -pub fn run( - io: std.Io, - allocator: std.mem.Allocator, - svc: *zfin.DataService, - portfolio_path: []const u8, - args: []const []const u8, - today: Date, - color: bool, - out: *std.Io.Writer, -) !void { - _ = svc; // milestones reads local files only - _ = today; // current behavior: detection bounded by series, no "now" +pub fn parseArgs(ctx: *framework.RunCtx, cmd_args: []const []const u8) !ParsedArgs { var step_str: ?[]const u8 = null; var want_real = false; var i: usize = 0; - while (i < args.len) : (i += 1) { - const a = args[i]; + while (i < cmd_args.len) : (i += 1) { + const a = cmd_args[i]; if (std.mem.eql(u8, a, "--step")) { i += 1; - if (i >= args.len) { - try cli.stderrPrint(io, "Error: --step requires an argument\n"); + if (i >= cmd_args.len) { + try cli.stderrPrint(ctx.io, "Error: --step requires an argument\n"); return error.MissingStep; } - step_str = args[i]; + step_str = cmd_args[i]; } else if (std.mem.eql(u8, a, "--real")) { want_real = true; - } else if (std.mem.eql(u8, a, "-h") or std.mem.eql(u8, a, "--help")) { - try out.writeAll(usage_text); - return; } else { - try cli.stderrPrint(io, "Error: unknown argument to 'milestones': "); - try cli.stderrPrint(io, a); - try cli.stderrPrint(io, "\n"); + try cli.stderrPrint(ctx.io, "Error: unknown argument to 'milestones': "); + try cli.stderrPrint(ctx.io, a); + try cli.stderrPrint(ctx.io, "\n"); return error.UnexpectedArg; } } - const step_input = step_str orelse { - try cli.stderrPrint(io, "Error: --step is required\n"); - try cli.stderrPrint(io, usage_text); + const step_raw = step_str orelse { + try cli.stderrPrint(ctx.io, "Error: --step is required\n"); + try cli.stderrPrint(ctx.io, meta.help); return error.MissingStep; }; + return .{ .step_raw = step_raw, .real = want_real }; +} - const step = milestones.parseStep(step_input) catch |err| { +pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { + const io = ctx.io; + const allocator = ctx.allocator; + const out = ctx.out; + const color = ctx.color; + const want_real = parsed.real; + + const step = milestones.parseStep(parsed.step_raw) catch |err| { var buf: [256]u8 = undefined; const msg = std.fmt.bufPrint( &buf, "Error: cannot parse --step '{s}': {s}\n", - .{ step_input, @errorName(err) }, + .{ parsed.step_raw, @errorName(err) }, ) catch "Error: invalid --step\n"; try cli.stderrPrint(io, msg); return error.InvalidStep; }; + const pf = ctx.resolvePortfolioPath(); + defer pf.deinit(allocator); + const portfolio_path = pf.path; + // Load merged series. var series_owned = try loadMergedSeries(io, allocator, portfolio_path); defer series_owned.deinit(allocator); @@ -339,6 +360,45 @@ fn renderTable( // ── Tests ──────────────────────────────────────────────────── +test "parseArgs: --step and --real" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "--step", "1M", "--real" }; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("1M", parsed.step_raw); + try std.testing.expect(parsed.real); +} + +test "parseArgs: --step only" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "--step", "2x" }; + const parsed = try parseArgs(&ctx, &args); + try std.testing.expectEqualStrings("2x", parsed.step_raw); + try std.testing.expect(!parsed.real); +} + +test "parseArgs: missing --step errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{}; + try std.testing.expectError(error.MissingStep, parseArgs(&ctx, &args)); +} + +test "parseArgs: --step without value errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{"--step"}; + try std.testing.expectError(error.MissingStep, parseArgs(&ctx, &args)); +} + +test "parseArgs: unknown flag errors" { + var ctx: framework.RunCtx = undefined; + ctx.io = std.testing.io; + const args = [_][]const u8{ "--step", "1M", "--bogus" }; + try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); +} + test "loadMergedSeries: empty when no history" { const io = std.testing.io; var tmp_dir = std.testing.tmpDir(.{}); diff --git a/src/main.zig b/src/main.zig index ebd1185..a061d97 100644 --- a/src/main.zig +++ b/src/main.zig @@ -22,6 +22,9 @@ const command_modules = .{ .earnings = @import("commands/earnings.zig"), .etf = @import("commands/etf.zig"), + // Portfolio analysis + .milestones = @import("commands/milestones.zig"), + // Data hygiene .enrich = @import("commands/enrich.zig"), .lookup = @import("commands/lookup.zig"), @@ -721,17 +724,6 @@ fn runCli(init: std.process.Init) !u8 { => return 1, else => return err, }; - } else if (std.mem.eql(u8, command, "milestones")) { - const pf = resolveUserPath(io, allocator, config, globals.portfolio_path, zfin.Config.default_portfolio_filename); - defer if (pf.resolved) |r| r.deinit(allocator); - commands.milestones.run(io, allocator, &svc, pf.path, cmd_args, today, color, out) catch |err| switch (err) { - error.UnexpectedArg, - error.MissingStep, - error.InvalidStep, - error.NoData, - => return 1, - else => return err, - }; } else { try cli.stderrPrint(io, "Unknown command. Run 'zfin help' for usage.\n"); return 1;