//! Compiles a downloaded blocklist into the three bodies nxdns stores on disk:
//! a `.list` body of exact names, a `.wild` body of suffixes and an `.allow`
//! body of the names the list's `@@` exceptions lift.
//!
//! Pure over reader/writer interfaces: an allocator, a `*std.Io.Reader` and
//! three `*std.Io.Writer`. No `std.Io` value, no file, no clock. A compiled body
//! is a pure function of (bytes, format), which is what makes two runs — and two
//! permutations of the same input — byte-identical.
//!
//! Nothing but the sorted, deduplicated names is written: no header, no
//! timestamp, no counts. The header belongs to the caller, and the checksum
//! covers the three bodies only.

const std = @import("std");
const parsers = @import("parsers.zig");
const name = @import("../dns/name.zig");
const types = @import("../dns/types.zig");

const Sha256 = std.crypto.hash.sha2.Sha256;

pub const max_domains: u32 = 2_000_000;
pub const max_line_len: usize = 4096;

pub const Counts = struct {
    domains: u32 = 0,
    wildcards: u32 = 0,
    /// Written, deduplicated `.allow` entries: the names this list's `@@`
    /// exceptions lift out of what other lists block.
    exceptions: u32 = 0,
    /// Regex lines this list carried, counted and skipped. nxdns has an engine
    /// for them now, but it stays reserved for operator rules: a downloaded list
    /// is other people's patterns, and PLAN §2.2 keeps them out.
    skipped_regex: u32 = 0,
    skipped_unsupported: u32 = 0,
    /// Not a valid domain name (`dns.name.fromText` rejected it, a non-ASCII
    /// byte, or fewer than two labels).
    invalid: u32 = 0,
    /// Lines longer than `max_line_len`, skipped whole.
    long_lines: u32 = 0,
    /// Duplicates removed by the sort/unique pass.
    duplicates: u32 = 0,
};

pub const Result = struct {
    counts: Counts,
    /// Lowercase hex sha256 over the `.list` body, the `.wild` body and the
    /// `.allow` body in that order, each followed by `body_separator`.
    ///
    /// The separator is what makes the digest identify a compile. Without it
    /// the three bodies concatenate ambiguously: a wildcard is stored with its
    /// `*.` stripped, so an upstream that changes `a.example` to `*.a.example`
    /// moves the same bytes from the `.list` body to the `.wild` body and
    /// hashes to the same digest. `Manager.diskBodiesMatch` would then accept
    /// the stale files, the refresh would keep them, and the wildcard would
    /// never take effect.
    checksum: [64]u8,
};

/// Fed to the checksum hasher after each of the three bodies, so the digest
/// reads them as three fields rather than one run of bytes.
///
/// `0x00` is sound as a separator because it can never occur inside a body:
/// `addCandidate` rejects every control byte and every byte at or above `0x80`,
/// so a body holds none. Any producer of this digest must use this constant —
/// `compiler.compile` hashes while it emits and `Manager.bodyChecksum` hashes
/// three finished buffers, and a one-byte disagreement between them would make
/// every refresh republish for ever.
pub const body_separator = "\x00";

pub const Error = error{ OutOfMemory, TooManyDomains, ReadFailed, WriteFailed };

