//! The command line, everything except the process shell around it.
//!
//! `main.zig` parses `argv`, dispatches and returns an exit code; every command
//! body lives here and takes its writers as parameters, so a test can capture
//! what an operator would see into a `std.Io.Writer.Allocating`.
//!
//! Exit codes (milestone-1 convention, extended):
//!
//! - `0` success;
//! - `1` a runtime failure — I/O, database, out of memory;
//! - `2` a configuration the operator can fix, or a `check` that found a
//!   problem;
//! - `64` a usage error.

const std = @import("std");
const Allocator = std.mem.Allocator;
const Writer = std.Io.Writer;
const Certificate = std.crypto.Certificate;
const tls = std.crypto.tls;

const app = @import("app.zig");
const config_export = @import("config/export.zig");
const faults = @import("config/faults.zig");
const import = @import("config/import.zig");
const loader = @import("config/loader.zig");
const model = @import("config/model.zig");
const validate = @import("config/validate.zig");
const cert_store = @import("server/cert_store.zig");
const db = @import("storage/db.zig");
const migrations = @import("storage/migrations.zig");
const querylog_schema = @import("storage/querylog_schema.zig");
const doh_client = @import("upstream/doh_client.zig");
const dot_client = @import("upstream/dot_client.zig");
const pool = @import("upstream/pool.zig");
const transport = @import("upstream/transport.zig");
const safe_url = @import("safe_url.zig");
const version = @import("version.zig");

pub const exit_ok: u8 = 0;
pub const exit_runtime: u8 = 1;
pub const exit_check: u8 = 2;
pub const exit_usage: u8 = 64;

pub const config_db_name = "config.db";
pub const querylog_db_name = "querylog.db";

// ---------------------------------------------------------------------------
// argument parsing (pure)
// ---------------------------------------------------------------------------

pub const Paths = struct {
    /// PLAN §3.13.
    data_dir: []const u8 = "/var/lib/nxdns",
};

/// Milestone-20 ruling 1: `--config` has no default path, and its presence is
/// the whole of the authority decision. Null means the database is authority —
/// for `run` that is today's appliance behaviour, for `check` it is the database
/// that gets graded. A file sitting at a well-known path that no flag names
/// changes nothing.
pub const CheckArgs = struct { paths: Paths = .{}, config: ?[]const u8 = null };
pub const ExportArgs = struct { paths: Paths = .{}, out: ?[]const u8 = null };

/// `allow_delete` is `--allow-delete`: it permits an import whose diff removes
/// declarative rows (ruling 6).
pub const ImportArgs = struct { paths: Paths = .{}, file: []const u8, allow_delete: bool = false };

/// `config` names the managed configuration file and, by being present at all,
/// makes that file the sole declarative source of truth: it is read, validated
/// and reconciled into the database on every start.
///
/// `admin_dev` is milestone-8 ruling 24's `--admin-dev <dir>`: serve the web
/// interface from that directory instead of the embedded assets.
pub const RunArgs = struct {
    paths: Paths = .{},
    config: ?[]const u8 = null,
    admin_dev: ?[]const u8 = null,
};

pub const Command = union(enum) {
    run: RunArgs,
    check: CheckArgs,
    export_: ExportArgs,
    import_: ImportArgs,
    version,
    help,
};

/// The argv spelling of a command paired with its tag. The two differ for
/// `export` and `import`, whose tags carry a trailing underscore because both
/// words are Zig keywords.
pub const CommandName = struct { name: []const u8, tag: std.meta.Tag(Command) };

/// The one list of subcommands. `parseArgs` matches the command word against
/// it, `docs_drift_test.zig` derives its reference-heading needles from it, and
/// a test below holds `usage_text` to it.
pub const command_names = [_]CommandName{
    .{ .name = "run", .tag = .run },
    .{ .name = "check", .tag = .check },
    .{ .name = "export", .tag = .export_ },
    .{ .name = "import", .tag = .import_ },
    .{ .name = "version", .tag = .version },
    .{ .name = "help", .tag = .help },
};

comptime {
    // A tag added to `Command` without an entry here fails the compile rather
    // than silently dropping out of the parser and the doc guard.
    std.debug.assert(command_names.len == @typeInfo(Command).@"union".fields.len);
}

pub const ParseError = error{
    UnknownCommand,
    UnknownFlag,
    MissingValue,
    MissingArgument,
    TooManyArguments,
};

/// `argv` excludes the program name. Every slice in the result borrows from
/// `argv`.
pub fn parseArgs(argv: []const []const u8) ParseError!Command {
    if (argv.len == 0) return error.UnknownCommand;
    const command = argv[0];
    const rest = argv[1..];

    // The two flag spellings of `help` are not subcommands, so they are not in
    // `command_names` and are matched before it.
    if (eql(command, "--help") or eql(command, "-h")) {
        if (rest.len != 0) return error.TooManyArguments;
        return .help;
    }

    for (command_names) |entry| {
        if (!eql(command, entry.name)) continue;
        switch (entry.tag) {
            .run => return .{ .run = try parseRunArgs(rest) },
            .check => return .{ .check = try parseCheckArgs(rest) },
            .export_ => return .{ .export_ = try parseExportArgs(rest) },
            .import_ => return .{ .import_ = try parseImportArgs(rest) },
            .version => {
                if (rest.len != 0) return error.TooManyArguments;
                return .version;
            },
            .help => {
                if (rest.len != 0) return error.TooManyArguments;
                return .help;
            },
        }
    }
    return error.UnknownCommand;
}

const Flag = struct {
    /// Without the leading `--`.
    name: []const u8,
    /// The `value` of `--flag=value`, absent for `--flag`.
    attached: ?[]const u8,
};

fn splitFlag(arg: []const u8) ?Flag {
    if (!std.mem.startsWith(u8, arg, "--")) return null;
    const body = arg[2..];
    if (std.mem.findScalar(u8, body, '=')) |at| {
        return .{ .name = body[0..at], .attached = body[at + 1 ..] };
    }
    return .{ .name = body, .attached = null };
}

/// `--flag=value` and `--flag value` both work. An empty attached value is a
/// missing value, not an empty path.
fn flagValue(flag: Flag, argv: []const []const u8, i: *usize) ParseError![]const u8 {
    if (flag.attached) |attached| {
        if (attached.len == 0) return error.MissingValue;
        return attached;
    }
    if (i.* + 1 >= argv.len) return error.MissingValue;
    i.* += 1;
    return argv[i.*];
}

/// `run` takes `check`'s two flags plus `--admin-dev`, which only a process that
/// serves has any use for; `check` deliberately rejects it.
fn parseRunArgs(argv: []const []const u8) ParseError!RunArgs {
    var args: RunArgs = .{};
    var i: usize = 0;
    while (i < argv.len) : (i += 1) {
        const flag = splitFlag(argv[i]) orelse return error.TooManyArguments;
        if (eql(flag.name, "data-dir")) {
            args.paths.data_dir = try flagValue(flag, argv, &i);
        } else if (eql(flag.name, "config")) {
            args.config = try flagValue(flag, argv, &i);
        } else if (eql(flag.name, "admin-dev")) {
            args.admin_dev = try flagValue(flag, argv, &i);
        } else return error.UnknownFlag;
    }
    return args;
}

fn parseCheckArgs(argv: []const []const u8) ParseError!CheckArgs {
    var args: CheckArgs = .{};
    var i: usize = 0;
    while (i < argv.len) : (i += 1) {
        const flag = splitFlag(argv[i]) orelse return error.TooManyArguments;
        if (eql(flag.name, "data-dir")) {
            args.paths.data_dir = try flagValue(flag, argv, &i);
        } else if (eql(flag.name, "config")) {
            args.config = try flagValue(flag, argv, &i);
        } else return error.UnknownFlag;
    }
    return args;
}

fn parseExportArgs(argv: []const []const u8) ParseError!ExportArgs {
    var args: ExportArgs = .{};
    var i: usize = 0;
    while (i < argv.len) : (i += 1) {
        const flag = splitFlag(argv[i]) orelse return error.TooManyArguments;
        if (eql(flag.name, "data-dir")) {
            args.paths.data_dir = try flagValue(flag, argv, &i);
        } else if (eql(flag.name, "out")) {
            args.out = try flagValue(flag, argv, &i);
        } else return error.UnknownFlag;
    }
    return args;
}

fn parseImportArgs(argv: []const []const u8) ParseError!ImportArgs {
    var paths: Paths = .{};
    var allow_delete = false;
    var file: ?[]const u8 = null;

    var i: usize = 0;
    while (i < argv.len) : (i += 1) {
        const flag = splitFlag(argv[i]) orelse {
            if (file != null) return error.TooManyArguments;
            file = argv[i];
            continue;
        };
        if (eql(flag.name, "data-dir")) {
            paths.data_dir = try flagValue(flag, argv, &i);
        } else if (eql(flag.name, "allow-delete")) {
            // A boolean flag takes no value, so `--allow-delete=1` is not a
            // spelling of any flag this program has.
            if (flag.attached != null) return error.UnknownFlag;
            allow_delete = true;
        } else return error.UnknownFlag;
    }

    return .{
        .paths = paths,
        .file = file orelse return error.MissingArgument,
        .allow_delete = allow_delete,
    };
}

