Skip to content
zit Artifact [82b661bcdf]
Login

Artifact [82b661bcdf]

Artifact 82b661bcdf3748c8f12d63180182babae852c4f69ce5d28596647f2c36b9f820:


//! Sync Protocol — draft-fossil-sync-protocol-00.
//!
//! Two repositories reconcile a content-addressed artifact set over HTTP. The
//! protocol is client-driven; the server is stateless across rounds, so only
//! the client runs a state machine (Section 3.8).

const std = @import("std");
const artifact = @import("artifact.zig");

/// The client's protocol state (Section 3.8). The server reacts to each
/// request independently and has no state machine.
pub const ClientState = enum {
    /// Operation selected; no round sent yet.
    init,
    /// First round of a modern clone: login suppressed, config/uv-hash
    /// deferred, an `error` reply ignored (Sections 8.3, 8.4, 11.1).
    clone_round0,
    /// Steady round loop: authenticated request, reply dispatched, phantom /
    /// send-queue / config / private work advances (Section 3.2).
    exchange,
    /// Clean stop: no continuation condition of Section 3.7 held.
    terminated,
    /// A fatal protocol error ended the sync without convergence.
    aborted,
};

/// The operation a sync round is performing.
pub const Operation = enum { pull, push, sync, clone };

pub const Error = error{ FatalProtocolError, NotAuthorized, NotImplemented };

/// Robustness posture (Section 3.9): conservative in what it sends, strict in
/// what it accepts. An unknown non-`pragma` operator and a malformed content
/// card are fatal; liberality is confined to the four enumerated points.
pub const Client = struct {
    state: ClientState = .init,
    operation: Operation = .sync,

    // The round loop is a sans-IO state machine: one method produces the next
    // request bytes, another consumes a reply's bytes and updates `state`; the
    // transport (real HTTP or the simulator) is owned by the caller — the server
    // is stateless, only the client keeps state (Section 3.8). The exact
    // signatures land with the sync implementation, read from the full Section 3
    // round / dispatch / termination rules. Deliberately not stubbed here so
    // nothing is built against a placeholder shape.
};

test "client starts in init" {
    const c: Client = .{};
    try std.testing.expectEqual(ClientState.init, c.state);
}

// ---------------------------------------------------------------------------
// Section 5 — Card Format
// ---------------------------------------------------------------------------

/// §5.2 — card/comment line bound, measured from the first line byte
/// through and including the terminating LF, excluding any following
/// byte-counted payload.
pub const max_line_len: usize = 65535;

/// §5.2 — current Fossil implementations expose at most the first six
/// parsed tokens to the per-card handler; later tokens are surplus.
pub const max_tokens: usize = 6;

/// §5.2.1, §5-r20 — the complete card operator catalog. `parseOperator`
/// resolves a token against this set by exact field-name match, so the
/// catalog itself is the lexical gate for §5-r6 (lowercase-plus-
/// underscore operator tokens): anything else simply has no field.
pub const Operator = enum {
    cookie,
    login,
    push,
    pull,
    clone,
    file,
    cfile,
    uvfile,
    private,
    igot,
    uvigot,
    gimme,
    uvgimme,
    reqconfig,
    config,
    clone_seqno,
    pragma,
    @"error",
    message,
};

comptime {
    // §5-r20 — Section 5.2.1 lists 4, the remaining-operators sentence
    // lists 15; 4 + 15 = 19.
    std.debug.assert(@typeInfo(Operator).@"enum".fields.len == 19);
}

/// Which end of the exchange is classifying the card (§5-r17: the same
/// disposition is fatal for the client and non-fatal for the server).
pub const Side = enum { client, server };

/// §5.2 outcome of classifying one card line.
pub const Disposition = enum { dispatch, unknown_operator, malformed_content, bad_command };

/// §5.1 — one LF-delimited line. `bytes` excludes the LF; `next` is the
/// body offset immediately after it, where a following byte-counted
/// payload (if any) begins.
pub const Line = struct {
    bytes: []const u8,
    next: usize,
};

/// §5-r11 — the sole line-framing failure: no LF within the bound.
pub const LineError = error{MissingTerminator};

/// §5.2 tokenization result: up to `max_tokens` slots into the source
/// line, plus a flag for tokens beyond that window (§5-r14).
pub const Tokens = struct {
    items: [max_tokens][]const u8 = undefined,
    len: usize = 0,
    surplus: bool = false,
};

/// §5.2 classification of a card line: its tokens, the resolved
/// operator (null when unrecognized), and the disposition that follows.
pub const CardClass = struct {
    tokens: Tokens,
    operator: ?Operator,
    disposition: Disposition,
};

/// §5.3, App. A.4, App. B.3 — a parsed `# timestamp DATETIME errors N`
/// comment subform.
pub const TimestampComment = struct {
    datetime: []const u8,
    errors: u64,
};

/// §5.1, §5-r10/r11 — find the next LF-terminated line starting at
/// `start`. The LF must fall within `max_line_len` bytes of `start`
/// (bound counted through-and-including the LF); a following byte-
/// counted payload is never scanned (§5-r3) — the caller advances past
/// it using `next + declared_count`.
pub fn readLine(body: []const u8, start: usize) LineError!Line {
    std.debug.assert(start <= body.len);
    const window_end = @min(body.len, start + max_line_len);
    var i: usize = start;
    while (i < window_end) : (i += 1) {
        if (body[i] == '\n') {
            const line: Line = .{ .bytes = body[start..i], .next = i + 1 };
            std.debug.assert(line.bytes.len <= max_line_len - 1);
            std.debug.assert(line.next <= body.len);
            return line;
        }
    }
    return LineError.MissingTerminator;
}

/// §5.2, §5-r7 — Fossil whitespace: SP, HTAB, VT, FF, CR. LF is
/// deliberately excluded: it terminates the card line and is never a
/// token separator.
pub fn isFossilWhitespace(b: u8) bool {
    return switch (b) {
        ' ', '\t', 0x0b, 0x0c, '\r' => true,
        else => false,
    };
}

/// §5.2, §5-r4/r5/r7/r8/r9/r14 — tokenize a card line (LF already
/// stripped by `readLine`) on runs of Fossil whitespace. The canonical
/// single-space wire form and the compatibility multi-run extension
/// produce identical tokens. A token past the sixth sets `surplus` and
/// is not stored (Fossil's fixed six-slot handler window).
pub fn tokenizeCard(line: []const u8) Tokens {
    std.debug.assert(line.len <= max_line_len - 1);
    std.debug.assert(std.mem.indexOfScalar(u8, line, '\n') == null);
    var result: Tokens = .{};
    var i: usize = 0;
    while (i < line.len) {
        if (isFossilWhitespace(line[i])) {
            i += 1;
            continue;
        }
        const start = i;
        while (i < line.len and !isFossilWhitespace(line[i])) : (i += 1) {}
        const token = line[start..i];
        if (result.len < max_tokens) {
            result.items[result.len] = token;
            result.len += 1;
        } else {
            result.surplus = true;
        }
    }
    std.debug.assert(result.len <= max_tokens);
    std.debug.assert(!result.surplus or result.len == max_tokens);
    return result;
}

/// §5.2, §5-r6/r12 — resolve an operator token against the catalog.
/// `std.meta.stringToEnum` only matches an exact field-name string, so
/// an uppercase letter, a stray digit, or a misplaced underscore falls
/// through to null (unknown operator) without a separate lexical check.
pub fn parseOperator(token: []const u8) ?Operator {
    return std.meta.stringToEnum(Operator, token);
}

/// §5-r16 — the three payload-bearing card types, whose arity failure
/// aborts with `malformed CARD line` rather than `bad command:`.
pub fn isContentBearing(op: Operator) bool {
    return switch (op) {
        .file, .cfile, .uvfile => true,
        else => false,
    };
}

/// §5.2 dispatch gate: tokenize once, then apply §5-r12 (unknown first
/// token) and §5-r14/r15/r16/r17 (surplus-token disposition, split on
/// content-bearing vs. simple operators). Per-operator arity within the
/// six-token window is that card's own handler (§5-r13), out of scope
/// here.
pub fn classifyCard(line: []const u8) CardClass {
    const tokens = tokenizeCard(line);
    const result: CardClass = blk: {
        if (tokens.len == 0) {
            break :blk .{ .tokens = tokens, .operator = null, .disposition = .unknown_operator };
        }
        const operator = parseOperator(tokens.items[0]);
        if (operator == null) {
            break :blk .{ .tokens = tokens, .operator = null, .disposition = .unknown_operator };
        }
        if (tokens.surplus) {
            const disposition: Disposition = if (isContentBearing(operator.?)) .malformed_content else .bad_command;
            break :blk .{ .tokens = tokens, .operator = operator, .disposition = disposition };
        }
        break :blk .{ .tokens = tokens, .operator = operator, .disposition = .dispatch };
    };
    // Positive/negative space pairing on the disposition: dispatch is
    // the only outcome that carries a resolved operator with no
    // surplus tokens; every other outcome fails one of those two.
    std.debug.assert((result.disposition == .dispatch) ==
        (result.operator != null and !result.tokens.surplus));
    std.debug.assert((result.disposition == .unknown_operator) == (result.operator == null));
    return result;
}

/// §5-r12/r16/r17, Table 3 — unknown operator and malformed content are
/// fatal on either side. Bad command is fatal only when the client
/// detects it; on the server it is non-fatal (§5-r17).
pub fn isFatal(d: Disposition, side: Side) bool {
    return switch (d) {
        .unknown_operator, .malformed_content => true,
        .bad_command => side == .client,
        .dispatch => false,
    };
}

/// §5-r17/r18, App. C — the sole unrecognized-card wire text: exactly
/// `"bad command: "` followed by the offending line. There is no
/// "Unknown message" text anywhere in the protocol.
pub fn writeBadCommand(line: []const u8, out: []u8) []u8 {
    const prefix = "bad command: ";
    std.debug.assert(out.len >= prefix.len + line.len);
    @memcpy(out[0..prefix.len], prefix);
    @memcpy(out[prefix.len .. prefix.len + line.len], line);
    return out[0 .. prefix.len + line.len];
}

/// §5-r16, App. C — `malformed CARD line`, where CARD is the operator
/// name (the same uppercase-metavariable convention as LINE in
/// §5-r17), e.g. `malformed file line`.
pub fn writeMalformedCard(op: Operator, out: []u8) []u8 {
    std.debug.assert(isContentBearing(op));
    const name = @tagName(op);
    const prefix = "malformed ";
    const suffix = " line";
    const needed = prefix.len + name.len + suffix.len;
    std.debug.assert(out.len >= needed);
    var i: usize = 0;
    @memcpy(out[i .. i + prefix.len], prefix);
    i += prefix.len;
    @memcpy(out[i .. i + name.len], name);
    i += name.len;
    @memcpy(out[i .. i + suffix.len], suffix);
    i += suffix.len;
    return out[0..i];
}

