Skip to content
zit Artifact [c405b4fa75]
Login

Artifact [c405b4fa75]

Artifact c405b4fa75818a3c66c0a61475c79ee169e40f8d5a2e24c07a44166f98156958:


//! `zit wiki` / `zit ticket` — record wiki and ticket-change artifacts (#71).
//!
//! MACHINE VERBS. Quarry's subprocess boundary (quarry RFC 0001 §2) makes zit
//! the sole writer of `.zit` bytes, so quarry's wiki and ticket pages call
//! these instead of constructing artifact bytes themselves. That consumer
//! shapes every contract here: stdout carries ONLY the machine-read result
//! (the artifact hash, plus the minted ticket id for `ticket new`), human
//! diagnostics go to stderr, and the exit codes are exact — 0 recorded,
//! 1 user error, 2 usage. It also shapes their VISIBILITY: neither verb is
//! listed in the human `zit help` command list, deliberately — they are
//! plumbing, and a person browsing the porcelain should not meet them there.
//! Each still answers `zit wiki --help` / `zit help wiki` in full.
//!
//! Both operate on a bare store: `-R <repo>` needs no working tree, because
//! quarry serves bare stores. The write lands through `cli.openStoreAppend`,
//! so two racing quarry requests serialize on the store's own lock (#59) —
//! last-write-wins per fold semantics, never a torn log.
//!
//! The bytes come from `artifact.create`, the one canonical writer, and are
//! round-tripped through `artifact.parse` before storing IN EVERY BUILD MODE:
//! commit's parse self-check is safety-mode-only because its inputs are the
//! checkout's own state, but these inputs arrive from another process, and a
//! malformed artifact minted here would be quarry's data-loss bug to discover.

const std = @import("std");
const zit = @import("zit");
const cli = @import("zit").cli;
const artifact = @import("zit").artifact;
const logstore = @import("zit").logstore;
const storage = @import("zit").storage;

const main = @import("../main.zig");
const stateOom = main.stateOom;
const not_a_repo_msg = main.not_a_repo_msg;
const wiki_help = main.wiki_help;
const ticket_help = main.ticket_help;
const forum_help = main.forum_help;
const technote_help = main.technote_help;
const digest_len = main.digest_len;
const hexToDigest = main.hexToDigest;

/// One bound for both bodies: a wiki page or ticket change beyond this is not
/// authoring, it is a bulk import that should arrive some other way.
const max_body_bytes: usize = 16 * 1024 * 1024;

/// Upper bound on `technote add --tag` repetitions (TigerStyle: every loop and
/// buffer is finite by inspection). Fossil's own technote editor takes one
/// space-separated field, so a note wearing dozens of tags is already past any
/// real use; the cap exists so the T-run buffer is a fixed array.
const max_technote_tags: usize = 64;

pub fn runWiki(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    var title: ?[]const u8 = null;
    var user: ?[]const u8 = null;
    var mimetype: ?[]const u8 = null;
    var saw_commit = false;
    var i: usize = 0;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(wiki_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, tok, "--user")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "wiki", .unknown_option, "--user");
            user = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--mimetype")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "wiki", .unknown_option, "--mimetype");
            mimetype = verb_args[i];
        } else if (tok.len > 1 and tok[0] == '-') {
            return cli.usageError(streams, "wiki", .unknown_option, tok);
        } else if (!saw_commit) {
            if (!std.mem.eql(u8, tok, "commit")) return cli.usageError(streams, "wiki", .unknown_option, tok);
            saw_commit = true;
        } else if (title == null) {
            title = tok;
        } else {
            return cli.usageError(streams, "wiki", .extra_argument, tok);
        }
    }
    if (!saw_commit or title == null) {
        streams.err.writeAll("zit wiki: usage: zit wiki commit <title> [-R repo] [--user NAME] [--mimetype TYPE] < body\n") catch {};
        return .usage;
    }
    if (title.?.len == 0) {
        streams.err.writeAll("zit wiki: the title must not be empty\n") catch {};
        return .failure;
    }
    const author = resolveAuthor(user, amb) orelse {
        streams.err.writeAll("zit wiki: --user is required (no checkout default here)\n") catch {};
        return .failure;
    };

    // The body IS stdin, whole: quarry pipes the page and closes the write
    // end, exactly the read-to-EOF contract this states.
    const stdin = std.Io.File.stdin();
    var read_buf: [64 * 1024]u8 = undefined;
    var reader = stdin.readerStreaming(io, &read_buf);
    const body = reader.interface.allocRemaining(gpa, .limited(max_body_bytes)) catch {
        streams.err.writeAll("zit wiki: cannot read the body from stdin\n") catch {};
        return .failure;
    };
    defer gpa.free(body);

    var ts_buf: [23]u8 = undefined;
    const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .wiki,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = .{ .title = title.? },
        .n = if (mimetype) |mt| .{ .mimetype = mt } else null,
        .r = null,
        .u = .{ .login = author },
        .z = null,
        .w = body,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .s = &.{},
        .t = &.{},
    };
    defer m.deinit();
    return recordAndPrint(gpa, io, arena, streams, amb, "wiki", &m, null);
}

