Skip to main content

crdt_codegen/
validator.rs

1use crate::schema::{lookup_crdt, Entity, SchemaFile};
2use std::collections::HashSet;
3use std::fmt;
4
5/// A single validation error with context about where it occurred.
6#[derive(Debug, Clone)]
7pub struct ValidationError {
8    pub entity: Option<String>,
9    pub version: Option<u32>,
10    pub field: Option<String>,
11    pub message: String,
12}
13
14impl fmt::Display for ValidationError {
15    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
16        let mut ctx = Vec::new();
17        if let Some(e) = &self.entity {
18            ctx.push(format!("entity={e}"));
19        }
20        if let Some(v) = self.version {
21            ctx.push(format!("v{v}"));
22        }
23        if let Some(field) = &self.field {
24            ctx.push(format!("field={field}"));
25        }
26        if ctx.is_empty() {
27            write!(f, "{}", self.message)
28        } else {
29            write!(f, "[{}] {}", ctx.join(", "), self.message)
30        }
31    }
32}
33
34/// Supported primitive types for fields.
35const SUPPORTED_PRIMITIVES: &[&str] = &[
36    "String", "bool", "u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64", "f32", "f64",
37];
38
39/// Validate a parsed schema file. Returns `Ok(())` if valid, or a list of errors.
40pub fn validate_schema(schema: &SchemaFile) -> Result<(), Vec<ValidationError>> {
41    let mut errors = Vec::new();
42
43    if schema.config.output.is_empty() {
44        errors.push(ValidationError {
45            entity: None,
46            version: None,
47            field: None,
48            message: "config.output must not be empty".into(),
49        });
50    }
51
52    if schema.entities.is_empty() {
53        errors.push(ValidationError {
54            entity: None,
55            version: None,
56            field: None,
57            message: "schema must define at least one entity".into(),
58        });
59    }
60
61    // Validate events config.
62    if let Some(events) = &schema.config.events {
63        if events.enabled && events.snapshot_threshold == 0 {
64            errors.push(ValidationError {
65                entity: None,
66                version: None,
67                field: None,
68                message: "config.events.snapshot_threshold must be > 0".into(),
69            });
70        }
71    }
72
73    // Validate sync config: requires at least one entity with CRDT fields.
74    if let Some(sync) = &schema.config.sync {
75        if sync.enabled {
76            let any_crdt = schema.entities.iter().any(|e| {
77                e.versions
78                    .iter()
79                    .any(|v| v.fields.iter().any(|f| f.crdt.is_some()))
80            });
81            if !any_crdt {
82                errors.push(ValidationError {
83                    entity: None,
84                    version: None,
85                    field: None,
86                    message: "config.sync.enabled requires at least one entity with CRDT fields"
87                        .into(),
88                });
89            }
90        }
91    }
92
93    let all_entity_names: HashSet<&str> = schema.entities.iter().map(|e| e.name.as_str()).collect();
94
95    let mut entity_names = HashSet::new();
96    for entity in &schema.entities {
97        if !entity_names.insert(&entity.name) {
98            errors.push(ValidationError {
99                entity: Some(entity.name.clone()),
100                version: None,
101                field: None,
102                message: "duplicate entity name".into(),
103            });
104        }
105        validate_entity(entity, &all_entity_names, &mut errors);
106    }
107
108    if errors.is_empty() {
109        Ok(())
110    } else {
111        Err(errors)
112    }
113}
114
115fn validate_entity(
116    entity: &Entity,
117    all_entity_names: &HashSet<&str>,
118    errors: &mut Vec<ValidationError>,
119) {
120    // Check name is non-empty and starts with uppercase.
121    if entity.name.is_empty()
122        || !entity
123            .name
124            .chars()
125            .next()
126            .unwrap_or('a')
127            .is_ascii_uppercase()
128    {
129        errors.push(ValidationError {
130            entity: Some(entity.name.clone()),
131            version: None,
132            field: None,
133            message: "entity name must be PascalCase (start with uppercase)".into(),
134        });
135    }
136
137    if entity.table.is_empty() {
138        errors.push(ValidationError {
139            entity: Some(entity.name.clone()),
140            version: None,
141            field: None,
142            message: "table name must not be empty".into(),
143        });
144    }
145
146    if entity.versions.is_empty() {
147        errors.push(ValidationError {
148            entity: Some(entity.name.clone()),
149            version: None,
150            field: None,
151            message: "entity must have at least one version".into(),
152        });
153        return;
154    }
155
156    // Check versions are contiguous starting at 1.
157    for (i, ver) in entity.versions.iter().enumerate() {
158        let expected = (i as u32) + 1;
159        if ver.version != expected {
160            errors.push(ValidationError {
161                entity: Some(entity.name.clone()),
162                version: Some(ver.version),
163                field: None,
164                message: format!("expected version {expected}, got {}", ver.version),
165            });
166        }
167    }
168
169    // Validate fields in each version.
170    let mut prev_fields: Option<HashSet<String>> = None;
171    for ver in &entity.versions {
172        let mut field_names = HashSet::new();
173        for field in &ver.fields {
174            if !field_names.insert(field.name.clone()) {
175                errors.push(ValidationError {
176                    entity: Some(entity.name.clone()),
177                    version: Some(ver.version),
178                    field: Some(field.name.clone()),
179                    message: "duplicate field name".into(),
180                });
181            }
182
183            // Check field name is non-empty and looks like snake_case.
184            if field.name.is_empty()
185                || field
186                    .name
187                    .chars()
188                    .next()
189                    .unwrap_or('A')
190                    .is_ascii_uppercase()
191            {
192                errors.push(ValidationError {
193                    entity: Some(entity.name.clone()),
194                    version: Some(ver.version),
195                    field: Some(field.name.clone()),
196                    message: "field name must be snake_case (start with lowercase)".into(),
197                });
198            }
199
200            // Check field type is supported.
201            if !is_supported_type(&field.field_type) {
202                errors.push(ValidationError {
203                    entity: Some(entity.name.clone()),
204                    version: Some(ver.version),
205                    field: Some(field.name.clone()),
206                    message: format!("unsupported type `{}`", field.field_type),
207                });
208            }
209
210            // Check CRDT type is valid.
211            if let Some(crdt_name) = &field.crdt {
212                if lookup_crdt(crdt_name).is_none() {
213                    errors.push(ValidationError {
214                        entity: Some(entity.name.clone()),
215                        version: Some(ver.version),
216                        field: Some(field.name.clone()),
217                        message: format!(
218                            "unsupported CRDT type `{crdt_name}` (supported: GCounter, PNCounter, LWWRegister, MVRegister, GSet, TwoPSet, ORSet)"
219                        ),
220                    });
221                }
222            }
223
224            // Check relation references a known entity.
225            if let Some(rel) = &field.relation {
226                if !all_entity_names.contains(rel.as_str()) {
227                    errors.push(ValidationError {
228                        entity: Some(entity.name.clone()),
229                        version: Some(ver.version),
230                        field: Some(field.name.clone()),
231                        message: format!("relation references unknown entity `{rel}`"),
232                    });
233                }
234            }
235
236            // Check that new fields in later versions have defaults.
237            // CRDT fields get auto-defaults, so they don't need explicit ones.
238            if let Some(prev) = &prev_fields {
239                let has_auto_default = field.crdt.is_some();
240                if !prev.contains(&field.name) && field.default.is_none() && !has_auto_default {
241                    errors.push(ValidationError {
242                        entity: Some(entity.name.clone()),
243                        version: Some(ver.version),
244                        field: Some(field.name.clone()),
245                        message: "field added in a later version must have a `default` value"
246                            .into(),
247                    });
248                }
249            }
250        }
251        prev_fields = Some(field_names);
252    }
253}
254
255fn is_supported_type(ty: &str) -> bool {
256    // Direct primitive match.
257    if SUPPORTED_PRIMITIVES.contains(&ty) {
258        return true;
259    }
260    // Option<T>
261    if let Some(inner) = ty.strip_prefix("Option<").and_then(|s| s.strip_suffix('>')) {
262        return is_supported_type(inner.trim());
263    }
264    // Vec<T>
265    if let Some(inner) = ty.strip_prefix("Vec<").and_then(|s| s.strip_suffix('>')) {
266        return is_supported_type(inner.trim());
267    }
268    false
269}
270
271#[cfg(test)]
272mod tests {
273    use super::*;
274    use crate::schema::*;
275
276    fn make_schema(entities: Vec<Entity>) -> SchemaFile {
277        SchemaFile {
278            config: SchemaConfig {
279                output: "src/generated".into(),
280                events: None,
281                sync: None,
282            },
283            entities,
284        }
285    }
286
287    fn make_entity(name: &str, table: &str, versions: Vec<EntityVersion>) -> Entity {
288        Entity {
289            name: name.into(),
290            table: table.into(),
291            versions,
292        }
293    }
294
295    fn make_version(version: u32, fields: Vec<Field>) -> EntityVersion {
296        EntityVersion { version, fields }
297    }
298
299    fn make_field(name: &str, field_type: &str, default: Option<&str>) -> Field {
300        Field {
301            name: name.into(),
302            field_type: field_type.into(),
303            default: default.map(|s| s.into()),
304            crdt: None,
305            relation: None,
306        }
307    }
308
309    #[test]
310    fn valid_minimal_schema() {
311        let schema = make_schema(vec![make_entity(
312            "Task",
313            "tasks",
314            vec![make_version(1, vec![make_field("title", "String", None)])],
315        )]);
316        assert!(validate_schema(&schema).is_ok());
317    }
318
319    #[test]
320    fn empty_output_fails() {
321        let mut schema = make_schema(vec![make_entity(
322            "Task",
323            "tasks",
324            vec![make_version(1, vec![make_field("title", "String", None)])],
325        )]);
326        schema.config.output = String::new();
327        let errs = validate_schema(&schema).unwrap_err();
328        assert!(errs.iter().any(|e| e.message.contains("output")));
329    }
330
331    #[test]
332    fn non_contiguous_versions_fail() {
333        let schema = make_schema(vec![make_entity(
334            "Task",
335            "tasks",
336            vec![
337                make_version(1, vec![make_field("title", "String", None)]),
338                make_version(3, vec![make_field("title", "String", None)]),
339            ],
340        )]);
341        let errs = validate_schema(&schema).unwrap_err();
342        assert!(errs
343            .iter()
344            .any(|e| e.message.contains("expected version 2")));
345    }
346
347    #[test]
348    fn new_field_without_default_fails() {
349        let schema = make_schema(vec![make_entity(
350            "Task",
351            "tasks",
352            vec![
353                make_version(1, vec![make_field("title", "String", None)]),
354                make_version(
355                    2,
356                    vec![
357                        make_field("title", "String", None),
358                        make_field("priority", "Option<u8>", None), // missing default!
359                    ],
360                ),
361            ],
362        )]);
363        let errs = validate_schema(&schema).unwrap_err();
364        assert!(errs.iter().any(|e| e.message.contains("default")));
365    }
366
367    #[test]
368    fn new_field_with_default_passes() {
369        let schema = make_schema(vec![make_entity(
370            "Task",
371            "tasks",
372            vec![
373                make_version(1, vec![make_field("title", "String", None)]),
374                make_version(
375                    2,
376                    vec![
377                        make_field("title", "String", None),
378                        make_field("priority", "Option<u8>", Some("None")),
379                    ],
380                ),
381            ],
382        )]);
383        assert!(validate_schema(&schema).is_ok());
384    }
385
386    #[test]
387    fn unsupported_type_fails() {
388        let schema = make_schema(vec![make_entity(
389            "Task",
390            "tasks",
391            vec![make_version(
392                1,
393                vec![make_field("data", "HashMap<String, String>", None)],
394            )],
395        )]);
396        let errs = validate_schema(&schema).unwrap_err();
397        assert!(errs.iter().any(|e| e.message.contains("unsupported type")));
398    }
399
400    #[test]
401    fn supported_types_pass() {
402        let fields = vec![
403            make_field("a", "String", None),
404            make_field("b", "bool", None),
405            make_field("c", "u8", None),
406            make_field("d", "u64", None),
407            make_field("e", "f32", None),
408            make_field("f", "Option<String>", None),
409            make_field("g", "Vec<u8>", None),
410            make_field("h", "Option<Vec<String>>", None),
411        ];
412        let schema = make_schema(vec![make_entity(
413            "Task",
414            "tasks",
415            vec![make_version(1, fields)],
416        )]);
417        assert!(validate_schema(&schema).is_ok());
418    }
419
420    #[test]
421    fn duplicate_entity_names_fail() {
422        let schema = make_schema(vec![
423            make_entity(
424                "Task",
425                "tasks",
426                vec![make_version(1, vec![make_field("title", "String", None)])],
427            ),
428            make_entity(
429                "Task",
430                "other",
431                vec![make_version(1, vec![make_field("name", "String", None)])],
432            ),
433        ]);
434        let errs = validate_schema(&schema).unwrap_err();
435        assert!(errs.iter().any(|e| e.message.contains("duplicate entity")));
436    }
437
438    #[test]
439    fn lowercase_entity_name_fails() {
440        let schema = make_schema(vec![make_entity(
441            "task",
442            "tasks",
443            vec![make_version(1, vec![make_field("title", "String", None)])],
444        )]);
445        let errs = validate_schema(&schema).unwrap_err();
446        assert!(errs.iter().any(|e| e.message.contains("PascalCase")));
447    }
448}