/// §5.3, §5-r21 — a comment line is any line whose first byte is
/// ASCII `#` (0x23).
pub fn isCommentLine(line: []const u8) bool {
    return line.len > 0 and line[0] == '#';
}

/// §5.3, §5-r23/r24/r25, App. A.4, App. B.3 — parse the
/// `# timestamp DATETIME errors N` subform out of a comment line. Any
/// other shape (nonce comment, `# sending-clusters`, a non-numeric N)
/// yields null: those lines are not semantically interpreted (§5-r25).
pub fn parseTimestampComment(line: []const u8) ?TimestampComment {
    std.debug.assert(isCommentLine(line));
    const tokens = tokenizeCard(line);
    if (tokens.len != 5 or tokens.surplus) return null;
    if (!std.mem.eql(u8, tokens.items[0], "#")) return null;
    if (!std.mem.eql(u8, tokens.items[1], "timestamp")) return null;
    if (!std.mem.eql(u8, tokens.items[3], "errors")) return null;
    const n = std.fmt.parseInt(u64, tokens.items[4], 10) catch return null;
    return .{ .datetime = tokens.items[2], .errors = n };
}

test "sync §5.1: message body lines are LF-separated" {
    const body = "a\nb\n";
    const first = try readLine(body, 0);
    try std.testing.expectEqualStrings("a", first.bytes);
    try std.testing.expectEqual(@as(usize, 2), first.next);
    const second = try readLine(body, first.next);
    try std.testing.expectEqualStrings("b", second.bytes);
    try std.testing.expectEqual(@as(usize, 4), second.next);
}

test "sync §5.1: each line is a card or a comment" {
    try std.testing.expect(isCommentLine("#x"));
    try std.testing.expect(!isCommentLine("push a b"));
}

test "sync §5.1: payload framed by byte count, not by scanning for LF" {
    // "file h 5\n" is the card line; the declared payload is 5 bytes,
    // "AB\nCD", which itself contains a LF that must not be scanned for.
    const body = "file h 5\nAB\nCD";
    const line = try readLine(body, 0);
    try std.testing.expectEqualStrings("file h 5", line.bytes);
    const payload = body[line.next .. line.next + 5];
    try std.testing.expectEqualStrings("AB\nCD", payload);
}

test "sync §5.2: a card is operator plus zero or more argument tokens" {
    try std.testing.expectEqual(@as(usize, 1), tokenizeCard("private").len);
    try std.testing.expectEqual(@as(usize, 3), tokenizeCard("push a b").len);
}

test "sync §5.2: canonical wire form is single-space, no surrounding whitespace" {
    const t = tokenizeCard("push a b");
    try std.testing.expectEqual(@as(usize, 3), t.len);
    try std.testing.expectEqualStrings("push", t.items[0]);
    try std.testing.expectEqualStrings("a", t.items[1]);
    try std.testing.expectEqualStrings("b", t.items[2]);
}

test "sync §5.2: operator token is lowercase letters plus underscore for clone_seqno" {
    try std.testing.expectEqual(Operator.clone_seqno, parseOperator("clone_seqno").?);
    try std.testing.expectEqual(Operator.push, parseOperator("push").?);
    try std.testing.expect(parseOperator("PUSH") == null);
    try std.testing.expect(parseOperator("push_") == null);
    try std.testing.expect(parseOperator("cl0ne") == null);
}

test "sync §5.2: card tokenizes on Fossil-whitespace runs, LF is not a token byte" {
    const t = tokenizeCard("push\ta\x0b\x0c\rb");
    try std.testing.expectEqual(@as(usize, 3), t.len);
    try std.testing.expectEqualStrings("push", t.items[0]);
    try std.testing.expectEqualStrings("a", t.items[1]);
    try std.testing.expectEqualStrings("b", t.items[2]);
}

test "sync §5.2: receiver accepts the canonical single-space form" {
    const t = tokenizeCard("igot h 1");
    try std.testing.expectEqual(@as(usize, 3), t.len);
    try std.testing.expectEqualStrings("igot", t.items[0]);
    try std.testing.expectEqualStrings("h", t.items[1]);
    try std.testing.expectEqualStrings("1", t.items[2]);
}

test "sync §5.2: receiver accepts Fossil-whitespace runs (compat)" {
    const t = tokenizeCard("push   a\t\tb");
    try std.testing.expectEqual(@as(usize, 3), t.len);
    try std.testing.expectEqualStrings("push", t.items[0]);
    try std.testing.expectEqualStrings("a", t.items[1]);
    try std.testing.expectEqualStrings("b", t.items[2]);
}

test "sync §5.2: card and comment lines bounded at 65535 bytes including LF" {
    var ok_buf: [max_line_len]u8 = undefined;
    @memset(ok_buf[0 .. max_line_len - 1], 'x');
    ok_buf[max_line_len - 1] = '\n';
    const ok_line = try readLine(&ok_buf, 0);
    try std.testing.expectEqual(@as(usize, max_line_len - 1), ok_line.bytes.len);

    var over_buf: [max_line_len]u8 = undefined;
    @memset(&over_buf, 'x');
    try std.testing.expectError(LineError.MissingTerminator, readLine(&over_buf, 0));
}

test "sync §5.2: unknown card operator is a fatal unknown-operator disposition" {
    const c = classifyCard("frobnicate x");
    try std.testing.expectEqual(Disposition.unknown_operator, c.disposition);
    try std.testing.expect(c.operator == null);
    try std.testing.expect(isFatal(c.disposition, .client));
    try std.testing.expect(isFatal(c.disposition, .server));
}

test "sync §5.2: defined operator with in-window arg count is dispatched not unknown" {
    const c = classifyCard("private extra");
    try std.testing.expectEqual(Operator.private, c.operator.?);
    try std.testing.expectEqual(Disposition.dispatch, c.disposition);
}

test "sync §5.2: card with surplus tokens beyond the six-slot window is malformed" {
    const t = tokenizeCard("push a b c d e f");
    try std.testing.expectEqual(@as(usize, max_tokens), t.len);
    try std.testing.expect(t.surplus);
    const c = classifyCard("push a b c d e f");
    try std.testing.expect(c.disposition != .dispatch);
}

test "sync §5.2: receiver treats surplus tokens as malformed" {
    const simple = classifyCard("push a b c d e f");
    try std.testing.expectEqual(Disposition.bad_command, simple.disposition);
    const content = classifyCard("file a b c d e f");
    try std.testing.expectEqual(Disposition.malformed_content, content.disposition);
}

test "sync §5.2: content card arity failure yields malformed CARD line and aborts" {
    const c = classifyCard("file a b c d e f");
    try std.testing.expectEqual(Disposition.malformed_content, c.disposition);
    var out: [64]u8 = undefined;
    const msg = writeMalformedCard(.file, &out);
    try std.testing.expectEqualStrings("malformed file line", msg);
    try std.testing.expect(isFatal(c.disposition, .client));
    try std.testing.expect(isFatal(c.disposition, .server));
}

test "sync §5.2: simple card arity failure yields bad command, fatal client non-fatal server" {
    const line = "push a b c d e f";
    const c = classifyCard(line);
    try std.testing.expectEqual(Disposition.bad_command, c.disposition);
    var out: [64]u8 = undefined;
    const msg = writeBadCommand(line, &out);
    try std.testing.expectEqualStrings("bad command: push a b c d e f", msg);
    try std.testing.expect(isFatal(c.disposition, .client));
    try std.testing.expect(!isFatal(c.disposition, .server));
}

test "sync §5.2: unrecognized card framing is exactly 'bad command:' plus offending line" {
    var out: [64]u8 = undefined;
    const msg = writeBadCommand("frobnicate x y", &out);
    try std.testing.expectEqualStrings("bad command: frobnicate x y", msg);
    try std.testing.expect(std.mem.indexOf(u8, msg, "Unknown message") == null);
}

test "sync §5.2.1: defined operator catalog resolves client/server operators" {
    try std.testing.expectEqual(Operator.cookie, parseOperator("cookie").?);
    try std.testing.expectEqual(Operator.login, parseOperator("login").?);
    try std.testing.expectEqual(Operator.push, parseOperator("push").?);
    try std.testing.expectEqual(Operator.pull, parseOperator("pull").?);
}

test "sync §5.2.1: remaining operators resolve and catalog size is 19" {
    const names = [_][]const u8{
        "clone",  "file",        "cfile",  "uvfile",  "private",
        "igot",   "uvigot",      "gimme",  "uvgimme", "reqconfig",
        "config", "clone_seqno", "pragma", "error",   "message",
    };
    for (names) |name| {
        try std.testing.expect(parseOperator(name) != null);
    }
    try std.testing.expectEqual(@as(usize, 19), @typeInfo(Operator).@"enum".fields.len);
}

test "sync §5.3: a line whose first byte is # is a comment line" {
    try std.testing.expect(isCommentLine("#x"));
    try std.testing.expect(!isCommentLine(""));
    try std.testing.expect(!isCommentLine("push"));
}

test "sync §5.3: comment lines are not dispatched as cards" {
    // A comment line may textually contain an operator name, but its
    // leading # is what a card dispatcher sees as the first token, and
    // "#" is not in the operator catalog -- so even a comment line that
    // reads like a push card can never resolve to Operator.push
    // (§5-r22).
    try std.testing.expect(isCommentLine("# push a b"));
    const c = classifyCard("# push a b");
    try std.testing.expectEqual(Disposition.unknown_operator, c.disposition);
    try std.testing.expect(c.operator == null);
}

test "sync §5.3: timestamp subform exposes DATETIME and informational error count" {
    const tc = parseTimestampComment("# timestamp 2026-07-02T18:31:13 errors 0").?;
    try std.testing.expectEqualStrings("2026-07-02T18:31:13", tc.datetime);
    try std.testing.expectEqual(@as(u64, 0), tc.errors);
}

test "sync §5.3: other comment lines are not semantically interpreted" {
    try std.testing.expect(parseTimestampComment("# F2A895EF75B47B7157664A7FEAC978EFFFDB01C4") == null);
    try std.testing.expect(parseTimestampComment("# sending-clusters") == null);
    try std.testing.expect(parseTimestampComment("# timestamp X errors zz") == null);
}

// ---------------------------------------------------------------------------
// Section 10 — Pragmas
// ---------------------------------------------------------------------------

/// §10 L2287-2291 — the complete pragma name catalog: nine client-to-
/// server names followed by five server-to-client names, in the RFC's
/// own listing order.
pub const PragmaName = enum {
    @"send-private",
    @"send-catalog",
    @"client-version",
    @"uv-hash",
    @"ci-lock",
    @"ci-unlock",
    @"client-url",
    @"req-links",
    @"req-clusters",
    @"server-version",
    @"uv-push-ok",
    @"uv-pull-only",
    @"ci-lock-fail",
    @"avoid-delta-manifests",
};

comptime {
    // §10 L2287 — "Fourteen pragma names are defined."
    std.debug.assert(@typeInfo(PragmaName).@"enum".fields.len == 14);
}

/// §10 L2287-2291 — which side originates a pragma.
pub const PragmaDirection = enum { client_to_server, server_to_client };

