Skip to content
zit Artifact [8814f0cbeb]
Login

Artifact [8814f0cbeb]

Artifact 8814f0cbeb5d9bb50ddce6a012436cd14dcba333b16158f31aa93bb354cd26dc:


//! Artifact Format — draft-fossil-artifact-format-00.
//!
//! An artifact is an immutable byte sequence named by the lowercase-hex hash
//! of its content (Section 3). Structural artifacts are line-oriented "cards".
//! This module owns the canonical vocabulary for the whole suite: naming, the
//! card grammar, the structural types, and the two checksums (Z, R).
//!
//! Scope note: zit targets the version-control substrate — the check-in
//! (manifest), cluster, and tag/control artifact types. The collaboration
//! types (wiki, ticket, forum, attachment, technote) are carried opaquely.

const std = @import("std");

/// Card-type letters. Only the version-control cards are interpreted; others
/// are recognised syntactically and otherwise carried opaquely.
pub const CardType = enum(u8) {
    B = 'B', // baseline (delta manifest)
    C = 'C', // comment
    D = 'D', // date (mtime)
    F = 'F', // file
    M = 'M', // cluster member
    N = 'N', // mimetype
    P = 'P', // parent(s)
    Q = 'Q', // cherrypick
    R = 'R', // aggregate content checksum (MD5)
    T = 'T', // tag
    U = 'U', // user
    Z = 'Z', // artifact self-checksum (MD5), always last
};

/// Structural artifact types in scope for the version-control substrate.
pub const ArtifactType = enum {
    manifest, // a check-in
    cluster, // a set of artifact hashes (sync housekeeping)
    control, // a tag/branch mutation
    other, // recognised but carried opaquely (wiki/ticket/forum/...)
};

/// Hash algorithm that names an artifact (Section 3, Section 7 hash policy).
/// The canonical (writer) form is always lowercase hex.
pub const HashAlgo = enum { sha1, sha3_256 };

/// Compute the artifact name: the lowercase-hex hash of `content`.
/// Writes into `out` and returns the populated slice (40 hex for SHA-1,
/// 64 hex for SHA3-256).
pub fn artifactName(algo: HashAlgo, content: []const u8, out: *[64]u8) []u8 {
    switch (algo) {
        .sha1 => {
            var digest: [20]u8 = undefined;
            std.crypto.hash.Sha1.hash(content, &digest, .{});
            const hex = std.fmt.bytesToHex(digest, .lower);
            @memcpy(out[0..hex.len], &hex);
            return out[0..hex.len];
        },
        .sha3_256 => {
            var digest: [32]u8 = undefined;
            std.crypto.hash.sha3.Sha3_256.hash(content, &digest, .{});
            const hex = std.fmt.bytesToHex(digest, .lower);
            @memcpy(out[0..hex.len], &hex);
            return out[0..hex.len];
        },
    }
}

/// Length-to-algorithm dispatch (§3.2-m1). The two name lengths are disjoint,
/// so length alone fully determines the algorithm: 40 hex digits is SHA-1,
/// 64 is SHA3-256, any other length names nothing.
pub fn nameAlgo(len: usize) ?HashAlgo {
    return switch (len) {
        40 => .sha1,
        64 => .sha3_256,
        else => null,
    };
}