/// Reads `r` to end of stream and writes the three compiled bodies.
///
/// `counts.domains`, `counts.wildcards` and `counts.exceptions` are the written,
/// deduplicated counts: they are what `blocklist_sources.domain_count`,
/// `wildcard_count` and `exception_count` store and what the UI shows.
pub fn compile(
    gpa: std.mem.Allocator,
    r: *std.Io.Reader,
    format: parsers.Format,
    list_w: *std.Io.Writer,
    wild_w: *std.Io.Writer,
    allow_w: *std.Io.Writer,
) Error!Result {
    var counts: Counts = .{};

    var bodies: Bodies = .{};
    defer bodies.deinit(gpa);

    while (try parsers.nextBoundedLine(r, max_line_len)) |event| {
        const raw = switch (event) {
            .long_line => {
                counts.long_lines += 1;
                continue;
            },
            .line => |line| line,
        };

        var line = raw;
        if (line.len != 0 and line[line.len - 1] == '\r') line = line[0 .. line.len - 1];

        const parsed = parsers.parseLine(format, line);
        switch (parsed.kind) {
            .ignore => {},
            .regex => counts.skipped_regex += 1,
            .unsupported => counts.skipped_unsupported += 1,
            .domain => {
                var fields = std.mem.tokenizeAny(u8, parsed.text, &std.ascii.whitespace);
                while (fields.next()) |field| {
                    try addCandidate(gpa, field, parsed, &bodies, &counts);
                }
            },
            .wildcard, .exception => try addCandidate(gpa, parsed.text, parsed, &bodies, &counts),
        }
    }

    // Each body is followed by `body_separator`, which is what keeps the digest
    // from confusing a name in one body with the same name in another.
    var hasher = Sha256.init(.{});
    counts.domains = try emit(&bodies.list, list_w, &hasher, &counts.duplicates);
    hasher.update(body_separator);
    counts.wildcards = try emit(&bodies.wild, wild_w, &hasher, &counts.duplicates);
    hasher.update(body_separator);
    counts.exceptions = try emit(&bodies.allow, allow_w, &hasher, &counts.duplicates);
    hasher.update(body_separator);

    var digest: [Sha256.digest_length]u8 = undefined;
    hasher.final(&digest);
    return .{ .counts = counts, .checksum = std.fmt.bytesToHex(digest, .lower) };
}

/// Normalizes one whitespace-separated candidate of `line` and files it under
/// `.list`, `.wild`, `.allow`, or nowhere.
///
/// The normalization below is deliberately not `dns.name.normalizeText`: this
/// one adds the two-label minimum, rejects control bytes, and reports every
/// rejection through `counts.invalid` rather than an error.
fn addCandidate(
    gpa: std.mem.Allocator,
    field: []const u8,
    line: parsers.Line,
    bodies: *Bodies,
    counts: *Counts,
) Error!void {
    var candidate = field;
    var is_wildcard = line.kind == .wildcard;
    if (std.mem.startsWith(u8, candidate, "*.")) {
        is_wildcard = true;
        candidate = candidate[2..];
    }
    // A '*' anywhere else makes this a pattern, and patterns belong to the
    // `rules` table, where the operator writes them as a `.wildcard` or a
    // `.regex`; a blocklist entry is a name or a suffix.
    if (std.mem.indexOfScalar(u8, candidate, '*') != null) {
        counts.invalid += 1;
        return;
    }

    if (candidate.len != 0 and candidate[candidate.len - 1] == '.') {
        candidate = candidate[0 .. candidate.len - 1];
    }
    if (candidate.len == 0 or candidate.len > types.max_name_len) {
        counts.invalid += 1;
        return;
    }

    var buf: [types.max_name_len]u8 = undefined;
    for (candidate, 0..) |c, i| {
        if (c >= 0x80 or std.ascii.isControl(c)) {
            counts.invalid += 1;
            return;
        }
        buf[i] = std.ascii.toLower(c);
    }
    const normalized = buf[0..candidate.len];

    const parsed_name = name.fromText(normalized) catch {
        counts.invalid += 1;
        return;
    };
    // The two-label minimum is what keeps `localhost`, `local`, `broadcasthost`
    // and the `ip6-*` names that every hosts list carries from black-holing the
    // loopback names of every client on the LAN.
    if (parsed_name.labelCount() < 2) {
        counts.invalid += 1;
        return;
    }

    // An exception needs no apex entry beside its suffix entry: the matcher
    // walks the `.allow` set over the full name and every parent, so one entry
    // lifts `x` and every subdomain of it at once.
    if (line.kind == .exception) {
        try bodies.allow.append(gpa, normalized);
    } else if (is_wildcard) {
        try bodies.wild.append(gpa, normalized);
        // An ABP `||x^` rule covers `x` itself as well as its subdomains.
        if (line.covers_apex) try bodies.list.append(gpa, normalized);
    } else {
        try bodies.list.append(gpa, normalized);
    }
}