fn eql(a: []const u8, b: []const u8) bool {
    return std.mem.eql(u8, a, b);
}

// ---------------------------------------------------------------------------
// path assembly
// ---------------------------------------------------------------------------

pub const DataDir = struct {
    dir: std.Io.Dir,
    config_db_path: [:0]const u8,
    querylog_db_path: [:0]const u8,

    /// Creates `data_dir` and its parents at mode 0700 when `create` is set,
    /// then opens it. Plain `createDirPath` hardcodes 0777 (Dir.zig:843) and
    /// must not be used here: this directory holds `config.db`, which holds
    /// `web.password_hash`.
    pub fn open(io: std.Io, gpa: Allocator, data_dir: []const u8, create: bool) !DataDir {
        if (create) {
            _ = try std.Io.Dir.cwd().createDirPathStatus(io, data_dir, .fromMode(0o700));
        }

        // `.iterate`: the disk monitor sizes the databases by scanning this
        // directory, and an fd opened without it cannot be read as a directory.
        var dir = try std.Io.Dir.cwd().openDir(io, data_dir, .{ .iterate = true });
        errdefer dir.close(io);

        // SQLite opens by path, not by directory handle, so both paths are
        // joined and NUL-terminated here rather than resolved through `dir`.
        const config_db_path = try std.fs.path.joinZ(gpa, &.{ data_dir, config_db_name });
        errdefer gpa.free(config_db_path);
        const querylog_db_path = try std.fs.path.joinZ(gpa, &.{ data_dir, querylog_db_name });

        return .{
            .dir = dir,
            .config_db_path = config_db_path,
            .querylog_db_path = querylog_db_path,
        };
    }

    pub fn close(self: *DataDir, io: std.Io, gpa: Allocator) void {
        self.dir.close(io);
        gpa.free(self.config_db_path);
        gpa.free(self.querylog_db_path);
    }

    /// The order of these three steps is the specification, not a style:
    ///
    /// 1. `db.Db.open` creates `config.db`, at `0644 & ~umask` — SQLite's
    ///    choice, not ours.
    /// 2. chmod 0600.
    /// 3. `applyPragmas` turns on WAL, which is what creates `config.db-wal`
    ///    and `config.db-shm`. SQLite copies the main database file's
    ///    permissions onto both sidecars, so setting 0600 first gets them for
    ///    free. The other order leaves them world-readable, and the WAL of a
    ///    database holding `web.password_hash` is as sensitive as the database.
    pub fn openConfigDb(self: *const DataDir, io: std.Io) !db.Db {
        var database = try db.Db.open(self.config_db_path, .{});
        errdefer database.close();
        try self.dir.setFilePermissions(io, config_db_name, .fromMode(0o600), .{});
        try db.applyPragmas(&database, .{});
        return database;
    }

    /// The first connection to `querylog.db`: `querylog_schema.open` creates the
    /// file when it is missing and recreates it when it is unusable, so this is
    /// the call that establishes the schema. `reopenQuerylogDb` is for the
    /// connections that follow.
    ///
    /// The 0600 chmod cannot come first the way `openConfigDb` does it — the
    /// file may not exist yet, and a recreate replaces it — so the main file and
    /// both WAL sidecars are locked down afterwards instead. A query log holds
    /// every domain every client asked for, which is as sensitive as anything in
    /// `config.db`.
    ///
    /// `querylog_schema.open` resolves the path through SQLite's VFS as well as
    /// through the directory handle, so it is given `cwd` and the joined path
    /// rather than `self.dir` and a name (see its doc comment).
    /// Returns the whole `OpenResult`, recreate reason and aside name included:
    /// the composition root records a recreate as a one-shot diagnostics event,
    /// and the aside name is what tells an operator where the old file went.
    pub fn openQuerylogDb(self: *const DataDir, io: std.Io) !querylog_schema.OpenResult {
        var opened = try querylog_schema.open(io, std.Io.Dir.cwd(), self.querylog_db_path);
        errdefer opened.database.close();
        try self.restrictQuerylogPermissions(io);
        return opened;
    }

    /// An additional connection to a `querylog.db` that `openQuerylogDb` has
    /// already established, bound to this data directory's path.
    ///
    /// The opener itself is `querylog_schema.reopen`, beside the schema it
    /// belongs to: a logger generation opens its own writer connection at
    /// runtime and has no `DataDir` to ask.
    pub fn reopenQuerylogDb(self: *const DataDir, io: std.Io) !db.Db {
        return querylog_schema.reopen(io, std.Io.Dir.cwd(), self.querylog_db_path);
    }

    /// A sidecar that does not exist yet is not a failure: `-wal` and `-shm`
    /// appear when SQLite first writes, and the next call catches them.
    fn restrictQuerylogPermissions(self: *const DataDir, io: std.Io) !void {
        for ([_][]const u8{
            querylog_db_name,
            querylog_db_name ++ "-wal",
            querylog_db_name ++ "-shm",
        }) |entry| {
            self.dir.setFilePermissions(io, entry, .fromMode(0o600), .{}) catch |e| switch (e) {
                error.FileNotFound => {},
                else => |other| return other,
            };
        }
    }
};

// ---------------------------------------------------------------------------
// entry functions
// ---------------------------------------------------------------------------

pub const Runner = struct {
    io: std.Io,
    gpa: Allocator,
    out: *Writer,
    err: *Writer,
};

const usage_text =
    \\usage: nxdns <command> [options]
    \\
    \\commands:
    \\  run                 serve DNS
    \\  check               validate the configuration
    \\  export              write the configuration to stdout, or to --out
    \\  import FILE         replace the configuration with FILE
    \\  version             print version information
    \\  help                print this message
    \\
    \\options:
    \\  --data-dir DIR      data directory (default /var/lib/nxdns)
    \\  --config FILE       run: make FILE the sole source of configuration and
    \\                      reconcile the database onto it at every start;
    \\                      check: grade FILE instead of the database
    \\  --out FILE          write the export to FILE instead of stdout
    \\  --allow-delete      let import apply a file whose diff deletes rows
    \\  --admin-dev DIR     run only: serve the web interface from DIR instead of
    \\                      the embedded assets
    \\
;

/// The one remediation line for a database that holds no usable configuration.
/// Both routes to that state print it: `run` refusing an unconfigured database
/// (`NoUsableUpstreams`) and `check` finding no `config.db` at all.
///
/// It lives here, beside the exit-code mapping, because a Zig error carries no
/// text and `config/validate.zig` must stay blind to which source it is grading
/// — the same file validates a database reading and a managed file.
pub const db_source_hint =
    "load one with `nxdns import <file>`, or make a file the source of truth with `nxdns run --config <file>`\n";

pub fn writeDbSourceHint(w: *Writer) void {
    w.writeAll(db_source_hint) catch {};
}

/// Returns nothing, so a writer failure here has nowhere to go. Every caller
/// flushes afterwards and reports that failure instead.
pub fn usage(w: *Writer) void {
    w.writeAll(usage_text) catch {};
}

/// Both `main` and the tests end a command the same way: flush, then report.
/// A flush that fails is a runtime failure even when the command succeeded —
/// output the operator never received is not output.
fn finish(r: Runner, code: u8) u8 {
    r.out.flush() catch return exit_runtime;
    r.err.flush() catch return exit_runtime;
    return code;
}

pub fn runUsageError(r: Runner, e: ParseError) u8 {
    r.err.print("{s}\n\n", .{parseErrorMessage(e)}) catch {};
    usage(r.err);
    return finish(r, exit_usage);
}

fn parseErrorMessage(e: ParseError) []const u8 {
    return switch (e) {
        error.UnknownCommand => "unknown command",
        error.UnknownFlag => "unknown flag",
        error.MissingValue => "a flag was given without its value",
        error.MissingArgument => "a required argument is missing",
        error.TooManyArguments => "too many arguments",
    };
}

pub fn runHelp(r: Runner) u8 {
    usage(r.out);
    return finish(r, exit_ok);
}

pub fn runVersion(r: Runner) u8 {
    r.out.print("nxdns {s} ({s})\nzig {s}\n", .{
        version.string,
        version.git_commit,
        version.zig_version_string,
    }) catch return finish(r, exit_runtime);
    return finish(r, exit_ok);
}

/// Serves DNS until SIGINT or SIGTERM. The whole of it lives in `app.zig`,
/// which is where the composition root belongs; this stays the entry point so
/// that `main` dispatches every command the same way.
pub fn runRun(r: Runner, args: RunArgs) u8 {
    return app.run(r, args);
}

pub fn runExport(r: Runner, args: ExportArgs) u8 {
    exportImpl(r, args) catch |e| {
        r.err.print("export failed: {s}\n", .{@errorName(e)}) catch {};
        return finish(r, exit_runtime);
    };
    return finish(r, exit_ok);
}

