Artifact edcbe97b8989b81ce35d16807246088d03e3856670662b18172ac52cb5c1d6dc:
- File
src/cmd/records.zig
— part of check-in
[10a1236a7a]
at
2026-08-17 19:28:13
on branch trunk
— -R: say which mistake was made, not 'no checkout here' (#97)
The discovery message tells the operator to name the repository with -R. For a -R failure that advises supplying the flag already supplied, and it reads the same whether the path was mistyped or simply holds no repository -- so a stale path in a script looked like a bug in the verb. Reported against tarball; every -R verb had it. One reporter, routed through all nine resolveRoot call sites; the no-flag discovery message is unchanged. (user: dmestas, size: 32578) [annotate] [blame] [check-ins using]
//! `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 { main.reportNoRoot(streams, io, verb, amb.repo_override); 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 { main.reportNoRoot(streams, io, "forum", amb.repo_override); 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]; }