/// ABNF `lhexdig` (§3.5): a lowercase hex digit, 0-9 or a-f. Governs the
/// canonical (writer/parse) form only.
fn isHexLower(c: u8) bool {
    return (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f');
}

/// ABNF `hexdig-ci` (§3.5): 0-9, A-F, or a-f. Used only on the prefix-lookup
/// path (§3.6), which canonicalizes case; the parse path never uses this.
fn isHexCi(c: u8) bool {
    return (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f') or (c >= 'A' and c <= 'F');
}

/// Canonical-form validation (§3.2, §3.3, §3.5 `artifact-name`). A token is
/// valid iff it is exactly 40 or 64 lowercase hex digits. Length is checked
/// before character class (§3.2-r1: length gate precedes char-class check).
/// Uppercase digits are rejected outright, never case-normalized (§3.3-m2).
pub fn validateName(token: []const u8) bool {
    if (nameAlgo(token.len) == null) return false;
    std.debug.assert(token.len == 40 or token.len == 64);
    var i: usize = 0;
    while (i < token.len) : (i += 1) {
        if (!isHexLower(token[i])) return false;
    }
    return true;
}

/// Verify `name` against `content` (§3.4). Dispatch on length picks the
/// digest to recompute; the compare is exact byte-for-byte lowercase hex.
/// Wrong length, non-hex characters, and a digest mismatch collapse into the
/// same `false` (§3.4-m1) — nothing here distinguishes them for the caller.
/// An uppercase name never verifies either, since the compare never
/// case-folds (§3.4-r1).
pub fn verify(name: []const u8, content: []const u8) bool {
    const algo = nameAlgo(name.len) orelse return false;
    var buf: [64]u8 = undefined;
    const computed = artifactName(algo, content, &buf);
    std.debug.assert(computed.len == name.len);
    return std.mem.eql(u8, name, computed);
}

/// Prefix-resolution outcome (§3.6, informative — not part of the wire
/// format). `one` carries the matching index into the caller's `names`.
pub const Resolution = union(enum) { none, one: usize, ambiguous };

/// Resolve a hex name or prefix against a candidate universe (§3.6).
/// `prefix` must be 4-64 hex characters of any case; it is lowercased before
/// matching (the lookup path canonicalizes case, unlike the parse path —
/// see `validateName`). One query walk covers both name lengths, so a
/// cross-algorithm collision (one 40-char match, one 64-char match) reports
/// `.ambiguous` exactly like a same-algorithm collision (§3.6-r1).
pub fn resolvePrefix(prefix: []const u8, names: []const []const u8) error{InvalidPrefix}!Resolution {
    if (prefix.len < 4 or prefix.len > 64) return error.InvalidPrefix;
    std.debug.assert(prefix.len >= 4 and prefix.len <= 64);

    var lowered: [64]u8 = undefined;
    var i: usize = 0;
    while (i < prefix.len) : (i += 1) {
        const c = prefix[i];
        if (!isHexCi(c)) return error.InvalidPrefix;
        lowered[i] = if (c >= 'A' and c <= 'F') c + ('a' - 'A') else c;
    }
    const query = lowered[0..prefix.len];

    var first: ?usize = null;
    var matches: usize = 0;
    var n: usize = 0;
    while (n < names.len) : (n += 1) {
        std.debug.assert(matches <= names.len);
        if (std.mem.startsWith(u8, names[n], query)) {
            matches += 1;
            if (first == null) first = n;
            if (matches > 1) return .ambiguous;
        }
    }
    if (first) |idx| {
        std.debug.assert(idx < names.len);
        return .{ .one = idx };
    }
    return .none;
}

/// The Z-card value: lowercase-hex MD5 of all stripped-content bytes that
/// precede the `Z ` line (Section 6, Z card).
pub fn selfChecksum(stripped_prefix: []const u8, out: *[32]u8) []u8 {
    var digest: [16]u8 = undefined;
    std.crypto.hash.Md5.hash(stripped_prefix, &digest, .{});
    const hex = std.fmt.bytesToHex(digest, .lower);
    @memcpy(out, &hex);
    return out;
}

// ---------------------------------------------------------------------------
// §4.1.1 Signature-Wrapper Removal
// ---------------------------------------------------------------------------

/// PGP/SSH clearsign open-armor literals (§4.1.1-r1): stripping fires only
/// when one of these matches the first 34 bytes of the input, at offset 0.
const pgp_open_armor = "-----BEGIN PGP SIGNED MESSAGE-----";
const ssh_open_armor = "-----BEGIN SSH SIGNED MESSAGE-----";
comptime {
    std.debug.assert(pgp_open_armor.len == 34);
    std.debug.assert(ssh_open_armor.len == 34);
}

/// Trailing-armor compare literals (§4.1.1-p3). The real armor line is 30
/// bytes (LF + a 29-byte dashed literal), but only 29 are ever compared:
/// LF plus the first 28 armor bytes, one dash short of the closing run —
/// the 30th byte, whatever it is, is never read.
const pgp_close_armor = "\n-----BEGIN PGP SIGNATURE----";
const ssh_close_armor = "\n-----BEGIN SSH SIGNATURE----";
comptime {
    std.debug.assert(pgp_close_armor.len == 29);
    std.debug.assert(ssh_close_armor.len == 29);
}

/// Forward scan for the first blank-line separator (§4.1.1-p2): LF LF or
/// LF CR LF — the sole CR tolerance anywhere in the parse pipeline. Returns
/// the offset just past the blank line (start of body), or null if none is
/// found before end of input.
fn firstBlankLineEnd(bytes: []const u8) ?usize {
    var i: usize = 0;
    while (i < bytes.len) : (i += 1) {
        if (bytes[i] != '\n') continue;
        if (i + 1 < bytes.len and bytes[i + 1] == '\n') return i + 2;
        if (i + 2 < bytes.len and bytes[i + 1] == '\r' and bytes[i + 2] == '\n') return i + 3;
    }
    return null;
}

/// Backward scan for the trailing signature armor (§4.1.1-p3). "First
/// occurrence" read right-to-left makes the rightmost match win. Returns
/// the truncation length (keeping the matched LF as the final byte), or
/// `body.len` when no armor is found.
fn trailingArmorEnd(body: []const u8) usize {
    if (body.len < pgp_close_armor.len) return body.len;
    var p: usize = body.len - pgp_close_armor.len;
    while (true) : (p -= 1) {
        std.debug.assert(p + pgp_close_armor.len <= body.len);
        const slice = body[p .. p + pgp_close_armor.len];
        if (std.mem.eql(u8, slice, pgp_close_armor) or std.mem.eql(u8, slice, ssh_close_armor)) {
            return p + 1; // truncate here, keeping the matched LF
        }
        if (p == 0) break;
    }
    return body.len;
}

/// §4.1.1 signature-wrapper removal. Total: never fails. Returns `bytes`
/// unchanged unless the first 34 bytes are a recognized open armor AND a
/// blank line follows somewhere in the input — either condition failing
/// leaves the input wholly unstripped (§4.1.1-r1, -m1).
pub fn stripSignatureWrapper(bytes: []const u8) []const u8 {
    const is_pgp = bytes.len >= pgp_open_armor.len and
        std.mem.eql(u8, bytes[0..pgp_open_armor.len], pgp_open_armor);
    const is_ssh = bytes.len >= ssh_open_armor.len and
        std.mem.eql(u8, bytes[0..ssh_open_armor.len], ssh_open_armor);
    if (!is_pgp and !is_ssh) return bytes;

    const body_start = firstBlankLineEnd(bytes) orelse return bytes;
    std.debug.assert(body_start <= bytes.len);

    const body = bytes[body_start..];
    const body_end = trailingArmorEnd(body);
    std.debug.assert(body_end <= body.len);

    const result = body[0..body_end];
    std.debug.assert(result.len <= bytes.len);
    return result;
}

// ---------------------------------------------------------------------------
// §4.1 Artifact Framing
// ---------------------------------------------------------------------------

/// §6.2 Z-card verification outcome (Table 6).
pub const ZVerdict = enum { absent, match, mismatch };

/// §6.2 Z self-checksum, evaluated positionally on the final 35 bytes of
/// `content` (already wrapper-stripped): the marker sits at n-35, the
/// digest at n-33..n-1. Never reads `content[n-1]`, the artifact's
/// terminating byte — that byte belongs to the §4.1 raw-LF framing rule.
pub fn verifyZCard(content: []const u8) ZVerdict {
    const n = content.len;
    if (n < 35) return .absent;
    if (content[n - 35] != 'Z' or content[n - 34] != ' ') return .absent;

    const stored = content[n - 33 .. n - 1];
    std.debug.assert(stored.len == 32);
    var digest_buf: [32]u8 = undefined;
    const computed = selfChecksum(content[0 .. n - 35], &digest_buf);
    std.debug.assert(computed.len == 32);

    return if (std.mem.eql(u8, stored, computed)) .match else .mismatch;
}

/// §4.1 framing gates, evaluated in RFC order before any card is
/// interpreted. Returns the wrapper-stripped, gate-passed content, or null
/// if the input fails any gate — every rejection reason collapses to null,
/// as the RFC only ever says "reject" (the §4.1.2 fast well-formedness
/// probe reads this way too).
///
/// The final §4.1 rule — unconsumed bytes after the card loop MUST cause
/// rejection — is not enforced here: it is defined relative to the card
/// loop, which this module does not yet implement. It lands with the §4.2
/// card-loop parser, which calls `frame` as its entry seam.
pub fn frame(bytes: []const u8) ?[]const u8 {
    // Gate 1: raw bytes, non-empty, final byte LF — checked before strip.
    if (bytes.len == 0 or bytes[bytes.len - 1] != '\n') return null;

    // Gate 2: strip signature wrapper, if present.
    const stripped = stripSignatureWrapper(bytes);
    if (stripped.len > 0) std.debug.assert(stripped[stripped.len - 1] == '\n');

    // Gate 3: strict line-1 (length, byte0 A-Z, byte1 SP).
    if (stripped.len < 10) return null;
    if (stripped[0] < 'A' or stripped[0] > 'Z') return null;
    if (stripped[1] != ' ') return null;

    // Gate 4: Z self-checksum — a mismatch rejects; absent or matching pass.
    if (verifyZCard(stripped) == .mismatch) return null;

    return stripped;
}

// ---------------------------------------------------------------------------
// §4.2 Line and Token Grammar / §4.2.2 Closed Card Alphabet
// ---------------------------------------------------------------------------

/// §4.2.2 closed alphabet: true for exactly the 21 defined card letters.
/// A-Z minus the five in-range gaps (O S V X Y) — closed, no
/// forward-compatibility path for an unknown letter (RFC lines 623-627).
pub fn isCardLetter(b: u8) bool {
    return switch (b) {
        'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'P', 'Q', 'R', 'T', 'U', 'W', 'Z' => true,
        else => false,
    };
}

comptime {
    var count: usize = 0;
    var b: usize = 0;
    while (b < 256) : (b += 1) {
        if (isCardLetter(@intCast(b))) count += 1;
    }
    std.debug.assert(count == 21);
    std.debug.assert(!isCardLetter('O'));
    std.debug.assert(!isCardLetter('S'));
    std.debug.assert(!isCardLetter('V'));
    std.debug.assert(!isCardLetter('X'));
    std.debug.assert(!isCardLetter('Y'));
    std.debug.assert(!isTokenByte(' '));
    std.debug.assert(!isTokenByte('\n'));
    std.debug.assert(isTokenByte(0x00));
    std.debug.assert(isTokenByte(0xFF));
}

/// §4.2 tchar: any octet except SP (0x20) and LF (0x0A).
pub fn isTokenByte(b: u8) bool {
    return b != ' ' and b != '\n';
}

/// Splits a card's raw argument bytes on SP only (§4.2-m2 — tab is never a
/// delimiter). `rest == null` yields zero tokens (argument-less card);
/// `rest.?.len == 0` yields exactly one empty token (§4.7.17's "empty
/// token" case) before going null.
pub const TokenIterator = struct {
    rest: ?[]const u8,

    pub fn next(self: *TokenIterator) ?[]const u8 {
        const s = self.rest orelse return null;
        var i: usize = 0;
        while (i < s.len) : (i += 1) {
            if (s[i] == ' ') {
                self.rest = s[i + 1 ..];
                return s[0..i];
            }
        }
        self.rest = null;
        return s;
    }
};

/// One tokenized card (§4.2 `card`). Borrows from the input; no allocation.
pub const CardLine = struct {
    letter: u8,
    /// null  => argument-less card (`X\n`), 0 tokens.
    /// slice => SP form (`X ...\n`); raw arg bytes with the LF and the one
    ///          leading SP removed. May be "" (one empty token).
    args: ?[]const u8,

    pub fn tokens(self: CardLine) TokenIterator {
        return .{ .rest = self.args };
    }
};

/// Tokenize one card, given its full byte extent including the terminating
/// LF (the card loop supplies the extent; W's byte-counted body is that
/// loop's concern, not this function's — §4.7.18). Returns null iff the
/// letter is outside the closed 21-letter alphabet (§4.2.2-r1,-r2) or the
/// byte after the letter is neither SP nor LF (§4.2-m1).
pub fn tokenizeCardLine(line: []const u8) ?CardLine {
    std.debug.assert(line.len >= 1);
    std.debug.assert(line[line.len - 1] == '\n');

    // Non-W invariant: no embedded LF before the terminator. The W card's
    // byte-counted body is the sole exception (§4.7.18), realized by the
    // card loop, not here.
    var i: usize = 0;
    while (i < line.len - 1) : (i += 1) {
        std.debug.assert(line[i] != '\n');
    }

    if (line.len < 2) return null;
    if (!isCardLetter(line[0])) return null;

    const result: CardLine = if (line[1] == '\n')
        .{ .letter = line[0], .args = null }
    else if (line[1] == ' ')
        .{ .letter = line[0], .args = line[2 .. line.len - 1] }
    else
        return null;

    std.debug.assert(isCardLetter(result.letter));
    return result;
}

// ---------------------------------------------------------------------------
// §4.4 Card Ordering
// ---------------------------------------------------------------------------

comptime {
    // Z is the last defined letter (§4.4 "Nothing follows Z", lines 739-741):
    // every card letter sorts at or below it.
    var b: usize = 0;
    while (b < 256) : (b += 1) {
        if (isCardLetter(@intCast(b))) std.debug.assert(b <= 'Z');
    }
    // Premises the N-after-P exception rests on (lines 714-717, §A.1 2499-2500).
    std.debug.assert('N' < 'P');
    std.debug.assert('E' < 'P');
}

/// §4.4 card-type ordering, fed one accepted card letter at a time in
/// document order. Holds the previous letter and whether a valid E card has
/// been presented (the N-after-P technote exception, lines 714-717).
pub const OrderState = struct {
    prev: ?u8 = null,
    e_seen: bool = false,

    /// Admit `letter` as the next card. Returns false iff it is out of
    /// order and not the N-after-P exception; the caller collapses false
    /// to reject, matching the module's "every rejection reason collapses
    /// to null/false" posture (§4.1). Pure predicate: no error set.
    pub fn admit(self: *OrderState, letter: u8) bool {
        std.debug.assert(isCardLetter(letter));
        if (self.prev) |p| std.debug.assert(isCardLetter(p));

        const ok = if (self.prev == null)
            true
        else if (letter >= self.prev.?)
            true
        else
            letter == 'N' and self.prev.? == 'P' and self.e_seen;
        if (!ok) return false;

        if (letter == 'E') self.e_seen = true;
        self.prev = letter;
        std.debug.assert(self.prev.? == letter);
        std.debug.assert(!(letter == 'E') or self.e_seen);
        return true;
    }
};

// ---------------------------------------------------------------------------
// §4.3 Character-Escape Encoding
// ---------------------------------------------------------------------------

/// §4.3 Table 1: the decoded octet for a recognized escape successor, or
/// null for an unrecognized one. Exactly eight pairs are defined; every
/// other successor byte falls through to `else`.
fn escapeOctet(c: u8) ?u8 {
    return switch (c) {
        'n' => 0x0A, // LF
        's' => 0x20, // SP
        't' => 0x09, // TAB
        'r' => 0x0D, // CR
        'v' => 0x0B, // VT
        'f' => 0x0C, // FF
        '0' => 0x00, // NUL
        '\\' => 0x5C, // backslash
        else => null,
    };
}

comptime {
    var count: usize = 0;
    var b: usize = 0;
    while (b < 256) : (b += 1) {
        if (escapeOctet(@intCast(b)) != null) count += 1;
    }
    std.debug.assert(count == 8);
    std.debug.assert(escapeOctet('n').? == 0x0A);
    std.debug.assert(escapeOctet('s').? == 0x20);
    std.debug.assert(escapeOctet('0').? == 0x00);
    std.debug.assert(escapeOctet('\\').? == 0x5C);
}

/// §4.3 in-place escape decode of one free-text token. Writes the decoded
/// bytes into `out` and returns the used prefix. Never errors (§4.3: "No
/// error is raised"). Precondition: `out.len >= token.len` — decoded length
/// never exceeds input length (a recognized or unrecognized escape turns 2
/// input bytes into 1; an ordinary byte or a lone trailing backslash is 1:1,
/// §4.3-amb1).
pub fn decodeEscapes(token: []const u8, out: []u8) []u8 {
    std.debug.assert(out.len >= token.len);
    var i: usize = 0;
    var w: usize = 0;
    while (i < token.len) {
        std.debug.assert(w <= i);
        if (token[i] == '\\' and i + 1 < token.len) {
            out[w] = escapeOctet(token[i + 1]) orelse token[i + 1];
            i += 2;
        } else {
            // Ordinary byte, or a backslash with no successor (§4.3-amb1):
            // pass through literally.
            out[w] = token[i];
            i += 1;
        }
        w += 1;
    }
    std.debug.assert(w <= token.len);
    return out[0..w];
}

/// §4.3 Table 1 inverse: octet -> its escape successor byte, or null if
/// the octet is not one of the eight Table 1 entries. Exact inverse of
/// `escapeOctet` (checked below), so the two tables cannot drift apart.
fn escapeSuccessor(octet: u8) ?u8 {
    return switch (octet) {
        0x0A => 'n',
        0x20 => 's',
        0x09 => 't',
        0x0D => 'r',
        0x0B => 'v',
        0x0C => 'f',
        0x00 => '0',
        0x5C => '\\',
        else => null,
    };
}

comptime {
    var count: usize = 0;
    var b: usize = 0;
    while (b < 256) : (b += 1) {
        if (escapeSuccessor(@intCast(b))) |succ| {
            count += 1;
            std.debug.assert(escapeOctet(succ).? == b);
        }
    }
    std.debug.assert(count == 8);
}

/// §4.3 — sync §11's error/message TEXT is "encoded using the fossilize
/// character-escaping scheme", the forward direction of Table 1.
/// Every one of the eight Table 1 octets emits a 2-byte `\<successor>`;
/// every other byte passes through 1:1, so `decodeEscapes(encodeEscapes(x))
/// == x` for any `x` (encoding all eight, not merely SP, is what keeps a
/// literal backslash in `raw` from colliding with an emitted escape pair —
/// see the design note in the batch plan). Precondition `out.len >=
/// 2*raw.len`; postcondition returned len `<= 2*raw.len`.
pub fn encodeEscapes(raw: []const u8, out: []u8) []u8 {
    std.debug.assert(out.len >= 2 * raw.len);
    var w: usize = 0;
    for (raw) |b| {
        std.debug.assert(w <= 2 * raw.len);
        if (escapeSuccessor(b)) |succ| {
            out[w] = '\\';
            out[w + 1] = succ;
            w += 2;
        } else {
            out[w] = b;
            w += 1;
        }
    }
    std.debug.assert(w <= 2 * raw.len);
    return out[0..w];
}

// ---------------------------------------------------------------------------
// §4.5 Occurrence Classes and Intra-Type Ordering
// ---------------------------------------------------------------------------

/// §4.5.1 occurrence class of a card letter (lines 753-766). `single`: a
/// duplicate MUST be rejected. `repeatable`: each occurrence appends to the
/// artifact's growing list for that type — a duplicate is never a syntax
/// error. `neither`: the Z card only — no duplicate guard, no list.
pub const OccurrenceClass = enum { single, repeatable, neither };

/// §4.5.1 classifier (lines 755-764). Precondition: `isCardLetter(letter)`.
pub fn occurrenceClass(letter: u8) OccurrenceClass {
    std.debug.assert(isCardLetter(letter));
    return switch (letter) {
        'A', 'B', 'C', 'D', 'E', 'G', 'H', 'I', 'K', 'L', 'N', 'R', 'U', 'W' => .single,
        'F', 'J', 'M', 'T', 'P', 'Q' => .repeatable,
        'Z' => .neither,
        else => unreachable, // isCardLetter admits only the 21 letters above
    };
}

comptime {
    var single: usize = 0;
    var repeatable: usize = 0;
    var neither: usize = 0;
    var b: usize = 0;
    while (b < 256) : (b += 1) {
        if (!isCardLetter(@intCast(b))) continue;
        switch (occurrenceClass(@intCast(b))) {
            .single => single += 1,
            .repeatable => repeatable += 1,
            .neither => neither += 1,
        }
    }
    std.debug.assert(single == 14);
    std.debug.assert(repeatable == 6);
    std.debug.assert(neither == 1);
    std.debug.assert(occurrenceClass('Z') == .neither);
}

/// §4.5.2 Table 2 intra-run check structure for a repeatable card's list
/// (lines 789-809). `ascending`: strict ascent by a single decoded key (F,
/// J, M — the key itself varies by letter, extracted by the caller).
/// `tag_two_level`: T's (tag name, then target hash) rule. `unordered`: P
/// (semantic order, lines 804-805) and Q (no check at all, lines 807-808).
pub const IntraRunRule = enum { ascending, tag_two_level, unordered };

/// §4.5.2 Table 2 dispatch. Non-null iff `letter` is repeatable; null for
/// every single-occurrence letter and for Z. Precondition: `isCardLetter`.
pub fn intraRunRule(letter: u8) ?IntraRunRule {
    std.debug.assert(isCardLetter(letter));
    return switch (letter) {
        'F', 'J', 'M' => .ascending,
        'T' => .tag_two_level,
        'P', 'Q' => .unordered,
        else => null,
    };
}

comptime {
    var b: usize = 0;
    while (b < 256) : (b += 1) {
        if (!isCardLetter(@intCast(b))) continue;
        const letter: u8 = @intCast(b);
        const has_rule = intraRunRule(letter) != null;
        const is_repeatable = occurrenceClass(letter) == .repeatable;
        std.debug.assert(has_rule == is_repeatable);
    }
    std.debug.assert(intraRunRule('F').? == .ascending);
    std.debug.assert(intraRunRule('J').? == .ascending);
    std.debug.assert(intraRunRule('M').? == .ascending);
    std.debug.assert(intraRunRule('T').? == .tag_two_level);
    std.debug.assert(intraRunRule('P').? == .unordered);
    std.debug.assert(intraRunRule('Q').? == .unordered);
    std.debug.assert(intraRunRule('Z') == null);
}

/// §4.5.1-r1 duplicate guard, fed one accepted card letter at a time.
/// Fixed 26-slot membership set indexed by `letter - 'A'` — a card letter is
/// always in A..Z (`isCardLetter`), so 26 slots cover every case with no
/// dynamic sizing.
pub const SingleOccurrenceSet = struct {
    seen: [26]bool = .{false} ** 26,

    /// Admit `letter` as an occurrence. Returns false iff `letter` is
    /// single-occurrence and this is its second-or-later appearance;
    /// repeatable letters and Z are always admitted (§4.5.1-m1, §4.5.2-m3,
    /// the Z paragraph at lines 764-766).
    pub fn admit(self: *SingleOccurrenceSet, letter: u8) bool {
        std.debug.assert(isCardLetter(letter));
        if (occurrenceClass(letter) != .single) return true;
        const idx = letter - 'A';
        std.debug.assert(idx < 26);
        if (self.seen[idx]) return false;
        self.seen[idx] = true;
        std.debug.assert(self.seen[idx]);
        return true;
    }
};

/// §4.5.3 the canonical comparator (lines 823-836): raw-byte comparison,
/// octets as unsigned values (u8 is already unsigned), locale-independent,
/// no Unicode normalization, no case folding. On a common-prefix tie the
/// shorter string sorts first (§4.5.3-m1). No byte value — including `/`
/// 0x2F — gets special treatment (§4.5.3-m2): there is no branch for it.
pub fn compareBytes(a: []const u8, b: []const u8) std.math.Order {
    const n = @min(a.len, b.len);
    var i: usize = 0;
    while (i < n) : (i += 1) {
        // n = min(a.len, b.len), so this is what actually justifies the
        // unchecked a[i] / b[i] below — not a restatement of `i < n`.
        std.debug.assert(i < a.len and i < b.len);
        if (a[i] != b[i]) return std.math.order(a[i], b[i]);
    }
    return std.math.order(a.len, b.len);
}

comptime {
    std.debug.assert(compareBytes("A", "a") == .lt); // no case folding
    std.debug.assert(compareBytes("abc", "abcd") == .lt); // shorter-first
    std.debug.assert(compareBytes("a.b", "a/b") == .lt); // 0x2E < 0x2F, '/' unprivileged
}

/// §4.5.3-m1 "an absent value sorts before any present value." Both absent
/// compares equal; one absent sorts before any present value including "".
/// Two present values compare by `compareBytes`.
pub fn compareOptional(a: ?[]const u8, b: ?[]const u8) std.math.Order {
    if (a == null and b == null) return .eq;
    if (a == null) return .lt;
    if (b == null) return .gt;
    return compareBytes(a.?, b.?);
}

/// §4.5.2-r4 T two-level rule: compare tag name first; on a name tie,
/// target hash must strictly increase, so the hash comparison decides.
pub fn compareTag(a_name: []const u8, a_hash: []const u8, b_name: []const u8, b_hash: []const u8) std.math.Order {
    const name_order = compareBytes(a_name, b_name);
    if (name_order != .eq) return name_order;
    return compareBytes(a_hash, b_hash);
}

/// §4.6.1 Simple-Pathname predicate. Operates on the DECODED form of a token
/// (§4.6 line 853), as a sequence of unsigned octets. Applies to the A-card
/// filename and to the F-card filename and old-name (§4.6.1-p1, line 858).
/// Returns true iff the name is ACCEPTED; false iff it must be rejected.
/// Rejection is decidable from the '/'-separator structure alone (§4.6.1-m1):
/// no byte-content restriction — backslashes, spaces, control bytes, and
/// arbitrary/invalid UTF-8 all pass. The stricter writer profile does NOT
/// constrain acceptance (§4.6.1-m2, line 888).
pub fn isSimplePathname(name: []const u8) bool {
    if (name.len == 0) return false; // §4.6.1-r1: empty
    var start: usize = 0;
    var i: usize = 0;
    while (i < name.len) : (i += 1) { // bound: i++ each step, ends at name.len
        std.debug.assert(start <= i); // invariant: start never runs past i
        if (name[i] != '/') continue;
        if (!pathElementOk(name[start..i])) return false;
        start = i + 1; // element after the '/'
    }
    std.debug.assert(start <= name.len);
    return pathElementOk(name[start..name.len]); // final element, no trailing '/'
}

/// A single '/'-delimited element is bad iff empty (r2/r5/r6) or exactly
/// `.`/`..` (r3/r4/r7). See `isSimplePathname` doc comment for the mapping
/// from these two checks back to the seven enumerated RFC bullets.
fn pathElementOk(elem: []const u8) bool {
    if (elem.len == 0) return false;
    if (std.mem.eql(u8, elem, ".") or std.mem.eql(u8, elem, "..")) return false;
    return true;
}

comptime {
    std.debug.assert(isSimplePathname("alpha.txt")); // §A.3 accept
    std.debug.assert(isSimplePathname("a/b/c")); // nested accept
    std.debug.assert(isSimplePathname("...")); // r3/r7 are exact: "..." passes
    std.debug.assert(!isSimplePathname("")); // r1
    std.debug.assert(!isSimplePathname("/a")); // r2
    std.debug.assert(!isSimplePathname(".")); // r3
    std.debug.assert(!isSimplePathname("./a")); // r4
    std.debug.assert(!isSimplePathname("a//b")); // r5
    std.debug.assert(!isSimplePathname("a/")); // r6
    std.debug.assert(!isSimplePathname("a/../b")); // r7
}

const WIKI_NAME_MAX: usize = 100; // §4.6.2-r5 upper bound, in bytes

/// §4.6.2 Wiki-Name predicate. Operates on the DECODED form of a token as a
/// sequence of unsigned octets. Applies to the L-card title and to the A-card
/// target in its wiki-name alternative (§4.6.2-p1, line 903). Returns true iff
/// ACCEPTED; false iff it must be rejected. Reject iff any of: first byte
/// <= 0x20 (r1: empty/leading-space/leading-control); a subsequent byte < 0x20
/// (r2: control incl. tab/LF); consecutive 0x20 0x20 anywhere (r3); final byte
/// 0x20 (r4); byte length outside 1..100 (r5). Bytes >= 0x21 are otherwise
/// unconstrained: 0x7F and all bytes >= 0x80 accepted, no UTF-8 check, length
/// counted in bytes not characters (§4.6.2-m1, lines 919-921).
pub fn isWikiName(name: []const u8) bool {
    if (name.len < 1 or name.len > WIKI_NAME_MAX) return false; // r5 (also empty)
    if (name[0] <= 0x20) return false; // r1: first byte
    if (name[name.len - 1] == 0x20) return false; // r4: final byte
    var i: usize = 1;
    while (i < name.len) : (i += 1) { // bound: i++ each step, ends at name.len
        // i starts at 1 and only grows, so this is what actually justifies
        // the unchecked i - 1 below — not a restatement of i < name.len.
        std.debug.assert(i >= 1);
        const b = name[i];
        if (b < 0x20) return false; // r2: subsequent control byte
        if (b == 0x20 and name[i - 1] == 0x20) return false; // r3: consecutive spaces
    }
    return true;
}

comptime {
    std.debug.assert(isWikiName("A")); // 1-byte accept (r5 lower)
    std.debug.assert(isWikiName("Wiki Page")); // single interior space ok
    std.debug.assert(isWikiName("x" ** 100)); // r5 upper accept
    std.debug.assert(isWikiName("a\x7f")); // 0x7F accepted (m1)
    std.debug.assert(isWikiName("a\x80")); // >=0x80 accepted (m1)
    std.debug.assert(!isWikiName("")); // r5/r1: empty
    std.debug.assert(!isWikiName(" x")); // r1: leading space
    std.debug.assert(!isWikiName("\x1fx")); // r1: leading control
    std.debug.assert(!isWikiName("a\tb")); // r2: tab
    std.debug.assert(!isWikiName("a\nb")); // r2: LF
    std.debug.assert(!isWikiName("a  b")); // r3: double space
    std.debug.assert(!isWikiName("a ")); // r4: trailing space
    std.debug.assert(!isWikiName("x" ** 101)); // r5 upper reject
}

/// Sidecar suffixes stripped in reserved-filename step 1 (§4.6.3-p1, line
/// 926-929). Order does not matter for correctness: the three are mutually
/// non-suffixing (none is a suffix of another), so at most one can ever
/// match a given trailing region; kept as a fixed comptime-unrolled list.
const RESERVED_SIDECAR_SUFFIXES = [_][]const u8{ "-journal", "-wal", "-shm" };

/// §4.6.3 Reserved-filename predicate. Operates on the decoded CURRENT
/// F-card filename only -- the old-name field is never tested (line 957).
/// Returns true iff the three-step procedure (lines 926-937) matches, which
/// means the caller MUST silently drop the F card, not reject it (r2, line
/// 961): reserved-ness is a bool drop signal, never a ParseError variant.
pub fn isReservedFilename(name: []const u8) bool {
    // Step 1 (p1, lines 926-929): strip exactly one sidecar suffix. The
    // `!sidecar_stripped` guard is what makes this "exactly one" -- once a
    // suffix matches we stop, we never re-scan the shortened remainder.
    var remaining = name;
    var sidecar_stripped = false;
    inline for (RESERVED_SIDECAR_SUFFIXES) |suffix| {
        if (!sidecar_stripped and std.ascii.endsWithIgnoreCase(remaining, suffix)) {
            remaining = remaining[0 .. remaining.len - suffix.len];
            sidecar_stripped = true;
        }
    }

    // Step 2 (p2, lines 933-934): reserved tail, case-insensitive.
    const tail_fossil = "_FOSSIL_";
    const tail_fslckout = ".fslckout";
    const tail_len: usize = if (std.ascii.endsWithIgnoreCase(remaining, tail_fossil))
        tail_fossil.len
    else if (std.ascii.endsWithIgnoreCase(remaining, tail_fslckout))
        tail_fslckout.len
    else
        return false;
    std.debug.assert(remaining.len >= tail_len); // endsWithIgnoreCase guarantees this

    // Step 3 (p3, lines 936-937): whole-string, preceding '/', or sidecar.
    if (remaining.len == tail_len) return true; // 3a: tail is the whole string
    if (sidecar_stripped) return true; // 3c: a sidecar suffix was stripped
    std.debug.assert(remaining.len > tail_len); // 3a ruled out above; index is in-bounds
    return remaining[remaining.len - tail_len - 1] == '/'; // 3b
}

comptime {
    std.debug.assert(isReservedFilename("_FOSSIL_")); // 3a whole string
    std.debug.assert(isReservedFilename(".fslckout")); // 3a whole string
    std.debug.assert(isReservedFilename("dir/_FOSSIL_")); // 3b preceding '/'
    std.debug.assert(isReservedFilename("x_FOSSIL_-wal")); // 3c sidecar stripped
    std.debug.assert(isReservedFilename("_FOSSIL_-WAL")); // p1 case-insensitive strip
    std.debug.assert(isReservedFilename("_fossil_")); // p2 case-insensitive tail
    std.debug.assert(!isReservedFilename("x_FOSSIL_")); // r1: no step-3 condition holds
    std.debug.assert(!isReservedFilename("CON")); // m1: no reserved tail at all
    std.debug.assert(!isReservedFilename("readme.txt")); // p2 fails: no tail
    std.debug.assert(!isReservedFilename("")); // underflow guard boundary
    std.debug.assert(!isReservedFilename("foo-wal")); // strip leaves no tail
}

/// §4.6.3-m2 sequencing (lines 967-973): "the F card sort check (Section
/// 4.5.2) runs before the reserved-name drop, so a reserved-named F card
/// must itself be in sort position relative to the previously retained
/// entry, and the next F card is compared against that same retained
/// entry. The dropped name never becomes the comparison anchor. A
/// validator that checks order before dropping reproduces this behavior
/// exactly." `OutOfOrder` is the §4.5.2-r1 sort rejection (equal or
/// descending); it is distinct from a drop, which is not an error.
pub const FCardSortError = error{OutOfOrder};

/// Feeds F cards one at a time, in wire order, and tracks the effective
/// file set (§4.6.3-r2): a reserved-named card is silently dropped, not
/// admitted, so it never appears in the retained set and never replaces
/// `anchor`. `admit` runs the §4.5.2 sort check first and the §4.6.3
/// reserved check second, per the sequencing clause above.
pub const FCardAdmission = struct {
    /// Decoded current filename of the last RETAINED F card, or null
    /// before the first admitted card. This is the sort-check anchor; a
    /// dropped card's name is never written here.
    anchor: ?[]const u8 = null,

    /// Admit one F card by its decoded CURRENT filename only (§4.6.3 lines
    /// 957-959: the old-name field is never tested by this rule -- there
    /// is no old-name parameter for it to reach). Order is checked against
    /// `anchor` first: equal or descending is `OutOfOrder`, matching
    /// §4.5.2-r1, and applies even to a reserved name -- reserved-ness
    /// does not exempt a card from the sort check. Only a card that passes
    /// the sort check is then tested for reserved-ness. Returns true and
    /// advances `anchor` to `name` iff retained; returns false (anchor
    /// unchanged) iff dropped.
    pub fn admit(self: *FCardAdmission, name: []const u8) FCardSortError!bool {
        if (self.anchor) |prev| {
            if (compareBytes(prev, name) != .lt) return FCardSortError.OutOfOrder;
        }
        if (isReservedFilename(name)) return false; // dropped: anchor untouched
        self.anchor = name;
        std.debug.assert(self.anchor != null and std.mem.eql(u8, self.anchor.?, name));
        return true;
    }
};

// ---------------------------------------------------------------------------
// §4.6.4 Timestamps
// ---------------------------------------------------------------------------

/// The five accepted §4.6.4 D/E timestamp token forms (p1: the token is
/// classified RAW, never escape-decoded). `date_time` is the broad reader
/// form -- leading '-', whitespace/'T' separator, and tz all permitted; a
/// writer that must satisfy m8 uses `isWriterTimestamp` for the narrower
/// canonical subset.
pub const TimestampForm = enum { date_time, date, time, relative, julian };

/// §4.6.4 classify the raw D/E card timestamp token. Returns the matched
/// form, or null iff the token is rejected outright. Pure: reads no clock,
/// filesystem, or RNG state (m9 -- `now`/`subsec`/`subsecond` are classified
/// as `.relative`, never resolved to an instant here; resolution happens at
/// [FossilRepoState] ingestion time, outside this module).
pub fn classifyTimestamp(token: []const u8) ?TimestampForm {
    if (token.len == 0) return null; // negative space: nothing to classify
    if (matchDate(token)) |dm| {
        if (dm.cur == token.len) {
            if (!dateGateOk(dm.y_neg, dm.y, dm.m, dm.d)) return null; // r3
            return .date;
        }
        // Not a bare date: the remainder must be a date-time separator +
        // time [+ tz], or the whole token is rejected -- a matched date
        // prefix is never reinterpreted as a bare time (p1/r1 tokens rely
        // on this: they fail here, not by falling through below).
        var cur = dm.cur;
        const sep_start = cur;
        while (cur < token.len and (token[cur] == 'T' or std.ascii.isWhitespace(token[cur]))) : (cur += 1) {}
        if (cur == sep_start) return null; // m1 "1 or more"; r1 lowercase t never advances this
        const tm = matchTime(token, cur) orelse return null;
        const tz_end = matchTz(token, tm.cur);
        if (tz_end != token.len) return null; // trailing garbage after tz
        if (!dateGateOk(dm.y_neg, dm.y, dm.m, dm.d)) return null; // r3
        return .date_time;
    }
    if (matchTime(token, 0)) |tm| {
        if (tm.cur == token.len) return .time; // m4: implied 2000-01-01, gate always passes
    }
    if (isRelativeLiteral(token)) return .relative; // m5
    if (matchBareDecimal(token)) return .julian; // m6/r3
    return null;
}

/// §4.6.4-m8 writer grammar: `date %x54 time [frac]` -- YYYY-MM-DD, exactly
/// the byte 'T' (never lowercase, never whitespace, never a bare space), then
/// HH:MM:SS with SS mandatory, then an optional `.frac`. No leading sign, no
/// timezone. True iff `token` is exactly that canonical interoperable form
/// (w1: `2017-12-06T15:36:24.287`). This is the oracle a future D/E emitter
/// and any round-trip check call; parallels the hash-token canonical-writer
/// split already in this module (see `validateName` vs. its callers).
pub fn isWriterTimestamp(token: []const u8) bool {
    if (token.len == 0) return false; // negative space
    const dm = matchDate(token) orelse return false;
    if (dm.y_neg) return false; // writer form: no leading '-'
    if (dm.cur >= token.len or token[dm.cur] != 'T') return false; // exactly one 'T', no ws
    const tm = matchTime(token, dm.cur + 1) orelse return false;
    if (!tm.had_seconds) return false; // writer time = HH:MM:SS, SS mandatory
    if (tm.cur != token.len) return false; // no tz permitted
    return dateGateOk(dm.y_neg, dm.y, dm.m, dm.d); // r3
}

/// Parse exactly `n` ASCII digit bytes from the front of `s` as an unsigned
/// int, or null if fewer than `n` bytes remain or any of the first `n` bytes
/// is not '0'-'9' (§4.6.4-m7 "exactly N digits" -- no sign, no short/long
/// reads). Wire-derived input: failure is a guard return, never a panic.
fn parseFixedDigits(s: []const u8, comptime n: usize) ?u32 {
    comptime std.debug.assert(n <= 4); // largest field here is the 4-digit year
    if (s.len < n) return null;
    var v: u32 = 0;
    for (s[0..n]) |b| { // bound: fixed at comptime n
        if (b < '0' or b > '9') return null;
        v = v * 10 + (b - '0');
    }
    std.debug.assert(v < comptime std.math.pow(u32, 10, n)); // n digits bound the value
    return v;
}

const DateMatch = struct { cur: usize, y_neg: bool, y: u32, m: u32, d: u32 };

/// §4.6.4-m3/m7 date: optional leading '-', then YYYY-MM-DD. Month is
/// range-checked to 01-12 and day to 01-31 here (m7); there is no
/// calendar-aware check, so 2024-02-31 parses fine (that acceptance is
/// exercised by the caller, not rejected here). Returns the match plus the
/// cursor just past the day field, or null on any mismatch.
fn matchDate(token: []const u8) ?DateMatch {
    var cur: usize = 0;
    var y_neg = false;
    if (token.len > 0 and token[0] == '-') {
        y_neg = true;
        cur = 1;
    }
    const y = parseFixedDigits(token[cur..], 4) orelse return null;
    cur += 4;
    if (cur >= token.len or token[cur] != '-') return null;
    cur += 1;
    const m = parseFixedDigits(token[cur..], 2) orelse return null;
    if (m < 1 or m > 12) return null; // m7: month 01-12
    std.debug.assert(m >= 1 and m <= 12);
    cur += 2;
    if (cur >= token.len or token[cur] != '-') return null;
    cur += 1;
    const d = parseFixedDigits(token[cur..], 2) orelse return null;
    if (d < 1 or d > 31) return null; // m7: day 01-31, no calendar check
    std.debug.assert(d >= 1 and d <= 31);
    cur += 2;
    std.debug.assert(cur <= token.len);
    return DateMatch{ .cur = cur, .y_neg = y_neg, .y = y, .m = m, .d = d };
}

/// §4.6.4-r3 JD>0 gate, date-granularity reading (RESOLVED operator decision,
/// 2026-07-09 -- see ambiguities.md): reject a date/date-time token iff its
/// signed (year, month, day) is lexicographically <= (-4713, 11, 24),
/// independent of time-of-day or tz. `year` is the signed astronomical year
/// (y_neg negates `y`); this is what makes -4713-11-24T18:00:00 reject
/// despite its positive fractional Julian Date.
fn dateGateOk(y_neg: bool, y: u32, m: u32, d: u32) bool {
    std.debug.assert(m >= 1 and m <= 12);
    std.debug.assert(d >= 1 and d <= 31);
    const year: i32 = if (y_neg) -@as(i32, @intCast(y)) else @as(i32, @intCast(y));
    if (year < -4713) return false;
    if (year > -4713) return true;
    if (m < 11) return false;
    if (m > 11) return true;
    return d > 24;
}

const TimeMatch = struct { cur: usize, had_seconds: bool };

/// §4.6.4-m1/m4/m7 time-of-day: HH:MM, HH:MM:SS, or HH:MM:SS.frac. HH is
/// 00-24, MM/SS are 00-59, frac (only legal after ':SS') is 1+ digits.
/// Returns the cursor just past the match plus whether ':SS' was present, or
/// null on any mismatch.
fn matchTime(token: []const u8, i: usize) ?TimeMatch {
    var cur = i;
    const hh = parseFixedDigits(token[cur..], 2) orelse return null;
    if (hh > 24) return null; // m7: hour 00-24
    std.debug.assert(hh <= 24);
    cur += 2;
    if (cur >= token.len or token[cur] != ':') return null;
    cur += 1;
    const mm = parseFixedDigits(token[cur..], 2) orelse return null;
    if (mm > 59) return null; // m7: minute 00-59
    std.debug.assert(mm <= 59);
    cur += 2;
    var had_seconds = false;
    if (cur < token.len and token[cur] == ':') {
        cur += 1;
        const ss = parseFixedDigits(token[cur..], 2) orelse return null;
        if (ss > 59) return null; // m7: second 00-59
        std.debug.assert(ss <= 59);
        cur += 2;
        had_seconds = true;
        if (cur < token.len and token[cur] == '.') {
            cur += 1;
            const frac_start = cur;
            while (cur < token.len and std.ascii.isDigit(token[cur])) : (cur += 1) {} // bound: token.len
            if (cur == frac_start) return null; // m7: frac needs 1+ digits
            std.debug.assert(cur > frac_start);
        }
    }
    std.debug.assert(cur <= token.len);
    return TimeMatch{ .cur = cur, .had_seconds = had_seconds };
}

/// §4.6.4-m2 optional timezone: optional whitespace, then '+HH:MM',
/// '-HH:MM', 'Z', or 'z'. HH/MM are exactly 2 digits each, structural only --
/// the RFC gives no tz range and no conforming writer ever emits one (m8).
/// Returns the cursor past a matched tz, or `i` unchanged if none is present:
/// that "no advance" is what makes the whole timezone optional -- the
/// caller's final `cur == token.len` check does the actual accept/reject.
fn matchTz(token: []const u8, i: usize) usize {
    var cur = i;
    while (cur < token.len and std.ascii.isWhitespace(token[cur])) : (cur += 1) {} // bound: token.len
    if (cur >= token.len) return i; // no tz; don't consume trailing ws
    const c = token[cur];
    if (c == 'Z' or c == 'z') return cur + 1;
    if (c == '+' or c == '-') {
        var p = cur + 1;
        _ = parseFixedDigits(token[p..], 2) orelse return i;
        p += 2;
        if (p >= token.len or token[p] != ':') return i;
        p += 1;
        _ = parseFixedDigits(token[p..], 2) orelse return i;
        p += 2;
        std.debug.assert(p <= token.len);
        return p;
    }
    return i; // no tz
}

/// §4.6.4-m5: `now`, `subsec`, or `subsecond`, case-insensitive, whole-token
/// exact match only.
fn isRelativeLiteral(token: []const u8) bool {
    return std.ascii.eqlIgnoreCase(token, "now") or
        std.ascii.eqlIgnoreCase(token, "subsec") or
        std.ascii.eqlIgnoreCase(token, "subsecond");
}

/// §4.6.4-m6/r3 bare decimal: lexically `1*DIGIT ["." 1*DIGIT]` (>=1 integer
/// digit; a dot requires >=1 fraction digit, mirroring the time frac rule),
/// consuming the whole token, then accepted iff its value r satisfies
/// `0 < r < 5373484.5` (m6 gives `0 <= r`; r3 tightens to `r > 0`, dropping
/// the numeric token "0").
fn matchBareDecimal(token: []const u8) bool {
    var i: usize = 0;
    while (i < token.len and std.ascii.isDigit(token[i])) : (i += 1) {} // bound: token.len
    if (i == 0) return false; // need >=1 integer digit
    std.debug.assert(i >= 1);
    if (i < token.len and token[i] == '.') {
        var j = i + 1;
        while (j < token.len and std.ascii.isDigit(token[j])) : (j += 1) {} // bound: token.len
        if (j == i + 1) return false; // dot requires >=1 fraction digit
        i = j;
    }
    if (i != token.len) return false; // trailing byte the lexical grammar doesn't cover
    const r = std.fmt.parseFloat(f64, token) catch return false; // unreachable on a validated slice
    return r > 0 and r < 5373484.5;
}

comptime {
    std.debug.assert(classifyTimestamp("2017-12-06T15:36:24.287").? == .date_time); // w1
    std.debug.assert(isWriterTimestamp("2017-12-06T15:36:24.287")); // w1
    std.debug.assert(classifyTimestamp("2017-12-06").? == .date); // m3
    std.debug.assert(classifyTimestamp("15:36:24").? == .time); // m4
    std.debug.assert(classifyTimestamp("now").? == .relative); // m5
    std.debug.assert(classifyTimestamp("2451545").? == .julian); // m6
    std.debug.assert(classifyTimestamp("2017-12-06t15:36:24") == null); // r1
    std.debug.assert(classifyTimestamp("-4713-11-24") == null); // r3 boundary
    std.debug.assert(classifyTimestamp("0") == null); // r3 numeric zero
    std.debug.assert(!isWriterTimestamp("2017-12-06")); // m8: date alone is not the writer form
}

// ---------------------------------------------------------------------------
// Manifest parsing (Sections 4, 5) — TODO
// ---------------------------------------------------------------------------

pub const Manifest = struct {
    // Populated by parse(): cards, type, name. Skeleton for now.
    kind: ArtifactType = .other,
};

pub const ParseError = error{ SyntaxError, NotImplemented };

/// Parse a structural artifact from its bytes. See Section 4 (card syntax),
/// Section 5 (types + per-type validity). Not yet implemented.
pub fn parse(allocator: std.mem.Allocator, bytes: []const u8) ParseError!Manifest {
    _ = allocator;
    _ = bytes;
    return ParseError.NotImplemented;
}

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

test "artifact §3.1: name is SHA3-256 of exact raw bytes" {
    var out: [64]u8 = undefined;
    // §A.3 / Table 8 (RFC line 2584): alpha.txt's content is "hello world\n",
    // named by the SHA3-256 given as its F-card hash.
    const name = artifactName(.sha3_256, "hello world\n", &out);
    try std.testing.expectEqualStrings(
        "a8009a7a528d87778c356da3a55d964719e818666a04e4f960c9e2439e35f138",
        name,
    );
}

// The §A.1.1 SHA-1 worked-example manifest: 127 raw bytes (27+22+2+35+6+35
// per the §A.2 Z-card offset table), literal backslash-s escapes retained
// exactly as printed in the RFC. Its stated name is
// a28c83647dfa805f05f3204a7e146eb1f0d90505 (RFC line 2426).
const a1_1_manifest = @embedFile("rfc_vectors/artifact_a1_1_sha1_manifest.txt");
const a1_1_name = "a28c83647dfa805f05f3204a7e146eb1f0d90505";

test "artifact §3.2: 40-hex name is SHA-1 over raw bytes" {
    try std.testing.expectEqual(@as(usize, 127), a1_1_manifest.len);
    var out: [64]u8 = undefined;
    const name = artifactName(.sha1, a1_1_manifest, &out);
    try std.testing.expectEqualStrings(a1_1_name, name);
}

test "artifact §3.2: name length selects algorithm" {
    try std.testing.expectEqual(HashAlgo.sha1, nameAlgo(40).?);
    try std.testing.expectEqual(HashAlgo.sha3_256, nameAlgo(64).?);
}

test "artifact §3.2: name of any other length is rejected" {
    const bad_lens = [_]usize{ 0, 4, 39, 41, 63, 65, 128 };
    for (bad_lens) |len| {
        try std.testing.expect(nameAlgo(len) == null);
        // Fabricate an all-'a' token of that length: rejection here must be
        // on length alone, before any character-class check (§3.2-r1).
        var buf: [128]u8 = undefined;
        std.debug.assert(len <= buf.len);
        @memset(buf[0..len], 'a');
        try std.testing.expect(!validateName(buf[0..len]));
    }
}

test "artifact §3.3: computed name is lowercase hex" {
    var out: [64]u8 = undefined;
    const sha1_name = artifactName(.sha1, a1_1_manifest, &out);
    for (sha1_name) |c| try std.testing.expect(isHexLower(c));

    var out2: [64]u8 = undefined;
    const sha3_name = artifactName(.sha3_256, "hello world\n", &out2);
    for (sha3_name) |c| try std.testing.expect(isHexLower(c));
}

test "artifact §3.3: validation rejects uppercase, does not normalize" {
    // Same fixture as the rejection test above, restated to make the point
    // explicit: a normalize-then-accept parser would flip this to true.
    var upper: [40]u8 = undefined;
    for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c);
    try std.testing.expect(!validateName(&upper));
    // And the lowercase original still validates, proving the reject is
    // case-specific rather than a blanket failure.
    try std.testing.expect(validateName(a1_1_name));
}

test "artifact §3.4: verify dispatches on length" {
    try std.testing.expect(verify(a1_1_name, a1_1_manifest));
    try std.testing.expect(verify(
        "a8009a7a528d87778c356da3a55d964719e818666a04e4f960c9e2439e35f138",
        "hello world\n",
    ));
}

test "artifact §3.4: uppercase name never verifies" {
    var upper: [40]u8 = undefined;
    for (a1_1_name, 0..) |c, i| upper[i] = std.ascii.toUpper(c);
    try std.testing.expect(!verify(&upper, a1_1_manifest));
}

test "artifact §3.4: one undifferentiated failure signal" {
    // Wrong length.
    try std.testing.expect(!verify("deadbeef10", a1_1_manifest));
    // Non-hex character at a valid (40) length.
    var non_hex: [40]u8 = undefined;
    @memcpy(&non_hex, a1_1_name);
    non_hex[0] = 'g';
    try std.testing.expect(!verify(&non_hex, a1_1_manifest));
    // Valid-form 40-hex name, wrong digest (mismatched content).
    try std.testing.expect(!verify(a1_1_name, "hello world\n"));
}

test "artifact §3.5: ABNF canonical grammar accepts lhexdig only" {
    var boundary: [40]u8 = undefined;
    @memset(&boundary, '0');
    try std.testing.expect(validateName(&boundary));
    @memset(&boundary, '9');
    try std.testing.expect(validateName(&boundary));
    @memset(&boundary, 'a');
    try std.testing.expect(validateName(&boundary));
    @memset(&boundary, 'f');
    try std.testing.expect(validateName(&boundary));

    var has_g: [40]u8 = undefined;
    @memset(&has_g, 'a');
    has_g[39] = 'g';
    try std.testing.expect(!validateName(&has_g));

    var has_upper: [40]u8 = undefined;
    @memset(&has_upper, 'a');
    has_upper[0] = 'A';
    try std.testing.expect(!validateName(&has_upper));

    var sha3_all_f: [64]u8 = undefined;
    @memset(&sha3_all_f, 'f');
    try std.testing.expect(validateName(&sha3_all_f));
}

test "artifact §3.6: prefix resolves and lowercases uppercase input" {
    const names = [_][]const u8{a1_1_name};
    switch (try resolvePrefix("a28c", &names)) {
        .one => |idx| try std.testing.expectEqual(@as(usize, 0), idx),
        else => return error.TestUnexpectedResult,
    }
    switch (try resolvePrefix("A28C", &names)) {
        .one => |idx| try std.testing.expectEqual(@as(usize, 0), idx),
        else => return error.TestUnexpectedResult,
    }
}

test "artifact §3.6: prefix length outside 4..64 is invalid" {
    const names = [_][]const u8{a1_1_name};
    try std.testing.expectError(error.InvalidPrefix, resolvePrefix("a28", &names));
    var too_long: [65]u8 = undefined;
    @memset(&too_long, 'a');
    try std.testing.expectError(error.InvalidPrefix, resolvePrefix(&too_long, &names));
    try std.testing.expectError(error.InvalidPrefix, resolvePrefix("zzzz", &names));
}

test "artifact §3.6: ambiguous prefix, incl. cross-algorithm collision" {
    // Cross-algorithm: one SHA-1-named, one SHA3-named, sharing "a28c".
    const cross = [_][]const u8{
        a1_1_name,
        "a28c000000000000000000000000000000000000000000000000000000000000",
    };
    try std.testing.expectEqual(Resolution.ambiguous, try resolvePrefix("a28c", &cross));

    // Same-algorithm: two 40-char names sharing a prefix, reported the same.
    const same = [_][]const u8{
        a1_1_name,
        "a28c000000000000000000000000000000000000",
    };
    try std.testing.expectEqual(Resolution.ambiguous, try resolvePrefix("a28c", &same));
}

test "self-checksum = lowercase MD5 (empty input vector)" {
    var out: [32]u8 = undefined;
    const z = selfChecksum("", &out);
    try std.testing.expectEqualStrings("d41d8cd98f00b204e9800998ecf8427e", z);
}

test "artifact §4.1.1: stripping only when first 34 bytes match armor" {
    // Under 34 bytes: too short to match, returned unchanged.
    const short = "-----BEGIN PGP SIGNED MESSAGE---"; // 32 bytes, two dashes short
    try std.testing.expectEqualStrings(short, stripSignatureWrapper(short));

    // Armor text present, but not at offset 0: unchanged.
    const offset5 = "12345-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n";
    try std.testing.expectEqualStrings(offset5, stripSignatureWrapper(offset5));

    // 34 bytes, none of them the armor literal: unchanged.
    const not_armor = "x" ** 34 ++ "\n\nbody\n";
    try std.testing.expectEqualStrings(not_armor, stripSignatureWrapper(not_armor));

    // Exact open armor at offset 0: stripped.
    const wrapped = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n";
    try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(wrapped));
}