fn exportImpl(r: Runner, args: ExportArgs) !void {
    var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, false);
    defer data.close(r.io, r.gpa);

    var database = try data.openConfigDb(r.io);
    defer database.close();
    _ = try migrations.migrate(&database);

    const path = args.out orelse return config_export.writeToWriter(r.gpa, &database, r.out);

    // Relative to the working directory, not to the data directory: an operator
    // running `nxdns export --out backup.zon` means the shell's directory.
    // `writeToFile` is the atomic, 0600 path — the file carries
    // `web.password_hash`.
    try config_export.writeToFile(r.io, r.gpa, &database, std.Io.Dir.cwd(), path);
    try r.out.print("wrote {s}\n", .{path});
}

pub fn runImport(r: Runner, args: ImportArgs) u8 {
    var diags: validate.Diagnostics = .init(r.gpa);
    defer diags.deinit();

    importImpl(r, args, &diags) catch |e| {
        // Every problem, not just the first: an operator fixing a config file
        // should need one run to see the whole list.
        diags.writeAll(r.err) catch {};
        r.err.print("import failed: {s}\n", .{@errorName(e)}) catch {};
        return finish(r, failureExitCode(e, diags.failureCount()));
    };
    return finish(r, exit_ok);
}

fn importImpl(r: Runner, args: ImportArgs, diags: *validate.Diagnostics) !void {
    var data = try DataDir.open(r.io, r.gpa, args.paths.data_dir, true);
    defer data.close(r.io, r.gpa);

    var database = try data.openConfigDb(r.io);
    defer database.close();
    _ = try migrations.migrate(&database);

    try import.importFile(
        r.io,
        r.gpa,
        &database,
        std.Io.Dir.cwd(),
        args.file,
        .{ .allow_delete = args.allow_delete },
        diags,
    );

    // A configuration that imports cleanly can still have recorded warnings — a
    // blocklist source in no group is the one that found this. `validate`
    // returns nothing for a warning, so printing diagnostics on the failure path
    // alone made `import` the command that read the finding and threw it away,
    // while `check` printed it from the same file. Only warnings can be here:
    // any recorded failure returned above.
    try diags.writeAll(r.out);
    try r.out.print("imported {s}\n", .{args.file});
}

/// A configuration the operator can fix exits 2; everything else is a runtime
/// failure. `validate` records a diagnostic for every error it returns and then
/// returns the first one, so a recorded failure is the reliable discriminator;
/// `config/faults.zig` classifies the errors that never reach the validator.
/// That file is the only list — this function keeps none of its own, which is
/// what stops `run`, `check` and `import` drifting apart again (D1).
///
/// `error.DestructiveImport` is the one exception, and it is deliberate: it
/// reports what the diff would do to the database rather than the content of a
/// file, so it is not a configuration fault, and `import` alone decides it is
/// exit 2.
///
/// `error.OutOfMemory` is matched first, before anything else is consulted.
/// Both recording paths — `validate` and import's per-line rendering of a ZON
/// syntax error — add one problem at a time and can run out of memory partway,
/// which leaves problems recorded for a run whose real outcome is a resource
/// failure. A partial report is not a verdict on the configuration, so the
/// runtime exit code wins.
///
/// `failures` counts recorded failures, never warnings: a warning never changes
/// an exit code (F-b).
fn failureExitCode(e: anyerror, failures: usize) u8 {
    if (e == error.OutOfMemory) return exit_runtime;
    if (failures != 0) return exit_check;
    if (e == error.DestructiveImport) return exit_check;
    return if (faults.isConfigFault(e)) exit_check else exit_runtime;
}

// ---------------------------------------------------------------------------
// check
// ---------------------------------------------------------------------------

/// An A query for example.com: id 0x1234, RD set, one question. The probe wants
/// a name every resolver on earth answers, so a FAIL line means the upstream is
/// unreachable rather than that the name is odd.
const probe_query =
    "\x12\x34\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00" ++
    "\x07example\x03com\x00\x00\x01\x00\x01";

/// The point of `check` is that an operator sees every problem in one run, so
/// this never returns early on a finding. `probe` is false in unit tests and
/// true from `main`: the probe leaves the machine.
pub fn runCheck(r: Runner, args: CheckArgs, probe: bool) u8 {
    const code = checkImpl(r, args, probe) catch |e| {
        r.err.print("check failed: {s}\n", .{@errorName(e)}) catch {};
        return finish(r, exit_runtime);
    };
    return finish(r, code);
}

fn checkImpl(r: Runner, args: CheckArgs, probe: bool) !u8 {
    var arena_state: std.heap.ArenaAllocator = .init(r.gpa);
    defer arena_state.deinit();
    const arena = arena_state.allocator();

    // Which source was used is printed in every branch, so the answer is never
    // ambiguous about what it checked.
    //
    // Ruling 1: the invocation decides, and nothing else. The heuristic that
    // used to live here — probe for `config.db`, fall back to probing
    // `/etc/nxdns/config.zon`, grade whichever exists — made the answer a
    // function of what happened to be on disk, which is the ambient inference
    // that made seed-once bootstrap a source of documentation lies. A file no
    // flag names is not graded.
    if (args.config) |path| {
        try r.out.print("checking configuration file {s}\n", .{path});
        return checkFile(r, arena, path, probe);
    }

    const config_db_path = try std.fs.path.joinZ(arena, &.{ args.paths.data_dir, config_db_name });
    if (!try pathExists(r.io, config_db_path)) {
        // The deleted heuristic's "nothing to check" branch, replaced rather
        // than dropped: an operator running `check` on a box that has never
        // been configured gets the same exit code and one line saying what to
        // do about it.
        try r.out.print("no config database at {s}\n", .{config_db_path});
        try r.out.writeAll(db_source_hint);
        return exit_check;
    }

    try r.out.print("checking database {s}\n", .{config_db_path});
    return checkDatabase(r, arena, config_db_path, probe);
}

/// `check` reads `config.db` and writes nothing to it (F-c): no create, no
/// chmod, no `applyPragmas` — which is what would turn WAL on and leave
/// `config.db-wal` and `config.db-shm` behind — and above all no `migrate`. A
/// database behind this binary's schema is reported here; upgrading it is
/// `nxdns run`'s job, and a command that claims to validate without writing may
/// not commit a schema step.
///
/// `.immutable` is the enforcement, not a convention: SQLite refuses every
/// statement that would write, and the mode's `immutable=1` also stops the
/// pager building a wal-index, so no `config.db-wal` and no `config.db-shm`
/// appear beside the file. `.read_only` alone creates both and cannot delete
/// them on close, which is how a "validate without writing" command left two
/// files behind.
fn checkDatabase(r: Runner, arena: Allocator, path: [:0]const u8, probe: bool) !u8 {
    var database = db.Db.open(path, .{ .mode = .{ .immutable = r.io } }) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        error.WalPending => return walPendingFailure(r, path),
        else => return unreadable(r, path, e),
    };
    defer database.close();

    const reading = try readDatabase(&database, arena);

    // `immutable=1` takes no lock — that is what keeps it from building a
    // wal-index and leaving two sidecars behind — so a writer was free to append
    // to the log or checkpoint into the main file for the whole of the read
    // above. Proved before one word of it is reported: a stale answer and a torn
    // read both look exactly like an ordinary finding, which is how this failure
    // stays invisible.
    database.verifyImmutable() catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        error.WalPending => return walPendingFailure(r, path),
        else => return unreadable(r, path, e),
    };

    switch (reading) {
        .version_unreadable => |e| {
            try r.out.print("FAIL {s}: the schema version cannot be read ({s})\n", .{ path, @errorName(e) });
            return exit_check;
        },
        // Naming both numbers is the point: "at 1, expects 2" tells an operator
        // to run `nxdns run`, where a bare SQLite complaint about a missing
        // column tells them nothing.
        .version_mismatch => |stamped| {
            const fix = if (stamped < migrations.target_version)
                "`nxdns run` migrates it, `check` will not"
            else
                "it was written by a newer nxdns";
            try r.out.print(
                "FAIL {s}: schema version {d}, this nxdns expects {d}; {s}\n",
                .{ path, stamped, migrations.target_version, fix },
            );
            return exit_check;
        },
        .config_unreadable => {
            // The schema version is already known good, so whatever this is,
            // the SQLite message is the only thing that narrows it down.
            // `verifyImmutable` makes no SQLite call, so this is still the
            // message from the read.
            var buf: [256]u8 = undefined;
            try r.out.print("FAIL {s}: cannot be read ({s})\n", .{ path, database.lastError(&buf) });
            return exit_check;
        },
        .config => |cfg| return checkConfig(r, cfg, probe),
    }
}

/// Everything read out of `config.db`, held rather than reported, because a
/// value from an unlocked read is only worth reporting once `verifyImmutable`
/// has said the files it came from stood still. A wrong schema-version line is
/// as misleading as a wrong setting.
const DbReading = union(enum) {
    config: model.Config,
    version_unreadable: anyerror,
    version_mismatch: u32,
    config_unreadable,
};

