main.zig

21.3 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    yes: bool = false, // bypass output.confirm() for scripted/automated invocations28};2930pub fn main(startup: std.process.Init) !void {31    const arena = startup.arena.allocator();32    const gpa = startup.gpa;33    const io = startup.io;3435    // Command allocations go through an arena freed when main returns. nina is a36    // one-shot CLI that exits after one command, and std.process.exit skips37    // defers, so per-allocation frees were easy to miss — the arena guarantees38    // everything is released in one shot, eliminating exit-time leak reports.39    var cmd_arena = std.heap.ArenaAllocator.init(gpa);40    defer cmd_arena.deinit();41    const ca = cmd_arena.allocator();4243    // Initialize output module with buffered stdout writer. Streaming (not the44    // default positional) mode: stdout may be a terminal, pipe, or a redirected45    // regular file. Positional mode pwrites from offset 0 and ignores the fd's46    // inherited offset, which corrupts `{ echo h; nina x; } > f` and `>>` append47    // by overwriting from the start. Streaming honors the current file offset.48    var stdout_buf: [16384]u8 = undefined;49    var stdout_fw: Io.File.Writer = .initStreaming(.stdout(), io, &stdout_buf);50    output.init(&stdout_fw.interface, io, startup.environ_map, true);51    defer output.flush();5253    const argv = try startup.minimal.args.toSlice(arena);54    const args = try parseArgs(arena, argv);5556    // --yes bypasses output.confirm()'s prompts for legitimate automation (CI,57    // hooks, cron). Without it, confirm() now refuses on a closed/non-interactive58    // stdin instead of silently assuming yes (NINA-005).59    if (args.yes) output.setAutoYes(true);6061    if (args.version) {62        output.version();63        return;64    }6566    // Help and hello work without config67    if (args.command.len == 0 or std.mem.eql(u8, args.command, "help")) {68        output.help();69        return;70    }7172    // man works without a config — it's pure docs, no machine context needed.73    if (std.mem.eql(u8, args.command, "man")) {74        const topic: []const u8 = if (args.rest.len > 0) args.rest[0] else "";75        man.run(io, topic) catch {};76        return;77    }7879    // setup and update run before config so they work without ~/.nina.conf80    if (std.mem.eql(u8, args.command, "setup") or std.mem.eql(u8, args.command, "update")) {81        const dummy_machine: types.Machine = .{ .name = "local", .local = true };82        const dummy_cfg: types.NinaConfig = .{};83        const pre_ctx = commands.Ctx{84            .gpa = ca,85            .io = io,86            .environ = startup.environ_map,87            .machine = dummy_machine,88            .cfg = dummy_cfg,89            .args = args.rest,90            .passthrough = args.passthrough,91            .sub = args.sub,92            .dry = args.dry,93            .check = args.check,94            .no_apply = args.no_apply,95            .all = args.all,96            .last = args.last,97            .json = args.json,98            .user = args.user,99        };100        if (std.mem.eql(u8, args.command, "setup")) {101            commands.setup(ca, io, startup.environ_map) catch |err| {102                const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);103                const detail = errors.error_info.getDetail();104                const suggestion = errors.error_info.getSuggestion();105                output.printError(msg, detail, suggestion);106                output.flush();107                std.process.exit(1);108            };109        } else {110            dispatch(pre_ctx, args.command) catch |err| {111                reportError(err, ca);112                output.flush();113                std.process.exit(1);114            };115        }116        return;117    }118119    // Load config and resolve machine. On first run with no config file, offer120    // the setup wizard when stdout is a TTY; skip silently in piped/scripted mode.121    const cfg = cfg_blk: {122        if (config.load(arena, io, startup.environ_map)) |c| {123            break :cfg_blk c;124        } else |err| {125            if (err == error.ConfigNotFound and (std.Io.File.stdout().isTty(io) catch false)) {126                commands.firstRunWizard(ca, io, startup.environ_map) catch |wizard_err| {127                    const msg = errors.error_info.getMessage() orelse errors.staticMessage(wizard_err);128                    const detail = errors.error_info.getDetail();129                    const suggestion = errors.error_info.getSuggestion();130                    output.printError(msg, detail, suggestion);131                    output.flush();132                    std.process.exit(1);133                };134                errors.error_info.reset();135                break :cfg_blk config.load(arena, io, startup.environ_map) catch |err2| {136                    const msg = errors.error_info.getMessage() orelse errors.staticMessage(err2);137                    const detail = errors.error_info.getDetail();138                    const suggestion = errors.error_info.getSuggestion();139                    output.printError(msg, detail, suggestion);140                    output.flush();141                    std.process.exit(1);142                };143            }144            const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);145            const detail = errors.error_info.getDetail();146            const suggestion = errors.error_info.getSuggestion();147            output.printError(msg, detail, suggestion);148            output.flush();149            std.process.exit(1);150        }151    };152153    output.setTeach(cfg.teach);154155    const machine = config.resolveMachine(&cfg, args.machine) catch |err| {156        const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);157        const detail = errors.error_info.getDetail();158        const suggestion = errors.error_info.getSuggestion();159        output.printError(msg, detail, suggestion);160        output.flush();161        std.process.exit(1);162    };163164    const ctx = commands.Ctx{165        .gpa = ca,166        .io = io,167        .environ = startup.environ_map,168        .machine = machine,169        .cfg = cfg,170        .args = args.rest,171        .passthrough = args.passthrough,172        .sub = args.sub,173        .dry = args.dry,174        .check = args.check,175        .no_apply = args.no_apply,176        .all = args.all,177        .last = args.last,178        .json = args.json,179        .user = args.user,180    };181182    dispatch(ctx, args.command) catch |err| {183        reportError(err, ca);184        output.flush();185        std.process.exit(1);186    };187}188189// Network/ssh/search failures are the machine's fault, not the user's — surface190// them with the sad kaomoji. BuildFailed is routed through the 3-layer191// translation pipeline. Everything else uses a plain error line.192fn reportError(err: anyerror, allocator: std.mem.Allocator) void {193    switch (err) {194        // abort message already printed by output.aborted() — just exit non-zero195        error.HookAborted => {},196        // "config invalid" already printed by output.checkFailed() — just exit non-zero197        error.CheckFailed => {},198        error.BuildFailed => {199            const stderr = errors.getBuildStderr();200            const translated = errors.translateError(allocator, stderr);201            output.buildError(translated);202        },203        error.NetworkError, error.SshFailed, error.SearchFailed => {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.printSystemError(msg, detail, suggestion);208        },209        else => {210            const msg = errors.error_info.getMessage() orelse errors.staticMessage(err);211            const detail = errors.error_info.getDetail();212            const suggestion = errors.error_info.getSuggestion();213            output.printError(msg, detail, suggestion);214        },215    }216}217218fn dispatch(ctx: commands.Ctx, cmd: []const u8) !void {219    if (std.mem.eql(u8, cmd, "apply")) return commands.apply(ctx);220    if (std.mem.eql(u8, cmd, "check")) return commands.check(ctx);221    if (std.mem.eql(u8, cmd, "back")) return commands.back(ctx);222    if (std.mem.eql(u8, cmd, "go")) return commands.go(ctx);223    if (std.mem.eql(u8, cmd, "history")) return commands.history(ctx);224    if (std.mem.eql(u8, cmd, "gen")) return commands.genCmd(ctx);225    if (std.mem.eql(u8, cmd, "clean")) return commands.clean(ctx);226    if (std.mem.eql(u8, cmd, "status")) return commands.statusCmd(ctx);227    if (std.mem.eql(u8, cmd, "diff")) return commands.diff(ctx);228    if (std.mem.eql(u8, cmd, "doctor")) return commands.doctor(ctx);229    if (std.mem.eql(u8, cmd, "mood")) return commands.mood(ctx);230    if (std.mem.eql(u8, cmd, "update")) return commands.update(ctx);231    if (std.mem.eql(u8, cmd, "upgrade")) return commands.upgrade(ctx);232    if (std.mem.eql(u8, cmd, "log")) return commands.log(ctx);233    if (std.mem.eql(u8, cmd, "edit")) return commands.edit(ctx);234    if (std.mem.eql(u8, cmd, "fmt")) return commands.fmt(ctx);235    if (std.mem.eql(u8, cmd, "repl")) return commands.repl(ctx);236    if (std.mem.eql(u8, cmd, "develop")) return commands.develop(ctx);237    if (std.mem.eql(u8, cmd, "info")) return commands.info(ctx);238    if (std.mem.eql(u8, cmd, "boot")) return commands.boot(ctx);239    if (std.mem.eql(u8, cmd, "search")) return commands.searchCmd(ctx);240    if (std.mem.eql(u8, cmd, "option")) return commands.optionCmd(ctx);241    if (std.mem.eql(u8, cmd, "install")) return commands.install(ctx);242    if (std.mem.eql(u8, cmd, "remove")) return commands.remove(ctx);243    if (std.mem.eql(u8, cmd, "list")) return commands.list(ctx);244    if (std.mem.eql(u8, cmd, "try")) return commands.tryCmd(ctx);245    if (std.mem.eql(u8, cmd, "service")) return commands.service(ctx);246    if (std.mem.eql(u8, cmd, "flake")) return commands.flake(ctx);247    if (std.mem.eql(u8, cmd, "store")) return commands.store(ctx);248    if (std.mem.eql(u8, cmd, "channel")) return commands.channel(ctx);249    if (std.mem.eql(u8, cmd, "profile")) return commands.profile(ctx);250    if (std.mem.eql(u8, cmd, "pkg")) return commands.pkg(ctx);251    if (std.mem.eql(u8, cmd, "why")) return commands.why(ctx);252    if (std.mem.eql(u8, cmd, "pin")) return commands.pin(ctx);253    if (std.mem.eql(u8, cmd, "unpin")) return commands.unpin(ctx);254    if (std.mem.eql(u8, cmd, "hash")) return commands.hash(ctx);255    if (std.mem.eql(u8, cmd, "fetch")) return commands.fetch(ctx);256    if (std.mem.eql(u8, cmd, "build")) return commands.build(ctx);257    if (std.mem.eql(u8, cmd, "run")) return commands.runCmd(ctx);258    if (std.mem.eql(u8, cmd, "home")) return commands.home(ctx);259    if (std.mem.eql(u8, cmd, "goodbye")) return commands.goodbye(ctx);260    if (std.mem.eql(u8, cmd, "hello")) return commands.hello(ctx);261    if (std.mem.eql(u8, cmd, "help")) {262        output.help();263        return;264    }265266    errors.error_info.setMessage("unknown command: {s}", .{cmd});267    errors.error_info.setSuggestion("nina help", .{});268    return error.Internal;269}270271fn parseArgs(arena: std.mem.Allocator, argv: []const [:0]const u8) !Args {272    var result = Args{};273    var rest: std.ArrayList([]const u8) = .empty;274    var passthrough: std.ArrayList([]const u8) = .empty;275    var i: usize = 1; // skip program name276277    // First positional = command278    if (i < argv.len and argv[i][0] != '-') {279        result.command = argv[i];280        i += 1;281    }282283    // Second positional (if not a flag) = subcommand for multi-word commands284    if (i < argv.len and argv[i][0] != '-') {285        const multi_word = isMultiWord(result.command);286        if (multi_word) {287            result.sub = argv[i];288            i += 1;289        }290    }291292    // Once a literal `--` separator is seen, everything after it (and the293    // separator itself) is opaque data for the wrapped program — e.g. `nina run294    // hello -- --version` must forward `--version` to hello, not have nina steal295    // it as its own --version flag. Stop matching nina's own flags at that point.296    var past_separator = false;297    while (i < argv.len) : (i += 1) {298        const a = argv[i];299        if (past_separator) {300            try passthrough.append(arena, a);301            continue;302        }303        if (std.mem.eql(u8, a, "--")) {304            past_separator = true;305            try passthrough.append(arena, a);306            continue;307        }308        if (std.mem.eql(u8, a, "--on") or std.mem.eql(u8, a, "-on")) {309            i += 1;310            if (i < argv.len) result.machine = argv[i];311        } else if (std.mem.eql(u8, a, "--dry")) {312            result.dry = true;313        } else if (std.mem.eql(u8, a, "--check")) {314            result.check = true;315        } else if (std.mem.eql(u8, a, "--version")) {316            result.version = true;317        } else if (std.mem.eql(u8, a, "--no-apply")) {318            result.no_apply = true;319        } else if (std.mem.eql(u8, a, "--all")) {320            result.all = true;321        } else if (std.mem.eql(u8, a, "--user")) {322            result.user = true;323        } else if (std.mem.eql(u8, a, "--json")) {324            result.json = true;325        } else if (std.mem.eql(u8, a, "--yes")) {326            result.yes = true;327        } else if (std.mem.eql(u8, a, "--last")) {328            i += 1;329            if (i < argv.len) result.last = std.fmt.parseInt(u32, argv[i], 10) catch 50;330        } else if (std.mem.eql(u8, a, "-f")) {331            // -f flag for service logs follow332            try rest.append(arena, "-f");333        } else if (std.mem.eql(u8, a, "--switch")) {334            // --switch flag for `home init` (activate after init)335            try rest.append(arena, "--switch");336        } else if (std.mem.eql(u8, a, "--dir")) {337            // --dir flag for `nina edit` (open the config directory)338            try rest.append(arena, "--dir");339        } else if (a.len > 0 and a[0] != '-') {340            try rest.append(arena, a);341        } else {342            // Unrecognized flag (e.g. --refresh) — forwarded verbatim to whatever343            // nix command the subcommand wraps, instead of being dropped silently.344            try passthrough.append(arena, a);345        }346    }347348    result.rest = try rest.toOwnedSlice(arena);349    result.passthrough = try passthrough.toOwnedSlice(arena);350    return result;351}352353fn isMultiWord(cmd: []const u8) bool {354    const multi = [_][]const u8{ "service", "flake", "store", "channel", "profile", "pkg", "gen", "develop", "edit", "home" };355    for (multi) |m| {356        if (std.mem.eql(u8, cmd, m)) return true;357    }358    return false;359}360361// Zig 0.16's test runner only discovers `test` blocks in files it can see are362// reachable from the root; refAllDecls forces every decl main.zig imports363// (transitively) to be referenced, which is what pulls in test blocks living364// in exec.zig, commands.zig, errors.zig, config.zig, etc.365test {366    std.testing.refAllDecls(@This());367}368369test "parse args basic" {370    const gpa = std.testing.allocator;371    const argv = [_][:0]const u8{ "nina", "apply", "--dry" };372    const args = try parseArgs(gpa, &argv);373    defer gpa.free(args.rest);374    try std.testing.expectEqualStrings("apply", args.command);375    try std.testing.expect(args.dry);376}377378test "parse args with machine" {379    const gpa = std.testing.allocator;380    const argv = [_][:0]const u8{ "nina", "status", "--on", "build-server" };381    const args = try parseArgs(gpa, &argv);382    defer gpa.free(args.rest);383    try std.testing.expectEqualStrings("status", args.command);384    try std.testing.expectEqualStrings("build-server", args.machine.?);385}386387test "parse args forwards unrecognized flags" {388    const gpa = std.testing.allocator;389    const argv = [_][:0]const u8{ "nina", "flake", "update", "--refresh" };390    const args = try parseArgs(gpa, &argv);391    defer gpa.free(args.rest);392    defer gpa.free(args.passthrough);393    try std.testing.expectEqualStrings("flake", args.command);394    try std.testing.expectEqualStrings("update", args.sub);395    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);396    try std.testing.expectEqualStrings("--refresh", args.passthrough[0]);397}398399test "parse args forwards apply --show-trace" {400    const gpa = std.testing.allocator;401    const argv = [_][:0]const u8{ "nina", "apply", "--show-trace" };402    const args = try parseArgs(gpa, &argv);403    defer gpa.free(args.rest);404    defer gpa.free(args.passthrough);405    try std.testing.expectEqualStrings("apply", args.command);406    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);407    try std.testing.expectEqualStrings("--show-trace", args.passthrough[0]);408}409410test "parse args forwards check --show-trace" {411    const gpa = std.testing.allocator;412    const argv = [_][:0]const u8{ "nina", "check", "--show-trace" };413    const args = try parseArgs(gpa, &argv);414    defer gpa.free(args.rest);415    defer gpa.free(args.passthrough);416    try std.testing.expectEqualStrings("check", args.command);417    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);418    try std.testing.expectEqualStrings("--show-trace", args.passthrough[0]);419}420421test "parse args forwards flake check --all-systems" {422    const gpa = std.testing.allocator;423    const argv = [_][:0]const u8{ "nina", "flake", "check", "--all-systems" };424    const args = try parseArgs(gpa, &argv);425    defer gpa.free(args.rest);426    defer gpa.free(args.passthrough);427    try std.testing.expectEqualStrings("flake", args.command);428    try std.testing.expectEqualStrings("check", args.sub);429    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);430    try std.testing.expectEqualStrings("--all-systems", args.passthrough[0]);431}432433test "parse args forwards build --print-build-logs" {434    const gpa = std.testing.allocator;435    const argv = [_][:0]const u8{ "nina", "build", "--print-build-logs" };436    const args = try parseArgs(gpa, &argv);437    defer gpa.free(args.rest);438    defer gpa.free(args.passthrough);439    try std.testing.expectEqualStrings("build", args.command);440    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);441    try std.testing.expectEqualStrings("--print-build-logs", args.passthrough[0]);442}443444test "parse args forwards develop --show-trace" {445    const gpa = std.testing.allocator;446    const argv = [_][:0]const u8{ "nina", "develop", "--show-trace" };447    const args = try parseArgs(gpa, &argv);448    defer gpa.free(args.rest);449    defer gpa.free(args.passthrough);450    try std.testing.expectEqualStrings("develop", args.command);451    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);452    try std.testing.expectEqualStrings("--show-trace", args.passthrough[0]);453}454455test "parse args forwards run's -- separator and program args" {456    const gpa = std.testing.allocator;457    const argv = [_][:0]const u8{ "nina", "run", "hello", "--", "--version" };458    const args = try parseArgs(gpa, &argv);459    defer gpa.free(args.rest);460    defer gpa.free(args.passthrough);461    try std.testing.expectEqualStrings("run", args.command);462    try std.testing.expectEqual(@as(usize, 1), args.rest.len);463    try std.testing.expectEqualStrings("hello", args.rest[0]);464    try std.testing.expectEqual(@as(usize, 2), args.passthrough.len);465    try std.testing.expectEqualStrings("--", args.passthrough[0]);466    try std.testing.expectEqualStrings("--version", args.passthrough[1]);467}468469test "parse args forwards home apply --show-trace" {470    const gpa = std.testing.allocator;471    const argv = [_][:0]const u8{ "nina", "home", "apply", "--show-trace" };472    const args = try parseArgs(gpa, &argv);473    defer gpa.free(args.rest);474    defer gpa.free(args.passthrough);475    try std.testing.expectEqualStrings("home", args.command);476    try std.testing.expectEqualStrings("apply", args.sub);477    try std.testing.expectEqual(@as(usize, 1), args.passthrough.len);478    try std.testing.expectEqualStrings("--show-trace", args.passthrough[0]);479}480481test "parse subcommand" {482    const gpa = std.testing.allocator;483    const argv = [_][:0]const u8{ "nina", "service", "list" };484    const args = try parseArgs(gpa, &argv);485    defer gpa.free(args.rest);486    try std.testing.expectEqualStrings("service", args.command);487    try std.testing.expectEqualStrings("list", args.sub);488}489490test "parse args recognizes --yes" {491    const gpa = std.testing.allocator;492    const argv = [_][:0]const u8{ "nina", "clean", "--yes" };493    const args = try parseArgs(gpa, &argv);494    defer gpa.free(args.rest);495    defer gpa.free(args.passthrough);496    try std.testing.expectEqualStrings("clean", args.command);497    try std.testing.expect(args.yes);498    try std.testing.expectEqual(@as(usize, 0), args.passthrough.len);499}500501test "parse version flag" {502    const gpa = std.testing.allocator;503    const argv = [_][:0]const u8{ "nina", "--version" };504    const args = try parseArgs(gpa, &argv);505    defer gpa.free(args.rest);506    try std.testing.expect(args.version);507}