//! One group's explicit rules (PLAN §3.10 levels 1–6), compiled once into an
//! immutable form the query path can read without allocating.
//!
//! Exact patterns go into a `DomainSet`; wildcard patterns stay a flat, sorted
//! array of strings and are scanned linearly. Operator-authored wildcards are
//! few — `max_wildcards_per_group` caps them at 4096 — and a linear scan over
//! that many short patterns is cheaper than an index that would have to be
//! rebuilt on every snapshot swap.
//!
//! Regex patterns are compiled here, once per snapshot, into the linear-time
//! programs of `regex.zig` and scanned the same way. `max_regex_per_group` caps
//! them far lower, at 256: a regex costs a whole VM run where a wildcard costs a
//! label comparison, and the matcher reaches them only after every hash and
//! wildcard level has missed.
//!
//! Pure: allocators and plain values, no `std.Io`, no clock, no entropy
//! source. The hash seed arrives as a parameter.

const std = @import("std");
const Allocator = std.mem.Allocator;

const model = @import("../config/model.zig");
const name = @import("../dns/name.zig");
const types = @import("../dns/types.zig");
const domain_set = @import("domain_set.zig");
const regex = @import("regex.zig");
const wildcard = @import("wildcard.zig");

pub const Error = error{
    OutOfMemory,
    BadPattern,
    TooManyWildcards,
    TooManyRegexRules,
} || domain_set.DomainSet.Error;

/// Both wildcard lists of one group together. The cap exists so a rules table
/// edited into the millions cannot turn every query into a linear scan.
pub const max_wildcards_per_group: usize = 4096;

/// Both regex lists of one group together, capped well below the wildcards: a
/// miss at this level runs every program to its end.
pub const max_regex_per_group: usize = 256;

/// A compiled operator regex beside the text it was written as. The text is what
/// `Decision.matched` reports, so the query log names the rule the operator
/// wrote rather than an instruction count.
pub const RegexRule = struct {
    pattern: []const u8,
    program: regex.Program,

    /// Frees through a copy of the program: the slices holding these rules are
    /// `const`, and `Program.deinit` wants a mutable pointer only to blank the
    /// struct it is finished with.
    fn free(self: RegexRule, gpa: Allocator) void {
        var program = self.program;
        program.deinit(gpa);
    }
};