/// Sorts, deduplicates, writes and hashes one body. Returns the written count.
fn emit(
    entries: *Entries,
    w: *std.Io.Writer,
    hasher: *Sha256,
    duplicates: *u32,
) Error!u32 {
    const offsets = entries.offsets.items;
    if (offsets.len > 1) std.mem.sortUnstableContext(0, offsets.len, SortContext{ .entries = entries });

    var written: u32 = 0;
    var prev: []const u8 = "";
    var first = true;
    for (0..offsets.len) |i| {
        const text = entries.get(i);
        if (!first and std.mem.eql(u8, prev, text)) {
            duplicates.* += 1;
            continue;
        }
        try w.writeAll(text);
        try w.writeByte('\n');
        hasher.update(text);
        hasher.update("\n");
        prev = text;
        first = false;
        written += 1;
    }
    return written;
}

/// The three bodies under construction, in the order they are written and
/// hashed.
const Bodies = struct {
    list: Entries = .{},
    wild: Entries = .{},
    allow: Entries = .{},

    fn deinit(self: *Bodies, gpa: std.mem.Allocator) void {
        self.list.deinit(gpa);
        self.wild.deinit(gpa);
        self.allow.deinit(gpa);
    }
};

/// Length-prefixed candidate bytes plus the offsets that index them. Sorting
/// permutes the offsets, so the bytes never move.
const Entries = struct {
    bytes: std.ArrayList(u8) = .empty,
    offsets: std.ArrayList(u32) = .empty,

    fn deinit(self: *Entries, gpa: std.mem.Allocator) void {
        self.bytes.deinit(gpa);
        self.offsets.deinit(gpa);
    }

    fn append(self: *Entries, gpa: std.mem.Allocator, text: []const u8) Error!void {
        if (self.offsets.items.len >= max_domains) return error.TooManyDomains;
        const offset = std.math.cast(u32, self.bytes.items.len) orelse return error.TooManyDomains;
        try self.bytes.append(gpa, @intCast(text.len));
        try self.bytes.appendSlice(gpa, text);
        try self.offsets.append(gpa, offset);
    }

    fn get(self: *const Entries, i: usize) []const u8 {
        const offset = self.offsets.items[i];
        const len = self.bytes.items[offset];
        return self.bytes.items[offset + 1 ..][0..len];
    }
};

const SortContext = struct {
    entries: *Entries,

    pub fn lessThan(self: SortContext, a: usize, b: usize) bool {
        return std.mem.order(u8, self.entries.get(a), self.entries.get(b)) == .lt;
    }

    pub fn swap(self: SortContext, a: usize, b: usize) void {
        const offsets = self.entries.offsets.items;
        std.mem.swap(u32, &offsets[a], &offsets[b]);
    }
};

const testing = std.testing;

const Compiled = struct {
    result: Result,
    list_w: std.Io.Writer.Allocating,
    wild_w: std.Io.Writer.Allocating,
    allow_w: std.Io.Writer.Allocating,

    fn deinit(self: *Compiled) void {
        self.list_w.deinit();
        self.wild_w.deinit();
        self.allow_w.deinit();
    }

    fn list(self: *Compiled) []const u8 {
        return self.list_w.written();
    }

    fn wild(self: *Compiled) []const u8 {
        return self.wild_w.written();
    }

    fn allow(self: *Compiled) []const u8 {
        return self.allow_w.written();
    }
};

fn compileText(gpa: std.mem.Allocator, text: []const u8, format: parsers.Format) Error!Compiled {
    var r: std.Io.Reader = .fixed(text);
    return compileReader(gpa, &r, format);
}

