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 let components = ir
143 .get("components")
144 .and_then(|c| c.as_array())
145 .ok_or_else(|| PortabilityError {
146 path: format!("{path}.components"),
147 message: "IR.components must be an array".into(),
148 })?;
149 for (ci, c) in components.iter().enumerate() {
150 assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
151 }
152 Ok(())
153}
154
155fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
156 if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
157 return perr(&format!("{path}.name"), "component.name must be a string");
158 }
159 let body = c
160 .get("body")
161 .and_then(|b| b.as_array())
162 .ok_or_else(|| PortabilityError {
163 path: format!("{path}.body"),
164 message: "component.body must be an array".into(),
165 })?;
166 for (ni, n) in body.iter().enumerate() {
167 assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
168 }
169 assert_portable_expr(
170 c.get("output").unwrap_or(&J::Null),
171 &format!("{path}.output"),
172 )?;
173 if let Some(ot) = c.get("outputType") {
175 assert_portable_type_notation(ot, &format!("{path}.outputType"))?;
176 }
177 Ok(())
178}
179
180fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
181 assert_portable_body_node_kind(n, path)?;
182 if let Some(ot) = n.get("outType") {
185 assert_portable_type_notation(ot, &format!("{path}.outType"))?;
186 }
187 Ok(())
188}
189
190fn assert_portable_body_node_kind(n: &J, path: &str) -> Result<(), PortabilityError> {
191 if let Some(m) = n.get("map") {
192 require_string_component(m.get("component"), &format!("{path}.map.component"))?;
193 assert_portable_expr(
194 m.get("over").unwrap_or(&J::Null),
195 &format!("{path}.map.over"),
196 )?;
197 assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
198 if let Some(w) = m.get("when") {
201 assert_portable_expr(w, &format!("{path}.map.when"))?;
202 }
203 if let Some(into) = m.get("into") {
204 if !into.is_string() {
205 return perr(&format!("{path}.map.into"), "'into' must be a string key");
206 }
207 }
208 if let Some(b) = m.get("batched") {
209 if !b.is_boolean() {
210 return perr(
211 &format!("{path}.map.batched"),
212 "'batched' must be a boolean",
213 );
214 }
215 }
216 Ok(())
217 } else if let Some(co) = n.get("cond") {
218 assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
219 assert_portable_expr(
220 co.get("then").unwrap_or(&J::Null),
221 &format!("{path}.cond.then"),
222 )?;
223 assert_portable_expr(
224 co.get("else").unwrap_or(&J::Null),
225 &format!("{path}.cond.else"),
226 )
227 } else if n.get("component").is_some() {
228 require_string_component(n.get("component"), &format!("{path}.component"))?;
229 assert_portable_ports(n.get("ports"), &format!("{path}.ports"))
230 } else {
231 perr(path, "unknown body node kind (not componentRef/map/cond)")
232 }
233}
234
235fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
236 match v {
237 Some(J::String(_)) => Ok(()),
238 _ => perr(path, "'component' must be a string catalog reference"),
239 }
240}
241
242fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
243 let obj = ports
244 .and_then(|p| p.as_object())
245 .ok_or_else(|| PortabilityError {
246 path: path.to_string(),
247 message: "ports must be an object".into(),
248 })?;
249 for (k, v) in obj {
250 assert_portable_expr(v, &format!("{path}.{k}"))?;
251 }
252 Ok(())
253}
254
255fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
256 match node {
257 J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
258 J::Array(a) => {
259 for (i, e) in a.iter().enumerate() {
260 assert_portable_expr(e, &format!("{path}[{i}]"))?;
261 }
262 Ok(())
263 }
264 J::Object(o) => {
265 if o.len() == 1 {
266 let (op, arg) = o.iter().next().unwrap();
267 if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
268 return perr(path, format!("unknown operator '{op}' (fail-closed)"));
269 }
270 if op == "obj" {
274 let Some(fields) = arg.as_object() else {
275 return perr(
276 &format!("{path}.obj"),
277 "{obj: ...} expects an object".to_string(),
278 );
279 };
280 for (k, v) in fields {
281 if k == FORBIDDEN_OBJECT_KEY {
285 return perr(
286 &format!("{path}.obj"),
287 format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
288 );
289 }
290 assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
291 }
292 return Ok(());
293 }
294 assert_portable_expr(arg, &format!("{path}.{op}"))
295 } else {
296 for (k, v) in o {
297 assert_portable_expr(v, &format!("{path}.{k}"))?;
298 }
299 Ok(())
300 }
301 }
302 }
303}