test "artifact §4.1.1: body begins after first blank line (LF LF or LF CR LF)" {
    const wrapped_lf = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\nbody line\n";
    const wrapped_crlf = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\r\nbody line\n";
    try std.testing.expectEqualStrings("body line\n", stripSignatureWrapper(wrapped_lf));
    try std.testing.expectEqualStrings("body line\n", stripSignatureWrapper(wrapped_crlf));
}

test "artifact §4.1.1: no blank line leaves content wholly unstripped" {
    const wrapped = "-----BEGIN PGP SIGNED MESSAGE-----\nhello\n";
    try std.testing.expectEqualStrings(wrapped, stripSignatureWrapper(wrapped));
}

test "artifact §4.1.1: trailing signature armor truncates, retaining the LF" {
    const pgp_wrapped = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n" ++
        "-----BEGIN PGP SIGNATURE-----\nabc\n-----END PGP SIGNATURE-----\n";
    try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(pgp_wrapped));

    const ssh_wrapped = "-----BEGIN SSH SIGNED MESSAGE-----\n\nbody\n" ++
        "-----BEGIN SSH SIGNATURE-----\nabc\n-----END SSH SIGNATURE-----\n";
    try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(ssh_wrapped));

    // No trailing armor at all: runs to end of input.
    const no_close = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n";
    try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(no_close));
}

