P3.2: manifest model + std.json parse + validate

Define the v0 publish manifest, parse it via std.json into a typed
struct, and validate it.

- src/manifest/manifest.sx: typed Manifest { app, version, channel,
  artifacts: List(ManifestArtifact) } + ManifestArtifact { platform,
  path, filename, content_type, metadata }. parse_manifest walks the
  std.json Value tree into the struct, surfacing every malformed/
  missing/wrong-type field as a distinct typed ManifestErr (BadJson,
  WrongType, MissingField, UnknownPlatform, MissingArtifact, Io) — never
  a silent default. Reuses P2.1 parse_platform for the platform id.
  validate_manifest checks each artifact path exists on disk (fs.exists),
  resolved relative to the manifest's directory. Strings are copied into
  the caller's allocator (long-lived-container rule).
- examples/dist.json: representative valid manifest (android_apk + ios).
- examples/fixtures/: tiny stand-in artifact byte files referenced by it.
- tests/manifest_parse.sx: parses dist.json and asserts fields; asserts
  the three failure classes surface distinct typed errors.
This commit is contained in:
agra
2026-06-06 03:46:39 +03:00
parent 94e241180f
commit 09853f8882
5 changed files with 312 additions and 0 deletions

18
examples/dist.json Normal file
View File

@@ -0,0 +1,18 @@
{
"app": "acme-app",
"version": "1.2.3",
"channel": "stable",
"artifacts": [
{
"platform": "android_apk",
"path": "fixtures/acme-1.2.3-android.apk",
"filename": "acme.apk",
"content_type": "application/vnd.android.package-archive",
"metadata": "{\"min_sdk\":21}"
},
{
"platform": "ios",
"path": "fixtures/acme-1.2.3-ios.ipa"
}
]
}

View File

@@ -0,0 +1 @@
APK0

View File

@@ -0,0 +1 @@
IPA0

201
src/manifest/manifest.sx Normal file
View File

