main.zig

20.4 KB · raw

1// main.zig — entry point, arg parsing, dispatch, error handler2// THE ONLY FILE that creates the Io.Writer and passes it to output.init().3const std = @import("std");4const Io = std.Io;56const types = @import("types.zig");7const errors = @import("errors.zig");8const config = @import("config.zig");9const commands = @import("commands.zig");10const output = @import("output.zig");11const man = @import("man.zig");1213const Args = struct {14    command: []const u8 = "",15    sub: []const u8 = "",16    rest: []const []const u8 = &.{},17    passthrough: []const []const u8 = &.{}, // unrecognized flags (e.g. --refresh), forwarded to the wrapped nix command18    machine: ?[]const u8 = null,19    version: bool = false,20    dry: bool = false,21    check: bool = false,22    no_apply: bool = false,23    all: bool = false,24    json: bool = false,25    last: u32 = 50,26    user: bool = false,27};2829pub fn main(startup: std.process.Init) !void {30    const arena = startup.arena.allocator();31    const gpa = startup.gpa;32    const io = startup.io;3334    // Command allocations go through an arena freed when main returns. nina is a35    // one-shot CLI that exits after one command, and std.process.exit skips36    // defers, so per-allocation frees were easy to miss — the arena guarantees37    // everything is released in one shot, eliminating exit-time leak reports.38    var cmd_arena = std.heap.ArenaAllocator.init(gpa);39    defer cmd_arena.deinit();40    const ca = cmd_arena.allocator();4142    // Initialize output module with buffered stdout writer. Streaming (not the43    // default positional) mode: stdout may be a terminal, pipe, or a redirected44    // regular file. Positional mode pwrites from offset 0 and ignores the fd's45    // inherited offset, which corrupts `{ echo h; nina x; } > f` and `>>` append46    // by overwriting from the start. Streaming honors the current file offset.47    var stdout_buf: [16384]u8 = undefined;48    var stdout_fw: Io.File.Writer = .initStreaming(.stdout(), io, &stdout_buf);49    output.init(&stdout_fw.interface, io, startup.environ_map, true);50    defer output.flush();5152    const argv = try startup.minimal.args.toSlice(arena);53    const args = try parseArgs(arena, argv);5455    if (args.version) {56        output.version();57        return;58    }5960    // Help and hello work without config61    if (args.command.len == 0 or std.mem.eql(u8, args.command, "help")) {62        output.help();63        return;64    }6566    // man works without a config — it's pure docs, no machine context needed.67    if (std.mem.eql(u8, args.command, "man")) {68        const topic: []const u8 = if (args.rest.len > 0) args.rest[0] else "";69        man.run(io, topic) catch {};70        return;71    }7273    // setup and update run before config so they work without ~/.nina.conf74    if (std.mem.eql(u8, args.command, "setup") or std.mem.eql(u8, args.command, "update")) {75        const dummy_machine: types.Machine = .{ .name = "local", .local = true };76        const dummy_cfg: types.NinaConfig = .{};77        const pre_ctx = commands.Ctx{78            .gpa = ca,79            .io = io,80            .environ = startup.environ_map,81            .machine = dummy_machine,82            .cfg = dummy_cfg,83            .args = args.rest,84            .passthrough = args.passthrough,85            .sub = args.sub,86            .dry = args.dry,87            .check = args.check,88            .no_apply = args.no_apply,89            .all = args.all,90            .last = args.last,91            .json = args.json,92            .user = args.user,93        };94        if (std.mem.eql(u8, args.command, "setup")) {95            commands.setup(ca, io, startup.environ_map) catch |err| {96                const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);97                const detail = errors.error_info.getDetail();98                const suggestion = errors.error_info.getSuggestion();99                output.printError(msg, detail, suggestion);100                output.flush();101                std.process.exit(1);102            };103        } else {104            dispatch(pre_ctx, args.command) catch |err| {105                reportError(err, ca);106                output.flush();107                std.process.exit(1);108            };109        }110        return;111    }112113    // Load config and resolve machine. On first run with no config file, offer114    // the setup wizard when stdout is a TTY; skip silently in piped/scripted mode.115    const cfg = cfg_blk: {116        if (config.load(arena, io, startup.environ_map)) |c| {117            break :cfg_blk c;118        } else |err| {119            if (err == error.ConfigNotFound and (std.Io.File.stdout().isTty(io) catch false)) {120                commands.firstRunWizard(ca, io, startup.environ_map) catch |wizard_err| {121                    const msg = errors.error_info.getMessage() orelse errors.staticMessage(wizard_err);122                    const detail = errors.error_info.getDetail();123                    const suggestion = errors.error_info.getSuggestion();124                    output.printError(msg, detail, suggestion);125                    output.flush();126                    std.process.exit(1);127                };128                errors.error_info.reset();129                break :cfg_blk config.load(arena, io, startup.environ_map) catch |err2| {130                    const msg = errors.error_info.getMessage() orelse errors.staticMessage(err2);131                    const detail = errors.error_info.getDetail();132                    const suggestion = errors.error_info.getSuggestion();133                    output.printError(msg, detail, suggestion);134                    output.flush();135                    std.process.exit(1);136                };137            }138            const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);139            const detail = errors.error_info.getDetail();140            const suggestion = errors.error_info.getSuggestion();141            output.printError(msg, detail, suggestion);142            output.flush();143            std.process.exit(1);144        }145    };146147    output.setTeach(cfg.teach);148149    const machine = config.resolveMachine(&cfg, args.machine) catch |err| {150        const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);151        const detail = errors.error_info.getDetail();152        const suggestion = errors.error_info.getSuggestion();153        output.printError(msg, detail, suggestion);154        output.flush();155        std.process.exit(1);156    };157158    const ctx = commands.Ctx{159        .gpa = ca,160        .io = io,161        .environ = startup.environ_map,162        .machine = machine,163        .cfg = cfg,164        .args = args.rest,165        .passthrough = args.passthrough,166        .sub = args.sub,167        .dry = args.dry,168        .check = args.check,169        .no_apply = args.no_apply,170        .all = args.all,171        .last = args.last,172        .json = args.json,173        .user = args.user,174    };175176    dispatch(ctx, args.command) catch |err| {177        reportError(err, ca);178        output.flush();179        std.process.exit(1);180    };181}182183// Network/ssh/search failures are the machine's fault, not the user's — surface184// them with the sad kaomoji. BuildFailed is routed through the 3-layer185// translation pipeline. Everything else uses a plain error line.186fn reportError(err: anyerror, allocator: std.mem.Allocator) void {187    switch (err) {188        // abort message already printed by output.aborted() — just exit non-zero189        error.HookAborted => {},190        // "config invalid" already printed by output.checkFailed() — just exit non-zero191        error.CheckFailed => {},192        error.BuildFailed => {193            const stderr = errors.getBuildStderr();194            const translated = errors.translateError(allocator, stderr);195            output.buildError(translated);196        },197        error.NetworkError, error.SshFailed, error.SearchFailed => {198            const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);199            const detail = errors.error_info.getDetail();200            const suggestion = errors.error_info.getSuggestion();201            output.printSystemError(msg, detail, suggestion);202        },203        else => {204            const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);205            const detail = errors.error_info.getDetail();206            const suggestion = errors.error_info.getSuggestion();207            output.printError(msg, detail, suggestion);208        },209    }210}211212fn dispatch(ctx: commands.Ctx, cmd: []const u8) !void {213    if (std.mem.eql(u8, cmd, "apply")) return commands.apply(ctx);214    if (std.mem.eql(u8, cmd, "check")) return commands.check(ctx);215    if (std.mem.eql(u8, cmd, "back")) return commands.back(ctx);216    if (std.mem.eql(u8, cmd, "go")) return commands.go(ctx);217    if (std.mem.eql(u8, cmd, "history")) return commands.history(ctx);218    if (std.mem.eql(u8, cmd, "gen")) return commands.genCmd(ctx);219    if (std.mem.eql(u8, cmd, "clean")) return commands.clean(ctx);220    if (std.mem.eql(u8, cmd, "status")) return commands.statusCmd(ctx);221    if (std.mem.eql(u8, cmd, "diff")) return commands.diff(ctx);222    if (std.mem.eql(u8, cmd, "doctor")) return commands.doctor(ctx);223    if (std.mem.eql(u8, cmd, "mood")) return commands.mood(ctx);224    if (std.mem.eql(u8, cmd, "update")) return commands.update(ctx);225    if (std.mem.eql(u8, cmd, "upgrade")) return commands.upgrade(ctx);226    if (std.mem.eql(u8, cmd, "log")) return commands.log(ctx);227    if (std.mem.eql(u8, cmd, "edit")) return commands.edit(ctx);228    if (std.mem.eql(u8, cmd, "fmt")) return commands.fmt(ctx);229    if (std.mem.eql(u8, cmd, "repl")) return commands.repl(ctx);230    if (std.mem.eql(u8, cmd, "develop")) return commands.develop(ctx);231    if (std.mem.eql(u8, cmd, "info")) return commands.info(ctx);232    if (std.mem.eql(u8, cmd, "boot")) return commands.boot(ctx);233    if (std.mem.eql(u8, cmd, "search")) return commands.searchCmd(ctx);234    if (std.mem.eql(u8, cmd, "option")) return commands.optionCmd(ctx);235    if (std.mem.eql(u8, cmd, "install")) return commands.install(ctx);236    if (std.mem.eql(u8, cmd, "remove")) return commands.remove(ctx);237    if (std.mem.eql(u8, cmd, "list")) return commands.list(ctx);238    if (std.mem.eql(u8, cmd, "try")) return commands.tryCmd(ctx);239    if (std.mem.eql(u8, cmd, "service")) return commands.service(ctx);240    if (std.mem.eql(u8, cmd, "flake")) return commands.flake(ctx);241    if (std.mem.eql(u8, cmd, "store")) return commands.store(ctx);242    if (std.mem.eql(u8, cmd, "channel")) return commands.channel(ctx);243    if (std.mem.eql(u8, cmd, "profile")) return commands.profile(ctx);244    if (std.mem.eql(u8, cmd, "pkg")) return commands.pkg(ctx);245    if (std.mem.eql(u8, cmd, "why")) return commands.why(ctx);246    if (std.mem.eql(u8, cmd, "pin")) return commands.pin(ctx);247    if (std.mem.eql(u8, cmd, "unpin")) return commands.unpin(ctx);248    if (std.mem.eql(u8, cmd, "hash")) return commands.hash(ctx);249    if (std.mem.eql(u8, cmd, "fetch")) return commands.fetch(ctx);250    if (std.mem.eql(u8, cmd, "build")) return commands.build(ctx);251    if (std.mem.eql(u8, cmd, "run")) return commands.runCmd(ctx);252    if (std.mem.eql(u8, cmd, "home")) return commands.home(ctx);253    if (std.mem.eql(u8, cmd, "goodbye")) return commands.goodbye(ctx);254    if (std.mem.eql(u8, cmd, "hello")) return commands.hello(ctx);255    if (std.mem.eql(u8, cmd, "help")) {256        output.help();257        return;258    }259260    errors.error_info.setMessage("unknown command: {s}", .{cmd});261    errors.error_info.setSuggestion("nina help", .{});262    return error.Internal;263}264265fn parseArgs(arena: std.mem.Allocator, argv: []const [:0]const u8) !Args {266    var result = Args{};267    var rest: std.ArrayList([]const u8) = .empty;268    var passthrough: std.ArrayList([]const u8) = .empty;269    var i: usize = 1; // skip program name270271    // First positional = command272    if (i < argv.len and argv[i][0] != '-') {273        result.command = argv[i];274        i += 1;275    }276277    // Second positional (if not a flag) = subcommand for multi-word commands278    if (i < argv.len and argv[i][0] != '-') {279        const multi_word = isMultiWord(result.command);280        if (multi_word) {281            result.sub = argv[i];282            i += 1;283        }284    }285286    // Once a literal `--` separator is seen, everything after it (and the287    // separator itself) is opaque data for the wrapped program — e.g. `nina run288    // hello -- --version` must forward `--version` to hello, not have nina steal289    // it as its own --version flag. Stop matching nina's own flags at that point.290    var past_separator = false;291    while (i < argv.len) : (i += 1) {292        const a = argv[i];293        if (past_separator) {294            try passthrough.append(arena, a);295            continue;296        }297        if (std.mem.eql(u8, a, "--")) {298            past_separator = true;299            try passthrough.append(arena, a);300            continue;301        }302        if (std.mem.eql(u8, a, "--on") or std.mem.eql(u8, a, "-on")) {303            i += 1;304            if (i < argv.len) result.machine = argv[i];305        } else if (std.mem.eql(u8, a, "--dry")) {306            result.dry = true;307        } else if (std.mem.eql(u8, a, "--check")) {308            result.check = true;309        } else if (std.mem.eql(u8, a, "--version")) {310            result.version = true;311        } else if (std.mem.eql(u8, a, "--no-apply")) {312            result.no_apply = true;313        } else if (std.mem.eql(u8, a, "--all")) {314            result.all = true;315        } else if (std.mem.eql(u8, a, "--user")) {316            result.user = true;317        } else if (std.mem.eql(u8, a, "--json")) {318            result.json = true;319        } else if (std.mem.eql(u8, a, "--last")) {320            i += 1;321            if (i < argv.len) result.last = std.fmt.parseInt(u32, argv[i], 10) catch 50;322        } else if (std.mem.eql(u8, a, "-f")) {323            // -f flag for service logs follow324            try rest.append(arena, "-f");325        } else if (std.mem.eql(u8, a, "--switch")) {326            // --switch flag for `home init` (activate after init)327            try rest.append(arena, "--switch");328        } else if (std.mem.eql(u8, a, "--dir")) {329            // --dir flag for `nina edit` (open the config directory)330            try rest.append(arena, "--dir");331        } else if (a.len > 0 and a[0] != '-') {332            try rest.append(arena, a);333        } else {334            // Unrecognized flag (e.g. --refresh) — forwarded verbatim to whatever335            // nix command the subcommand wraps, instead of being dropped silently.336            try passthrough.append(arena, a);337        }338    }339340    result.rest = try rest.toOwnedSlice(arena);341    result.passthrough = try passthrough.toOwnedSlice(arena);342    return result;343}344345fn isMultiWord(cmd: []const u8) bool {346    const multi = [_][]const u8{ "service", "flake", "store", "channel", "profile", "pkg", "gen", "develop", "edit", "home" };347    for (multi) |m| {348        if (std.mem.eql(u8, cmd, m)) return true;349    }350    return false;351}352353// Zig 0.16's test runner only discovers `test` blocks in files it can see are354// reachable from the root; refAllDecls forces every decl main.zig imports355// (transitively) to be referenced, which is what pulls in test blocks living356// in exec.zig, commands.zig, errors.zig, config.zig, etc.357test {358    std.testing.refAllDecls(@This());359}360361test "parse args basic" {362    const gpa = std.testing.allocator;363    const argv = [_][:0]const u8{ "nina", "apply", "--dry" };364    const args = try parseArgs(gpa, &argv);365    defer gpa.free(args.rest);366    try std.testing.expectEqualStrings("apply", args.command);367    try std.testing.expect(args.dry);368}369370test "parse args with machine" {371    const gpa = std.testing.allocator;372    const argv = [_][:0]const u8{ "nina", "status", "--on", "build-server" };373    const args = try parseArgs(gpa, &argv);374    defer gpa.free(args.rest);375    try std.testing.expectEqualStrings("status", args.command);376    try std.testing.expectEqualStrings("build-server", args.machine.?);377}378379test "parse args forwards unrecognized flags" {380    const gpa = std.testing.allocator;381    const argv = [_][:0]const u8{ "nina", "flake", "update", "--refresh" };382    const args = try parseArgs(gpa, &argv);383    defer gpa.free(args.rest);384    defer gpa.free(args.passthrough);385    try std.testing.expectEqualStrings("flake", args.command);386    try std.testing.expectEqualStrings("update", args.sub);387    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);388    try std.testing.expectEqualStrings("--refresh", args.passthrough[0]);389}390391test "parse args forwards apply --show-trace" {392    const gpa = std.testing.allocator;393    const argv = [_][:0]const u8{ "nina", "apply", "--show-trace" };394    const args = try parseArgs(gpa, &argv);395    defer gpa.free(args.rest);396    defer gpa.free(args.passthrough);397    try std.testing.expectEqualStrings("apply", args.command);398    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);399    try std.testing.expectEqualStrings("--show-trace", args.passthrough[0]);400}401402test "parse args forwards check --show-trace" {403    const gpa = std.testing.allocator;404    const argv = [_][:0]const u8{ "nina", "check", "--show-trace" };405    const args = try parseArgs(gpa, &argv);406    defer gpa.free(args.rest);407    defer gpa.free(args.passthrough);408    try std.testing.expectEqualStrings("check", args.command);409    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);410    try std.testing.expectEqualStrings("--show-trace", args.passthrough[0]);411}412413test "parse args forwards flake check --all-systems" {414    const gpa = std.testing.allocator;415    const argv = [_][:0]const u8{ "nina", "flake", "check", "--all-systems" };416    const args = try parseArgs(gpa, &argv);417    defer gpa.free(args.rest);418    defer gpa.free(args.passthrough);419    try std.testing.expectEqualStrings("flake", args.command);420    try std.testing.expectEqualStrings("check", args.sub);421    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);422    try std.testing.expectEqualStrings("--all-systems", args.passthrough[0]);423}424425test "parse args forwards build --print-build-logs" {426    const gpa = std.testing.allocator;427    const argv = [_][:0]const u8{ "nina", "build", "--print-build-logs" };428    const args = try parseArgs(gpa, &argv);429    defer gpa.free(args.rest);430    defer gpa.free(args.passthrough);431    try std.testing.expectEqualStrings("build", args.command);432    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);433    try std.testing.expectEqualStrings("--print-build-logs", args.passthrough[0]);434}435436test "parse args forwards develop --show-trace" {437    const gpa = std.testing.allocator;438    const argv = [_][:0]const u8{ "nina", "develop", "--show-trace" };439    const args = try parseArgs(gpa, &argv);440    defer gpa.free(args.rest);441    defer gpa.free(args.passthrough);442    try std.testing.expectEqualStrings("develop", args.command);443    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);444    try std.testing.expectEqualStrings("--show-trace", args.passthrough[0]);445}446447test "parse args forwards run's -- separator and program args" {448    const gpa = std.testing.allocator;449    const argv = [_][:0]const u8{ "nina", "run", "hello", "--", "--version" };450    const args = try parseArgs(gpa, &argv);451    defer gpa.free(args.rest);452    defer gpa.free(args.passthrough);453    try std.testing.expectEqualStrings("run", args.command);454    try std.testing.expectEqual(@as(usize, 1), args.rest.len);455    try std.testing.expectEqualStrings("hello", args.rest[0]);456    try std.testing.expectEqual(@as(usize, 2), args.passthrough.len);457    try std.testing.expectEqualStrings("--", args.passthrough[0]);458    try std.testing.expectEqualStrings("--version", args.passthrough[1]);459}460461test "parse args forwards home apply --show-trace" {462    const gpa = std.testing.allocator;463    const argv = [_][:0]const u8{ "nina", "home", "apply", "--show-trace" };464    const args = try parseArgs(gpa, &argv);465    defer gpa.free(args.rest);466    defer gpa.free(args.passthrough);467    try std.testing.expectEqualStrings("home", args.command);468    try std.testing.expectEqualStrings("apply", args.sub);469    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);470    try std.testing.expectEqualStrings("--show-trace", args.passthrough[0]);471}472473test "parse subcommand" {474    const gpa = std.testing.allocator;475    const argv = [_][:0]const u8{ "nina", "service", "list" };476    const args = try parseArgs(gpa, &argv);477    defer gpa.free(args.rest);478    try std.testing.expectEqualStrings("service", args.command);479    try std.testing.expectEqualStrings("list", args.sub);480}481482test "parse version flag" {483    const gpa = std.testing.allocator;484    const argv = [_][:0]const u8{ "nina", "--version" };485    const args = try parseArgs(gpa, &argv);486    defer gpa.free(args.rest);487    try std.testing.expect(args.version);488}