const std = @import("std"); const zfin = @import("../root.zig"); const cli = @import("common.zig"); const framework = @import("framework.zig"); const isCusipLike = @import("../models/portfolio.zig").isCusipLike; pub const ParsedArgs = struct { cusip: []const u8, }; pub const meta = struct { pub const name: []const u8 = "lookup"; pub const group: framework.Group = .hygiene; pub const synopsis: []const u8 = "Look up a CUSIP to ticker via OpenFIGI"; pub const uppercase_first_arg: bool = true; pub const help: []const u8 = \\Usage: zfin lookup \\ \\Look up a CUSIP (9-char alphanumeric security identifier) to \\its ticker via the OpenFIGI API. Successful results are cached \\indefinitely in `cusip_tickers.srf`. OPENFIGI_API_KEY raises \\the rate limit but the unauthenticated tier works for low \\volume. \\ \\Mutual funds frequently have no OpenFIGI coverage; the \\command surfaces that and suggests a manual portfolio entry. \\ \\Examples: \\ zfin lookup 037833100 # → AAPL \\ ; }; comptime { framework.validateCommandModule(@This()); } 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"); return error.MissingCusip; } if (cmd_args.len > 1) { try cli.stderrPrint(ctx.io, "Error: 'lookup' takes a single CUSIP argument\n"); return error.UnexpectedArg; } return .{ .cusip = cmd_args[0] }; } pub fn run(ctx: *framework.RunCtx, parsed: ParsedArgs) !void { const svc = ctx.svc orelse return error.MissingDataService; const out = ctx.out; const color = ctx.color; const allocator = ctx.allocator; if (!isCusipLike(parsed.cusip)) { 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"); // 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"); return; }; defer { for (results) |r| { if (r.ticker) |t| allocator.free(t); if (r.name) |n| allocator.free(n); if (r.security_type) |s| allocator.free(s); } allocator.free(results); } if (results.len == 0 or !results[0].found) { try out.print("No result from OpenFIGI for '{s}'\n", .{parsed.cusip}); return; } try display(results[0], parsed.cusip, color, out); // Also cache it if (results[0].ticker) |ticker| { svc.cacheCusipTicker(parsed.cusip, ticker); } } pub fn display(result: zfin.CusipResult, cusip: []const u8, color: bool, out: *std.Io.Writer) !void { if (result.ticker) |ticker| { try cli.printBold(out, color, "{s}", .{cusip}); try out.print(" -> ", .{}); try cli.printFg(out, color, cli.CLR_ACCENT, "{s}\n", .{ticker}); if (result.name) |name| { try cli.printFg(out, color, cli.CLR_MUTED, " Name: {s}\n", .{name}); } if (result.security_type) |st| { try cli.printFg(out, color, cli.CLR_MUTED, " Type: {s}\n", .{st}); } try out.print("\n To use in portfolio: ticker::{s}\n", .{ticker}); } else { try out.print("No ticker found for CUSIP '{s}'\n", .{cusip}); if (result.name) |name| { try cli.printFg(out, color, cli.CLR_MUTED, " Name: {s}\n", .{name}); } try out.print("\n Tip: For mutual funds, OpenFIGI often has no coverage.\n", .{}); try out.print(" Add manually: symbol::{s},ticker::XXXX,...\n", .{cusip}); } } // ── Tests ──────────────────────────────────────────────────── test "parseArgs: accepts a single CUSIP" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{"037833100"}; const parsed = try parseArgs(&ctx, &args); try std.testing.expectEqualStrings("037833100", parsed.cusip); } test "parseArgs: missing CUSIP errors" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{}; try std.testing.expectError(error.MissingCusip, parseArgs(&ctx, &args)); } test "parseArgs: extra args error" { var ctx: framework.RunCtx = undefined; ctx.io = std.testing.io; const args = [_][]const u8{ "037833100", "extra" }; try std.testing.expectError(error.UnexpectedArg, parseArgs(&ctx, &args)); } test "display shows ticker mapping" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const result: zfin.CusipResult = .{ .ticker = "AAPL", .name = "Apple Inc", .security_type = "Common Stock", .found = true, }; try display(result, "037833100", false, &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "037833100") != null); try std.testing.expect(std.mem.indexOf(u8, out, "AAPL") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Apple Inc") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Common Stock") != null); try std.testing.expect(std.mem.indexOf(u8, out, "ticker::AAPL") != null); } test "display shows no-ticker message" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const result: zfin.CusipResult = .{ .ticker = null, .name = "Some Fund", .security_type = null, .found = true, }; try display(result, "123456789", false, &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "No ticker found") != null); try std.testing.expect(std.mem.indexOf(u8, out, "Some Fund") != null); try std.testing.expect(std.mem.indexOf(u8, out, "mutual funds") != null); } test "display no ANSI without color" { var buf: [4096]u8 = undefined; var w: std.Io.Writer = .fixed(&buf); const result: zfin.CusipResult = .{ .ticker = "MSFT", .name = null, .security_type = null, .found = true, }; try display(result, "594918104", false, &w); const out = w.buffered(); try std.testing.expect(std.mem.indexOf(u8, out, "\x1b[") == null); }