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 fn assert_portable_component_graph(ir: &J) -> Result<(), PortabilityError> {
73 assert_portable_cg_at(ir, "$")
74}
75
76fn assert_portable_cg_at(ir: &J, path: &str) -> Result<(), PortabilityError> {
77 let components = ir
78 .get("components")
79 .and_then(|c| c.as_array())
80 .ok_or_else(|| PortabilityError {
81 path: format!("{path}.components"),
82 message: "IR.components must be an array".into(),
83 })?;
84 for (ci, c) in components.iter().enumerate() {
85 assert_portable_component(c, &format!("{path}.components[{ci}]"))?;
86 }
87 Ok(())
88}
89
90fn assert_portable_component(c: &J, path: &str) -> Result<(), PortabilityError> {
91 if !c.get("name").map(|n| n.is_string()).unwrap_or(false) {
92 return perr(&format!("{path}.name"), "component.name must be a string");
93 }
94 let body = c
95 .get("body")
96 .and_then(|b| b.as_array())
97 .ok_or_else(|| PortabilityError {
98 path: format!("{path}.body"),
99 message: "component.body must be an array".into(),
100 })?;
101 for (ni, n) in body.iter().enumerate() {
102 assert_portable_body_node(n, &format!("{path}.body[{ni}]"))?;
103 }
104 assert_portable_expr(
105 c.get("output").unwrap_or(&J::Null),
106 &format!("{path}.output"),
107 )
108}
109
110fn assert_portable_body_node(n: &J, path: &str) -> Result<(), PortabilityError> {
111 if let Some(m) = n.get("map") {
112 require_string_component(m.get("component"), &format!("{path}.map.component"))?;
113 assert_portable_expr(
114 m.get("over").unwrap_or(&J::Null),
115 &format!("{path}.map.over"),
116 )?;
117 assert_portable_ports(m.get("ports"), &format!("{path}.map.ports"))?;
118 if let Some(w) = m.get("when") {
121 assert_portable_expr(w, &format!("{path}.map.when"))?;
122 }
123 if let Some(into) = m.get("into") {
124 if !into.is_string() {
125 return perr(&format!("{path}.map.into"), "'into' must be a string key");
126 }
127 }
128 if let Some(b) = m.get("batched") {
129 if !b.is_boolean() {
130 return perr(
131 &format!("{path}.map.batched"),
132 "'batched' must be a boolean",
133 );
134 }
135 }
136 Ok(())
137 } else if let Some(co) = n.get("cond") {
138 assert_portable_expr(co.get("if").unwrap_or(&J::Null), &format!("{path}.cond.if"))?;
139 assert_portable_expr(
140 co.get("then").unwrap_or(&J::Null),
141 &format!("{path}.cond.then"),
142 )?;
143 assert_portable_expr(
144 co.get("else").unwrap_or(&J::Null),
145 &format!("{path}.cond.else"),
146 )
147 } else if n.get("component").is_some() {
148 require_string_component(n.get("component"), &format!("{path}.component"))?;
149 assert_portable_ports(n.get("ports"), &format!("{path}.ports"))
150 } else {
151 perr(path, "unknown body node kind (not componentRef/map/cond)")
152 }
153}
154
155fn require_string_component(v: Option<&J>, path: &str) -> Result<(), PortabilityError> {
156 match v {
157 Some(J::String(_)) => Ok(()),
158 _ => perr(path, "'component' must be a string catalog reference"),
159 }
160}
161
162fn assert_portable_ports(ports: Option<&J>, path: &str) -> Result<(), PortabilityError> {
163 let obj = ports
164 .and_then(|p| p.as_object())
165 .ok_or_else(|| PortabilityError {
166 path: path.to_string(),
167 message: "ports must be an object".into(),
168 })?;
169 for (k, v) in obj {
170 assert_portable_expr(v, &format!("{path}.{k}"))?;
171 }
172 Ok(())
173}
174
175fn assert_portable_expr(node: &J, path: &str) -> Result<(), PortabilityError> {
176 match node {
177 J::Null | J::Bool(_) | J::Number(_) | J::String(_) => Ok(()),
178 J::Array(a) => {
179 for (i, e) in a.iter().enumerate() {
180 assert_portable_expr(e, &format!("{path}[{i}]"))?;
181 }
182 Ok(())
183 }
184 J::Object(o) => {
185 if o.len() == 1 {
186 let (op, arg) = o.iter().next().unwrap();
187 if !PORTABLE_EXPR_OPERATORS.contains(&op.as_str()) {
188 return perr(path, format!("unknown operator '{op}' (fail-closed)"));
189 }
190 if op == "obj" {
194 let Some(fields) = arg.as_object() else {
195 return perr(
196 &format!("{path}.obj"),
197 "{obj: ...} expects an object".to_string(),
198 );
199 };
200 for (k, v) in fields {
201 if k == FORBIDDEN_OBJECT_KEY {
205 return perr(
206 &format!("{path}.obj"),
207 format!("object key \"{FORBIDDEN_OBJECT_KEY}\" is forbidden (fail-closed)"),
208 );
209 }
210 assert_portable_expr(v, &format!("{path}.obj.{k}"))?;
211 }
212 return Ok(());
213 }
214 assert_portable_expr(arg, &format!("{path}.{op}"))
215 } else {
216 for (k, v) in o {
217 assert_portable_expr(v, &format!("{path}.{k}"))?;
218 }
219 Ok(())
220 }
221 }
222 }
223}