pub fn runTicket(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    const Mode = enum { new, set };
    var mode: ?Mode = null;
    var ticket_id: ?[]const u8 = null;
    var user: ?[]const u8 = null;
    var fields: std.ArrayList(artifact.JCard) = .empty;
    defer fields.deinit(gpa);
    var i: usize = 0;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(ticket_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, tok, "--user")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "ticket", .unknown_option, "--user");
            user = verb_args[i];
        } else if (tok.len > 1 and tok[0] == '-') {
            return cli.usageError(streams, "ticket", .unknown_option, tok);
        } else if (mode == null) {
            if (std.mem.eql(u8, tok, "new")) {
                mode = .new;
            } else if (std.mem.eql(u8, tok, "set")) {
                mode = .set;
            } else {
                return cli.usageError(streams, "ticket", .unknown_option, tok);
            }
        } else if (mode == .set and ticket_id == null) {
            ticket_id = tok;
        } else if (std.mem.indexOfScalar(u8, tok, '=')) |eq| {
            const field = tok[0..eq];
            const value = tok[eq + 1 ..];
            if (field.len == 0) {
                streams.err.print("zit ticket: empty field name in '{s}'\n", .{tok}) catch {};
                return .failure;
            }
            fields.append(gpa, .{ .field = field, .value = value }) catch return stateOom(streams, "ticket");
        } else {
            return cli.usageError(streams, "ticket", .extra_argument, tok);
        }
    }
    const chosen = mode orelse {
        streams.err.writeAll("zit ticket: usage: zit ticket new|set <ticket-id> [-R repo] [--user NAME] field=value ...\n") catch {};
        return .usage;
    };
    if (chosen == .set and ticket_id == null) {
        streams.err.writeAll("zit ticket: set needs a <ticket-id>\n") catch {};
        return .usage;
    }
    if (fields.items.len == 0) {
        streams.err.writeAll("zit ticket: at least one field=value is required\n") catch {};
        return .failure;
    }
    const author = resolveAuthor(user, amb) orelse {
        streams.err.writeAll("zit ticket: --user is required (no checkout default here)\n") catch {};
        return .failure;
    };

    // §4.5.2: J cards strictly ascend by field name. Sorting here is the
    // service; a DUPLICATE field is the caller's ambiguity ("which value
    // wins?") and is refused rather than resolved by position.
    std.mem.sort(artifact.JCard, fields.items, {}, jLessThan);
    var fi: usize = 1;
    while (fi < fields.items.len) : (fi += 1) {
        if (std.mem.eql(u8, fields.items[fi - 1].field, fields.items[fi].field)) {
            streams.err.print("zit ticket: field '{s}' given twice\n", .{fields.items[fi].field}) catch {};
            return .failure;
        }
    }

    // The id: minted for `new` (20 random bytes as 40-hex, fossil's shape),
    // validated and REQUIRED KNOWN for `set` — a typo'd id must not silently
    // open a second ticket.
    var id_buf: [40]u8 = undefined;
    const id: []const u8 = switch (chosen) {
        .new => blk: {
            var raw: [20]u8 = undefined;
            io.randomSecure(&raw) catch {
                streams.err.writeAll("zit ticket: no entropy source for a fresh ticket id\n") catch {};
                return .failure;
            };
            const hex = std.fmt.bytesToHex(raw, .lower);
            @memcpy(&id_buf, &hex);
            break :blk &id_buf;
        },
        .set => ticket_id.?,
    };
    if (id.len != 40 or !isLowerHex(id)) {
        streams.err.print("zit ticket: '{s}' is not a ticket id (40 lowercase hex)\n", .{id}) catch {};
        return .failure;
    }

    var ts_buf: [23]u8 = undefined;
    const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .ticket,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = ts },
        .e = null,
        .g = null,
        .h = null,
        .i = null,
        .k = .{ .ticket_id = id },
        .l = null,
        .n = null,
        .r = null,
        .u = .{ .login = author },
        .z = null,
        .w = null,
        .f = &.{},
        .j = fields.items,
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .s = &.{},
        .t = &.{},
    };
    defer m.deinit();
    return recordAndPrint(gpa, io, arena, streams, amb, "ticket", &m, if (chosen == .new) id else null);
}