pub const RuleSet = struct {
    exact_allow: domain_set.DomainSet = .empty,
    exact_block: domain_set.DomainSet = .empty,
    /// Normalized and sorted, so a rebuild of the same rows produces the same
    /// order and the same first match.
    wildcard_allow: []const []const u8 = &.{},
    wildcard_block: []const []const u8 = &.{},
    /// Sorted and deduplicated like the wildcards, so the first regex to match a
    /// name is the same one on every rebuild of the same rows.
    regex_allow: []const RegexRule = &.{},
    regex_block: []const RegexRule = &.{},
    /// One block holding the pattern bytes of all four lists; freed as a unit.
    pattern_bytes: []const u8 = &.{},

    pub const empty: RuleSet = .{};

    /// `rows` are one group's rules only; splitting `listRules` output by group
    /// belongs to the caller, which is the only holder of the group table.
    ///
    /// Name patterns are normalized (lowercase over ASCII, one trailing dot
    /// stripped) and validated: `.exact` through `dns.name.fromText`,
    /// `.wildcard` through `wildcard.validate`, `.regex` by compiling it. An
    /// invalid pattern is `error.BadPattern`, not a skipped row — every pattern
    /// passed `config/validate.zig` on the way in, so an invalid one here means
    /// the rows were edited underneath nxdns and a silently dropped allow rule
    /// would block a domain the operator unblocked.
    ///
    /// Two allocators, because the caller's `perm` is a snapshot arena: an
    /// arena reclaims only its most recent allocation, so every temporary taken
    /// from it would live as long as the snapshot and go unreported by
    /// `memoryBytes`. `perm` owns what the returned set retains and is what
    /// `deinit` frees; `scratch` owns the build's working storage, which is
    /// released by the time `build` returns. Passing one allocator as both is
    /// correct wherever freeing works normally.
    pub fn build(
        perm: Allocator,
        scratch: Allocator,
        rows: []const model.Rule,
        seed: u64,
    ) Error!RuleSet {
        if (rows.len == 0) return .empty;

        var joined: std.ArrayList(u8) = .empty;
        defer joined.deinit(scratch);
        var spans: [6]std.ArrayList(Span) = @splat(.empty);
        defer for (&spans) |*bucket| bucket.deinit(scratch);

        var wildcards: usize = 0;
        var regexes: usize = 0;
        var buf: [types.max_name_len]u8 = undefined;
        for (rows) |row| {
            const pattern = switch (row.kind) {
                .exact => blk: {
                    const text = normalize(row.pattern, &buf) catch return error.BadPattern;
                    _ = name.fromText(text) catch return error.BadPattern;
                    break :blk text;
                },
                .wildcard => blk: {
                    const text = normalize(row.pattern, &buf) catch return error.BadPattern;
                    wildcard.validate(text) catch return error.BadPattern;
                    wildcards += 1;
                    if (wildcards > max_wildcards_per_group) return error.TooManyWildcards;
                    break :blk text;
                },
                // A regex is not a name, so `normalize` must not touch it: it
                // strips a trailing `.`, which here is the any-byte atom, and it
                // lowercases, which turns the rejected `\D` into the accepted
                // `\d`. Either would silently change what the rule matches. The
                // bytes stay as the operator wrote them — the same bytes
                // `config/validate.zig` compiled at the edge. Compiling waits
                // until after the sort, so a duplicate is compiled once.
                .regex => blk: {
                    regexes += 1;
                    if (regexes > max_regex_per_group) return error.TooManyRegexRules;
                    break :blk row.pattern;
                },
            };
            const bucket = &spans[bucketOf(row.kind, row.action)];
            try bucket.append(scratch, .{ .offset = joined.items.len, .len = pattern.len });
            try joined.appendSlice(scratch, pattern);
        }

        // `joined` stops growing here, so spans can become slices of it.
        var sorted: [6]std.ArrayList([]const u8) = @splat(.empty);
        defer for (&sorted) |*bucket| bucket.deinit(scratch);
        for (&spans, &sorted) |*bucket, *out| {
            try out.ensureTotalCapacityPrecise(scratch, bucket.items.len);
            for (bucket.items) |span| {
                out.appendAssumeCapacity(joined.items[span.offset..][0..span.len]);
            }
            std.mem.sort([]const u8, out.items, {}, lessThanBytes);
            dedupSorted(out);
        }

        var self: RuleSet = .empty;
        errdefer self.deinit(perm);

        self.exact_allow = try buildSet(perm, scratch, sorted[bucketOf(.exact, .allow)].items, seed);
        self.exact_block = try buildSet(perm, scratch, sorted[bucketOf(.exact, .block)].items, seed);

        const wild_allow = sorted[bucketOf(.wildcard, .allow)].items;
        const wild_block = sorted[bucketOf(.wildcard, .block)].items;
        const re_allow = sorted[bucketOf(.regex, .allow)].items;
        const re_block = sorted[bucketOf(.regex, .block)].items;
        var total: usize = 0;
        for ([_][]const []const u8{ wild_allow, wild_block, re_allow, re_block }) |list| {
            for (list) |pattern| total += pattern.len;
        }

        const bytes = try perm.alloc(u8, total);
        self.pattern_bytes = bytes;
        var at: usize = 0;
        self.wildcard_allow = try copyPatterns(perm, wild_allow, bytes, &at);
        self.wildcard_block = try copyPatterns(perm, wild_block, bytes, &at);
        self.regex_allow = try compilePatterns(perm, scratch, re_allow, bytes, &at);
        self.regex_block = try compilePatterns(perm, scratch, re_block, bytes, &at);

        return self;
    }

    pub fn deinit(self: *RuleSet, gpa: Allocator) void {
        self.exact_allow.deinit(gpa);
        self.exact_block.deinit(gpa);
        gpa.free(self.wildcard_allow);
        gpa.free(self.wildcard_block);
        freeRules(gpa, self.regex_allow);
        freeRules(gpa, self.regex_block);
        gpa.free(self.pattern_bytes);
        self.* = .empty;
    }

    pub fn memoryBytes(self: *const RuleSet) usize {
        var programs: usize = 0;
        for (self.regex_allow) |item| programs += item.program.memoryBytes();
        for (self.regex_block) |item| programs += item.program.memoryBytes();
        return self.exact_allow.memoryBytes() +
            self.exact_block.memoryBytes() +
            self.pattern_bytes.len +
            programs +
            (self.wildcard_allow.len + self.wildcard_block.len) * @sizeOf([]const u8) +
            (self.regex_allow.len + self.regex_block.len) * @sizeOf(RegexRule);
    }
};