/// Reads only, so an immutable handle serves it.
fn readDatabase(database: *db.Db, arena: Allocator) error{OutOfMemory}!DbReading {
    const stamped = migrations.readVersion(database) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return .{ .version_unreadable = e },
    };
    if (stamped != migrations.target_version) return .{ .version_mismatch = stamped };

    const cfg = config_export.readConfig(database, arena) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => return .config_unreadable,
    };
    return .{ .config = cfg };
}

/// One wording for `error.WalPending`, whether the log was already there when
/// the read opened or arrived while it ran: from the operator's side those are
/// the same situation, a writer holding changes this read cannot see.
///
/// `immutable=1` ignores the write-ahead log, so the newest committed settings
/// would be invisible and `check` would quietly grade the older ones in the main
/// file. The guard is deliberately conservative — a live writer, an interrupted
/// process and a checkpointed log that was simply kept all look the same from
/// outside — so the line says what to do and does not claim anything is damaged.
fn walPendingFailure(r: Runner, path: []const u8) !u8 {
    try r.out.print(
        "FAIL {s}: uncheckpointed changes are waiting in {s}{s}, and reading without writing would answer from the older settings in the main file; `nxdns run` applies them. A running nxdns normally holds this log, which is the usual reason to see this line.\n",
        .{ path, path, db.wal_suffix },
    );
    return exit_check;
}

fn unreadable(r: Runner, path: []const u8, e: anyerror) !u8 {
    try r.out.print("FAIL {s}: cannot be opened for reading ({s})\n", .{ path, @errorName(e) });
    return exit_check;
}

fn pathExists(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
    std.Io.Dir.cwd().access(io, path, .{}) catch |e| switch (e) {
        error.FileNotFound => return false,
        else => |other| return other,
    };
    return true;
}

/// `AccessOptions.read` is the `R_OK` bit of `faccessat` (`Io/Threaded.zig`
/// `dirAccessPosix`); the default `.{}` sends mode 0, which is `F_OK` and tests
/// existence only. A file that exists but denies this user a read is exactly the
/// case `check` has to catch, so it needs the bit set.
fn pathReadable(io: std.Io, path: []const u8) std.Io.Dir.AccessError!bool {
    std.Io.Dir.cwd().access(io, path, .{ .read = true }) catch |e| switch (e) {
        error.FileNotFound, error.AccessDenied, error.PermissionDenied => return false,
        else => |other| return other,
    };
    return true;
}

/// Grades the file `--config` named, through `config/loader.zig` — the same
/// read and the same classification `nxdns run --config` uses. That shared
/// helper is what makes the scoped agreement of ruling 2 true: `check` reaches
/// exactly the read, size and parse faults `run` would reach, and validation
/// below is the same call on the same `Config`.
///
/// D4: a named file that is missing or unreadable is the same operator-fixable
/// condition as one that fails to parse, so it is reported as a finding rather
/// than escaping as a runtime failure.
fn checkFile(r: Runner, arena: Allocator, path: []const u8, probe: bool) !u8 {
    var diags: validate.Diagnostics = .init(r.gpa);
    defer diags.deinit();

    const cfg = loader.load(r.io, arena, std.Io.Dir.cwd(), path, &diags) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        error.ManagedConfigUnreadable, error.ConfigTooLarge, error.ParseZon => {
            // One line per problem, the promise the rest of `check` keeps: a
            // multi-line ZON rendering is several problems, not one `FAIL`
            // record with newlines inside it.
            try diags.writeAll(r.out);
            return exit_check;
        },
        // A box fault — fd exhaustion, an I/O error — is not a verdict on the
        // configuration and keeps its own name at exit 1.
        else => |other| return other,
    };

    return checkConfig(r, cfg, probe);
}

/// What one part of `check` found. Failures set exit 2; warnings are printed,
/// counted for the summary, and never change an exit code (F-b) — the service
/// starts either way.
const Tally = struct {
    failures: usize = 0,
    warnings: usize = 0,

    fn plus(self: Tally, other: Tally) Tally {
        return .{
            .failures = self.failures + other.failures,
            .warnings = self.warnings + other.warnings,
        };
    }
};

/// The half of `check` that has a `Config` already: validate, report every
/// diagnostic, then the certificate and upstream checks. With TLS disabled and
/// `probe` false it touches neither the filesystem nor the network, which is
/// what makes it unit-testable.
///
/// The summary never contradicts the lines above it (D2): a run that printed
/// WARN lines says so instead of claiming no problems were found, and still
/// exits 0.
pub fn checkConfig(r: Runner, cfg: model.Config, probe: bool) !u8 {
    var diags: validate.Diagnostics = .init(r.gpa);
    defer diags.deinit();

    // The returned error is the first recorded failure — one of the lines about
    // to be printed — so it carries nothing the report does not. Only an
    // allocation failure means the report itself is incomplete.
    validate.validate(cfg, &diags) catch |e| switch (e) {
        error.OutOfMemory => return error.OutOfMemory,
        else => {},
    };
    // `writeAll` prints the "FAIL "/"WARN " prefix itself; the lines below add
    // their own because they are not diagnostics.
    try diags.writeAll(r.out);

    var tally: Tally = .{ .failures = diags.failureCount(), .warnings = diags.warningCount() };
    tally = tally.plus(try checkCertificates(r, cfg));
    if (probe) tally.failures += try probeUpstreams(r, cfg);

    if (tally.failures != 0) return exit_check;
    if (tally.warnings != 0) {
        try r.out.print("OK: no failures found, {d} warning{s}\n", .{
            tally.warnings,
            if (tally.warnings == 1) "" else "s",
        });
        return exit_ok;
    }
    try r.out.writeAll("OK: no problems found\n");
    return exit_ok;
}

fn checkCertificates(r: Runner, cfg: model.Config) !Tally {
    const doh = try checkTlsFiles(r, cfg.doh_server, "doh_server");
    const dot = try checkTlsFiles(r, cfg.dot_server, "dot_server");
    return doh.plus(dot);
}

/// Proves the pair rather than the paths (D3). `CertStore.init` is the load the
/// listeners boot with: it reads both PEM files and builds a
/// `tls_server.ServerContext`, which is where Mbed TLS parses the chain, parses
/// the key and checks that the key belongs to the leaf. Testing readability
/// alone let `check` exit 0 on a certificate and key that do not pair, seconds
/// before `run` exited 2 on `BadCertificate`.
///
/// No listener is bound and nothing is published: the context is built and
/// freed. `alpn` is null because ALPN is negotiated per connection and plays no
/// part in loading a pair; every other input is the server's.
///
/// A key readable by anyone beyond its owner stays a warning (PLAN §19) and is
/// reported even when the pair itself fails, because it is a separate finding
/// about a file that exists.
fn checkTlsFiles(r: Runner, endpoint: model.TlsEndpoint, comptime section: []const u8) !Tally {
    if (!endpoint.enabled) return .{};
    var tally: Tally = .{};

    if (try pathReadable(r.io, endpoint.key_path)) {
        const stat = try std.Io.Dir.cwd().statFile(r.io, endpoint.key_path, .{});
        const mode = stat.permissions.toMode() & 0o777;
        if (mode & 0o077 != 0) {
            try r.out.print(
                "WARN " ++ section ++ ".key_path: '{s}' is mode {o}; a TLS key must be readable by its owner only\n",
                .{ endpoint.key_path, mode },
            );
            tally.warnings += 1;
        }
    }

    var store = cert_store.CertStore.init(
        r.gpa,
        r.io,
        endpoint.cert_path,
        endpoint.key_path,
        null,
    ) catch |e| {
        const at: struct { field: []const u8, path: []const u8 } = switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            // Not a verdict on the configuration: the platform's entropy source
            // failed, which is the same failure `run` would hit.
            error.EntropyFailed => return error.EntropyFailed,
            error.CertUnreadable,
            error.CertTooLarge,
            error.CertParse,
            // Mbed TLS rejected the server configuration built from this pair,
            // so the certificate is what the operator has to look at.
            error.ConfigFailed,
            => .{ .field = "cert_path", .path = endpoint.cert_path },
            error.KeyUnreadable,
            error.KeyTooLarge,
            error.KeyParse,
            error.KeyMismatch,
            => .{ .field = "key_path", .path = endpoint.key_path },
        };
        try r.out.print("FAIL " ++ section ++ ".{s}: '{s}': {s}\n", .{
            at.field,
            at.path,
            cert_store.humanMessage(e),
        });
        tally.failures += 1;
        return tally;
    };
    store.deinit(r.io);

    return tally;
}

