migrate milestones to new cli framework

This commit is contained in:
Emil Lerch 2026-05-18 16:05:21 -07:00
parent 1228d50ce6
commit 51aaee9966
Signed by: lobo
GPG key ID: A7B62D657EF764F8
2 changed files with 109 additions and 57 deletions

View file

@ -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 <expr> [--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 <expr> 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 <expr> [--real]
\\
\\ --step <expr> 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(.{});

View file

@ -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;