/// The first regex of `list` that matches `domain`, or null. `list` is sorted,
/// so "first" is stable across rebuilds of the same rows. The caller checks the
/// allow list before the block list, as it does for wildcards.
pub fn matchRegex(list: []const RegexRule, domain: []const u8) ?[]const u8 {
    for (list) |item| {
        if (regex.matches(&item.program, domain)) return item.pattern;
    }
    return null;
}

// ---------------------------------------------------------------------------
// Internals
// ---------------------------------------------------------------------------

/// Patterns are recorded as offsets because `scratch` reallocates while it
/// grows; they become slices only after the collecting pass ends.
const Span = struct { offset: usize, len: usize };

fn bucketOf(kind: model.RuleKind, action: model.RuleAction) usize {
    const kind_base: usize = switch (kind) {
        .exact => 0,
        .wildcard => 2,
        .regex => 4,
    };
    const action_offset: usize = switch (action) {
        .allow => 0,
        .block => 1,
    };
    return kind_base + action_offset;
}

fn lessThanBytes(_: void, a: []const u8, b: []const u8) bool {
    return std.mem.order(u8, a, b) == .lt;
}

/// Duplicate rows are removed rather than rejected: two identical block rules
/// are not a corrupt database, and `DomainSet.build` requires a strictly
/// ascending body.
fn dedupSorted(list: *std.ArrayList([]const u8)) void {
    var kept: usize = 0;
    for (list.items) |item| {
        if (kept > 0 and std.mem.eql(u8, list.items[kept - 1], item)) continue;
        list.items[kept] = item;
        kept += 1;
    }
    list.shrinkRetainingCapacity(kept);
}

fn buildSet(
    perm: Allocator,
    scratch: Allocator,
    patterns: []const []const u8,
    seed: u64,
) Error!domain_set.DomainSet {
    if (patterns.len == 0) return .empty;

    var body: std.ArrayList(u8) = .empty;
    defer body.deinit(scratch);
    for (patterns) |pattern| {
        try body.appendSlice(scratch, pattern);
        try body.append(scratch, '\n');
    }
    return domain_set.DomainSet.build(perm, body.items, seed);
}

fn copyPatterns(
    perm: Allocator,
    patterns: []const []const u8,
    bytes: []u8,
    at: *usize,
) Error![]const []const u8 {
    if (patterns.len == 0) return &.{};
    const out = try perm.alloc([]const u8, patterns.len);
    for (out, patterns) |*slot, pattern| {
        @memcpy(bytes[at.*..][0..pattern.len], pattern);
        slot.* = bytes[at.*..][0..pattern.len];
        at.* += pattern.len;
    }
    return out;
}

/// Copies the pattern texts into `bytes` like `copyPatterns` and compiles each
/// one. A compile failure is `error.BadPattern` whichever of the engine's three
/// refusals fired: the pattern already passed `config/validate.zig`, which names
/// the limit, so a row that fails here was written around that check.
///
/// Compiling into `scratch` and cloning across is what keeps a parse-time AST
/// out of `perm`: `regex.compile` builds the AST, the child lists and the
/// growing instruction buffer through the allocator it returns the program on.
fn compilePatterns(
    perm: Allocator,
    scratch: Allocator,
    patterns: []const []const u8,
    bytes: []u8,
    at: *usize,
) Error![]const RegexRule {
    if (patterns.len == 0) return &.{};
    const out = try perm.alloc(RegexRule, patterns.len);
    var built: usize = 0;
    errdefer {
        for (out[0..built]) |item| item.free(perm);
        perm.free(out);
    }
    for (out, patterns) |*slot, pattern| {
        var compiled = regex.compile(scratch, pattern) catch |err| switch (err) {
            error.OutOfMemory => return error.OutOfMemory,
            else => return error.BadPattern,
        };
        defer compiled.deinit(scratch);
        const program = try compiled.clone(perm);

        @memcpy(bytes[at.*..][0..pattern.len], pattern);
        slot.* = .{ .pattern = bytes[at.*..][0..pattern.len], .program = program };
        at.* += pattern.len;
        built += 1;
    }
    return out;
}

