From c4cc763a35fef90622abfbe186d86a82beda5cf1 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:41:50 +0800 Subject: [PATCH 1/9] Restore `codedb serve --port` on Zig 0.16 Port the legacy HTTP endpoint (stubbed in 56ea465 / v0.2.578) to the new `std.Io.net` surface: bind 127.0.0.1 with `IpAddress.parse`/`listen`, accept in a loop, and hand each stream to a detached thread. The routes and JSON response shapes match the pre-0.16 implementation so existing clients don't need changes. Refs #307, #285 Co-Authored-By: Claude Sonnet 4.6 --- src/server.zig | 840 ++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 828 insertions(+), 12 deletions(-) diff --git a/src/server.zig b/src/server.zig index c246804..21febd2 100644 --- a/src/server.zig +++ b/src/server.zig @@ -1,15 +1,20 @@ -// codedb HTTP server — DISABLED on 0.16 migration (issue #285). +// codedb HTTP server — ported to Zig 0.16 std.Io.net (issue #285). // -// The legacy HTTP port server (`codedb serve --port`) used std.net which was -// removed in 0.16. MCP stdio mode is the primary entry, so this port is kept -// as a stub that returns an error. When 0.16's std.Io.net stabilizes enough -// to rebuild this, restore from `git show 0.2.578~0:src/server.zig`. +// This restores the port server that upstream stubbed out in commit 56ea465 +// (v0.2.578). The route set and JSON response shapes match the pre-0.16 +// implementation byte-for-byte; only the transport layer (listen/accept/read/ +// write/close) was rewritten against the new `std.Io.net` surface. MCP stdio +// remains the primary entry, but `codedb serve --port` is once again usable +// by external clients that speak HTTP/1.1. const std = @import("std"); +const cio = @import("cio.zig"); const Store = @import("store.zig").Store; const AgentRegistry = @import("agent.zig").AgentRegistry; const Explorer = @import("explore.zig").Explorer; +const snapshot_json = @import("snapshot_json.zig"); const watcher = @import("watcher.zig"); +const edit_mod = @import("edit.zig"); pub fn serve( io: std.Io, @@ -20,12 +25,823 @@ pub fn serve( queue: *watcher.EventQueue, port: u16, ) !void { - _ = io; - _ = allocator; - _ = store; - _ = agents; - _ = explorer; _ = queue; - _ = port; - return error.ServerDisabledOn016; + + const addr = std.Io.net.IpAddress.parse("127.0.0.1", port) catch unreachable; + var srv = try addr.listen(io, .{ + .reuse_address = true, + .mode = .stream, + .protocol = .tcp, + }); + defer srv.deinit(io); + + while (true) { + const stream = srv.accept(io) catch |err| switch (err) { + error.WouldBlock, error.ConnectionAborted => continue, + else => return err, + }; + const ctx = allocator.create(HandlerCtx) catch { + stream.close(io); + continue; + }; + ctx.* = .{ + .io = io, + .allocator = allocator, + .store = store, + .agents = agents, + .explorer = explorer, + .stream = stream, + }; + const t = std.Thread.spawn(.{}, handleThread, .{ctx}) catch { + stream.close(io); + allocator.destroy(ctx); + continue; + }; + t.detach(); + } +} + +const HandlerCtx = struct { + io: std.Io, + allocator: std.mem.Allocator, + store: *Store, + agents: *AgentRegistry, + explorer: *Explorer, + stream: std.Io.net.Stream, +}; + +fn handleThread(ctx: *HandlerCtx) void { + const io = ctx.io; + const allocator = ctx.allocator; + defer { + ctx.stream.close(io); + allocator.destroy(ctx); + } + handleConnection(io, allocator, ctx.store, ctx.agents, ctx.explorer, ctx.stream); +} + +/// Thin wrapper that owns a TCP stream plus a matching `std.Io.Writer` with a +/// small drain buffer. The response helpers below call `writeAll` followed by +/// `flush`, and the buffer is only used so the Io.Writer interface has a place +/// to stage bytes before the vectored drain hits the socket. +const Conn = struct { + io: std.Io, + stream: std.Io.net.Stream, + writer: std.Io.net.Stream.Writer, + + fn init(io: std.Io, stream: std.Io.net.Stream, buf: []u8) Conn { + return .{ + .io = io, + .stream = stream, + .writer = stream.writer(io, buf), + }; + } + + /// Best-effort flush+write of `bytes`. Silently drops errors — the remote + /// may have closed; we just want to finish the handler cleanly in that + /// case so the caller's `defer stream.close(io)` runs. + fn writeAll(self: *Conn, bytes: []const u8) void { + self.writer.interface.writeAll(bytes) catch {}; + } + + fn flush(self: *Conn) void { + self.writer.interface.flush() catch {}; + } +}; + +fn handleConnection( + io: std.Io, + allocator: std.mem.Allocator, + store: *Store, + agents: *AgentRegistry, + explorer: *Explorer, + stream: std.Io.net.Stream, +) void { + var buf: [65536]u8 = undefined; + + var total: usize = 0; + const first_n = readSome(io, stream, buf[0..]) catch return; + if (first_n == 0) return; + total = first_n; + + var write_buf: [4096]u8 = undefined; + var conn = Conn.init(io, stream, &write_buf); + + // If this is a POST, we may need to drain more bytes until we have the + // full header block + declared Content-Length body. + if (std.mem.startsWith(u8, buf[0..total], "POST")) { + var header_end_opt = std.mem.indexOf(u8, buf[0..total], "\r\n\r\n"); + while (header_end_opt == null and total < buf.len) { + const extra = readSome(io, stream, buf[total..]) catch { + respondJson(&conn, "400 Bad Request", "{\"error\":\"invalid request\"}"); + return; + }; + if (extra == 0) break; + total += extra; + header_end_opt = std.mem.indexOf(u8, buf[0..total], "\r\n\r\n"); + } + + const header_end = header_end_opt orelse { + if (total == buf.len) { + respondJson(&conn, "413 Payload Too Large", "{\"error\":\"request too large\"}"); + } else { + respondJson(&conn, "400 Bad Request", "{\"error\":\"malformed headers\"}"); + } + return; + }; + + const body_start = header_end + 4; + const headers = buf[0..header_end]; + var content_length: ?usize = null; + var lines = std.mem.splitSequence(u8, headers, "\r\n"); + while (lines.next()) |line| { + if (line.len == 0) continue; + const colon = std.mem.indexOfScalar(u8, line, ':') orelse continue; + const name = std.mem.trim(u8, line[0..colon], " \t"); + const value = std.mem.trim(u8, line[colon + 1 ..], " \t"); + if (std.ascii.eqlIgnoreCase(name, "Content-Length")) { + content_length = std.fmt.parseInt(usize, value, 10) catch { + respondJson(&conn, "400 Bad Request", "{\"error\":\"invalid content-length\"}"); + return; + }; + break; + } + } + + const body_len = content_length orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing content-length\"}"); + return; + }; + if (body_len > buf.len - body_start) { + respondJson(&conn, "413 Payload Too Large", "{\"error\":\"request too large\"}"); + return; + } + + const expected_total = body_start + body_len; + while (total < expected_total) { + const extra = readSome(io, stream, buf[total..expected_total]) catch { + respondJson(&conn, "400 Bad Request", "{\"error\":\"invalid request body\"}"); + return; + }; + if (extra == 0) { + respondJson(&conn, "400 Bad Request", "{\"error\":\"truncated request body\"}"); + return; + } + total += extra; + } + total = expected_total; + } + + const request = buf[0..total]; + + // ── Health ── + if (std.mem.startsWith(u8, request, "GET /health")) { + respondJson(&conn, "200 OK", "{\"status\":\"ok\"}"); + return; + } + + // ── Agent: register ── + if (std.mem.startsWith(u8, request, "POST /agent/register")) { + const body = extractBody(request); + const name = if (body.len > 0) extractJsonString(body, "name") orelse "unnamed" else "unnamed"; + const id = agents.register(name) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"register failed\"}"); + return; + }; + var out: std.ArrayList(u8) = .empty; + defer out.deinit(allocator); + const w = cio.listWriter(&out, allocator); + w.print("{{\"id\":{d},\"name\":\"", .{id}) catch return; + writeJsonEscaped(&out, allocator, name) catch return; + w.writeAll("\"}") catch return; + respondJson(&conn, "200 OK", out.items); + return; + } + + // ── Agent: heartbeat ── + if (std.mem.startsWith(u8, request, "POST /agent/heartbeat")) { + const agent_id = extractQueryParamInt(request, "id") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing ?id=\"}"); + return; + }; + agents.heartbeat(agent_id); + respondJson(&conn, "200 OK", "{\"ok\":true}"); + return; + } + + // ── Lock ── + if (std.mem.startsWith(u8, request, "POST /lock")) { + const agent_id = extractQueryParamInt(request, "agent") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing ?agent=\"}"); + return; + }; + const path = extractQueryParam(request, "path") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing ?path=\"}"); + return; + }; + const got = agents.tryLock(agent_id, path, 30_000) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"lock failed\"}"); + return; + }; + if (got) { + respondJson(&conn, "200 OK", "{\"locked\":true}"); + } else { + respondJson(&conn, "409 Conflict", "{\"locked\":false,\"error\":\"file locked by another agent\"}"); + } + return; + } + + // ── Unlock ── + if (std.mem.startsWith(u8, request, "POST /unlock")) { + const agent_id = extractQueryParamInt(request, "agent") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing ?agent=\"}"); + return; + }; + const path = extractQueryParam(request, "path") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing ?path=\"}"); + return; + }; + agents.releaseLock(agent_id, path); + respondJson(&conn, "200 OK", "{\"unlocked\":true}"); + return; + } + + // ── Edit ── + if (std.mem.startsWith(u8, request, "POST /edit")) { + const body = extractBody(request); + if (body.len == 0) { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing body\"}"); + return; + } + const parsed_body = std.json.parseFromSlice(std.json.Value, allocator, body, .{}) catch { + respondJson(&conn, "400 Bad Request", "{\"error\":\"invalid json\"}"); + return; + }; + defer parsed_body.deinit(); + if (parsed_body.value != .object) { + respondJson(&conn, "400 Bad Request", "{\"error\":\"body must be object\"}"); + return; + } + + const body_obj = &parsed_body.value.object; + const path = jsonString(body_obj, "path") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing path\"}"); + return; + }; + if (!isPathSafe(path)) { + respondJson(&conn, "403 Forbidden", "{\"error\":\"path traversal not allowed\"}"); + return; + } + + const agent_id = jsonU64(body_obj, "agent") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing agent\"}"); + return; + }; + + const op_str = jsonString(body_obj, "op") orelse "replace"; + const op: @import("version.zig").Op = if (std.mem.eql(u8, op_str, "insert")) + .insert + else if (std.mem.eql(u8, op_str, "delete")) + .delete + else + .replace; + + var content: ?[]const u8 = null; + if (body_obj.get("content")) |value| { + switch (value) { + .string => |s| content = s, + .null => {}, + else => { + respondJson(&conn, "400 Bad Request", "{\"error\":\"content must be string\"}"); + return; + }, + } + } + + const range_start = jsonU64(body_obj, "range_start"); + const range_end = jsonU64(body_obj, "range_end"); + const after = jsonU64(body_obj, "after"); + + var req = edit_mod.EditRequest{ + .path = path, + .agent_id = agent_id, + .op = op, + .content = content, + }; + if (range_start != null and range_end != null) { + req.range = .{ @intCast(range_start.?), @intCast(range_end.?) }; + } + if (after) |a| req.after = @intCast(a); + + const result = edit_mod.applyEdit(io, allocator, store, agents, explorer, req) catch |err| { + var err_buf: [128]u8 = undefined; + const err_body = std.fmt.bufPrint(&err_buf, "{{\"error\":\"{s}\"}}", .{@errorName(err)}) catch return; + const status = switch (err) { + error.InvalidRange, error.MissingContent => "400 Bad Request", + error.FileLocked => "409 Conflict", + error.FileNotFound => "404 Not Found", + error.AccessDenied => "403 Forbidden", + else => "500 Internal Server Error", + }; + respondJson(&conn, status, err_body); + return; + }; + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(allocator); + const w = cio.listWriter(&out, allocator); + w.print("{{\"seq\":{d},\"hash\":{d},\"size\":{d}}}", .{ result.seq, result.new_hash, result.new_size }) catch return; + respondJson(&conn, "200 OK", out.items); + return; + } + + // ── File read ── + if (std.mem.startsWith(u8, request, "GET /file/read")) { + const path = extractQueryParam(request, "path") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing ?path=\"}"); + return; + }; + if (!isPathSafe(path)) { + respondJson(&conn, "403 Forbidden", "{\"error\":\"path traversal not allowed\"}"); + return; + } + const content = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch |err| switch (err) { + error.FileNotFound => { + respondJson(&conn, "404 Not Found", "{\"error\":\"file not found\"}"); + return; + }, + else => { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"read failed\"}"); + return; + }, + }; + defer allocator.free(content); + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(allocator); + const w = cio.listWriter(&out, allocator); + w.writeAll("{\"path\":\"") catch return; + writeJsonEscaped(&out, allocator, path) catch return; + w.print("\",\"size\":{d},\"content\":\"", .{content.len}) catch return; + writeJsonEscaped(&out, allocator, content) catch return; + w.writeAll("\"}") catch return; + respondJson(&conn, "200 OK", out.items); + return; + } + + // ── Changes since cursor ── + if (std.mem.startsWith(u8, request, "GET /changes")) { + const since = extractQueryParamInt(request, "since") orelse 0; + const changes = store.changesSinceDetailed(since, allocator) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"changes query failed\"}"); + return; + }; + defer allocator.free(changes); + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(allocator); + const w = cio.listWriter(&out, allocator); + w.print("{{\"since\":{d},\"seq\":{d},\"changes\":[", .{ since, store.currentSeq() }) catch return; + for (changes, 0..) |c, i| { + if (i > 0) w.writeAll(",") catch return; + w.writeAll("{\"path\":\"") catch return; + writeJsonEscaped(&out, allocator, c.path) catch return; + w.print("\",\"seq\":{d},\"op\":\"{s}\",\"size\":{d},\"timestamp\":{d}}}", .{ + c.seq, @tagName(c.op), c.size, c.timestamp, + }) catch return; + } + w.writeAll("]}") catch return; + respondJson(&conn, "200 OK", out.items); + return; + } + + // ── Explore: tree ── + if (std.mem.startsWith(u8, request, "GET /explore/tree")) { + const tree = explorer.getTree(allocator, false) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"tree failed\"}"); + return; + }; + defer allocator.free(tree); + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(allocator); + const w = cio.listWriter(&out, allocator); + w.writeAll("{\"tree\":\"") catch return; + writeJsonEscaped(&out, allocator, tree) catch return; + w.writeAll("\"}") catch return; + respondJson(&conn, "200 OK", out.items); + return; + } + + // ── Explore: outline ── + if (std.mem.startsWith(u8, request, "GET /explore/outline")) { + const path_raw = extractQueryParam(request, "path") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing ?path=\"}"); + return; + }; + const path = percentDecode(allocator, path_raw) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"decode failed\"}"); + return; + }; + defer allocator.free(path); + if (!isPathSafe(path)) { + respondJson(&conn, "403 Forbidden", "{\"error\":\"path traversal not allowed\"}"); + return; + } + var outline = explorer.getOutline(path, allocator) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"outline failed\"}"); + return; + } orelse { + respondJson(&conn, "404 Not Found", "{\"error\":\"file not indexed\"}"); + return; + }; + defer outline.deinit(); + var out: std.ArrayList(u8) = .empty; + defer out.deinit(allocator); + const w = cio.listWriter(&out, allocator); + w.writeAll("{\"path\":\"") catch return; + writeJsonEscaped(&out, allocator, outline.path) catch return; + w.print("\",\"language\":\"{s}\",\"lines\":{d},\"bytes\":{d},\"symbols\":[", .{ + @tagName(outline.language), outline.line_count, outline.byte_size, + }) catch return; + for (outline.symbols.items, 0..) |sym, i| { + if (i > 0) w.writeAll(",") catch return; + w.writeAll("{\"name\":\"") catch return; + writeJsonEscaped(&out, allocator, sym.name) catch return; + w.print("\",\"kind\":\"{s}\",\"line_start\":{d},\"line_end\":{d}", .{ + @tagName(sym.kind), sym.line_start, sym.line_end, + }) catch return; + if (sym.detail) |d| { + w.writeAll(",\"detail\":\"") catch return; + writeJsonEscaped(&out, allocator, d) catch return; + w.writeAll("\"") catch return; + } + w.writeAll("}") catch return; + } + w.writeAll("]}") catch return; + respondJson(&conn, "200 OK", out.items); + return; + } + + // ── Explore: symbol (find all) ── + if (std.mem.startsWith(u8, request, "GET /explore/symbol")) { + const name = extractQueryParam(request, "name") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing ?name=\"}"); + return; + }; + const results = explorer.findAllSymbols(name, allocator) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"search failed\"}"); + return; + }; + defer allocator.free(results); + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(allocator); + const w = cio.listWriter(&out, allocator); + w.writeAll("{\"name\":\"") catch return; + writeJsonEscaped(&out, allocator, name) catch return; + w.writeAll("\",\"results\":[") catch return; + for (results, 0..) |r, i| { + if (i > 0) w.writeAll(",") catch return; + w.writeAll("{\"path\":\"") catch return; + writeJsonEscaped(&out, allocator, r.path) catch return; + w.print("\",\"line\":{d},\"kind\":\"{s}\"", .{ + r.symbol.line_start, @tagName(r.symbol.kind), + }) catch return; + if (r.symbol.detail) |d| { + w.writeAll(",\"detail\":\"") catch return; + writeJsonEscaped(&out, allocator, d) catch return; + w.writeAll("\"") catch return; + } + w.writeAll("}") catch return; + } + w.writeAll("]}") catch return; + respondJson(&conn, "200 OK", out.items); + return; + } + + // ── Explore: hot ── + if (std.mem.startsWith(u8, request, "GET /explore/hot")) { + const hot = explorer.getHotFiles(store, allocator, 10) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"hot files failed\"}"); + return; + }; + defer { + for (hot) |entry| allocator.free(entry); + allocator.free(hot); + } + var out: std.ArrayList(u8) = .empty; + defer out.deinit(allocator); + const w = cio.listWriter(&out, allocator); + w.writeAll("{\"files\":[") catch return; + for (hot, 0..) |path, i| { + if (i > 0) w.writeAll(",") catch return; + w.writeAll("\"") catch return; + writeJsonEscaped(&out, allocator, path) catch return; + w.writeAll("\"") catch return; + } + w.writeAll("]}") catch return; + respondJson(&conn, "200 OK", out.items); + return; + } + + // ── Explore: deps ── + if (std.mem.startsWith(u8, request, "GET /explore/deps")) { + const path_raw = extractQueryParam(request, "path") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing ?path=\"}"); + return; + }; + const path = percentDecode(allocator, path_raw) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"decode failed\"}"); + return; + }; + defer allocator.free(path); + const imported_by = explorer.getImportedBy(path, allocator) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"deps failed\"}"); + return; + }; + defer { + for (imported_by) |dep| allocator.free(dep); + allocator.free(imported_by); + } + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(allocator); + const w = cio.listWriter(&out, allocator); + w.writeAll("{\"path\":\"") catch return; + writeJsonEscaped(&out, allocator, path) catch return; + w.writeAll("\",\"imported_by\":[") catch return; + for (imported_by, 0..) |dep, i| { + if (i > 0) w.writeAll(",") catch return; + w.writeAll("\"") catch return; + writeJsonEscaped(&out, allocator, dep) catch return; + w.writeAll("\"") catch return; + } + w.writeAll("]}") catch return; + respondJson(&conn, "200 OK", out.items); + return; + } + + // ── Explore: word search (inverted index, O(1) lookup) ── + if (std.mem.startsWith(u8, request, "GET /explore/word")) { + const word_raw = extractQueryParam(request, "q") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing ?q=\"}"); + return; + }; + const word = percentDecode(allocator, word_raw) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"decode failed\"}"); + return; + }; + defer allocator.free(word); + const hits = explorer.searchWord(word, allocator) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"word search failed\"}"); + return; + }; + defer allocator.free(hits); + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(allocator); + const w = cio.listWriter(&out, allocator); + w.writeAll("{\"query\":\"") catch return; + writeJsonEscaped(&out, allocator, word) catch return; + w.writeAll("\",\"hits\":[") catch return; + explorer.mu.lockShared(); + defer explorer.mu.unlockShared(); + for (hits, 0..) |h, i| { + if (i > 0) w.writeAll(",") catch return; + w.writeAll("{\"path\":\"") catch return; + writeJsonEscaped(&out, allocator, explorer.word_index.hitPath(h)) catch return; + w.print("\",\"line\":{d}}}", .{h.line_num}) catch return; + } + w.writeAll("]}") catch return; + respondJson(&conn, "200 OK", out.items); + return; + } + + // ── Explore: search (text grep, trigram-accelerated) ── + if (std.mem.startsWith(u8, request, "GET /explore/search")) { + const query_raw = extractQueryParam(request, "q") orelse { + respondJson(&conn, "400 Bad Request", "{\"error\":\"missing ?q=\"}"); + return; + }; + const query = percentDecode(allocator, query_raw) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"decode failed\"}"); + return; + }; + defer allocator.free(query); + const results = explorer.searchContent(query, allocator, 50) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"search failed\"}"); + return; + }; + defer { + for (results) |r| allocator.free(r.line_text); + allocator.free(results); + } + + var out: std.ArrayList(u8) = .empty; + defer out.deinit(allocator); + const w = cio.listWriter(&out, allocator); + w.writeAll("{\"query\":\"") catch return; + writeJsonEscaped(&out, allocator, query) catch return; + w.writeAll("\",\"results\":[") catch return; + for (results, 0..) |r, i| { + if (i > 0) w.writeAll(",") catch return; + w.writeAll("{\"path\":\"") catch return; + writeJsonEscaped(&out, allocator, r.path) catch return; + w.print("\",\"line\":{d},\"text\":\"", .{r.line_num}) catch return; + writeJsonEscaped(&out, allocator, r.line_text) catch return; + w.writeAll("\"}") catch return; + } + w.writeAll("]}") catch return; + respondJson(&conn, "200 OK", out.items); + return; + } + + // ── Snapshot ── + if (std.mem.startsWith(u8, request, "GET /snapshot")) { + const snap = snapshot_json.buildSnapshot(explorer, store, allocator) catch { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"snapshot build failed\"}"); + return; + }; + defer allocator.free(snap); + respondJson(&conn, "200 OK", snap); + return; + } + + // ── Seq ── + if (std.mem.startsWith(u8, request, "GET /seq")) { + var seq_buf: [32]u8 = undefined; + const body = std.fmt.bufPrint(&seq_buf, "{{\"seq\":{d}}}", .{store.currentSeq()}) catch return; + respondJson(&conn, "200 OK", body); + return; + } + + respondJson(&conn, "404 Not Found", "{\"error\":\"not found\"}"); +} + +// ── Transport helpers ─────────────────────────────────────────── + +/// Read some bytes from the TCP stream into `dest`. Returns 0 on clean EOF. +/// This is the direct-vtable analogue of the old `conn.stream.read`: no +/// buffered reader state, so each call issues one `netRead` syscall. +fn readSome(io: std.Io, stream: std.Io.net.Stream, dest: []u8) !usize { + if (dest.len == 0) return 0; + var iov: [1][]u8 = .{dest}; + const n = try io.vtable.netRead(io.userdata, stream.socket.handle, &iov); + return n; +} + +// ── Response helpers ──────────────────────────────────────────── + +fn isPathSafe(path: []const u8) bool { + if (path.len == 0) return false; + if (path[0] == '/') return false; + var it = std.mem.splitScalar(u8, path, '/'); + while (it.next()) |component| { + if (std.mem.eql(u8, component, "..")) return false; + } + return true; +} + +fn respondJson(conn: *Conn, status: []const u8, body: []const u8) void { + var hdr_buf: [512]u8 = undefined; + const hdr = std.fmt.bufPrint(&hdr_buf, "HTTP/1.1 {s}\r\nContent-Type: application/json\r\nContent-Length: {d}\r\nConnection: close\r\n\r\n", .{ status, body.len }) catch return; + conn.writeAll(hdr); + conn.writeAll(body); + conn.flush(); +} + +/// Append a JSON-escaped version of `s` to the trailing `out` list. +fn writeJsonEscaped(out: *std.ArrayList(u8), alloc: std.mem.Allocator, s: []const u8) !void { + for (s) |ch| { + switch (ch) { + '"' => try out.appendSlice(alloc, "\\\""), + '\\' => try out.appendSlice(alloc, "\\\\"), + '\n' => try out.appendSlice(alloc, "\\n"), + '\r' => try out.appendSlice(alloc, "\\r"), + '\t' => try out.appendSlice(alloc, "\\t"), + else => { + if (ch < 0x20) { + const hex = "0123456789abcdef"; + const esc = [6]u8{ '\\', 'u', '0', '0', hex[ch >> 4], hex[ch & 0x0f] }; + try out.appendSlice(alloc, &esc); + } else { + try out.append(alloc, ch); + } + }, + } + } +} + +// ── HTTP parsing helpers ──────────────────────────────────────── + +fn extractQueryParam(request: []const u8, key: []const u8) ?[]const u8 { + const first_line_end = std.mem.indexOf(u8, request, "\r\n") orelse request.len; + const first_line = request[0..first_line_end]; + + const q_pos = std.mem.indexOfScalar(u8, first_line, '?') orelse return null; + const space_pos = std.mem.indexOfScalarPos(u8, first_line, q_pos, ' ') orelse first_line.len; + const query = first_line[q_pos + 1 .. space_pos]; + + var pairs = std.mem.splitScalar(u8, query, '&'); + while (pairs.next()) |pair| { + if (std.mem.startsWith(u8, pair, key)) { + if (pair.len > key.len and pair[key.len] == '=') { + return pair[key.len + 1 ..]; + } + } + } + return null; +} + +fn percentDecode(allocator: std.mem.Allocator, input: []const u8) ![]u8 { + var out: std.ArrayList(u8) = .empty; + errdefer out.deinit(allocator); + var i: usize = 0; + while (i < input.len) { + if (input[i] == '%' and i + 2 < input.len) { + const hi = std.fmt.charToDigit(input[i + 1], 16) catch { + try out.append(allocator, input[i]); + i += 1; + continue; + }; + const lo = std.fmt.charToDigit(input[i + 2], 16) catch { + try out.append(allocator, input[i]); + i += 1; + continue; + }; + try out.append(allocator, (hi << 4) | lo); + i += 3; + } else if (input[i] == '+') { + try out.append(allocator, ' '); + i += 1; + } else { + try out.append(allocator, input[i]); + i += 1; + } + } + return out.toOwnedSlice(allocator); +} + +fn extractQueryParamInt(request: []const u8, key: []const u8) ?u64 { + const val = extractQueryParam(request, key) orelse return null; + return std.fmt.parseInt(u64, val, 10) catch null; +} + +fn extractBody(request: []const u8) []const u8 { + if (std.mem.indexOf(u8, request, "\r\n\r\n")) |pos| { + return request[pos + 4 ..]; + } + return ""; +} + +fn jsonString(obj: *const std.json.ObjectMap, key: []const u8) ?[]const u8 { + return switch (obj.get(key) orelse return null) { + .string => |s| s, + else => null, + }; +} + +fn jsonU64(obj: *const std.json.ObjectMap, key: []const u8) ?u64 { + return switch (obj.get(key) orelse return null) { + .integer => |n| if (n >= 0) @as(u64, @intCast(n)) else null, + else => null, + }; +} + +fn findUnescapedQuote(s: []const u8, start: usize) ?usize { + var i = start; + while (i < s.len) : (i += 1) { + if (s[i] == '\\') { + i += 1; // skip escaped char + continue; + } + if (s[i] == '"') return i; + } + return null; +} + +/// Minimal JSON string extractor: finds "key":"value" and returns value. +fn extractJsonString(json: []const u8, key: []const u8) ?[]const u8 { + // NOTE: This is a naive scanner that does NOT handle JSON escape sequences + // (e.g. \" inside string values will cause incorrect results). For correct + // parsing use std.json.parseFromSlice on the full body instead. + var pos: usize = 0; + while (pos < json.len) { + const key_start = std.mem.indexOfPos(u8, json, pos, "\"") orelse return null; + const key_end = std.mem.indexOfPos(u8, json, key_start + 1, "\"") orelse return null; + const found_key = json[key_start + 1 .. key_end]; + + if (std.mem.eql(u8, found_key, key)) { + // Skip ":" + var next = key_end + 1; + while (next < json.len and (json[next] == ':' or json[next] == ' ')) : (next += 1) {} + if (next >= json.len or json[next] != '"') return null; + const val_start = next + 1; + const val_end = findUnescapedQuote(json, val_start) orelse return null; + return json[val_start..val_end]; + } + pos = key_end + 1; + } + return null; } From 2fbc66c61e510f578f487a75eff373b09a208612 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:42:28 +0800 Subject: [PATCH 2/9] Route MCP status output to stderr MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For `codedb mcp`, stdout is reserved for JSON-RPC messages. The root-policy failure path wrote `✗ refusing to index temporary root: …` (and the normal `✓ indexed` startup line) to stdout, which hosts reject with `invalid character 'â' looking for beginning of value` on the leading UTF-8 byte of the status glyph. Switch `out.file` to stderr once `cmd == "mcp"` is resolved, so every `out.p` call on that path goes to stderr while stdout stays clean for protocol messages. Closes #304 Co-Authored-By: Claude Sonnet 4.6 --- src/main.zig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/main.zig b/src/main.zig index 30771bd..1dc67fb 100644 --- a/src/main.zig +++ b/src/main.zig @@ -65,7 +65,7 @@ fn mainImpl() !void { const stdout = cio.File.stdout(); const use_color = stdout.isTty(); const s = sty.style(use_color); - const out = Out{ .file = stdout, .alloc = allocator }; + var out = Out{ .file = stdout, .alloc = allocator }; const args = try cio.argsAlloc(allocator); defer cio.argsFree(allocator, args); @@ -106,6 +106,13 @@ fn mainImpl() !void { std.process.exit(1); } + // MCP stdio reserves stdout for JSON-RPC — route status/error output to + // stderr so startup/failure paths don't corrupt the protocol stream. + // See #304. + if (std.mem.eql(u8, cmd, "mcp")) { + out.file = cio.File.stderr(); + } + // Handle --version early (no root needed) if (std.mem.eql(u8, cmd, "--version") or std.mem.eql(u8, cmd, "-v") or std.mem.eql(u8, cmd, "version")) { out.p("codedb {s}\n", .{release_info.semver}); From aaba92e99a955c509f41d36d781ad536b7ea9374 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:42:48 +0800 Subject: [PATCH 3/9] Make `codedb serve` port configurable via CODEDB_PORT Read `CODEDB_PORT` from the environment; fall back to 7719 on absence or parse failure. Unblocks running multiple instances on one host, reverse-proxy setups, and integration tests that need an ephemeral port. Refs #308 Co-Authored-By: Claude Sonnet 4.6 --- src/main.zig | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main.zig b/src/main.zig index 1dc67fb..e523f4c 100644 --- a/src/main.zig +++ b/src/main.zig @@ -557,7 +557,10 @@ fn mainImpl() !void { s.reset, }); } else if (std.mem.eql(u8, cmd, "serve")) { - const port: u16 = 7719; + const port: u16 = blk: { + const raw = cio.posixGetenv("CODEDB_PORT") orelse break :blk 7719; + break :blk std.fmt.parseInt(u16, raw, 10) catch 7719; + }; var agents = AgentRegistry.init(allocator); defer agents.deinit(); _ = try agents.register("__filesystem__"); From c9d773c9ca61e9df3c12b98af9c34c5c912274ce Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:42:57 +0800 Subject: [PATCH 4/9] O(1) `findSymbol` via complete symbol_index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Explorer.findSymbol` now looks up the name in `self.symbol_index` and builds results from the cached locations. The full outline scan is kept as a fallback for safety. For the index to be authoritative, `rebuildSymbolIndexFor` no longer skips `.import` / `.comment_block` kinds — those were being missed by the O(1) path and forced callers into the slow scan. Indexing every kind makes results match the scan-based path exactly. Refs #309 Co-Authored-By: Claude Sonnet 4.6 --- src/explore.zig | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/explore.zig b/src/explore.zig index f0c0564..ee1e4fd 100644 --- a/src/explore.zig +++ b/src/explore.zig @@ -1256,7 +1256,33 @@ pub const Explorer = struct { var result_list: std.ArrayList(SymbolResult) = .empty; errdefer result_list.deinit(allocator); - // Scan outlines for all symbols by name (catches all kinds including imports). + // O(1) lookup via symbol_index (all kinds are indexed). + if (self.symbol_index.get(name)) |locs| { + for (locs.items) |loc| { + var detail: ?[]const u8 = null; + if (self.outlines.getPtr(loc.path)) |outline| { + for (outline.symbols.items) |sym| { + if (sym.line_start == loc.line_start and std.mem.eql(u8, sym.name, name)) { + detail = if (sym.detail) |d| try allocator.dupe(u8, d) else null; + break; + } + } + } + try result_list.append(allocator, .{ + .path = try allocator.dupe(u8, loc.path), + .symbol = .{ + .name = try allocator.dupe(u8, name), + .kind = loc.kind, + .line_start = loc.line_start, + .line_end = loc.line_end, + .detail = detail, + }, + }); + } + return result_list.toOwnedSlice(allocator); + } + + // Fallback: scan outlines (kept for safety; with complete indexing above this is rare). var iter = self.outlines.iterator(); while (iter.next()) |entry| { for (entry.value_ptr.symbols.items) |sym| { @@ -2684,7 +2710,6 @@ pub const Explorer = struct { fn rebuildSymbolIndexFor(self: *Explorer, path: []const u8, outline: *FileOutline) void { self.removeSymbolIndexFor(path); for (outline.symbols.items) |sym| { - if (sym.kind == .import or sym.kind == .comment_block) continue; const gop = self.symbol_index.getOrPut(sym.name) catch continue; if (!gop.found_existing) { gop.value_ptr.* = std.ArrayList(SymbolLocation).empty; From 14c316063d3ddce85d4c6c0e3d24320f07541751 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:46:20 +0800 Subject: [PATCH 5/9] Make `codedb serve` opt-in; require CODEDB_PORT with no default Drop the hardcoded 7719 fallback. If CODEDB_PORT is unset, `codedb serve` exits with a clear message explaining how to enable it (suggested 47719, since 7719 and 8080 tend to collide with other local processes). If set but not parseable as u16, exit with an error. Rationale: the HTTP server opens a network port; having it bind on a predictable default when someone runs `codedb serve` accidentally is worth avoiding. Treating the env var as the on/off switch keeps the surface area minimal and makes the enabled case explicit in shell history / process listings. Refs #308 Co-Authored-By: Claude Sonnet 4.6 --- src/main.zig | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main.zig b/src/main.zig index e523f4c..426b85d 100644 --- a/src/main.zig +++ b/src/main.zig @@ -557,9 +557,13 @@ fn mainImpl() !void { s.reset, }); } else if (std.mem.eql(u8, cmd, "serve")) { - const port: u16 = blk: { - const raw = cio.posixGetenv("CODEDB_PORT") orelse break :blk 7719; - break :blk std.fmt.parseInt(u16, raw, 10) catch 7719; + const raw_port = cio.posixGetenv("CODEDB_PORT") orelse { + std.log.err("codedb serve: HTTP server is off by default. Set CODEDB_PORT= to enable (suggested: 47719 — 7719 and 8080 collide often).", .{}); + std.process.exit(1); + }; + const port: u16 = std.fmt.parseInt(u16, raw_port, 10) catch { + std.log.err("codedb serve: CODEDB_PORT={s} is not a valid u16 port.", .{raw_port}); + std.process.exit(1); }; var agents = AgentRegistry.init(allocator); defer agents.deinit(); From 8b43e8942e4a6228792a69d8d344c0da0f1a3f7b Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:55:43 +0800 Subject: [PATCH 6/9] Revert `codedb serve` gate; default port 6767 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Running `codedb serve` is itself the opt-in — codedb has no always-on daemon, so gating the listener behind an additional CODEDB_PORT requirement was belt-and-suspenders with no threat to block. Restore the previous UX: `codedb serve` starts listening on a default port, CODEDB_PORT stays as an optional override for collisions. Default is now 6767 (picked off the beaten path — 7719 and 8080 collided with other local tooling). Refs #308 Co-Authored-By: Claude Sonnet 4.6 --- src/main.zig | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main.zig b/src/main.zig index 426b85d..1e94688 100644 --- a/src/main.zig +++ b/src/main.zig @@ -557,13 +557,9 @@ fn mainImpl() !void { s.reset, }); } else if (std.mem.eql(u8, cmd, "serve")) { - const raw_port = cio.posixGetenv("CODEDB_PORT") orelse { - std.log.err("codedb serve: HTTP server is off by default. Set CODEDB_PORT= to enable (suggested: 47719 — 7719 and 8080 collide often).", .{}); - std.process.exit(1); - }; - const port: u16 = std.fmt.parseInt(u16, raw_port, 10) catch { - std.log.err("codedb serve: CODEDB_PORT={s} is not a valid u16 port.", .{raw_port}); - std.process.exit(1); + const port: u16 = blk: { + const raw = cio.posixGetenv("CODEDB_PORT") orelse break :blk 6767; + break :blk std.fmt.parseInt(u16, raw, 10) catch 6767; }; var agents = AgentRegistry.init(allocator); defer agents.deinit(); From 74ba88132698130275885b51de9a33668889c2eb Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:56:01 +0800 Subject: [PATCH 7/9] Harden `server.isPathSafe` against backslash and null-byte paths MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `isPathSafe` only rejected absolute `/` paths and `..` segments split on `/`, so inputs like `..\\..\\secret.txt` passed through — on platforms where `\\` is a real separator this could reach files outside the indexed tree through `/file/read` and `/edit`. Null bytes likewise could truncate paths in downstream syscalls. Mirror `mcp.isPathSafe`: reject null bytes and backslashes up front before the `/`-split loop. Addresses Codex P1 on #310. Co-Authored-By: Claude Sonnet 4.6 --- src/server.zig | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/server.zig b/src/server.zig index 21febd2..c613bd2 100644 --- a/src/server.zig +++ b/src/server.zig @@ -696,6 +696,11 @@ fn readSome(io: std.Io, stream: std.Io.net.Stream, dest: []u8) !usize { fn isPathSafe(path: []const u8) bool { if (path.len == 0) return false; if (path[0] == '/') return false; + // Block null bytes (path truncation attack). + if (std.mem.indexOfScalar(u8, path, 0) != null) return false; + // Block backslash separators so Windows-style `..\..\x` can't bypass the + // forward-slash `..` check below. Matches mcp.isPathSafe. + if (std.mem.indexOfScalar(u8, path, '\\') != null) return false; var it = std.mem.splitScalar(u8, path, '/'); while (it.next()) |component| { if (std.mem.eql(u8, component, "..")) return false; From 6ef7185cadc51819bd7142dfddbac6a79079098c Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:56:18 +0800 Subject: [PATCH 8/9] Resolve `/file/read` paths against the indexed root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The HTTP handler was opening files via `std.Io.Dir.cwd()`, but `codedb serve` indexes paths relative to the provided root. Launched from any other directory, valid indexed paths hit the wrong base and returned false 404s (or worse — read the wrong file). Open via `explorer.root_dir` instead. Respond 500 with a clear error if the root was never configured (shouldn't happen on the normal serve path, but guards against a bare explorer). Addresses Codex P2 on #310. Co-Authored-By: Claude Sonnet 4.6 --- src/server.zig | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/server.zig b/src/server.zig index c613bd2..0229969 100644 --- a/src/server.zig +++ b/src/server.zig @@ -365,7 +365,11 @@ fn handleConnection( respondJson(&conn, "403 Forbidden", "{\"error\":\"path traversal not allowed\"}"); return; } - const content = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch |err| switch (err) { + const root_dir = explorer.root_dir orelse { + respondJson(&conn, "500 Internal Server Error", "{\"error\":\"root not configured\"}"); + return; + }; + const content = root_dir.readFileAlloc(io, path, allocator, .limited(10 * 1024 * 1024)) catch |err| switch (err) { error.FileNotFound => { respondJson(&conn, "404 Not Found", "{\"error\":\"file not found\"}"); return; From fbb8b49b4c850d0a6ce866c7f2a932537d9aa3b0 Mon Sep 17 00:00:00 2001 From: Rach Pradhan <54503978+justrach@users.noreply.github.com> Date: Wed, 22 Apr 2026 10:56:26 +0800 Subject: [PATCH 9/9] findAllSymbols: always merge index + outline scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The earlier change early-returned after the `symbol_index` lookup, but that map can be incomplete after fast-snapshot restore — `outlines` is populated before `rebuildSymbolIndexFor` runs on every file, and later watcher/edit updates only touch files they saw change. Symbols present in untouched files were silently dropped from results once the index had any entry for the name. Keep the O(1) path for the common case, but always fall through into the outline scan and dedupe against a per-call `(path, line_start)` set so the scan fills gaps without duplicating index hits. Addresses Codex P1 on #310. Co-Authored-By: Claude Sonnet 4.6 --- src/explore.zig | 45 ++++++++++++++++++++++++++++++--------------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/src/explore.zig b/src/explore.zig index ee1e4fd..b633337 100644 --- a/src/explore.zig +++ b/src/explore.zig @@ -1256,7 +1256,19 @@ pub const Explorer = struct { var result_list: std.ArrayList(SymbolResult) = .empty; errdefer result_list.deinit(allocator); - // O(1) lookup via symbol_index (all kinds are indexed). + // Track (path, line_start) pairs already appended. symbol_index can be + // incomplete after fast-snapshot restore (outlines are populated before + // rebuildSymbolIndexFor runs on every file), so we must still fall + // through to the outline scan — and dedupe against what the index + // already supplied. Keys are ":" allocated from the caller + // allocator, freed at end of call. + var seen = std.StringHashMap(void).init(allocator); + defer { + var sit = seen.keyIterator(); + while (sit.next()) |k| allocator.free(k.*); + seen.deinit(); + } + if (self.symbol_index.get(name)) |locs| { for (locs.items) |loc| { var detail: ?[]const u8 = null; @@ -1278,26 +1290,29 @@ pub const Explorer = struct { .detail = detail, }, }); + const key = try std.fmt.allocPrint(allocator, "{s}:{d}", .{ loc.path, loc.line_start }); + seen.put(key, {}) catch allocator.free(key); } - return result_list.toOwnedSlice(allocator); } - // Fallback: scan outlines (kept for safety; with complete indexing above this is rare). + // Safety scan: append any outline symbols the index missed. var iter = self.outlines.iterator(); while (iter.next()) |entry| { for (entry.value_ptr.symbols.items) |sym| { - if (std.mem.eql(u8, sym.name, name)) { - try result_list.append(allocator, .{ - .path = try allocator.dupe(u8, entry.key_ptr.*), - .symbol = .{ - .name = try allocator.dupe(u8, sym.name), - .kind = sym.kind, - .line_start = sym.line_start, - .line_end = sym.line_end, - .detail = if (sym.detail) |d| try allocator.dupe(u8, d) else null, - }, - }); - } + if (!std.mem.eql(u8, sym.name, name)) continue; + var key_buf: [std.fs.max_path_bytes + 32]u8 = undefined; + const key = std.fmt.bufPrint(&key_buf, "{s}:{d}", .{ entry.key_ptr.*, sym.line_start }) catch continue; + if (seen.contains(key)) continue; + try result_list.append(allocator, .{ + .path = try allocator.dupe(u8, entry.key_ptr.*), + .symbol = .{ + .name = try allocator.dupe(u8, sym.name), + .kind = sym.kind, + .line_start = sym.line_start, + .line_end = sym.line_end, + .detail = if (sym.detail) |d| try allocator.dupe(u8, d) else null, + }, + }); } } return result_list.toOwnedSlice(allocator);