/// §10 L2287-2291 — direction table, split exactly as the RFC lists it.
pub fn pragmaDirection(name: PragmaName) PragmaDirection {
    return switch (name) {
        .@"send-private",
        .@"send-catalog",
        .@"client-version",
        .@"uv-hash",
        .@"ci-lock",
        .@"ci-unlock",
        .@"client-url",
        .@"req-links",
        .@"req-clusters",
        => .client_to_server,
        .@"server-version",
        .@"uv-push-ok",
        .@"uv-pull-only",
        .@"ci-lock-fail",
        .@"avoid-delta-manifests",
        => .server_to_client,
    };
}

comptime {
    // §10 L2287-2291 — "Nine are client-to-server ... Five are
    // server-to-client."
    var c2s: usize = 0;
    var s2c: usize = 0;
    for (@typeInfo(PragmaName).@"enum".fields) |f| {
        switch (pragmaDirection(@enumFromInt(f.value))) {
            .client_to_server => c2s += 1,
            .server_to_client => s2c += 1,
        }
    }
    std.debug.assert(c2s == 9 and s2c == 5);
}

/// §10 r2/r3/r5 L2281-2283 — resolve a pragma name token. Unrecognised
/// tokens fall through to null via the same exact-field-name match
/// `parseOperator` uses for card operators; this IS the forward-
/// compatibility mechanism, not a special case of it.
pub fn parsePragmaName(token: []const u8) ?PragmaName {
    return std.meta.stringToEnum(PragmaName, token);
}

/// §10 L2279 result of classifying a `pragma` card: the resolved name
/// (null when the receiver does not recognise it, §10 r2) plus the raw
/// tokens for the caller's own per-pragma argument parsing.
pub const PragmaClass = struct { name: ?PragmaName, tokens: Tokens };

/// §10 r1 L2279 — a pragma card is `pragma NAME [ARGUMENTS]`. Runs only
/// after `classifyCard` has already dispatched the line as `.pragma`
/// with no surplus tokens (§5); this function does not re-derive that.
pub fn classifyPragma(tokens: Tokens) PragmaClass {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
    std.debug.assert(!tokens.surplus);
    const name: ?PragmaName = if (tokens.len >= 2) parsePragmaName(tokens.items[1]) else null;
    return .{ .name = name, .tokens = tokens };
}

/// §10.1 L2310-2312 — DATE is YYYYMMDD, TIME is HHMMSS; a pragma carries
/// both together or neither (L2312, L2320-2321), so this is one
/// both-or-neither optional on `VersionInfo`/`Peer`, never two fields.
pub const DateTime = struct { date: u32, time: u32 };

/// §10.1 — one parsed wire shape shared by both version pragmas
/// (client-version and server-version carry the same VERSION [DATE
/// TIME] grammar, L2306/L2319-2320).
pub const VersionInfo = struct { version: u32, datetime: ?DateTime = null };

/// §3.1 L353-357 endpoint state entity ("peer version" / "peer date")
/// kept deliberately distinct from `VersionInfo`: `VersionInfo` is the
/// parsed wire payload of one pragma, `Peer` is the carried per-session
/// record with a role-specific pre-pragma default (server seeds VERSION
/// 0 per r10 L2317-2319; client seeds its own release integer per r14
/// L2326-2328). Same fields, different lifecycle and default — merging
/// them would erase which constructor supplied the seed.
pub const Peer = struct {
    version: u32,
    datetime: ?DateTime = null,

    /// §10.1 r10 L2317-2319 — server's peer record before any
    /// client-version pragma has been received.
    pub fn serverInit() Peer {
        return .{ .version = 0 };
    }

    /// §10.1 r14 L2326-2328 — client's peer record before any
    /// server-version pragma has been received: its own release
    /// integer.
    pub fn clientInit(own_version: u32) Peer {
        return .{ .version = own_version };
    }
};

/// §10.1 r9 L2316-2317 (server records client) and r13 L2325-2326
/// (client records server) — the recording action is byte-for-byte
/// identical on both sides, so one function serves both; only the
/// pre-record default differs, and that lives in the two `Peer`
/// constructors above, not here.
pub fn recordPeerVersion(p: *Peer, v: VersionInfo) void {
    p.version = v.version;
    p.datetime = v.datetime;
    std.debug.assert(p.version == v.version and std.meta.eql(p.datetime, v.datetime));
}

/// §10.1 r6 L2303-2304 — client-version is mandatory on every request;
/// a missing or non-decimal VERSION is a real parse failure, not a
/// modelled-away optional.
pub const PragmaError = error{Malformed};

/// §10.1 r11 L2321 — "at least five tokens total" (pragma, NAME,
/// VERSION, DATE, TIME) is the sole gate for DATE/TIME presence.
pub const min_datetime_tokens: usize = 5;

/// §10.1 r7 L2308-2310 — VERSION = MAJOR*10000 + MINOR*100 + PATCH.
/// MINOR and PATCH are two-digit fields; MAJOR is bounded so the sum
/// cannot overflow the u32 result.
pub fn encodeVersion(major: u32, minor: u32, patch: u32) u32 {
    std.debug.assert(minor < 100 and patch < 100);
    const max_major = (std.math.maxInt(u32) - 9999) / 10000;
    std.debug.assert(major <= max_major);
    return major * 10000 + minor * 100 + patch;
}

/// §10.1 r6/r8/r11 L2303-2323 — parse `pragma {client,server}-version
/// VERSION [DATE TIME]`. VERSION is `items[2]` and mandatory (r6);
/// DATE/TIME are `items[3]`/`items[4]`, present iff the card has at
/// least `min_datetime_tokens` tokens (r11).
pub fn parseVersionPragma(tokens: Tokens) PragmaError!VersionInfo {
    if (tokens.len < 3) return PragmaError.Malformed;
    const version = std.fmt.parseInt(u32, tokens.items[2], 10) catch return PragmaError.Malformed;
    var datetime: ?DateTime = null;
    if (tokens.len >= min_datetime_tokens) {
        const date = std.fmt.parseInt(u32, tokens.items[3], 10) catch return PragmaError.Malformed;
        const time = std.fmt.parseInt(u32, tokens.items[4], 10) catch return PragmaError.Malformed;
        datetime = .{ .date = date, .time = time };
    }
    const result: VersionInfo = .{ .version = version, .datetime = datetime };
    std.debug.assert((result.datetime == null) == (tokens.len < min_datetime_tokens));
    return result;
}

/// §10.1 r11/r12 L2319-2323 — the server replies with server-version
/// exactly when the received client-version pragma carried DATE and
/// TIME.
pub fn serverEmitsServerVersion(v: VersionInfo) bool {
    return v.datetime != null;
}

/// §10.1 r6/r8 L2303-2313 — write `pragma client-version VERSION [DATE
/// TIME]`: full form when `v.datetime` is set, VERSION-only otherwise.
/// DATE/TIME are zero-padded fixed-width YYYYMMDD/HHMMSS (L2310-2312
/// specifies positional *forms*, which requires leading zeros).
pub fn writeClientVersion(v: VersionInfo, out: []u8) std.fmt.BufPrintError![]u8 {
    if (v.datetime) |dt| {
        std.debug.assert(dt.date < 100000000 and dt.time < 1000000);
        const needed = std.fmt.count("pragma client-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time });
        std.debug.assert(out.len >= needed);
        return try std.fmt.bufPrint(out, "pragma client-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time });
    }
    const needed = std.fmt.count("pragma client-version {d}", .{v.version});
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "pragma client-version {d}", .{v.version});
}

/// §10.1 r11 L2319-2323 — write `pragma server-version VERSION DATE
/// TIME`. Always the full form (the server never emits it without
/// DATE/TIME, r12); same zero-padded fixed-width form as
/// `writeClientVersion`.
pub fn writeServerVersion(v: VersionInfo, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(v.datetime != null);
    const dt = v.datetime.?;
    std.debug.assert(dt.date < 100000000 and dt.time < 1000000);
    const needed = std.fmt.count("pragma server-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time });
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "pragma server-version {d} {d:0>8} {d:0>6}", .{ v.version, dt.date, dt.time });
}

/// §10.2 r15 L2333 — `pragma uv-hash HASH`; HASH is `items[2]`.
pub fn parseUvHash(tokens: Tokens) ?[]const u8 {
    return if (tokens.len >= 3) tokens.items[2] else null;
}

/// §8.4 — a request class deferred on clone's first round: the client
/// suppresses login, config exchange, and uv-hash while `op == .clone`
/// and `round == 0`. One definition shared by every deferred pragma
/// (§10.2 r16, §10.3 r22) rather than each re-deriving the predicate.
pub fn sendableAfterCloneRound0(op: Operation, round: u64) bool {
    return !(op == .clone and round == 0);
}

/// §10.2 r16 L2335-2337, §9.1.1 L2058-2059 — uv-hash goes out at most
/// once per session and never on clone round 0.
pub fn clientMaySendUvHash(hash_sent: bool, op: Operation, round: u64) bool {
    return !hash_sent and sendableAfterCloneRound0(op, round);
}

/// §10.2 r17/r18 L2339-2343 — the server's per-request unversioned-file
/// response.
pub const UvResponse = enum { none, push_ok, pull_only };

/// §10.2 r17/r18 L2339-2343, §9.1.1 L2077-2081 — matching catalog hash
/// or missing read permission yields no response; otherwise the server
/// grants push or pull-only depending on upload permission.
pub fn serverUvResponse(match: bool, has_read: bool, may_upload: bool) UvResponse {
    const result: UvResponse = blk: {
        if (match) break :blk .none;
        if (!has_read) break :blk .none;
        break :blk if (may_upload) .push_ok else .pull_only;
    };
    std.debug.assert(result != .push_ok or (!match and has_read and may_upload));
    std.debug.assert(result != .pull_only or (!match and has_read and !may_upload));
    std.debug.assert(result != .none or (match or !has_read));
    return result;
}

/// §9.1.1 L2088-2091, §10.2 L2359-2361 — the client's per-session
/// unversioned-upload state as a single enum, not two independent
/// bools: the r19 pull-only latch is one field and the contradictory
/// "push-ok AND pull-only" state is unrepresentable.
pub const UvUpload = enum { undetermined, push_ok, pull_only };

/// §3.1 L374-399 — mirrors the "catalog sent" flag and the collapsed
/// upload latch [rw-1] as the client's per-session unversioned state.
pub const UvSession = struct {
    hash_sent: bool = false,
    upload: UvUpload = .undetermined,
};

/// §9.1.1 L2088-2089 — pragma uv-push-ok sets upload-enabled, but MUST
/// NOT override an existing pull-only latch (r19, §10.2 L2359-2361):
/// once pull-only, a later push-ok is a no-op.
pub fn clientOnUvPushOk(s: *UvSession) void {
    const old = s.upload;
    if (s.upload != .pull_only) s.upload = .push_ok;
    std.debug.assert(if (old == .pull_only) s.upload == .pull_only else s.upload == .push_ok);
}

/// §9.1.1 L2089-2091, §10.2 r19 L2359-2361 — pragma uv-pull-only
/// latches the session unconditionally; it always wins.
pub fn clientOnUvPullOnly(s: *UvSession) void {
    s.upload = .pull_only;
    std.debug.assert(s.upload == .pull_only);
}

test "sync §10 r1: a pragma card is 'pragma NAME [ARGUMENTS]'" {
    const c1 = classifyPragma(tokenizeCard("pragma send-catalog"));
    try std.testing.expectEqual(PragmaName.@"send-catalog", c1.name.?);
    const c2 = classifyPragma(tokenizeCard("pragma ci-lock H ID"));
    try std.testing.expectEqual(PragmaName.@"ci-lock", c2.name.?);
}

test "sync §10 r2: an unrecognised pragma name is silently ignored" {
    try std.testing.expect(parsePragmaName("future-x") == null);
    const c = classifyPragma(tokenizeCard("pragma future-x arg"));
    try std.testing.expect(c.name == null);
}

test "sync §10 r3: unknown pragma name is ignored but unknown card operator is fatal" {
    const c = classifyPragma(tokenizeCard("pragma future-x"));
    try std.testing.expect(c.name == null);

    const bad = classifyCard("frobnicate x");
    try std.testing.expectEqual(Disposition.unknown_operator, bad.disposition);
    try std.testing.expect(isFatal(bad.disposition, .client));
    try std.testing.expect(isFatal(bad.disposition, .server));
}

test "sync §10 r4: fourteen pragma names, nine c2s and five s2c" {
    const Case = struct { name: []const u8, dir: PragmaDirection };
    const cases = [_]Case{
        .{ .name = "send-private", .dir = .client_to_server },
        .{ .name = "send-catalog", .dir = .client_to_server },
        .{ .name = "client-version", .dir = .client_to_server },
        .{ .name = "uv-hash", .dir = .client_to_server },
        .{ .name = "ci-lock", .dir = .client_to_server },
        .{ .name = "ci-unlock", .dir = .client_to_server },
        .{ .name = "client-url", .dir = .client_to_server },
        .{ .name = "req-links", .dir = .client_to_server },
        .{ .name = "req-clusters", .dir = .client_to_server },
        .{ .name = "server-version", .dir = .server_to_client },
        .{ .name = "uv-push-ok", .dir = .server_to_client },
        .{ .name = "uv-pull-only", .dir = .server_to_client },
        .{ .name = "ci-lock-fail", .dir = .server_to_client },
        .{ .name = "avoid-delta-manifests", .dir = .server_to_client },
    };
    try std.testing.expectEqual(@as(usize, 14), cases.len);
    var c2s: usize = 0;
    var s2c: usize = 0;
    for (cases) |c| {
        const name = parsePragmaName(c.name).?;
        try std.testing.expectEqual(c.dir, pragmaDirection(name));
        switch (c.dir) {
            .client_to_server => c2s += 1,
            .server_to_client => s2c += 1,
        }
    }
    try std.testing.expectEqual(@as(usize, 9), c2s);
    try std.testing.expectEqual(@as(usize, 5), s2c);
}

test "sync §10 r5: pragma resolution is not version-gated" {
    const names = [_][]const u8{
        "send-private", "send-catalog", "client-version", "uv-hash",               "ci-lock",
        "ci-unlock",    "client-url",   "req-links",      "req-clusters",          "server-version",
        "uv-push-ok",   "uv-pull-only", "ci-lock-fail",   "avoid-delta-manifests",
    };
    for (names) |name| {
        try std.testing.expect(parsePragmaName(name) != null);
    }
    try std.testing.expect(parsePragmaName("unknown-thing") == null);
}

test "sync §10.1 r6: client-version is 'pragma client-version VERSION DATE TIME'" {
    var buf: [64]u8 = undefined;
    const v: VersionInfo = .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 120000 } };
    try std.testing.expectEqualStrings("pragma client-version 20000 20260709 120000", try writeClientVersion(v, &buf));
}

