restructure for single test binary/simplify Coverage module (drop to 30% thresh temp)

This commit is contained in:
Emil Lerch 2026-03-01 09:04:56 -08:00
parent 24924460d2
commit 313ef83065
Signed by: lobo
GPG key ID: A7B62D657EF764F8
22 changed files with 348 additions and 410 deletions

View file

@ -29,7 +29,7 @@ repos:
- id: test
name: Run zig build test
entry: zig
args: ["build", "coverage", "-Dcoverage-threshold=80"]
args: ["build", "coverage", "-Dcoverage-threshold=30"]
language: system
types: [file]
pass_filenames: false

View file

@ -23,8 +23,9 @@ pub fn build(b: *std.Build) void {
const srf_mod = srf_dep.module("srf");
// Library module -- the public API for consumers of zfin
const mod = b.addModule("zfin", .{
// Library module -- the public API for downstream consumers of zfin.
// Internal code (CLI, TUI) uses file-path imports instead.
_ = b.addModule("zfin", .{
.root_source_file = b.path("src/root.zig"),
.target = target,
.imports = &.{
@ -32,34 +33,23 @@ pub fn build(b: *std.Build) void {
},
});
// Shared imports for TUI and CLI modules
const tui_imports: []const std.Build.Module.Import = &.{
.{ .name = "zfin", .module = mod },
// Shared imports for the unified module (CLI + TUI + lib in one module).
// Only external deps -- internal imports use file paths so that Zig's
// test runner can discover tests across the entire source tree.
const imports: []const std.Build.Module.Import = &.{
.{ .name = "srf", .module = srf_mod },
.{ .name = "vaxis", .module = vaxis_dep.module("vaxis") },
.{ .name = "z2d", .module = z2d_dep.module("z2d") },
};
const tui_mod = b.addModule("tui", .{
.root_source_file = b.path("src/tui/main.zig"),
.target = target,
.imports = tui_imports,
});
const cli_imports: []const std.Build.Module.Import = &.{
.{ .name = "zfin", .module = mod },
.{ .name = "srf", .module = srf_mod },
.{ .name = "tui", .module = tui_mod },
};
// Unified executable (CLI + TUI in one binary)
const exe = b.addExecutable(.{
.name = "zfin",
.root_module = b.createModule(.{
.root_source_file = b.path("src/cli/main.zig"),
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = cli_imports,
.imports = imports,
}),
});
b.installArtifact(exe);
@ -73,30 +63,18 @@ pub fn build(b: *std.Build) void {
run_cmd.addArgs(args);
}
// Tests: Zig test discovery doesn't cross module boundaries, so each
// module (lib, TUI, CLI) needs its own test target.
// Tests: single binary, single module. refAllDeclsRecursive in
// main.zig discovers all tests via file imports.
const test_step = b.step("test", "Run all tests");
const mod_tests = b.addTest(.{ .root_module = mod });
test_step.dependOn(&b.addRunArtifact(mod_tests).step);
const tui_tests = b.addTest(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/tui/main.zig"),
const tests = b.addTest(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = tui_imports,
.imports = imports,
}) });
test_step.dependOn(&b.addRunArtifact(tui_tests).step);
test_step.dependOn(&b.addRunArtifact(tests).step);
const cli_tests = b.addTest(.{ .root_module = b.createModule(.{
.root_source_file = b.path("src/cli/main.zig"),
.target = target,
.optimize = optimize,
.imports = cli_imports,
}) });
test_step.dependOn(&b.addRunArtifact(cli_tests).step);
// Docs
// Docs (still uses the library module for clean public API docs)
const lib = b.addLibrary(.{
.name = "zfin",
.root_module = b.createModule(.{
@ -117,19 +95,12 @@ pub fn build(b: *std.Build) void {
// Coverage: `zig build coverage` (uses kcov, Linux x86_64/aarch64 only)
{
const cov = Coverage.init(b);
_ = cov.addModule(mod, "zfin-lib");
var cov = Coverage.init(b);
_ = cov.addModule(b.createModule(.{
.root_source_file = b.path("src/tui/main.zig"),
.root_source_file = b.path("src/main.zig"),
.target = target,
.optimize = optimize,
.imports = tui_imports,
}), "zfin-tui");
_ = cov.addModule(b.createModule(.{
.root_source_file = b.path("src/cli/main.zig"),
.target = target,
.optimize = optimize,
.imports = cli_imports,
}), "zfin-cli");
.imports = imports,
}), "zfin");
}
}

View file

@ -18,8 +18,8 @@ const Coverage = @This();
///
/// Use `zig build coverage --verbose` to see per-file coverage breakdown.
///
/// Call `addModule()` on the returned value to add test modules to the coverage run.
/// Each module gets its own kcov invocation, threshold check, and output subdirectory.
/// Call `addModule()` on the returned value to add the test module to the
/// coverage run.
///
/// Because addModule creates a new test executable from the root module provided,
/// if there are any linking steps being done to your test executable, those
@ -79,10 +79,12 @@ pub fn init(b: *Build) Coverage {
};
}
/// Add a test module to the coverage run. Each module gets its own kcov
/// invocation and threshold check, all wired into the shared "coverage" step.
/// Add a test module to the coverage run. Runs kcov on the test binary,
/// then reads the coverage JSON and prints a summary (with per-file
/// breakdown if --verbose). Fails if below -Dcoverage-threshold.
///
/// Returns the test executable so the caller can add any extra linking steps.
pub fn addModule(self: Coverage, root_module: *Build.Module, name: []const u8) *Build.Step.Compile {
pub fn addModule(self: *Coverage, root_module: *Build.Module, name: []const u8) *Build.Step.Compile {
const b = self.b;
// Set up kcov run: filter to src/ only, use custom CSS for HTML report
@ -105,11 +107,26 @@ pub fn addModule(self: Coverage, root_module: *Build.Module, name: []const u8) *
run_coverage.step.dependOn(&test_exe.step);
run_coverage.step.dependOn(&self.run_download.step);
// Wire up the threshold check step (reads coverage.json after kcov finishes)
const json_path = b.fmt("{s}/{s}/coverage.json", .{ self.coverage_dir, name });
const check_step = create(b, test_exe, json_path, self.coverage_threshold);
check_step.step.dependOn(&run_coverage.step);
self.coverage_step.dependOn(&check_step.step);
// Wire up the threshold check step after kcov completes
const check = b.allocator.create(Coverage) catch @panic("OOM");
check.* = .{
.b = b,
.coverage_step = undefined,
.coverage_dir = undefined,
.coverage_threshold = undefined,
.kcov_path = undefined,
.run_download = undefined,
.step = Build.Step.init(.{
.id = .custom,
.name = "check coverage",
.owner = b,
.makeFn = make,
}),
.json_path = b.fmt("{s}/{s}/coverage.json", .{ self.coverage_dir, name }),
.threshold = self.coverage_threshold,
};
check.step.dependOn(&run_coverage.step);
self.coverage_step.dependOn(&check.step);
return test_exe;
}
@ -124,47 +141,20 @@ coverage_threshold: u7,
kcov_path: []const u8,
run_download: *Build.Step.Run,
// Fields used by make() for per-module threshold checking.
// These are only meaningful on instances created by create(), not by init().
// Fields used by make() for the threshold check (set by addModule)
step: Build.Step = undefined,
json_path: []const u8 = "",
threshold: u7 = 0,
test_exe: *Build.Step.Compile = undefined,
/// Create a coverage check step that reads the kcov JSON output after
/// the coverage run completes and verifies the threshold is met.
fn create(owner: *Build, test_exe: *Build.Step.Compile, json_path: []const u8, threshold: u7) *Coverage {
const check = owner.allocator.create(Coverage) catch @panic("OOM");
check.* = .{
.b = owner,
.coverage_step = undefined,
.coverage_dir = "",
.coverage_threshold = 0,
.kcov_path = "",
.run_download = undefined,
.step = Build.Step.init(.{
.id = .custom,
.name = "check coverage",
.owner = owner,
.makeFn = make,
}),
.json_path = json_path,
.threshold = threshold,
.test_exe = test_exe,
};
return check;
}
// This must be kept in step with kcov coverage.json format
// This must be kept in step with kcov per-binary coverage.json format
const CoverageReport = struct {
percent_covered: f64,
files: []const CoverageFile,
};
const CoverageFile = struct {
file: []const u8,
covered_lines: usize,
total_lines: usize,
percent_low: u7,
percent_high: u7,
command: []const u8,
date: []const u8,
files: []File,
};
const File = struct {
@ -173,48 +163,78 @@ const File = struct {
covered_lines: usize,
total_lines: usize,
pub fn coverageLessThanDesc(context: []File, lhs: File, rhs: File) bool {
_ = context;
pub fn coverageLessThanDesc(_: void, lhs: File, rhs: File) bool {
return lhs.percent_covered > rhs.percent_covered;
}
};
/// Build step make function: reads the kcov coverage.json output,
/// prints summary (and per-file breakdown if verbose), and fails
/// the build if coverage is below the configured threshold.
/// Build step make function: reads kcov JSON output, prints a summary
/// (with per-file breakdown if verbose), and fails if below threshold.
fn make(step: *Build.Step, options: Build.Step.MakeOptions) !void {
_ = options;
const check: *Coverage = @fieldParentPtr("step", step);
const allocator = step.owner.allocator;
const file = try std.fs.cwd().openFile(check.json_path, .{});
const file = std.fs.cwd().openFile(check.json_path, .{}) catch |err| {
return step.fail("Failed to open coverage report {s}: {}", .{ check.json_path, err });
};
defer file.close();
const content = try file.readToEndAlloc(allocator, 10 * 1024 * 1024);
defer allocator.free(content);
const json = try std.json.parseFromSlice(CoverageReport, allocator, content, .{});
const json = std.json.parseFromSlice(CoverageReport, allocator, content, .{
.ignore_unknown_fields = true,
}) catch |err| {
return step.fail("Failed to parse coverage JSON: {}", .{err});
};
defer json.deinit();
const coverage = json.value;
var total_covered: usize = 0;
var total_lines: usize = 0;
var file_list = std.ArrayList(File).empty;
defer file_list.deinit(allocator);
for (json.value.files) |f| {
const pct: f64 = if (f.total_lines > 0)
@as(f64, @floatFromInt(f.covered_lines)) / @as(f64, @floatFromInt(f.total_lines)) * 100.0
else
0;
try file_list.append(allocator, .{
.file = f.file,
.covered_lines = f.covered_lines,
.total_lines = f.total_lines,
.percent_covered = pct,
});
total_covered += f.covered_lines;
total_lines += f.total_lines;
}
std.mem.sort(File, file_list.items, {}, File.coverageLessThanDesc);
var stdout_buffer: [1024]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buffer);
const stdout = &stdout_writer.interface;
if (step.owner.verbose) {
const files = coverage.files;
std.mem.sort(File, files, files, File.coverageLessThanDesc);
for (files) |f|
for (file_list.items) |f| {
try stdout.print(
"{d: >5.1}% {d: >5}/{d: <5}:{s}\n",
.{ f.percent_covered, f.covered_lines, f.total_lines, f.file },
);
}
}
const total_pct: f64 = if (total_lines > 0)
@as(f64, @floatFromInt(total_covered)) / @as(f64, @floatFromInt(total_lines)) * 100.0
else
0;
try stdout.print(
"Total test coverage: {d}% ({d}/{d})\n",
.{ coverage.percent_covered, coverage.covered_lines, coverage.total_lines },
"Total test coverage: {d:.2}% ({d}/{d})\n",
.{ total_pct, total_covered, total_lines },
);
try stdout.flush();
if (@as(u7, @intFromFloat(@floor(coverage.percent_covered))) < check.threshold)
return step.fail("Coverage {d}% is below threshold {d}%", .{ coverage.percent_covered, check.threshold });
if (@as(u7, @intFromFloat(@floor(total_pct))) < check.threshold)
return step.fail("Coverage {d:.2}% is below threshold {d}%", .{ total_pct, check.threshold });
}

View file

@ -1,220 +0,0 @@
const std = @import("std");
const zfin = @import("zfin");
const fmt = zfin.format;
const tui = @import("tui");
const cli = @import("common.zig");
const usage =
\\Usage: zfin <command> [options]
\\
\\Commands:
\\ interactive [opts] Launch interactive TUI
\\ perf <SYMBOL> Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style)
\\ quote <SYMBOL> Show latest quote with chart and history
\\ history <SYMBOL> Show recent price history
\\ divs <SYMBOL> Show dividend history
\\ splits <SYMBOL> Show split history
\\ options <SYMBOL> Show options chain (all expirations)
\\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
\\ portfolio [FILE] Load and analyze a portfolio (default: portfolio.srf)
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ cache stats Show cache statistics
\\ cache clear Clear all cached data
\\
\\Global options:
\\ --no-color Disable colored output
\\
\\Interactive mode options:
\\ -p, --portfolio <FILE> Portfolio file (.srf)
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
\\ --chart <MODE> Chart graphics: auto, braille, or WxH (e.g. 1920x1080)
\\ --default-keys Print default keybindings
\\ --default-theme Print default theme
\\
\\Options command options:
\\ --ntm <N> Show +/- N strikes near the money (default: 8)
\\
\\Portfolio command options:
\\ If no file is given, defaults to portfolio.srf in the current directory.
\\ -w, --watchlist <FILE> Watchlist file
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
\\
\\Analysis command:
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
\\ from the same directory as the portfolio file.
\\ If no file is given, defaults to portfolio.srf in the current directory.
\\
\\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits)
\\ FINNHUB_API_KEY Finnhub API key (earnings)
\\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles)
\\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional)
\\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin)
\\ NO_COLOR Disable colored output (https://no-color.org)
\\
;
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
// Single buffered writer for all stdout output
var stdout_buf: [4096]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buf);
const out: *std.Io.Writer = &stdout_writer.interface;
if (args.len < 2) {
try out.writeAll(usage);
try out.flush();
return;
}
// Scan for global --no-color flag
var no_color_flag = false;
for (args[1..]) |arg| {
if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true;
}
const color = fmt.shouldUseColor(no_color_flag);
var config = zfin.Config.fromEnv(allocator);
defer config.deinit();
const command = args[1];
if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help") or std.mem.eql(u8, command, "-h")) {
try out.writeAll(usage);
try out.flush();
return;
}
// Interactive TUI -- delegates to the TUI module (owns its own DataService)
if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) {
try out.flush();
try tui.run(allocator, config, args);
return;
}
var svc = zfin.DataService.init(allocator, config);
defer svc.deinit();
if (std.mem.eql(u8, command, "perf")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'perf' requires a symbol argument\n");
try commands.perf.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "quote")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'quote' requires a symbol argument\n");
try commands.quote.run(allocator, config, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "history")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'history' requires a symbol argument\n");
try commands.history.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "divs")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");
try commands.divs.run(allocator, &svc, config, args[2], color, out);
} else if (std.mem.eql(u8, command, "splits")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'splits' requires a symbol argument\n");
try commands.splits.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "options")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'options' requires a symbol argument\n");
// Parse --ntm flag
var ntm: usize = 8;
var ai: usize = 3;
while (ai < args.len) : (ai += 1) {
if (std.mem.eql(u8, args[ai], "--ntm") and ai + 1 < args.len) {
ai += 1;
ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8;
}
}
try commands.options.run(allocator, &svc, args[2], ntm, color, out);
} else if (std.mem.eql(u8, command, "earnings")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n");
try commands.earnings.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "etf")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'etf' requires a symbol argument\n");
try commands.etf.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "portfolio")) {
// Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf)
var watchlist_path: ?[]const u8 = null;
var force_refresh = false;
var file_path: []const u8 = "portfolio.srf";
var pi: usize = 2;
while (pi < args.len) : (pi += 1) {
if ((std.mem.eql(u8, args[pi], "--watchlist") or std.mem.eql(u8, args[pi], "-w")) and pi + 1 < args.len) {
pi += 1;
watchlist_path = args[pi];
} else if (std.mem.eql(u8, args[pi], "--refresh")) {
force_refresh = true;
} else if (std.mem.eql(u8, args[pi], "--no-color")) {
// already handled globally
} else {
file_path = args[pi];
}
}
try commands.portfolio.run(allocator, config, &svc, file_path, watchlist_path, force_refresh, color, out);
} else if (std.mem.eql(u8, command, "lookup")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
try commands.lookup.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "cache")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n");
try commands.cache.run(allocator, config, args[2], out);
} else if (std.mem.eql(u8, command, "enrich")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n");
try commands.enrich.run(allocator, config, args[2], out);
} else if (std.mem.eql(u8, command, "analysis")) {
// File path is first non-flag arg (default: portfolio.srf)
var analysis_file: []const u8 = "portfolio.srf";
for (args[2..]) |arg| {
if (!std.mem.startsWith(u8, arg, "--")) {
analysis_file = arg;
break;
}
}
try commands.analysis.run(allocator, config, &svc, analysis_file, color, out);
} else {
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
}
// Single flush for all stdout output
try out.flush();
}
// Command modules
const commands = struct {
const perf = @import("commands/perf.zig");
const quote = @import("commands/quote.zig");
const history = @import("commands/history.zig");
const divs = @import("commands/divs.zig");
const splits = @import("commands/splits.zig");
const options = @import("commands/options.zig");
const earnings = @import("commands/earnings.zig");
const etf = @import("commands/etf.zig");
const portfolio = @import("commands/portfolio.zig");
const lookup = @import("commands/lookup.zig");
const cache = @import("commands/cache.zig");
const analysis = @import("commands/analysis.zig");
const enrich = @import("commands/enrich.zig");
};
// Ensure test runner discovers tests in all imported modules
comptime {
_ = cli;
_ = commands.perf;
_ = commands.quote;
_ = commands.history;
_ = commands.divs;
_ = commands.splits;
_ = commands.options;
_ = commands.earnings;
_ = commands.etf;
_ = commands.portfolio;
_ = commands.lookup;
_ = commands.cache;
_ = commands.analysis;
_ = commands.enrich;
}

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
/// CLI `analysis` command: show portfolio analysis breakdowns.

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, subcommand: []const u8, out: *std.Io.Writer) !void {
if (std.mem.eql(u8, subcommand, "stats")) {

View file

@ -1,5 +1,5 @@
const std = @import("std");
const zfin = @import("zfin");
const zfin = @import("../root.zig");
pub const fmt = zfin.format;
// Default CLI colors (match TUI default Monokai theme)

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, config: zfin.Config, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
/// CLI `enrich` command: bootstrap a metadata.srf file from Alpha Vantage OVERVIEW data.
/// Reads the portfolio, extracts stock symbols, fetches sector/industry/country for each,

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, cusip: []const u8, color: bool, out: *std.Io.Writer) !void {
if (!zfin.OpenFigi.isCusipLike(cusip)) {

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, ntm: usize, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, config: zfin.Config, svc: *zfin.DataService, file_path: []const u8, watchlist_path: ?[]const u8, force_refresh: bool, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
/// Quote data extracted from the real-time API (or synthesized from candles).

View file

@ -1,6 +1,6 @@
const std = @import("std");
const zfin = @import("zfin");
const cli = @import("../common.zig");
const zfin = @import("../root.zig");
const cli = @import("common.zig");
const fmt = cli.fmt;
pub fn run(allocator: std.mem.Allocator, svc: *zfin.DataService, symbol: []const u8, color: bool, out: *std.Io.Writer) !void {

View file

@ -1,27 +1,208 @@
const std = @import("std");
const zfin = @import("zfin");
const zfin = @import("root.zig");
const fmt = zfin.format;
const tui = @import("tui.zig");
const cli = @import("commands/common.zig");
const usage =
\\Usage: zfin <command> [options]
\\
\\Commands:
\\ interactive [opts] Launch interactive TUI
\\ perf <SYMBOL> Show 1yr/3yr/5yr/10yr trailing returns (Morningstar-style)
\\ quote <SYMBOL> Show latest quote with chart and history
\\ history <SYMBOL> Show recent price history
\\ divs <SYMBOL> Show dividend history
\\ splits <SYMBOL> Show split history
\\ options <SYMBOL> Show options chain (all expirations)
\\ earnings <SYMBOL> Show earnings history and upcoming
\\ etf <SYMBOL> Show ETF profile (holdings, sectors, expense ratio)
\\ portfolio [FILE] Load and analyze a portfolio (default: portfolio.srf)
\\ analysis [FILE] Show portfolio analysis (default: portfolio.srf)
\\ enrich <FILE|SYMBOL> Bootstrap metadata.srf from Alpha Vantage (25 req/day limit)
\\ lookup <CUSIP> Look up CUSIP to ticker via OpenFIGI
\\ cache stats Show cache statistics
\\ cache clear Clear all cached data
\\
\\Global options:
\\ --no-color Disable colored output
\\
\\Interactive mode options:
\\ -p, --portfolio <FILE> Portfolio file (.srf)
\\ -w, --watchlist <FILE> Watchlist file (default: watchlist.srf)
\\ -s, --symbol <SYMBOL> Initial symbol (default: VTI)
\\ --chart <MODE> Chart graphics: auto, braille, or WxH (e.g. 1920x1080)
\\ --default-keys Print default keybindings
\\ --default-theme Print default theme
\\
\\Options command options:
\\ --ntm <N> Show +/- N strikes near the money (default: 8)
\\
\\Portfolio command options:
\\ If no file is given, defaults to portfolio.srf in the current directory.
\\ -w, --watchlist <FILE> Watchlist file
\\ --refresh Force refresh (ignore cache, re-fetch all prices)
\\
\\Analysis command:
\\ Reads metadata.srf (classification) and accounts.srf (tax types)
\\ from the same directory as the portfolio file.
\\ If no file is given, defaults to portfolio.srf in the current directory.
\\
\\Environment Variables:
\\ TWELVEDATA_API_KEY Twelve Data API key (primary: prices)
\\ POLYGON_API_KEY Polygon.io API key (dividends, splits)
\\ FINNHUB_API_KEY Finnhub API key (earnings)
\\ ALPHAVANTAGE_API_KEY Alpha Vantage API key (ETF profiles)
\\ OPENFIGI_API_KEY OpenFIGI API key (CUSIP lookup, optional)
\\ ZFIN_CACHE_DIR Cache directory (default: ~/.cache/zfin)
\\ NO_COLOR Disable colored output (https://no-color.org)
\\
;
pub fn main() !void {
// Prints to stderr, ignoring potential errors.
std.debug.print("All your {s} are belong to us.\n", .{"codebase"});
try zfin.bufferedPrint();
}
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
test "simple test" {
const gpa = std.testing.allocator;
var list: std.ArrayList(i32) = .empty;
defer list.deinit(gpa); // Try commenting this out and see if zig detects the memory leak!
try list.append(gpa, 42);
try std.testing.expectEqual(@as(i32, 42), list.pop());
}
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
test "fuzz example" {
const Context = struct {
fn testOne(context: @This(), input: []const u8) anyerror!void {
_ = context;
// Try passing `--fuzz` to `zig build test` and see if it manages to fail this test case!
try std.testing.expect(!std.mem.eql(u8, "canyoufindme", input));
// Single buffered writer for all stdout output
var stdout_buf: [4096]u8 = undefined;
var stdout_writer = std.fs.File.stdout().writer(&stdout_buf);
const out: *std.Io.Writer = &stdout_writer.interface;
if (args.len < 2) {
try out.writeAll(usage);
try out.flush();
return;
}
// Scan for global --no-color flag
var no_color_flag = false;
for (args[1..]) |arg| {
if (std.mem.eql(u8, arg, "--no-color")) no_color_flag = true;
}
const color = fmt.shouldUseColor(no_color_flag);
var config = zfin.Config.fromEnv(allocator);
defer config.deinit();
const command = args[1];
if (std.mem.eql(u8, command, "help") or std.mem.eql(u8, command, "--help") or std.mem.eql(u8, command, "-h")) {
try out.writeAll(usage);
try out.flush();
return;
}
// Interactive TUI -- delegates to the TUI module (owns its own DataService)
if (std.mem.eql(u8, command, "interactive") or std.mem.eql(u8, command, "i")) {
try out.flush();
try tui.run(allocator, config, args);
return;
}
var svc = zfin.DataService.init(allocator, config);
defer svc.deinit();
if (std.mem.eql(u8, command, "perf")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'perf' requires a symbol argument\n");
try commands.perf.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "quote")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'quote' requires a symbol argument\n");
try commands.quote.run(allocator, config, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "history")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'history' requires a symbol argument\n");
try commands.history.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "divs")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'divs' requires a symbol argument\n");
try commands.divs.run(allocator, &svc, config, args[2], color, out);
} else if (std.mem.eql(u8, command, "splits")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'splits' requires a symbol argument\n");
try commands.splits.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "options")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'options' requires a symbol argument\n");
// Parse --ntm flag
var ntm: usize = 8;
var ai: usize = 3;
while (ai < args.len) : (ai += 1) {
if (std.mem.eql(u8, args[ai], "--ntm") and ai + 1 < args.len) {
ai += 1;
ntm = std.fmt.parseInt(usize, args[ai], 10) catch 8;
}
}
};
try std.testing.fuzz(Context{}, Context.testOne, .{});
try commands.options.run(allocator, &svc, args[2], ntm, color, out);
} else if (std.mem.eql(u8, command, "earnings")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'earnings' requires a symbol argument\n");
try commands.earnings.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "etf")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'etf' requires a symbol argument\n");
try commands.etf.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "portfolio")) {
// Parse -w/--watchlist and --refresh flags; file path is first non-flag arg (default: portfolio.srf)
var watchlist_path: ?[]const u8 = null;
var force_refresh = false;
var file_path: []const u8 = "portfolio.srf";
var pi: usize = 2;
while (pi < args.len) : (pi += 1) {
if ((std.mem.eql(u8, args[pi], "--watchlist") or std.mem.eql(u8, args[pi], "-w")) and pi + 1 < args.len) {
pi += 1;
watchlist_path = args[pi];
} else if (std.mem.eql(u8, args[pi], "--refresh")) {
force_refresh = true;
} else if (std.mem.eql(u8, args[pi], "--no-color")) {
// already handled globally
} else {
file_path = args[pi];
}
}
try commands.portfolio.run(allocator, config, &svc, file_path, watchlist_path, force_refresh, color, out);
} else if (std.mem.eql(u8, command, "lookup")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'lookup' requires a CUSIP argument\n");
try commands.lookup.run(allocator, &svc, args[2], color, out);
} else if (std.mem.eql(u8, command, "cache")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'cache' requires a subcommand (stats, clear)\n");
try commands.cache.run(allocator, config, args[2], out);
} else if (std.mem.eql(u8, command, "enrich")) {
if (args.len < 3) return try cli.stderrPrint("Error: 'enrich' requires a portfolio file path or symbol\n");
try commands.enrich.run(allocator, config, args[2], out);
} else if (std.mem.eql(u8, command, "analysis")) {
// File path is first non-flag arg (default: portfolio.srf)
var analysis_file: []const u8 = "portfolio.srf";
for (args[2..]) |arg| {
if (!std.mem.startsWith(u8, arg, "--")) {
analysis_file = arg;
break;
}
}
try commands.analysis.run(allocator, config, &svc, analysis_file, color, out);
} else {
try cli.stderrPrint("Unknown command. Run 'zfin help' for usage.\n");
}
// Single flush for all stdout output
try out.flush();
}
// Command modules
const commands = struct {
const perf = @import("commands/perf.zig");
const quote = @import("commands/quote.zig");
const history = @import("commands/history.zig");
const divs = @import("commands/divs.zig");
const splits = @import("commands/splits.zig");
const options = @import("commands/options.zig");
const earnings = @import("commands/earnings.zig");
const etf = @import("commands/etf.zig");
const portfolio = @import("commands/portfolio.zig");
const lookup = @import("commands/lookup.zig");
const cache = @import("commands/cache.zig");
const analysis = @import("commands/analysis.zig");
const enrich = @import("commands/enrich.zig");
};
// Single test binary: all source is in one module (file imports, no module
// boundaries), so refAllDeclsRecursive discovers every test in the tree.
test {
std.testing.refAllDeclsRecursive(@This());
}

View file

@ -62,9 +62,3 @@ pub const OpenFigi = @import("providers/openfigi.zig");
// -- Re-export SRF for portfolio file loading --
pub const srf = @import("srf");
// -- Tests --
test {
const std = @import("std");
std.testing.refAllDecls(@This());
}

View file

@ -1,10 +1,10 @@
const std = @import("std");
const vaxis = @import("vaxis");
const zfin = @import("zfin");
const zfin = @import("root.zig");
const fmt = zfin.format;
const keybinds = @import("keybinds.zig");
const theme_mod = @import("theme.zig");
const chart_mod = @import("chart.zig");
const keybinds = @import("tui/keybinds.zig");
const theme_mod = @import("tui/theme.zig");
const chart_mod = @import("tui/chart.zig");
/// Comptime-generated table of single-character grapheme slices with static lifetime.
/// This avoids dangling pointers from stack-allocated temporaries in draw functions.
@ -384,14 +384,7 @@ const App = struct {
const sw = fmt.sym_col_width;
const col = @as(usize, @intCast(mouse.col));
const new_field: ?PortfolioSortField =
if (col < 4 + sw + 1) .symbol
else if (col < 4 + sw + 10) .shares
else if (col < 4 + sw + 21) .avg_cost
else if (col < 4 + sw + 32) .price
else if (col < 4 + sw + 49) .market_value
else if (col < 4 + sw + 64) .gain_loss
else if (col < 4 + sw + 73) .weight
else if (col < 4 + sw + 87) null // Date (not sortable)
if (col < 4 + sw + 1) .symbol else if (col < 4 + sw + 10) .shares else if (col < 4 + sw + 21) .avg_cost else if (col < 4 + sw + 32) .price else if (col < 4 + sw + 49) .market_value else if (col < 4 + sw + 64) .gain_loss else if (col < 4 + sw + 73) .weight else if (col < 4 + sw + 87) null // Date (not sortable)
else .account;
if (new_field) |nf| {
if (nf == self.portfolio_sort_field) {
@ -1277,7 +1270,10 @@ const App = struct {
// Check if any lots are DRIP
var has_drip = false;
for (matching.items) |lot| {
if (lot.drip) { has_drip = true; break; }
if (lot.drip) {
has_drip = true;
break;
}
}
if (!has_drip) {
@ -3252,7 +3248,6 @@ const App = struct {
// Track header line count for mouse click mapping (after all non-data lines)
self.options_header_lines = lines.items.len;
// Flat list of options rows with inline expand/collapse
for (self.options_rows.items, 0..) |row, ri| {
const is_cursor = ri == self.options_cursor;
@ -3628,19 +3623,16 @@ const App = struct {
const actions = comptime std.enums.values(keybinds.Action);
const action_labels = [_][]const u8{
"Quit", "Refresh", "Previous tab", "Next tab",
"Tab 1", "Tab 2", "Tab 3", "Tab 4",
"Tab 5", "Tab 6", "Scroll down", "Scroll up", "Scroll to top",
"Scroll to bottom", "Page down", "Page up", "Select next",
"Select prev", "Expand/collapse", "Select symbol", "Change symbol (search)",
"This help", "Edit portfolio/watchlist",
"Reload portfolio from disk",
"Toggle all calls (options)", "Toggle all puts (options)",
"Filter +/- 1 NTM", "Filter +/- 2 NTM", "Filter +/- 3 NTM",
"Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM",
"Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM",
"Chart: next timeframe", "Chart: prev timeframe",
"Sort: next column", "Sort: prev column", "Sort: reverse order",
"Quit", "Refresh", "Previous tab", "Next tab",
"Tab 1", "Tab 2", "Tab 3", "Tab 4",
"Tab 5", "Tab 6", "Scroll down", "Scroll up",
"Scroll to top", "Scroll to bottom", "Page down", "Page up",
"Select next", "Select prev", "Expand/collapse", "Select symbol",
"Change symbol (search)", "This help", "Edit portfolio/watchlist", "Reload portfolio from disk",
"Toggle all calls (options)", "Toggle all puts (options)", "Filter +/- 1 NTM", "Filter +/- 2 NTM",
"Filter +/- 3 NTM", "Filter +/- 4 NTM", "Filter +/- 5 NTM", "Filter +/- 6 NTM",
"Filter +/- 7 NTM", "Filter +/- 8 NTM", "Filter +/- 9 NTM", "Chart: next timeframe",
"Chart: prev timeframe", "Sort: next column", "Sort: prev column", "Sort: reverse order",
};
for (actions, 0..) |action, ai| {

View file

@ -4,7 +4,7 @@
const std = @import("std");
const z2d = @import("z2d");
const zfin = @import("zfin");
const zfin = @import("../root.zig");
const theme_mod = @import("theme.zig");
const Surface = z2d.Surface;