fn jLessThan(_: void, a: artifact.JCard, b: artifact.JCard) bool {
    return artifact.compareBytes(a.field, b.field) == .lt;
}

fn isLowerHex(s: []const u8) bool {
    for (s) |c| {
        const ok = (c >= '0' and c <= '9') or (c >= 'a' and c <= 'f');
        if (!ok) return false;
    }
    return true;
}

/// The author: `--user` wins, else the ambient OS user exactly as `commit`
/// defaults it. Empty is a refusal either way — real-world stores carry
/// manifests with empty U tokens from other writers' bugs, and this writer
/// will not mint more.
fn resolveAuthor(user: ?[]const u8, amb: cli.Ambient) ?[]const u8 {
    const chosen = user orelse (amb.user_env orelse return null);
    if (chosen.len == 0) return null;
    return chosen;
}

/// Serialize, self-check, store under the lock, report. `mint` is the fresh
/// ticket id `ticket new` prepends to its stdout line; everything else prints
/// the artifact hash alone. `verb` names the diagnostics.
fn recordAndPrint(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    verb: []const u8,
    m: *artifact.Manifest,
    mint: ?[]const u8,
) cli.Exit {
    const bytes = artifact.create(m, gpa) catch return stateOom(streams, verb);
    defer gpa.free(bytes);

    // Round-trip in EVERY build mode (module doc says why). classify() ran
    // inside parse; the kind equality is the whole claim.
    var check = artifact.parse(gpa, bytes) catch {
        streams.err.print("zit {s}: refusing to store a malformed artifact (a zit bug — please report the invocation)\n", .{verb}) catch {};
        return .failure;
    };
    const kind_ok = check.kind == m.kind;
    check.deinit();
    if (!kind_ok) {
        streams.err.print("zit {s}: refusing to store a malformed artifact (a zit bug — please report the invocation)\n", .{verb}) catch {};
        return .failure;
    }

    const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return stateOom(streams, verb)) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return .failure;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);

    // `set` only exists for a ticket that exists: scan the store for any
    // artifact carrying this K card before appending. A full-store read, and
    // deliberately so — the store has no ticket index yet, and a wrong
    // "unknown" that silently minted a SECOND ticket would be worse than a
    // slow check. Quarry-scale stores read in milliseconds.
    // (For `new` the freshly minted id is absent by construction.)
    const knowledge_needed = mint == null and m.kind == .ticket;

    var file = cli.openStoreAppend(io, root_dir) catch {
        streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
        return .failure;
    };
    defer file.close(io);
    const st = file.stat(io) catch {
        streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
        return .failure;
    };
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = logstore.LogStore.open(gpa, fs.storageFile(), 0) catch {
        streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
        return .failure;
    };
    defer log.deinit(gpa);

    if (knowledge_needed) {
        const known = ticketKnown(gpa, &log, m.k.?.ticket_id) catch return stateOom(streams, verb);
        if (!known) {
            streams.err.print("zit ticket: unknown ticket '{s}'\n", .{m.k.?.ticket_id}) catch {};
            return .failure;
        }
    }

    var digest: [32]u8 = undefined;
    std.crypto.hash.sha3.Sha3_256.hash(bytes, &digest, .{});
    var name_buf: [64]u8 = undefined;
    const name = artifact.artifactName(.sha3_256, bytes, &name_buf);
    _ = log.putArtifact(&digest, bytes, null) catch {
        streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
        return .failure;
    };
    log.commit() catch {
        streams.err.print("zit {s}: cannot write the repository\n", .{verb}) catch {};
        return .failure;
    };

    if (mint) |id| {
        streams.out.print("{s} {s}\n", .{ id, name }) catch {};
    } else {
        streams.out.print("{s}\n", .{name}) catch {};
    }
    return .ok;
}

