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

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;
}