test "artifact §4.1.1: trailing armor one dash short still matches (29 of 30)" {
    // 4 trailing dashes (one short of the closing run) + arbitrary byte: matches.
    const one_short = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n" ++
        "-----BEGIN PGP SIGNATURE----X\ntrailer\n";
    try std.testing.expectEqualStrings("body\n", stripSignatureWrapper(one_short));

    // 3 trailing dashes (two short): the 29-byte compare fails, runs to EOF.
    const two_short = "-----BEGIN PGP SIGNED MESSAGE-----\n\nbody\n" ++
        "-----BEGIN PGP SIGNATURE---\ntrailer\n";
    const expected_two_short = "body\n-----BEGIN PGP SIGNATURE---\ntrailer\n";
    try std.testing.expectEqualStrings(expected_two_short, stripSignatureWrapper(two_short));
}

test "artifact §4.1.1: PGP and SSH wrappers are removed before parsing" {
    const pgp_wrapped = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" ++
        a1_1_manifest ++ "-----BEGIN PGP SIGNATURE-----\nabc123\n-----END PGP SIGNATURE-----\n";
    try std.testing.expectEqualStrings(a1_1_manifest, stripSignatureWrapper(pgp_wrapped));

    const ssh_wrapped = "-----BEGIN SSH SIGNED MESSAGE-----\n\n" ++
        a1_1_manifest ++ "-----BEGIN SSH SIGNATURE-----\nabc123\n-----END SSH SIGNATURE-----\n";
    try std.testing.expectEqualStrings(a1_1_manifest, stripSignatureWrapper(ssh_wrapped));
}