fn freeRules(gpa: Allocator, list: []const RegexRule) void {
    for (list) |item| item.free(gpa);
    gpa.free(list);
}

const NameError = error{BadName};

/// Lowercases over ASCII and strips one trailing dot. A byte ≥ 0x80 is
/// rejected: query names reach the matcher ASCII-lowercased, so a pattern
/// carrying a high byte could never match anything.
///
/// Deliberately not `dns.name.normalizeText`: a pattern may hold `*`, which
/// `name.fromText` would reject, so this variant skips that check and rejects
/// control bytes and space instead.
fn normalize(text: []const u8, buf: *[types.max_name_len]u8) NameError![]const u8 {
    var rest = text;
    if (rest.len > 0 and rest[rest.len - 1] == '.') rest = rest[0 .. rest.len - 1];
    if (rest.len == 0 or rest.len > types.max_name_len) return error.BadName;

    for (rest, 0..) |byte, i| {
        if (byte >= 0x80 or byte < 0x21) return error.BadName;
        buf[i] = std.ascii.toLower(byte);
    }
    return buf[0..rest.len];
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

const testing = std.testing;

fn rule(pattern: []const u8, kind: model.RuleKind, action: model.RuleAction) model.Rule {
    return .{ .group = "default", .pattern = pattern, .kind = kind, .action = action };
}

test "exact rules land in the matching set" {
    const rows = [_]model.Rule{
        rule("ads.example.com", .exact, .block),
        rule("good.example.com", .exact, .allow),
    };
    var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
    defer set.deinit(testing.allocator);

    try testing.expect(set.exact_block.contains("ads.example.com"));
    try testing.expect(!set.exact_block.contains("good.example.com"));
    try testing.expect(set.exact_allow.contains("good.example.com"));
    try testing.expectEqual(@as(usize, 0), set.wildcard_allow.len);
    try testing.expectEqual(@as(usize, 0), set.wildcard_block.len);
}

test "wildcard rules land in the matching list, sorted" {
    const rows = [_]model.Rule{
        rule("*.z.example.com", .wildcard, .block),
        rule("*.a.example.com", .wildcard, .block),
        rule("*.allowed.example.com", .wildcard, .allow),
    };
    var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
    defer set.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 2), set.wildcard_block.len);
    try testing.expectEqualStrings("*.a.example.com", set.wildcard_block[0]);
    try testing.expectEqualStrings("*.z.example.com", set.wildcard_block[1]);
    try testing.expectEqual(@as(usize, 1), set.wildcard_allow.len);
    try testing.expectEqualStrings("*.allowed.example.com", set.wildcard_allow[0]);
}

test "patterns are normalized to lowercase without a trailing dot" {
    const rows = [_]model.Rule{
        rule("ADS.Example.COM.", .exact, .block),
        rule("*.Tracker.NET.", .wildcard, .block),
    };
    var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
    defer set.deinit(testing.allocator);

    try testing.expect(set.exact_block.contains("ads.example.com"));
    try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]);
}

test "duplicate rows collapse to one entry" {
    const rows = [_]model.Rule{
        rule("ads.example.com", .exact, .block),
        rule("ads.example.com.", .exact, .block),
        rule("*.x.example.com", .wildcard, .block),
        rule("*.x.example.com", .wildcard, .block),
    };
    var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
    defer set.deinit(testing.allocator);

    try testing.expectEqual(@as(u32, 1), set.exact_block.count);
    try testing.expectEqual(@as(usize, 1), set.wildcard_block.len);
}

test "an invalid exact pattern is an error" {
    for ([_][]const u8{ "", ".", "a..b", "ads example.com", "ads\u{00e9}.example.com" }) |pattern| {
        const rows = [_]model.Rule{rule(pattern, .exact, .block)};
        try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
    }
}

test "regex rules land in their own buckets, compiled and sorted" {
    const rows = [_]model.Rule{
        rule("^zz", .regex, .block),
        rule("^aa", .regex, .block),
        rule("ok$", .regex, .allow),
    };
    var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0x5eed);
    defer set.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 2), set.regex_block.len);
    try testing.expectEqualStrings("^aa", set.regex_block[0].pattern);
    try testing.expectEqualStrings("^zz", set.regex_block[1].pattern);
    try testing.expectEqual(@as(usize, 1), set.regex_allow.len);
    try testing.expectEqualStrings("ok$", set.regex_allow[0].pattern);

    try testing.expectEqualStrings("^aa", matchRegex(set.regex_block, "aabb.example").?);
    try testing.expect(matchRegex(set.regex_block, "bbaa.example") == null);
    try testing.expectEqualStrings("ok$", matchRegex(set.regex_allow, "example.ok").?);
}