/// Whether any stored artifact carries `K <id>`. The canonical writer puts
/// exactly one space and a trailing LF around the id and a D card always
/// precedes K, so the byte pattern `\nK <id>\n` is exact — no parse needed.
fn ticketKnown(gpa: std.mem.Allocator, log: *logstore.LogStore, id: []const u8) error{OutOfMemory}!bool {
    std.debug.assert(id.len == 40);
    var needle_buf: [44]u8 = undefined;
    needle_buf[0] = '\n';
    needle_buf[1] = 'K';
    needle_buf[2] = ' ';
    @memcpy(needle_buf[3..43], id);
    needle_buf[43] = '\n';
    const needle = needle_buf[0..44];
    var rid: logstore.Rid = 1;
    const count: logstore.Rid = @intCast(log.entries.items.len);
    while (rid <= count) : (rid += 1) {
        const bytes = (log.content(gpa, rid) catch |e| switch (e) {
            error.OutOfMemory => return error.OutOfMemory,
            // A damaged or phantom entry cannot be the ticket's birth; skip
            // it rather than failing the write — verify owns damage reports.
            else => continue,
        }) orelse continue;
        defer gpa.free(bytes);
        if (std.mem.indexOf(u8, bytes, needle) != null) return true;
    }
    return false;
}

pub fn runForum(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    const Mode = enum { post, reply };
    var mode: ?Mode = null;
    var reply_to: ?[]const u8 = null;
    var user: ?[]const u8 = null;
    var title: ?[]const u8 = null;
    var mimetype: ?[]const u8 = null;
    var i: usize = 0;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(forum_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, tok, "--user")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "forum", .unknown_option, "--user");
            user = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--mimetype")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "forum", .unknown_option, "--mimetype");
            mimetype = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--title")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "forum", .unknown_option, "--title");
            title = verb_args[i];
        } else if (tok.len > 1 and tok[0] == '-') {
            return cli.usageError(streams, "forum", .unknown_option, tok);
        } else if (mode == null) {
            if (std.mem.eql(u8, tok, "post")) {
                mode = .post;
            } else if (std.mem.eql(u8, tok, "reply")) {
                mode = .reply;
            } else {
                return cli.usageError(streams, "forum", .unknown_option, tok);
            }
        } else if (mode == .reply and reply_to == null) {
            reply_to = tok;
        } else {
            return cli.usageError(streams, "forum", .extra_argument, tok);
        }
    }
    const chosen = mode orelse {
        streams.err.writeAll("zit forum: usage: zit forum post|reply <post-hash> [-R repo] [--user NAME] [--title TITLE] [--mimetype TYPE] < body\n") catch {};
        return .usage;
    };
    if (chosen == .reply and reply_to == null) {
        streams.err.writeAll("zit forum: reply needs a <post-hash>\n") catch {};
        return .usage;
    }
    if (chosen == .reply and title != null) {
        // §5.4 — a forum artifact carries H XOR I: a thread has one title, on
        // its root. Refused rather than dropped, so the caller learns it.
        streams.err.writeAll("zit forum: --title belongs to 'post'; a reply inherits its thread's title\n") catch {};
        return .usage;
    }
    const author = resolveAuthor(user, amb) orelse {
        streams.err.writeAll("zit forum: --user is required (no checkout default here)\n") catch {};
        return .failure;
    };

    const stdin = std.Io.File.stdin();
    var read_buf: [64 * 1024]u8 = undefined;
    var reader = stdin.readerStreaming(io, &read_buf);
    const body = reader.interface.allocRemaining(gpa, .limited(max_body_bytes)) catch {
        streams.err.writeAll("zit forum: cannot read the body from stdin\n") catch {};
        return .failure;
    };
    defer gpa.free(body);
    if (body.len == 0) {
        streams.err.writeAll("zit forum: the body must not be empty\n") catch {};
        return .failure;
    }

    // A reply names its thread: G is the thread ROOT (the replied-to post's
    // own G when it has one, else that post itself), I is the replied-to
    // post. Resolved against the store before anything is built, so an
    // unknown hash is a clean exit-1 refusal.
    var root_hash_buf: [64]u8 = undefined;
    var g_hash: ?[]const u8 = null;
    if (chosen == .reply) {
        g_hash = resolveThreadRoot(gpa, io, arena, streams, amb, reply_to.?, &root_hash_buf) orelse return .failure;
    }

    var ts_buf: [23]u8 = undefined;
    const ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .forum,
        .a = null,
        .b = null,
        .c = null,
        .d = .{ .timestamp = ts },
        .e = null,
        .g = if (g_hash) |g| .{ .hash = g } else null,
        .h = if (title) |t| .{ .title = t } else null,
        .i = if (chosen == .reply) .{ .hash = reply_to.? } else null,
        .k = null,
        .l = null,
        .n = if (mimetype) |mt| .{ .mimetype = mt } else null,
        .r = null,
        .u = .{ .login = author },
        .z = null,
        .w = body,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .s = &.{},
        .t = &.{},
    };
    defer m.deinit();
    return recordAndPrint(gpa, io, arena, streams, amb, "forum", &m, null);
}