/// One `Pool` per upstream, never one pool over all of them. The pool's job is
/// failover: a shared pool would report success as soon as any upstream
/// answered, and a broken upstream would stay invisible — the exact thing
/// `check` exists to surface. Driving the real pool rather than the client
/// directly keeps the deadline, cancellation and health machinery identical to
/// what the server will do.
fn probeUpstreams(r: Runner, cfg: model.Config) !usize {
    var failures: usize = 0;

    var http: std.http.Client = .{ .allocator = r.gpa, .io = r.io };
    defer http.deinit();

    // Shared across every DoT endpoint: the scan is expensive and the trust
    // store does not vary per upstream.
    var bundle: Certificate.Bundle = .empty;
    defer bundle.deinit(r.gpa);
    var bundle_lock: std.Io.RwLock = .init;

    const chunk = tls.Client.min_buffer_len;
    const tls_buffers = try r.gpa.alloc(u8, 4 * chunk);
    defer r.gpa.free(tls_buffers);

    // The same sizes `nxdns run` serves with, so the probe reports on the
    // buffers the server will actually use.
    var request_buf: [doh_client.default_request_buf_len]u8 = undefined;
    var transfer_buf: [doh_client.default_transfer_buf_len]u8 = undefined;
    const response_buf = try r.gpa.alloc(u8, transport.max_message_len);
    defer r.gpa.free(response_buf);

    // The same pair the server runs with, so a probe that passes here says
    // something about the deadlines a real query will get.
    const timeouts: pool.Timeouts = .{
        .attempt = .{ .raw = model.attemptTimeout(cfg.upstream), .clock = .awake },
        .total = .{ .raw = model.totalTimeout(cfg.upstream), .clock = .awake },
    };
    // The pool jitters backoff from this; one probe per upstream never reaches
    // backoff, so the value only has to be a value.
    const seed: u64 = @truncate(@as(u96, @bitCast(std.Io.Clock.real.now(r.io).nanoseconds)));

    // Every line below names the upstream by its index into the configuration
    // as well as by its redacted url, and the index is the config index rather
    // than a count of the upstreams probed — a disabled entry still occupies
    // one. The url alone no longer identifies an entry: `safe_url.redact` drops
    // the path, and two upstreams on one host commonly differ only there (a
    // NextDNS profile is `https://dns.nextdns.io/<profile>`). `upstreams[N]` is
    // the same name `config/validate.zig` gives the entry, so a FAIL line here
    // and a FAIL line from the validator point at the same place.
    for (cfg.upstreams, 0..) |server, i| {
        if (!server.enabled) continue;

        const endpoint = transport.Endpoint.parse(server.url) catch {
            try r.out.print("FAIL upstreams[{d}] {f}: not an https:// or tls:// endpoint\n", .{ i, safe_url.redactQuoted(server.url) });
            failures += 1;
            continue;
        };

        // Both clients are pinned for the pool's lifetime: the entry's one slot
        // holds an erased pointer into one of them.
        var doh: doh_client.DohClient = undefined;
        var dot: dot_client.DotClient = undefined;
        // A DoT probe leaves a connection open on a client whose buffers the
        // next iteration reuses, so it is closed at the end of every iteration —
        // on the FAIL paths and on any error out of the loop too. The flag is
        // what keeps the close off `dot` while it is still undefined.
        var dot_wired = false;
        defer if (dot_wired) dot.close(r.io);
        const client: transport.Client = switch (endpoint.scheme) {
            .doh => doh: {
                doh = doh_client.DohClient.init(&http, endpoint, &request_buf, &transfer_buf) catch {
                    try r.out.print("FAIL upstreams[{d}] {f}: not a usable DoH url\n", .{ i, safe_url.redactQuoted(server.url) });
                    failures += 1;
                    continue;
                };
                break :doh doh.client();
            },
            .dot => dot: {
                dot = dot_client.DotClient.init(endpoint, server.tls_name, r.gpa, &bundle, &bundle_lock, null, .{
                    .tls_read = tls_buffers[0..chunk],
                    .tls_write = tls_buffers[chunk .. 2 * chunk],
                    .stream_read = tls_buffers[2 * chunk .. 3 * chunk],
                    .stream_write = tls_buffers[3 * chunk ..],
                });
                dot_wired = true;
                break :dot dot.client();
            },
        };

        // One slot: the probe is sequential by design, and one exchange per
        // upstream is the whole of it.
        var slots = [_]pool.Slot{.{ .client = client }};
        // The pool's counter has to point somewhere, and a probe has no
        // process-wide counter storage to point it at.
        var recoveries: std.atomic.Value(u64) = .init(0);
        var entries = [_]pool.Entry{.{
            .endpoint = endpoint,
            .slots = &slots,
            .priority = server.priority,
            .enabled = true,
            .health = .init,
            .admission = .{ .permits = slots.len },
            .reuse_recoveries = &recoveries,
        }};
        var single: pool.Pool = .init(&entries, .{}, timeouts, seed);

        var selected: ?[]const u8 = null;
        if (single.exchange(r.io, probe_query, response_buf, &selected)) |_| {
            try r.out.print("OK upstreams[{d}] {f}\n", .{ i, safe_url.redact(server.url) });
        } else |_| {
            // The concrete cause lives in the entry's health, which is where the
            // pool put it; `@errorName` of the pool's return value would only
            // repeat the last attempt's classification.
            var snapshots: [1]pool.Snapshot = undefined;
            const taken = try single.snapshot(r.io, &snapshots);
            const detail = if (taken == 1) snapshots[0].last_error else "no detail recorded";
            try r.out.print("FAIL upstreams[{d}] {f}: {s}\n", .{ i, safe_url.redactQuoted(server.url), detail });
            failures += 1;
        }
    }

    return failures;
}

// ---------------------------------------------------------------------------
// tests
// ---------------------------------------------------------------------------

const testing = std.testing;

test "run without --config selects database authority" {
    // Ruling 1: authority is the invocation. No default path, so nothing on
    // disk can make a bare `run` read a file.
    const command = try parseArgs(&.{"run"});
    try testing.expectEqualStrings("/var/lib/nxdns", command.run.paths.data_dir);
    try testing.expectEqual(@as(?[]const u8, null), command.run.config);
    try testing.expectEqual(@as(?[]const u8, null), command.run.admin_dev);
}

test "run --config selects file authority and the path lands in the run args" {
    const attached = try parseArgs(&.{ "run", "--config=/etc/nxdns/config.zon" });
    try testing.expectEqualStrings("/etc/nxdns/config.zon", attached.run.config.?);

    const command = try parseArgs(&.{ "run", "--data-dir", "/srv/nx", "--config", "/tmp/c.zon" });
    try testing.expectEqualStrings("/srv/nx", command.run.paths.data_dir);
    try testing.expectEqualStrings("/tmp/c.zon", command.run.config.?);
}

test "parseArgs accepts run with --admin-dev in both spellings" {
    const attached = try parseArgs(&.{ "run", "--admin-dev=admin/dist" });
    try testing.expectEqualStrings("admin/dist", attached.run.admin_dev.?);

    const separate = try parseArgs(&.{ "run", "--admin-dev", "admin/dist" });
    try testing.expectEqualStrings("admin/dist", separate.run.admin_dev.?);
}

test "parseArgs rejects --admin-dev without a value and outside run" {
    try testing.expectError(error.MissingValue, parseArgs(&.{ "run", "--admin-dev" }));
    try testing.expectError(error.MissingValue, parseArgs(&.{ "run", "--admin-dev=" }));
    try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--admin-dev", "admin/dist" }));
    try testing.expectError(error.UnknownFlag, parseArgs(&.{ "export", "--admin-dev", "admin/dist" }));
}

test "parseArgs accepts --data-dir with and without an equals sign" {
    const attached = try parseArgs(&.{ "check", "--data-dir=/srv/nx" });
    try testing.expectEqualStrings("/srv/nx", attached.check.paths.data_dir);

    const separate = try parseArgs(&.{ "check", "--data-dir", "/srv/nx" });
    try testing.expectEqualStrings("/srv/nx", separate.check.paths.data_dir);
}

test "bare check grades the database and check --config grades the file" {
    const implicit = try parseArgs(&.{"check"});
    try testing.expectEqual(@as(?[]const u8, null), implicit.check.config);

    const explicit = try parseArgs(&.{ "check", "--config=/tmp/c.zon" });
    try testing.expectEqualStrings("/tmp/c.zon", explicit.check.config.?);
}

test "parseArgs accepts export with --out" {
    const command = try parseArgs(&.{ "export", "--out", "backup.zon" });
    try testing.expectEqualStrings("backup.zon", command.export_.out.?);

    const bare = try parseArgs(&.{"export"});
    try testing.expectEqual(@as(?[]const u8, null), bare.export_.out);
}

test "parseArgs accepts import with a file, --allow-delete and --data-dir" {
    const command = try parseArgs(&.{ "import", "c.zon", "--allow-delete", "--data-dir=/srv/nx" });
    try testing.expectEqualStrings("c.zon", command.import_.file);
    try testing.expect(command.import_.allow_delete);
    try testing.expectEqualStrings("/srv/nx", command.import_.paths.data_dir);
}

test "parseArgs accepts import with the file after the flags" {
    const command = try parseArgs(&.{ "import", "--data-dir", "/srv/nx", "c.zon" });
    try testing.expectEqualStrings("c.zon", command.import_.file);
    try testing.expect(!command.import_.allow_delete);
}

test "the renamed import flag replaces --force rather than joining it" {
    try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--force" }));
}

test "parseArgs accepts version" {
    try testing.expectEqual(Command.version, try parseArgs(&.{"version"}));
}