@@ -0,0 +1,201 @@
// =====================================================================
// manifest.sx — the v0 publish manifest: typed model + parse + validate
// (subplan 03, "Manifest Shape").
//
// A manifest (`examples/dist.json`) describes ONE publish: an app slug, a
// version, a target channel, and a list of artifacts (one binary per
// platform). It is parsed via `std.json` and walked into the typed
// `Manifest` below — every malformed / missing / wrong-type field surfaces
// as a machine-readable `ManifestErr`, NEVER a silent default.
//
// PATH RESOLUTION: an artifact `path` is interpreted RELATIVE TO THE
// DIRECTORY CONTAINING THE MANIFEST FILE. `load_manifest` resolves each
// path against `dirname(manifest_path)` before the on-disk existence check,
// so a manifest at `examples/dist.json` with `"path": "fixtures/x.apk"`
// references `examples/fixtures/x.apk`. `validate_manifest` takes that base
// directory explicitly so the resolution rule is visible at the call site.
//
// OWNERSHIP / LIFETIME (long-lived-container rule): `parse_manifest` COPIES
// every string field into the caller-provided `alloc` (via `dup_str`), so a
// returned `Manifest` — and its artifact `List` — is self-contained and
// outlives both the source bytes and the transient `std.json` value tree.
// The caller owns `alloc` (typically an Arena) and frees the whole manifest
// by dropping it.
// =====================================================================
#import "modules/std.sx";
#import "modules/std/json.sx";
#import "modules/fs.sx";
#import "../domain/platform.sx";
#import "../domain/validate.sx";
// Typed manifest failures. One distinct tag per failure class so a caller
// (and, later, the CLI) can report a precise reason instead of a bare bool.
// BadJson — the bytes were not valid JSON at all.
// WrongType — a field was present but the wrong JSON type.
// MissingField — a required field was absent or an empty string.
// UnknownPlatform — an artifact `platform` id did not name a Platform.
// MissingArtifact — an artifact `path` did not exist on disk.
// Io — the manifest file itself could not be read.
ManifestErr :: error {
BadJson,
WrongType,
MissingField,
UnknownPlatform,
MissingArtifact,
Io,
}
// One artifact entry. `platform` + `path` are required; `filename`,
// `content_type`, and `metadata` are optional overrides that default to ""
// when absent. `metadata` is an opaque string mirroring Artifact.metadata.
ManifestArtifact :: struct {
platform: Platform;
path: string;
filename: string;
content_type: string;
metadata: string;
}
// A single publish: which app/version/channel, and the artifacts that make
// it up. Strings are owned by the allocator passed to `parse_manifest`.
Manifest :: struct {
app: string;
version: string;
channel: string;
artifacts: List(ManifestArtifact);
}
// ── value-tree helpers (strict; copy strings into `alloc`) ────────────
// Copy `s` into `alloc`-owned, null-terminated storage so the manifest
// survives the source bytes / parse scratch being dropped.
dup_str :: (s: string, alloc: Allocator) -> string {
raw : [*]u8 = xx alloc.alloc(s.len + 1);
if s.len > 0 { memcpy(raw, s.ptr, s.len); }
raw[s.len] = 0;
return string.{ ptr = raw, len = s.len };
}
obj_find :: (o: Object, key: string) -> ?Value {
i := 0;
while i < o.len {
if o.items[i].key == key { return o.items[i].val; }
i += 1;
}
return null;
}
// Required string field, copied into `alloc`. Absent or empty -> MissingField;
// present-but-not-a-string -> WrongType.
req_str_nonempty :: (o: Object, key: string, alloc: Allocator) -> (string, !ManifestErr) {
v := obj_find(o, key);
if v == null { raise error.MissingField; }
val := v!;
if val != .str { raise error.WrongType; }
if val.str.len == 0 { raise error.MissingField; }
return dup_str(val.str, alloc);
}
// Optional string override, copied into `alloc`. Absent -> "" (a documented
// default, not a silent one); present-but-not-a-string -> WrongType.
opt_str :: (o: Object, key: string, alloc: Allocator) -> (string, !ManifestErr) {
v := obj_find(o, key);
if v == null { return ""; }
val := v!;
if val != .str { raise error.WrongType; }
return dup_str(val.str, alloc);
}
// Required array field. Absent -> MissingField; present-but-not-an-array ->
// WrongType.
req_arr :: (o: Object, key: string) -> (Array, !ManifestErr) {
v := obj_find(o, key);
if v == null { raise error.MissingField; }
val := v!;
if val != .array { raise error.WrongType; }
return val.array;
}
req_obj :: (v: Value) -> (Object, !ManifestErr) {
if v != .object { raise error.WrongType; }
return v.object;
}
// Walk one artifact object into a typed ManifestArtifact. `platform` must be
// present, a string, and a known Platform (reusing the P2.1 `parse_platform`
// — an unrecognized id surfaces as UnknownPlatform, never a default).
artifact_from_json :: (o: Object, alloc: Allocator) -> (ManifestArtifact, !ManifestErr) {
a : ManifestArtifact = .{};
pv := obj_find(o, "platform");
if pv == null { raise error.MissingField; }
pval := pv!;
if pval != .str { raise error.WrongType; }
if pval.str.len == 0 { raise error.MissingField; }
plat, pe := parse_platform(pval.str);
if pe { raise error.UnknownPlatform; }
a.platform = plat;
a.path = try req_str_nonempty(o, "path", alloc);
a.filename = try opt_str(o, "filename", alloc);
a.content_type = try opt_str(o, "content_type", alloc);
a.metadata = try opt_str(o, "metadata", alloc);
return a;
}
// ── public API ───────────────────────────────────────────────────────
// Parse manifest bytes into a typed `Manifest`. PURE: no filesystem access.
// Surfaces malformed JSON (BadJson), a non-object root or wrong-typed field
// (WrongType), a missing/empty required field (MissingField), and an unknown
// artifact platform (UnknownPlatform). All strings are copied into `alloc`.
parse_manifest :: (src: string, alloc: Allocator) -> (Manifest, !ManifestErr) {
root, pe := parse(src, alloc);
if pe { raise error.BadJson; }
if root != .object { raise error.WrongType; }
ro := root.object;
m : Manifest = .{};
m.app = try req_str_nonempty(ro, "app", alloc);
m.version = try req_str_nonempty(ro, "version", alloc);
m.channel = try req_str_nonempty(ro, "channel", alloc);
arts := try req_arr(ro, "artifacts");
i := 0;
while i < arts.len {
ao := try req_obj(arts.items[i]);
a := try artifact_from_json(ao, alloc);
m.artifacts.append(a, alloc);
i += 1;
}
return m;
}
// Filesystem validation of an already-parsed manifest: every artifact `path`,
// resolved against `base_dir`, must exist on disk — a missing file surfaces
// as MissingArtifact. (Required-field/non-empty/platform checks are enforced
// by `parse_manifest`; this pass adds only the on-disk existence check.)
validate_manifest :: (m: Manifest, base_dir: string) -> !ManifestErr {
i := 0;
while i < m.artifacts.len {
a := m.artifacts.items[i];
full := path_join(base_dir, a.path);
if !exists(full) { raise error.MissingArtifact; }
i += 1;
}
return;
}
// Read, parse, and validate a manifest file. Artifact paths resolve relative
// to the manifest's own directory (`dirname(path)`). Unreadable file -> Io;
// otherwise the parse/validate errors above.
load_manifest :: (path: string, alloc: Allocator) -> (Manifest, !ManifestErr) {
cpath := dup_str(path, alloc); // null-terminated for the libc read
src := read_file(cpath);
if src == null { raise error.Io; }
bytes := src!;
m := try parse_manifest(bytes, alloc);
try validate_manifest(m, dirname(path));
return m;
}