test "sync §10.1 r7: VERSION = MAJOR*10000+MINOR*100+PATCH; 2.0 -> 20000" {
    try std.testing.expectEqual(@as(u32, 20000), encodeVersion(2, 0, 0));
    try std.testing.expectEqual(@as(u32, 19999), encodeVersion(1, 99, 99));
    try std.testing.expect(encodeVersion(1, 99, 99) < 20000);
}

test "sync §10.1 r8: DATE and TIME are optional; VERSION-only is allowed" {
    var buf: [64]u8 = undefined;
    const v: VersionInfo = .{ .version = 20000 };
    try std.testing.expectEqualStrings("pragma client-version 20000", try writeClientVersion(v, &buf));

    const parsed = try parseVersionPragma(tokenizeCard("pragma client-version 20000"));
    try std.testing.expect(parsed.datetime == null);
}

test "sync §10.1 r9: received client-version updates server peer version and timestamp" {
    var p = Peer.serverInit();
    recordPeerVersion(&p, .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 120000 } });
    try std.testing.expectEqual(@as(u32, 20000), p.version);
    try std.testing.expectEqual(@as(u32, 20260709), p.datetime.?.date);
    try std.testing.expectEqual(@as(u32, 120000), p.datetime.?.time);
}

test "sync §10.1 r10: before client-version, server peer VERSION is 0" {
    const p = Peer.serverInit();
    try std.testing.expectEqual(@as(u32, 0), p.version);
    try std.testing.expect(p.datetime == null);
}

test "sync §10.1 r11: server emits server-version iff pragma had DATE and TIME (>=5 tokens)" {
    const v = try parseVersionPragma(tokenizeCard("pragma client-version 20000 20260709 120000"));
    try std.testing.expect(serverEmitsServerVersion(v));
}

test "sync §10.1 r12: VERSION-only client-version suppresses server-version" {
    const v = try parseVersionPragma(tokenizeCard("pragma client-version 20000"));
    try std.testing.expect(!serverEmitsServerVersion(v));
}

test "sync §10.1 r13: received server-version updates client peer version and timestamp" {
    var p = Peer.clientInit(19999);
    recordPeerVersion(&p, .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 120000 } });
    try std.testing.expectEqual(@as(u32, 20000), p.version);
    try std.testing.expectEqual(@as(u32, 20260709), p.datetime.?.date);
}

test "sync §10.1 r14: before server-version, client peer VERSION is its own release integer" {
    const p = Peer.clientInit(19999);
    try std.testing.expectEqual(@as(u32, 19999), p.version);
    try std.testing.expect(p.datetime == null);
}

test "sync §10.1: server-version DATE/TIME zero-padded to YYYYMMDD/HHMMSS form" {
    var buf: [64]u8 = undefined;
    const v1: VersionInfo = .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 90503 } };
    try std.testing.expectEqualStrings("pragma server-version 20000 20260709 090503", try writeServerVersion(v1, &buf));

    var buf2: [64]u8 = undefined;
    const v2: VersionInfo = .{ .version = 20000, .datetime = .{ .date = 20260709, .time = 1 } };
    try std.testing.expectEqualStrings("pragma server-version 20000 20260709 000001", try writeServerVersion(v2, &buf2));
}

test "sync §10.1: client-version numeric fields must parse (rejection)" {
    const cases = [_][]const u8{
        "pragma client-version",
        "pragma client-version x",
        "pragma client-version 20000 x 120000",
        "pragma client-version 20000 20260709 x",
    };
    for (cases) |line| {
        try std.testing.expectError(PragmaError.Malformed, parseVersionPragma(tokenizeCard(line)));
    }
}

test "sync §10.2 r15: uv-hash carries the client's catalog hash" {
    const t = tokenizeCard("pragma uv-hash ABC123");
    try std.testing.expectEqualStrings("ABC123", parseUvHash(t).?);
    const c = classifyPragma(t);
    try std.testing.expectEqual(PragmaName.@"uv-hash", c.name.?);
}

test "sync §10.2 r16: uv-hash sent at most once per session and never on clone round 0" {
    const Case = struct { hash_sent: bool, op: Operation, round: u64, want: bool };
    const cases = [_]Case{
        .{ .hash_sent = false, .op = .sync, .round = 3, .want = true },
        .{ .hash_sent = true, .op = .sync, .round = 3, .want = false },
        .{ .hash_sent = false, .op = .clone, .round = 0, .want = false },
        .{ .hash_sent = false, .op = .clone, .round = 1, .want = true },
    };
    for (cases) |c| {
        try std.testing.expectEqual(c.want, clientMaySendUvHash(c.hash_sent, c.op, c.round));
    }
}

test "sync §10.2 r17: matching catalog hash suppresses the uv response" {
    try std.testing.expectEqual(UvResponse.none, serverUvResponse(true, true, true));
}

test "sync §10.2 r18: differing hash with read yields push-ok or pull-only" {
    const Case = struct { match: bool, read: bool, upload: bool, want: UvResponse };
    const cases = [_]Case{
        .{ .match = false, .read = true, .upload = true, .want = .push_ok },
        .{ .match = false, .read = true, .upload = false, .want = .pull_only },
        .{ .match = false, .read = false, .upload = true, .want = .none },
    };
    for (cases) |c| {
        try std.testing.expectEqual(c.want, serverUvResponse(c.match, c.read, c.upload));
    }
}

test "sync §10.2 r19: uv-pull-only latches the client and push-ok cannot undo it" {
    var s: UvSession = .{};
    try std.testing.expectEqual(UvUpload.undetermined, s.upload);
    clientOnUvPushOk(&s);
    try std.testing.expectEqual(UvUpload.push_ok, s.upload);
    clientOnUvPullOnly(&s);
    try std.testing.expectEqual(UvUpload.pull_only, s.upload);
    clientOnUvPushOk(&s);
    try std.testing.expectEqual(UvUpload.pull_only, s.upload);
}

// ---------------------------------------------------------------------------
// Section 10.3 — Configuration Exchange
// ---------------------------------------------------------------------------

/// §5.2 — tokenization splits a card on Fossil-whitespace runs, so an
/// unquoted wire arg (group name, pragma arg) that embeds one would
/// silently split into two tokens on the wire; shared by every writer
/// below that formats a raw token straight onto the wire.
pub fn containsFossilWhitespace(s: []const u8) bool {
    for (s) |b| {
        if (isFossilWhitespace(b)) return true;
    }
    return false;
}

/// §10.3 L2381-2383 — the scalar-settings marker: a fixed GROUPNAME
/// that carries every scalar repository setting regardless of which
/// group the client actually requested (r24, r27).
pub const config_scalar_marker: []const u8 = "/config";

/// §10.3 L2381-2404 — a config card's payload is either the fixed
/// scalar marker or a table GROUPNAME (r24 vs. r28).
pub const ConfigKind = enum { scalar, table };

/// §10.3 r24/r28 — classify a GROUPNAME by exact match against the
/// scalar marker; anything else is a table-type group name.
pub fn configKind(groupname: []const u8) ConfigKind {
    return if (std.mem.eql(u8, groupname, config_scalar_marker)) .scalar else .table;
}