test "parseArgs accepts help, --help and -h" {
    try testing.expectEqual(Command.help, try parseArgs(&.{"help"}));
    try testing.expectEqual(Command.help, try parseArgs(&.{"--help"}));
    try testing.expectEqual(Command.help, try parseArgs(&.{"-h"}));
}

test "parseArgs rejects import without a file" {
    try testing.expectError(error.MissingArgument, parseArgs(&.{"import"}));
    try testing.expectError(error.MissingArgument, parseArgs(&.{ "import", "--allow-delete" }));
}

test "parseArgs rejects --out without a value" {
    try testing.expectError(error.MissingValue, parseArgs(&.{ "export", "--out" }));
    try testing.expectError(error.MissingValue, parseArgs(&.{ "export", "--out=" }));
}

test "parseArgs rejects an unknown flag" {
    try testing.expectError(error.UnknownFlag, parseArgs(&.{ "check", "--nope" }));
    try testing.expectError(error.UnknownFlag, parseArgs(&.{ "import", "c.zon", "--allow-delete=1" }));
}

test "parseArgs rejects an unknown command" {
    try testing.expectError(error.UnknownCommand, parseArgs(&.{"frobnicate"}));
}

test "parseArgs rejects an empty argument list" {
    try testing.expectError(error.UnknownCommand, parseArgs(&.{}));
}

test "parseArgs rejects an extra positional argument" {
    try testing.expectError(error.TooManyArguments, parseArgs(&.{ "export", "extra" }));
    try testing.expectError(error.TooManyArguments, parseArgs(&.{ "version", "extra" }));
    try testing.expectError(error.TooManyArguments, parseArgs(&.{ "import", "a.zon", "b.zon" }));
    try testing.expectError(error.TooManyArguments, parseArgs(&.{ "help", "extra" }));
    try testing.expectError(error.TooManyArguments, parseArgs(&.{ "--help", "extra" }));
    try testing.expectError(error.TooManyArguments, parseArgs(&.{ "-h", "extra" }));
}

test "usage_text lists every command in command_names" {
    // The commands block indents each entry by two spaces, so a command that
    // survives only as a word inside an option description does not count.
    for (command_names) |entry| {
        var needle_buf: [32]u8 = undefined;
        const needle = try std.fmt.bufPrint(&needle_buf, "\n  {s} ", .{entry.name});
        if (std.mem.indexOf(u8, usage_text, needle) == null) {
            std.debug.print("command missing from usage_text: {s}\n", .{entry.name});
            return error.CommandMissingFromUsage;
        }
    }
}

test "usage_text names the flags this milestone renamed and describes --config" {
    // The flag an operator reaches for is the one the help text names. `--force`
    // is gone rather than aliased (greenfield rules), and `--config` no longer
    // advertises a default path, because there is none: its presence is the
    // whole authority decision.
    try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, "  --allow-delete "));
    try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "--force"));
    try testing.expectEqual(@as(usize, 0), std.mem.count(u8, usage_text, "default /etc/nxdns/config.zon"));
    try testing.expect(std.mem.containsAtLeast(u8, usage_text, 1, "sole source of configuration"));
}

test "usage writes non-empty text" {
    var out: Writer.Allocating = .init(testing.allocator);
    defer out.deinit();
    usage(&out.writer);
    try testing.expect(out.written().len > 0);
    try testing.expect(std.mem.startsWith(u8, out.written(), "usage: nxdns"));
}

const Captured = struct {
    threaded: std.Io.Threaded,
    out: Writer.Allocating,
    err: Writer.Allocating,

    fn init(gpa: Allocator) Captured {
        return .{
            .threaded = .init(gpa, .{}),
            .out = .init(gpa),
            .err = .init(gpa),
        };
    }

    fn deinit(self: *Captured) void {
        self.out.deinit();
        self.err.deinit();
        self.threaded.deinit();
    }

    fn runner(self: *Captured) Runner {
        return .{
            .io = self.threaded.io(),
            .gpa = testing.allocator,
            .out = &self.out.writer,
            .err = &self.err.writer,
        };
    }
};

fn countLines(text: []const u8) usize {
    return std.mem.count(u8, text, "\n");
}

test "checkConfig returns 0 for a clean configuration" {
    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();

    const cfg: model.Config = .{
        .groups = &.{.{ .name = "default" }},
        .upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
    };

    try testing.expectEqual(exit_ok, try checkConfig(captured.runner(), cfg, false));
    try testing.expectEqualStrings("OK: no problems found\n", captured.out.written());
}

test "checkConfig returns 2 and prints one line per problem" {
    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();

    // Three problems: no group named 'default', no enabled upstream, and a
    // zero port.
    const cfg: model.Config = .{ .dns = .{ .port = 0 } };

    try testing.expectEqual(exit_check, try checkConfig(captured.runner(), cfg, false));

    const text = captured.out.written();
    try testing.expectEqual(@as(usize, 3), countLines(text));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "dns.port:"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "groups:"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "upstreams:"));
}

test "checkConfig reports every problem rather than stopping at the first" {
    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();

    const cfg: model.Config = .{
        .groups = &.{.{ .name = "default" }},
        .upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
        .clients = &.{
            .{ .ip = "not-an-ip" },
            .{ .ip = "also-not-an-ip" },
            .{ .ip = "192.168.1.1", .group = "missing" },
        },
    };

    try testing.expectEqual(exit_check, try checkConfig(captured.runner(), cfg, false));
    try testing.expectEqual(@as(usize, 3), countLines(captured.out.written()));
}

test "runVersion prints the milestone-1 version lines and exits 0" {
    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();

    try testing.expectEqual(exit_ok, runVersion(captured.runner()));
    try testing.expect(std.mem.startsWith(u8, captured.out.written(), "nxdns "));
    try testing.expectEqual(@as(usize, 2), countLines(captured.out.written()));
}

// `runRun` now binds sockets and serves until a signal arrives, so it has no
// unit test: booting it is `src/server/phase7_integration_test.zig`'s case 11.

test "runUsageError names the fault and prints the usage text" {
    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();

    try testing.expectEqual(exit_usage, runUsageError(captured.runner(), error.UnknownFlag));
    const text = captured.err.written();
    try testing.expect(std.mem.startsWith(u8, text, "unknown flag\n"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "usage: nxdns"));
}

test "failureExitCode separates a fixable configuration from a runtime failure" {
    try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));
    try testing.expectEqual(exit_check, failureExitCode(error.ParseZon, 0));
    try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 1));
    try testing.expectEqual(exit_runtime, failureExitCode(error.IoErr, 0));
    try testing.expectEqual(exit_runtime, failureExitCode(error.OutOfMemory, 0));
    // A partial report from an allocation failure is a runtime failure, not a
    // finding about the configuration.
    try testing.expectEqual(exit_runtime, failureExitCode(error.OutOfMemory, 3));
}

test "an allocation failure while recording diagnostics exits 1, not 2" {
    // The recording path for real: `validate` adds one problem per fault, so an
    // allocator that fails partway leaves problems in the list AND returns
    // `error.OutOfMemory`. How many allocations one problem costs is
    // `Diagnostics.add`'s business, so the failure point is swept rather than
    // guessed.
    const cfg: model.Config = .{ .dns = .{ .port = 0 } };

    var saw_partial_report = false;
    var fail_index: usize = 0;
    while (fail_index < 32) : (fail_index += 1) {
        var failing: std.testing.FailingAllocator = .init(testing.allocator, .{ .fail_index = fail_index });
        var diags: validate.Diagnostics = .init(failing.allocator());
        defer diags.deinit();

        validate.validate(cfg, &diags) catch |e| {
            if (e != error.OutOfMemory) continue;
            const problems = diags.problems.items.len;
            if (problems == 0) continue;
            saw_partial_report = true;
            try testing.expectEqual(exit_runtime, failureExitCode(e, problems));
        };
    }

    try testing.expect(saw_partial_report);
}

test "failureExitCode keeps no list of its own and classifies through config/faults.zig" {
    // D1: every one of these reached `import` from a rejected seed file and was
    // classified as a runtime failure by the private list this function used to
    // carry, while `check` called the same file a configuration fault.
    try testing.expectEqual(exit_check, failureExitCode(error.MissingDefaultGroup, 0));
    try testing.expectEqual(exit_check, failureExitCode(error.NoUpstreams, 0));
    try testing.expectEqual(exit_check, failureExitCode(error.BadUpstreamUrl, 0));
    try testing.expectEqual(exit_check, failureExitCode(error.NoUsableUpstreams, 0));
    try testing.expectEqual(exit_check, failureExitCode(error.BadCertificate, 0));

    // Every member of the shared classification, so a variant added to
    // `ValidateError` cannot exit 1 from `import` while exiting 2 from `run`.
    inline for (@typeInfo(validate.ValidateError).error_set.?) |member| {
        const err = @field(anyerror, member.name);
        const expected: u8 = if (faults.isConfigFault(err)) exit_check else exit_runtime;
        try testing.expectEqual(expected, failureExitCode(err, 0));
    }

    // The one config-shaped exit 2 `cli` still decides for itself: it reports
    // what the diff would do to the database, not the content of a file.
    try testing.expect(!faults.isConfigFault(error.DestructiveImport));
    try testing.expectEqual(exit_check, failureExitCode(error.DestructiveImport, 0));

    // The managed file goes the other way: `config/loader.zig` converts the
    // path class, so the classification — not this function — carries it.
    try testing.expectEqual(exit_check, failureExitCode(error.ManagedConfigUnreadable, 0));
}