fn compileReader(gpa: std.mem.Allocator, r: *std.Io.Reader, format: parsers.Format) Error!Compiled {
    var list_w: std.Io.Writer.Allocating = .init(gpa);
    errdefer list_w.deinit();
    var wild_w: std.Io.Writer.Allocating = .init(gpa);
    errdefer wild_w.deinit();
    var allow_w: std.Io.Writer.Allocating = .init(gpa);
    errdefer allow_w.deinit();
    const result = try compile(gpa, r, format, &list_w.writer, &wild_w.writer, &allow_w.writer);
    return .{ .result = result, .list_w = list_w, .wild_w = wild_w, .allow_w = allow_w };
}

const hosts_fixture =
    "# a comment\n" ++
    "0.0.0.0 ads.example.com\n" ++
    "0.0.0.0 ads.example.com\n" ++
    "127.0.0.1 localhost\n" ++
    "0.0.0.0 tracker.example.org # tracker\n" ++
    "/ads\\d+/\n" ++
    "\n" ++
    "0.0.0.0 EXAMPLE.com.\n";

test "hosts fixture compiles to a sorted deduplicated body" {
    var c = try compileText(testing.allocator, hosts_fixture, .hosts);
    defer c.deinit();

    try testing.expectEqualStrings(
        "ads.example.com\nexample.com\ntracker.example.org\n",
        c.list(),
    );
    try testing.expectEqualStrings("", c.wild());
    try testing.expectEqual(@as(u32, 3), c.result.counts.domains);
    try testing.expectEqual(@as(u32, 0), c.result.counts.wildcards);
    try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_regex);
    try testing.expectEqual(@as(u32, 0), c.result.counts.skipped_unsupported);
    try testing.expectEqual(@as(u32, 1), c.result.counts.invalid);
    try testing.expectEqual(@as(u32, 0), c.result.counts.long_lines);
    try testing.expectEqual(@as(u32, 1), c.result.counts.duplicates);
}

test "domains fixture compiles" {
    const fixture =
        "# a comment\n" ++
        "b.example.com\n" ++
        "! a bang comment\n" ++
        "a.example.com\n" ++
        "/re/\n" ++
        "0.0.0.0 two.fields.com\n" ++
        "localhost\n";

    var c = try compileText(testing.allocator, fixture, .domains);
    defer c.deinit();

    try testing.expectEqualStrings("a.example.com\nb.example.com\n", c.list());
    try testing.expectEqualStrings("", c.wild());
    try testing.expectEqual(@as(u32, 2), c.result.counts.domains);
    try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_regex);
    try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_unsupported);
    try testing.expectEqual(@as(u32, 1), c.result.counts.invalid);
}

test "abp apex rule lands in both bodies" {
    const fixture =
        "! Title: test\n" ++
        "||x.com^\n" ++
        "||y.com^$third-party\n" ++
        "@@||z.com^\n" ++
        "/re/\n" ++
        "bare.com\n";

    var c = try compileText(testing.allocator, fixture, .abp);
    defer c.deinit();

    try testing.expectEqualStrings("bare.com\nx.com\n", c.list());
    try testing.expectEqualStrings("x.com\n", c.wild());
    try testing.expectEqualStrings("z.com\n", c.allow());
    try testing.expectEqual(@as(u32, 2), c.result.counts.domains);
    try testing.expectEqual(@as(u32, 1), c.result.counts.wildcards);
    try testing.expectEqual(@as(u32, 1), c.result.counts.exceptions);
    try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_regex);
    try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_unsupported);
}

