config.zig

11.1 KB · raw

1const std = @import("std");2const types = @import("types.zig");3const errors = @import("errors.zig");4const exec = @import("exec.zig");5const output = @import("output.zig");67// Resolve the config file path: $XDG_CONFIG_HOME/nina/config, falling back to8// ~/.config/nina/config. nina historically stored ~/.nina.conf; that legacy9// location is auto-migrated to this path on load (see migrateLegacyConfig).10pub fn configPath(arena: std.mem.Allocator, environ: *const std.process.Environ.Map) ![]const u8 {11    if (environ.get("XDG_CONFIG_HOME")) |xdg| {12        if (xdg.len > 0) return std.fs.path.join(arena, &.{ xdg, "nina", "config" });13    }14    const home = environ.get("HOME") orelse {15        errors.error_info.setSuggestion("set HOME environment variable", .{});16        return error.ConfigNotFound;17    };18    return std.fs.path.join(arena, &.{ home, ".config", "nina", "config" });19}2021fn legacyPath(arena: std.mem.Allocator, environ: *const std.process.Environ.Map) ?[]const u8 {22    const home = environ.get("HOME") orelse return null;23    return std.fmt.allocPrint(arena, "{s}/.nina.conf", .{home}) catch null;24}2526fn fileExists(io: std.Io, path: []const u8) bool {27    const f = std.Io.Dir.openFile(.cwd(), io, path, .{}) catch return false;28    f.close(io);29    return true;30}3132// One-time migration of the legacy ~/.nina.conf to the XDG path. Runs only when33// the old file exists and the new one does not, so it's idempotent and a no-op34// for fresh installs. Best-effort: any failure leaves the legacy file in place35// so the next load still finds it.36pub fn migrateLegacyConfig(arena: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) void {37    const new_path = configPath(arena, environ) catch return;38    if (fileExists(io, new_path)) return;39    const legacy = legacyPath(arena, environ) orelse return;40    const content = std.Io.Dir.readFileAlloc(.cwd(), io, legacy, arena, .unlimited) catch return;4142    if (std.fs.path.dirname(new_path)) |dir| exec.ensureDir(arena, io, dir);43    const f = std.Io.Dir.createFile(.cwd(), io, new_path, .{}) catch return;44    f.writePositionalAll(io, content, 0) catch {45        f.close(io);46        return;47    };48    f.close(io);49    std.Io.Dir.deleteFile(.cwd(), io, legacy) catch {};50    output.configMigrated(homeShort(arena, new_path, environ));51}5253// Collapse a leading $HOME to ~ for display only. Returns the input unchanged on54// any mismatch or allocation failure.55fn homeShort(arena: std.mem.Allocator, path: []const u8, environ: *const std.process.Environ.Map) []const u8 {56    const home = environ.get("HOME") orelse return path;57    if (home.len == 0 or !std.mem.startsWith(u8, path, home)) return path;58    return std.fmt.allocPrint(arena, "~{s}", .{path[home.len..]}) catch path;59}6061pub fn load(arena: std.mem.Allocator, io: std.Io, environ: *const std.process.Environ.Map) !types.NinaConfig {62    migrateLegacyConfig(arena, io, environ);6364    const path = try configPath(arena, environ);65    const home = environ.get("HOME") orelse "";6667    const content = std.Io.Dir.readFileAlloc(.cwd(), io, path, arena, .unlimited) catch {68        errors.error_info.setSuggestion("run nina to set up", .{});69        return error.ConfigNotFound;70    };71    const cfg = try parse(arena, content, home);72    if (environ.contains("NINA_DEBUG")) debugDumpConfig(cfg);73    return cfg;74}7576fn parse(arena: std.mem.Allocator, input: []const u8, home: []const u8) !types.NinaConfig {77    var config = types.NinaConfig{};78    var machines: std.ArrayList(types.Machine) = .empty;79    var current: ?types.Machine = null;8081    var lines = std.mem.splitScalar(u8, input, '\n');82    while (lines.next()) |line| {83        const trimmed = std.mem.trim(u8, line, " \t\r");84        if (trimmed.len == 0 or trimmed[0] == '#') continue;8586        if (std.mem.eql(u8, trimmed, "[machine]")) {87            if (current) |m| try machines.append(arena, m);88            current = .{};89            continue;90        }9192        const eq = std.mem.indexOfScalar(u8, trimmed, '=') orelse continue;93        const key = std.mem.trim(u8, trimmed[0..eq], " \t");94        const val = std.mem.trim(u8, trimmed[eq + 1 ..], " \t");9596        if (current) |*m| {97            setMachineField(arena, m, key, val, home) catch {};98        } else {99            setConfigField(arena, &config, key, val) catch {};100        }101    }102103    if (current) |m| try machines.append(arena, m);104    config.machines = try machines.toOwnedSlice(arena);105    return config;106}107108fn setConfigField(arena: std.mem.Allocator, config: *types.NinaConfig, key: []const u8, val: []const u8) !void {109    if (std.mem.eql(u8, key, "editor")) {110        config.editor = try arena.dupe(u8, val);111    } else if (std.mem.eql(u8, key, "generations")) {112        config.generations = std.fmt.parseInt(u32, val, 10) catch 5;113    } else if (std.mem.eql(u8, key, "confirm")) {114        config.confirm = parseBool(val, true);115    } else if (std.mem.eql(u8, key, "teach")) {116        config.teach = parseBool(val, false);117    } else if (std.mem.eql(u8, key, "color")) {118        config.color = parseBool(val, true);119    } else if (std.mem.eql(u8, key, "flake")) {120        config.flake = parseBool(val, false);121    }122}123124fn setMachineField(arena: std.mem.Allocator, m: *types.Machine, key: []const u8, val: []const u8, home: []const u8) !void {125    if (std.mem.eql(u8, key, "name")) {126        m.name = try arena.dupe(u8, val);127    } else if (std.mem.eql(u8, key, "config")) {128        m.config_path = try arena.dupe(u8, val);129    } else if (std.mem.eql(u8, key, "local")) {130        m.local = parseBool(val, true);131    } else if (std.mem.eql(u8, key, "default")) {132        m.default = parseBool(val, false);133    } else if (std.mem.eql(u8, key, "host")) {134        m.host = try arena.dupe(u8, val);135        // A machine with a host is remote unless explicitly marked local. The136        // Machine struct defaults local=true, so without this a [machine] block137        // that only sets host/user/ssh_key would render as "local".138        m.local = false;139    } else if (std.mem.eql(u8, key, "user")) {140        m.user = try arena.dupe(u8, val);141    } else if (std.mem.eql(u8, key, "ssh_key")) {142        m.ssh_key = try expandHome(arena, val, home);143    }144}145146fn parseBool(val: []const u8, default: bool) bool {147    if (std.mem.eql(u8, val, "true") or std.mem.eql(u8, val, "yes") or std.mem.eql(u8, val, "y") or std.mem.eql(u8, val, "on") or std.mem.eql(u8, val, "1")) {148        return true;149    }150    if (std.mem.eql(u8, val, "false") or std.mem.eql(u8, val, "no") or std.mem.eql(u8, val, "n") or std.mem.eql(u8, val, "off") or std.mem.eql(u8, val, "0")) {151        return false;152    }153    return default;154}155156fn debugDumpConfig(cfg: types.NinaConfig) void {157    std.debug.print("NINA_DEBUG config\n", .{});158    std.debug.print("editor={s}\n", .{cfg.editor});159    std.debug.print("generations={d}\n", .{cfg.generations});160    std.debug.print("confirm={}\n", .{cfg.confirm});161    std.debug.print("teach={}\n", .{cfg.teach});162    std.debug.print("color={}\n", .{cfg.color});163    std.debug.print("flake={}\n", .{cfg.flake});164    for (cfg.machines, 0..) |m, i| {165        std.debug.print("machine[{d}].name={s}\n", .{ i, m.name });166        std.debug.print("machine[{d}].config={s}\n", .{ i, m.config_path });167        std.debug.print("machine[{d}].local={}\n", .{ i, m.local });168        std.debug.print("machine[{d}].default={}\n", .{ i, m.default });169        if (m.host) |host| std.debug.print("machine[{d}].host={s}\n", .{ i, host });170        if (m.user) |user| std.debug.print("machine[{d}].user={s}\n", .{ i, user });171    }172}173174fn expandHome(arena: std.mem.Allocator, path: []const u8, home: []const u8) ![]const u8 {175    if (!std.mem.startsWith(u8, path, "~")) return arena.dupe(u8, path);176    return std.fmt.allocPrint(arena, "{s}{s}", .{ home, path[1..] });177}178179pub fn resolveMachine(config: *const types.NinaConfig, name: ?[]const u8) !types.Machine {180    if (name) |n| {181        for (config.machines) |m| {182            if (std.mem.eql(u8, m.name, n)) return m;183        }184        errors.error_info.setMessage("'{s}' is not in your config", .{n});185        errors.error_info.setSuggestion("check ~/.config/nina/config", .{});186        return error.MachineNotFound;187    }188    for (config.machines) |m| {189        if (m.default) return m;190    }191    if (config.machines.len > 0) return config.machines[0];192    // No machines defined — return a default local machine193    return .{};194}195196test "parse basic config" {197    const input =198        \\editor = hx199        \\generations = 3200        \\confirm = false201        \\teach = true202        \\203        \\[machine]204        \\name = test-box205        \\local = true206        \\default = true207    ;208    const config = try parse(std.testing.allocator, input, "/home/test");209    defer std.testing.allocator.free(config.machines);210    defer std.testing.allocator.free(config.editor);211    defer std.testing.allocator.free(config.machines[0].name);212213    try std.testing.expectEqualStrings("hx", config.editor);214    try std.testing.expectEqual(@as(u32, 3), config.generations);215    try std.testing.expect(!config.confirm);216    try std.testing.expect(config.teach);217    try std.testing.expectEqual(@as(usize, 1), config.machines.len);218    try std.testing.expectEqualStrings("test-box", config.machines[0].name);219}220221test "parse alternate booleans" {222    const input =223        \\confirm = y224        \\teach = no225        \\color = 1226        \\227        \\[machine]228        \\name = test-box229        \\local = n230        \\default = on231    ;232    const config = try parse(std.testing.allocator, input, "/home/test");233    defer std.testing.allocator.free(config.machines);234    defer std.testing.allocator.free(config.machines[0].name);235236    try std.testing.expect(config.confirm);237    try std.testing.expect(!config.teach);238    try std.testing.expect(config.color);239    try std.testing.expect(!config.machines[0].local);240    try std.testing.expect(config.machines[0].default);241}242243test "parse flake flag" {244    const with =245        \\flake = true246        \\247        \\[machine]248        \\name = box249    ;250    const cfg_with = try parse(std.testing.allocator, with, "/home/test");251    defer std.testing.allocator.free(cfg_with.machines);252    defer std.testing.allocator.free(cfg_with.machines[0].name);253    try std.testing.expect(cfg_with.flake);254255    const without =256        \\[machine]257        \\name = box258    ;259    const cfg_without = try parse(std.testing.allocator, without, "/home/test");260    defer std.testing.allocator.free(cfg_without.machines);261    defer std.testing.allocator.free(cfg_without.machines[0].name);262    try std.testing.expect(!cfg_without.flake);263}264265test "resolve machine by name" {266    var machines = [_]types.Machine{267        .{ .name = "alpha", .default = true },268        .{ .name = "beta" },269    };270    const config = types.NinaConfig{ .machines = &machines };271    const m = try resolveMachine(&config, "beta");272    try std.testing.expectEqualStrings("beta", m.name);273}274275test "resolve default machine" {276    var machines = [_]types.Machine{277        .{ .name = "alpha", .default = true },278        .{ .name = "beta" },279    };280    const config = types.NinaConfig{ .machines = &machines };281    const m = try resolveMachine(&config, null);282    try std.testing.expectEqualStrings("alpha", m.name);283}