test "artifact §4.1: raw input must be non-empty and end in LF" {
    try std.testing.expect(frame("") == null);
    try std.testing.expect(frame("C comment") == null); // no trailing LF
    try std.testing.expect(frame("C comment\n") != null);
}

test "artifact §4.1: stripped content under 10 bytes is rejected" {
    try std.testing.expect(frame("C 123456\n") == null); // 9 bytes
    try std.testing.expect(frame("C 1234567\n") != null); // 10 bytes
}

test "artifact §4.1: first stripped byte must be uppercase A-Z" {
    const table = [_]struct { byte0: u8, ok: bool }{
        .{ .byte0 = '@', .ok = false }, // 0x40, just below 'A'
        .{ .byte0 = 'A', .ok = true },
        .{ .byte0 = 'Z', .ok = true },
        .{ .byte0 = '[', .ok = false }, // 0x5B, just above 'Z'
        .{ .byte0 = 'c', .ok = false }, // lowercase
    };
    for (table) |row| {
        var buf = [_]u8{ row.byte0, ' ', '1', '2', '3', '4', '5', '6', '7', '\n' };
        std.debug.assert(buf.len == 10);
        try std.testing.expectEqual(row.ok, frame(&buf) != null);
    }
}

test "artifact §4.1: second stripped byte must be a single space" {
    const table = [_]struct { byte1: u8, ok: bool }{
        .{ .byte1 = ' ', .ok = true },
        .{ .byte1 = '\t', .ok = false },
        .{ .byte1 = 'X', .ok = false },
        .{ .byte1 = '\n', .ok = false }, // argument-less first card
    };
    for (table) |row| {
        var buf = [_]u8{ 'C', row.byte1, '1', '2', '3', '4', '5', '6', '7', '\n' };
        std.debug.assert(buf.len == 10);
        try std.testing.expectEqual(row.ok, frame(&buf) != null);
    }
}

test "artifact §4.1/§6.2: present but mismatched Z card is rejected" {
    // Z card occupies the final 35 bytes of the 127-byte manifest: Z@92,
    // SP@93, digest 94..125, LF@126 (§A.2 offset table).
    var flipped: [127]u8 = undefined;
    @memcpy(&flipped, a1_1_manifest);
    flipped[94] = 'd'; // digest[0] was 'c'; still hex, still a mismatch
    try std.testing.expectEqual(ZVerdict.mismatch, verifyZCard(&flipped));
    try std.testing.expect(frame(&flipped) == null);

    var uppercased: [127]u8 = undefined;
    @memcpy(&uppercased, a1_1_manifest);
    uppercased[94] = 'C'; // numerically same digit, wrong case
    try std.testing.expectEqual(ZVerdict.mismatch, verifyZCard(&uppercased));
    try std.testing.expect(frame(&uppercased) == null);
}

test "artifact §4.1/§6.2: absent Z card passes the framing gate" {
    try std.testing.expectEqual(ZVerdict.absent, verifyZCard("C comment\n"));
    try std.testing.expect(frame("C comment\n") != null);

    try std.testing.expectEqual(ZVerdict.match, verifyZCard(a1_1_manifest));
    try std.testing.expect(frame(a1_1_manifest) != null);
}

test "artifact §4.1: gates apply in order (raw-LF before strip)" {
    // Outer wrapper omits its final LF: gate 1 must fire on the raw bytes,
    // before stripping ever runs — the stripped inner body (a1_1_manifest)
    // always ends in LF regardless, so this only fails if gates run in order.
    const wrapped_no_lf = "-----BEGIN PGP SIGNED MESSAGE-----\nHash: SHA256\n\n" ++
        a1_1_manifest ++ "-----BEGIN PGP SIGNATURE-----\nabc\n-----END PGP SIGNATURE-----";
    try std.testing.expect(frame(wrapped_no_lf) == null);

    const wrapped_with_lf = wrapped_no_lf ++ "\n";
    try std.testing.expect(frame(wrapped_with_lf) != null);
}

// ---------------------------------------------------------------------------
// §4.2 Line and Token Grammar / §4.2.2 Closed Card Alphabet — tests
// ---------------------------------------------------------------------------

test "artifact §4.2.2: exactly the 21 defined letters are accepted" {
    // Lines 622-624: "Exactly 21 card letters are defined: A, B, C, D, E, F,
    // G, H, I, J, K, L, M, N, P, Q, R, T, U, W, Z." Closed set, no
    // forward-compat (lines 626-627).
    const undefined_in_range = "OSVXY";
    var accepted: usize = 0;
    var c: u8 = 'A';
    while (true) : (c += 1) {
        const is_undefined = std.mem.indexOfScalar(u8, undefined_in_range, c) != null;
        try std.testing.expectEqual(!is_undefined, isCardLetter(c));
        if (isCardLetter(c)) accepted += 1;
        if (c == 'Z') break;
    }
    try std.testing.expectEqual(@as(usize, 21), accepted);
}

test "artifact §4.2.2: card letter outside A-Z is rejected" {
    // Line 624: "A card letter outside A-Z MUST be rejected."
    const table = [_]u8{ 0x40, '[', 'c', '0' }; // '@' just below 'A', '[' just above 'Z'
    for (table) |b| {
        try std.testing.expect(!isCardLetter(b));
        var buf = [_]u8{ b, '\n' };
        try std.testing.expect(tokenizeCardLine(&buf) == null);
    }
}

test "artifact §4.2.2: in-range undefined letters O S V X Y are rejected" {
    // Line 625: "an in-range but undefined letter (O, S, V, X, Y) MUST be
    // rejected."
    const table = [_]u8{ 'O', 'S', 'V', 'X', 'Y' };
    for (table) |b| {
        try std.testing.expect(!isCardLetter(b));
        var buf = [_]u8{ b, '\n' };
        try std.testing.expect(tokenizeCardLine(&buf) == null);
    }
}

test "artifact §4.2: tchar admits every octet except SP and LF" {
    // Lines 590-591: "tchar = %x00-09 / %x0B-1F / %x21-FF ; any octet except
    // SP (0x20) and LF (0x0A)".
    const admitted = [_]u8{ 0x00, 0x09, 0x0B, 0x1F, 0x21, 0xFF };
    for (admitted) |b| try std.testing.expect(isTokenByte(b));
    const excluded = [_]u8{ 0x0A, 0x20 };
    for (excluded) |b| try std.testing.expect(!isTokenByte(b));
}

test "artifact §4.2: card tokenizes to letter and SP-split tokens" {
    // Grammar lines 586-589: card-args = token *( SP token ).
    const line = "F path abc123 w\n";
    const card = tokenizeCardLine(line).?;
    try std.testing.expectEqual(@as(u8, 'F'), card.letter);
    try std.testing.expectEqualStrings("path abc123 w", card.args.?);

    var it = card.tokens();
    try std.testing.expectEqualStrings("path", it.next().?);
    try std.testing.expectEqualStrings("abc123", it.next().?);
    try std.testing.expectEqualStrings("w", it.next().?);
    try std.testing.expect(it.next() == null);
}

test "artifact §4.2-m1: byte after card letter must be SP or LF, tab rejected" {
    // Lines 593-595: "MUST be exactly one space (0x20 ...) or LF ... Any
    // other second byte — including tab (0x09) — makes the card invalid."
    {
        var buf = "C x\n".*;
        const card = tokenizeCardLine(&buf).?;
        try std.testing.expectEqualStrings("x", card.args.?);
    }
    {
        var buf = "C\n".*;
        const card = tokenizeCardLine(&buf).?;
        try std.testing.expectEqual(@as(?[]const u8, null), card.args);
        var it = card.tokens();
        try std.testing.expect(it.next() == null);
    }
    {
        var buf = "C\tx\n".*;
        try std.testing.expect(tokenizeCardLine(&buf) == null);
    }
    {
        var buf = "CX\n".*;
        try std.testing.expect(tokenizeCardLine(&buf) == null);
    }
}

test "artifact §4.2-m2: tokens split on space only; tab retained in token" {
    // Lines 596-598: "split on space and LF only; tab is not a delimiter and
    // is retained ... a token can never contain an embedded space." The
    // byte after the letter must still be SP (§4.2-m1); the tab lives
    // inside the token that follows, not in the letter/SP boundary.
    var buf = "T \tvalue\n".*;
    const card = tokenizeCardLine(&buf).?;
    var it = card.tokens();
    const only = it.next().?;
    try std.testing.expectEqualStrings("\tvalue", only);
    try std.testing.expect(std.mem.indexOfScalar(u8, only, ' ') == null);
    try std.testing.expect(it.next() == null);

    var buf2 = "T a b c\n".*;
    const card2 = tokenizeCardLine(&buf2).?;
    var it2 = card2.tokens();
    var n: usize = 0;
    while (it2.next()) |tok| : (n += 1) {
        try std.testing.expect(std.mem.indexOfScalar(u8, tok, ' ') == null);
    }
    try std.testing.expectEqual(@as(usize, 3), n);
}

test "artifact §4.2-m3: CR is an ordinary token byte, not a delimiter" {
    // Lines 598-600: "There is no CR (0x0D) handling in the card and token
    // path — a CR is an ordinary token byte."
    var buf = "C a\rb\n".*;
    const card = tokenizeCardLine(&buf).?;
    var it = card.tokens();
    try std.testing.expectEqualStrings("a\rb", it.next().?);
    try std.testing.expect(it.next() == null);
}

test "artifact §4.2: bare U vs empty-token U distinguished (RFC line 1418)" {
    // §4.7.17: a bare U line (0 tokens) differs from a U followed by an
    // empty token (1 empty token) — both collapse to "anonymous" at the
    // per-card layer, but the tokenizer must still tell them apart.
    var bare = "U\n".*;
    const bare_card = tokenizeCardLine(&bare).?;
    try std.testing.expectEqual(@as(?[]const u8, null), bare_card.args);
    var bare_it = bare_card.tokens();
    try std.testing.expect(bare_it.next() == null);

    var empty = "U \n".*;
    const empty_card = tokenizeCardLine(&empty).?;
    try std.testing.expectEqualStrings("", empty_card.args.?);
    var empty_it = empty_card.tokens();
    try std.testing.expectEqualStrings("", empty_it.next().?);
    try std.testing.expect(empty_it.next() == null);
}

// ---------------------------------------------------------------------------
// §4.3 Character-Escape Encoding — tests
// ---------------------------------------------------------------------------

test "artifact §4.3-m1: the eight escape pairs decode to their octets" {
    // Table 1 (lines 635-653): each row's "Decoded octet" column.
    const table = [_]struct { input: []const u8, expected: u8 }{
        .{ .input = "\\n", .expected = 0x0A },
        .{ .input = "\\s", .expected = 0x20 },
        .{ .input = "\\t", .expected = 0x09 },
        .{ .input = "\\r", .expected = 0x0D },
        .{ .input = "\\v", .expected = 0x0B },
        .{ .input = "\\f", .expected = 0x0C },
        .{ .input = "\\0", .expected = 0x00 },
        .{ .input = "\\\\", .expected = 0x5C },
    };
    for (table) |row| {
        var out: [2]u8 = undefined;
        const decoded = decodeEscapes(row.input, &out);
        try std.testing.expectEqual(@as(usize, 1), decoded.len);
        try std.testing.expectEqual(row.expected, decoded[0]);
    }
}

test "artifact §4.3-m2: unrecognized escape drops the backslash, keeps the successor" {
    // Lines 657-660: "the decoder discards the backslash and emits the
    // following character unchanged ... decodes to the single byte C."
    const table = [_]struct { input: []const u8, expected: u8 }{
        .{ .input = "\\x", .expected = 'x' },
        .{ .input = "\\1", .expected = '1' }, // only \0 is in Table 1
        .{ .input = "\\!", .expected = '!' },
    };
    for (table) |row| {
        var out: [2]u8 = undefined;
        const decoded = decodeEscapes(row.input, &out);
        try std.testing.expectEqual(@as(usize, 1), decoded.len);
        try std.testing.expectEqual(row.expected, decoded[0]);
    }
}

