Skip to main content

aimdb_codegen/
state.rs

1//! AimDB Codegen — architecture state types and TOML parser
2//!
3//! Deserialises `.aimdb/state.toml` into [`ArchitectureState`].
4
5use serde::{Deserialize, Serialize};
6
7// ── Top-level state ──────────────────────────────────────────────────────────
8
9/// The full contents of `.aimdb/state.toml`.
10#[derive(Debug, Clone, Serialize, Deserialize)]
11pub struct ArchitectureState {
12    /// Optional project metadata for common crate generation.
13    #[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    /// Parse from a TOML string (the contents of `state.toml`).
28    pub fn from_toml(s: &str) -> Result<Self, toml::de::Error> {
29        toml::from_str(s)
30    }
31
32    /// Serialise back to a TOML string.
33    pub fn to_toml(&self) -> Result<String, toml::ser::Error> {
34        toml::to_string_pretty(self)
35    }
36}
37
38// ── Meta block ───────────────────────────────────────────────────────────────
39
40/// `[meta]` block — version and timestamps.
41#[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// ── Project metadata ─────────────────────────────────────────────────────────
49
50/// `[project]` block — drives common crate naming and Rust edition.
51#[derive(Debug, Clone, Serialize, Deserialize)]
52pub struct ProjectDef {
53    /// Project name, used for crate naming: `{name}-common`.
54    pub name: String,
55    /// Rust edition for the generated crate (default `"2024"` at codegen time).
56    #[serde(default)]
57    pub edition: Option<String>,
58}
59
60// ── Serialization type ───────────────────────────────────────────────────────
61
62/// Serialization format for `Linkable` trait generation.
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
64#[serde(rename_all = "lowercase")]
65pub enum SerializationType {
66    /// JSON via `serde_json` (std-only, `no_std` fallback returns error).
67    #[default]
68    Json,
69    /// Binary via `postcard` (works in both std and `no_std`).
70    Postcard,
71    /// No generated `Linkable` impl — user provides their own.
72    Custom,
73}
74
75// ── Observable metadata ─────────────────────────────────────────────────────
76
77/// `[records.observable]` block — metadata for `Observable` trait generation.
78#[derive(Debug, Clone, Serialize, Deserialize)]
79pub struct ObservableDef {
80    /// Field name to use as `Observable::signal()` return value.
81    pub signal_field: String,
82    /// Icon/emoji for log output (e.g. `"🌡️"`).
83    pub icon: String,
84    /// Unit label for the signal (e.g. `"°C"`).
85    pub unit: String,
86}
87
88// ── Record definition ────────────────────────────────────────────────────────
89
90/// One `[[records]]` entry.
91#[derive(Debug, Clone, Serialize, Deserialize)]
92pub struct RecordDef {
93    /// PascalCase name, e.g. `TemperatureReading`.
94    pub name: String,
95    /// Buffer type selection.
96    pub buffer: BufferType,
97    /// Required when `buffer == SpmcRing`. Ignored otherwise.
98    #[serde(default)]
99    pub capacity: Option<usize>,
100    /// Common key prefix, e.g. `"sensors.temp."`.
101    #[serde(default)]
102    pub key_prefix: String,
103    /// Concrete key variant strings, e.g. `["indoor", "outdoor", "garage"]`.
104    #[serde(default)]
105    pub key_variants: Vec<String>,
106    /// Names of tasks that produce values into this record.
107    #[serde(default)]
108    pub producers: Vec<String>,
109    /// Names of tasks that consume values from this record.
110    #[serde(default)]
111    pub consumers: Vec<String>,
112
113    /// Schema version for `SchemaType::VERSION` (default 1).
114    #[serde(default)]
115    pub schema_version: Option<u32>,
116    /// Serialization format for `Linkable` generation (default `"json"`).
117    #[serde(default)]
118    pub serialization: Option<SerializationType>,
119    /// Observable trait metadata (omit to skip `Observable` impl).
120    #[serde(default)]
121    pub observable: Option<ObservableDef>,
122
123    /// Value struct fields (agent-derived from datasheets / specs / conversation).
124    #[serde(default)]
125    pub fields: Vec<FieldDef>,
126    /// External connector definitions.
127    #[serde(default)]
128    pub connectors: Vec<ConnectorDef>,
129}
130
131// ── Buffer type ──────────────────────────────────────────────────────────────
132
133/// The three AimDB buffer primitives.
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135pub enum BufferType {
136    SpmcRing,
137    SingleLatest,
138    Mailbox,
139}
140
141impl BufferType {
142    /// Human-readable label used in Mermaid node annotations.
143    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    /// The `BufferCfg` expression emitted into generated Rust.
155    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    /// The `BufferCfg` expression as a token stream for use with `quote!`.
167    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// ── Field definition ─────────────────────────────────────────────────────────
181
182/// One `[[records.fields]]` entry — a typed field in the value struct.
183#[derive(Debug, Clone, Serialize, Deserialize)]
184pub struct FieldDef {
185    pub name: String,
186    /// Rust primitive type string, e.g. `"f64"`, `"u64"`, `"String"`, `"bool"`.
187    #[serde(rename = "type")]
188    pub field_type: String,
189    #[serde(default)]
190    pub description: String,
191    /// Include this field in `Settable::Value` tuple (default `false`).
192    #[serde(default)]
193    pub settable: bool,
194}
195
196// ── Connector definition ─────────────────────────────────────────────────────
197
198/// One `[[records.connectors]]` entry.
199#[derive(Debug, Clone, Serialize, Deserialize)]
200pub struct ConnectorDef {
201    /// Protocol identifier lower-case, e.g. `"mqtt"`, `"knx"`.
202    pub protocol: String,
203    /// `"outbound"` → `link_to`, `"inbound"` → `link_from`.
204    pub direction: ConnectorDirection,
205    /// URL template, may contain `{variant}` placeholder.
206    pub url: String,
207}
208
209impl ConnectorDef {
210    /// Human-readable direction label for doc comments.
211    pub fn direction_label(&self) -> &'static str {
212        match self.direction {
213            ConnectorDirection::Outbound => "outbound",
214            ConnectorDirection::Inbound => "inbound",
215        }
216    }
217}
218
219/// Connector data flow direction.
220#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221#[serde(rename_all = "lowercase")]
222pub enum ConnectorDirection {
223    Outbound,
224    Inbound,
225}
226
227// ── Task definition ──────────────────────────────────────────────────────────
228
229/// The functional role of a task — drives stub body generation.
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
231#[serde(rename_all = "lowercase")]
232pub enum TaskType {
233    /// Reads one or more records, transforms them, writes to output records.
234    #[default]
235    Transform,
236    /// LLM-driven reasoning loop, flags anomalies, cross-correlates data.
237    Agent,
238    /// Fetches external data and writes values into a record.
239    Source,
240    /// Forwards, stores, or logs values — no output records in the DB.
241    Tap,
242}
243
244/// One `[[tasks.inputs]]` or `[[tasks.outputs]]` entry.
245#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct TaskIo {
247    /// PascalCase record name, e.g. `"HourlyForecastPoint"`.
248    pub record: String,
249    /// Specific variants; empty (`[]`) means all variants of that record.
250    #[serde(default)]
251    pub variants: Vec<String>,
252}
253
254/// One `[[tasks]]` entry — describes an async task function.
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct TaskDef {
257    /// snake_case function name, e.g. `"hub_validation_task"`.
258    pub name: String,
259    /// Functional classification — drives stub body.
260    #[serde(default)]
261    pub task_type: TaskType,
262    /// Human-readable description, used in doc comments and todo! msgs.
263    #[serde(default)]
264    pub description: String,
265    /// Records this task reads from.
266    #[serde(default)]
267    pub inputs: Vec<TaskIo>,
268    /// Records this task writes to.
269    #[serde(default)]
270    pub outputs: Vec<TaskIo>,
271}
272
273// ── Binary definition ────────────────────────────────────────────────────────
274
275/// One `[[binaries.external_connectors]]` entry — a runtime broker connection.
276#[derive(Debug, Clone, Serialize, Deserialize)]
277pub struct ExternalConnectorDef {
278    /// Protocol identifier, e.g. `"mqtt"`.
279    pub protocol: String,
280    /// Environment variable that provides the broker URL at runtime.
281    pub env_var: String,
282    /// Default URL when the env var is not set.
283    #[serde(default)]
284    pub default: String,
285}
286
287/// One `[[binaries]]` entry — a deployable binary crate.
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct BinaryDef {
290    /// Directory name of the binary crate, e.g. `"weather-sentinel-hub"`.
291    /// The codegen derives the crate path as `../{name}/`.
292    pub name: String,
293    /// Task names belonging to this binary (must match `[[tasks]]` entries).
294    #[serde(default)]
295    pub tasks: Vec<String>,
296    /// Runtime broker connections needed by this binary.
297    #[serde(default)]
298    pub external_connectors: Vec<ExternalConnectorDef>,
299}
300
301// ── Decision log entry ───────────────────────────────────────────────────────
302
303/// One `[[decisions]]` entry — architectural rationale.
304#[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// ── Tests ────────────────────────────────────────────────────────────────────
315
316#[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); // timestamp
513        assert!(fields[1].settable); // temperature_celsius
514        assert!(fields[2].settable); // humidity_percent
515    }
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}