1use crate::state::{ArchitectureState, BufferType};
7
8#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct ValidationError {
11 pub message: String,
13 pub location: String,
15 pub severity: Severity,
17}
18
19#[derive(Debug, Clone, PartialEq, Eq)]
21pub enum Severity {
22 Error,
24 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
38pub const VALID_FIELD_TYPES: &[&str] = &[
40 "f64", "f32", "u8", "u16", "u32", "u64", "i8", "i16", "i32", "i64", "bool", "String",
41];
42
43pub 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
57pub fn is_valid(state: &ArchitectureState) -> bool {
59 !validate(state)
60 .iter()
61 .any(|e| e.severity == Severity::Error)
62}
63
64fn 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 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; }
91
92 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 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 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 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 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 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 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 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 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 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 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 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 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
301fn 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 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 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 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 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 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 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#[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}