test "artifact §4.3-amb1: a lone trailing backslash is emitted literally" {
    // ambiguities.md RESOLVED reading #1 (pass-through, operator 2026-07-09):
    // a backslash with no successor forms no pair, emitted as its own byte.
    var out1: [1]u8 = undefined;
    const decoded1 = decodeEscapes("\\", &out1);
    try std.testing.expectEqualStrings("\\", decoded1);

    var out2: [2]u8 = undefined;
    const decoded2 = decodeEscapes("a\\", &out2);
    try std.testing.expectEqualStrings("a\\", decoded2);
}

test "artifact §4.3-w1: a pure-hexadecimal wire token decodes to itself" {
    // Line 698: "A pure-hexadecimal wire token decodes to itself" — no
    // backslash present, every byte is ordinary.
    const short = "deadbeef";
    var out_short: [short.len]u8 = undefined;
    try std.testing.expectEqualStrings(short, decodeEscapes(short, &out_short));

    const long = "a28c83647dfa805f05f3204a7e146eb1f0d9050500000000000000000000000";
    var out_long: [long.len]u8 = undefined;
    try std.testing.expectEqualStrings(long, decodeEscapes(long, &out_long));
}

test "artifact §4.3: escapes decode left-to-right without reprocessing output" {
    // §4.3 escapes are "decoded in place" over the wire token (lines
    // 632-633): the decoded backslash from \\ -> 0x5C is emitted, not
    // re-read, so a trailing 'n' after it stays literal (not LF).
    const input = "\\\\n"; // backslash, backslash, n
    var out: [3]u8 = undefined;
    const decoded = decodeEscapes(input, &out);
    try std.testing.expectEqual(@as(usize, 2), decoded.len);
    try std.testing.expectEqual(@as(u8, 0x5C), decoded[0]);
    try std.testing.expectEqual(@as(u8, 'n'), decoded[1]);

    var out_empty: [0]u8 = undefined;
    try std.testing.expectEqualStrings("", decodeEscapes("", &out_empty));
}

test "artifact §4.3: escape encode is the inverse of decode" {
    // Table 1 (lines 635-653): each of the eight octets round-trips through
    // encodeEscapes -> decodeEscapes; every other byte passes through 1:1.
    const table = [_]struct { octet: u8, successor: u8 }{
        .{ .octet = 0x0A, .successor = 'n' },
        .{ .octet = 0x20, .successor = 's' },
        .{ .octet = 0x09, .successor = 't' },
        .{ .octet = 0x0D, .successor = 'r' },
        .{ .octet = 0x0B, .successor = 'v' },
        .{ .octet = 0x0C, .successor = 'f' },
        .{ .octet = 0x00, .successor = '0' },
        .{ .octet = 0x5C, .successor = '\\' },
    };
    for (table) |row| {
        const raw = [_]u8{row.octet};
        var enc_buf: [2]u8 = undefined;
        const encoded = encodeEscapes(&raw, &enc_buf);
        try std.testing.expectEqual(@as(usize, 2), encoded.len);
        try std.testing.expectEqual(@as(u8, '\\'), encoded[0]);
        try std.testing.expectEqual(row.successor, encoded[1]);

        var dec_buf: [2]u8 = undefined;
        const decoded = decodeEscapes(encoded, &dec_buf);
        try std.testing.expectEqualSlices(u8, &raw, decoded);
    }

    // ordinary byte passes through unescaped.
    var pass_buf: [2]u8 = undefined;
    try std.testing.expectEqualStrings("x", encodeEscapes("x", &pass_buf));

    // sync §11 worked usage: a space becomes \s.
    var sp_buf: [6]u8 = undefined;
    try std.testing.expectEqualStrings("a\\sb", encodeEscapes("a b", &sp_buf));

    var rt_buf: [8]u8 = undefined;
    const rt_encoded = encodeEscapes("a b", &rt_buf);
    var rt_dec_buf: [4]u8 = undefined;
    try std.testing.expectEqualStrings("a b", decodeEscapes(rt_encoded, &rt_dec_buf));
}

test "artifact §4.3 fuzz: encodeEscapes/decodeEscapes round-trip never crashes" {
    // Seeded deterministic fuzz over untrusted wire input:
    // decodeEscapes(encodeEscapes(x)) == x for arbitrary byte strings,
    // including adversarial ones that stress the backslash-collision case
    // this codec exists to avoid (raw text already containing '\').
    var prng = std.Random.DefaultPrng.init(0xA17F0551);
    const random = prng.random();
    var iter: usize = 0;
    const max_iters: usize = 500;
    const max_len: usize = 64;
    var raw_buf: [max_len]u8 = undefined;
    var enc_buf: [2 * max_len]u8 = undefined;
    var dec_buf: [2 * max_len]u8 = undefined;
    while (iter < max_iters) : (iter += 1) {
        const len = random.intRangeAtMost(usize, 0, max_len);
        for (raw_buf[0..len]) |*b| b.* = random.int(u8);
        const raw = raw_buf[0..len];
        const encoded = encodeEscapes(raw, &enc_buf);
        try std.testing.expect(encoded.len <= 2 * raw.len);
        const decoded = decodeEscapes(encoded, &dec_buf);
        try std.testing.expectEqualSlices(u8, raw, decoded);
    }
}

// ---------------------------------------------------------------------------
// §4.4 Card Ordering — tests
// ---------------------------------------------------------------------------

test "artifact §4.4-p1: cards admit in non-decreasing type order" {
    // Lines 705-707: "MUST appear in non-decreasing lexicographic card-type
    // order: each card's type letter MUST be greater than or equal to the
    // previous card's type letter."
    var s = OrderState{};
    const in_order = "CCDFTUZ";
    for (in_order) |letter| try std.testing.expect(s.admit(letter));

    var below = OrderState{};
    try std.testing.expect(below.admit('F'));
    try std.testing.expect(!below.admit('D')); // D (0x44) < F (0x46)
}

test "artifact §4.4-p2: consecutive equal-letter cards are permitted" {
    // Lines 708-711: consecutive same-letter cards permitted; the ordering
    // is non-decreasing, not strictly increasing.
    var s1 = OrderState{};
    try std.testing.expect(s1.admit('F'));
    try std.testing.expect(s1.admit('F'));

    var s2 = OrderState{};
    try std.testing.expect(s2.admit('P'));
    try std.testing.expect(s2.admit('P'));
}

test "artifact §4.4-r1: a letter sorting below the previous is rejected" {
    // Lines 708-709: "The rejection condition fires only when a card's
    // letter sorts below the previous card's letter" — exactly that
    // condition, nothing else.
    const table = [_]struct { prev: u8, cur: u8, ok: bool }{
        .{ .prev = 'D', .cur = 'C', .ok = false },
        .{ .prev = 'P', .cur = 'C', .ok = false },
        .{ .prev = 'Z', .cur = 'U', .ok = false },
        .{ .prev = 'C', .cur = 'C', .ok = true },
        .{ .prev = 'C', .cur = 'D', .ok = true },
    };
    for (table) |row| {
        var s = OrderState{};
        try std.testing.expect(s.admit(row.prev));
        try std.testing.expectEqual(row.ok, s.admit(row.cur));
    }
}

test "artifact §4.4-m1: N after P admitted once a valid E precedes" {
    // Lines 714-717: "an N card MAY appear immediately after a P card when
    // the artifact has already presented a valid E card. A parser MUST
    // accept that sequence, and any other out-of-order card MUST be
    // rejected."
    var s = OrderState{};
    for ("CDE") |letter| try std.testing.expect(s.admit(letter));
    try std.testing.expect(s.admit('P'));
    try std.testing.expect(s.admit('N')); // N (0x4E) < P (0x50): exception fires

    // Same driver still rejects a non-exception out-of-order step.
    try std.testing.expect(!s.admit('C')); // C (0x43) < N (0x4E), not the exception
}

test "artifact §4.4-r2: the N-after-P exception requires a prior E card" {
    // Lines 714-716 precondition: "has already presented a valid E card."
    var no_e = OrderState{};
    try std.testing.expect(no_e.admit('P'));
    try std.testing.expect(!no_e.admit('N')); // N < P, but e_seen is false: inert

    var with_e = OrderState{};
    try std.testing.expect(with_e.admit('E'));
    try std.testing.expect(with_e.admit('P'));
    try std.testing.expect(with_e.admit('N')); // exception fires
}

test "artifact §4.4-r3: out-of-order cards other than N-after-P are rejected" {
    // Lines 716-717: "any other out-of-order card MUST be rejected."
    // Appendix C (lines 2952-2955): "other out-of-order cards remain
    // rejected." Only N-immediately-after-P is tolerated.
    {
        var s = OrderState{};
        try std.testing.expect(s.admit('E'));
        try std.testing.expect(s.admit('P'));
        try std.testing.expect(!s.admit('M')); // M (0x4D) < P, not N
    }
    {
        var s = OrderState{};
        try std.testing.expect(s.admit('E'));
        try std.testing.expect(s.admit('Q'));
        try std.testing.expect(!s.admit('N')); // prev is Q, not P: inert
    }
    {
        var s = OrderState{};
        for ("EPN") |letter| try std.testing.expect(s.admit(letter));
        try std.testing.expect(!s.admit('M')); // M < N, below-N step still rejects
    }
}

test "artifact §4.4-r4: no letter follows Z; the exception does not involve Z" {
    // Lines 739-741: "Z is the lexicographically last defined letter, the
    // ordering rule admits no letter after it (the N-after-P exception
    // does not involve Z)."
    var s1 = OrderState{};
    try std.testing.expect(s1.admit('Z'));
    try std.testing.expect(!s1.admit('U'));

    var s2 = OrderState{};
    try std.testing.expect(s2.admit('Z'));
    try std.testing.expect(!s2.admit('C'));

    var s3 = OrderState{};
    try std.testing.expect(s3.admit('E'));
    try std.testing.expect(s3.admit('Z'));
    try std.testing.expect(!s3.admit('N')); // prev is Z, not P: exception inert
}

test "artifact §4.4-w1: the technote sequence C D E P N T U W Z is admitted" {
    // §A.1 worked example (lines 2471-2489) card letters in order; lines
    // 2498-2501: "the N card (MIME type) appears after the P card even
    // though N sorts before P in ASCII order. This is accepted because the
    // E card has already been parsed when N is encountered."
    var s = OrderState{};
    const sequence = "CDEPNTUWZ";
    for (sequence) |letter| try std.testing.expect(s.admit(letter));
}

// ---------------------------------------------------------------------------
// §4.5 Occurrence Classes and Intra-Type Ordering — tests
// ---------------------------------------------------------------------------

test "artifact §4.5.1: card letters partition into single / repeatable / neither" {
    // Lines 753-766: 14 single-occurrence, 6 repeatable, 1 (Z) neither —
    // 21 total, matching the closed alphabet.
    var single: usize = 0;
    var repeatable: usize = 0;
    var neither: usize = 0;
    var c: u8 = 'A';
    while (true) : (c += 1) {
        if (isCardLetter(c)) {
            switch (occurrenceClass(c)) {
                .single => single += 1,
                .repeatable => repeatable += 1,
                .neither => neither += 1,
            }
        }
        if (c == 'Z') break;
    }
    try std.testing.expectEqual(@as(usize, 14), single);
    try std.testing.expectEqual(@as(usize, 6), repeatable);
    try std.testing.expectEqual(@as(usize, 1), neither);
}

test "artifact §4.5.1-r1: a single-occurrence duplicate is rejected" {
    // Line 755-756: "Single-occurrence (a duplicate MUST be rejected): A, B,
    // C, D, E, G, H, I, K, L, N, R, U, W." Rejection: 2nd occurrence.
    const single_letters = "ABCDEGHIKLNRUW";
    try std.testing.expectEqual(@as(usize, 14), single_letters.len);
    for (single_letters) |letter| {
        var set = SingleOccurrenceSet{};
        try std.testing.expect(set.admit(letter));
        try std.testing.expect(!set.admit(letter));
    }
}

test "artifact §4.5.1-m1: repeatable card types are not single-occurrence" {
    // Lines 758-762: "Repeatable (each occurrence appends ...): F, J, M, T,
    // P, Q. It is not the case that any duplicate card type is a syntax
    // error." A duplicate must be admitted, not rejected.
    const repeatable_letters = "FJMTPQ";
    try std.testing.expectEqual(@as(usize, 6), repeatable_letters.len);
    for (repeatable_letters) |letter| {
        try std.testing.expectEqual(OccurrenceClass.repeatable, occurrenceClass(letter));
        var set = SingleOccurrenceSet{};
        try std.testing.expect(set.admit(letter));
        try std.testing.expect(set.admit(letter));
    }
}

test "artifact §4.5.1-m2: the Z card belongs to neither class and has no duplicate guard" {
    // Lines 764-766: "The Z card ... belongs to neither class. It has no
    // duplicate guard, and the ordering rule permits equal consecutive
    // letters."
    try std.testing.expectEqual(OccurrenceClass.neither, occurrenceClass('Z'));
    var set = SingleOccurrenceSet{};
    try std.testing.expect(set.admit('Z'));
    try std.testing.expect(set.admit('Z'));
    try std.testing.expectEqual(@as(?IntraRunRule, null), intraRunRule('Z'));
}

test "artifact §4.5.2-r1: F cards must strictly ascend by decoded filename" {
    // Table 2 line 792: "Strictly ascending by decoded filename (equal or
    // descending rejected)."
    try std.testing.expectEqual(IntraRunRule.ascending, intraRunRule('F').?);
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("a", "b"));
    // Rejection: equal.
    try std.testing.expectEqual(std.math.Order.eq, compareBytes("a", "a"));
    // Rejection: descending.
    try std.testing.expectEqual(std.math.Order.gt, compareBytes("b", "a"));
}

test "artifact §4.5.2-m1: the F sort check runs on the post-decode filename" {
    // Lines 813-817: "runs on the post-decode filename, not on the raw
    // wire-encoded token (decoding happens before the sort check)." Wire
    // "a\sb" decodes (§4.3 Table 1, \s -> SP 0x20) to "a b"; wire "a!b" has
    // no escape and stays "a!b". Decoded 2nd byte 0x20 < 0x21 ascends.
    var buf1: [8]u8 = undefined;
    const decoded1 = decodeEscapes("a\\sb", &buf1);
    try std.testing.expectEqualStrings("a b", decoded1);
    const raw2 = "a!b";
    try std.testing.expectEqual(std.math.Order.lt, compareBytes(decoded1, raw2));

    // On the RAW wire token (undecoded), "a\sb" is 0x61 0x5C 0x73 0x62: 2nd
    // byte 0x5C > 0x21, so a raw-token compare would report .gt (reject) —
    // the opposite of the post-decode result, proving decode precedes sort.
    const raw1 = "a\\sb";
    try std.testing.expectEqual(std.math.Order.gt, compareBytes(raw1, raw2));
}