test "an abp list's hosts-style lines reach the domain body through the split" {
    // The case `parser_abp` defers here: it hands a whitespace-carrying bare
    // candidate over whole, and only the tokenization in `compile` files the
    // name out of it. A mixed list — `!` header and `||` rules, so `detectFormat`
    // calls the whole file `abp`, plus the hosts lines such lists carry — reaches
    // a compiled body no other way, and no parser test can see it happen.
    const fixture =
        "! Title: mixed\n" ++
        "||blocked.example^\n" ++
        "0.0.0.0 ads.example\n" ++
        "127.0.0.1 localhost\n";

    var c = try compileText(testing.allocator, fixture, .abp);
    defer c.deinit();

    // The address field is filed as a name of its own: abp lines have no hosts
    // framing, so the compiler cannot know which field is the address. `0.0.0.0`
    // and `127.0.0.1` are names nobody resolves, which is why the split is worth
    // more than the two spurious entries cost.
    try testing.expectEqualStrings(
        "0.0.0.0\n127.0.0.1\nads.example\nblocked.example\n",
        c.list(),
    );
    try testing.expectEqualStrings("blocked.example\n", c.wild());
    try testing.expectEqual(@as(u32, 4), c.result.counts.domains);
    // `localhost` is the one label the two-label minimum drops.
    try testing.expectEqual(@as(u32, 1), c.result.counts.invalid);
}

test "the allow body is sorted, deduplicated and normalized like the others" {
    const fixture =
        "@@||GOOD.ads.example^\n" ++
        "@@||a.ads.example^$important\n" ++
        "@@||good.ads.example\n" ++
        "@@||localhost^\n" ++
        "@@||bad*.ads.example^\n" ++
        "||ads.example^\n";

    var c = try compileText(testing.allocator, fixture, .abp);
    defer c.deinit();

    try testing.expectEqualStrings("a.ads.example\ngood.ads.example\n", c.allow());
    try testing.expectEqual(@as(u32, 2), c.result.counts.exceptions);
    try testing.expectEqual(@as(u32, 1), c.result.counts.duplicates);
    // `localhost` is one label, and the starred name is not a name at all.
    try testing.expectEqual(@as(u32, 1), c.result.counts.invalid);
    try testing.expectEqual(@as(u32, 1), c.result.counts.skipped_unsupported);

    // The exceptions changed neither block body.
    try testing.expectEqualStrings("ads.example\n", c.list());
    try testing.expectEqualStrings("ads.example\n", c.wild());
}

test "moving a name between bodies changes the checksum" {
    // The collision the separator exists to prevent. A wildcard is stored with
    // its `*.` stripped, so both compiles write the bytes `a.example\n` — one
    // into the `.list` body, one into the `.wild` body. Unframed, the two hash
    // identically, `diskBodiesMatch` accepts the stale files, and an upstream
    // that switched a name to a wildcard never takes effect.
    var exact = try compileText(testing.allocator, "a.example\n", .domains);
    defer exact.deinit();
    var wild = try compileText(testing.allocator, "*.a.example\n", .domains);
    defer wild.deinit();

    try testing.expectEqualStrings("a.example\n", exact.list());
    try testing.expectEqualStrings("", exact.wild());
    try testing.expectEqualStrings("", wild.list());
    try testing.expectEqualStrings("a.example\n", wild.wild());

    try testing.expect(!std.mem.eql(u8, &exact.result.checksum, &wild.result.checksum));
}

test "the checksum of a source with exceptions covers all three bodies in order" {
    const fixture =
        "||ads.example^\n" ++
        "@@||good.ads.example^\n";

    var c = try compileText(testing.allocator, fixture, .abp);
    defer c.deinit();

    var hasher = Sha256.init(.{});
    hasher.update(c.list());
    hasher.update(body_separator);
    hasher.update(c.wild());
    hasher.update(body_separator);
    hasher.update(c.allow());
    hasher.update(body_separator);
    var digest: [Sha256.digest_length]u8 = undefined;
    hasher.final(&digest);
    try testing.expectEqualStrings(&std.fmt.bytesToHex(digest, .lower), &c.result.checksum);

    // A non-empty allow body does move the digest, so a list that gains an
    // exception is recompiled rather than silently kept.
    var without = try compileText(testing.allocator, "||ads.example^\n", .abp);
    defer without.deinit();
    try testing.expect(!std.mem.eql(u8, &c.result.checksum, &without.result.checksum));
}