pub fn runTechnote(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    verb_args: []const []const u8,
    streams: cli.Streams,
    amb: cli.Ambient,
) cli.Exit {
    var saw_add = false;
    var user: ?[]const u8 = null;
    var mimetype: ?[]const u8 = null;
    var event_id: ?[]const u8 = null;
    var when: ?[]const u8 = null;
    var comment: ?[]const u8 = null;
    var tag_names: [max_technote_tags][]const u8 = undefined;
    var tag_count: usize = 0;
    var i: usize = 0;
    while (i < verb_args.len) : (i += 1) {
        const tok = verb_args[i];
        if (std.mem.eql(u8, tok, "-h") or std.mem.eql(u8, tok, "--help")) {
            streams.out.writeAll(technote_help) catch {};
            return .ok;
        } else if (std.mem.eql(u8, tok, "--user")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--user");
            user = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--comment")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--comment");
            comment = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--tag")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--tag");
            if (tag_count == max_technote_tags) {
                streams.err.print("zit technote: at most {d} --tag values\n", .{max_technote_tags}) catch {};
                return .usage;
            }
            tag_names[tag_count] = verb_args[i];
            tag_count += 1;
        } else if (std.mem.eql(u8, tok, "--mimetype")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--mimetype");
            mimetype = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--event")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--event");
            event_id = verb_args[i];
        } else if (std.mem.eql(u8, tok, "--time")) {
            i += 1;
            if (i >= verb_args.len) return cli.usageError(streams, "technote", .unknown_option, "--time");
            when = verb_args[i];
        } else if (tok.len > 1 and tok[0] == '-') {
            return cli.usageError(streams, "technote", .unknown_option, tok);
        } else if (!saw_add) {
            if (!std.mem.eql(u8, tok, "add")) return cli.usageError(streams, "technote", .unknown_option, tok);
            saw_add = true;
        } else {
            return cli.usageError(streams, "technote", .extra_argument, tok);
        }
    }
    if (!saw_add) {
        streams.err.writeAll("zit technote: usage: zit technote add [-R repo] [--user NAME] [--comment TEXT] [--tag NAME] [--mimetype TYPE] [--event EVENT-ID] [--time TIMESTAMP] < body\n") catch {};
        return .usage;
    }
    const author = resolveAuthor(user, amb) orelse {
        streams.err.writeAll("zit technote: --user is required (no checkout default here)\n") catch {};
        return .failure;
    };
    // The C card is what a timeline renders as the note's title, and no later
    // verb can attach one — so an empty value is refused here rather than
    // minting a note that is titled with the empty string forever.
    if (comment) |c| {
        if (c.len == 0) {
            streams.err.writeAll("zit technote: --comment must not be empty\n") catch {};
            return .failure;
        }
    }
    for (tag_names[0..tag_count]) |name| {
        if (!technoteTagUsable(name)) {
            streams.err.print("zit technote: '{s}' is not usable as a tag (empty, or contains whitespace)\n", .{name}) catch {};
            return .failure;
        }
    }
    const tag_cards = composeTechnoteTags(arena, tag_names[0..tag_count]) catch |e| switch (e) {
        error.OutOfMemory => {
            streams.err.writeAll("zit technote: out of memory\n") catch {};
            return .failure;
        },
        error.DuplicateTag => {
            streams.err.writeAll("zit technote: the same --tag was given twice\n") catch {};
            return .failure;
        },
    };

    // The event id: minted fresh, or the caller's (an edit of an existing
    // technote — latest wins at fold time, which #75's millisecond D cards
    // keep honest).
    var id_buf: [40]u8 = undefined;
    const id: []const u8 = event_id orelse blk: {
        var raw: [20]u8 = undefined;
        io.randomSecure(&raw) catch {
            streams.err.writeAll("zit technote: no entropy source for a fresh event id\n") catch {};
            return .failure;
        };
        const hex = std.fmt.bytesToHex(raw, .lower);
        @memcpy(&id_buf, &hex);
        break :blk &id_buf;
    };
    if (!((id.len == 40 or id.len == 64) and isLowerHex(id))) {
        streams.err.print("zit technote: '{s}' is not an event id (40 or 64 lowercase hex)\n", .{id}) catch {};
        return .failure;
    }

    const stdin = std.Io.File.stdin();
    var read_buf: [64 * 1024]u8 = undefined;
    var reader = stdin.readerStreaming(io, &read_buf);
    const body = reader.interface.allocRemaining(gpa, .limited(max_body_bytes)) catch {
        streams.err.writeAll("zit technote: cannot read the body from stdin\n") catch {};
        return .failure;
    };
    defer gpa.free(body);

    var ts_buf: [23]u8 = undefined;
    const d_ts = cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &ts_buf);
    var e_ts_buf: [23]u8 = undefined;
    const e_ts: []const u8 = if (when) |w| blk: {
        if (artifact.classifyTimestamp(w) == null) {
            streams.err.print("zit technote: '{s}' is not a timestamp the format accepts\n", .{w}) catch {};
            return .failure;
        }
        break :blk w;
    } else cli.formatWriterTimestampMs(amb.now_epoch, amb.now_ms, &e_ts_buf);

    var m: artifact.Manifest = .{
        .arena_state = std.heap.ArenaAllocator.init(gpa),
        .kind = .event,
        .a = null,
        .b = null,
        .c = if (comment) |c| .{ .comment = c } else null,
        .d = .{ .timestamp = d_ts },
        .e = .{ .timestamp = e_ts, .technote_id = id },
        .g = null,
        .h = null,
        .i = null,
        .k = null,
        .l = null,
        .n = if (mimetype) |mt| .{ .mimetype = mt } else null,
        .r = null,
        .u = .{ .login = author },
        .z = null,
        .w = body,
        .f = &.{},
        .j = &.{},
        .m = &.{},
        .p = &.{},
        .q = &.{},
        .s = &.{},
        .t = tag_cards,
    };
    defer m.deinit();
    return recordAndPrint(gpa, io, arena, streams, amb, "technote", &m, id);
}

