Skip to main content

aimdb_codegen/
validate.rs

1//! Architecture state validator
2//!
3//! Checks an [`ArchitectureState`] for structural and semantic errors before
4//! code is generated or proposals are confirmed.
5
6use crate::state::{ArchitectureState, BufferType};
7
8/// A single validation problem.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ValidationError {
11    /// Human-readable description of the problem.
12    pub message: String,
13    /// Location in state.toml that caused the error (e.g. `records[0].fields[1]`).
14    pub location: String,
15    /// Whether this blocks code generation (`Error`) or is advisory (`Warning`).
16    pub severity: Severity,
17}
18
19/// Severity of a [`ValidationError`].
20#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Severity {
22    /// Blocks code generation — generated code would be invalid or uncompilable.
23    Error,
24    /// Advisory — generated code may still work but behaviour could be unexpected.
25    Warning,
26}
27
28impl std::fmt::Display for ValidationError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        let tag = match self.severity {
31            Severity::Error => "ERROR",
32            Severity::Warning => "WARN",
33        };
34        write!(f, "[{}] {}: {}", tag, self.location, self.message)
35    }
36}
37
38/// Supported Rust field types for `records.fields[*].type`.
39pub const VALID_FIELD_TYPES: &[&str] = &[
40    "f64", "f32", "u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64", "bool", "String",
41];
42
43/// Validate an [`ArchitectureState`] and return all problems found.
44///
45/// An empty `Vec` means the state is valid and codegen may proceed.
46/// Any entry with [`Severity::Error`] should block generation.
47pub fn validate(state: &ArchitectureState) -> Vec<ValidationError> {
48    let mut errors: Vec<ValidationError> = Vec::new();
49
50    validate_meta(state, &mut errors);
51    validate_records(state, &mut errors);
52    validate_tasks_and_binaries(state, &mut errors);
53
54    errors
55}
56
57/// Returns `true` if `validate()` produces no `Error`-severity issues.
58pub fn is_valid(state: &ArchitectureState) -> bool {
59    !validate(state)
60        .iter()
61        .any(|e| e.severity == Severity::Error)
62}
63
64// ── Internal validators ────────────────────────────────────────────────────────
65
66fn validate_meta(state: &ArchitectureState, errors: &mut Vec<ValidationError>) {
67    if state.meta.aimdb_version.is_empty() {
68        errors.push(ValidationError {
69            message: "aimdb_version must not be empty".to_string(),
70            location: "meta.aimdb_version".to_string(),
71            severity: Severity::Error,
72        });
73    }
74}
75
76fn validate_records(state: &ArchitectureState, errors: &mut Vec<ValidationError>) {
77    let mut seen_names: Vec<&str> = Vec::new();
78
79    for (idx, rec) in state.records.iter().enumerate() {
80        let loc = format!("records[{idx}]");
81
82        // Name must be non-empty
83        if rec.name.is_empty() {
84            errors.push(ValidationError {
85                message: "record name must not be empty".to_string(),
86                location: loc.clone(),
87                severity: Severity::Error,
88            });
89            continue; // Can't do further checks without a name
90        }
91
92        // Name should start with an uppercase letter (PascalCase convention)
93        if !rec
94            .name
95            .chars()
96            .next()
97            .map(|c| c.is_uppercase())
98            .unwrap_or(false)
99        {
100            errors.push(ValidationError {
101                message: format!(
102                    "record name '{}' should start with an uppercase letter (PascalCase)",
103                    rec.name
104                ),
105                location: format!("{loc}.name"),
106                severity: Severity::Warning,
107            });
108        }
109
110        // Duplicate record names
111        if seen_names.contains(&rec.name.as_str()) {
112            errors.push(ValidationError {
113                message: format!("duplicate record name '{}'", rec.name),
114                location: format!("{loc}.name"),
115                severity: Severity::Error,
116            });
117        } else {
118            seen_names.push(&rec.name);
119        }
120
121        // SpmcRing must have capacity > 0
122        if rec.buffer == BufferType::SpmcRing {
123            match rec.capacity {
124                None => {
125                    errors.push(ValidationError {
126                        message: "SpmcRing requires 'capacity' to be set".to_string(),
127                        location: format!("{loc}.capacity"),
128                        severity: Severity::Error,
129                    });
130                }
131                Some(0) => {
132                    errors.push(ValidationError {
133                        message: "SpmcRing capacity must be > 0".to_string(),
134                        location: format!("{loc}.capacity"),
135                        severity: Severity::Error,
136                    });
137                }
138                _ => {}
139            }
140        }
141
142        // Warn if capacity is set but buffer is not SpmcRing
143        if rec.buffer != BufferType::SpmcRing && rec.capacity.is_some() {
144            errors.push(ValidationError {
145                message: "capacity is only meaningful for SpmcRing; it will be ignored".to_string(),
146                location: format!("{loc}.capacity"),
147                severity: Severity::Warning,
148            });
149        }
150
151        // Warn if no key variants
152        if rec.key_variants.is_empty() {
153            errors.push(ValidationError {
154                message: format!(
155                    "record '{}' has no key_variants — the key enum will be empty and unusable",
156                    rec.name
157                ),
158                location: format!("{loc}.key_variants"),
159                severity: Severity::Warning,
160            });
161        }
162
163        // Duplicate key variants
164        let mut seen_variants: Vec<&str> = Vec::new();
165        for variant in &rec.key_variants {
166            if seen_variants.contains(&variant.as_str()) {
167                errors.push(ValidationError {
168                    message: format!("duplicate key variant '{variant}'"),
169                    location: format!("{loc}.key_variants"),
170                    severity: Severity::Error,
171                });
172            } else {
173                seen_variants.push(variant);
174            }
175        }
176
177        // Warn if no fields
178        if rec.fields.is_empty() {
179            errors.push(ValidationError {
180                message: format!(
181                    "record '{}' has no fields — the value struct will be empty",
182                    rec.name
183                ),
184                location: format!("{loc}.fields"),
185                severity: Severity::Warning,
186            });
187        }
188
189        // schema_version must be >= 1 if specified
190        if rec.schema_version == Some(0) {
191            errors.push(ValidationError {
192                message: format!(
193                    "record '{}' has schema_version = 0; versions must be >= 1",
194                    rec.name
195                ),
196                location: format!("{loc}.schema_version"),
197                severity: Severity::Warning,
198            });
199        }
200
201        // Warn if settable fields exist but no timestamp field is present
202        let has_settable = rec.fields.iter().any(|f| f.settable);
203        if has_settable {
204            let timestamp_names = ["timestamp", "computed_at", "fetched_at"];
205            let has_timestamp = rec
206                .fields
207                .iter()
208                .any(|f| f.field_type == "u64" && timestamp_names.contains(&f.name.as_str()));
209            if !has_timestamp {
210                errors.push(ValidationError {
211                    message: format!(
212                        "record '{}' has settable fields but no timestamp field \
213                         (u64 named timestamp, computed_at, or fetched_at) — \
214                         Settable::set() will use Default::default() for the timestamp slot",
215                        rec.name
216                    ),
217                    location: format!("{loc}.fields"),
218                    severity: Severity::Warning,
219                });
220            }
221        }
222
223        // Validate field types
224        for (fidx, field) in rec.fields.iter().enumerate() {
225            if field.name.is_empty() {
226                errors.push(ValidationError {
227                    message: "field name must not be empty".to_string(),
228                    location: format!("{loc}.fields[{fidx}]"),
229                    severity: Severity::Error,
230                });
231            }
232            if !VALID_FIELD_TYPES.contains(&field.field_type.as_str()) {
233                errors.push(ValidationError {
234                    message: format!(
235                        "unsupported field type '{}' — valid types: {}",
236                        field.field_type,
237                        VALID_FIELD_TYPES.join(", ")
238                    ),
239                    location: format!("{loc}.fields[{fidx}].type"),
240                    severity: Severity::Error,
241                });
242            }
243        }
244
245        // Validate connectors
246        for (cidx, conn) in rec.connectors.iter().enumerate() {
247            if conn.url.is_empty() {
248                errors.push(ValidationError {
249                    message: "connector URL must not be empty".to_string(),
250                    location: format!("{loc}.connectors[{cidx}].url"),
251                    severity: Severity::Error,
252                });
253            }
254            if conn.protocol.is_empty() {
255                errors.push(ValidationError {
256                    message: "connector protocol must not be empty".to_string(),
257                    location: format!("{loc}.connectors[{cidx}].protocol"),
258                    severity: Severity::Error,
259                });
260            }
261        }
262
263        // Validate observable block
264        if let Some(obs) = &rec.observable {
265            let field_exists = rec.fields.iter().any(|f| f.name == obs.signal_field);
266            if !field_exists {
267                errors.push(ValidationError {
268                    message: format!(
269                        "observable signal_field '{}' does not match any field in record '{}'",
270                        obs.signal_field, rec.name
271                    ),
272                    location: format!("{loc}.observable.signal_field"),
273                    severity: Severity::Error,
274                });
275            } else {
276                // Check signal_field type is numeric (Observable::Signal: PartialOrd + Copy)
277                let field = rec
278                    .fields
279                    .iter()
280                    .find(|f| f.name == obs.signal_field)
281                    .unwrap();
282                let numeric_types = [
283                    "f32", "f64", "u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64",
284                ];
285                if !numeric_types.contains(&field.field_type.as_str()) {
286                    errors.push(ValidationError {
287                        message: format!(
288                            "observable signal_field '{}' has type '{}' which is not numeric — \
289                             Observable::Signal must implement PartialOrd + Copy",
290                            obs.signal_field, field.field_type
291                        ),
292                        location: format!("{loc}.observable.signal_field"),
293                        severity: Severity::Warning,
294                    });
295                }
296            }
297        }
298    }
299}
300
301// ── Tasks and binaries validation ─────────────────────────────────────────────
302
303fn validate_tasks_and_binaries(state: &ArchitectureState, errors: &mut Vec<ValidationError>) {
304    let record_names: Vec<&str> = state.records.iter().map(|r| r.name.as_str()).collect();
305    let task_names: Vec<&str> = state.tasks.iter().map(|t| t.name.as_str()).collect();
306
307    // Rule 1: task name in producers/consumers has no [[tasks]] entry → Warning
308    for (ridx, rec) in state.records.iter().enumerate() {
309        for producer in &rec.producers {
310            if !task_names.contains(&producer.as_str()) {
311                errors.push(ValidationError {
312                    message: format!(
313                        "producer '{producer}' in record '{}' has no [[tasks]] entry",
314                        rec.name
315                    ),
316                    location: format!("records[{ridx}].producers"),
317                    severity: Severity::Warning,
318                });
319            }
320        }
321        for consumer in &rec.consumers {
322            if !task_names.contains(&consumer.as_str()) {
323                errors.push(ValidationError {
324                    message: format!(
325                        "consumer '{consumer}' in record '{}' has no [[tasks]] entry",
326                        rec.name
327                    ),
328                    location: format!("records[{ridx}].consumers"),
329                    severity: Severity::Warning,
330                });
331            }
332        }
333    }
334
335    // Rules 2, 3, 5: task I/O references
336    for (tidx, task) in state.tasks.iter().enumerate() {
337        let tloc = format!("tasks[{tidx}]");
338
339        for (iidx, input) in task.inputs.iter().enumerate() {
340            // Rule 2: inputs reference a record not in [[records]]
341            if !record_names.contains(&input.record.as_str()) {
342                errors.push(ValidationError {
343                    message: format!(
344                        "task '{}' input references unknown record '{}'",
345                        task.name, input.record
346                    ),
347                    location: format!("{tloc}.inputs[{iidx}].record"),
348                    severity: Severity::Error,
349                });
350            }
351        }
352
353        for (oidx, output) in task.outputs.iter().enumerate() {
354            // Rule 3: outputs reference a record not in [[records]]
355            if !record_names.contains(&output.record.as_str()) {
356                errors.push(ValidationError {
357                    message: format!(
358                        "task '{}' output references unknown record '{}'",
359                        task.name, output.record
360                    ),
361                    location: format!("{tloc}.outputs[{oidx}].record"),
362                    severity: Severity::Error,
363                });
364                continue;
365            }
366
367            // Rule 5: output variant not in that record's key_variants (only when variants is non-empty)
368            if !output.variants.is_empty() {
369                let rec = state.records.iter().find(|r| r.name == output.record);
370                if let Some(rec) = rec {
371                    for variant in &output.variants {
372                        if !rec.key_variants.contains(variant) {
373                            errors.push(ValidationError {
374                                message: format!(
375                                    "task '{}' output variant '{variant}' not found in record '{}' key_variants",
376                                    task.name, output.record
377                                ),
378                                location: format!("{tloc}.outputs[{oidx}].variants"),
379                                severity: Severity::Error,
380                            });
381                        }
382                    }
383                }
384            }
385        }
386    }
387
388    // Rule 4: binary task name not found in [[tasks]]
389    for (bidx, bin) in state.binaries.iter().enumerate() {
390        for task_name in &bin.tasks {
391            if !task_names.contains(&task_name.as_str()) {
392                errors.push(ValidationError {
393                    message: format!(
394                        "binary '{}' references task '{task_name}' which has no [[tasks]] entry",
395                        bin.name
396                    ),
397                    location: format!("binaries[{bidx}].tasks"),
398                    severity: Severity::Error,
399                });
400            }
401        }
402    }
403}
404
405// ── Tests ─────────────────────────────────────────────────────────────────────
406
407#[cfg(test)]
408mod tests {
409    use super::*;
410    use crate::state::ArchitectureState;
411
412    const VALID_TOML: &str = r#"
413[meta]
414aimdb_version = "0.5.0"
415created_at = "2026-02-22T14:00:00Z"
416last_modified = "2026-02-22T14:33:00Z"
417
418[[records]]
419name = "TemperatureReading"
420buffer = "SpmcRing"
421capacity = 256
422key_prefix = "sensors.temp."
423key_variants = ["indoor", "outdoor"]
424producers = ["sensor_task"]
425consumers = ["dashboard"]
426
427[[records.fields]]
428name = "celsius"
429type = "f64"
430description = "Temperature"
431
432[[records.connectors]]
433protocol = "mqtt"
434direction = "outbound"
435url = "mqtt://sensors/temp/{variant}"
436"#;
437
438    fn valid_state() -> ArchitectureState {
439        ArchitectureState::from_toml(VALID_TOML).unwrap()
440    }
441
442    #[test]
443    fn valid_state_has_no_errors() {
444        let errs = validate(&valid_state());
445        let error_errs: Vec<_> = errs
446            .iter()
447            .filter(|e| e.severity == Severity::Error)
448            .collect();
449        assert!(error_errs.is_empty(), "Unexpected errors: {error_errs:?}");
450    }
451
452    #[test]
453    fn is_valid_returns_true_for_clean_state() {
454        assert!(is_valid(&valid_state()));
455    }
456
457    #[test]
458    fn detects_spmc_missing_capacity() {
459        let toml = VALID_TOML.replace("capacity = 256\n", "");
460        let state = ArchitectureState::from_toml(&toml).unwrap();
461        let errs = validate(&state);
462        let has_err = errs
463            .iter()
464            .any(|e| e.severity == Severity::Error && e.message.contains("capacity"));
465        assert!(
466            has_err,
467            "Should detect missing SpmcRing capacity:\n{errs:?}"
468        );
469    }
470
471    #[test]
472    fn detects_spmc_zero_capacity() {
473        let toml = VALID_TOML.replace("capacity = 256", "capacity = 0");
474        let state = ArchitectureState::from_toml(&toml).unwrap();
475        let errs = validate(&state);
476        let has_err = errs
477            .iter()
478            .any(|e| e.severity == Severity::Error && e.message.contains("capacity must be > 0"));
479        assert!(has_err, "Should detect zero capacity:\n{errs:?}");
480    }
481
482    #[test]
483    fn detects_duplicate_record_names() {
484        let toml = format!(
485            "{VALID_TOML}{}",
486            r#"
487[[records]]
488name = "TemperatureReading"
489buffer = "SingleLatest"
490key_variants = ["a"]
491
492[[records.fields]]
493name = "value"
494type = "f64"
495description = "Value"
496"#
497        );
498        let state = ArchitectureState::from_toml(&toml).unwrap();
499        let errs = validate(&state);
500        let has_err = errs
501            .iter()
502            .any(|e| e.severity == Severity::Error && e.message.contains("duplicate record name"));
503        assert!(has_err, "Should detect duplicate record name:\n{errs:?}");
504    }
505
506    #[test]
507    fn detects_duplicate_key_variants() {
508        let toml = VALID_TOML.replace(
509            r#"key_variants = ["indoor", "outdoor"]"#,
510            r#"key_variants = ["indoor", "indoor"]"#,
511        );
512        let state = ArchitectureState::from_toml(&toml).unwrap();
513        let errs = validate(&state);
514        let has_err = errs
515            .iter()
516            .any(|e| e.severity == Severity::Error && e.message.contains("duplicate key variant"));
517        assert!(has_err, "Should detect duplicate key variants:\n{errs:?}");
518    }
519
520    #[test]
521    fn detects_invalid_field_type() {
522        let toml = VALID_TOML.replace(r#"type = "f64""#, r#"type = "float64""#);
523        let state = ArchitectureState::from_toml(&toml).unwrap();
524        let errs = validate(&state);
525        let has_err = errs
526            .iter()
527            .any(|e| e.severity == Severity::Error && e.message.contains("unsupported field type"));
528        assert!(has_err, "Should detect invalid field type:\n{errs:?}");
529    }
530
531    #[test]
532    fn detects_empty_connector_url() {
533        let toml = VALID_TOML.replace(r#"url = "mqtt://sensors/temp/{variant}""#, r#"url = """#);
534        let state = ArchitectureState::from_toml(&toml).unwrap();
535        let errs = validate(&state);
536        let has_err = errs
537            .iter()
538            .any(|e| e.severity == Severity::Error && e.message.contains("URL must not be empty"));
539        assert!(has_err, "Should detect empty connector URL:\n{errs:?}");
540    }
541
542    #[test]
543    fn warning_for_non_pascal_case_name() {
544        let toml = VALID_TOML.replace(
545            "name = \"TemperatureReading\"",
546            "name = \"temperatureReading\"",
547        );
548        let state = ArchitectureState::from_toml(&toml).unwrap();
549        let errs = validate(&state);
550        let has_warn = errs
551            .iter()
552            .any(|e| e.severity == Severity::Warning && e.message.contains("uppercase"));
553        assert!(has_warn, "Should warn about non-PascalCase name:\n{errs:?}");
554    }
555
556    #[test]
557    fn warning_for_capacity_on_non_spmc() {
558        let toml = VALID_TOML.replace("buffer = \"SpmcRing\"", "buffer = \"SingleLatest\"");
559        let state = ArchitectureState::from_toml(&toml).unwrap();
560        let errs = validate(&state);
561        let has_warn = errs.iter().any(|e| {
562            e.severity == Severity::Warning && e.message.contains("capacity is only meaningful")
563        });
564        assert!(
565            has_warn,
566            "Should warn about capacity on non-SpmcRing:\n{errs:?}"
567        );
568    }
569
570    #[test]
571    fn display_format() {
572        let e = ValidationError {
573            message: "something wrong".to_string(),
574            location: "records[0].name".to_string(),
575            severity: Severity::Error,
576        };
577        let s = format!("{e}");
578        assert!(s.contains("[ERROR]"), "Display should show [ERROR]:\n{s}");
579        assert!(
580            s.contains("records[0].name"),
581            "Display should show location:\n{s}"
582        );
583    }
584
585    #[test]
586    fn detects_observable_missing_signal_field() {
587        let toml = r#"
588[meta]
589aimdb_version = "0.5.0"
590created_at = "2026-02-22T14:00:00Z"
591last_modified = "2026-02-22T14:33:00Z"
592
593[[records]]
594name = "TemperatureReading"
595buffer = "SpmcRing"
596capacity = 256
597key_prefix = "sensors.temp."
598key_variants = ["indoor"]
599
600[records.observable]
601signal_field = "nonexistent"
602icon = "🌡️"
603unit = "°C"
604
605[[records.fields]]
606name = "celsius"
607type = "f64"
608description = "Temperature"
609"#;
610        let state = ArchitectureState::from_toml(toml).unwrap();
611        let errs = validate(&state);
612        let has_err = errs.iter().any(|e| {
613            e.severity == Severity::Error && e.message.contains("does not match any field")
614        });
615        assert!(
616            has_err,
617            "Should detect missing observable signal_field:\n{errs:?}"
618        );
619    }
620
621    #[test]
622    fn warns_schema_version_zero() {
623        let toml = r#"
624[meta]
625aimdb_version = "0.5.0"
626created_at = "2026-02-22T14:00:00Z"
627last_modified = "2026-02-22T14:33:00Z"
628
629[[records]]
630name = "TemperatureReading"
631buffer = "SpmcRing"
632capacity = 256
633key_prefix = "sensors.temp."
634key_variants = ["indoor"]
635schema_version = 0
636
637[[records.fields]]
638name = "celsius"
639type = "f64"
640description = "Temperature"
641"#;
642        let state = ArchitectureState::from_toml(toml).unwrap();
643        let errs = validate(&state);
644        let has_warn = errs
645            .iter()
646            .any(|e| e.severity == Severity::Warning && e.message.contains("schema_version = 0"));
647        assert!(has_warn, "Should warn about schema_version = 0:\n{errs:?}");
648    }
649
650    #[test]
651    fn warns_settable_fields_without_timestamp() {
652        let toml = r#"
653[meta]
654aimdb_version = "0.5.0"
655created_at = "2026-02-22T14:00:00Z"
656last_modified = "2026-02-22T14:33:00Z"
657
658[[records]]
659name = "TemperatureReading"
660buffer = "SpmcRing"
661capacity = 256
662key_prefix = "sensors.temp."
663key_variants = ["indoor"]
664
665[[records.fields]]
666name = "celsius"
667type = "f64"
668description = "Temperature"
669settable = true
670"#;
671        let state = ArchitectureState::from_toml(toml).unwrap();
672        let errs = validate(&state);
673        let has_warn = errs
674            .iter()
675            .any(|e| e.severity == Severity::Warning && e.message.contains("no timestamp field"));
676        assert!(
677            has_warn,
678            "Should warn about settable fields with no timestamp:\n{errs:?}"
679        );
680    }
681
682    #[test]
683    fn no_warn_settable_fields_with_timestamp() {
684        let toml = r#"
685[meta]
686aimdb_version = "0.5.0"
687created_at = "2026-02-22T14:00:00Z"
688last_modified = "2026-02-22T14:33:00Z"
689
690[[records]]
691name = "TemperatureReading"
692buffer = "SpmcRing"
693capacity = 256
694key_prefix = "sensors.temp."
695key_variants = ["indoor"]
696
697[[records.fields]]
698name = "timestamp"
699type = "u64"
700description = "Unix ms"
701
702[[records.fields]]
703name = "celsius"
704type = "f64"
705description = "Temperature"
706settable = true
707"#;
708        let state = ArchitectureState::from_toml(toml).unwrap();
709        let errs = validate(&state);
710        let has_warn = errs
711            .iter()
712            .any(|e| e.severity == Severity::Warning && e.message.contains("no timestamp field"));
713        assert!(
714            !has_warn,
715            "Should not warn when timestamp field is present:\n{errs:?}"
716        );
717    }
718
719    #[test]
720    fn warns_observable_non_numeric_signal_field() {
721        let toml = r#"
722[meta]
723aimdb_version = "0.5.0"
724created_at = "2026-02-22T14:00:00Z"
725last_modified = "2026-02-22T14:33:00Z"
726
727[[records]]
728name = "TemperatureReading"
729buffer = "SpmcRing"
730capacity = 256
731key_prefix = "sensors.temp."
732key_variants = ["indoor"]
733
734[records.observable]
735signal_field = "label"
736icon = "📊"
737unit = ""
738
739[[records.fields]]
740name = "label"
741type = "String"
742description = "A label"
743"#;
744        let state = ArchitectureState::from_toml(toml).unwrap();
745        let errs = validate(&state);
746        let has_warn = errs
747            .iter()
748            .any(|e| e.severity == Severity::Warning && e.message.contains("not numeric"));
749        assert!(
750            has_warn,
751            "Should warn about non-numeric signal_field:\n{errs:?}"
752        );
753    }
754}