test "two runs of the same input are byte-identical" {
    var a = try compileText(testing.allocator, hosts_fixture, .hosts);
    defer a.deinit();
    var b = try compileText(testing.allocator, hosts_fixture, .hosts);
    defer b.deinit();

    try testing.expectEqualStrings(a.list(), b.list());
    try testing.expectEqualStrings(a.wild(), b.wild());
    try testing.expectEqualStrings(a.allow(), b.allow());
    try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum);
}

test "a permutation of the input compiles to the same bodies" {
    const shuffled =
        "0.0.0.0 EXAMPLE.com.\n" ++
        "/ads\\d+/\n" ++
        "0.0.0.0 tracker.example.org # tracker\n" ++
        "\n" ++
        "0.0.0.0 ads.example.com\n" ++
        "127.0.0.1 localhost\n" ++
        "# a comment\n" ++
        "0.0.0.0 ads.example.com\n";

    var a = try compileText(testing.allocator, hosts_fixture, .hosts);
    defer a.deinit();
    var b = try compileText(testing.allocator, shuffled, .hosts);
    defer b.deinit();

    try testing.expectEqualStrings(a.list(), b.list());
    try testing.expectEqualStrings(a.wild(), b.wild());
    try testing.expectEqualStrings(a.allow(), b.allow());
    try testing.expectEqualSlices(u8, &a.result.checksum, &b.result.checksum);
}

test "uppercase and trailing dot normalize" {
    var c = try compileText(testing.allocator, "AdS.Example.COM.\n", .domains);
    defer c.deinit();
    try testing.expectEqualStrings("ads.example.com\n", c.list());
}

test "invalid candidates are counted and written nowhere" {
    const fixture =
        "caf\xc3\xa9.example.com\n" ++
        "localhost\n" ++
        "a*b.com\n";

    var c = try compileText(testing.allocator, fixture, .domains);
    defer c.deinit();

    try testing.expectEqual(@as(u32, 3), c.result.counts.invalid);
    try testing.expectEqual(@as(u32, 0), c.result.counts.domains);
    try testing.expectEqualStrings("", c.list());
    try testing.expectEqualStrings("", c.wild());
}

test "a leading star label becomes a wildcard entry" {
    var c = try compileText(testing.allocator, "*.ads.example.com\n", .domains);
    defer c.deinit();
    try testing.expectEqualStrings("", c.list());
    try testing.expectEqualStrings("ads.example.com\n", c.wild());
    try testing.expectEqual(@as(u32, 1), c.result.counts.wildcards);
}

test "an over-long line is skipped whole" {
    const gpa = testing.allocator;
    var text: std.ArrayList(u8) = .empty;
    defer text.deinit(gpa);

    var line_buf: [64]u8 = undefined;
    var i: usize = 0;
    while (i < 3_000) : (i += 1) {
        if (i == 1_500) {
            try text.appendNTimes(gpa, 'x', 5_000);
            try text.append(gpa, '\n');
        }
        try text.appendSlice(gpa, try std.fmt.bufPrint(&line_buf, "d{d:0>5}.example.com\n", .{i}));
    }

    // A reader buffer smaller than the long line makes `takeDelimiter` report
    // `error.StreamTooLong`, which is the path that loops forever without the
    // discard.
    var backing: std.Io.Reader = .fixed(text.items);
    var buf: [max_line_len]u8 = undefined;
    var limited = backing.limited(.unlimited, &buf);

    var c = try compileReader(gpa, &limited.interface, .domains);
    defer c.deinit();

    try testing.expectEqual(@as(u32, 1), c.result.counts.long_lines);
    try testing.expectEqual(@as(u32, 3_000), c.result.counts.domains);
    try testing.expect(std.mem.startsWith(u8, c.list(), "d00000.example.com\n"));
    try testing.expect(std.mem.endsWith(u8, c.list(), "d02999.example.com\n"));
    try testing.expect(std.mem.indexOf(u8, c.list(), "d01499.example.com\n") != null);
    try testing.expect(std.mem.indexOf(u8, c.list(), "d01500.example.com\n") != null);
    try testing.expect(std.mem.indexOf(u8, c.list(), "xxxx") == null);
}