test "artifact §4.5.2-r2: J cards must strictly ascend by field name" {
    // Table 2 line 795: "Strictly ascending by field name (equal or
    // descending rejected)."
    try std.testing.expectEqual(IntraRunRule.ascending, intraRunRule('J').?);
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("a", "b"));
    try std.testing.expectEqual(std.math.Order.eq, compareBytes("a", "a"));
    try std.testing.expectEqual(std.math.Order.gt, compareBytes("b", "a"));
}

test "artifact §4.5.2-r3: M cards must strictly ascend by hash" {
    // Table 2 line 798: "Strictly ascending by hash (equal or descending
    // rejected)."
    try std.testing.expectEqual(IntraRunRule.ascending, intraRunRule('M').?);
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("aa", "ab"));
    try std.testing.expectEqual(std.math.Order.eq, compareBytes("aa", "aa"));
    try std.testing.expectEqual(std.math.Order.gt, compareBytes("ab", "aa"));
}

test "artifact §4.5.2-r4: T cards ascend two-level by tag name then target hash" {
    // Table 2 lines 801-802: "Two-level ascending by (tag name, then target
    // hash); equal names require strictly increasing hash."
    try std.testing.expectEqual(IntraRunRule.tag_two_level, intraRunRule('T').?);
    // Names ascend: hash irrelevant.
    try std.testing.expectEqual(std.math.Order.lt, compareTag("a", "zz", "b", "aa"));
    // Equal name, ascending hash.
    try std.testing.expectEqual(std.math.Order.lt, compareTag("a", "aa", "a", "ab"));
    // Rejection: equal name, equal hash.
    try std.testing.expectEqual(std.math.Order.eq, compareTag("a", "aa", "a", "aa"));
    // Rejection: equal name, descending hash.
    try std.testing.expectEqual(std.math.Order.gt, compareTag("a", "ab", "a", "aa"));
    // Rejection: descending name (hash ascending doesn't rescue it).
    try std.testing.expectEqual(std.math.Order.gt, compareTag("b", "aa", "a", "zz"));
}

test "artifact §4.5.2-m2: P cards are neither sorted nor deduplicated" {
    // Table 2 lines 804-805: "Not sorted or deduplicated; order is semantic
    // (primary parent first)."
    try std.testing.expectEqual(IntraRunRule.unordered, intraRunRule('P').?);
}

test "artifact §4.5.2-m3: Q cards are unordered-repeatable with no duplicate guard" {
    // Table 2 lines 807-808: "No duplicate guard or ordering check;
    // unordered-repeatable."
    try std.testing.expectEqual(IntraRunRule.unordered, intraRunRule('Q').?);
    var set = SingleOccurrenceSet{};
    try std.testing.expect(set.admit('Q'));
    try std.testing.expect(set.admit('Q'));
}

test "artifact §4.5.3-p1: the comparator is unsigned, case-sensitive, and normalization-free" {
    // Lines 827-829: "octets are compared as unsigned values,
    // locale-independent, with no Unicode normalization and no case
    // folding."
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("A", "a")); // 0x41 < 0x61
    // 0x80 vs 0x7F: unsigned compare puts 0x80 above 0x7F (a signed i8
    // compare would invert this, since 0x80 as i8 is negative).
    try std.testing.expectEqual(
        std.math.Order.gt,
        compareBytes(&[_]u8{0x80}, &[_]u8{0x7F}),
    );
    // A precomposed vs. decomposed representation of the same visual
    // character (e.g. NFC "é" = 0xC3 0xA9 vs. NFD "e" + combining acute =
    // 0x65 0xCC 0x81) compares by raw octets, not by Unicode equivalence:
    // first byte 0xC3 > 0x65, so no normalization occurred.
    const nfc = [_]u8{ 0xC3, 0xA9 };
    const nfd = [_]u8{ 0x65, 0xCC, 0x81 };
    try std.testing.expectEqual(std.math.Order.gt, compareBytes(&nfc, &nfd));
}

test "artifact §4.5.3-m1: shorter sorts before an extension, absent before present" {
    // Lines 829-830: "A shorter string sorts before any extension of
    // itself, and an absent value sorts before any present value."
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("abc", "abcd"));
    try std.testing.expectEqual(std.math.Order.lt, compareOptional(null, "x"));
    // Absent precedes even the empty string.
    try std.testing.expectEqual(std.math.Order.lt, compareOptional(null, ""));
    try std.testing.expectEqual(std.math.Order.eq, compareOptional(null, null));
    try std.testing.expectEqual(std.math.Order.eq, compareOptional("", ""));
}

test "artifact §4.5.3-m2: the directory separator receives no special treatment" {
    // Line 831: "The directory separator / (0x2F) receives no special
    // treatment."
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("a.b", "a/b")); // 0x2E < 0x2F
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("a/", "a0")); // 0x2F < 0x30
    // Shorter-first still wins even though '/' would otherwise look like a
    // path-component boundary worth privileging.
    try std.testing.expectEqual(std.math.Order.lt, compareBytes("a", "a/b"));
}

test "artifact §4.6.1-r1: an empty decoded name is rejected" {
    // Line 862: "it is empty".
    try std.testing.expect(!isSimplePathname(""));
}

test "artifact §4.6.1-r2: a leading slash is rejected" {
    // Line 864: "its first byte is /".
    try std.testing.expect(!isSimplePathname("/foo"));
    try std.testing.expect(!isSimplePathname("/"));
}

test "artifact §4.6.1-r3: the whole name being . or .. is rejected" {
    // Line 866: "the whole string is . or ..".
    try std.testing.expect(!isSimplePathname("."));
    try std.testing.expect(!isSimplePathname(".."));
}

test "artifact §4.6.1-r4: a name beginning ./ or ../ is rejected" {
    // Line 868: "it begins with ./ or ../".
    try std.testing.expect(!isSimplePathname("./a"));
    try std.testing.expect(!isSimplePathname("../a"));
}

test "artifact §4.6.1-r5: an embedded // is rejected" {
    // Line 870: "it contains //".
    try std.testing.expect(!isSimplePathname("a//b"));
}

test "artifact §4.6.1-r6: a trailing slash is rejected" {
    // Line 872: "it ends with /".
    try std.testing.expect(!isSimplePathname("a/"));
}

test "artifact §4.6.1-r7: a . or .. path element is rejected" {
    // Lines 874-875: "any path element equals . or .. -- that is, it
    // contains /./ or /../, or ends with /. or /..". Table-driven over all
    // four element positions the RFC text distinguishes.
    const cases = [_][]const u8{
        "a/./b", // interior /./
        "a/../b", // interior /../
        "a/.", // ends /.
        "a/..", // ends /..
    };
    for (cases) |name| try std.testing.expect(!isSimplePathname(name));
}

test "artifact §4.6.1-p1: the predicate accepts well-formed simple pathnames" {
    // Positive-space / "if and only if" side of the procedure clause
    // (line 858). Accept vectors from the §A.3 (lines 2561-2565) and §A.4
    // (lines 2618-2620) worked-example F-card filenames, plus a nested
    // path and two names that look dot-like but are not exact `.`/`..`
    // elements (r3/r7 are exact-match, so these must pass).
    const cases = [_][]const u8{
        "alpha.txt", "beta.txt", "gamma.txt", "file-a.txt",
        "a/b/c",     "...",      ".hidden",
    };
    for (cases) |name| try std.testing.expect(isSimplePathname(name));
}

test "artifact §4.6.1-m1: byte content does not affect acceptance" {
    // Lines 877-880: "backslashes, spaces ... control bytes, and
    // arbitrary or invalid UTF-8 sequences all pass. Accept or reject is
    // decidable from the separator structure alone."
    const accept_cases = [_][]const u8{
        "a\\b", // backslash 0x5C
        "a b", // space 0x20, reachable via \s decoding
        "a\x00b", // control byte
        "a\xffb", // arbitrary high byte
        "\xc3\x28", // invalid UTF-8 (lone continuation-less lead byte)
    };
    for (accept_cases) |name| try std.testing.expect(isSimplePathname(name));
    // A hostile name with bad separator structure still rejects, so
    // acceptance above is not vacuous permissiveness.
    try std.testing.expect(!isSimplePathname("a\xff/../b"));
}

test "artifact §4.6.1-m2: the writer profile does not constrain acceptance" {
    // Lines 888-890: "A validator of existing artifacts MUST apply only
    // the parser rules; the writer profile does not constrain acceptance."
    // This name violates both writer-only bans (lines 882-887): it has a
    // backslash and an invalid UTF-8 byte. The validator still accepts it
    // because its separator structure is clean.
    try std.testing.expect(isSimplePathname("dir/a\\\xffb.txt"));
}

test "artifact §4.6.2-r1: a leading space, control byte, or empty name is rejected" {
    // Lines 907-908: "its first byte is less than or equal to 0x20 (this
    // rejects the empty string, a leading space, and any leading control
    // byte)".
    try std.testing.expect(!isWikiName(""));
    try std.testing.expect(!isWikiName(" abc")); // leading 0x20
    try std.testing.expect(!isWikiName("\x00abc")); // leading NUL
    try std.testing.expect(!isWikiName("\x1fabc")); // leading control
}

test "artifact §4.6.2-r2: a control byte after the first is rejected" {
    // Lines 910-911: "any subsequent byte is less than 0x20 (all control
    // bytes, including tab and LF)".
    const cases = [_][]const u8{
        "a\tb", // 0x09 tab
        "a\nb", // 0x0A LF
        "a\x1fb", // interior control
        "ab\x01", // trailing control
    };
    for (cases) |name| try std.testing.expect(!isWikiName(name));
}

test "artifact §4.6.2-r3: two consecutive spaces are rejected" {
    // Line 913: "two consecutive space bytes (0x20 0x20) appear anywhere".
    try std.testing.expect(!isWikiName("a  b")); // interior double space
    try std.testing.expect(!isWikiName("ab   cd")); // triple contains a pair
}

test "artifact §4.6.2-r4: a trailing space is rejected" {
    // Line 915: "its final byte is a space (0x20)".
    try std.testing.expect(!isWikiName("abc "));
    try std.testing.expect(!isWikiName("a b "));
}

test "artifact §4.6.2-r5: a length outside 1 through 100 is rejected" {
    // Line 917: "its total length in bytes is outside 1 through 100
    // inclusive." Boundary accepts (1-byte and 100-byte) live in the p1
    // test to keep the iff both-sided.
    try std.testing.expect(!isWikiName("")); // 0 bytes
    try std.testing.expect(!isWikiName("x" ** 101)); // 101 bytes
}

test "artifact §4.6.2-p1: the predicate accepts well-formed wiki names" {
    // Positive/"if and only if" side of the procedure clause (line
    // 903-905).
    const cases = [_][]const u8{
        "A", // r5 lower bound, 1 byte
        "Wiki Page",
        "a b c", // single interior spaces are fine
        "x" ** 100, // r5 upper bound, 100 bytes
    };
    for (cases) |name| try std.testing.expect(isWikiName(name));
}

test "artifact §4.6.2-m1: bytes >= 0x21 are unconstrained; length is bytes not characters" {
    // Lines 919-921: "All bytes greater than or equal to 0x21 are
    // otherwise unconstrained: 0x7F and all bytes at or above 0x80 are
    // accepted, no UTF-8 validity check is performed, and length is
    // counted in bytes, not characters."
    const accept_cases = [_][]const u8{
        "a\x7fb", // 0x7F
        "a\x80b", // >=0x80
        "\xff\xfe", // arbitrary high bytes
        "\xc3\x28", // invalid UTF-8 (lone continuation-less lead byte)
    };
    for (accept_cases) |name| try std.testing.expect(isWikiName(name));
    // Length-in-bytes proof: 50 two-byte sequences = 100 bytes accepts;
    // 51 = 102 bytes rejects even though it is only 51 "characters".
    try std.testing.expect(isWikiName("\xc3\xa9" ** 50));
    try std.testing.expect(!isWikiName("\xc3\xa9" ** 51));
    // A control byte still rejects even amid high bytes, so acceptance
    // above is not vacuous permissiveness.
    try std.testing.expect(!isWikiName("a\x80\x01"));
}

test "artifact §4.6.3-p1: one sidecar suffix stripped, case-insensitively" {
    // Line 929: "ends, case-insensitively, with exactly one of -wal, -shm,
    // or -journal, strip that one suffix". "_FOSSIL_-WAL" strips to
    // "_FOSSIL_" (mixed case), which is then the whole-string tail (p2/p3a)
    // => reserved.
    try std.testing.expect(isReservedFilename("_FOSSIL_-WAL"));
    // "Exactly one": only the LAST-matching suffix strips. "-shm" strips
    // first (it's the trailing one), leaving "_FOSSIL_-wal", which ends
    // with neither reserved tail (line 934) => p2 fails => not reserved.
    try std.testing.expect(!isReservedFilename("_FOSSIL_-wal-shm"));
}

test "artifact §4.6.3-p2: reserved tail matched case-insensitively" {
    // Lines 933-934: "_FOSSIL_ (8 bytes) or .fslckout (9 bytes)",
    // case-insensitive.
    try std.testing.expect(isReservedFilename("_fossil_")); // lowercase tail
    try std.testing.expect(isReservedFilename(".FSLCKOUT")); // uppercase tail
    // No reserved tail present at all => p2 fails.
    try std.testing.expect(!isReservedFilename("readme.txt"));
}

test "artifact §4.6.3-p3: tail qualifies only via whole-string, slash, or sidecar" {
    // Line 936: "the reserved tail is the entire remaining string".
    try std.testing.expect(isReservedFilename("_FOSSIL_"));
    try std.testing.expect(isReservedFilename(".fslckout"));
    // Line 937: "the byte immediately before the tail is /".
    try std.testing.expect(isReservedFilename("dir/_FOSSIL_"));
    try std.testing.expect(isReservedFilename("dir/.fslckout"));
}

test "artifact §4.6.3-r1: a tail match satisfying no step-3 condition is not reserved" {
    // Line 940, verbatim worked counter-example: "A tail match that
    // satisfies none of step 3, such as x_FOSSIL_, is not reserved." Not
    // the whole string, the byte before the tail is 'x' not '/', and no
    // sidecar suffix was stripped.
    try std.testing.expect(!isReservedFilename("x_FOSSIL_"));
}

test "artifact §4.6.3-w1: a sidecar-suffix match is reserved via the step-1 strip" {
    // Lines 941-942, verbatim worked examples: "A sidecar-suffix match such
    // as x_FOSSIL_-wal or dir/.fslckout-journal is reserved because step 1
    // stripped a suffix." Neither would qualify under 3a/3b alone (tail
    // isn't the whole string and isn't preceded by '/' after the strip
    // leaves "x_FOSSIL_" / "dir/.fslckout"), so only 3c (sidecar stripped)
    // explains the reserved verdict.
    try std.testing.expect(isReservedFilename("x_FOSSIL_-wal"));
    try std.testing.expect(isReservedFilename("dir/.fslckout-journal"));
}

