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 if o.len() != 1 {
87 return perr(
88 path,
89 format!(
90 "compound type must be a single-key object (opt|arr|obj), got {} keys (fail-closed)",
91 o.len()
92 ),
93 );
94 }
95 let (kind, arg) = o.iter().next().unwrap();
96 match kind.as_str() {
97 "opt" | "arr" => assert_portable_type_notation(arg, &format!("{path}.{kind}")),
98 "obj" => {
99 let Some(fields) = arg.as_object() else {
100 return perr(
101 &format!("{path}.obj"),
102 "{obj: ...} type expects a field->type object (fail-closed)".to_string(),
103 );
104 };
105 for (k, v) in fields {
106 if k == FORBIDDEN_OBJECT_KEY {
107 return perr(
108 &format!("{path}.obj"),
109 format!("object type key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
110 );
111 }
112 assert_portable_type_notation(v, &format!("{path}.obj.{k}"))?;
113 }
114 Ok(())
115 }
116 other => perr(
117 path,
118 format!("unknown compound type kind '{other}' (opt|arr|obj) (fail-closed)"),
119 ),
120 }
121 }
122 _ => perr(
123 path,
124 "type notation must be a scalar-type string or a single-key {opt|arr|obj} object (fail-closed)".to_string(),
125 ),
126 }
127}
128
129pub fn assert_portable_component_graph(ir: &J) -> Result<(), PortabilityError> {
138 assert_portable_cg_at(ir, "$")
139}
140
141fn assert_portable_cg_at(ir: &J, path: &str) -> Result<(), PortabilityError> {
142 if ir.get("irVersion").and_then(|v| v.as_i64()) != Some(2) {
145 return perr(
146 &format!("{path}.irVersion"),
147 "irVersion must be 2 (portable IR carries the mandatory port type contract portSchemas; v1 is retired) (fail-closed)",
148 );
149 }
150 let components = ir
151 .get("components")
152 .and_then(|c| c.as_array())
153 .ok_or_else(|| PortabilityError {
154 path: format!("{path}.components"),
155 message: "IR.components must be an array".into(),
156 })?;
157 for (ci, c) in components.iter().enumerate() {
158 assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
159 }
160 Ok(())
161}
162
163fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
164 if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
165 return perr(&format!("{path}.name"), "component.name must be a string");
166 }
167 let body = c
168 .get("body")
169 .and_then(|b| b.as_array())
170 .ok_or_else(|| PortabilityError {
171 path: format!("{path}.body"),
172 message: "component.body must be an array".into(),
173 })?;
174 for (ni, n) in body.iter().enumerate() {
175 assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
176 }
177 assert_portable_expr(
178 c.get("output").unwrap_or(&J::Null),
179 &format!("{path}.output"),
180 )?;
181 if let Some(ot) = c.get("outputType") {
183 assert_portable_type_notation(ot, &format!("{path}.outputType"))?;
184 }
185 Ok(())
186}
187
188fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
189 assert_portable_body_node_kind(n, path)?;
190 if let Some(ot) = n.get("outType") {
193 assert_portable_type_notation(ot, &format!("{path}.outType"))?;
194 }
195 Ok(())
196}
197
198fn assert_portable_body_node_kind(n: &J, path: &str) -> Result<(), PortabilityError> {
199 if let Some(m) = n.get("map") {
200 require_string_component(m.get("component"), &format!("{path}.map.component"))?;
201 assert_portable_expr(
202 m.get("over").unwrap_or(&J::Null),
203 &format!("{path}.map.over"),
204 )?;
205 assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
206 if let Some(w) = m.get("when") {
209 assert_portable_expr(w, &format!("{path}.map.when"))?;
210 }
211 if let Some(into) = m.get("into") {
212 if !into.is_string() {
213 return perr(&format!("{path}.map.into"), "'into' must be a string key");
214 }
215 }
216 if let Some(b) = m.get("batched") {
217 if !b.is_boolean() {
218 return perr(
219 &format!("{path}.map.batched"),
220 "'batched' must be a boolean",
221 );
222 }
223 }
224 if let Some(ep) = m.get("elementPolicy") {
227 let eps = ep.as_str();
228 if eps != Some("error") && eps != Some("skip") {
229 return perr(
230 &format!("{path}.map.elementPolicy"),
231 "'elementPolicy' must be \"error\" or \"skip\"",
232 );
233 }
234 if eps == Some("skip") && m.get("batched").and_then(|b| b.as_bool()) == Some(true) {
235 return perr(
236 &format!("{path}.map.elementPolicy"),
237 "'elementPolicy' \"skip\" needs a per-element Failure, but a batched map takes ONE outcome for the whole batch",
238 );
239 }
240 }
241 assert_port_schemas_present(m, &format!("{path}.map")) } else if let Some(co) = n.get("cond") {
243 assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
244 assert_portable_expr(
245 co.get("then").unwrap_or(&J::Null),
246 &format!("{path}.cond.then"),
247 )?;
248 assert_portable_expr(
249 co.get("else").unwrap_or(&J::Null),
250 &format!("{path}.cond.else"),
251 )
252 } else if n.get("component").is_some() {
253 require_string_component(n.get("component"), &format!("{path}.component"))?;
254 assert_portable_ports(n.get("ports"), &format!("{path}.ports"))?;
255 assert_port_schemas_present(n, path)
256 } else {
257 perr(path, "unknown body node kind (not componentRef/map/cond)")
258 }
259}
260
261fn assert_port_schemas_present(holder: &J, path: &str) -> Result<(), PortabilityError> {
266 match holder.get("portSchemas") {
267 Some(ps) if ps.is_object() => Ok(()),
268 _ => perr(
269 &format!("{path}.portSchemas"),
270 "portSchemas is required (irVersion 2 carries the port type contract) and must be an object",
271 ),
272 }
273}
274
275fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
276 match v {
277 Some(J::String(_)) => Ok(()),
278 _ => perr(path, "'component' must be a string catalog reference"),
279 }
280}
281
282fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
283 let obj = ports
284 .and_then(|p| p.as_object())
285 .ok_or_else(|| PortabilityError {
286 path: path.to_string(),
287 message: "ports must be an object".into(),
288 })?;
289 for (k, v) in obj {
290 assert_portable_expr(v, &format!("{path}.{k}"))?;
291 }
292 Ok(())
293}
294
295fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
296 match node {
297 J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
298 J::Array(a) => {
299 for (i, e) in a.iter().enumerate() {
300 assert_portable_expr(e, &format!("{path}[{i}]"))?;
301 }
302 Ok(())
303 }
304 J::Object(o) => {
305 if o.len() == 1 {
306 let (op, arg) = o.iter().next().unwrap();
307 if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
308 return perr(path, format!("unknown operator '{op}' (fail-closed)"));
309 }
310 if op == "obj" {
314 let Some(fields) = arg.as_object() else {
315 return perr(
316 &format!("{path}.obj"),
317 "{obj: ...} expects an object".to_string(),
318 );
319 };
320 for (k, v) in fields {
321 if k == FORBIDDEN_OBJECT_KEY {
325 return perr(
326 &format!("{path}.obj"),
327 format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
328 );
329 }
330 assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
331 }
332 return Ok(());
333 }
334 assert_portable_expr(arg, &format!("{path}.{op}"))
335 } else {
336 for (k, v) in o {
337 assert_portable_expr(v, &format!("{path}.{k}"))?;
338 }
339 Ok(())
340 }
341 }
342 }
343}