test "a regex pattern keeps the bytes the operator wrote" {
    // `normalize` would strip the trailing dot and lowercase the escape, and
    // either edit would change what the pattern matches. The exact and wildcard
    // kinds still normalize; only this one is exempt.
    const rows = [_]model.Rule{
        rule("ADS\\.Example\\.", .regex, .block),
        rule("ADS.Example.", .exact, .block),
    };
    var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
    defer set.deinit(testing.allocator);

    try testing.expectEqualStrings("ADS\\.Example\\.", set.regex_block[0].pattern);
    try testing.expect(set.exact_block.contains("ads.example"));
}

test "duplicate regex rows collapse to one compiled program" {
    const rows = [_]model.Rule{
        rule("^ad[0-9]+-", .regex, .block),
        rule("^ad[0-9]+-", .regex, .block),
    };
    var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
    defer set.deinit(testing.allocator);

    try testing.expectEqual(@as(usize, 1), set.regex_block.len);
}

test "a regex pattern the engine refuses is an error, not a skipped row" {
    for ([_][]const u8{ "(", "", "a+?", "[z-a]", "\\s" }) |pattern| {
        const rows = [_]model.Rule{rule(pattern, .regex, .block)};
        try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
    }

    // The size limits arrive as `BadPattern` too: which one fired is
    // `config/validate.zig`'s to report, and by here the row is simply wrong.
    const long = [_]model.Rule{rule("a" ** 300, .regex, .block)};
    try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &long, 0));
    const complex = [_]model.Rule{rule("(abcdefghij){200}", .regex, .block)};
    try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &complex, 0));
}

test "too many regex rules is an error" {
    const gpa = testing.allocator;
    const rows = try gpa.alloc(model.Rule, max_regex_per_group + 1);
    defer gpa.free(rows);

    var patterns: std.ArrayList([]u8) = .empty;
    defer {
        for (patterns.items) |p| gpa.free(p);
        patterns.deinit(gpa);
    }
    for (rows, 0..) |*row, i| {
        const pattern = try std.fmt.allocPrint(gpa, "^n{d}-", .{i});
        try patterns.append(gpa, pattern);
        row.* = rule(pattern, .regex, .block);
    }

    try testing.expectError(error.TooManyRegexRules, RuleSet.build(gpa, gpa, rows, 0));

    // The cap counts both actions together, like the wildcard one.
    rows[0].action = .allow;
    try testing.expectError(error.TooManyRegexRules, RuleSet.build(gpa, gpa, rows, 0));
    try testing.expectEqual(@as(usize, 256), max_regex_per_group);
}

test "an invalid wildcard pattern is an error" {
    for ([_][]const u8{ "example.com", "ad*.example.com", "*..com" }) |pattern| {
        const rows = [_]model.Rule{rule(pattern, .wildcard, .block)};
        try testing.expectError(error.BadPattern, RuleSet.build(testing.allocator, testing.allocator, &rows, 0));
    }
}

test "too many wildcards is an error" {
    const gpa = testing.allocator;
    const rows = try gpa.alloc(model.Rule, max_wildcards_per_group + 1);
    defer gpa.free(rows);

    var patterns: std.ArrayList([]u8) = .empty;
    defer {
        for (patterns.items) |p| gpa.free(p);
        patterns.deinit(gpa);
    }
    for (rows, 0..) |*row, i| {
        const pattern = try std.fmt.allocPrint(gpa, "*.n{d}.example.com", .{i});
        try patterns.append(gpa, pattern);
        row.* = rule(pattern, .wildcard, .block);
    }

    try testing.expectError(error.TooManyWildcards, RuleSet.build(gpa, gpa, rows, 0));
}

test "an empty rule list builds the empty set" {
    var set = try RuleSet.build(testing.allocator, testing.allocator, &[_]model.Rule{}, 0);
    defer set.deinit(testing.allocator);

    try testing.expect(!set.exact_block.contains("ads.example.com"));
    try testing.expectEqual(@as(usize, 0), set.memoryBytes());
}