/// §10.3 r20 L2369-2370 — the reqconfig NAME MUST-begin-with-/ gate.
pub fn configNameHasSlashPrefix(name: []const u8) bool {
    return name.len >= 1 and name[0] == '/';
}

/// §10.3 r20 — write `reqconfig NAME`.
pub fn writeReqconfig(name: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(configNameHasSlashPrefix(name));
    std.debug.assert(!containsFossilWhitespace(name));
    const needed = std.fmt.count("reqconfig {s}", .{name});
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "reqconfig {s}", .{name});
}

/// §10.3 r20/r21 — the requested group name off a dispatched reqconfig
/// card; null when the card carries no NAME token.
pub fn parseReqconfigName(tokens: Tokens) ?[]const u8 {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .reqconfig);
    return if (tokens.len >= 2) tokens.items[1] else null;
}

/// §10.3 r21 L2370-2372 — the server's reqconfig disposition: `.ignore`
/// emits no error card at all, unlike every fatal disposition in §5.
pub const ReqconfigDisposition = enum { reply, ignore };

/// §10.3 r21 — Read permission is the sole gate; unauthorized is a
/// silent no-op, not a rejection card.
pub fn serverReqconfigDisposition(has_read: bool) ReqconfigDisposition {
    return if (has_read) .reply else .ignore;
}

/// §10.3 r23 L2377-2378 — a parsed `config GROUPNAME SIZE` card header.
pub const ConfigCard = struct { groupname: []const u8, size: u32 };

/// §10.3 r32 L2430-2431 — a negative SIZE is distinguished from an
/// ordinary parse failure because it names a specific fatal action
/// (abort the transaction) rather than an unspecified malformed card.
pub const ConfigError = error{ Malformed, NegativeSize };

/// §10.3 r23/r32 — parse `config GROUPNAME SIZE`. SIZE is read as a
/// signed field so a negative literal is representable and reported as
/// `NegativeSize` rather than folded into `Malformed`; a positive value
/// exceeding `u32` is rejected outright, never silently truncated.
pub fn parseConfigCard(tokens: Tokens) ConfigError!ConfigCard {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .config);
    if (tokens.len < 3) return ConfigError.Malformed;
    const raw = std.fmt.parseInt(i64, tokens.items[2], 10) catch return ConfigError.Malformed;
    if (raw < 0) return ConfigError.NegativeSize;
    if (raw > std.math.maxInt(u32)) return ConfigError.Malformed;
    const size: u32 = @intCast(raw);
    return .{ .groupname = tokens.items[1], .size = size };
}

/// §10.3 r23 — write `config GROUPNAME SIZE`.
pub fn writeConfigCard(groupname: []const u8, size: u32, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(!containsFossilWhitespace(groupname));
    const needed = std.fmt.count("config {s} {d}", .{ groupname, size });
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "config {s} {d}", .{ groupname, size });
}

/// §10.3 r23/r29/r33 — the SIZE content bytes plus the offset just past
/// the mandatory trailing newline.
pub const ConfigPayload = struct { bytes: []const u8, next: usize };

/// §10.3 r29 L2419-2421 — the two ways a config payload frame fails:
/// the declared span (or its trailing newline byte) runs past the
/// buffer, or the byte immediately after SIZE bytes is not the LF r29
/// mandates.
pub const ConfigFrameError = error{ Truncated, MissingNewline };

/// §10.3 r23/r29/r33 — slice out the SIZE content bytes starting at
/// `start` and consume the single mandatory trailing newline.
/// Overflow-safe: `start + size + 1` is computed with checked addition
/// so an adversarial SIZE cannot wrap `usize` into passing the bound
/// check.
pub fn consumeConfigPayload(body: []const u8, start: usize, size: u32) ConfigFrameError!ConfigPayload {
    std.debug.assert(start <= body.len);
    const end = std.math.add(usize, start, size) catch return ConfigFrameError.Truncated;
    const next = std.math.add(usize, end, 1) catch return ConfigFrameError.Truncated;
    if (next > body.len) return ConfigFrameError.Truncated;
    if (body[end] != '\n') return ConfigFrameError.MissingNewline;
    const result: ConfigPayload = .{ .bytes = body[start..end], .next = next };
    std.debug.assert(result.next == start + @as(usize, size) + 1);
    std.debug.assert(body[start + @as(usize, size)] == '\n');
    return result;
}

/// §10.3 r26 L2391-2393 — wrap `raw` in Fossil quote encoding: enclosing
/// single quotes, every interior single quote doubled (SQL-style
/// string-literal escaping).
pub fn fossilQuote(raw: []const u8, out: []u8) []u8 {
    std.debug.assert(out.len >= 2 + 2 * raw.len);
    var i: usize = 0;
    out[i] = '\'';
    i += 1;
    for (raw) |b| {
        out[i] = b;
        i += 1;
        if (b == '\'') {
            out[i] = '\'';
            i += 1;
        }
    }
    out[i] = '\'';
    i += 1;
    const result = out[0..i];
    std.debug.assert(result.len >= 2 and result[0] == '\'' and result[result.len - 1] == '\'');
    std.debug.assert(result.len <= 2 + 2 * raw.len);
    return result;
}

/// §10.3 r26 — a quoted token missing its enclosing quotes, or with an
/// interior single quote that is not doubled, is malformed.
pub const QuoteError = error{Malformed};

/// §10.3 r26 L2393-2395 — undo `fossilQuote`: strip the enclosing
/// quotes and undouble every interior `''` back to `'`. A single pass
/// bounded by `quoted.len`.
pub fn fossilUnquote(quoted: []const u8, out: []u8) QuoteError![]const u8 {
    if (quoted.len < 2 or quoted[0] != '\'' or quoted[quoted.len - 1] != '\'') return QuoteError.Malformed;
    std.debug.assert(out.len >= quoted.len - 2);
    const inner_end = quoted.len - 1;
    var i: usize = 1;
    var j: usize = 0;
    while (i < inner_end) {
        if (quoted[i] == '\'') {
            if (i + 1 < inner_end and quoted[i + 1] == '\'') {
                out[j] = '\'';
                j += 1;
                i += 2;
            } else {
                return QuoteError.Malformed;
            }
        } else {
            out[j] = quoted[i];
            j += 1;
            i += 1;
        }
    }
    const result = out[0..j];
    std.debug.assert(result.len <= quoted.len - 2);
    return result;
}

/// §10.3 r25 L2386-2390 — the scalar payload's two quoted fields, still
/// in their wire-quoted form (unquoting is the caller's explicit
/// `fossilUnquote` step, so this parse stays allocation-free).
pub const ScalarConfig = struct { mtime: u64, quoted_name: []const u8, quoted_value: []const u8 };

/// §10.3 r25/r26 — scan one Fossil-quoted token starting at `payload[start]`
/// (which must be `'`); returns the index just past its closing quote,
/// or null if the payload ends before the token closes. Bounded by
/// `payload.len`.
fn scanQuoted(payload: []const u8, start: usize) ?usize {
    std.debug.assert(start < payload.len);
    std.debug.assert(payload[start] == '\'');
    var i: usize = start + 1;
    while (i < payload.len) {
        if (payload[i] == '\'') {
            if (i + 1 < payload.len and payload[i + 1] == '\'') {
                i += 2;
                continue;
            }
            return i + 1;
        }
        i += 1;
    }
    return null;
}

/// §10.3 r25 L2386-2390 — parse `MTIME SP QUOTED-NAME SP value SP
/// QUOTED-VALUE`: the literal token `value` appears verbatim between
/// the two quoted fields. A single left-to-right pass bounded by
/// `payload.len`; any structural miss is `Malformed`.
pub fn parseScalarConfig(payload: []const u8) error{Malformed}!ScalarConfig {
    const sp1 = std.mem.indexOfScalar(u8, payload, ' ') orelse return error.Malformed;
    const mtime = std.fmt.parseInt(u64, payload[0..sp1], 10) catch return error.Malformed;
    var i = sp1 + 1;
    if (i >= payload.len or payload[i] != '\'') return error.Malformed;
    const name_end = scanQuoted(payload, i) orelse return error.Malformed;
    const quoted_name = payload[i..name_end];
    i = name_end;
    if (i >= payload.len or payload[i] != ' ') return error.Malformed;
    i += 1;
    const literal = "value";
    if (i + literal.len > payload.len or !std.mem.eql(u8, payload[i .. i + literal.len], literal)) return error.Malformed;
    i += literal.len;
    if (i >= payload.len or payload[i] != ' ') return error.Malformed;
    i += 1;
    if (i >= payload.len or payload[i] != '\'') return error.Malformed;
    const value_end = scanQuoted(payload, i) orelse return error.Malformed;
    if (value_end != payload.len) return error.Malformed;
    const result: ScalarConfig = .{ .mtime = mtime, .quoted_name = quoted_name, .quoted_value = payload[i..value_end] };
    std.debug.assert(result.quoted_name.len >= 2 and result.quoted_name[0] == '\'' and result.quoted_name[result.quoted_name.len - 1] == '\'');
    std.debug.assert(result.quoted_value.len >= 2 and result.quoted_value[0] == '\'' and result.quoted_value[result.quoted_value.len - 1] == '\'');
    return result;
}

/// §10.3 r25/r26 — assemble `MTIME SP 'NAME' SP value SP 'VALUE'`,
/// quoting the raw name/value with `fossilQuote`.
pub fn writeScalarConfig(mtime: u64, raw_name: []const u8, raw_value: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
    const mtime_len = std.fmt.count("{d}", .{mtime});
    const name_bound = 2 + 2 * raw_name.len;
    const value_bound = 2 + 2 * raw_value.len;
    const literal = " value ";
    const needed = mtime_len + 1 + name_bound + literal.len + value_bound;
    std.debug.assert(out.len >= needed);
    var i: usize = 0;
    const mtime_str = try std.fmt.bufPrint(out[i..], "{d}", .{mtime});
    i += mtime_str.len;
    out[i] = ' ';
    i += 1;
    const name_q = fossilQuote(raw_name, out[i .. i + name_bound]);
    i += name_q.len;
    @memcpy(out[i .. i + literal.len], literal);
    i += literal.len;
    const value_q = fossilQuote(raw_value, out[i .. i + value_bound]);
    i += value_q.len;
    const result = out[0..i];
    std.debug.assert(result.len <= needed);
    return result;
}

/// §10.3 r31 L2427-2429 — the exact error-card text for an
/// unauthorized pushed config card.
pub const config_push_unauthorized_text: []const u8 = "not authorized to push configuration";

/// §10.3 r31/r33 — the server's disposition of a pushed config card.
pub const ConfigPushDecision = enum { apply, reject };

/// §10.3 r31 — Admin capability is the sole gate for a pushed config
/// card; without it the card is rejected and not applied.
pub fn serverConfigPushDecision(has_admin: bool) ConfigPushDecision {
    return if (has_admin) .apply else .reject;
}

// ---------------------------------------------------------------------------
// Section 10.4 — Commit-Lock Pragmas
// ---------------------------------------------------------------------------

