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:
18
examples/dist.json
Normal file
18
examples/dist.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
examples/fixtures/acme-1.2.3-android.apk
Normal file
1
examples/fixtures/acme-1.2.3-android.apk
Normal file
@@ -0,0 +1 @@
|
||||
APK0
|
||||
1
examples/fixtures/acme-1.2.3-ios.ipa
Normal file
1
examples/fixtures/acme-1.2.3-ios.ipa
Normal file
@@ -0,0 +1 @@
|
||||
IPA0
|
||||
201
src/manifest/manifest.sx
Normal file
201
src/manifest/manifest.sx
Normal 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
91
tests/manifest_parse.sx
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user