/// Compose a technote's T run from `--tag` values: one `T +sym-NAME *` card
/// each, sorted ascending and rejecting duplicates.
///
/// The shape is fossil's, byte for byte — authoring a technote there with
/// `--technote-tags "alpha beta"` yields `T +sym-alpha *` / `T +sym-beta *`.
/// Singleton (`+`) because a technote has no descendants to propagate
/// through; self-referential target (null, written `*`) because §4.7.16's
/// target is "the artifact carrying the card" in that spelling; `sym-`
/// prefixed because these are names a technote is found by.
///
/// Sorted and deduplicated because §4.5.2 requires a strictly ascending run:
/// a repeated `--tag` would otherwise mint an artifact that fails its own
/// admission check. Pure; bounded by `max_technote_tags`.
fn composeTechnoteTags(
    arena: std.mem.Allocator,
    names: []const []const u8,
) error{ OutOfMemory, DuplicateTag }![]artifact.TCard {
    std.debug.assert(names.len <= max_technote_tags);
    const cards = try arena.alloc(artifact.TCard, names.len);
    for (names, 0..) |name, idx| {
        cards[idx] = .{
            .sign = .add,
            .name = try std.mem.concat(arena, u8, &.{ artifact.sym_tag_prefix, name }),
            .target = null,
            .value = null,
        };
    }
    std.mem.sort(artifact.TCard, cards, {}, artifact.tagTokenLess);
    var idx: usize = 1;
    while (idx < cards.len) : (idx += 1) {
        if (std.mem.eql(u8, cards[idx - 1].name, cards[idx].name)) return error.DuplicateTag;
    }
    return cards;
}

