1use crate::expr::FORBIDDEN_OBJECT_KEY;
10use crate::value::Value;
11use serde_json::Value as J;
12
13#[derive(Debug, Clone)]
14pub struct PortabilityError {
15 pub path: String,
16 pub message: String,
17}
18
19impl std::fmt::Display for PortabilityError {
20 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
21 write!(f, "Portability Guard at {}: {}", self.path, self.message)
22 }
23}
24impl std::error::Error for PortabilityError {}
25
26pub fn assert_portable(value: &Value) -> Result<(), PortabilityError> {
29 assert_portable_at(value, "$")
30}
31
32fn assert_portable_at(value: &Value, path: &str) -> Result<(), PortabilityError> {
33 match value {
34 Value::Float(f) if !f.is_finite() => Err(PortabilityError {
35 path: path.to_string(),
36 message: format!("non-finite float {f} cannot be placed in portable IR"),
37 }),
38 Value::Arr(a) => {
39 for (i, v) in a.iter().enumerate() {
40 assert_portable_at(v, &format!("{path}[{i}]"))?;
41 }
42 Ok(())
43 }
44 Value::Obj(o) => {
45 for (k, v) in o {
46 assert_portable_at(v, &format!("{path}.{k}"))?;
47 }
48 Ok(())
49 }
50 _ => Ok(()),
51 }
52}
53
54pub const PORTABLE_EXPR_OPERATORS: &[&str] = &[
56 "int", "float", "ref", "refOpt", "obj", "arr", "add", "sub", "mul", "neg", "div", "mod",
57 "concat", "eq", "ne", "lt", "le", "gt", "ge", "and", "or", "not", "coalesce", "cond", "len",
58];
59
60fn perr<T>(path: &str, message: impl Into<String>) -> Result<T, PortabilityError> {
61 Err(PortabilityError {
62 path: path.to_string(),
63 message: message.into(),
64 })
65}
66
67pub const PORTABLE_SCALAR_TYPES: &[&str] = &["string", "int", "float", "bool", "null"];
71
72fn assert_portable_type_notation(node: &J, path: &str) -> Result<(), PortabilityError> {
78 match node {
79 J::String(s) => {
80 if !PORTABLE_SCALAR_TYPES.contains(&s.as_str()) {
81 return perr(path, format!("unknown scalar type '{s}' (fail-closed)"));
82 }
83 Ok(())
84 }
85 J::Object(o) => {
86 let kind = ["opt", "arr", "obj"]
88 .iter()
89 .find(|k| o.contains_key(**k))
90 .copied();
91 let kind = match kind {
92 Some(k) if k == "obj" || o.len() == 1 => k,
93 _ => {
94 return perr(
95 path,
96 format!(
97 "compound type must be a single-key object (opt|arr|obj; obj may carry an optional 'name'), got {} keys (fail-closed)",
98 o.len()
99 ),
100 );
101 }
102 };
103 let arg = o.get(kind).unwrap();
104 match kind {
105 "opt" | "arr" => assert_portable_type_notation(arg, &format!("{path}.{kind}")),
106 "obj" => {
107 for k in o.keys() {
108 if k != "obj" && k != "name" {
109 return perr(
110 path,
111 format!("{{obj}} type allows only 'obj' and optional 'name', got '{k}' (fail-closed)"),
112 );
113 }
114 }
115 if let Some(name_v) = o.get("name") {
116 let ok = name_v.as_str().is_some_and(|s| {
117 let mut cs = s.chars();
118 matches!(cs.next(), Some(c) if c == '_' || c.is_ascii_alphabetic())
119 && cs.all(|c| c == '_' || c.is_ascii_alphanumeric())
120 });
121 if !ok {
122 return perr(
123 &format!("{path}.name"),
124 "obj type 'name' must be a non-empty identifier (fail-closed)".to_string(),
125 );
126 }
127 }
128 let Some(fields) = arg.as_object() else {
129 return perr(
130 &format!("{path}.obj"),
131 "{obj: ...} type expects a field->type object (fail-closed)".to_string(),
132 );
133 };
134 for (k, v) in fields {
135 if k == FORBIDDEN_OBJECT_KEY {
136 return perr(
137 &format!("{path}.obj"),
138 format!("object type key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
139 );
140 }
141 assert_portable_type_notation(v, &format!("{path}.obj.{k}"))?;
142 }
143 Ok(())
144 }
145 other => perr(
146 path,
147 format!("unknown compound type kind '{other}' (opt|arr|obj) (fail-closed)"),
148 ),
149 }
150 }
151 _ => perr(
152 path,
153 "type notation must be a scalar-type string or a single-key {opt|arr|obj} object (fail-closed)".to_string(),
154 ),
155 }
156}
157
158pub fn assert_portable_component_graph(ir: &J) -> Result<(), PortabilityError> {
167 assert_portable_cg_at(ir, "$")
168}
169
170fn assert_portable_cg_at(ir: &J, path: &str) -> Result<(), PortabilityError> {
171 if !matches!(
174 ir.get("irVersion").and_then(|v| v.as_i64()),
175 Some(2) | Some(3)
176 ) {
177 return perr(
178 &format!("{path}.irVersion"),
179 "irVersion must be 2 or 3 (v3 adds optional nominal type names) (fail-closed)",
180 );
181 }
182 let components = ir
183 .get("components")
184 .and_then(|c| c.as_array())
185 .ok_or_else(|| PortabilityError {
186 path: format!("{path}.components"),
187 message: "IR.components must be an array".into(),
188 })?;
189 for (ci, c) in components.iter().enumerate() {
190 assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
191 }
192 Ok(())
193}
194
195fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
196 if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
197 return perr(&format!("{path}.name"), "component.name must be a string");
198 }
199 let body = c
200 .get("body")
201 .and_then(|b| b.as_array())
202 .ok_or_else(|| PortabilityError {
203 path: format!("{path}.body"),
204 message: "component.body must be an array".into(),
205 })?;
206 for (ni, n) in body.iter().enumerate() {
207 assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
208 }
209 assert_portable_expr(
210 c.get("output").unwrap_or(&J::Null),
211 &format!("{path}.output"),
212 )?;
213 if let Some(ot) = c.get("outputType") {
215 assert_portable_type_notation(ot, &format!("{path}.outputType"))?;
216 }
217 Ok(())
218}
219
220fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
221 assert_portable_body_node_kind(n, path)?;
222 if let Some(ot) = n.get("outType") {
225 assert_portable_type_notation(ot, &format!("{path}.outType"))?;
226 }
227 Ok(())
228}
229
230fn assert_portable_body_node_kind(n: &J, path: &str) -> Result<(), PortabilityError> {
231 if let Some(m) = n.get("map") {
232 require_string_component(m.get("component"), &format!("{path}.map.component"))?;
233 assert_portable_expr(
234 m.get("over").unwrap_or(&J::Null),
235 &format!("{path}.map.over"),
236 )?;
237 assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
238 if let Some(w) = m.get("when") {
241 assert_portable_expr(w, &format!("{path}.map.when"))?;
242 }
243 if let Some(into) = m.get("into") {
244 if !into.is_string() {
245 return perr(&format!("{path}.map.into"), "'into' must be a string key");
246 }
247 }
248 if let Some(b) = m.get("batched") {
249 if !b.is_boolean() {
250 return perr(
251 &format!("{path}.map.batched"),
252 "'batched' must be a boolean",
253 );
254 }
255 }
256 if let Some(ep) = m.get("elementPolicy") {
259 let eps = ep.as_str();
260 if eps != Some("error") && eps != Some("skip") {
261 return perr(
262 &format!("{path}.map.elementPolicy"),
263 "'elementPolicy' must be \"error\" or \"skip\"",
264 );
265 }
266 if eps == Some("skip") && m.get("batched").and_then(|b| b.as_bool()) == Some(true) {
267 return perr(
268 &format!("{path}.map.elementPolicy"),
269 "'elementPolicy' \"skip\" needs a per-element Failure, but a batched map takes ONE outcome for the whole batch",
270 );
271 }
272 }
273 assert_port_schemas_present(m, &format!("{path}.map")) } else if let Some(co) = n.get("cond") {
275 assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
276 assert_portable_expr(
277 co.get("then").unwrap_or(&J::Null),
278 &format!("{path}.cond.then"),
279 )?;
280 assert_portable_expr(
281 co.get("else").unwrap_or(&J::Null),
282 &format!("{path}.cond.else"),
283 )
284 } else if n.get("component").is_some() {
285 require_string_component(n.get("component"), &format!("{path}.component"))?;
286 assert_portable_ports(n.get("ports"), &format!("{path}.ports"))?;
287 assert_port_schemas_present(n, path)
288 } else {
289 perr(path, "unknown body node kind (not componentRef/map/cond)")
290 }
291}
292
293fn assert_port_schemas_present(holder: &J, path: &str) -> Result<(), PortabilityError> {
298 match holder.get("portSchemas") {
299 Some(ps) if ps.is_object() => Ok(()),
300 _ => perr(
301 &format!("{path}.portSchemas"),
302 "portSchemas is required (irVersion 2 carries the port type contract) and must be an object",
303 ),
304 }
305}
306
307fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
308 match v {
309 Some(J::String(_)) => Ok(()),
310 _ => perr(path, "'component' must be a string catalog reference"),
311 }
312}
313
314fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
315 let obj = ports
316 .and_then(|p| p.as_object())
317 .ok_or_else(|| PortabilityError {
318 path: path.to_string(),
319 message: "ports must be an object".into(),
320 })?;
321 for (k, v) in obj {
322 assert_portable_expr(v, &format!("{path}.{k}"))?;
323 }
324 Ok(())
325}
326
327fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
328 match node {
329 J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
330 J::Array(a) => {
331 for (i, e) in a.iter().enumerate() {
332 assert_portable_expr(e, &format!("{path}[{i}]"))?;
333 }
334 Ok(())
335 }
336 J::Object(o) => {
337 if o.len() == 1 {
338 let (op, arg) = o.iter().next().unwrap();
339 if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
340 return perr(path, format!("unknown operator '{op}' (fail-closed)"));
341 }
342 if op == "obj" {
346 let Some(fields) = arg.as_object() else {
347 return perr(
348 &format!("{path}.obj"),
349 "{obj: ...} expects an object".to_string(),
350 );
351 };
352 for (k, v) in fields {
353 if k == FORBIDDEN_OBJECT_KEY {
357 return perr(
358 &format!("{path}.obj"),
359 format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
360 );
361 }
362 assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
363 }
364 return Ok(());
365 }
366 assert_portable_expr(arg, &format!("{path}.{op}"))
367 } else {
368 for (k, v) in o {
369 assert_portable_expr(v, &format!("{path}.{k}"))?;
370 }
371 Ok(())
372 }
373 }
374 }
375}