Skip to content
zit Artifact [f8d6eafe85]
Login

Artifact [f8d6eafe85]

Artifact f8d6eafe8560b22831836dd73e6e2fb352427fb124361c2b7b570e9e73bf9d74:


//! 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");

/// 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;
}

/// §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 !(op == .clone and round == 0);
}

/// §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);
}