/// Whether `name` is usable as a technote tag: non-empty, and free of the
/// whitespace fossil's own space-separated tag field could never carry (it
/// would re-split such a name on read, so accepting it would author a note
/// fossil and zit disagree about).
fn technoteTagUsable(name: []const u8) bool {
    if (name.len == 0) return false;
    for (name) |c| {
        if (c == ' ' or c == '\t' or c == '\n' or c == '\r') return false;
    }
    return true;
}

/// The thread root a reply's G card names: the replied-to post's own G when
/// it carries one, else the post itself. Null (with the diagnostic printed)
/// when the hash is unknown or does not parse as a forum post — replying
/// into the void must be a refusal, not a dangling thread.
fn resolveThreadRoot(
    gpa: std.mem.Allocator,
    io: std.Io,
    arena: std.mem.Allocator,
    streams: cli.Streams,
    amb: cli.Ambient,
    post_hash: []const u8,
    out: *[64]u8,
) ?[]const u8 {
    const root = (cli.resolveRoot(arena, io, amb.repo_override) catch return null) orelse {
        streams.err.writeAll(not_a_repo_msg) catch {};
        return null;
    };
    var root_dir = root.dir;
    defer root_dir.close(io);
    var file = cli.openStoreRead(io, root_dir) catch {
        streams.err.writeAll("zit forum: cannot read the repository\n") catch {};
        return null;
    };
    defer file.close(io);
    const st = file.stat(io) catch {
        streams.err.writeAll("zit forum: cannot read the repository\n") catch {};
        return null;
    };
    var fs = storage.FileStorage.init(io, file, st.size);
    var log = logstore.LogStore.openReadOnly(gpa, fs.storageFile()) catch {
        streams.err.writeAll("zit forum: cannot read the repository\n") catch {};
        return null;
    };
    defer log.deinit(gpa);

    var digest_buf: [digest_len]u8 = undefined;
    const digest = hexToDigest(post_hash, &digest_buf) orelse {
        streams.err.print("zit forum: '{s}' is not an artifact hash\n", .{post_hash}) catch {};
        return null;
    };
    const rid = log.lookup(digest) orelse {
        streams.err.print("zit forum: unknown post '{s}'\n", .{post_hash}) catch {};
        return null;
    };
    const bytes = (log.content(gpa, rid) catch {
        streams.err.print("zit forum: unknown post '{s}'\n", .{post_hash}) catch {};
        return null;
    }) orelse {
        streams.err.print("zit forum: unknown post '{s}'\n", .{post_hash}) catch {};
        return null;
    };
    defer gpa.free(bytes);
    var parsed = artifact.parse(gpa, bytes) catch {
        streams.err.print("zit forum: '{s}' is not a forum post\n", .{post_hash}) catch {};
        return null;
    };
    defer parsed.deinit();
    if (parsed.kind != .forum) {
        streams.err.print("zit forum: '{s}' is not a forum post\n", .{post_hash}) catch {};
        return null;
    }
    const chosen: []const u8 = if (parsed.g) |g| g.hash else post_hash;
    std.debug.assert(chosen.len <= out.len);
    @memcpy(out[0..chosen.len], chosen);
    return out[0..chosen.len];
}