use crate::expr::FORBIDDEN_OBJECT_KEY;
use crate::value::Value;
use serde_json::Value as J;
#[derive(Debug, Clone)]
pub struct PortabilityError {
pub path: String,
pub message: String,
}
impl std::fmt::Display for PortabilityError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Portability Guard at {}: {}", self.path, self.message)
}
}
impl std::error::Error for PortabilityError {}
pub fn assert_portable(value: &Value) -> Result<(), PortabilityError> {
assert_portable_at(value, "$")
}
fn assert_portable_at(value: &Value, path: &str) -> Result<(), PortabilityError> {
match value {
Value::Float(f) if !f.is_finite() => Err(PortabilityError {
path: path.to_string(),
message: format!("non-finite float {f} cannot be placed in portable IR"),
}),
Value::Arr(a) => {
for (i, v) in a.iter().enumerate() {
assert_portable_at(v, &format!("{path}[{i}]"))?;
}
Ok(())
}
Value::Obj(o) => {
for (k, v) in o {
assert_portable_at(v, &format!("{path}.{k}"))?;
}
Ok(())
}
_ => Ok(()),
}
}
pub const PORTABLE_EXPR_OPERATORS: &[&str] = &[
"int", "float", "ref", "refOpt", "obj", "arr", "add", "sub", "mul", "neg", "div", "mod",
"concat", "eq", "ne", "lt", "le", "gt", "ge", "and", "or", "not", "coalesce", "cond", "len",
];
fn perr<T>(path: &str, message: impl Into<String>) -> Result<T, PortabilityError> {
Err(PortabilityError {
path: path.to_string(),
message: message.into(),
})
}
pub const PORTABLE_SCALAR_TYPES: &[&str] = &["string", "int", "float", "bool", "null"];
fn assert_portable_type_notation(node: &J, path: &str) -> Result<(), PortabilityError> {
match node {
J::String(s) => {
if !PORTABLE_SCALAR_TYPES.contains(&s.as_str()) {
return perr(path, format!("unknown scalar type '{s}' (fail-closed)"));
}
Ok(())
}
J::Object(o) => {
if o.len() != 1 {
return perr(
path,
format!(
"compound type must be a single-key object (opt|arr|obj), got {} keys (fail-closed)",
o.len()
),
);
}
let (kind, arg) = o.iter().next().unwrap();
match kind.as_str() {
"opt" | "arr" => assert_portable_type_notation(arg, &format!("{path}.{kind}")),
"obj" => {
let Some(fields) = arg.as_object() else {
return perr(
&format!("{path}.obj"),
"{obj: ...} type expects a field->type object (fail-closed)".to_string(),
);
};
for (k, v) in fields {
if k == FORBIDDEN_OBJECT_KEY {
return perr(
&format!("{path}.obj"),
format!("object type key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
);
}
assert_portable_type_notation(v, &format!("{path}.obj.{k}"))?;
}
Ok(())
}
other => perr(
path,
format!("unknown compound type kind '{other}' (opt|arr|obj) (fail-closed)"),
),
}
}
_ => perr(
path,
"type notation must be a scalar-type string or a single-key {opt|arr|obj} object (fail-closed)".to_string(),
),
}
}
pub fn assert_portable_component_graph(ir: &J) -> Result<(), PortabilityError> {
assert_portable_cg_at(ir, "$")
}
fn assert_portable_cg_at(ir: &J, path: &str) -> Result<(), PortabilityError> {
let components = ir
.get("components")
.and_then(|c| c.as_array())
.ok_or_else(|| PortabilityError {
path: format!("{path}.components"),
message: "IR.components must be an array".into(),
})?;
for (ci, c) in components.iter().enumerate() {
assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
}
Ok(())
}
fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
return perr(&format!("{path}.name"), "component.name must be a string");
}
let body = c
.get("body")
.and_then(|b| b.as_array())
.ok_or_else(|| PortabilityError {
path: format!("{path}.body"),
message: "component.body must be an array".into(),
})?;
for (ni, n) in body.iter().enumerate() {
assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
}
assert_portable_expr(
c.get("output").unwrap_or(&J::Null),
&format!("{path}.output"),
)?;
if let Some(ot) = c.get("outputType") {
assert_portable_type_notation(ot, &format!("{path}.outputType"))?;
}
Ok(())
}
fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
assert_portable_body_node_kind(n, path)?;
if let Some(ot) = n.get("outType") {
assert_portable_type_notation(ot, &format!("{path}.outType"))?;
}
Ok(())
}
fn assert_portable_body_node_kind(n: &J, path: &str) -> Result<(), PortabilityError> {
if let Some(m) = n.get("map") {
require_string_component(m.get("component"), &format!("{path}.map.component"))?;
assert_portable_expr(
m.get("over").unwrap_or(&J::Null),
&format!("{path}.map.over"),
)?;
assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
if let Some(w) = m.get("when") {
assert_portable_expr(w, &format!("{path}.map.when"))?;
}
if let Some(into) = m.get("into") {
if !into.is_string() {
return perr(&format!("{path}.map.into"), "'into' must be a string key");
}
}
if let Some(b) = m.get("batched") {
if !b.is_boolean() {
return perr(
&format!("{path}.map.batched"),
"'batched' must be a boolean",
);
}
}
Ok(())
} else if let Some(co) = n.get("cond") {
assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
assert_portable_expr(
co.get("then").unwrap_or(&J::Null),
&format!("{path}.cond.then"),
)?;
assert_portable_expr(
co.get("else").unwrap_or(&J::Null),
&format!("{path}.cond.else"),
)
} else if n.get("component").is_some() {
require_string_component(n.get("component"), &format!("{path}.component"))?;
assert_portable_ports(n.get("ports"), &format!("{path}.ports"))
} else {
perr(path, "unknown body node kind (not componentRef/map/cond)")
}
}
fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
match v {
Some(J::String(_)) => Ok(()),
_ => perr(path, "'component' must be a string catalog reference"),
}
}
fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
let obj = ports
.and_then(|p| p.as_object())
.ok_or_else(|| PortabilityError {
path: path.to_string(),
message: "ports must be an object".into(),
})?;
for (k, v) in obj {
assert_portable_expr(v, &format!("{path}.{k}"))?;
}
Ok(())
}
fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
match node {
J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
J::Array(a) => {
for (i, e) in a.iter().enumerate() {
assert_portable_expr(e, &format!("{path}[{i}]"))?;
}
Ok(())
}
J::Object(o) => {
if o.len() == 1 {
let (op, arg) = o.iter().next().unwrap();
if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
return perr(path, format!("unknown operator '{op}' (fail-closed)"));
}
if op == "obj" {
let Some(fields) = arg.as_object() else {
return perr(
&format!("{path}.obj"),
"{obj: ...} expects an object".to_string(),
);
};
for (k, v) in fields {
if k == FORBIDDEN_OBJECT_KEY {
return perr(
&format!("{path}.obj"),
format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
);
}
assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
}
return Ok(());
}
assert_portable_expr(arg, &format!("{path}.{op}"))
} else {
for (k, v) in o {
assert_portable_expr(v, &format!("{path}.{k}"))?;
}
Ok(())
}
}
}
}