test "an over-long line is skipped when the reader buffer is large" {
    const gpa = testing.allocator;
    var text: std.ArrayList(u8) = .empty;
    defer text.deinit(gpa);

    try text.appendSlice(gpa, "a.example.com\n");
    try text.appendNTimes(gpa, 'x', 5_000);
    try text.append(gpa, '\n');
    try text.appendSlice(gpa, "b.example.com\n");

    var c = try compileText(gpa, text.items, .domains);
    defer c.deinit();

    try testing.expectEqual(@as(u32, 1), c.result.counts.long_lines);
    try testing.expectEqualStrings("a.example.com\nb.example.com\n", c.list());
}

test "an over-long final line with no newline ends the stream inside the discard" {
    const gpa = testing.allocator;
    var text: std.ArrayList(u8) = .empty;
    defer text.deinit(gpa);

    try text.appendSlice(gpa, "a.example.com\n");
    try text.appendNTimes(gpa, 'x', 5_000);

    // A reader buffer smaller than the trailing line makes `takeDelimiter`
    // report `error.StreamTooLong`, and the discard that follows then runs out
    // of input because nothing terminates that line. That is the `EndOfStream`
    // arm: the loop must count the line and stop, not treat the exhausted
    // stream as a read failure.
    var backing: std.Io.Reader = .fixed(text.items);
    var buf: [max_line_len]u8 = undefined;
    var limited = backing.limited(.unlimited, &buf);

    var c = try compileReader(gpa, &limited.interface, .domains);
    defer c.deinit();

    try testing.expectEqual(@as(u32, 1), c.result.counts.long_lines);
    try testing.expectEqual(@as(u32, 1), c.result.counts.domains);
    try testing.expectEqualStrings("a.example.com\n", c.list());
    try testing.expect(std.mem.indexOf(u8, c.list(), "xxxx") == null);
}

test "carriage returns are stripped" {
    var c = try compileText(testing.allocator, "b.example.com\r\na.example.com\r\n", .domains);
    defer c.deinit();
    try testing.expectEqualStrings("a.example.com\nb.example.com\n", c.list());
}

test "empty input produces empty bodies and the digest of three separators" {
    var c = try compileText(testing.allocator, "", .domains);
    defer c.deinit();

    try testing.expectEqualStrings("", c.list());
    try testing.expectEqualStrings("", c.wild());
    try testing.expectEqualStrings("", c.allow());
    // Three empty bodies still hash their three separators, so an empty compile
    // has a digest of its own rather than the sha256 of the empty string.
    try testing.expectEqualStrings(
        "709e80c88487a2411e1ee4dfb9f22a861492d20c4765150c0c794abd70f8147c",
        &c.result.checksum,
    );
}

fn compileUnderFailure(gpa: std.mem.Allocator) !void {
    var c = compileText(gpa, hosts_fixture, .hosts) catch |err| switch (err) {
        // `Writer.Allocating` reports an exhausted allocator as `WriteFailed`;
        // it has no other failure mode.
        error.WriteFailed => return error.OutOfMemory,
        else => |e| return e,
    };
    defer c.deinit();
    try testing.expectEqual(@as(u32, 3), c.result.counts.domains);
}

test "compile under allocation failure" {
    try testing.checkAllAllocationFailures(testing.allocator, compileUnderFailure, .{});
}
