From 898e02f268e4d16148960aa8db089a05679fdc76 Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Sun, 12 Jul 2026 01:02:02 +0300 Subject: [PATCH] feat(cli): headless CLI subcommands + parity table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add src/cli.zig: the Studio binary's second face. maybeRun dispatches on argv and, for a known subcommand, runs it and exits before any window opens (called from main before runWithOptions — no runner eject needed, per the spike). A bare trace path still opens the GUI. Subcommands mirror the toolkit CLI: inspect, report (markdown/html), diff (text/json), anonymize, ci (exit 0/1), and scenario (list / run) — thin glue over the already-built pure engines. The render core is pure (bytes in, bytes out) and unit-tested with fixtures; the argv / init.io / stdout shell is thin. Refactor conformance/harness.zig to expose a callable runAll / runNamed / scenarioNames (shared by the conformance test and the ci/scenario commands). Document parity and the intentional differences (stdout-only, embedded-contract ci) in docs/cli-parity.md. Closes #45. --- CURRENT_STATE.md | 29 +- ROADMAP.md | 16 +- docs/cli-parity.md | 41 +++ src/cli.zig | 471 +++++++++++++++++++++++++++++++ src/main.zig | 7 + src/ocpp/conformance/harness.zig | 69 ++++- src/ocpp/ocpp.zig | 3 +- 7 files changed, 608 insertions(+), 28 deletions(-) create mode 100644 docs/cli-parity.md create mode 100644 src/cli.zig diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 83822a8..5f90ec2 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -8,11 +8,10 @@ ## Active milestone -**S4 — Analysis parity+ (0.3.0): in progress.** S0–S3 are complete (the engine -and the native inspector). S4 reaches parity with the toolkit's analysis surface; -report generation (#41), anonymize-on-export (#42), semantic trace diff (#43), and -replay (#44 — engine + manual-scrub transport) have landed. Only the headless CLI -(#45) remains (see [ROADMAP.md](ROADMAP.md)). +**S4 — Analysis parity+ (0.3.0): complete.** S0–S4 are done. S4 reached parity +with the toolkit's analysis surface — report generation (#41), anonymize (#42), +semantic diff (#43), replay (#44), and a headless CLI in the same binary (#45). +Next up is **S5 — Live capture** (the flagship; see [ROADMAP.md](ROADMAP.md)). ## What's done @@ -117,9 +116,9 @@ Deferred out of S3: interactive open — native dialog + drag-drop (#33) — and timeline viewport scroll on jump (session/failure), both of which need an ejected runner (see ADR-0006). Tracked as follow-ups, not blockers. -### S4 — Analysis parity+ (0.3.0) 🚧 +### S4 — Analysis parity+ (0.3.0) ✅ -In progress. Landed so far: +Every issue landed: - **Report generation (#41)** — `src/ocpp/report.zig` renders a trace analysis as Markdown and self-contained HTML, mirroring the toolkit's reporter (the same six @@ -150,12 +149,20 @@ In progress. Landed so far: (filtered) events, reusing `select_event`. Real wall-clock auto-play is deferred to the runner-eject bucket (#33), since the zero-config runner exposes no timer effect (spiked). +- **Headless CLI (#45)** — `src/cli.zig`, a second face in the same binary: + `inspect` / `report` / `diff` / `anonymize` / `ci` / `scenario`, dispatched in + `main` before any window opens (no runner eject — the spike confirmed a clean + `main`-branch). A testable render core (pure `render*(bytes) → bytes`) under a + thin argv / `init.io` / stdout shell; `conformance/harness.zig` grew a callable + `runAll` / `runNamed` for `ci` / `scenario`. Parity and the intentional + differences are documented in [docs/cli-parity.md](docs/cli-parity.md). ## What's next -Remaining **S4 — Analysis parity+ (0.3.0)** work: the headless CLI subcommands -(#45) — a clean `main`-branch (argv → engine → stdout), per the spike. Then -**S5 — Live capture** (the flagship). +**S5 — Live capture ⭐ (0.4.0)** — the flagship: a live WebSocket proxy between a +charge point and its CSMS, decoding OCPP frames in flight, running detection as +events stream, recording to the canonical trace format, and surfacing it in a +live timeline with OS notifications on critical failures. ## Known blockers / decisions pending @@ -170,5 +177,5 @@ Remaining **S4 — Analysis parity+ (0.3.0)** work: the headless CLI subcommands | `ocpp` (engine) | ✅ S2 + ingestion (#29) + reports (#41) + anonymize (#42) + diff (#43) + replay core (#44); O(n) detection pending (#36) | | `ui` (native views) | ✅ S3 inspector (#27–#32) + replay transport (#44) | | `capture` (live proxy) | ⬜ not started (S5) | -| `cli` (headless) | ⬜ not started (S4) | +| `cli` (headless) | ✅ inspect/report/diff/anonymize/ci/scenario (#45) | | `conformance` | ✅ done for S2 (15/15, `contract-v1`) | diff --git a/ROADMAP.md b/ROADMAP.md index 756a918..6476598 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -12,7 +12,7 @@ the stable part. | **S1** | 0.1.0 | Engine core | ✅ Complete | | **S2** | 0.1.0 | Detection + conformance | ✅ Complete | | **S3** | 0.2.0 | Inspector UI | ✅ Complete | -| **S4** | 0.3.0 | Analysis parity+ | Next up | +| **S4** | 0.3.0 | Analysis parity+ | ✅ Complete | | **S5** | 0.4.0 | Live capture ⭐ | Planned | | **S6** | 1.0.0 | 1.0 polish & launch | Planned | @@ -49,12 +49,16 @@ tab can hold. Traces open from command-line paths and a built-in sample; interactive open (native dialog + drag-drop) is deferred to #33 (needs an ejected runner, ADR-0006). -## S4 — Analysis parity+ (0.3.0) +## S4 — Analysis parity+ (0.3.0) ✅ -Reach parity with the toolkit's analysis surface: Markdown / HTML reports, -anonymize-on-export, trace diffing, and replay — with real wall-clock playback -that the offline library cannot offer. A headless CLI mode ships in the same -binary. +Parity with the toolkit's analysis surface: Markdown / HTML reports, +anonymize-on-export, trace diffing, and a deterministic replay engine with a +manual-scrub transport. A headless CLI ships in the **same binary** (`inspect` / +`report` / `diff` / `anonymize` / `ci` / `scenario`) — see +[docs/cli-parity.md](docs/cli-parity.md). **Exit criteria met:** all six issues +(#41–#45) merged; `native test` green (123 tests) and the app builds and runs the +CLI. Real wall-clock replay playback is deferred to #33 (the zero-config runner +exposes no timer effect), tracked as a follow-up. ## S5 — Live capture ⭐ (0.4.0) diff --git a/docs/cli-parity.md b/docs/cli-parity.md new file mode 100644 index 0000000..794364d --- /dev/null +++ b/docs/cli-parity.md @@ -0,0 +1,41 @@ +# CLI parity with the toolkit + +Studio ships a headless CLI in the **same binary** as the GUI: if the first +argument is a subcommand, it runs to completion and exits without opening a +window (a bare trace path, or no arguments, opens the inspector instead). The CLI +mirrors the toolkit's `ocpp-debugkit` command surface (source of truth: +`packages/toolkit/src/cli/`); this table records the mapping and every +intentional difference. + +| Toolkit command | Studio command | Parity | +| --- | --- | --- | +| `inspect ` | `studio inspect ` | ✅ Full — parses, analyzes, prints a counts + failures summary. | +| `report -f ` | `studio report [-f markdown\|html]` | ✅ Full — Markdown + self-contained HTML. | +| `diff --format ` | `studio diff [--format text\|json]` | ✅ Full — text and JSON. | +| `anonymize ` | `studio anonymize ` | ✅ Full — same field rules (see the anonymize module). | +| `ci [dir]` | `studio ci` | ⚠️ Runs the **vendored** `contract-v1` scenarios (Studio embeds the contract); the `[dir]` argument is not accepted. Exits `0` (all pass) / `1` (any fail). | +| `scenario list` | `studio scenario list` | ✅ Full. | +| `scenario run ` | `studio scenario run ` | ✅ Built-in scenarios. | +| `scenario run --file ` | — | ⚠️ Deferred — Studio runs only the built-in contract scenarios. | + +## Intentional differences + +- **Output is stdout; no `-o ` flag.** Redirect to save: + `studio report trace.json -f html > report.html`. The toolkit's `-o` + convenience is a deferred follow-up (it needs the sandboxed `init.io` write + path); redirection covers the same need today. +- **`ci` runs the embedded contract**, not a user directory. Studio vendors the + 15 `contract-v1` fixtures + goldens under `src/ocpp/conformance/` (ADR-0004), + so `ci` is a self-contained conformance gate — the same logic the + `native test` conformance harness runs. +- **One binary, two faces.** The CLI shares the exact pure engine the GUI uses + (`src/ocpp/`); there is no separate code path to drift. A bare trace path opens + the GUI: `studio path/to/trace.json`. + +## Exit codes + +| Code | Meaning | +| --- | --- | +| `0` | Success (for `ci` / `scenario run`: all scenarios matched their goldens). | +| `1` | Runtime failure — a file could not be read, or a scenario mismatched. | +| `2` | Usage error — a missing/invalid argument, or an unknown scenario name. | diff --git a/src/cli.zig b/src/cli.zig new file mode 100644 index 0000000..3d2aa10 --- /dev/null +++ b/src/cli.zig @@ -0,0 +1,471 @@ +//! Headless CLI — the Studio binary's second face. +//! +//! `maybeRun` inspects argv; when the first argument is a known subcommand it +//! runs to completion and returns an exit code, and `main` then exits WITHOUT +//! opening a window (it calls `maybeRun` before `runWithOptions`). Everything the +//! CLI computes flows through the same pure engine the GUI uses. +//! +//! Two layers: +//! * **render core** — `render*` functions are pure (bytes in, owned bytes out) +//! so they are unit-tested with fixtures and no I/O. +//! * **I/O shell** — `maybeRun` + the `cmd*` handlers parse argv, read files via +//! `init.io`, and write results to stdout. +//! +//! Output goes to stdout (redirect to save: `studio report t.json > out.md`); a +//! `-o ` flag is a deferred convenience (see docs/cli-parity.md). + +const std = @import("std"); +const ocpp = @import("ocpp/ocpp.zig"); +const parser = ocpp.parser; +const timeline = ocpp.timeline; +const detection = ocpp.detection; +const summarizer = ocpp.summarizer; +const types = ocpp.types; +const report = ocpp.report; +const anonymize = ocpp.anonymize; +const diff = ocpp.diff; +const conformance = ocpp.conformance; + +const Allocator = std.mem.Allocator; + +/// Largest CLI-opened trace file (matches the GUI's command-line cap). +const max_file_bytes: usize = 256 * 1024 * 1024; + +const ReportFormat = enum { markdown, html }; +const DiffFormat = enum { text, json }; + +// --------------------------------------------------------------------------- +// Dispatch +// --------------------------------------------------------------------------- + +/// If argv names a CLI subcommand, run it and return its exit code (0 = success). +/// Returns null when there is no subcommand — the caller should open the GUI. +pub fn maybeRun(init: std.process.Init) ?u8 { + const gpa = std.heap.page_allocator; + const args = init.minimal.args.toSlice(gpa) catch return null; + defer gpa.free(args); + if (args.len < 2) return null; + + const cmd = args[1]; + const rest = args[2..]; + + if (eql(cmd, "inspect")) return cmdInspect(gpa, init, rest); + if (eql(cmd, "report")) return cmdReport(gpa, init, rest); + if (eql(cmd, "diff")) return cmdDiff(gpa, init, rest); + if (eql(cmd, "anonymize")) return cmdAnonymize(gpa, init, rest); + if (eql(cmd, "ci")) return cmdCi(gpa, init.io); + if (eql(cmd, "scenario")) return cmdScenario(gpa, init.io, rest); + if (eql(cmd, "help") or eql(cmd, "--help") or eql(cmd, "-h")) { + _ = emit(init.io, help_text); + return 0; + } + // Anything else is (probably) a trace path — let `main` open the GUI. + return null; +} + +const help_text = + \\OCPP DebugKit Studio — headless CLI + \\ + \\Usage: studio [args] + \\ inspect Parse and analyze a trace; print a summary. + \\ report [-f markdown|html] Generate a report (stdout). + \\ diff [--format text|json] Compare two traces. + \\ anonymize Strip sensitive fields (stdout). + \\ ci Run the conformance scenarios; exit 0/1. + \\ scenario list | run List or run a conformance scenario. + \\ + \\With no command, a trace path opens the GUI: studio path/to/trace.json + \\ +; + +// --------------------------------------------------------------------------- +// Render core (pure: bytes in, owned bytes out) +// --------------------------------------------------------------------------- + +/// Full engine pipeline over trusted input → an `AnalysisResult`. Detection is +/// skipped past its cap (ADR-0007); the trace still parses and correlates. +fn buildAnalysis(a: Allocator, trace_bytes: []const u8) !report.AnalysisResult { + const parsed = try parser.parseTraceTrusted(a, trace_bytes); + const sessions = try timeline.buildSessionTimeline(a, parsed.events); + const failures: []const types.Failure = if (parsed.events.len > detection.max_events_for_detection) + &.{} + else + try detection.detectFailures(a, parsed.events, sessions); + const summaries = try summarizer.summarizeSessions(a, sessions, failures); + return .{ + .events = parsed.events, + .sessions = sessions, + .failures = failures, + .summaries = summaries, + .warnings = parsed.warnings, + .metadata = null, + }; +} + +pub fn renderInspect(a: Allocator, trace_bytes: []const u8) ![]u8 { + const r = try buildAnalysis(a, trace_bytes); + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(a); + try appf(&buf, a, "OCPP DebugKit — trace inspection\n\n", .{}); + try appf(&buf, a, "Events: {d}\nSessions: {d}\nFailures: {d}\nWarnings: {d}\n", .{ + r.events.len, r.sessions.len, r.failures.len, r.warnings.len, + }); + if (r.failures.len > 0) { + try appf(&buf, a, "\nFailures:\n", .{}); + for (r.failures) |f| { + try appf(&buf, a, " [{s}] {s} - {s}\n", .{ f.severity.toWire(), f.code.toWire(), f.description }); + } + } + return buf.toOwnedSlice(a); +} + +pub fn renderReport(a: Allocator, trace_bytes: []const u8, format: ReportFormat) ![]u8 { + const r = try buildAnalysis(a, trace_bytes); + return switch (format) { + .markdown => report.generateMarkdownReport(a, r), + .html => report.generateHtmlReport(a, r), + }; +} + +pub fn renderAnonymize(a: Allocator, trace_bytes: []const u8) ![]u8 { + return anonymize.anonymizeJsonText(a, trace_bytes); +} + +pub fn renderDiff(a: Allocator, a_bytes: []const u8, b_bytes: []const u8, format: DiffFormat) ![]u8 { + const pa = try parser.parseTraceTrusted(a, a_bytes); + const pb = try parser.parseTraceTrusted(a, b_bytes); + const d = try diff.diffTraces(a, pa, pb); + return switch (format) { + .text => renderDiffText(a, d), + .json => renderDiffJson(a, d), + }; +} + +fn renderDiffText(a: Allocator, d: diff.TraceDiff) ![]u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(a); + try appf(&buf, a, "OCPP DebugKit - trace diff\n\n", .{}); + try appf(&buf, a, "Only in A: {d}\n", .{d.only_in_a.len}); + for (d.only_in_a) |e| try appf(&buf, a, " - {s}\n", .{e.message_id}); + try appf(&buf, a, "Only in B: {d}\n", .{d.only_in_b.len}); + for (d.only_in_b) |e| try appf(&buf, a, " + {s}\n", .{e.message_id}); + try appf(&buf, a, "Modified fields: {d}\n", .{d.modified.len}); + for (d.modified) |m| try appf(&buf, a, " ~ {s} {s}: {s} -> {s}\n", .{ m.message_id, @tagName(m.field), m.value_a, m.value_b }); + try appf(&buf, a, "Failures only in A: {d}\n", .{d.failures_only_in_a.len}); + for (d.failures_only_in_a) |f| try appf(&buf, a, " - {s}\n", .{f.code.toWire()}); + try appf(&buf, a, "Failures only in B: {d}\n", .{d.failures_only_in_b.len}); + for (d.failures_only_in_b) |f| try appf(&buf, a, " + {s}\n", .{f.code.toWire()}); + if (d.summary_diff.differences.len > 0) { + try appf(&buf, a, "Summary differences:\n", .{}); + for (d.summary_diff.differences) |s| try appf(&buf, a, " {s}\n", .{s}); + } + return buf.toOwnedSlice(a); +} + +fn renderDiffJson(a: Allocator, d: diff.TraceDiff) ![]u8 { + var buf: std.ArrayList(u8) = .empty; + errdefer buf.deinit(a); + try appf(&buf, a, "{{\"onlyInA\":", .{}); + try jsonMessageIds(&buf, a, d.only_in_a); + try appf(&buf, a, ",\"onlyInB\":", .{}); + try jsonMessageIds(&buf, a, d.only_in_b); + try appf(&buf, a, ",\"modified\":[", .{}); + for (d.modified, 0..) |m, i| { + if (i > 0) try buf.append(a, ','); + try appf(&buf, a, "{{\"messageId\":", .{}); + try jsonString(&buf, a, m.message_id); + try appf(&buf, a, ",\"field\":", .{}); + try jsonString(&buf, a, @tagName(m.field)); + try appf(&buf, a, ",\"valueA\":", .{}); + try jsonString(&buf, a, m.value_a); + try appf(&buf, a, ",\"valueB\":", .{}); + try jsonString(&buf, a, m.value_b); + try buf.append(a, '}'); + } + try appf(&buf, a, "],\"failuresOnlyInA\":", .{}); + try jsonFailureCodes(&buf, a, d.failures_only_in_a); + try appf(&buf, a, ",\"failuresOnlyInB\":", .{}); + try jsonFailureCodes(&buf, a, d.failures_only_in_b); + try appf(&buf, a, ",\"summaryDifferences\":[", .{}); + for (d.summary_diff.differences, 0..) |s, i| { + if (i > 0) try buf.append(a, ','); + try jsonString(&buf, a, s); + } + try appf(&buf, a, "]}}\n", .{}); + return buf.toOwnedSlice(a); +} + +fn jsonMessageIds(buf: *std.ArrayList(u8), a: Allocator, events: []const types.Event) !void { + try buf.append(a, '['); + for (events, 0..) |e, i| { + if (i > 0) try buf.append(a, ','); + try jsonString(buf, a, e.message_id); + } + try buf.append(a, ']'); +} + +fn jsonFailureCodes(buf: *std.ArrayList(u8), a: Allocator, failures: []const types.Failure) !void { + try buf.append(a, '['); + for (failures, 0..) |f, i| { + if (i > 0) try buf.append(a, ','); + try jsonString(buf, a, f.code.toWire()); + } + try buf.append(a, ']'); +} + +fn jsonString(buf: *std.ArrayList(u8), a: Allocator, s: []const u8) !void { + try buf.append(a, '"'); + for (s) |c| switch (c) { + '"' => try buf.appendSlice(a, "\\\""), + '\\' => try buf.appendSlice(a, "\\\\"), + '\n' => try buf.appendSlice(a, "\\n"), + '\r' => try buf.appendSlice(a, "\\r"), + '\t' => try buf.appendSlice(a, "\\t"), + else => if (c < 0x20) try appf(buf, a, "\\u{x:0>4}", .{c}) else try buf.append(a, c), + }; + try buf.append(a, '"'); +} + +// --------------------------------------------------------------------------- +// Command handlers (I/O shell) +// --------------------------------------------------------------------------- + +fn cmdInspect(gpa: Allocator, init: std.process.Init, args: []const []const u8) u8 { + if (args.len != 1) return usageErr("inspect: expected exactly "); + const bytes = readFile(init, gpa, args[0]) catch |e| return ioErr("inspect", args[0], e); + defer gpa.free(bytes); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const out = renderInspect(arena.allocator(), bytes) catch |e| return renderErr("inspect", e); + return emit(init.io, out); +} + +fn cmdReport(gpa: Allocator, init: std.process.Init, args: []const []const u8) u8 { + var file: ?[]const u8 = null; + var format: ReportFormat = .markdown; + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (eql(arg, "-f") or eql(arg, "--format")) { + i += 1; + if (i >= args.len) return usageErr("report: -f needs a value"); + format = parseReportFormat(args[i]) orelse return usageErr("report: format must be 'markdown' or 'html'"); + } else if (file == null) { + file = arg; + } else return usageErr("report: unexpected extra argument"); + } + const path = file orelse return usageErr("report: missing "); + const bytes = readFile(init, gpa, path) catch |e| return ioErr("report", path, e); + defer gpa.free(bytes); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const out = renderReport(arena.allocator(), bytes, format) catch |e| return renderErr("report", e); + return emit(init.io, out); +} + +fn cmdDiff(gpa: Allocator, init: std.process.Init, args: []const []const u8) u8 { + var a_path: ?[]const u8 = null; + var b_path: ?[]const u8 = null; + var format: DiffFormat = .text; + var i: usize = 0; + while (i < args.len) : (i += 1) { + const arg = args[i]; + if (eql(arg, "--format")) { + i += 1; + if (i >= args.len) return usageErr("diff: --format needs a value"); + format = parseDiffFormat(args[i]) orelse return usageErr("diff: format must be 'text' or 'json'"); + } else if (a_path == null) { + a_path = arg; + } else if (b_path == null) { + b_path = arg; + } else return usageErr("diff: unexpected extra argument"); + } + const pa = a_path orelse return usageErr("diff: missing "); + const pb = b_path orelse return usageErr("diff: missing "); + const a_bytes = readFile(init, gpa, pa) catch |e| return ioErr("diff", pa, e); + defer gpa.free(a_bytes); + const b_bytes = readFile(init, gpa, pb) catch |e| return ioErr("diff", pb, e); + defer gpa.free(b_bytes); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const out = renderDiff(arena.allocator(), a_bytes, b_bytes, format) catch |e| return renderErr("diff", e); + return emit(init.io, out); +} + +fn cmdAnonymize(gpa: Allocator, init: std.process.Init, args: []const []const u8) u8 { + if (args.len != 1) return usageErr("anonymize: expected exactly "); + const bytes = readFile(init, gpa, args[0]) catch |e| return ioErr("anonymize", args[0], e); + defer gpa.free(bytes); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + const out = renderAnonymize(arena.allocator(), bytes) catch |e| return renderErr("anonymize", e); + return emit(init.io, out); +} + +fn cmdCi(gpa: Allocator, io: std.Io) u8 { + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + var buf: [8192]u8 = undefined; + var fw = std.Io.File.stdout().writer(io, &buf); + const w = &fw.interface; + const all_ok = conformance.runAll(arena.allocator(), w) catch |e| return renderErr("ci", e); + w.flush() catch return 1; + return if (all_ok) 0 else 1; +} + +fn cmdScenario(gpa: Allocator, io: std.Io, args: []const []const u8) u8 { + if (args.len < 1) return usageErr("scenario: expected 'list' or 'run '"); + var arena = std.heap.ArenaAllocator.init(gpa); + defer arena.deinit(); + var buf: [4096]u8 = undefined; + var fw = std.Io.File.stdout().writer(io, &buf); + const w = &fw.interface; + + if (eql(args[0], "list")) { + for (conformance.scenarioNames()) |name| w.print("{s}\n", .{name}) catch return 1; + w.flush() catch return 1; + return 0; + } + if (eql(args[0], "run")) { + if (args.len < 2) return usageErr("scenario run: missing "); + const res = conformance.runNamed(arena.allocator(), w, args[1]) catch |e| return renderErr("scenario", e); + w.flush() catch return 1; + const ok = res orelse { + std.debug.print("error: unknown scenario '{s}'\n", .{args[1]}); + return 2; + }; + return if (ok) 0 else 1; + } + return usageErr("scenario: expected 'list' or 'run '"); +} + +// --------------------------------------------------------------------------- +// Small helpers +// --------------------------------------------------------------------------- + +fn eql(a: []const u8, b: []const u8) bool { + return std.mem.eql(u8, a, b); +} + +fn parseReportFormat(s: []const u8) ?ReportFormat { + if (eql(s, "markdown") or eql(s, "md")) return .markdown; + if (eql(s, "html")) return .html; + return null; +} + +fn parseDiffFormat(s: []const u8) ?DiffFormat { + if (eql(s, "text")) return .text; + if (eql(s, "json")) return .json; + return null; +} + +fn readFile(init: std.process.Init, gpa: Allocator, path: []const u8) ![]u8 { + return std.Io.Dir.cwd().readFileAlloc(init.io, path, gpa, .limited(max_file_bytes)); +} + +/// Write `bytes` to stdout. Returns 0, or 1 on a write failure. +fn emit(io: std.Io, bytes: []const u8) u8 { + std.Io.File.stdout().writeStreamingAll(io, bytes) catch return 1; + return 0; +} + +fn appf(buf: *std.ArrayList(u8), gpa: Allocator, comptime fmt: []const u8, args: anytype) !void { + const s = try std.fmt.allocPrint(gpa, fmt, args); + defer gpa.free(s); + try buf.appendSlice(gpa, s); +} + +fn usageErr(msg: []const u8) u8 { + std.debug.print("error: {s}\n", .{msg}); + return 2; +} + +fn ioErr(cmd: []const u8, path: []const u8, e: anyerror) u8 { + std.debug.print("error: {s}: cannot read {s}: {s}\n", .{ cmd, path, @errorName(e) }); + return 1; +} + +fn renderErr(cmd: []const u8, e: anyerror) u8 { + std.debug.print("error: {s}: {s}\n", .{ cmd, @errorName(e) }); + return 1; +} + +// --------------------------------------------------------------------------- +// Tests (render core + scenario runner) +// --------------------------------------------------------------------------- + +const testing = std.testing; +const sample = @embedFile("ocpp/testdata/normal-session.json"); +const failed_auth = @embedFile("ocpp/conformance/fixtures/failed-auth.json"); +const normal_fixture = @embedFile("ocpp/conformance/fixtures/normal-session.json"); + +fn contains(haystack: []const u8, needle: []const u8) bool { + return std.mem.indexOf(u8, haystack, needle) != null; +} + +test "renderInspect summarizes counts" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const out = try renderInspect(arena.allocator(), sample); + try testing.expect(contains(out, "OCPP DebugKit — trace inspection")); + try testing.expect(contains(out, "Events: 22")); + try testing.expect(contains(out, "Sessions: 1")); +} + +test "renderReport emits Markdown and HTML" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const md = try renderReport(a, sample, .markdown); + try testing.expect(contains(md, "# OCPP DebugKit — Trace Analysis Report")); + const html = try renderReport(a, sample, .html); + try testing.expect(std.mem.startsWith(u8, html, "")); +} + +test "renderAnonymize replaces sensitive fields" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const out = try renderAnonymize(arena.allocator(), + \\{"events":[{"message":[2,"m1","Authorize",{"idTag":"SECRET"}]}]} + ); + try testing.expect(contains(out, "\"idTag\": \"anonymized\"")); + try testing.expect(!contains(out, "SECRET")); +} + +test "renderDiff produces text and JSON over failed-auth vs normal" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const text = try renderDiff(a, failed_auth, normal_fixture, .text); + try testing.expect(contains(text, "trace diff")); + try testing.expect(contains(text, "FAILED_AUTHORIZATION")); // detected only in A + try testing.expect(contains(text, "Failures only in B: 0")); // the clean trace has none + + const json = try renderDiff(a, failed_auth, normal_fixture, .json); + try testing.expect(contains(json, "FAILED_AUTHORIZATION")); + try testing.expect(contains(json, "\"failuresOnlyInB\":[]")); + // Valid JSON that re-parses. + _ = try std.json.parseFromSliceLeaky(std.json.Value, a, json, .{}); +} + +test "the scenario runner passes every locked golden" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + var out: [4096]u8 = undefined; + var w = std.Io.Writer.fixed(&out); + const all_ok = try conformance.runAll(arena.allocator(), &w); + try testing.expect(all_ok); + try testing.expect(contains(w.buffered(), "PASS normal-session")); +} + +test "runNamed distinguishes a known scenario from an unknown one" { + var arena = std.heap.ArenaAllocator.init(testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + var out: [1024]u8 = undefined; + var w = std.Io.Writer.fixed(&out); + try testing.expectEqual(@as(?bool, true), try conformance.runNamed(a, &w, "failed-auth")); + var out2: [1024]u8 = undefined; + var w2 = std.Io.Writer.fixed(&out2); + try testing.expectEqual(@as(?bool, null), try conformance.runNamed(a, &w2, "does-not-exist")); +} diff --git a/src/main.zig b/src/main.zig index 3d13e0f..a68cd47 100644 --- a/src/main.zig +++ b/src/main.zig @@ -13,6 +13,7 @@ const runner = @import("runner"); const native_sdk = @import("native_sdk"); const workspace = @import("ui/workspace.zig"); const inspector = @import("ui/inspector.zig"); +const cli = @import("cli.zig"); pub const panic = std.debug.FullPanic(native_sdk.debug.capturePanic); @@ -58,6 +59,11 @@ const InspectorApp = native_sdk.UiApp(Model, Msg); const max_trace_file_bytes: usize = 256 * 1024 * 1024; pub fn main(init: std.process.Init) !void { + // Second face: if argv names a CLI subcommand, run it to completion and exit + // BEFORE any window/app setup — no GUI is created. A bare trace path (or no + // args) returns null and falls through to the inspector below. + if (cli.maybeRun(init)) |code| std.process.exit(code); + // The app struct (and the Model) are multi-MB: `create` heap-allocates and // constructs in place so neither rides the stack. const app_state = try InspectorApp.create(std.heap.page_allocator, .{ @@ -111,4 +117,5 @@ test { _ = @import("tests.zig"); _ = @import("ocpp/ocpp.zig"); _ = @import("ui/ui.zig"); + _ = @import("cli.zig"); } diff --git a/src/ocpp/conformance/harness.zig b/src/ocpp/conformance/harness.zig index 9e0b667..c2fe451 100644 --- a/src/ocpp/conformance/harness.zig +++ b/src/ocpp/conformance/harness.zig @@ -9,6 +9,9 @@ //! toolkit (the source of truth), tagged `contract-v1`; they are not authored by //! hand. See `README.md` in this directory. This layout lives under `src/` so the //! zero-config build can `@embedFile` it (ADR-0004). +//! +//! The scenario runner is exposed (`runAll` / `runNamed` / `scenarioNames`) so +//! the headless CLI's `ci` and `scenario` subcommands share this exact logic. const std = @import("std"); const parser = @import("../parser.zig"); @@ -34,22 +37,55 @@ const scenario_names = [_][]const u8{ "unresponsive-csms", }; -test "conformance: detected failure codes match the locked goldens" { - var any_failed = false; +/// The shared scenario names, in contract order. +pub fn scenarioNames() []const []const u8 { + return &scenario_names; +} + +/// Run every scenario, writing a `PASS ` / `FAIL ` line per scenario +/// to `writer`. Returns true iff every detected-failure set matched its golden. +pub fn runAll(gpa: std.mem.Allocator, writer: *std.Io.Writer) !bool { + var all_ok = true; inline for (scenario_names) |name| { - runScenario( + const ok = scenarioMatches( + gpa, name, @embedFile("fixtures/" ++ name ++ ".json"), @embedFile("goldens/" ++ name ++ ".json"), - ) catch { - any_failed = true; - }; + ); + try writer.print("{s} {s}\n", .{ if (ok) "PASS" else "FAIL", name }); + if (!ok) all_ok = false; } - try std.testing.expect(!any_failed); + return all_ok; +} + +/// Run the single scenario named `name`. Returns null if no scenario has that +/// name; otherwise whether it matched its golden (writing a PASS/FAIL line). +pub fn runNamed(gpa: std.mem.Allocator, writer: *std.Io.Writer, name: []const u8) !?bool { + inline for (scenario_names) |sname| { + if (std.mem.eql(u8, name, sname)) { + const ok = scenarioMatches( + gpa, + sname, + @embedFile("fixtures/" ++ sname ++ ".json"), + @embedFile("goldens/" ++ sname ++ ".json"), + ); + try writer.print("{s} {s}\n", .{ if (ok) "PASS" else "FAIL", sname }); + return ok; + } + } + return null; +} + +/// True iff the trace's de-duplicated, sorted detected `FailureCode` set equals +/// the golden. Self-contained (owns an arena from `gpa`); prints a diagnostic to +/// stderr on mismatch. Any engine error counts as a non-match. +fn scenarioMatches(gpa: std.mem.Allocator, name: []const u8, trace_json: []const u8, golden_json: []const u8) bool { + return scenarioMatchesErr(gpa, name, trace_json, golden_json) catch false; } -fn runScenario(name: []const u8, trace_json: []const u8, golden_json: []const u8) !void { - var arena = std.heap.ArenaAllocator.init(std.testing.allocator); +fn scenarioMatchesErr(gpa: std.mem.Allocator, name: []const u8, trace_json: []const u8, golden_json: []const u8) !bool { + var arena = std.heap.ArenaAllocator.init(gpa); defer arena.deinit(); const a = arena.allocator(); @@ -78,8 +114,9 @@ fn runScenario(name: []const u8, trace_json: []const u8, golden_json: []const u8 std.debug.print("\n detected: ", .{}); printCodes(detected.items); std.debug.print("\n", .{}); - return error.ConformanceMismatch; + return false; } + return true; } fn equalCodes(a: []const []const u8, b: []const []const u8) bool { @@ -102,3 +139,15 @@ fn printCodes(codes: []const []const u8) void { } std.debug.print("]", .{}); } + +test "conformance: detected failure codes match the locked goldens" { + inline for (scenario_names) |name| { + const ok = scenarioMatches( + std.testing.allocator, + name, + @embedFile("fixtures/" ++ name ++ ".json"), + @embedFile("goldens/" ++ name ++ ".json"), + ); + try std.testing.expect(ok); + } +} diff --git a/src/ocpp/ocpp.zig b/src/ocpp/ocpp.zig index cbe406d..581c27e 100644 --- a/src/ocpp/ocpp.zig +++ b/src/ocpp/ocpp.zig @@ -14,6 +14,7 @@ pub const report = @import("report.zig"); pub const anonymize = @import("anonymize.zig"); pub const diff = @import("diff.zig"); pub const replay = @import("replay.zig"); +pub const conformance = @import("conformance/harness.zig"); test { _ = types; @@ -26,5 +27,5 @@ test { _ = anonymize; _ = diff; _ = replay; - _ = @import("conformance/harness.zig"); + _ = conformance; }