test "the empty rule set owns nothing" {
    var set: RuleSet = .empty;
    try testing.expect(!set.exact_allow.contains("x.example.com"));
    try testing.expectEqual(@as(usize, 0), set.memoryBytes());
    set.deinit(testing.allocator);
}

test "memoryBytes counts every part" {
    const rows = [_]model.Rule{
        rule("ads.example.com", .exact, .block),
        rule("*.tracker.net", .wildcard, .block),
    };
    var set = try RuleSet.build(testing.allocator, testing.allocator, &rows, 0);
    defer set.deinit(testing.allocator);

    try testing.expect(set.memoryBytes() > set.exact_block.memoryBytes());
    try testing.expect(set.memoryBytes() >= "*.tracker.net".len);

    // A compiled program is the largest thing a rule set holds, so leaving it
    // out would make the snapshot's memory report a fiction.
    const with_regex = [_]model.Rule{ rows[0], rows[1], rule("^ad[0-9]+-", .regex, .block) };
    var wider = try RuleSet.build(testing.allocator, testing.allocator, &with_regex, 0);
    defer wider.deinit(testing.allocator);

    try testing.expect(wider.memoryBytes() > set.memoryBytes() + "^ad[0-9]+-".len);
    try testing.expect(wider.memoryBytes() >= wider.regex_block[0].program.memoryBytes());
}

test "the build's temporaries stay out of the permanent allocator" {
    // The property the two-allocator split exists for. An arena reclaims only
    // its most recent allocation, so a temporary taken from `perm` would live
    // as long as the arena and be invisible to `memoryBytes`. Two checks, one
    // per direction: `testing.allocator` fails the test if anything the set
    // retains was taken from `scratch`, and the arena's capacity fails it if
    // the build's working storage was taken from `perm`.
    const gpa = testing.allocator;
    var patterns: std.ArrayList([]u8) = .empty;
    defer {
        for (patterns.items) |p| gpa.free(p);
        patterns.deinit(gpa);
    }
    var rows: std.ArrayList(model.Rule) = .empty;
    defer rows.deinit(gpa);

    var i: usize = 0;
    while (i < 64) : (i += 1) {
        const regex_pattern = try std.fmt.allocPrint(gpa, "^r{d}-[0-9]+\\.ads\\.invalid$", .{i});
        try patterns.append(gpa, regex_pattern);
        try rows.append(gpa, rule(regex_pattern, .regex, .block));

        const wild = try std.fmt.allocPrint(gpa, "*.w{d:0>5}.example.com", .{i});
        try patterns.append(gpa, wild);
        try rows.append(gpa, rule(wild, .wildcard, .block));

        const exact = try std.fmt.allocPrint(gpa, "e{d:0>5}.example.com", .{i});
        try patterns.append(gpa, exact);
        try rows.append(gpa, rule(exact, .exact, .block));
    }

    var arena: std.heap.ArenaAllocator = .init(gpa);
    defer arena.deinit();
    const set = try RuleSet.build(arena.allocator(), gpa, rows.items, 0x5eed);

    try testing.expectEqual(@as(usize, 64), set.regex_block.len);
    try testing.expect(set.exact_block.contains("e00007.example.com"));
    // Whole-arena capacity against what the set says it holds. The slack is the
    // allocator's page rounding; the defect this guards against was a factor of
    // twelve.
    try testing.expect(arena.queryCapacity() < 2 * set.memoryBytes());
}

fn buildUnderFailure(gpa: Allocator) !void {
    const rows = [_]model.Rule{
        rule("ads.example.com", .exact, .block),
        rule("good.example.com", .exact, .allow),
        rule("*.tracker.net", .wildcard, .block),
        rule("*.ok.tracker.net", .wildcard, .allow),
        rule("^ad[0-9]+-", .regex, .block),
        rule("\\.ok\\.", .regex, .allow),
    };
    var set = try RuleSet.build(gpa, gpa, &rows, 0x5eed);
    defer set.deinit(gpa);
    try testing.expect(set.exact_block.contains("ads.example.com"));
    try testing.expectEqualStrings("*.tracker.net", set.wildcard_block[0]);
    try testing.expectEqualStrings("^ad[0-9]+-", set.regex_block[0].pattern);
}

test "build leaks nothing under allocation failure" {
    try testing.checkAllAllocationFailures(testing.allocator, buildUnderFailure, .{});
}