/// §10.4 L2444-2445 — `pragma ci-lock HASH CLIENT-ID` arguments.
pub const CiLock = struct { hash: []const u8, client_id: []const u8 };

/// §10.4 — HASH/CLIENT-ID off a dispatched `pragma ci-lock` card.
pub fn parseCiLock(tokens: Tokens) ?CiLock {
    std.debug.assert(tokens.len >= 2);
    std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
    std.debug.assert(parsePragmaName(tokens.items[1]) == .@"ci-lock");
    if (tokens.len < 4) return null;
    return .{ .hash = tokens.items[2], .client_id = tokens.items[3] };
}

/// §10.4 — write `pragma ci-lock HASH CLIENT-ID`.
pub fn writeCiLock(hash: []const u8, client_id: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(hash.len > 0 and !containsFossilWhitespace(hash));
    std.debug.assert(client_id.len > 0 and !containsFossilWhitespace(client_id));
    const needed = std.fmt.count("pragma ci-lock {s} {s}", .{ hash, client_id });
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "pragma ci-lock {s} {s}", .{ hash, client_id });
}

/// §10.4 L2446-2449 — the server's disposition of a `ci-lock` request:
/// granted when unheld or already held by the same requester, failed
/// when held by a different client.
pub const CiLockResult = enum { granted, failed };

/// §10.4 r36 — `holder` is the current lock holder's login, if any.
pub fn serverCiLockResult(holder: ?[]const u8, requester: []const u8) CiLockResult {
    const result: CiLockResult = blk: {
        const h = holder orelse break :blk .granted;
        break :blk if (std.mem.eql(u8, h, requester)) .granted else .failed;
    };
    std.debug.assert(result != .failed or (holder != null and !std.mem.eql(u8, holder.?, requester)));
    return result;
}

/// §10.4 L2447-2449 — `pragma ci-lock-fail LOGIN MTIME` arguments.
pub const CiLockFail = struct { login: []const u8, mtime: u64 };

/// §10.4 r36 — LOGIN/MTIME off a dispatched `pragma ci-lock-fail` card;
/// null on a missing or non-decimal MTIME.
pub fn parseCiLockFail(tokens: Tokens) ?CiLockFail {
    std.debug.assert(tokens.len >= 2);
    std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
    std.debug.assert(parsePragmaName(tokens.items[1]) == .@"ci-lock-fail");
    if (tokens.len < 4) return null;
    const mtime = std.fmt.parseInt(u64, tokens.items[3], 10) catch return null;
    return .{ .login = tokens.items[2], .mtime = mtime };
}

/// §10.4 — write `pragma ci-lock-fail LOGIN MTIME`.
pub fn writeCiLockFail(login: []const u8, mtime: u64, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(login.len > 0 and !containsFossilWhitespace(login));
    const needed = std.fmt.count("pragma ci-lock-fail {s} {d}", .{ login, mtime });
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "pragma ci-lock-fail {s} {d}", .{ login, mtime });
}

/// §10.4 L2449-2450 — a client holding `ci-lock-fail` MUST NOT proceed
/// with the push it was attempting to lock for.
pub fn clientMayPushAfterCiLockFail(got_lock_fail: bool) bool {
    return !got_lock_fail;
}

/// §10.4 L2452 — CLIENT-ID off a dispatched `pragma ci-unlock` card.
pub fn parseCiUnlock(tokens: Tokens) ?[]const u8 {
    std.debug.assert(tokens.len >= 2);
    std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
    std.debug.assert(parsePragmaName(tokens.items[1]) == .@"ci-unlock");
    return if (tokens.len >= 3) tokens.items[2] else null;
}

/// §10.4 — write `pragma ci-unlock CLIENT-ID`.
pub fn writeCiUnlock(client_id: []const u8, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(client_id.len > 0 and !containsFossilWhitespace(client_id));
    const needed = std.fmt.count("pragma ci-unlock {s}", .{client_id});
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "pragma ci-unlock {s}", .{client_id});
}

/// §10.4 L2452-2454 — a client with a known client identifier sends
/// ci-unlock in a later round (round > 0) when no lock is pending for
/// the current exchange.
pub fn clientSendsCiUnlock(has_client_id: bool, lock_pending: bool, round: u64) bool {
    return has_client_id and !lock_pending and round > 0;
}

// ---------------------------------------------------------------------------
// Section 10.5 — Private-Content and Discovery Pragmas
// ---------------------------------------------------------------------------

/// ABNF App. B.7 L3566-3575 — the seven `"pragma" SP NAME LF` productions
/// that carry no argument (send-private, send-catalog, req-links,
/// req-clusters, avoid-delta-manifests, uv-push-ok, uv-pull-only). The
/// other seven names all carry `SP fossilized` / `SP decimal` / `SP
/// artifact-hash` after NAME. Derived from the ABNF, not from this
/// batch's callers, so `writeFlagPragma` stays the general arg-less
/// writer rather than one special-cased to §10.5.
pub fn pragmaIsArgless(name: PragmaName) bool {
    return switch (name) {
        .@"send-private",
        .@"send-catalog",
        .@"req-links",
        .@"req-clusters",
        .@"avoid-delta-manifests",
        .@"uv-push-ok",
        .@"uv-pull-only",
        => true,
        .@"client-version",
        .@"server-version",
        .@"uv-hash",
        .@"ci-lock",
        .@"ci-lock-fail",
        .@"ci-unlock",
        .@"client-url",
        => false,
    };
}

/// §10 ABNF — write `pragma NAME` for an arg-less pragma. `@tagName`
/// reproduces the wire token exactly: `PragmaName` fields are spelled to
/// match the wire names (`parsePragmaName` relies on the same identity).
/// Precondition `pragmaIsArgless(name)` — a name that carries an argument
/// is a caller bug, not a runtime error.
pub fn writeFlagPragma(name: PragmaName, out: []u8) std.fmt.BufPrintError![]u8 {
    std.debug.assert(pragmaIsArgless(name));
    const tag = @tagName(name);
    const needed = std.fmt.count("pragma {s}", .{tag});
    std.debug.assert(out.len >= needed);
    return try std.fmt.bufPrint(out, "pragma {s}", .{tag});
}

/// §10.5 L2474-2475 — the exact unauthorized-send-private error text.
pub const send_private_unauthorized_text = "not authorized to sync private content";

/// §10.5 L2472-2476 — the server's disposition of a `send-private`
/// pragma.
pub const SendPrivateDecision = enum { enable, reject };

/// §10.5 r41 — private-content transfer is enabled for the session only
/// when the requesting user holds the Private (x) capability; carries no
/// state of its own, so a repeated call with the same input always
/// yields the same decision (r43's idempotence).
pub fn serverSendPrivateDecision(has_private_x: bool) SendPrivateDecision {
    return if (has_private_x) .enable else .reject;
}

/// §10.5 r43/r48 — the two per-pragma send schedules the RFC states in
/// §10.5. Partial by design: the RFC gives a schedule for exactly three
/// names (send-private, client-url, req-links); the other eleven are
/// RFC-silent, so this returns `null` for them rather than fabricating a
/// total table the spec never states.
pub const PragmaSchedule = enum { once_pre_loop, every_private_round };

/// §10.5 r43 L2477-2479 (send-private repeats every private-sync round)
/// vs. r48 L2488-2490 (client-url/req-links go once, before the round
/// loop).
pub fn pragmaSchedule(name: PragmaName) ?PragmaSchedule {
    return switch (name) {
        .@"send-private" => .every_private_round,
        .@"client-url", .@"req-links" => .once_pre_loop,
        else => null,
    };
}

/// §10.5 r47, ABNF `client-url-pragma` L3570 — write `pragma client-url
/// <fossilized(url)>` in place: the fixed prefix goes straight into
/// `out`, then `artifact.encodeEscapes` fills the remainder — one
/// buffer, no scratch. Precondition `out.len >= prefix.len +
/// 2*url.len` (the encoder's own worst case), so unlike the sibling
/// `write*` helpers this never touches `std.fmt.bufPrint` and cannot
/// fail — no error union to force a `try` on an unreachable path.
pub fn writeClientUrl(url: []const u8, out: []u8) []u8 {
    const prefix = "pragma client-url ";
    std.debug.assert(out.len >= prefix.len + 2 * url.len);
    @memcpy(out[0..prefix.len], prefix);
    const encoded = artifact.encodeEscapes(url, out[prefix.len..]);
    return out[0 .. prefix.len + encoded.len];
}

/// §10.5 r47 — read the URL back off a dispatched `pragma client-url`
/// card: `items[2]` decoded. Null on a short card (negative space);
/// never indexes past `tokens.len`.
pub fn parseClientUrl(tokens: Tokens, out: []u8) ?[]const u8 {
    std.debug.assert(tokens.len >= 1);
    std.debug.assert(parseOperator(tokens.items[0]) == .pragma);
    if (tokens.len < 3) return null;
    std.debug.assert(parsePragmaName(tokens.items[1]) == .@"client-url");
    return artifact.decodeEscapes(tokens.items[2], out);
}

test "sync §10.3 r20: reqconfig NAME must begin with /" {
    try std.testing.expect(configNameHasSlashPrefix("/ui"));
    try std.testing.expect(!configNameHasSlashPrefix("ui"));
    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("reqconfig /ui", try writeReqconfig("/ui", &buf));
    const name = parseReqconfigName(tokenizeCard("reqconfig /ui")).?;
    try std.testing.expectEqualStrings("/ui", name);
}

test "sync §10.3 r21: server silently ignores reqconfig without Read, no error card" {
    try std.testing.expectEqual(ReqconfigDisposition.ignore, serverReqconfigDisposition(false));
    try std.testing.expectEqual(ReqconfigDisposition.reply, serverReqconfigDisposition(true));
}

test "sync §10.3 r22: clients defer reqconfig until after clone round 0" {
    const Case = struct { op: Operation, round: u64, want: bool };
    const cases = [_]Case{
        .{ .op = .clone, .round = 0, .want = false },
        .{ .op = .clone, .round = 1, .want = true },
        .{ .op = .sync, .round = 0, .want = true },
        .{ .op = .pull, .round = 3, .want = true },
    };
    for (cases) |c| {
        try std.testing.expectEqual(c.want, sendableAfterCloneRound0(c.op, c.round));
    }
}

test "sync §10.3 r23: config GROUPNAME SIZE card then SIZE content bytes" {
    const c = try parseConfigCard(tokenizeCard("config /config 32"));
    try std.testing.expectEqualStrings("/config", c.groupname);
    try std.testing.expectEqual(@as(u32, 32), c.size);

    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("config /config 32", try writeConfigCard("/config", 32, &buf));

    const payload = try consumeConfigPayload("abc\n", 0, 3);
    try std.testing.expectEqualStrings("abc", payload.bytes);
    try std.testing.expectEqual(@as(usize, 4), payload.next);
}