91
tests/manifest_parse.sx Normal file
View File

@@ -0,0 +1,91 @@
// Acceptance for P3.2 — the v0 publish manifest model + parse + validate.
//
// 1. Parses `examples/dist.json` into a typed `Manifest` and asserts the
// fields (app/version/channel, the artifact count, and each artifact's
// platform + path).
// 2. Asserts each failure class surfaces a DISTINCT machine-readable error
// (never a silent default):
// - a missing required field (a manifest lacking `version`) -> MissingField
// - an unknown platform ("psvita") -> UnknownPlatform
// - a non-existent artifact path -> MissingArtifact
//
// Prints a PASS/FAIL line per case and exits non-zero if any case behaves
// unexpectedly, so the test runner counts it as a failure.
#import "modules/std.sx";
#import "../src/domain/platform.sx";
#import "../src/manifest/manifest.sx";
// ── happy path: examples/dist.json -> typed Manifest ────────────────────
check_parse_valid :: (alloc: Allocator) -> bool {
m, e := load_manifest("examples/dist.json", alloc);
if e { return false; }
if m.app != "acme-app" { return false; }
if m.version != "1.2.3" { return false; }
if m.channel != "stable" { return false; }
if m.artifacts.len != 2 { return false; }
a0 := m.artifacts.items[0];
if a0.platform != .android_apk { return false; }
if a0.path != "fixtures/acme-1.2.3-android.apk" { return false; }
if a0.filename != "acme.apk" { return false; }
if a0.content_type != "application/vnd.android.package-archive" { return false; }
a1 := m.artifacts.items[1];
if a1.platform != .ios { return false; }
if a1.path != "fixtures/acme-1.2.3-ios.ipa" { return false; }
if a1.filename != "" { return false; } // optional override, absent -> ""
return true;
}
// ── failure: a required field (version) is absent -> MissingField ───────
check_missing_version :: (alloc: Allocator) -> bool {
src := "{\"app\":\"acme-app\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"ios\",\"path\":\"fixtures/acme-1.2.3-ios.ipa\"}]}";
_, e := parse_manifest(src, alloc);
return e == error.MissingField;
}
// ── failure: an artifact platform id is unknown -> UnknownPlatform ──────
check_unknown_platform :: (alloc: Allocator) -> bool {
src := "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"psvita\",\"path\":\"fixtures/acme-1.2.3-ios.ipa\"}]}";
_, e := parse_manifest(src, alloc);
return e == error.UnknownPlatform;
}
// ── failure: an artifact path does not exist on disk -> MissingArtifact ─
// Parse SUCCEEDS (parse is filesystem-free); the missing file surfaces only
// at validate time, against the manifest's base directory.
check_missing_artifact_path :: (alloc: Allocator) -> bool {
src := "{\"app\":\"acme-app\",\"version\":\"1.2.3\",\"channel\":\"stable\",\"artifacts\":[{\"platform\":\"ios\",\"path\":\"fixtures/does-not-exist.ipa\"}]}";
m, pe := parse_manifest(src, alloc);
if pe { return false; } // parse must not fail here
raised := false;
matched := false;
validate_manifest(m, "examples") catch err { raised = true; matched = (err == error.MissingArtifact); };
return raised and matched;
}
run_case :: (label: string, ok: bool) -> s32 {
if ok { print(" PASS {}\n", label); return 0; }
print(" FAIL {}\n", label);
return 1;
}
main :: () -> s32 {
gpa := GPA.init();
arena := Arena.init(xx gpa, 65536);
defer arena.deinit();
failures : s32 = 0;
failures += run_case("parse valid dist.json -> fields", check_parse_valid(xx arena));
failures += run_case("missing version -> MissingField", check_missing_version(xx arena));
failures += run_case("unknown platform 'psvita' -> UnknownPlatform", check_unknown_platform(xx arena));
failures += run_case("nonexistent path -> MissingArtifact", check_missing_artifact_path(xx arena));
print("------------------------------------------------\n");
if failures == 0 {
print("manifest_parse: ALL CASES PASS\n");
return 0;
}
print("manifest_parse: {} CASE(S) FAILED\n", failures);
return 1;
}