test "artifact §4.6.3-m1: excludes platform checkout filters and Windows device names" {
    // Lines 943-945: "The predicate is limited to Fossil repository
    // metadata filenames; it does not include platform checkout filters or
    // Windows device names such as CON." CON has no reserved tail, so p2
    // fails outright -- this locks the scope, not merely a coincidence of
    // step 3.
    try std.testing.expect(!isReservedFilename("CON"));
}

test "artifact §4.6.3-r2: reserved-ness is a bool drop signal, never a parse error" {
    // Line 961: "MUST be silently dropped, not rejected." Locked
    // structurally: the return type is bool, not an error union, so there
    // is no rejection variant for a reserved name to be mistaken for.
    try std.testing.expectEqual(bool, @TypeOf(isReservedFilename("_FOSSIL_")));
    try std.testing.expect(isReservedFilename("_FOSSIL_"));
}

test "artifact §4.6.3: sidecar strip with no remaining reserved tail is not reserved" {
    // "foo-wal" strips to "foo" (step 1), which ends with neither reserved
    // tail (step 2 fails) => not reserved. Covers the sidecar-strip path
    // that does NOT lead to a match.
    try std.testing.expect(!isReservedFilename("foo-wal"));
}

test "artifact §4.6.3: the empty string is not reserved" {
    // No reserved tail can match an empty remaining string; also exercises
    // the underflow-guard boundary (remaining.len < tail_len).
    try std.testing.expect(!isReservedFilename(""));
}

test "artifact §4.6.3-m2: sort check runs before the reserved-name drop" {
    // Lines 967-969: "the F card sort check (Section 4.5.2) runs before
    // the reserved-name drop, so a reserved-named F card must itself be in
    // sort position relative to the previously retained entry." Anchor
    // starts at "z"; "_FOSSIL_" is reserved but sorts BEFORE "z"
    // ('_' 0x5F < 'z' 0x7A), so the order check rejects it first -- it is
    // never reached far enough to be dropped.
    var adm = FCardAdmission{};
    try std.testing.expect(try adm.admit("z"));
    try std.testing.expectError(FCardSortError.OutOfOrder, adm.admit("_FOSSIL_"));
    // Reserved-ness alone never rescues an out-of-order name: "a" is not
    // reserved and is also out of order against anchor "z".
    try std.testing.expectError(FCardSortError.OutOfOrder, adm.admit("a"));
}

test "artifact §4.6.3-m2: the dropped name never becomes the comparison anchor" {
    // Lines 969-972: "the next F card is compared against that same
    // retained entry. The dropped name never becomes the comparison
    // anchor." "dir/_FOSSIL_" is in sort order after "a" (passes §4.5.2-r1)
    // and is reserved (3b, preceded by '/'), so it is dropped -- anchor
    // stays "a". "b" then proves the anchor is still "a": "b" sorts after
    // "a" but BEFORE "dir/_FOSSIL_" ('b' 0x62 < 'd' 0x64), so if the dropped
    // name had wrongly become the anchor, "b" would fail the order check.
    var adm = FCardAdmission{};
    try std.testing.expect(try adm.admit("a"));
    try std.testing.expect(!(try adm.admit("dir/_FOSSIL_"))); // dropped
    try std.testing.expect(try adm.admit("b")); // compared against "a", not "dir/_FOSSIL_"
}

test "artifact §4.6.3-r2: a dropped reserved F card is removed from the effective file set" {
    // Lines 961-963: "An F card whose decoded current filename is reserved
    // MUST be silently dropped, not rejected. Dropping removes the card
    // from the effective file set." Feed three cards in wire order and
    // collect the retained ones: the reserved middle card must be absent
    // from the resulting set.
    const wire_order = [_][]const u8{ "a", "dir/_FOSSIL_", "b" };
    var effective_set: [wire_order.len][]const u8 = undefined;
    var effective_len: usize = 0;
    var adm = FCardAdmission{};
    for (wire_order) |name| { // bound: wire_order.len, fixed at 3
        if (try adm.admit(name)) {
            std.debug.assert(effective_len < wire_order.len);
            effective_set[effective_len] = name;
            effective_len += 1;
        }
    }
    try std.testing.expectEqual(@as(usize, 2), effective_len);
    try std.testing.expectEqualStrings("a", effective_set[0]);
    try std.testing.expectEqualStrings("b", effective_set[1]);
}

test "artifact §4.6.3: the rule tests only the current filename, never old-name" {
    // Lines 957-959: "This rule applies only to the current filename field
    // of an F card, after escape decoding. The old-name field is not
    // tested by this reserved-name rule." Reinforced at lines 1202-1203.
    // Simulate a rename FROM a reserved name TO a plain one: old-name
    // "_FOSSIL_" is reserved (sanity check below), but `admit` has exactly
    // one filename parameter -- the decoded current name -- so old-name
    // never reaches the check. The card is retained, not dropped.
    const old_name = "_FOSSIL_";
    try std.testing.expect(isReservedFilename(old_name)); // would drop, if tested
    var adm = FCardAdmission{};
    try std.testing.expect(try adm.admit("plain.txt")); // current name only: not reserved
}

// ---------------------------------------------------------------------------
// §4.6.4 Timestamps
// ---------------------------------------------------------------------------

test "artifact §4.6.4-p1: the token is classified raw, never escape-decoded" {
    // p1: "The D and E card timestamps are each a single raw (never
    // decoded) token." Feed a wire token containing a literal backslash
    // (0x5C) followed by 't' (0x74) where a decoded tab would sit: if this
    // were escape-decoded first, \t -> 0x09 (whitespace) would satisfy the
    // m1 separator and the token would classify as date_time. Undecoded, the
    // separator scan sees 0x5C, which is neither 'T' nor whitespace, so the
    // date-time branch has zero separator bytes and rejects.
    const token = "2017-12-06\\t15:36:24";
    try std.testing.expect(classifyTimestamp(token) == null);
}

test "artifact §4.6.4-m1: date-time accepts T and whitespace separators, optional :SS/.frac" {
    // m1: "a date-time: an optional leading -, then YYYY-MM-DD, then one or
    // more separator bytes each of which is either a whitespace-class byte
    // or the uppercase letter T, then HH:MM, HH:MM:SS, or HH:MM:SS. followed
    // by one or more digits"
    const cases = [_][]const u8{
        "2017-12-06T15:36:24",
        "2017-12-06T15:36",
        "2017-12-06T15:36:24.287", // w1
        "2017-12-06\x0915:36:24", // tab separator (whitespace class)
        "2017-12-06TT15:36", // 1+ separator bytes
        "-2017-12-06T15:36:24", // optional leading '-'
    };
    for (cases) |c| { // bound: cases.len, fixed at 6
        try std.testing.expect(classifyTimestamp(c).? == .date_time);
    }
}

test "artifact §4.6.4-m2: optional timezone, +HH:MM / -HH:MM / Z / z, optional leading ws" {
    // m2: "then an optional timezone of optional whitespace plus +HH:MM,
    // -HH:MM, Z, or z"
    const cases = [_][]const u8{
        "2017-12-06T15:36:24+05:30",
        "2017-12-06T15:36:24-08:00",
        "2017-12-06T15:36:24Z",
        "2017-12-06T15:36:24z",
        "2017-12-06T15:36:24\x09Z", // optional whitespace before tz
    };
    for (cases) |c| { // bound: cases.len, fixed at 5
        try std.testing.expect(classifyTimestamp(c).? == .date_time);
    }
}

test "artifact §4.6.4-r1: lowercase t as the separator is rejected" {
    // r1 verbatim: "Lowercase t as the separator is rejected."
    try std.testing.expect(classifyTimestamp("2017-12-06t15:36:24") == null);
}

test "artifact §4.6.4-m3: date only, with and without leading -" {
    // m3: "A date only: optional leading -, then YYYY-MM-DD."
    try std.testing.expect(classifyTimestamp("2017-12-06").? == .date);
    try std.testing.expect(classifyTimestamp("-2017-12-06").? == .date);
}

test "artifact §4.6.4-m4: time only is interpreted on 2000-01-01" {
    // m4: "A time only: HH:MM, optionally :SS and a fraction, interpreted
    // as a time on 2000-01-01." Year 2000 is always well past the r3
    // boundary, so the gate never has anything to reject for this form.
    const cases = [_][]const u8{ "15:36", "15:36:24", "15:36:24.287" };
    for (cases) |c| { // bound: cases.len, fixed at 3
        try std.testing.expect(classifyTimestamp(c).? == .time);
    }
}

test "artifact §4.6.4-m5: now/subsec/subsecond, case-insensitive" {
    // m5 verbatim: "The literals now, subsec, or subsecond, case-insensitive."
    const cases = [_][]const u8{ "now", "NOW", "Now", "subsec", "SubSec", "subsecond", "SUBSECOND" };
    for (cases) |c| { // bound: cases.len, fixed at 7
        try std.testing.expect(classifyTimestamp(c).? == .relative);
    }
}

test "artifact §4.6.4-m6: bare decimal accepted as Julian day iff 0 <= r < 5373484.5" {
    // m6: "A bare decimal number, treated as a Julian day number when its
    // value r satisfies 0 <= r < 5373484.5." (r3 tightens the lower bound
    // to strictly greater than zero; covered separately below.)
    try std.testing.expect(classifyTimestamp("2451545").? == .julian);
    try std.testing.expect(classifyTimestamp("2451545.0").? == .julian);
    try std.testing.expect(classifyTimestamp("0.5").? == .julian);
    try std.testing.expect(classifyTimestamp("5373484.4").? == .julian);
    try std.testing.expect(classifyTimestamp("5373484.5") == null); // upper bound exclusive
    try std.testing.expect(classifyTimestamp("5373485") == null);
}

test "artifact §4.6.4-m7: exact digit counts and field ranges" {
    // m7 verbatim: "the year is exactly 4 digits, the month exactly 2
    // digits in 01-12, the day exactly 2 digits in 01-31 with no
    // calendar-aware check (2024-02-31 is accepted), the hour 2 digits in
    // 00-24, the minute and second 2 digits each in 00-59, and the fraction
    // one or more digits."
    const rejects = [_][]const u8{
        "2017-1-06", // month 1 digit
        "2017-13-06", // month 13
        "2017-12-00", // day 00
        "2017-12-32", // day 32
        "2017-12-06T25:36", // hour 25
        "2017-12-06T15:60", // minute 60
        "2017-12-06T15:36:60", // second 60
        "201-12-06", // year 3 digits
        "20171-12-06", // year 5 digits
        "2017-12-06T15:36:24.", // fraction 0 digits
    };
    for (rejects) |c| { // bound: rejects.len, fixed at 10
        try std.testing.expect(classifyTimestamp(c) == null);
    }
    try std.testing.expect(classifyTimestamp("2017-12-31").? == .date);
    try std.testing.expect(classifyTimestamp("2024-02-31").? == .date); // no calendar check
    try std.testing.expect(classifyTimestamp("2017-12-06T24:00").? == .date_time); // hour 24 in 00-24
}

test "artifact §4.6.4-r3: JD>0 gate at the -4713-11-24 boundary and numeric 0" {
    // r3: "the token's value expressed as a Julian day number MUST be
    // greater than zero; this additionally rejects dates at or before
    // -4713-11-24 and the numeric token 0." RESOLVED (ambiguities.md,
    // 2026-07-09): date-granularity reading -- reject iff
    // (year,month,day) <= (-4713,11,24), independent of time-of-day.
    const rejects = [_][]const u8{
        "-4713-11-24",
        "-4713-11-24T18:00:00", // any time on the boundary date still rejects
        "-4713-11-23",
        "-4713-10-31",
        "-4714-01-01",
        "0",
        "0.0",
    };
    for (rejects) |c| { // bound: rejects.len, fixed at 7
        try std.testing.expect(classifyTimestamp(c) == null);
    }
    try std.testing.expect(classifyTimestamp("-4713-11-25").? == .date);
    try std.testing.expect(classifyTimestamp("-4713-12-01").? == .date);
    try std.testing.expect(classifyTimestamp("-4712-01-01").? == .date);
}

test "artifact §4.6.4-m8: the writer form is the interoperable absolute date-time" {
    // m8: "For stored structural artifacts whose D or E card timestamp can
    // feed repository-state ordering, a producer MUST emit the absolute
    // date-time form above." isWriterTimestamp is the oracle for that exact
    // canonical subset: YYYY-MM-DDTHH:MM:SS[.frac], no sign, no tz.
    try std.testing.expect(isWriterTimestamp("2017-12-06T15:36:24.287")); // w1
    try std.testing.expect(isWriterTimestamp("2017-12-06T15:36:24"));
    const rejects = [_][]const u8{
        "2017-12-06T15:36", // no :SS
        "2017-12-06", // date only
        "2017-12-06T15:36:24Z", // tz present
        "-2017-12-06T15:36:24", // leading sign
        "2017-12-06\x0915:36:24", // non-'T' separator
    };
    for (rejects) |c| { // bound: rejects.len, fixed at 5
        try std.testing.expect(!isWriterTimestamp(c));
    }
}

test "artifact §4.6.4-s1: a reader accepts the broader forms 2 through 5" {
    // s1: "A reader MAY accept the broader forms in items 2 through 5 for
    // compatibility." Realized as classifyTimestamp acceptance of one
    // representative token per non-date-time form.
    try std.testing.expect(classifyTimestamp("2017-12-06").? == .date);
    try std.testing.expect(classifyTimestamp("15:36:24").? == .time);
    try std.testing.expect(classifyTimestamp("now").? == .relative);
    try std.testing.expect(classifyTimestamp("2451545").? == .julian);
}

test "artifact §4.6.4-m9: relative literals are classified, never resolved against a clock" {
    // m9: "now, subsec, and subsecond are resolved against parse-time clock
    // state rather than denoting a stable instant in the artifact bytes.
    // Two ingestions of identical bytes can therefore yield different
    // stored mtime values." classifyTimestamp does no clock resolution at
    // all (that's [FossilRepoState]'s job); it is pure, so two calls on the
    // identical bytes always agree on the FORM even though a later resolver
    // may pick different instants.
    try std.testing.expect(classifyTimestamp("now").? == .relative);
    try std.testing.expect(classifyTimestamp("subsec").? == .relative);
    try std.testing.expect(classifyTimestamp("subsecond").? == .relative);
    try std.testing.expect(classifyTimestamp("now").? == classifyTimestamp("now").?);
}

test "artifact §4.6.4-w1: the technote worked example's D card timestamp" {
    // w1 verbatim: "D 2017-12-06T15:36:24.287" -- the token after the card
    // letter and space is the date-time form with a fractional second, and
    // it is also the exact writer-canonical form (no sign, exactly 'T', SS
    // present, no tz).
    const token = "2017-12-06T15:36:24.287";
    try std.testing.expect(classifyTimestamp(token).? == .date_time);
    try std.testing.expect(isWriterTimestamp(token));
}