plotnik_lib/engine/
validate.rs

1//! Runtime validation of query results against type metadata.
2//!
3//! Validates that `Value` produced by the materializer matches the expected
4//! type from the IR. A mismatch indicates an IR construction bug.
5
6use std::fmt;
7
8use crate::ir::{
9    CompiledQuery, TYPE_COMPOSITE_START, TYPE_NODE, TYPE_STR, TYPE_VOID, TypeId, TypeKind,
10};
11
12use super::value::Value;
13
14/// Error returned when validation fails.
15#[derive(Debug)]
16pub struct TypeError {
17    pub expected: TypeDescription,
18    pub actual: TypeDescription,
19    pub path: Vec<PathSegment>,
20}
21
22impl fmt::Display for TypeError {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        write!(f, "type mismatch at ")?;
25        if self.path.is_empty() {
26            write!(f, "<root>")?;
27        } else {
28            for (i, seg) in self.path.iter().enumerate() {
29                if i > 0 {
30                    write!(f, ".")?;
31                }
32                match seg {
33                    PathSegment::Field(name) => write!(f, "{}", name)?,
34                    PathSegment::Index(i) => write!(f, "[{}]", i)?,
35                    PathSegment::Variant(tag) => write!(f, "<{}>", tag)?,
36                }
37            }
38        }
39        write!(f, ": expected {}, got {}", self.expected, self.actual)
40    }
41}
42
43/// Segment in the path to a type error.
44#[derive(Debug, Clone)]
45pub enum PathSegment {
46    Field(String),
47    Index(usize),
48    Variant(String),
49}
50
51/// Human-readable type description for error messages.
52#[derive(Debug, Clone)]
53pub enum TypeDescription {
54    Void,
55    Node,
56    String,
57    Optional(Box<TypeDescription>),
58    Array(Box<TypeDescription>),
59    NonEmptyArray(Box<TypeDescription>),
60    Record(String),
61    Enum(String),
62    // Actual value descriptions
63    ActualNull,
64    ActualNode,
65    ActualString,
66    ActualArray(usize),
67    ActualObject,
68    ActualVariant(String),
69}
70
71impl fmt::Display for TypeDescription {
72    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
73        match self {
74            TypeDescription::Void => write!(f, "void"),
75            TypeDescription::Node => write!(f, "Node"),
76            TypeDescription::String => write!(f, "string"),
77            TypeDescription::Optional(inner) => write!(f, "{}?", inner),
78            TypeDescription::Array(inner) => write!(f, "{}*", inner),
79            TypeDescription::NonEmptyArray(inner) => write!(f, "{}+", inner),
80            TypeDescription::Record(name) => write!(f, "struct {}", name),
81            TypeDescription::Enum(name) => write!(f, "enum {}", name),
82            TypeDescription::ActualNull => write!(f, "null"),
83            TypeDescription::ActualNode => write!(f, "Node"),
84            TypeDescription::ActualString => write!(f, "string"),
85            TypeDescription::ActualArray(len) => write!(f, "array[{}]", len),
86            TypeDescription::ActualObject => write!(f, "object"),
87            TypeDescription::ActualVariant(tag) => write!(f, "variant({})", tag),
88        }
89    }
90}
91
92/// Validates a value against the expected type.
93pub fn validate(
94    value: &Value<'_>,
95    expected: TypeId,
96    query: &CompiledQuery,
97) -> Result<(), TypeError> {
98    let mut ctx = ValidationContext {
99        query,
100        path: Vec::new(),
101    };
102    ctx.validate_value(value, expected)
103}
104
105struct ValidationContext<'a> {
106    query: &'a CompiledQuery,
107    path: Vec<PathSegment>,
108}
109
110impl ValidationContext<'_> {
111    fn validate_value(&mut self, value: &Value<'_>, expected: TypeId) -> Result<(), TypeError> {
112        match expected {
113            TYPE_VOID => self.expect_null(value),
114            TYPE_NODE => self.expect_node(value),
115            TYPE_STR => self.expect_string(value),
116            id if id >= TYPE_COMPOSITE_START => self.validate_composite(value, id),
117            _ => Ok(()), // Unknown primitive, skip validation
118        }
119    }
120
121    fn expect_null(&self, value: &Value<'_>) -> Result<(), TypeError> {
122        match value {
123            Value::Null => Ok(()),
124            _ => Err(self.type_error(TypeDescription::Void, self.describe_value(value))),
125        }
126    }
127
128    fn expect_node(&self, value: &Value<'_>) -> Result<(), TypeError> {
129        match value {
130            Value::Node(_) => Ok(()),
131            _ => Err(self.type_error(TypeDescription::Node, self.describe_value(value))),
132        }
133    }
134
135    fn expect_string(&self, value: &Value<'_>) -> Result<(), TypeError> {
136        match value {
137            Value::String(_) => Ok(()),
138            _ => Err(self.type_error(TypeDescription::String, self.describe_value(value))),
139        }
140    }
141
142    fn validate_composite(&mut self, value: &Value<'_>, type_id: TypeId) -> Result<(), TypeError> {
143        let idx = (type_id - TYPE_COMPOSITE_START) as usize;
144        let Some(def) = self.query.type_defs().get(idx) else {
145            return Ok(()); // Unknown type, skip
146        };
147
148        match def.kind {
149            TypeKind::Optional => self.validate_optional(value, def.inner_type().unwrap()),
150            TypeKind::ArrayStar => self.validate_array(value, def.inner_type().unwrap(), false),
151            TypeKind::ArrayPlus => self.validate_array(value, def.inner_type().unwrap(), true),
152            TypeKind::Record => self.validate_record(value, type_id, def),
153            TypeKind::Enum => self.validate_enum(value, type_id, def),
154        }
155    }
156
157    fn validate_optional(&mut self, value: &Value<'_>, inner: TypeId) -> Result<(), TypeError> {
158        match value {
159            Value::Null => Ok(()),
160            _ => self.validate_value(value, inner),
161        }
162    }
163
164    fn validate_array(
165        &mut self,
166        value: &Value<'_>,
167        element: TypeId,
168        non_empty: bool,
169    ) -> Result<(), TypeError> {
170        let Value::Array(items) = value else {
171            let expected = if non_empty {
172                TypeDescription::NonEmptyArray(Box::new(self.describe_type(element)))
173            } else {
174                TypeDescription::Array(Box::new(self.describe_type(element)))
175            };
176            return Err(self.type_error(expected, self.describe_value(value)));
177        };
178
179        if non_empty && items.is_empty() {
180            return Err(self.type_error(
181                TypeDescription::NonEmptyArray(Box::new(self.describe_type(element))),
182                TypeDescription::ActualArray(0),
183            ));
184        }
185
186        for (i, item) in items.iter().enumerate() {
187            self.path.push(PathSegment::Index(i));
188            self.validate_value(item, element)?;
189            self.path.pop();
190        }
191
192        Ok(())
193    }
194
195    fn validate_record(
196        &mut self,
197        value: &Value<'_>,
198        type_id: TypeId,
199        def: &crate::ir::TypeDef,
200    ) -> Result<(), TypeError> {
201        let Value::Object(fields) = value else {
202            return Err(self.type_error(self.describe_type(type_id), self.describe_value(value)));
203        };
204
205        let Some(members_slice) = def.members_slice() else {
206            return Ok(());
207        };
208        let members = self.query.resolve_type_members(members_slice);
209
210        for member in members {
211            let field_name = self.query.string(member.name);
212            self.path.push(PathSegment::Field(field_name.to_string()));
213
214            // Field ID in the object is the index, need to find it
215            if let Some(field_value) = fields.get(&member.name) {
216                self.validate_value(field_value, member.ty)?;
217            }
218            // Missing field is OK if it's optional (would be Null)
219
220            self.path.pop();
221        }
222
223        Ok(())
224    }
225
226    fn validate_enum(
227        &mut self,
228        value: &Value<'_>,
229        type_id: TypeId,
230        def: &crate::ir::TypeDef,
231    ) -> Result<(), TypeError> {
232        let Value::Variant { tag, value: inner } = value else {
233            return Err(self.type_error(self.describe_type(type_id), self.describe_value(value)));
234        };
235
236        let Some(members_slice) = def.members_slice() else {
237            return Ok(());
238        };
239        let members = self.query.resolve_type_members(members_slice);
240
241        // Find the variant by tag
242        let variant = members.iter().find(|m| m.name == *tag);
243        let Some(variant) = variant else {
244            // Unknown variant tag
245            let tag_name = self.query.string(*tag);
246            return Err(self.type_error(
247                self.describe_type(type_id),
248                TypeDescription::ActualVariant(tag_name.to_string()),
249            ));
250        };
251
252        let tag_name = self.query.string(variant.name);
253        self.path.push(PathSegment::Variant(tag_name.to_string()));
254        self.validate_value(inner, variant.ty)?;
255        self.path.pop();
256
257        Ok(())
258    }
259
260    fn describe_type(&self, type_id: TypeId) -> TypeDescription {
261        match type_id {
262            TYPE_VOID => TypeDescription::Void,
263            TYPE_NODE => TypeDescription::Node,
264            TYPE_STR => TypeDescription::String,
265            id if id >= TYPE_COMPOSITE_START => {
266                let idx = (id - TYPE_COMPOSITE_START) as usize;
267                if let Some(def) = self.query.type_defs().get(idx) {
268                    match def.kind {
269                        TypeKind::Optional => TypeDescription::Optional(Box::new(
270                            self.describe_type(def.inner_type().unwrap()),
271                        )),
272                        TypeKind::ArrayStar => TypeDescription::Array(Box::new(
273                            self.describe_type(def.inner_type().unwrap()),
274                        )),
275                        TypeKind::ArrayPlus => TypeDescription::NonEmptyArray(Box::new(
276                            self.describe_type(def.inner_type().unwrap()),
277                        )),
278                        TypeKind::Record => {
279                            let name = if def.name != crate::ir::STRING_NONE {
280                                self.query.string(def.name).to_string()
281                            } else {
282                                format!("T{}", type_id)
283                            };
284                            TypeDescription::Record(name)
285                        }
286                        TypeKind::Enum => {
287                            let name = if def.name != crate::ir::STRING_NONE {
288                                self.query.string(def.name).to_string()
289                            } else {
290                                format!("T{}", type_id)
291                            };
292                            TypeDescription::Enum(name)
293                        }
294                    }
295                } else {
296                    TypeDescription::Node
297                }
298            }
299            _ => TypeDescription::Node,
300        }
301    }
302
303    fn describe_value(&self, value: &Value<'_>) -> TypeDescription {
304        match value {
305            Value::Null => TypeDescription::ActualNull,
306            Value::Node(_) => TypeDescription::ActualNode,
307            Value::String(_) => TypeDescription::ActualString,
308            Value::Array(items) => TypeDescription::ActualArray(items.len()),
309            Value::Object(_) => TypeDescription::ActualObject,
310            Value::Variant { tag, .. } => {
311                let tag_name = self.query.string(*tag);
312                TypeDescription::ActualVariant(tag_name.to_string())
313            }
314        }
315    }
316
317    fn type_error(&self, expected: TypeDescription, actual: TypeDescription) -> TypeError {
318        TypeError {
319            expected,
320            actual,
321            path: self.path.clone(),
322        }
323    }
324}