///|
fn bad(code : String, path : String, message : String) -> @ledger.InputError {
@ledger.InputError::Invalid({
code,
report_id: "",
path,
message,
line: 0,
column: 0,
})
}
///|
fn object(
value : Json,
path : String,
) -> Map[String, Json] raise @ledger.InputError {
match value {
Object(fields) => fields
_ => raise bad("ExpectedObject", path, "Expected a JSON object")
}
}
///|
fn keys(
fields : Map[String, Json],
allowed : ArrayView[String],
path : String,
) -> Unit raise @ledger.InputError {
for key, _ in fields {
if !allowed.contains(key) {
raise bad("UnknownField", path + "/" + key, "Unsupported field: " + key)
}
}
}
///|
fn required(
fields : Map[String, Json],
key : String,
path : String,
) -> Json raise @ledger.InputError {
match fields.get(key) {
Some(value) => value
None =>
raise bad("MissingField", path + "/" + key, "Required field is missing")
}
}
///|
fn string_field(
fields : Map[String, Json],
key : String,
path : String,
) -> String raise @ledger.InputError {
match required(fields, key, path) {
String(value) => value
_ => raise bad("ExpectedString", path + "/" + key, "Expected a JSON string")
}
}
///|
fn array(value : Json, path : String) -> Array[Json] raise @ledger.InputError {
match value {
Array(values) => values
_ => raise bad("ExpectedArray", path, "Expected a JSON array")
}
}
///|
fn positive_integer(
fields : Map[String, Json],
key : String,
path : String,
) -> Int32 raise @ledger.InputError {
match required(fields, key, path) {
Number(value, ..) if value >= 1.0 && value <= 2147483647.0 => {
let result = value.to_int()
if result.to_double() != value {
raise bad(
"ExpectedInteger",
path + "/" + key,
"Expected an exact positive 32-bit integer",
)
}
result
}
_ =>
raise bad(
"ExpectedInteger",
path + "/" + key,
"Expected an exact positive 32-bit integer",
)
}
}
///|
fn schema(fields : Map[String, Json]) -> Unit raise @ledger.InputError {
let version = positive_integer(fields, "schema", "$")
if version != 1 {
raise bad(
"UnsupportedSchema", "$/schema", "Only schema version 1 is supported",
)
}
}
///|
fn case_key(
value : Json,
path : String,
) -> @ledger.CaseKey raise @ledger.InputError {
let fields = object(value, path)
keys(fields, ["classname", "name"], path)
{
classname: string_field(fields, "classname", path),
name: string_field(fields, "name", path),
}
}
///|
/// Decode a complete expected artifact/test inventory. Unknown versions and
/// fields are refused; duplicate keys are checked in the original JSON text.
pub fn decode_plan(
source : String,
limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> @ledger.RunPlan raise @ledger.InputError {
let fields = object(strict_json(source, limits), "$")
keys(fields, ["schema", "run_id", "revision", "reports"], "$")
schema(fields)
let items = array(required(fields, "reports", "$"), "$/reports")
if items.length() > limits.max_reports {
raise bad(
"ReportLimit", "$/reports", "Expected report inventory exceeds the configured limit",
)
}
let reports : Array[@ledger.ExpectedReport] = []
let mut count = 0
for i, value in items {
let path = "$/reports/\{i}"
let item = object(value, path)
keys(item, ["id", "environment", "shard", "attempt", "cases"], path)
let cases : Array[@ledger.CaseKey] = []
for j, key in array(required(item, "cases", path), path + "/cases") {
count += 1
if count > limits.max_cases {
raise bad(
"CaseLimit",
path + "/cases",
"Expected case inventory exceeds the configured limit",
)
}
cases.push(case_key(key, path + "/cases/\{j}"))
}
reports.push({
id: string_field(item, "id", path),
environment: string_field(item, "environment", path),
shard: string_field(item, "shard", path),
attempt: positive_integer(item, "attempt", path),
cases,
})
}
@ledger.RunPlan::new(
string_field(fields, "run_id", "$"),
string_field(fields, "revision", "$"),
reports,
limits~,
)
}
///|
fn nonempty(value : String, path : String) -> String raise @ledger.InputError {
if value.is_empty() || value.contains("\u0000") {
raise bad("InvalidText", path, "Value must be nonempty and contain no NUL")
}
value
}
///|
/// Decode producer bindings separately from the expected plan. Paths are
/// relative to the bundle file in the CLI, or supplied directly by library users.
pub fn decode_bundle(
source : String,
limits~ : @ledger.Limits = @ledger.Limits::default(),
) -> Bundle raise @ledger.InputError {
let fields = object(strict_json(source, limits), "$")
keys(fields, ["schema", "documents"], "$")
schema(fields)
let items = array(required(fields, "documents", "$"), "$/documents")
if items.length() > limits.max_reports {
raise bad(
"ReportLimit", "$/documents", "Document inventory exceeds the configured limit",
)
}
let documents : Array[ReportFile] = []
for i, value in items {
let path = "$/documents/\{i}"
let item = object(value, path)
keys(
item,
["id", "run_id", "revision", "environment", "shard", "attempt", "path"],
path,
)
documents.push({
binding: {
id: nonempty(string_field(item, "id", path), path + "/id"),
run_id: nonempty(string_field(item, "run_id", path), path + "/run_id"),
revision: nonempty(
string_field(item, "revision", path),
path + "/revision",
),
environment: nonempty(
string_field(item, "environment", path),
path + "/environment",
),
shard: nonempty(string_field(item, "shard", path), path + "/shard"),
attempt: positive_integer(item, "attempt", path),
},
path: nonempty(string_field(item, "path", path), path + "/path"),
})
}
{ documents, }
}