const fixtures = @import("test_fixtures");

/// A tmp directory holding the fixture PEM pair and a data directory, addressed
/// by cwd-relative paths the same way an operator's configuration names them.
/// Must not move after `init`: the slices point into the buffers.
const CheckEnv = struct {
    tmp: testing.TmpDir,
    cert_path_buf: [160]u8,
    key_path_buf: [160]u8,
    data_dir_buf: [160]u8,
    missing_path_buf: [160]u8,
    cert_path: []const u8,
    key_path: []const u8,
    data_dir: []const u8,
    /// A path inside the tmp directory that is never created.
    missing_path: []const u8,

    fn init(env: *CheckEnv) !void {
        env.tmp = testing.tmpDir(.{});
        errdefer env.tmp.cleanup();
        env.cert_path = try env.path(&env.cert_path_buf, "cert.pem");
        env.key_path = try env.path(&env.key_path_buf, "key.pem");
        env.data_dir = try env.path(&env.data_dir_buf, "data");
        env.missing_path = try env.path(&env.missing_path_buf, "no-such-config.zon");
    }

    fn deinit(env: *CheckEnv) void {
        env.tmp.cleanup();
    }

    fn path(env: *const CheckEnv, buf: []u8, name: []const u8) ![]const u8 {
        return std.fmt.bufPrint(buf, ".zig-cache/tmp/{s}/{s}", .{ env.tmp.sub_path, name });
    }

    /// The key lands at 0600, so only a test that asks for the permission
    /// warning gets one.
    fn writePair(env: *CheckEnv, io: std.Io, key_pem: []const u8) !void {
        try env.tmp.dir.writeFile(io, .{ .sub_path = "cert.pem", .data = fixtures.cert_pem });
        try env.tmp.dir.writeFile(io, .{ .sub_path = "key.pem", .data = key_pem });
        try env.tmp.dir.setFilePermissions(io, "key.pem", .fromMode(0o600), .{});
    }

    /// Valid but for whatever the test broke about the TLS pair.
    fn tlsConfig(env: *const CheckEnv) model.Config {
        return .{
            .groups = &.{.{ .name = "default" }},
            .upstreams = &.{.{ .url = "https://dns.example/dns-query" }},
            .doh_server = .{
                .enabled = true,
                .cert_path = env.cert_path,
                .key_path = env.key_path,
            },
        };
    }

    fn expectAbsent(env: *CheckEnv, io: std.Io, name: []const u8) !void {
        env.tmp.dir.access(io, name, .{}) catch |e| switch (e) {
            error.FileNotFound => return,
            else => |other| return other,
        };
        std.debug.print("expected '{s}' not to exist\n", .{name});
        return error.TestUnexpectedResult;
    }
};

test "check fails a certificate and a key that do not pair" {
    // D3: readability alone passed this configuration, and `run` then exited 2
    // on `BadCertificate` seconds later.
    var env: CheckEnv = undefined;
    try env.init();
    defer env.deinit();

    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();
    try env.writePair(r.io, fixtures.mismatched_key_pem);

    try testing.expectEqual(exit_check, try checkConfig(r, env.tlsConfig(), false));

    const text = captured.out.written();
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "FAIL doh_server.key_path"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "does not belong to the certificate"));
    try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "OK:"));
}

test "check fails a certificate file that does not parse" {
    var env: CheckEnv = undefined;
    try env.init();
    defer env.deinit();

    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();
    try env.writePair(r.io, fixtures.key_pem);
    try env.tmp.dir.writeFile(r.io, .{ .sub_path = "cert.pem", .data = "not a certificate\n" });

    try testing.expectEqual(exit_check, try checkConfig(r, env.tlsConfig(), false));

    const text = captured.out.written();
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "FAIL doh_server.cert_path"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "could not be parsed"));
}

test "check passes a certificate and a key that pair" {
    var env: CheckEnv = undefined;
    try env.init();
    defer env.deinit();

    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();
    try env.writePair(r.io, fixtures.key_pem);

    try testing.expectEqual(exit_ok, try checkConfig(r, env.tlsConfig(), false));
    try testing.expectEqualStrings("OK: no problems found\n", captured.out.written());
}

test "a check whose only findings are warnings exits 0 and says so" {
    // D2: the summary used to read "OK: no problems found" directly under the
    // WARN line it was contradicting.
    var env: CheckEnv = undefined;
    try env.init();
    defer env.deinit();

    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();
    try env.writePair(r.io, fixtures.key_pem);
    try env.tmp.dir.setFilePermissions(r.io, "key.pem", .fromMode(0o644), .{});

    // A warning never changes an exit code: the service still starts.
    try testing.expectEqual(exit_ok, try checkConfig(r, env.tlsConfig(), false));

    const text = captured.out.written();
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "WARN doh_server.key_path"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "OK: no failures found, 1 warning\n"));
    try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "no problems found"));
}

test "check --config naming a missing file is a reported failure at exit 2" {
    // D4: this escaped `checkImpl` as `check failed: FileNotFound` at exit 1,
    // while the implicit path exits 2 for the same operator-fixable condition.
    var env: CheckEnv = undefined;
    try env.init();
    defer env.deinit();

    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();

    const code = runCheck(r, .{ .config = env.missing_path }, false);

    try testing.expectEqual(exit_check, code);
    const text = captured.out.written();
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "FAIL"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "no such file"));
    try testing.expectEqualStrings("", captured.err.written());
}

test "bare check with no config database exits 2 and says how to make one" {
    // The deleted heuristic's "nothing to check" branch, replaced. The valid
    // file sitting in the same directory is the other half of ruling 1: bare
    // `check` grades the database, and a file no flag named is not consulted —
    // if it were, this run would print "OK: no problems found" instead.
    var env: CheckEnv = undefined;
    try env.init();
    defer env.deinit();

    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();

    try env.tmp.dir.writeFile(r.io, .{ .sub_path = "config.zon", .data =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\}
    });

    const code = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false);

    try testing.expectEqual(exit_check, code);
    const text = captured.out.written();
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "no config database at "));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, config_db_name));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, db_source_hint));
    // Not a bare count of "OK" over the whole text: this message embeds the
    // temporary directory path, and `std.testing.tmpDir` names that directory
    // with base64 over random bytes, so a run whose name happens to carry those
    // two letters would fail a test that has nothing to do with naming. Every
    // verdict this command prints — `OK:` and `OK upstreams[...]` alike — opens
    // a line, so that is what to assert on.
    var lines = std.mem.splitScalar(u8, text, '\n');
    while (lines.next()) |line| {
        try testing.expect(!std.mem.startsWith(u8, line, "OK"));
    }
    try testing.expectEqualStrings("", captured.err.written());
}

test "check renders a multi-line ZON failure as one FAIL line per message" {
    // The rendering used to go inline into a single `FAIL` record, which put
    // newlines mid-line and broke the one-line-per-problem promise the rest of
    // `check` keeps.
    var env: CheckEnv = undefined;
    try env.init();
    defer env.deinit();

    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();

    // An unexpected field renders as an "error:" line and a "note:" line.
    try env.tmp.dir.writeFile(r.io, .{ .sub_path = "config.zon", .data = ".{ .grops = .{} }\n" });
    var path_buf: [160]u8 = undefined;
    const config_path = try env.path(&path_buf, "config.zon");

    const code = runCheck(r, .{ .config = config_path }, false);
    try testing.expectEqual(exit_check, code);

    const text = captured.out.written();
    var lines = std.mem.splitScalar(u8, std.mem.trimEnd(u8, text, "\n"), '\n');
    // The first line names what was checked; every line after it is a problem.
    try testing.expect(std.mem.startsWith(u8, lines.next().?, "checking configuration file "));
    var failures: usize = 0;
    while (lines.next()) |line| {
        try testing.expect(std.mem.startsWith(u8, line, "FAIL config: "));
        failures += 1;
    }
    try testing.expectEqual(@as(usize, 2), failures);
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "error: unexpected field 'grops'"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "note: supported: "));
}