test "sync §10.3 r24: scalar settings travel as /config, an encoding marker not a group id" {
    try std.testing.expectEqual(ConfigKind.scalar, configKind("/config"));
    try std.testing.expect(std.mem.eql(u8, config_scalar_marker, "/config"));
}

test "sync §10.3 r25: scalar payload is MTIME SP QUOTED-NAME SP value SP QUOTED-VALUE" {
    var buf: [64]u8 = undefined;
    const written = try writeScalarConfig(1720483200, "autosync", "on", &buf);
    try std.testing.expectEqualStrings("1720483200 'autosync' value 'on'", written);

    const parsed = try parseScalarConfig(written);
    try std.testing.expectEqual(@as(u64, 1720483200), parsed.mtime);
    try std.testing.expectEqualStrings("'autosync'", parsed.quoted_name);
    try std.testing.expectEqualStrings("'on'", parsed.quoted_value);
}

test "sync §10.3 r26: Fossil quote encoding wraps in ' and doubles internal '" {
    var qbuf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("'it''s'", fossilQuote("it's", &qbuf));
    var ubuf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("it's", try fossilUnquote("'it''s'", &ubuf));

    var rbuf: [16]u8 = undefined;
    const requoted = fossilQuote("a'b'c", &rbuf);
    var rubuf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("a'b'c", try fossilUnquote(requoted, &rubuf));

    var xbuf: [8]u8 = undefined;
    try std.testing.expectEqualStrings("'x'", fossilQuote("x", &xbuf));
}

test "sync §10.3 r27: /config card name alone cannot identify the requested group" {
    // Two conceptually distinct requests both surface under the same
    // fixed marker: the byte carries no group information.
    try std.testing.expectEqual(ConfigKind.scalar, configKind(config_scalar_marker));
    try std.testing.expect(std.mem.eql(u8, config_scalar_marker, "/config"));
}

test "sync §10.3 r28: table entries travel as /GROUPNAME identifying the table" {
    try std.testing.expectEqual(ConfigKind.table, configKind("/shun"));
    try std.testing.expectEqual(ConfigKind.table, configKind("/user"));
    try std.testing.expectEqual(ConfigKind.scalar, configKind("/config"));
}

test "sync §10.3 r29: a single trailing newline follows each config payload and is consumed" {
    const ok = try consumeConfigPayload("hi\nX", 0, 2);
    try std.testing.expectEqualStrings("hi", ok.bytes);
    try std.testing.expectEqual(@as(usize, 3), ok.next);

    try std.testing.expectError(ConfigFrameError.MissingNewline, consumeConfigPayload("hiZ", 0, 2));
}

test "sync §10.3 r30: Admin may push a scalar config NAME SIZE card, same /config form" {
    var payload_buf: [64]u8 = undefined;
    const payload = try writeScalarConfig(1720483200, "autosync", "on", &payload_buf);
    var card_buf: [32]u8 = undefined;
    const card = try writeConfigCard("/config", @intCast(payload.len), &card_buf);
    try std.testing.expectEqualStrings("config /config 32", card);
    try std.testing.expectEqual(ConfigKind.scalar, configKind("/config"));
}

test "sync §10.3 r31: server requires Admin to push; else error card, not applied (rejection)" {
    try std.testing.expectEqual(ConfigPushDecision.reject, serverConfigPushDecision(false));
    try std.testing.expectEqual(ConfigPushDecision.apply, serverConfigPushDecision(true));
    try std.testing.expectEqualStrings("not authorized to push configuration", config_push_unauthorized_text);
}

test "sync §10.3 r32: pushed config card with negative SIZE is fatal (rejection)" {
    try std.testing.expectError(ConfigError.NegativeSize, parseConfigCard(tokenizeCard("config /config -1")));
}

test "sync §10.3 r33: well-formed authorized push is applied; SIZE payload + newline consumed" {
    try std.testing.expectEqual(ConfigPushDecision.apply, serverConfigPushDecision(true));
    const payload = try consumeConfigPayload("on\nNEXT", 0, 2);
    try std.testing.expectEqualStrings("on", payload.bytes);
    try std.testing.expectEqual(@as(usize, 3), payload.next);
}

test "sync §10.3 r34: only the scalar /config form is pushed, not table groups" {
    try std.testing.expectEqual(ConfigKind.scalar, configKind("/config"));
    try std.testing.expectEqual(ConfigKind.table, configKind("/shun"));
}

test "sync §10.3: quote/parse robustness negatives" {
    var buf: [16]u8 = undefined;
    try std.testing.expectError(QuoteError.Malformed, fossilUnquote("abc", &buf));
    try std.testing.expectError(QuoteError.Malformed, fossilUnquote("'a'b'", &buf));
    try std.testing.expectError(error.Malformed, parseScalarConfig("123 'n' notvalue 'v'"));
    try std.testing.expectError(ConfigError.Malformed, parseConfigCard(tokenizeCard("config /config x")));
    try std.testing.expectError(ConfigError.Malformed, parseConfigCard(tokenizeCard("config /config 5000000000")));
}

test "sync §10.4 r35: pragma ci-lock HASH CLIENT-ID" {
    const lock = parseCiLock(tokenizeCard("pragma ci-lock abcdef0123 checkout-42")).?;
    try std.testing.expectEqualStrings("abcdef0123", lock.hash);
    try std.testing.expectEqualStrings("checkout-42", lock.client_id);

    var buf: [48]u8 = undefined;
    try std.testing.expectEqualStrings("pragma ci-lock abcdef0123 checkout-42", try writeCiLock("abcdef0123", "checkout-42", &buf));
}

test "sync §10.4 r36: lock held by a different client -> ci-lock-fail LOGIN MTIME (rejection)" {
    try std.testing.expectEqual(CiLockResult.failed, serverCiLockResult("alice", "bob"));
    try std.testing.expectEqual(CiLockResult.granted, serverCiLockResult("alice", "alice"));
    try std.testing.expectEqual(CiLockResult.granted, serverCiLockResult(null, "bob"));

    var buf: [48]u8 = undefined;
    const written = try writeCiLockFail("alice", 1720483200, &buf);
    try std.testing.expectEqualStrings("pragma ci-lock-fail alice 1720483200", written);
    const parsed = parseCiLockFail(tokenizeCard(written)).?;
    try std.testing.expectEqualStrings("alice", parsed.login);
    try std.testing.expectEqual(@as(u64, 1720483200), parsed.mtime);
}

test "sync §10.4 r37: client receiving ci-lock-fail must not push" {
    try std.testing.expect(!clientMayPushAfterCiLockFail(true));
    try std.testing.expect(clientMayPushAfterCiLockFail(false));
}

test "sync §10.4 r38: pragma ci-unlock CLIENT-ID releases the lock" {
    const id = parseCiUnlock(tokenizeCard("pragma ci-unlock checkout-42")).?;
    try std.testing.expectEqualStrings("checkout-42", id);

    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("pragma ci-unlock checkout-42", try writeCiUnlock("checkout-42", &buf));
}

test "sync §10.4 r39: known-id clients send ci-unlock in later rounds when no lock pending" {
    const Case = struct { has_client_id: bool, lock_pending: bool, round: u64, want: bool };
    const cases = [_]Case{
        .{ .has_client_id = true, .lock_pending = false, .round = 3, .want = true },
        .{ .has_client_id = true, .lock_pending = true, .round = 3, .want = false },
        .{ .has_client_id = false, .lock_pending = false, .round = 3, .want = false },
        .{ .has_client_id = true, .lock_pending = false, .round = 0, .want = false },
    };
    for (cases) |c| {
        try std.testing.expectEqual(c.want, clientSendsCiUnlock(c.has_client_id, c.lock_pending, c.round));
    }
}

test "sync §10.5 r40: pragma send-private is client-to-server, arg-less" {
    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("pragma send-private", try writeFlagPragma(.@"send-private", &buf));
    try std.testing.expectEqual(PragmaDirection.client_to_server, pragmaDirection(.@"send-private"));
}

test "sync §10.5 r41: server enables private sync only with the Private (x) capability" {
    try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(true));
    try std.testing.expectEqual(SendPrivateDecision.reject, serverSendPrivateDecision(false));
}

test "sync §10.5 r42: unauthorized send-private emits a non-reset error card (rejection)" {
    try std.testing.expectEqualStrings("not authorized to sync private content", send_private_unauthorized_text);

    var buf: [128]u8 = undefined;
    try std.testing.expectEqualStrings(
        "error not\\sauthorized\\sto\\ssync\\sprivate\\scontent",
        writeSignalCard(.err, send_private_unauthorized_text, &buf),
    );

    try std.testing.expectEqual(ErrorReset.append, serverErrorReset(.unauthorized_send_private));
}

test "sync §10.5 r43: send-private repeats every private round; the decision is idempotent" {
    try std.testing.expectEqual(PragmaSchedule.every_private_round, pragmaSchedule(.@"send-private").?);
    try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(true));
    try std.testing.expectEqual(SendPrivateDecision.enable, serverSendPrivateDecision(true));
}

test "sync §10.5 r44: pragma send-catalog requests every igot (full resync)" {
    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("pragma send-catalog", try writeFlagPragma(.@"send-catalog", &buf));
}

test "sync §10.5 r45: pragma req-clusters requests cluster igot cards" {
    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("pragma req-clusters", try writeFlagPragma(.@"req-clusters", &buf));
}

test "sync §10.5 r46: pragma req-links requests link-graph data" {
    var buf: [32]u8 = undefined;
    try std.testing.expectEqualStrings("pragma req-links", try writeFlagPragma(.@"req-links", &buf));
}

test "sync §10.5 r47: pragma client-url communicates the client's canonical URL" {
    var buf: [64]u8 = undefined;
    try std.testing.expectEqualStrings("pragma client-url http://x/y", writeClientUrl("http://x/y", &buf));

    var write_buf: [64]u8 = undefined;
    const written = writeClientUrl("a b", &write_buf);
    try std.testing.expectEqualStrings("pragma client-url a\\sb", written);

    var decode_buf: [16]u8 = undefined;
    const parsed = parseClientUrl(tokenizeCard(written), &decode_buf).?;
    try std.testing.expectEqualStrings("a b", parsed);
}

test "sync §10.5 r48: client-url and req-links are sent once, before the round loop" {
    try std.testing.expectEqual(PragmaSchedule.once_pre_loop, pragmaSchedule(.@"client-url").?);
    try std.testing.expectEqual(PragmaSchedule.once_pre_loop, pragmaSchedule(.@"req-links").?);
}

test "sync §10.5 r49: pragma avoid-delta-manifests is server-to-client" {
    try std.testing.expectEqual(PragmaDirection.server_to_client, pragmaDirection(.@"avoid-delta-manifests"));
    var buf: [40]u8 = undefined;
    try std.testing.expectEqualStrings("pragma avoid-delta-manifests", try writeFlagPragma(.@"avoid-delta-manifests", &buf));
}

