1use serde::{Deserialize, Serialize};
6
7#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ArchitectureState {
12 #[serde(default)]
14 pub project: Option<ProjectDef>,
15 pub meta: Meta,
16 #[serde(default)]
17 pub records: Vec<RecordDef>,
18 #[serde(default)]
19 pub tasks: Vec<TaskDef>,
20 #[serde(default)]
21 pub binaries: Vec<BinaryDef>,
22 #[serde(default)]
23 pub decisions: Vec<DecisionEntry>,
24}
25
26impl ArchitectureState {
27 pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
29 toml::from_str(s)
30 }
31
32 pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
34 toml::to_string_pretty(self)
35 }
36}
37
38#[derive(Debug, Clone, Serialize, Deserialize)]
42pub struct Meta {
43 pub aimdb_version: String,
44 pub created_at: String,
45 pub last_modified: String,
46}
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ProjectDef {
53 pub name: String,
55 #[serde(default)]
57 pub edition: Option<String>,
58}
59
60#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
64#[serde(rename_all = "lowercase")]
65pub enum SerializationType {
66 #[default]
68 Json,
69 Postcard,
71 Custom,
73}
74
75#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ObservableDef {
80 pub signal_field: String,
82 pub icon: String,
84 pub unit: String,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct RecordDef {
93 pub name: String,
95 pub buffer: BufferType,
97 #[serde(default)]
99 pub capacity: Option<usize>,
100 #[serde(default)]
102 pub key_prefix: String,
103 #[serde(default)]
105 pub key_variants: Vec<String>,
106 #[serde(default)]
108 pub producers: Vec<String>,
109 #[serde(default)]
111 pub consumers: Vec<String>,
112
113 #[serde(default)]
115 pub schema_version: Option<u32>,
116 #[serde(default)]
118 pub serialization: Option<SerializationType>,
119 #[serde(default)]
121 pub observable: Option<ObservableDef>,
122
123 #[serde(default)]
125 pub fields: Vec<FieldDef>,
126 #[serde(default)]
128 pub connectors: Vec<ConnectorDef>,
129}
130
131#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135pub enum BufferType {
136 SpmcRing,
137 SingleLatest,
138 Mailbox,
139}
140
141impl BufferType {
142 pub fn label(&self, capacity: Option<usize>) -> String {
144 match self {
145 BufferType::SpmcRing => {
146 let cap = capacity.unwrap_or(256);
147 format!("SpmcRing · {cap}")
148 }
149 BufferType::SingleLatest => "SingleLatest".to_string(),
150 BufferType::Mailbox => "Mailbox".to_string(),
151 }
152 }
153
154 pub fn rust_expr(&self, capacity: Option<usize>) -> String {
156 match self {
157 BufferType::SpmcRing => {
158 let cap = capacity.unwrap_or(256);
159 format!("BufferCfg::SpmcRing {{ capacity: {cap} }}")
160 }
161 BufferType::SingleLatest => "BufferCfg::SingleLatest".to_string(),
162 BufferType::Mailbox => "BufferCfg::Mailbox".to_string(),
163 }
164 }
165
166 pub fn to_tokens(&self, capacity: Option<usize>) -> proc_macro2::TokenStream {
168 use quote::quote;
169 match self {
170 BufferType::SpmcRing => {
171 let cap = proc_macro2::Literal::usize_unsuffixed(capacity.unwrap_or(256));
172 quote! { BufferCfg::SpmcRing { capacity: #cap } }
173 }
174 BufferType::SingleLatest => quote! { BufferCfg::SingleLatest },
175 BufferType::Mailbox => quote! { BufferCfg::Mailbox },
176 }
177 }
178}
179
180#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct FieldDef {
185 pub name: String,
186 #[serde(rename = "type")]
188 pub field_type: String,
189 #[serde(default)]
190 pub description: String,
191 #[serde(default)]
193 pub settable: bool,
194}
195
196#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct ConnectorDef {
201 pub protocol: String,
203 pub direction: ConnectorDirection,
205 pub url: String,
207}
208
209impl ConnectorDef {
210 pub fn direction_label(&self) -> &'static str {
212 match self.direction {
213 ConnectorDirection::Outbound => "outbound",
214 ConnectorDirection::Inbound => "inbound",
215 }
216 }
217}
218
219#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221#[serde(rename_all = "lowercase")]
222pub enum ConnectorDirection {
223 Outbound,
224 Inbound,
225}
226
227#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
231#[serde(rename_all = "lowercase")]
232pub enum TaskType {
233 #[default]
235 Transform,
236 Agent,
238 Source,
240 Tap,
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct TaskIo {
247 pub record: String,
249 #[serde(default)]
251 pub variants: Vec<String>,
252}
253
254#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct TaskDef {
257 pub name: String,
259 #[serde(default)]
261 pub task_type: TaskType,
262 #[serde(default)]
264 pub description: String,
265 #[serde(default)]
267 pub inputs: Vec<TaskIo>,
268 #[serde(default)]
270 pub outputs: Vec<TaskIo>,
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct ExternalConnectorDef {
278 pub protocol: String,
280 pub env_var: String,
282 #[serde(default)]
284 pub default: String,
285}
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct BinaryDef {
290 pub name: String,
293 #[serde(default)]
295 pub tasks: Vec<String>,
296 #[serde(default)]
298 pub external_connectors: Vec<ExternalConnectorDef>,
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
305pub struct DecisionEntry {
306 pub record: String,
307 pub field: String,
308 pub chosen: String,
309 pub alternative: String,
310 pub reason: String,
311 pub timestamp: String,
312}
313
314#[cfg(test)]
317mod tests {
318 use super::*;
319
320 const SAMPLE_TOML: &str = r#"
321[meta]
322aimdb_version = "0.5.0"
323created_at = "2026-02-22T14:00:00Z"
324last_modified = "2026-02-22T14:33:00Z"
325
326[[records]]
327name = "TemperatureReading"
328buffer = "SpmcRing"
329capacity = 256
330key_prefix = "sensors.temp."
331key_variants = ["indoor", "outdoor", "garage"]
332producers = ["sensor_task"]
333consumers = ["dashboard", "anomaly_detector"]
334
335[[records.fields]]
336name = "celsius"
337type = "f64"
338description = "Temperature in degrees Celsius"
339
340[[records.fields]]
341name = "humidity_percent"
342type = "f64"
343description = "Relative humidity 0-100"
344
345[[records.fields]]
346name = "timestamp"
347type = "u64"
348description = "Unix timestamp in milliseconds"
349
350[[records.connectors]]
351protocol = "mqtt"
352direction = "outbound"
353url = "mqtt://sensors/temp/{variant}"
354
355[[records]]
356name = "OtaCommand"
357buffer = "Mailbox"
358key_prefix = "device.ota."
359key_variants = ["gateway-01"]
360producers = ["cloud_ota_service"]
361consumers = ["device_update_task"]
362
363[[records.fields]]
364name = "action"
365type = "String"
366description = "Command action"
367
368[[decisions]]
369record = "TemperatureReading"
370field = "buffer"
371chosen = "SpmcRing"
372alternative = "SingleLatest"
373reason = "Anomaly detector needs a sample window"
374timestamp = "2026-02-22T14:20:00Z"
375"#;
376
377 #[test]
378 fn parses_meta() {
379 let state = ArchitectureState::from_toml(SAMPLE_TOML).unwrap();
380 assert_eq!(state.meta.aimdb_version, "0.5.0");
381 assert_eq!(state.meta.created_at, "2026-02-22T14:00:00Z");
382 }
383
384 #[test]
385 fn parses_records() {
386 let state = ArchitectureState::from_toml(SAMPLE_TOML).unwrap();
387 assert_eq!(state.records.len(), 2);
388
389 let r = &state.records[0];
390 assert_eq!(r.name, "TemperatureReading");
391 assert_eq!(r.buffer, BufferType::SpmcRing);
392 assert_eq!(r.capacity, Some(256));
393 assert_eq!(r.key_prefix, "sensors.temp.");
394 assert_eq!(r.key_variants, vec!["indoor", "outdoor", "garage"]);
395 assert_eq!(r.producers, vec!["sensor_task"]);
396 assert_eq!(r.consumers, vec!["dashboard", "anomaly_detector"]);
397 }
398
399 #[test]
400 fn parses_fields() {
401 let state = ArchitectureState::from_toml(SAMPLE_TOML).unwrap();
402 let r = &state.records[0];
403 assert_eq!(r.fields.len(), 3);
404 assert_eq!(r.fields[0].name, "celsius");
405 assert_eq!(r.fields[0].field_type, "f64");
406 assert_eq!(r.fields[0].description, "Temperature in degrees Celsius");
407 }
408
409 #[test]
410 fn parses_connectors() {
411 let state = ArchitectureState::from_toml(SAMPLE_TOML).unwrap();
412 let r = &state.records[0];
413 assert_eq!(r.connectors.len(), 1);
414 assert_eq!(r.connectors[0].protocol, "mqtt");
415 assert_eq!(r.connectors[0].direction, ConnectorDirection::Outbound);
416 assert_eq!(r.connectors[0].url, "mqtt://sensors/temp/{variant}");
417 }
418
419 #[test]
420 fn parses_decisions() {
421 let state = ArchitectureState::from_toml(SAMPLE_TOML).unwrap();
422 assert_eq!(state.decisions.len(), 1);
423 assert_eq!(state.decisions[0].record, "TemperatureReading");
424 assert_eq!(state.decisions[0].chosen, "SpmcRing");
425 }
426
427 #[test]
428 fn buffer_label_spmc() {
429 assert_eq!(BufferType::SpmcRing.label(Some(256)), "SpmcRing · 256");
430 }
431
432 #[test]
433 fn buffer_label_single_latest() {
434 assert_eq!(BufferType::SingleLatest.label(None), "SingleLatest");
435 }
436
437 #[test]
438 fn buffer_rust_expr_mailbox() {
439 assert_eq!(BufferType::Mailbox.rust_expr(None), "BufferCfg::Mailbox");
440 }
441
442 const EXTENDED_TOML: &str = r#"
443[project]
444name = "weather-sentinel"
445
446[meta]
447aimdb_version = "0.5.0"
448created_at = "2026-02-24T21:39:15Z"
449last_modified = "2026-02-25T10:00:00Z"
450
451[[records]]
452name = "WeatherObservation"
453buffer = "SpmcRing"
454capacity = 256
455key_prefix = "weather.observation."
456key_variants = ["Vienna", "Munich"]
457schema_version = 2
458serialization = "json"
459
460[records.observable]
461signal_field = "temperature_celsius"
462icon = "🌡️"
463unit = "°C"
464
465[[records.fields]]
466name = "timestamp"
467type = "u64"
468description = "Unix timestamp in milliseconds"
469
470[[records.fields]]
471name = "temperature_celsius"
472type = "f32"
473description = "Air temperature"
474settable = true
475
476[[records.fields]]
477name = "humidity_percent"
478type = "f32"
479description = "Relative humidity"
480settable = true
481"#;
482
483 #[test]
484 fn parses_project_block() {
485 let state = ArchitectureState::from_toml(EXTENDED_TOML).unwrap();
486 let project = state.project.as_ref().unwrap();
487 assert_eq!(project.name, "weather-sentinel");
488 assert!(project.edition.is_none());
489 }
490
491 #[test]
492 fn parses_schema_version_and_serialization() {
493 let state = ArchitectureState::from_toml(EXTENDED_TOML).unwrap();
494 let r = &state.records[0];
495 assert_eq!(r.schema_version, Some(2));
496 assert_eq!(r.serialization, Some(SerializationType::Json));
497 }
498
499 #[test]
500 fn parses_observable_block() {
501 let state = ArchitectureState::from_toml(EXTENDED_TOML).unwrap();
502 let obs = state.records[0].observable.as_ref().unwrap();
503 assert_eq!(obs.signal_field, "temperature_celsius");
504 assert_eq!(obs.icon, "🌡️");
505 assert_eq!(obs.unit, "°C");
506 }
507
508 #[test]
509 fn parses_settable_field() {
510 let state = ArchitectureState::from_toml(EXTENDED_TOML).unwrap();
511 let fields = &state.records[0].fields;
512 assert!(!fields[0].settable); assert!(fields[1].settable); assert!(fields[2].settable); }
516
517 #[test]
518 fn project_block_is_optional() {
519 let state = ArchitectureState::from_toml(SAMPLE_TOML).unwrap();
520 assert!(state.project.is_none());
521 }
522
523 #[test]
524 fn new_fields_default_when_absent() {
525 let state = ArchitectureState::from_toml(SAMPLE_TOML).unwrap();
526 let r = &state.records[0];
527 assert!(r.schema_version.is_none());
528 assert!(r.serialization.is_none());
529 assert!(r.observable.is_none());
530 assert!(!r.fields[0].settable);
531 }
532
533 #[test]
534 fn round_trips_toml() {
535 let state = ArchitectureState::from_toml(SAMPLE_TOML).unwrap();
536 let serialised = state.to_toml().unwrap();
537 let state2 = ArchitectureState::from_toml(&serialised).unwrap();
538 assert_eq!(state.records.len(), state2.records.len());
539 assert_eq!(state.decisions.len(), state2.decisions.len());
540 }
541}