test "check reads config.db without writing to it" {
    // D6: the database branch opened read/write, chmod'ed 0600, turned WAL on —
    // which is what creates the two sidecars — and committed migration steps,
    // from a command whose contract is that it validates without writing.
    var env: CheckEnv = undefined;
    try env.init();
    defer env.deinit();

    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();

    {
        var data = try DataDir.open(r.io, r.gpa, env.data_dir, true);
        defer data.close(r.io, r.gpa);
        var database = try data.openConfigDb(r.io);
        defer database.close();
        _ = try migrations.migrate(&database);
    }

    // A mode `openConfigDb` would overwrite, so its chmod cannot hide.
    try env.tmp.dir.setFilePermissions(r.io, "data/config.db", .fromMode(0o644), .{});
    const before = try env.tmp.dir.statFile(r.io, "data/config.db", .{});

    _ = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false);
    try testing.expect(std.mem.containsAtLeast(u8, captured.out.written(), 1, "checking database"));

    // Byte for byte the database the writer left, at the mode the writer left:
    // no migration step committed, no chmod, no `PRAGMA journal_mode`.
    const after = try env.tmp.dir.statFile(r.io, "data/config.db", .{});
    try testing.expectEqual(before.size, after.size);
    try testing.expectEqual(before.mtime.nanoseconds, after.mtime.nanoseconds);
    try testing.expectEqual(
        @as(@TypeOf(after.permissions.toMode()), 0o644),
        after.permissions.toMode() & 0o777,
    );

    // Nothing beside it either. A `.read_only` open recreates the wal-index of
    // a database whose header says WAL and cannot delete it on close, which
    // left `config.db-wal` and `config.db-shm` behind; `.immutable` builds no
    // wal-index at all.
    try env.expectAbsent(r.io, "data/config.db-wal");
    try env.expectAbsent(r.io, "data/config.db-shm");
}

test "check refuses a database whose write-ahead log still holds changes" {
    // `immutable=1` ignores the log, so grading the main file would silently
    // report settings the operator already replaced.
    var env: CheckEnv = undefined;
    try env.init();
    defer env.deinit();

    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();

    {
        var data = try DataDir.open(r.io, r.gpa, env.data_dir, true);
        defer data.close(r.io, r.gpa);
        var database = try data.openConfigDb(r.io);
        defer database.close();
        _ = try migrations.migrate(&database);
    }
    // Bytes are all the guard reads, and it never gets as far as opening this
    // as a log: `db.Db.open` refuses on the size alone.
    try env.tmp.dir.writeFile(r.io, .{
        .sub_path = "data/config.db" ++ db.wal_suffix,
        .data = "uncheckpointed frames",
    });

    const code = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false);

    try testing.expectEqual(exit_check, code);
    const text = captured.out.written();
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "FAIL"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "uncheckpointed changes"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "config.db" ++ db.wal_suffix));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns run"));
    // Not a claim about damage: an operator reading this must not reach for a
    // recovery tool.
    try testing.expectEqual(@as(usize, 0), std.mem.count(u8, text, "corrupt"));
}

test "check reports a database behind the schema rather than migrating it" {
    var env: CheckEnv = undefined;
    try env.init();
    defer env.deinit();

    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();

    // Zero bytes is a valid, empty SQLite database: schema version 0, which is
    // exactly what an upgrade leaves behind when a step has not run yet.
    _ = try std.Io.Dir.cwd().createDirPathStatus(r.io, env.data_dir, .fromMode(0o700));
    try env.tmp.dir.writeFile(r.io, .{ .sub_path = "data/config.db", .data = "" });

    const code = runCheck(r, .{ .paths = .{ .data_dir = env.data_dir } }, false);

    try testing.expectEqual(exit_check, code);
    const text = captured.out.written();
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "FAIL"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "nxdns run"));

    // Both real numbers, not a generic SQLite complaint: an empty database is
    // at version 0, and the expected one comes from the migration list rather
    // than a literal, so adding a step cannot make this line lie.
    var expected_buf: [96]u8 = undefined;
    const expected = try std.fmt.bufPrint(
        &expected_buf,
        "schema version 0, this nxdns expects {d};",
        .{migrations.target_version},
    );
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, expected));

    // Not upgraded: the schema `migrate` would have committed is still absent.
    const after = try env.tmp.dir.statFile(r.io, "data/config.db", .{});
    try testing.expectEqual(@as(u64, 0), after.size);
    try env.expectAbsent(r.io, "data/config.db-wal");
    try env.expectAbsent(r.io, "data/config.db-shm");
}

test "the upstream probe redacts a url it cannot parse, without leaving the machine" {
    // No socket is opened on this branch: `Endpoint.parse` rejects the `@`
    // before the loop builds a client, so the leak is reachable in a required
    // test rather than only behind a live probe.
    //
    // It is also the only probe branch a credential can reach. `Endpoint.parse`
    // refuses `@`, `?` and `#` in the authority and `?`/`#` in the path, so a
    // url carrying userinfo or a query never gets as far as `DohClient.init`
    // and its "not a usable DoH url" line. That line's redaction is defensive.
    //
    // The two upstreams are the NextDNS shape, where the profile id in the path
    // is the account credential and is the only thing telling two entries
    // apart. Both halves of the contract are asserted at once: neither the
    // userinfo nor the profile id may reach stdout, and what is left has to
    // still say which of the two entries the operator must go and fix. The
    // disabled entry ahead of them is there because the index has to be the
    // index into `upstreams`, not a count of the entries probed.
    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();

    const cfg: model.Config = .{
        .groups = &.{.{ .name = "default" }},
        .upstreams = &.{
            .{ .url = "https://dns.example/dns-query", .enabled = false },
            .{ .url = "https://lists:hunter2@dns.nextdns.io/abcd12" },
            .{ .url = "https://lists:hunter2@dns.nextdns.io/efgh34" },
        },
    };

    try testing.expectEqual(@as(usize, 2), try probeUpstreams(r, cfg));

    const text = captured.out.written();
    try testing.expectEqualStrings(
        "FAIL upstreams[1] 'https://dns.nextdns.io': not an https:// or tls:// endpoint\n" ++
            "FAIL upstreams[2] 'https://dns.nextdns.io': not an https:// or tls:// endpoint\n",
        text,
    );
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "hunter2"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "abcd12"));
    try testing.expect(!std.mem.containsAtLeast(u8, text, 1, "efgh34"));
}

test "a failed DoT probe reaches close through the per-iteration defer without a session" {
    // Exactly that and no more. This is the only test that reaches the DoT
    // branch at all: it arms `dot_wired`, fails the dial, and runs the deferred
    // `DotClient.close` on a client that never opened a session — twice, so the
    // second iteration's dial happens after the first close. What that proves is
    // that the path compiles and survives: `dot_wired` set only after `init`,
    // and `close` tolerant of a sessionless client. It does *not* prove the
    // defer is load-bearing — with no session open, deleting the `defer` would
    // leave this test green.
    //
    // The property that matters — a probe's connection not surviving into the
    // next iteration's shared TLS buffers — needs a session to exist, so it is
    // pinned one level down on the client itself, by the close-after-failure
    // assertions in `dot_client_integration_test.zig`. A successful DoT probe
    // cannot be pinned in-repo at all: it needs a real handshake, and the only
    // DoT peer this repo has is the self-signed `-Dintegration` loopback
    // fixture, which `probeUpstreams` cannot verify because it builds its bundle
    // from the system trust store.
    //
    // `tls://` with a host that is not an IP literal fails inside `dial` before
    // any socket is opened or any CA store is read: upstream name resolution is
    // out of scope, so `resolveAddress` refuses it. That keeps the test off the
    // network and off the host's configuration.
    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();

    const cfg: model.Config = .{
        .groups = &.{.{ .name = "default" }},
        .upstreams = &.{
            .{ .url = "tls://dns.example:853", .tls_name = "dns.example" },
            .{ .url = "tls://other.example:853", .tls_name = "other.example" },
        },
    };

    try testing.expectEqual(@as(usize, 2), try probeUpstreams(r, cfg));
    try testing.expectEqualStrings(
        "FAIL upstreams[0] 'tls://dns.example:853': ConnectFailed\n" ++
            "FAIL upstreams[1] 'tls://other.example:853': ConnectFailed\n",
        captured.out.written(),
    );
}

test "a successful import prints the warnings the file earned" {
    // D5, second half. `check` printed this WARN and `import` recorded it and
    // threw it away, because diagnostics were written on the failure path only.
    // The operator who imported the file learned nothing about the source they
    // had just added, which blocks nothing until a group links it.
    var env: CheckEnv = undefined;
    try env.init();
    defer env.deinit();

    var captured: Captured = .init(testing.allocator);
    defer captured.deinit();
    const r = captured.runner();

    try env.tmp.dir.writeFile(r.io, .{ .sub_path = "config.zon", .data =
        \\.{
        \\    .groups = .{ .{ .name = "default" } },
        \\    .upstreams = .{ .{ .url = "https://dns.example/dns-query" } },
        \\    .blocklist_sources = .{ .{ .url = "https://lists.example/hosts.txt", .name = "ads" } },
        \\}
    });

    var file_buf: [160]u8 = undefined;
    const file = try env.path(&file_buf, "config.zon");
    const code = runImport(r, .{ .paths = .{ .data_dir = env.data_dir }, .file = file });

    // A warning never changes an exit code, and the import really happened.
    try testing.expectEqual(exit_ok, code);
    const text = captured.out.written();
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "WARN blocklist_sources[0]: "));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "belongs to no group"));
    try testing.expect(std.mem.containsAtLeast(u8, text, 1, "imported "));
    try testing.expectEqualStrings("", captured.err.written());
}

// `runExport` needs a real data directory and the upstream probe leaves the
// machine. Those cases are S7's:
// `src/storage/storage_integration_test.zig` cases 20-22.