test "sync §10.5: arg-less pragma set matches the ABNF" {
    const Case = struct { name: PragmaName, argless: bool };
    const cases = [_]Case{
        .{ .name = .@"send-private", .argless = true },
        .{ .name = .@"send-catalog", .argless = true },
        .{ .name = .@"req-links", .argless = true },
        .{ .name = .@"req-clusters", .argless = true },
        .{ .name = .@"avoid-delta-manifests", .argless = true },
        .{ .name = .@"uv-push-ok", .argless = true },
        .{ .name = .@"uv-pull-only", .argless = true },
        .{ .name = .@"client-version", .argless = false },
        .{ .name = .@"server-version", .argless = false },
        .{ .name = .@"uv-hash", .argless = false },
        .{ .name = .@"ci-lock", .argless = false },
        .{ .name = .@"ci-lock-fail", .argless = false },
        .{ .name = .@"ci-unlock", .argless = false },
        .{ .name = .@"client-url", .argless = false },
    };
    try std.testing.expectEqual(@as(usize, 14), cases.len);
    for (cases) |c| {
        try std.testing.expectEqual(c.argless, pragmaIsArgless(c.name));
    }
}

// ---------------------------------------------------------------------------
// Section 11 — Error and Message Cards
// ---------------------------------------------------------------------------

/// §11.1 L2546-2547 (worked example, App. A.2) — the exact decoded text
/// emitted when the SHA3-256 peer-version gate (§7.6) fires.
pub const peer_version_error_text = "Fossil version 2.0 or later required.";

/// §11.1/§11.2 — an error card is a fatal protocol signal (outside clone
/// round 0); a message card is a non-fatal advisory. TEXT is encoded
/// identically on both (r2, r10), so one writer/parser pair serves
/// both — only the leading operator word differs.
pub const SignalKind = enum { err, message };

/// §11.1 r1/r2 L2500-2508, §11.2 r9/r10 L2552-2556 — write `error TEXT`
/// or `message TEXT`, TEXT fossilize-encoded in place: the operator word
/// plus SP goes straight into `out`, then `artifact.encodeEscapes` fills
/// the remainder — one buffer, no scratch. Like `writeClientUrl`, this
/// never touches `std.fmt.bufPrint` and cannot fail, so it returns
/// `[]u8` directly rather than an error union nothing ever populates.
pub fn writeSignalCard(kind: SignalKind, text: []const u8, out: []u8) []u8 {
    const op: []const u8 = switch (kind) {
        .err => "error ",
        .message => "message ",
    };
    std.debug.assert(out.len >= op.len + 2 * text.len);
    @memcpy(out[0..op.len], op);
    const encoded = artifact.encodeEscapes(text, out[op.len..]);
    return out[0 .. op.len + encoded.len];
}

/// §11.1 r2, §11.2 r10 — read TEXT back off a dispatched `error`/
/// `message` card: `items[1]` decoded. Null on a card with no TEXT
/// token (negative space); never indexes past `tokens.len`.
pub fn parseSignalText(tokens: Tokens, out: []u8) ?[]const u8 {
    std.debug.assert(tokens.len >= 1);
    if (tokens.len < 2) return null;
    return artifact.decodeEscapes(tokens.items[1], out);
}

/// §11.1 r3/r7 — the client's disposition of a received `error` card.
pub const ClientErrorAction = enum { discard, terminate };

/// §11.1 r3 L2510-2513 (outside clone round 0: terminate) / r7 L2540-2542
/// (first round of a clone: silently discard) / r6 L2537-2538 (no HTTP-
/// status input in scope — the signal lives wholly in the message body).
/// Exhaustive over `ClientState`: only `clone_round0` discards.
pub fn clientOnErrorCard(state: ClientState) ClientErrorAction {
    return switch (state) {
        .clone_round0 => .discard,
        .init, .exchange, .terminated, .aborted => .terminate,
    };
}

/// §11.1 L2525-2536 — the fatal-error sites the RFC distinguishes by
/// whether the server resets prior partial output before emitting the
/// error card.
pub const ErrorSite = enum {
    wrong_project,
    auth_failure,
    duplicate_login,
    unknown_operator,
    peer_version_gate,
    unauthorized_send_private,
    private_card,
};

/// §11.1 L2525-2536 — reset (discard prior partial output) vs. append
/// (extend the already-built response) before emitting the error card.
pub const ErrorReset = enum { reset, append };

/// §11.1 r4 L2525-2529 (worked example: wrong project, auth failure,
/// duplicate login, unknown operator -> reset) vs. r5 L2532-2536 (peer-
/// version gate, unauthorized send-private, private card -> append).
pub fn serverErrorReset(site: ErrorSite) ErrorReset {
    return switch (site) {
        .wrong_project, .auth_failure, .duplicate_login, .unknown_operator => .reset,
        .peer_version_gate, .unauthorized_send_private, .private_card => .append,
    };
}

/// §11.2 — the client's disposition of a received `message` card.
pub const MessageAction = enum { pull_only_silent, display_optional };

/// §11.2 r12 L2560-2562, ambiguities.md §11.2 Reading A (settled) — the
/// glob `pull only *` carries its separating space; the trigger is the
/// literal 10-byte prefix `"pull only "`, so a bare `"pull only"` (no
/// trailing content) does not match.
pub fn matchesPullOnly(decoded: []const u8) bool {
    return std.mem.startsWith(u8, decoded, "pull only ");
}

/// §11.2 r11/r12/r13 — a message card never terminates the session
/// (r11); text matching the pull-only pattern is handled silently
/// (r12); everything else is optionally displayable (r13).
pub fn clientOnMessageCard(decoded: []const u8) MessageAction {
    return if (matchesPullOnly(decoded)) .pull_only_silent else .display_optional;
}

test "sync §11 r1: error card is 'error TEXT'" {
    var buf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("error abc", writeSignalCard(.err, "abc", &buf));
    try std.testing.expectEqual(Operator.@"error", classifyCard("error abc").operator.?);
}

test "sync §11 r2: TEXT is fossilize-encoded (spaces as \\s); a receiver decodes it" {
    var buf: [16]u8 = undefined;
    const written = writeSignalCard(.err, "a b", &buf);
    try std.testing.expectEqualStrings("error a\\sb", written);

    var decode_buf: [8]u8 = undefined;
    const decoded = parseSignalText(tokenizeCard(written), &decode_buf).?;
    try std.testing.expectEqualStrings("a b", decoded);
}

test "sync §11 r3: error card outside clone round 0 terminates the sync loop (rejection)" {
    try std.testing.expectEqual(ClientErrorAction.terminate, clientOnErrorCard(.exchange));
}

test "sync §11 r4: fatal-error sites that reset prior output (worked example)" {
    const sites = [_]ErrorSite{ .wrong_project, .auth_failure, .duplicate_login, .unknown_operator };
    for (sites) |s| try std.testing.expectEqual(ErrorReset.reset, serverErrorReset(s));
}

test "sync §11 r5: append-only error sites keep prior response cards" {
    const sites = [_]ErrorSite{ .peer_version_gate, .unauthorized_send_private, .private_card };
    for (sites) |s| try std.testing.expectEqual(ErrorReset.append, serverErrorReset(s));
}

test "sync §11 r6: the error signal lives wholly in the body, not an HTTP status" {
    // clientOnErrorCard's only input is ClientState -- no status parameter
    // is in scope, matching "HTTP response status is 200" (L2537-2538).
    try std.testing.expectEqual(ClientErrorAction.terminate, clientOnErrorCard(.exchange));
    try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0));
}

test "sync §11 r7: the first round of a clone silently discards an error card (worked example)" {
    try std.testing.expectEqual(ClientErrorAction.discard, clientOnErrorCard(.clone_round0));
}

test "sync §11 r8: SHA3 peer-version gate text is 'Fossil version 2.0 or later required.' (worked example)" {
    try std.testing.expectEqualStrings("Fossil version 2.0 or later required.", peer_version_error_text);

    var buf: [96]u8 = undefined;
    try std.testing.expectEqualStrings(
        "error Fossil\\sversion\\s2.0\\sor\\slater\\srequired.",
        writeSignalCard(.err, peer_version_error_text, &buf),
    );
}

test "sync §11 r9: message card is 'message TEXT', non-fatal" {
    var buf: [16]u8 = undefined;
    try std.testing.expectEqualStrings("message hi", writeSignalCard(.message, "hi", &buf));
    try std.testing.expectEqual(Operator.message, classifyCard("message hi").operator.?);
}

test "sync §11 r10: message TEXT is encoded identically to an error card" {
    var buf: [16]u8 = undefined;
    const written = writeSignalCard(.message, "a b", &buf);
    try std.testing.expectEqualStrings("message a\\sb", written);

    var decode_buf: [8]u8 = undefined;
    const decoded = parseSignalText(tokenizeCard(written), &decode_buf).?;
    try std.testing.expectEqualStrings("a b", decoded);
}

test "sync §11 r11: a message card never terminates the session" {
    try std.testing.expectEqual(MessageAction.display_optional, clientOnMessageCard("hello"));
}

test "sync §11 r12: 'pull only *' silently clears push intent (rejection/silence)" {
    try std.testing.expectEqual(MessageAction.pull_only_silent, clientOnMessageCard("pull only clone"));
    try std.testing.expect(matchesPullOnly("pull only clone"));
    try std.testing.expect(!matchesPullOnly("pull only"));
}

test "sync §11 r13: other message text may be displayed to the user (should)" {
    try std.testing.expectEqual(MessageAction.display_optional, clientOnMessageCard("hello world"));
    try std.testing.expect(!matchesPullOnly("pull all now"));
    try std.testing.expectEqual(MessageAction.display_optional, clientOnMessageCard("pull all now"));
}

test "sync §11 fuzz: writeSignalCard/parseSignalText round-trip never crashes" {
    // Seeded deterministic fuzz over untrusted wire input:
    // arbitrary TEXT bytes survive write -> tokenize -> parse unchanged,
    // and the encoded TEXT never splits into a second token, because every
    // Fossil-whitespace/LF octet is a member of artifact's Table 1 escape
    // set (proven by the artifact §4.3 fuzz test) and so never survives
    // encoding unescaped.
    var prng = std.Random.DefaultPrng.init(0x5E9A_11CA);
    const rand = prng.random();
    var round: usize = 0;
    const max_len: usize = 32;
    var text_buf: [max_len]u8 = undefined;
    var card_buf: [8 + 2 * max_len]u8 = undefined;
    var decode_buf: [2 * max_len]u8 = undefined;
    while (round < 300) : (round += 1) {
        const len = rand.uintLessThan(usize, max_len + 1);
        for (text_buf[0..len]) |*b| b.* = rand.int(u8);
        const text = text_buf[0..len];
        const kind: SignalKind = if (rand.boolean()) .err else .message;
        const written = writeSignalCard(kind, text, &card_buf);

        const tokens = tokenizeCard(written);
        try std.testing.expectEqual(@as(usize, if (len == 0) 1 else 2), tokens.len);

        const decoded = parseSignalText(tokens, &decode_buf);
        if (len == 0) {
            try std.testing.expect(decoded == null);
        } else {
            try std.testing.expectEqualSlices(u8, text, decoded.?);
        }
    }
}