|
| 1 | +// SPDX-License-Identifier: PMPL-1.0-or-later |
| 2 | +// Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk> |
| 3 | +// |
| 4 | +// BoJ cartridge invoker CLI (skinny Phase 2 per ADR-0005). |
| 5 | +// |
| 6 | +// Loads a cartridge shared library and calls one of the four standard |
| 7 | +// symbols the loader already requires: init / deinit / name / version. |
| 8 | +// Tool-level dispatch is deferred to Phase 2.1 (ADR-0006). |
| 9 | +// |
| 10 | +// Usage: |
| 11 | +// boj-invoke <cartridge-so-path> <verb> |
| 12 | +// |
| 13 | +// Verbs: |
| 14 | +// probe — run boj_cartridge_init, read name+version, run boj_cartridge_deinit, |
| 15 | +// emit {"ok":true,"name":"...","version":"..."} on stdout. |
| 16 | +// name — read boj_cartridge_name only. |
| 17 | +// version — read boj_cartridge_version only. |
| 18 | +// |
| 19 | +// Exit codes (mirrored into the Elixir invoker's error classification): |
| 20 | +// 0 success, JSON on stdout |
| 21 | +// 2 argument error (wrong argc / unknown verb) |
| 22 | +// 3 cartridge .so not found / cannot open |
| 23 | +// 4 missing required symbol in the .so |
| 24 | +// 5 cartridge init returned non-zero |
| 25 | +// 6 tool dispatch not yet wired (reserved for Phase 2.1) |
| 26 | +// |
| 27 | +// This binary is intentionally single-process: fork-per-invocation is |
| 28 | +// acceptable for the skeleton, and the Elixir side will move to a |
| 29 | +// long-lived Port pool in a follow-up once the ABI stabilises. |
| 30 | + |
| 31 | +const std = @import("std"); |
| 32 | + |
| 33 | +const EXIT_OK: u8 = 0; |
| 34 | +const EXIT_ARGS: u8 = 2; |
| 35 | +const EXIT_OPEN: u8 = 3; |
| 36 | +const EXIT_SYMBOL: u8 = 4; |
| 37 | +const EXIT_INIT: u8 = 5; |
| 38 | +const EXIT_UNWIRED: u8 = 6; |
| 39 | + |
| 40 | +const Verb = enum { probe, name, version }; |
| 41 | + |
| 42 | +fn parseVerb(s: []const u8) ?Verb { |
| 43 | + if (std.mem.eql(u8, s, "probe")) return .probe; |
| 44 | + if (std.mem.eql(u8, s, "name")) return .name; |
| 45 | + if (std.mem.eql(u8, s, "version")) return .version; |
| 46 | + return null; |
| 47 | +} |
| 48 | + |
| 49 | +fn emitJson(file: std.fs.File, alloc: std.mem.Allocator, comptime fmt: []const u8, args: anytype) !void { |
| 50 | + const line = try std.fmt.allocPrint(alloc, fmt ++ "\n", args); |
| 51 | + defer alloc.free(line); |
| 52 | + try file.writeAll(line); |
| 53 | +} |
| 54 | + |
| 55 | +pub fn main() !u8 { |
| 56 | + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; |
| 57 | + defer _ = gpa.deinit(); |
| 58 | + const alloc = gpa.allocator(); |
| 59 | + |
| 60 | + const argv = try std.process.argsAlloc(alloc); |
| 61 | + defer std.process.argsFree(alloc, argv); |
| 62 | + |
| 63 | + const stderr = std.fs.File.stderr(); |
| 64 | + const stdout = std.fs.File.stdout(); |
| 65 | + |
| 66 | + if (argv.len != 3) { |
| 67 | + try emitJson(stderr, alloc, |
| 68 | + "{{\"ok\":false,\"error\":\"args\",\"expected\":\"<cartridge-so-path> <verb>\",\"got_argc\":{d}}}", |
| 69 | + .{argv.len}); |
| 70 | + return EXIT_ARGS; |
| 71 | + } |
| 72 | + |
| 73 | + const so_path = argv[1]; |
| 74 | + const verb = parseVerb(argv[2]) orelse { |
| 75 | + try emitJson(stderr, alloc, |
| 76 | + "{{\"ok\":false,\"error\":\"unknown-verb\",\"verb\":\"{s}\"}}", |
| 77 | + .{argv[2]}); |
| 78 | + return EXIT_ARGS; |
| 79 | + }; |
| 80 | + |
| 81 | + var lib = std.DynLib.open(so_path) catch |err| { |
| 82 | + try emitJson(stderr, alloc, |
| 83 | + "{{\"ok\":false,\"error\":\"open\",\"path\":\"{s}\",\"cause\":\"{s}\"}}", |
| 84 | + .{ so_path, @errorName(err) }); |
| 85 | + return EXIT_OPEN; |
| 86 | + }; |
| 87 | + defer lib.close(); |
| 88 | + |
| 89 | + const NameFn = *const fn () callconv(.c) [*:0]const u8; |
| 90 | + const VersionFn = *const fn () callconv(.c) [*:0]const u8; |
| 91 | + const InitFn = *const fn () callconv(.c) c_int; |
| 92 | + const DeinitFn = *const fn () callconv(.c) void; |
| 93 | + |
| 94 | + switch (verb) { |
| 95 | + .name => { |
| 96 | + const name_fn = lib.lookup(NameFn, "boj_cartridge_name") orelse { |
| 97 | + try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"missing-symbol\",\"symbol\":\"boj_cartridge_name\"}}", .{}); |
| 98 | + return EXIT_SYMBOL; |
| 99 | + }; |
| 100 | + const n = std.mem.span(name_fn()); |
| 101 | + try emitJson(stdout, alloc, "{{\"ok\":true,\"name\":\"{s}\"}}", .{n}); |
| 102 | + return EXIT_OK; |
| 103 | + }, |
| 104 | + .version => { |
| 105 | + const version_fn = lib.lookup(VersionFn, "boj_cartridge_version") orelse { |
| 106 | + try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"missing-symbol\",\"symbol\":\"boj_cartridge_version\"}}", .{}); |
| 107 | + return EXIT_SYMBOL; |
| 108 | + }; |
| 109 | + const v = std.mem.span(version_fn()); |
| 110 | + try emitJson(stdout, alloc, "{{\"ok\":true,\"version\":\"{s}\"}}", .{v}); |
| 111 | + return EXIT_OK; |
| 112 | + }, |
| 113 | + .probe => { |
| 114 | + const init_fn = lib.lookup(InitFn, "boj_cartridge_init") orelse { |
| 115 | + try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"missing-symbol\",\"symbol\":\"boj_cartridge_init\"}}", .{}); |
| 116 | + return EXIT_SYMBOL; |
| 117 | + }; |
| 118 | + const deinit_fn = lib.lookup(DeinitFn, "boj_cartridge_deinit") orelse { |
| 119 | + try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"missing-symbol\",\"symbol\":\"boj_cartridge_deinit\"}}", .{}); |
| 120 | + return EXIT_SYMBOL; |
| 121 | + }; |
| 122 | + const name_fn = lib.lookup(NameFn, "boj_cartridge_name") orelse { |
| 123 | + try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"missing-symbol\",\"symbol\":\"boj_cartridge_name\"}}", .{}); |
| 124 | + return EXIT_SYMBOL; |
| 125 | + }; |
| 126 | + const version_fn = lib.lookup(VersionFn, "boj_cartridge_version") orelse { |
| 127 | + try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"missing-symbol\",\"symbol\":\"boj_cartridge_version\"}}", .{}); |
| 128 | + return EXIT_SYMBOL; |
| 129 | + }; |
| 130 | + |
| 131 | + const rc = init_fn(); |
| 132 | + if (rc != 0) { |
| 133 | + try emitJson(stderr, alloc, "{{\"ok\":false,\"error\":\"init-returned\",\"rc\":{d}}}", .{rc}); |
| 134 | + return EXIT_INIT; |
| 135 | + } |
| 136 | + defer deinit_fn(); |
| 137 | + |
| 138 | + const n = std.mem.span(name_fn()); |
| 139 | + const v = std.mem.span(version_fn()); |
| 140 | + try emitJson(stdout, alloc, "{{\"ok\":true,\"name\":\"{s}\",\"version\":\"{s}\"}}", .{ n, v }); |
| 141 | + return EXIT_OK; |
| 142 | + }, |
| 143 | + } |
| 144 | +} |
| 145 | + |
| 146 | +// Suppress "unused" warning for EXIT_UNWIRED — reserved for Phase 2.1. |
| 147 | +comptime { |
| 148 | + _ = EXIT_UNWIRED; |
| 149 | +} |
0 commit comments