1use proc_macro2::TokenStream;
11use quote::{format_ident, quote};
12
13use crate::state::{
14 ArchitectureState, ConnectorDef, ConnectorDirection, RecordDef, SerializationType, TaskDef,
15 TaskType,
16};
17
18pub fn generate_rust(state: &ArchitectureState) -> String {
28 let formatted = generate_rust_inner(state);
29
30 let header = "\
31// @generated — do not edit manually.\n\
32// Source: .aimdb/state.toml — edit via `aimdb generate` or the architecture agent.\n\
33// Regenerate: `aimdb generate` or confirm a proposal in the architecture agent.\n\n";
34
35 format!("{header}{formatted}")
36}
37
38pub fn generate_schema_rs(state: &ArchitectureState) -> String {
44 generate_types_inner(state)
45}
46
47fn generate_types_inner(state: &ArchitectureState) -> String {
49 let imports = emit_imports_types_only(state);
50
51 let record_items: Vec<TokenStream> = state
52 .records
53 .iter()
54 .flat_map(|rec| {
55 let mut items = vec![emit_value_struct(rec), emit_key_enum(rec)];
56 items.push(emit_schema_type_impl(rec));
57 let linkable = emit_linkable_impl(rec);
58 if !linkable.is_empty() {
59 items.push(linkable);
60 }
61 if let Some(obs) = emit_observable_impl(rec) {
62 items.push(obs);
63 }
64 if let Some(set) = emit_settable_impl(rec) {
65 items.push(set);
66 }
67 items
68 })
69 .collect();
70
71 let file_tokens = quote! {
72 #imports
73 #(#record_items)*
74 };
75
76 let syntax_tree = syn::parse2(file_tokens).expect("generated tokens should be valid Rust");
77 prettyplease::unparse(&syntax_tree)
78}
79
80fn generate_rust_inner(state: &ArchitectureState) -> String {
81 let imports = emit_imports(state);
82
83 let record_items: Vec<TokenStream> = state
84 .records
85 .iter()
86 .flat_map(|rec| {
87 let mut items = vec![emit_value_struct(rec), emit_key_enum(rec)];
88 items.push(emit_schema_type_impl(rec));
89 let linkable = emit_linkable_impl(rec);
90 if !linkable.is_empty() {
91 items.push(linkable);
92 }
93 if let Some(obs) = emit_observable_impl(rec) {
94 items.push(obs);
95 }
96 if let Some(set) = emit_settable_impl(rec) {
97 items.push(set);
98 }
99 items
100 })
101 .collect();
102
103 let configure_fn = emit_configure_schema(state);
104
105 let file_tokens = quote! {
106 #imports
107 #(#record_items)*
108 #configure_fn
109 };
110
111 let syntax_tree = syn::parse2(file_tokens).expect("generated tokens should be valid Rust");
112 prettyplease::unparse(&syntax_tree)
113}
114
115pub fn generate_cargo_toml(state: &ArchitectureState) -> String {
120 let project = state
121 .project
122 .as_ref()
123 .expect("generate_cargo_toml requires [project] block in state.toml");
124 let crate_name = format!("{}-common", project.name);
125 let edition = project.edition.as_deref().unwrap_or("2024");
126
127 let has_non_custom_ser = state.records.iter().any(|r| {
128 r.serialization.as_ref().unwrap_or(&SerializationType::Json) != &SerializationType::Custom
129 });
130 let has_postcard = state
131 .records
132 .iter()
133 .any(|r| r.serialization.as_ref() == Some(&SerializationType::Postcard));
134 let has_observable = state.records.iter().any(|r| r.observable.is_some());
135
136 let mut data_contracts_features = Vec::new();
137 if has_non_custom_ser {
138 data_contracts_features.push("\"linkable\"");
139 }
140
141 let dc_features_str = if data_contracts_features.is_empty() {
142 String::new()
143 } else {
144 format!(", features = [{}]", data_contracts_features.join(", "))
145 };
146
147 let mut std_deps = vec!["\"aimdb-data-contracts/std\"".to_string()];
149 if has_non_custom_ser && !has_postcard {
150 std_deps.push("\"serde_json\"".to_string());
151 }
152 if has_observable {
153 std_deps.push("\"aimdb-data-contracts/observable\"".to_string());
154 }
155 let std_features = std_deps.join(", ");
156
157 let mut optional_deps = String::new();
158 if has_non_custom_ser && !has_postcard {
159 optional_deps.push_str("serde_json = { version = \"1.0\", optional = true }\n");
160 }
161 if has_postcard {
162 optional_deps.push_str(
163 "postcard = { version = \"1.0\", default-features = false, features = [\"alloc\"] }\n",
164 );
165 }
166
167 format!(
168 r#"# Regenerate with `aimdb generate --common-crate`
169[package]
170name = "{crate_name}"
171version = "0.1.0"
172edition = "{edition}"
173
174[features]
175default = ["std"]
176std = [{std_features}]
177alloc = []
178
179[dependencies]
180aimdb-core = {{ version = "0.5", default-features = false, features = ["derive", "alloc"] }}
181aimdb-data-contracts = {{ version = "0.5", default-features = false{dc_features_str} }}
182serde = {{ version = "1.0", default-features = false, features = ["derive", "alloc"] }}
183{optional_deps}"#
184 )
185}
186
187pub fn generate_lib_rs() -> String {
189 "\
190// Regenerate with `aimdb generate --common-crate`
191#![cfg_attr(not(feature = \"std\"), no_std)]
192extern crate alloc;
193
194mod schema;
195
196// Re-export all public types for downstream crates
197pub use schema::*;
198"
199 .to_string()
200}
201
202pub fn generate_main_rs(state: &ArchitectureState, binary_name: &str) -> Option<String> {
209 let bin = state.binaries.iter().find(|b| b.name == binary_name)?;
210 let project_name = state
211 .project
212 .as_ref()
213 .map(|p| p.name.as_str())
214 .unwrap_or("project");
215 let common_crate = format_ident!("{}", format!("{}_common", project_name.replace('-', "_")));
216
217 let tasks: Vec<&TaskDef> = bin
219 .tasks
220 .iter()
221 .filter_map(|tname| state.tasks.iter().find(|t| &t.name == tname))
222 .collect();
223
224 let task_use_idents: Vec<syn::Ident> = bin
225 .tasks
226 .iter()
227 .map(|name| format_ident!("{}", name))
228 .collect();
229
230 let connector_use_stmts: Vec<TokenStream> = bin
232 .external_connectors
233 .iter()
234 .filter_map(|c| match c.protocol.as_str() {
235 "mqtt" => Some(quote! { use aimdb_mqtt_connector::MqttConnector; }),
236 "knx" => Some(quote! { use aimdb_knx_connector::KnxConnector; }),
237 "ws" => Some(quote! { use aimdb_websocket_connector::WebSocketConnector; }),
238 _ => None,
239 })
240 .collect();
241
242 let connector_let_stmts: Vec<TokenStream> = bin
244 .external_connectors
245 .iter()
246 .map(|c| {
247 let var_ident = format_ident!("{}", c.env_var.to_lowercase());
248 let var_name = &c.env_var;
249 let default = &c.default;
250 let ctor: TokenStream = match c.protocol.as_str() {
251 "mqtt" => quote! { MqttConnector::new(&#var_ident) },
252 "knx" => quote! { KnxConnector::new(&#var_ident) },
253 "ws" => quote! {
254 WebSocketConnector::new()
255 .bind(#var_ident.parse::<std::net::SocketAddr>()
256 .expect("invalid WebSocket bind address"))
257 .path("/ws")
258 },
259 _ => {
260 let msg = format!("build connector for protocol '{}'", c.protocol);
261 quote! { todo!(#msg) }
262 }
263 };
264 let connector_ident = format_ident!("{}_connector", c.protocol);
265 quote! {
266 let #var_ident = std::env::var(#var_name)
267 .unwrap_or_else(|_| #default.to_string());
268 let #connector_ident = #ctor;
269 }
270 })
271 .collect();
272
273 let with_connector_calls: Vec<TokenStream> = bin
275 .external_connectors
276 .iter()
277 .map(|c| {
278 let connector_ident = format_ident!("{}_connector", c.protocol);
279 quote! { .with_connector(#connector_ident) }
280 })
281 .collect();
282
283 let task_registrations: Vec<TokenStream> = tasks
285 .iter()
286 .flat_map(|task| {
287 task.outputs.iter().flat_map(move |output| {
288 let variants: Vec<String> = if output.variants.is_empty() {
289 state
290 .records
291 .iter()
292 .find(|r| r.name == output.record)
293 .map(|r| r.key_variants.clone())
294 .unwrap_or_default()
295 } else {
296 output.variants.clone()
297 };
298
299 let value_type = format_ident!("{}Value", output.record);
300 let key_type = format_ident!("{}Key", output.record);
301 let task_fn = format_ident!("{}", task.name);
302
303 variants.into_iter().map(move |variant| {
304 let variant_ident = format_ident!("{}", to_pascal_case(&variant));
305 quote! {
306 builder.configure::<#value_type>(#key_type::#variant_ident, |reg| {
307 reg.source(#task_fn);
308 });
309 }
310 })
311 })
312 })
313 .collect();
314
315 let file_tokens = quote! {
317 use aimdb_core::{AimDbBuilder, DbResult};
318 use aimdb_tokio_adapter::TokioAdapter;
319 #(#connector_use_stmts)*
320 use std::sync::Arc;
321 use #common_crate::configure_schema;
322
323 mod tasks;
324 use tasks::{#(#task_use_idents),*};
325
326 #[tokio::main]
327 async fn main() -> DbResult<()> {
328 tracing_subscriber::fmt::init();
329
330 #(#connector_let_stmts)*
331
332 let runtime = Arc::new(TokioAdapter::new());
333
334 let mut builder = AimDbBuilder::new()
335 .runtime(runtime)
336 #(#with_connector_calls)*
337 ;
338
339 configure_schema(&mut builder);
340
341 #(#task_registrations)*
342
343 builder.run().await
344 }
345 };
346
347 let header = format!(
348 "// @generated — do not edit manually.\n\
349 // Source: .aimdb/state.toml\n\
350 // Regenerate: `aimdb generate --binary {binary_name}`\n\n"
351 );
352
353 let syntax_tree =
354 syn::parse2(file_tokens).expect("generate_main_rs: tokens should be valid Rust");
355 Some(format!("{header}{}", prettyplease::unparse(&syntax_tree)))
356}
357
358pub fn generate_tasks_rs(state: &ArchitectureState, binary_name: &str) -> Option<String> {
365 let bin = state.binaries.iter().find(|b| b.name == binary_name)?;
366 let project_name = state
367 .project
368 .as_ref()
369 .map(|p| p.name.as_str())
370 .unwrap_or("project");
371 let common_crate = format_ident!("{}", format!("{}_common", project_name.replace('-', "_")));
372
373 let tasks: Vec<&TaskDef> = bin
375 .tasks
376 .iter()
377 .filter_map(|tname| state.tasks.iter().find(|t| &t.name == tname))
378 .collect();
379
380 let task_fns: Vec<TokenStream> = tasks
381 .iter()
382 .map(|task| {
383 let fn_name = format_ident!("{}", task.name);
384
385 let mut params: Vec<TokenStream> = vec![quote! { ctx: RuntimeContext<TokioAdapter> }];
387 for input in &task.inputs {
388 let arg_name = format_ident!("{}", to_snake_case(&input.record));
389 let value_type = format_ident!("{}Value", input.record);
390 params.push(quote! { #arg_name: Consumer<#value_type, TokioAdapter> });
391 }
392 for output in &task.outputs {
393 let arg_name = format_ident!("{}", to_snake_case(&output.record));
394 let value_type = format_ident!("{}Value", output.record);
395 params.push(quote! { #arg_name: Producer<#value_type, TokioAdapter> });
396 }
397
398 let todo_msg = match &task.task_type {
399 TaskType::Agent => "LLM agent stub — implement reasoning loop".to_string(),
400 _ => format!("implement: {}", task.description),
401 };
402
403 let doc_attr = if task.description.is_empty() {
404 quote! {}
405 } else {
406 let desc = &task.description;
407 quote! { #[doc = #desc] }
408 };
409
410 quote! {
411 #doc_attr
412 pub async fn #fn_name(#(#params),*) -> DbResult<()> {
413 todo!(#todo_msg)
414 }
415 }
416 })
417 .collect();
418
419 let file_tokens = quote! {
420 use aimdb_core::{Consumer, DbResult, Producer, RuntimeContext};
421 use aimdb_tokio_adapter::TokioAdapter;
422 use #common_crate::*;
423
424 #(#task_fns)*
425 };
426
427 let header = format!(
428 "// Implement the task bodies; signatures must not change.\n\
429 // Regenerate with `aimdb generate --binary {binary_name} --tasks-scaffold`\n\
430 // (only writes this file if it does not already exist)\n\n"
431 );
432
433 let syntax_tree =
434 syn::parse2(file_tokens).expect("generate_tasks_rs: tokens should be valid Rust");
435 Some(format!("{header}{}", prettyplease::unparse(&syntax_tree)))
436}
437
438pub fn generate_binary_cargo_toml(state: &ArchitectureState, binary_name: &str) -> Option<String> {
443 let bin = state.binaries.iter().find(|b| b.name == binary_name)?;
444 let project_name = state
445 .project
446 .as_ref()
447 .map(|p| p.name.as_str())
448 .unwrap_or("project");
449 let common_crate_name = format!("{project_name}-common");
450 let common_crate_dep = common_crate_name.replace('-', "_");
451 let edition = state
452 .project
453 .as_ref()
454 .and_then(|p| p.edition.as_deref())
455 .unwrap_or("2024");
456
457 let has_mqtt = bin.external_connectors.iter().any(|c| c.protocol == "mqtt");
458 let has_knx = bin.external_connectors.iter().any(|c| c.protocol == "knx");
459 let has_ws = bin.external_connectors.iter().any(|c| c.protocol == "ws");
460
461 let mut optional_connector_deps = String::new();
462 if has_mqtt {
463 optional_connector_deps.push_str(
464 "aimdb-mqtt-connector = { version = \"0.5\", features = [\"tokio-runtime\"] }\n",
465 );
466 }
467 if has_knx {
468 optional_connector_deps.push_str(
469 "aimdb-knx-connector = { version = \"0.5\", features = [\"tokio-runtime\"] }\n",
470 );
471 }
472 if has_ws {
473 optional_connector_deps.push_str(
474 "aimdb-websocket-connector = { version = \"0.5\", features = [\"tokio-runtime\"] }\n",
475 );
476 }
477
478 let out = format!(
479 "# @generated — do not edit manually.\n\
480# Source: .aimdb/state.toml — regenerate with `aimdb generate --binary {binary_name}`\n\
481[package]\n\
482name = \"{binary_name}\"\n\
483version = \"0.1.0\"\n\
484edition = \"{edition}\"\n\
485\n\
486[[bin]]\n\
487name = \"{binary_name}\"\n\
488path = \"src/main.rs\"\n\
489\n\
490[dependencies]\n\
491{common_crate_dep} = {{ path = \"../{common_crate_name}\" }}\n\
492aimdb-core = {{ version = \"0.5\" }}\n\
493aimdb-tokio-adapter = {{ version = \"0.5\", features = [\"tokio-runtime\"] }}\n\
494{optional_connector_deps}\
495tokio = {{ version = \"1\", features = [\"full\"] }}\n\
496tracing = \"0.1\"\n\
497tracing-subscriber = {{ version = \"0.3\", features = [\"env-filter\"] }}\n"
498 );
499
500 Some(out)
501}
502
503fn emit_imports_types_only(state: &ArchitectureState) -> TokenStream {
505 let has_non_custom_ser = state.records.iter().any(|r| {
506 r.serialization.as_ref().unwrap_or(&SerializationType::Json) != &SerializationType::Custom
507 });
508 let has_observable = state.records.iter().any(|r| r.observable.is_some());
509 let has_settable = state
510 .records
511 .iter()
512 .any(|r| r.fields.iter().any(|f| f.settable));
513
514 let mut contract_traits: Vec<TokenStream> = vec![quote! { SchemaType }];
515 if has_non_custom_ser {
516 contract_traits.push(quote! { Linkable });
517 }
518 if has_observable {
519 contract_traits.push(quote! { Observable });
520 }
521 if has_settable {
522 contract_traits.push(quote! { Settable });
523 }
524
525 quote! {
526 use aimdb_core::RecordKey;
527 use aimdb_data_contracts::{#(#contract_traits),*};
528 use serde::{Deserialize, Serialize};
529 }
530}
531
532fn emit_imports(state: &ArchitectureState) -> TokenStream {
534 let has_non_custom_ser = state.records.iter().any(|r| {
535 r.serialization.as_ref().unwrap_or(&SerializationType::Json) != &SerializationType::Custom
536 });
537 let has_observable = state.records.iter().any(|r| r.observable.is_some());
538 let has_settable = state
539 .records
540 .iter()
541 .any(|r| r.fields.iter().any(|f| f.settable));
542
543 let mut contract_traits: Vec<TokenStream> = vec![quote! { SchemaType }];
545 if has_non_custom_ser {
546 contract_traits.push(quote! { Linkable });
547 }
548 if has_observable {
549 contract_traits.push(quote! { Observable });
550 }
551 if has_settable {
552 contract_traits.push(quote! { Settable });
553 }
554
555 quote! {
556 use aimdb_core::buffer::BufferCfg;
557 use aimdb_core::builder::AimDbBuilder;
558 use aimdb_core::RecordKey;
559 use aimdb_data_contracts::{#(#contract_traits),*};
560 use aimdb_executor::Spawn;
561 use serde::{Deserialize, Serialize};
562 }
563}
564
565fn emit_value_struct(rec: &RecordDef) -> TokenStream {
568 let struct_name = format_ident!("{}Value", rec.name);
569 let doc = format!("Value type for `{}`.", rec.name);
570
571 let fields: Vec<TokenStream> = if rec.fields.is_empty() {
572 vec![emit_todo_field(
573 "add fields — use `propose_record` to define them via the architecture agent",
574 )]
575 } else {
576 rec.fields
577 .iter()
578 .map(|f| {
579 let fname = format_ident!("{}", f.name);
580 let ftype: syn::Type = syn::parse_str(&f.field_type).unwrap_or_else(|_| {
581 panic!("invalid type `{}` for field `{}`", f.field_type, f.name)
582 });
583 if f.description.is_empty() {
584 quote! { pub #fname: #ftype, }
585 } else {
586 let desc = &f.description;
587 quote! {
588 #[doc = #desc]
589 pub #fname: #ftype,
590 }
591 }
592 })
593 .collect()
594 };
595
596 quote! {
597 #[doc = #doc]
598 #[derive(Debug, Clone, Serialize, Deserialize)]
599 pub struct #struct_name {
600 #(#fields)*
601 }
602 }
603}
604
605fn emit_todo_field(msg: &str) -> TokenStream {
607 let doc = format!("TODO: {msg}");
608 quote! {
609 #[doc = #doc]
610 pub _placeholder: (),
611 }
612}
613
614fn emit_key_enum(rec: &RecordDef) -> TokenStream {
617 let enum_name = format_ident!("{}Key", rec.name);
618 let connector = rec.connectors.first();
622
623 let key_prefix_attr = if !rec.key_prefix.is_empty() {
624 let prefix = &rec.key_prefix;
625 quote! { #[key_prefix = #prefix] }
626 } else {
627 quote! {}
628 };
629
630 let variants: Vec<TokenStream> = if rec.key_variants.is_empty() {
631 let doc = "TODO: add key variants — use the architecture agent to resolve them";
632 vec![quote! {
633 #[doc = #doc]
634 _Placeholder,
635 }]
636 } else {
637 rec.key_variants
638 .iter()
639 .map(|variant_str| {
640 let variant_name = format_ident!("{}", to_pascal_case(variant_str));
641 let link_attr = connector.map(|conn| {
642 let addr = conn.url.replace("{variant}", variant_str);
643 quote! { #[link_address = #addr] }
644 });
645 quote! {
646 #[key = #variant_str]
647 #link_attr
648 #variant_name,
649 }
650 })
651 .collect()
652 };
653
654 let address_fns = emit_connector_address_fns(rec);
655
656 quote! {
657 #[derive(Debug, RecordKey, Clone, Copy, PartialEq, Eq)]
658 #key_prefix_attr
659 pub enum #enum_name {
660 #(#variants)*
661 }
662
663 #address_fns
664 }
665}
666
667fn emit_connector_address_fns(rec: &RecordDef) -> TokenStream {
674 if rec.connectors.len() <= 1 || rec.key_variants.is_empty() {
675 return quote! {};
676 }
677
678 let key_type = format_ident!("{}Key", rec.name);
679 let record_snake = to_snake_case(&rec.name);
680
681 let fns: Vec<TokenStream> = rec
682 .connectors
683 .iter()
684 .skip(1) .map(|conn| {
686 let fn_name = format_ident!("{}_{}_address", record_snake, conn.protocol);
687 let doc = format!(
688 "Link address for `{}` — {} connector (`{}`).",
689 rec.name,
690 conn.protocol,
691 conn.direction_label(),
692 );
693
694 let arms: Vec<TokenStream> = rec
695 .key_variants
696 .iter()
697 .map(|variant_str| {
698 let variant_ident = format_ident!("{}", to_pascal_case(variant_str));
699 let addr = conn.url.replace("{variant}", variant_str);
700 quote! { #key_type::#variant_ident => Some(#addr), }
701 })
702 .collect();
703
704 quote! {
705 #[doc = #doc]
706 pub fn #fn_name(key: &#key_type) -> Option<&'static str> {
707 match key {
708 #(#arms)*
709 }
710 }
711 }
712 })
713 .collect();
714
715 quote! { #(#fns)* }
716}
717
718fn emit_configure_schema(state: &ArchitectureState) -> TokenStream {
721 let record_blocks: Vec<TokenStream> = state
722 .records
723 .iter()
724 .map(emit_record_configure_block)
725 .collect();
726
727 quote! {
728 pub fn configure_schema<R: Spawn + 'static>(builder: &mut AimDbBuilder<R>) {
735 #(#record_blocks)*
736 }
737 }
738}
739
740fn emit_record_configure_block(rec: &RecordDef) -> TokenStream {
741 if rec.key_variants.is_empty() {
742 let msg = format!("TODO: {}: no key variants defined yet", rec.name);
743 return quote! {
744 let _ = (#msg,);
746 };
747 }
748
749 let value_type = format_ident!("{}Value", rec.name);
750 let key_type = format_ident!("{}Key", rec.name);
751 let buffer_tokens = rec.buffer.to_tokens(rec.capacity);
752
753 let variant_idents: Vec<syn::Ident> = rec
754 .key_variants
755 .iter()
756 .map(|v| format_ident!("{}", to_pascal_case(v)))
757 .collect();
758
759 let is_custom = rec
760 .serialization
761 .as_ref()
762 .map(|s| s == &SerializationType::Custom)
763 .unwrap_or(false);
764
765 if rec.connectors.is_empty() {
766 return quote! {
768 for key in [
769 #(#key_type::#variant_idents,)*
770 ] {
771 builder.configure::<#value_type>(key, |reg| {
772 reg.buffer(#buffer_tokens);
773 });
774 }
775 };
776 }
777
778 let record_snake = to_snake_case(&rec.name);
782
783 let addr_extractions: Vec<TokenStream> = rec
784 .connectors
785 .iter()
786 .enumerate()
787 .map(|(i, conn)| {
788 let addr_var = format_ident!("addr_{}", i);
789 if i == 0 {
790 quote! {
791 let #addr_var = key.link_address().map(|s| s.to_string());
792 }
793 } else {
794 let resolver_fn = format_ident!("{}_{}_address", record_snake, conn.protocol);
795 quote! {
796 let #addr_var = #resolver_fn(&key).map(|s| s.to_string());
797 }
798 }
799 })
800 .collect();
801
802 let linked_chain =
809 emit_connector_chain(&rec.connectors, &value_type, &buffer_tokens, is_custom);
810 let addr_conditions: Vec<TokenStream> = (0..rec.connectors.len())
811 .map(|i| {
812 let addr_var = format_ident!("addr_{}", i);
813 quote! { #addr_var.as_deref() }
814 })
815 .collect();
816
817 let body = if rec.connectors.len() == 1 {
820 let cond = &addr_conditions[0];
821 quote! {
822 if let Some(addr_0) = #cond {
823 #linked_chain
824 } else {
825 reg.buffer(#buffer_tokens);
826 }
827 }
828 } else {
829 let some_bindings: Vec<TokenStream> = (0..rec.connectors.len())
833 .map(|i| {
834 let binding = format_ident!("addr_{}", i);
835 quote! { Some(#binding) }
836 })
837 .collect();
838 quote! {
839 match (#(#addr_conditions),*) {
840 (#(#some_bindings),*) => {
841 #linked_chain
842 }
843 _ => {
844 reg.buffer(#buffer_tokens);
845 }
846 }
847 }
848 };
849
850 quote! {
851 for key in [
852 #(#key_type::#variant_idents,)*
853 ] {
854 #(#addr_extractions)*
855 builder.configure::<#value_type>(key, |reg| {
856 #body
857 });
858 }
859 }
860}
861
862fn emit_connector_chain(
868 connectors: &[ConnectorDef],
869 value_type: &syn::Ident,
870 buffer_tokens: &TokenStream,
871 is_custom: bool,
872) -> TokenStream {
873 let mut chain = quote! { reg.buffer(#buffer_tokens) };
875
876 for (i, conn) in connectors.iter().enumerate() {
877 let addr_var = format_ident!("addr_{}", i);
878
879 if is_custom {
880 let todo_comment = match conn.direction {
881 ConnectorDirection::Outbound => {
882 "TODO: chain .link_to(...).with_serializer(...) — serialization = \"custom\""
883 }
884 ConnectorDirection::Inbound => {
885 "TODO: chain .link_from(...).with_deserializer(...) — serialization = \"custom\""
886 }
887 };
888 chain = quote! {
891 #chain;
892 let _ = (#todo_comment, #addr_var)
893 };
894 } else {
895 match conn.direction {
896 ConnectorDirection::Inbound => {
897 chain = quote! {
898 #chain
899 .link_from(#addr_var)
900 .with_deserializer(#value_type::from_bytes)
901 .finish()
902 };
903 }
904 ConnectorDirection::Outbound => {
905 chain = quote! {
906 #chain
907 .link_to(#addr_var)
908 .with_serializer_raw(|v: &#value_type| {
909 v.to_bytes()
910 .map_err(|_| aimdb_core::connector::SerializeError::InvalidData)
911 })
912 .finish()
913 };
914 }
915 }
916 }
917 }
918
919 quote! { #chain; }
921}
922
923fn emit_schema_type_impl(rec: &RecordDef) -> TokenStream {
926 let struct_name = format_ident!("{}Value", rec.name);
927 let schema_name = to_snake_case(&rec.name);
928 let version = proc_macro2::Literal::u32_unsuffixed(rec.schema_version.unwrap_or(1));
929
930 quote! {
931 impl SchemaType for #struct_name {
932 const NAME: &'static str = #schema_name;
933 const VERSION: u32 = #version;
934 }
935 }
936}
937
938fn emit_linkable_impl(rec: &RecordDef) -> TokenStream {
939 let ser = rec
940 .serialization
941 .as_ref()
942 .unwrap_or(&SerializationType::Json);
943
944 match ser {
945 SerializationType::Custom => quote! {},
946 SerializationType::Json => emit_linkable_json(rec),
947 SerializationType::Postcard => emit_linkable_postcard(rec),
948 }
949}
950
951fn emit_linkable_json(rec: &RecordDef) -> TokenStream {
952 let struct_name = format_ident!("{}Value", rec.name);
953 quote! {
954 impl Linkable for #struct_name {
955 fn to_bytes(&self) -> Result<alloc::vec::Vec<u8>, alloc::string::String> {
956 #[cfg(feature = "std")]
957 {
958 serde_json::to_vec(self)
959 .map_err(|e| alloc::format!("serialize {}: {e}", Self::NAME))
960 }
961 #[cfg(not(feature = "std"))]
962 {
963 Err(alloc::string::String::from(
964 "no_std serialization not available — enable the std feature or use postcard",
965 ))
966 }
967 }
968
969 fn from_bytes(data: &[u8]) -> Result<Self, alloc::string::String> {
970 #[cfg(feature = "std")]
971 {
972 serde_json::from_slice(data)
973 .map_err(|e| alloc::format!("deserialize {}: {e}", Self::NAME))
974 }
975 #[cfg(not(feature = "std"))]
976 {
977 let _ = data;
978 Err(alloc::string::String::from(
979 "no_std deserialization not available — enable the std feature or use postcard",
980 ))
981 }
982 }
983 }
984 }
985}
986
987fn emit_linkable_postcard(rec: &RecordDef) -> TokenStream {
988 let struct_name = format_ident!("{}Value", rec.name);
989 quote! {
990 impl Linkable for #struct_name {
991 fn to_bytes(&self) -> Result<alloc::vec::Vec<u8>, alloc::string::String> {
992 postcard::to_allocvec(self)
993 .map_err(|e| alloc::format!("serialize {}: {e}", Self::NAME))
994 }
995
996 fn from_bytes(data: &[u8]) -> Result<Self, alloc::string::String> {
997 postcard::from_bytes(data)
998 .map_err(|e| alloc::format!("deserialize {}: {e}", Self::NAME))
999 }
1000 }
1001 }
1002}
1003
1004fn emit_observable_impl(rec: &RecordDef) -> Option<TokenStream> {
1005 let obs = rec.observable.as_ref()?;
1006 let struct_name = format_ident!("{}Value", rec.name);
1007
1008 let signal_field = rec.fields.iter().find(|f| f.name == obs.signal_field)?;
1010 let signal_type: syn::Type = syn::parse_str(&signal_field.field_type).ok()?;
1011 let signal_ident = format_ident!("{}", obs.signal_field);
1012
1013 let icon = &obs.icon;
1014 let unit = &obs.unit;
1015
1016 let timestamp_names = ["timestamp", "computed_at", "fetched_at"];
1018 let timestamp_field = rec
1019 .fields
1020 .iter()
1021 .find(|f| f.field_type == "u64" && timestamp_names.contains(&f.name.as_str()));
1022
1023 let format_log_body = if let Some(ts) = timestamp_field {
1024 let ts_ident = format_ident!("{}", ts.name);
1025 quote! {
1026 alloc::format!(
1027 "{} [{}] {}: {:.1}{} at {}",
1028 Self::ICON,
1029 node_id,
1030 Self::NAME,
1031 self.signal(),
1032 Self::UNIT,
1033 self.#ts_ident,
1034 )
1035 }
1036 } else {
1037 quote! {
1038 alloc::format!(
1039 "{} [{}] {}: {:.1}{}",
1040 Self::ICON,
1041 node_id,
1042 Self::NAME,
1043 self.signal(),
1044 Self::UNIT,
1045 )
1046 }
1047 };
1048
1049 Some(quote! {
1050 impl Observable for #struct_name {
1051 type Signal = #signal_type;
1052 const ICON: &'static str = #icon;
1053 const UNIT: &'static str = #unit;
1054
1055 fn signal(&self) -> #signal_type {
1056 self.#signal_ident
1057 }
1058
1059 fn format_log(&self, node_id: &str) -> alloc::string::String {
1060 #format_log_body
1061 }
1062 }
1063 })
1064}
1065
1066fn emit_settable_impl(rec: &RecordDef) -> Option<TokenStream> {
1067 let settable_fields: Vec<_> = rec.fields.iter().filter(|f| f.settable).collect();
1068 if settable_fields.is_empty() {
1069 return None;
1070 }
1071
1072 let struct_name = format_ident!("{}Value", rec.name);
1073
1074 let settable_types: Vec<syn::Type> = settable_fields
1076 .iter()
1077 .map(|f| syn::parse_str(&f.field_type).unwrap())
1078 .collect();
1079
1080 let value_type: TokenStream = if settable_types.len() == 1 {
1081 let t = &settable_types[0];
1082 quote! { #t }
1083 } else {
1084 quote! { (#(#settable_types),*) }
1085 };
1086
1087 let timestamp_names = ["timestamp", "computed_at", "fetched_at"];
1089 let timestamp_field = rec
1090 .fields
1091 .iter()
1092 .find(|f| f.field_type == "u64" && timestamp_names.contains(&f.name.as_str()));
1093
1094 let mut settable_idx = 0usize;
1096 let field_assignments: Vec<TokenStream> = rec
1097 .fields
1098 .iter()
1099 .map(|f| {
1100 let fname = format_ident!("{}", f.name);
1101 if timestamp_field.map(|tf| tf.name == f.name).unwrap_or(false) && !f.settable {
1102 quote! { #fname: timestamp, }
1104 } else if f.settable {
1105 let assignment = if settable_fields.len() == 1 {
1106 quote! { value }
1107 } else {
1108 let idx = syn::Index::from(settable_idx);
1109 quote! { value.#idx }
1110 };
1111 settable_idx += 1;
1112 quote! { #fname: #assignment, }
1113 } else {
1114 quote! { #fname: Default::default(), }
1116 }
1117 })
1118 .collect();
1119
1120 Some(quote! {
1121 impl Settable for #struct_name {
1122 type Value = #value_type;
1123
1124 fn set(value: Self::Value, timestamp: u64) -> Self {
1125 Self {
1126 #(#field_assignments)*
1127 }
1128 }
1129 }
1130 })
1131}
1132
1133pub fn to_snake_case(s: &str) -> String {
1145 let mut result = String::with_capacity(s.len() + 4);
1146 for (i, c) in s.chars().enumerate() {
1147 if c.is_uppercase() && i > 0 {
1148 result.push('_');
1149 }
1150 for lc in c.to_lowercase() {
1151 result.push(lc);
1152 }
1153 }
1154 result
1155}
1156
1157pub fn to_pascal_case(s: &str) -> String {
1168 s.split(['-', '_'])
1169 .map(|part| {
1170 let mut chars = part.chars();
1171 match chars.next() {
1172 None => String::new(),
1173 Some(first) => {
1174 let upper: String = first.to_uppercase().collect();
1175 upper + chars.as_str()
1176 }
1177 }
1178 })
1179 .collect()
1180}
1181
1182fn hub_task_names(state: &ArchitectureState) -> Vec<String> {
1196 use std::collections::HashSet;
1198 let external_producers: HashSet<&str> = state
1199 .records
1200 .iter()
1201 .filter(|r| {
1202 r.connectors
1203 .iter()
1204 .any(|c| matches!(c.direction, ConnectorDirection::Inbound))
1205 })
1206 .flat_map(|r| r.producers.iter().map(|p| p.as_str()))
1207 .collect();
1208
1209 let mut seen = HashSet::new();
1211 let mut tasks: Vec<String> = state
1212 .records
1213 .iter()
1214 .filter(|r| {
1215 !r.connectors
1216 .iter()
1217 .any(|c| matches!(c.direction, ConnectorDirection::Inbound))
1218 })
1219 .flat_map(|r| r.producers.iter().cloned())
1220 .filter(|p| !external_producers.contains(p.as_str()))
1221 .filter(|p| seen.insert(p.clone()))
1222 .collect();
1223
1224 for rec in &state.records {
1226 for consumer in &rec.consumers {
1227 let consumer = consumer.clone();
1228 if !external_producers.contains(consumer.as_str()) && seen.insert(consumer.clone()) {
1229 tasks.push(consumer);
1230 }
1231 }
1232 }
1233
1234 tasks
1235}
1236
1237pub fn generate_hub_schema_rs(state: &ArchitectureState) -> String {
1242 let project = state
1243 .project
1244 .as_ref()
1245 .expect("generate_hub_schema_rs requires [project] block in state.toml");
1246 let common_crate = format_ident!("{}", project.name.replace('-', "_") + "_common");
1247
1248 let configure_fn = emit_configure_schema(state);
1249
1250 let file_tokens = quote! {
1251 use aimdb_core::buffer::BufferCfg;
1252 use aimdb_core::builder::AimDbBuilder;
1253 use aimdb_executor::Spawn;
1254 use #common_crate::*;
1255
1256 #configure_fn
1257 };
1258
1259 let header = "// @generated — do not edit manually.\n\
1260// Source: .aimdb/state.toml — regenerate with `aimdb generate --hub`.\n\n";
1261
1262 let syntax_tree = syn::parse2(file_tokens).expect("generated tokens should be valid Rust");
1263 format!("{header}{}", prettyplease::unparse(&syntax_tree))
1264}
1265
1266pub fn generate_hub_cargo_toml(state: &ArchitectureState) -> String {
1268 let project = state
1269 .project
1270 .as_ref()
1271 .expect("generate_hub_cargo_toml requires [project] block in state.toml");
1272 let hub_crate = format!("{}-hub", project.name);
1273 let common_crate_name = format!("{}-common", project.name);
1274 let edition = project.edition.as_deref().unwrap_or("2024");
1275
1276 let has_mqtt = state
1277 .records
1278 .iter()
1279 .any(|r| r.connectors.iter().any(|c| c.protocol == "mqtt"));
1280 let has_knx = state
1281 .records
1282 .iter()
1283 .any(|r| r.connectors.iter().any(|c| c.protocol == "knx"));
1284 let has_ws = state
1285 .records
1286 .iter()
1287 .any(|r| r.connectors.iter().any(|c| c.protocol == "ws"));
1288
1289 let mut connector_deps = String::new();
1290 if has_mqtt {
1291 connector_deps.push_str(
1292 "aimdb-mqtt-connector = { version = \"0.5\", features = [\"tokio-runtime\"] }\n",
1293 );
1294 }
1295 if has_knx {
1296 connector_deps.push_str(
1297 "aimdb-knx-connector = { version = \"0.5\", features = [\"tokio-runtime\"] }\n",
1298 );
1299 }
1300 if has_ws {
1301 connector_deps.push_str(
1302 "aimdb-websocket-connector = { version = \"0.5\", features = [\"tokio-runtime\"] }\n",
1303 );
1304 }
1305
1306 format!(
1307 "# @generated — do not edit manually.\n\
1308# Source: .aimdb/state.toml — regenerate with `aimdb generate --hub`\n\
1309[package]\n\
1310name = \"{hub_crate}\"\n\
1311version = \"0.1.0\"\n\
1312edition = \"{edition}\"\n\
1313description = \"Hub binary for {project_name}\"\n\
1314publish = false\n\
1315\n\
1316[[bin]]\n\
1317name = \"{hub_crate}\"\n\
1318path = \"src/main.rs\"\n\
1319\n\
1320[dependencies]\n\
1321{common_crate_name} = {{ path = \"../{common_crate_name}\" }}\n\
1322aimdb-core = {{ version = \"0.5\" }}\n\
1323aimdb-data-contracts = {{ version = \"0.5\", features = [\"linkable\"] }}\n\
1324aimdb-tokio-adapter = {{ version = \"0.5\", features = [\"tokio-runtime\"] }}\n\
1325{connector_deps}\
1326tokio = {{ version = \"1\", features = [\"full\"] }}\n\
1327tracing = \"0.1\"\n\
1328tracing-subscriber = {{ version = \"0.3\", features = [\"env-filter\"] }}\n",
1329 project_name = project.name
1330 )
1331}
1332
1333pub fn generate_hub_main_rs(state: &ArchitectureState) -> String {
1338 let project = state
1339 .project
1340 .as_ref()
1341 .expect("generate_hub_main_rs requires [project] block in state.toml");
1342 let common_crate = format_ident!("{}", project.name.replace('-', "_") + "_common");
1343
1344 let has_mqtt = state
1346 .records
1347 .iter()
1348 .any(|r| r.connectors.iter().any(|c| c.protocol == "mqtt"));
1349 let has_knx = state
1350 .records
1351 .iter()
1352 .any(|r| r.connectors.iter().any(|c| c.protocol == "knx"));
1353 let has_ws = state
1354 .records
1355 .iter()
1356 .any(|r| r.connectors.iter().any(|c| c.protocol == "ws"));
1357
1358 let connector_use_stmts: Vec<TokenStream> = {
1360 let mut v = vec![];
1361 if has_mqtt {
1362 v.push(quote! { use aimdb_mqtt_connector::MqttConnector; });
1363 }
1364 if has_knx {
1365 v.push(quote! { use aimdb_knx_connector::KnxConnector; });
1366 }
1367 if has_ws {
1368 v.push(quote! { use aimdb_websocket_connector::WebSocketConnector; });
1369 }
1370 v
1371 };
1372
1373 let connector_let_stmts: TokenStream = {
1375 let mut ts = TokenStream::new();
1376 if has_mqtt {
1377 ts.extend(quote! {
1378 let mqtt_broker =
1379 std::env::var("MQTT_BROKER").unwrap_or_else(|_| "localhost".to_string());
1380 let mqtt_url = format!("mqtt://{}", mqtt_broker);
1381 });
1382 }
1383 if has_knx {
1384 ts.extend(quote! {
1385 let knx_gateway = std::env::var("KNX_GATEWAY")
1386 .unwrap_or_else(|_| "224.0.23.12:3671".to_string());
1387 });
1388 }
1389 if has_ws {
1390 ts.extend(quote! {
1391 let ws_bind: std::net::SocketAddr = std::env::var("WS_BIND")
1392 .unwrap_or_else(|_| "0.0.0.0:8080".to_string())
1393 .parse()
1394 .expect("invalid WS_BIND address");
1395 });
1396 }
1397 ts
1398 };
1399
1400 let with_connector_calls: Vec<TokenStream> = {
1402 let mut v = vec![];
1403 if has_mqtt {
1404 v.push(quote! { .with_connector(MqttConnector::new(&mqtt_url)) });
1405 }
1406 if has_knx {
1407 v.push(quote! { .with_connector(KnxConnector::new(&knx_gateway)) });
1408 }
1409 if has_ws {
1410 v.push(quote! { .with_connector(WebSocketConnector::new().bind(ws_bind).path("/ws")) });
1411 }
1412 v
1413 };
1414
1415 let record_blocks: Vec<TokenStream> = state
1417 .records
1418 .iter()
1419 .map(|r| emit_hub_record_configure_block(r, state))
1420 .collect();
1421
1422 let log_filter = format!(
1424 "{}_hub=info,aimdb_core=info",
1425 project.name.replace('-', "_")
1426 );
1427 let startup_msg = format!("Starting {} hub", project.name);
1428
1429 let file_tokens = quote! {
1431 use aimdb_core::{buffer::BufferCfg, AimDbBuilder, DbResult, RecordKey};
1432 use aimdb_data_contracts::Linkable;
1433 use aimdb_tokio_adapter::{TokioAdapter, TokioRecordRegistrarExt};
1434 #(#connector_use_stmts)*
1435 use std::sync::Arc;
1436 use #common_crate::*;
1437
1438 mod tasks;
1439 use tasks::*;
1440
1441 #[tokio::main]
1442 async fn main() -> DbResult<()> {
1443 tracing_subscriber::fmt()
1444 .with_env_filter(
1445 tracing_subscriber::EnvFilter::try_from_default_env()
1446 .unwrap_or_else(|_| #log_filter.into()),
1447 )
1448 .init();
1449
1450 tracing::info!(#startup_msg);
1451
1452 #connector_let_stmts
1453
1454 let runtime = Arc::new(TokioAdapter::new()?);
1455
1456 let mut builder = AimDbBuilder::new()
1457 .runtime(runtime)
1458 #(#with_connector_calls)*
1459 ;
1460
1461 #(#record_blocks)*
1462
1463 builder.run().await
1464 }
1465 };
1466
1467 let header = "// @generated — do not edit manually.\n\
1468// Source: .aimdb/state.toml\n\
1469// Regenerate: `aimdb generate --hub`\n\n";
1470
1471 let syntax_tree =
1472 syn::parse2(file_tokens).expect("generate_hub_main_rs: tokens should be valid Rust");
1473 format!("{header}{}", prettyplease::unparse(&syntax_tree))
1474}
1475
1476fn emit_hub_record_configure_block(rec: &RecordDef, state: &ArchitectureState) -> TokenStream {
1483 if rec.key_variants.is_empty() {
1484 let msg = format!("TODO: {}: no key variants defined yet", rec.name);
1485 return quote! { let _ = (#msg,); };
1486 }
1487
1488 let producing_task = state
1490 .tasks
1491 .iter()
1492 .find(|t| t.outputs.iter().any(|o| o.record == rec.name));
1493
1494 match producing_task {
1495 Some(task) => emit_transform_configure_block(rec, task),
1496 None => emit_record_configure_block(rec),
1497 }
1498}
1499
1500fn emit_transform_configure_block(rec: &RecordDef, task: &TaskDef) -> TokenStream {
1506 let value_type = format_ident!("{}Value", rec.name);
1507 let key_type = format_ident!("{}Key", rec.name);
1508 let buffer_tokens = rec.buffer.to_tokens(rec.capacity);
1509
1510 let has_outbound = rec
1512 .connectors
1513 .iter()
1514 .any(|c| matches!(c.direction, ConnectorDirection::Outbound));
1515 let outbound_chain = if has_outbound {
1516 quote! {
1517 .link_to(addr)
1518 .with_serializer_raw(|v: &#value_type| {
1519 v.to_bytes()
1520 .map_err(|_| aimdb_core::connector::SerializeError::InvalidData)
1521 })
1522 .finish()
1523 }
1524 } else {
1525 quote! {}
1526 };
1527
1528 let variant_idents: Vec<syn::Ident> = rec
1529 .key_variants
1530 .iter()
1531 .map(|v| format_ident!("{}", to_pascal_case(v)))
1532 .collect();
1533
1534 let per_variant: Vec<TokenStream> = variant_idents
1535 .iter()
1536 .map(|variant_ident| {
1537 let transform_call = build_transform_call(task, variant_ident);
1538
1539 if has_outbound {
1540 let outbound = outbound_chain.clone();
1541 quote! {
1542 {
1543 let link_addr = #key_type::#variant_ident
1544 .link_address()
1545 .map(|s| s.to_string());
1546 builder.configure::<#value_type>(#key_type::#variant_ident, |reg| {
1547 if let Some(addr) = link_addr.as_deref() {
1548 reg.buffer(#buffer_tokens)
1549 #transform_call
1550 #outbound;
1551 } else {
1552 reg.buffer(#buffer_tokens)
1553 #transform_call;
1554 }
1555 });
1556 }
1557 }
1558 } else {
1559 quote! {
1560 builder.configure::<#value_type>(#key_type::#variant_ident, |reg| {
1561 reg.buffer(#buffer_tokens)
1562 #transform_call;
1563 });
1564 }
1565 }
1566 })
1567 .collect();
1568
1569 quote! { #(#per_variant)* }
1570}
1571
1572fn build_transform_call(task: &TaskDef, variant_ident: &syn::Ident) -> TokenStream {
1577 if task.inputs.len() != 1 {
1578 let handler_ident = format_ident!("{}_handler", task.name);
1580 let input_calls: Vec<TokenStream> = task
1581 .inputs
1582 .iter()
1583 .map(|inp| {
1584 let in_val = format_ident!("{}Value", inp.record);
1585 let in_key = format_ident!("{}Key", inp.record);
1586 quote! { .input::<#in_val>(#in_key::#variant_ident) }
1587 })
1588 .collect();
1589 quote! {
1590 .transform_join(|j| {
1591 j #(#input_calls)*
1592 .on_triggers(#handler_ident)
1593 })
1594 }
1595 } else {
1596 let handler_ident = format_ident!("{}_transform", task.name);
1598 let inp = &task.inputs[0];
1599 let in_val = format_ident!("{}Value", inp.record);
1600 let in_key = format_ident!("{}Key", inp.record);
1601 quote! {
1602 .transform::<#in_val, _>(#in_key::#variant_ident, |b| b.map(#handler_ident))
1603 }
1604 }
1605}
1606
1607pub fn generate_hub_tasks_rs(state: &ArchitectureState) -> String {
1619 let project = state
1620 .project
1621 .as_ref()
1622 .expect("generate_hub_tasks_rs requires [project] block in state.toml");
1623 let common_crate = format!("{}_common", project.name.replace('-', "_"));
1624
1625 let mut fns = String::new();
1626 let mut handled: std::collections::HashSet<String> = std::collections::HashSet::new();
1627
1628 for task in &state.tasks {
1629 handled.insert(task.name.clone());
1630 let n_in = task.inputs.len();
1631 let n_out = task.outputs.len();
1632
1633 let out_t = task
1634 .outputs
1635 .first()
1636 .map(|o| format!("{}Value", o.record))
1637 .unwrap_or_else(|| "()".to_string());
1638 let in_t = task
1639 .inputs
1640 .first()
1641 .map(|i| format!("{}Value", i.record))
1642 .unwrap_or_else(|| "()".to_string());
1643
1644 if !task.description.is_empty() {
1645 fns.push_str(&format!("/// {}\n", task.description));
1646 }
1647
1648 if n_in > 1 && n_out >= 1 {
1649 let handler = format!("{}_handler", task.name);
1651 let inputs_doc = task
1652 .inputs
1653 .iter()
1654 .enumerate()
1655 .map(|(i, inp)| format!(" index {i} = {}", inp.record))
1656 .collect::<Vec<_>>()
1657 .join(", ");
1658 fns.push_str(&format!(
1659 "/// Join handler — match `trigger.index()` to identify which input fired:\n\
1660/// {inputs_doc}\n\
1661pub async fn {handler}(\n\
1662 mut _rx: aimdb_core::transform::JoinEventRx,\n\
1663 _producer: aimdb_core::Producer<{out_t}, TokioAdapter>,\n\
1664) {{\n\
1665 while let Ok(_trigger) = _rx.recv().await {{\n\
1666 todo!(\"implement {handler}\")\n\
1667 }}\n\
1668}}\n\n"
1669 ));
1670 } else if n_in == 1 && n_out >= 1 {
1671 let handler = format!("{}_transform", task.name);
1673 let input_rec = &task.inputs[0].record;
1674 let output_rec = task
1675 .outputs
1676 .first()
1677 .map(|o| o.record.as_str())
1678 .unwrap_or("?");
1679 fns.push_str(&format!(
1680 "/// Transform: {input_rec} → {output_rec}\n\
1681/// Return `Some(value)` to emit, `None` to skip this input.\n\
1682pub fn {handler}(input: &{in_t}) -> Option<{out_t}> {{\n\
1683 let _ = input;\n\
1684 todo!(\"implement {handler}\")\n\
1685}}\n\n"
1686 ));
1687 } else if n_in == 0 && n_out >= 1 {
1688 fns.push_str(&format!(
1690 "pub async fn {}(\n\
1691 _ctx: aimdb_core::RuntimeContext<TokioAdapter>,\n\
1692 _producer: aimdb_core::Producer<{out_t}, TokioAdapter>,\n\
1693) {{\n\
1694 todo!(\"implement {}\")\n\
1695}}\n\n",
1696 task.name, task.name
1697 ));
1698 } else if n_in >= 1 && n_out == 0 {
1699 fns.push_str(&format!(
1701 "pub async fn {}(\n\
1702 _ctx: aimdb_core::RuntimeContext<TokioAdapter>,\n\
1703 _consumer: aimdb_core::Consumer<{in_t}, TokioAdapter>,\n\
1704) {{\n\
1705 todo!(\"implement {}\")\n\
1706}}\n\n",
1707 task.name, task.name
1708 ));
1709 }
1710 }
1711
1712 for task_name in hub_task_names(state) {
1714 if handled.contains(&task_name) {
1715 continue;
1716 }
1717 fns.push_str(&format!(
1718 "/// Hub task: add a `[[tasks]]` entry in state.toml for a typed stub.\n\
1719pub async fn {task_name}() {{\n\
1720 todo!(\"implement {task_name}\")\n\
1721}}\n\n"
1722 ));
1723 }
1724
1725 format!(
1726 "// Implement task bodies; signatures are derived from state.toml [[tasks]].\n\
1727// This file is scaffolded once — it will not be overwritten on subsequent runs.\n\
1728// Regenerate signatures: delete this file, then run `aimdb generate --hub`.\n\
1729\n\
1730use aimdb_tokio_adapter::TokioAdapter;\n\
1731use {common_crate}::*;\n\
1732\n\
1733{fns}"
1734 )
1735}
1736
1737#[cfg(test)]
1740mod tests {
1741 use super::*;
1742 use crate::state::ArchitectureState;
1743
1744 const SAMPLE_TOML: &str = r#"
1745[meta]
1746aimdb_version = "0.5.0"
1747created_at = "2026-02-22T14:00:00Z"
1748last_modified = "2026-02-22T14:33:00Z"
1749
1750[[records]]
1751name = "TemperatureReading"
1752buffer = "SpmcRing"
1753capacity = 256
1754key_prefix = "sensors.temp."
1755key_variants = ["indoor", "outdoor", "garage"]
1756producers = ["sensor_task"]
1757consumers = ["dashboard", "anomaly_detector"]
1758
1759[[records.fields]]
1760name = "celsius"
1761type = "f64"
1762description = "Temperature in degrees Celsius"
1763
1764[[records.fields]]
1765name = "humidity_percent"
1766type = "f64"
1767description = "Relative humidity 0-100"
1768
1769[[records.fields]]
1770name = "timestamp"
1771type = "u64"
1772description = "Unix timestamp in milliseconds"
1773
1774[[records.connectors]]
1775protocol = "mqtt"
1776direction = "outbound"
1777url = "mqtt://sensors/temp/{variant}"
1778
1779[[records]]
1780name = "OtaCommand"
1781buffer = "Mailbox"
1782key_prefix = "device.ota."
1783key_variants = ["gateway-01", "sensor-hub-01"]
1784producers = ["cloud_ota"]
1785consumers = ["updater"]
1786
1787[[records.fields]]
1788name = "action"
1789type = "String"
1790description = "Command: update, rollback, reboot"
1791
1792[[records.fields]]
1793name = "target_version"
1794type = "String"
1795description = "Target firmware version"
1796
1797[[records.connectors]]
1798protocol = "mqtt"
1799direction = "inbound"
1800url = "mqtt://ota/cmd/{variant}"
1801"#;
1802
1803 fn state() -> ArchitectureState {
1804 ArchitectureState::from_toml(SAMPLE_TOML).unwrap()
1805 }
1806
1807 fn generated() -> String {
1808 generate_rust(&state())
1809 }
1810
1811 #[test]
1812 fn has_generated_header() {
1813 let out = generated();
1814 assert!(
1815 out.contains("@generated"),
1816 "Missing @generated header:\n{out}"
1817 );
1818 }
1819
1820 #[test]
1821 fn has_imports() {
1822 let out = generated();
1823 assert!(
1824 out.contains("use aimdb_core::buffer::BufferCfg;"),
1825 "Missing BufferCfg import:\n{out}"
1826 );
1827 assert!(
1828 out.contains("use aimdb_core::builder::AimDbBuilder;"),
1829 "Missing AimDbBuilder import:\n{out}"
1830 );
1831 assert!(
1832 out.contains("use aimdb_core::RecordKey;"),
1833 "Missing RecordKey import:\n{out}"
1834 );
1835 assert!(
1836 out.contains("use aimdb_executor::Spawn;"),
1837 "Missing Spawn import:\n{out}"
1838 );
1839 assert!(
1840 out.contains("use serde::{Deserialize, Serialize};"),
1841 "Missing serde import:\n{out}"
1842 );
1843 }
1844
1845 #[test]
1846 fn value_struct_generated() {
1847 let out = generated();
1848 assert!(
1849 out.contains("pub struct TemperatureReadingValue"),
1850 "Missing TemperatureReadingValue struct:\n{out}"
1851 );
1852 assert!(
1853 out.contains("pub celsius: f64,"),
1854 "Missing celsius field:\n{out}"
1855 );
1856 assert!(
1857 out.contains("pub humidity_percent: f64,"),
1858 "Missing humidity_percent field:\n{out}"
1859 );
1860 assert!(
1861 out.contains("pub timestamp: u64,"),
1862 "Missing timestamp field:\n{out}"
1863 );
1864 assert!(
1865 out.contains("#[derive(Debug, Clone, Serialize, Deserialize)]"),
1866 "Missing derives:\n{out}"
1867 );
1868 }
1869
1870 #[test]
1871 fn key_enum_generated() {
1872 let out = generated();
1873 assert!(
1874 out.contains("pub enum TemperatureReadingKey"),
1875 "Missing key enum:\n{out}"
1876 );
1877 assert!(
1878 out.contains("#[derive(Debug, RecordKey, Clone, Copy, PartialEq, Eq)]"),
1879 "Missing RecordKey derive:\n{out}"
1880 );
1881 assert!(
1882 out.contains("#[key_prefix = \"sensors.temp.\"]"),
1883 "Missing key_prefix:\n{out}"
1884 );
1885 assert!(
1886 out.contains("#[key = \"indoor\"]"),
1887 "Missing indoor key attr:\n{out}"
1888 );
1889 assert!(
1890 out.contains("#[key = \"outdoor\"]"),
1891 "Missing outdoor key attr:\n{out}"
1892 );
1893 assert!(
1894 out.contains("#[key = \"garage\"]"),
1895 "Missing garage key attr:\n{out}"
1896 );
1897 assert!(out.contains("Indoor,"), "Missing Indoor variant:\n{out}");
1898 assert!(out.contains("Outdoor,"), "Missing Outdoor variant:\n{out}");
1899 assert!(out.contains("Garage,"), "Missing Garage variant:\n{out}");
1900 }
1901
1902 #[test]
1903 fn link_address_substituted_per_variant() {
1904 let out = generated();
1905 assert!(
1906 out.contains("#[link_address = \"mqtt://sensors/temp/indoor\"]"),
1907 "link_address not substituted for indoor:\n{out}"
1908 );
1909 assert!(
1910 out.contains("#[link_address = \"mqtt://sensors/temp/outdoor\"]"),
1911 "link_address not substituted for outdoor:\n{out}"
1912 );
1913 assert!(
1914 out.contains("#[link_address = \"mqtt://sensors/temp/garage\"]"),
1915 "link_address not substituted for garage:\n{out}"
1916 );
1917 }
1918
1919 #[test]
1920 fn kebab_variants_to_pascal_case() {
1921 let out = generated();
1922 assert!(
1923 out.contains("pub enum OtaCommandKey"),
1924 "Missing OtaCommandKey enum:\n{out}"
1925 );
1926 assert!(
1927 out.contains("Gateway01,"),
1928 "gateway-01 should become Gateway01:\n{out}"
1929 );
1930 assert!(
1931 out.contains("SensorHub01,"),
1932 "sensor-hub-01 should become SensorHub01:\n{out}"
1933 );
1934 }
1935
1936 #[test]
1937 fn configure_schema_function_present() {
1938 let out = generated();
1939 assert!(
1940 out.contains(
1941 "pub fn configure_schema<R: Spawn + 'static>(builder: &mut AimDbBuilder<R>)"
1942 ),
1943 "Missing configure_schema function:\n{out}"
1944 );
1945 }
1946
1947 #[test]
1948 fn configure_schema_spmc_buffer() {
1949 let out = generated();
1950 assert!(
1952 out.contains("BufferCfg::SpmcRing"),
1953 "Missing SpmcRing buffer call:\n{out}"
1954 );
1955 assert!(
1956 out.contains("capacity: 256"),
1957 "Missing capacity value:\n{out}"
1958 );
1959 }
1960
1961 #[test]
1962 fn configure_schema_mailbox_buffer() {
1963 let out = generated();
1964 assert!(
1965 out.contains("BufferCfg::Mailbox"),
1966 "Missing Mailbox buffer call:\n{out}"
1967 );
1968 }
1969
1970 #[test]
1971 fn configure_schema_outbound_link_to_with_serializer() {
1972 let out = generated();
1973 assert!(
1974 out.contains("link_to(addr_0)"),
1975 "Missing link_to call:\n{out}"
1976 );
1977 assert!(
1978 out.contains("with_serializer"),
1979 "Missing with_serializer call:\n{out}"
1980 );
1981 assert!(out.contains(".finish()"), "Missing .finish() call:\n{out}");
1982 }
1983
1984 #[test]
1985 fn configure_schema_inbound_link_from_with_deserializer() {
1986 let out = generated();
1987 assert!(
1988 out.contains("link_from(addr_0)"),
1989 "Missing link_from call:\n{out}"
1990 );
1991 assert!(
1992 out.contains("with_deserializer(OtaCommandValue::from_bytes)"),
1993 "Missing with_deserializer call:\n{out}"
1994 );
1995 }
1996
1997 #[test]
1998 fn configure_schema_key_variants_iterated() {
1999 let out = generated();
2000 assert!(
2001 out.contains("TemperatureReadingKey::Indoor"),
2002 "Missing Indoor in configure_schema:\n{out}"
2003 );
2004 assert!(
2005 out.contains("TemperatureReadingKey::Outdoor"),
2006 "Missing Outdoor in configure_schema:\n{out}"
2007 );
2008 assert!(
2009 out.contains("TemperatureReadingKey::Garage"),
2010 "Missing Garage in configure_schema:\n{out}"
2011 );
2012 assert!(
2013 out.contains("OtaCommandKey::Gateway01"),
2014 "Missing Gateway01 in configure_schema:\n{out}"
2015 );
2016 }
2017
2018 #[test]
2021 fn pascal_case_simple() {
2022 assert_eq!(to_pascal_case("indoor"), "Indoor");
2023 assert_eq!(to_pascal_case("outdoor"), "Outdoor");
2024 }
2025
2026 #[test]
2027 fn pascal_case_kebab() {
2028 assert_eq!(to_pascal_case("gateway-01"), "Gateway01");
2029 assert_eq!(to_pascal_case("sensor-hub-01"), "SensorHub01");
2030 }
2031
2032 #[test]
2033 fn pascal_case_snake() {
2034 assert_eq!(to_pascal_case("sensor_hub_01"), "SensorHub01");
2035 }
2036
2037 #[test]
2038 fn pascal_case_already_capitalized() {
2039 assert_eq!(to_pascal_case("Indoor"), "Indoor");
2040 }
2041
2042 #[test]
2044 fn snapshot_full_output() {
2045 let out = generated();
2046 assert!(!out.is_empty());
2049 }
2050
2051 #[test]
2054 fn snake_case_basic() {
2055 assert_eq!(to_snake_case("WeatherObservation"), "weather_observation");
2056 assert_eq!(to_snake_case("Temperature"), "temperature");
2057 }
2058
2059 #[test]
2060 fn snake_case_acronym() {
2061 assert_eq!(to_snake_case("OtaCommand"), "ota_command");
2062 }
2063
2064 const EXTENDED_TOML: &str = r#"
2067[project]
2068name = "weather-sentinel"
2069
2070[meta]
2071aimdb_version = "0.5.0"
2072created_at = "2026-02-24T21:39:15Z"
2073last_modified = "2026-02-25T10:00:00Z"
2074
2075[[records]]
2076name = "WeatherObservation"
2077buffer = "SpmcRing"
2078capacity = 256
2079key_prefix = "weather.observation."
2080key_variants = ["Vienna", "Munich"]
2081schema_version = 2
2082serialization = "json"
2083
2084[records.observable]
2085signal_field = "temperature_celsius"
2086icon = "🌡️"
2087unit = "°C"
2088
2089[[records.fields]]
2090name = "timestamp"
2091type = "u64"
2092description = "Unix timestamp in milliseconds"
2093
2094[[records.fields]]
2095name = "temperature_celsius"
2096type = "f32"
2097description = "Air temperature"
2098settable = true
2099
2100[[records.fields]]
2101name = "humidity_percent"
2102type = "f32"
2103description = "Relative humidity"
2104settable = true
2105
2106[[records.connectors]]
2107protocol = "mqtt"
2108direction = "inbound"
2109url = "sensors/{variant}/observation"
2110"#;
2111
2112 fn extended_state() -> ArchitectureState {
2113 ArchitectureState::from_toml(EXTENDED_TOML).unwrap()
2114 }
2115
2116 fn extended_generated() -> String {
2117 generate_rust(&extended_state())
2118 }
2119
2120 #[test]
2121 fn schema_type_impl_generated() {
2122 let out = extended_generated();
2123 assert!(
2124 out.contains("impl SchemaType for WeatherObservationValue"),
2125 "Missing SchemaType impl:\n{out}"
2126 );
2127 assert!(
2128 out.contains("\"weather_observation\""),
2129 "Missing schema name:\n{out}"
2130 );
2131 assert!(
2132 out.contains("VERSION: u32 = 2"),
2133 "Missing schema version:\n{out}"
2134 );
2135 }
2136
2137 #[test]
2138 fn linkable_impl_json_generated() {
2139 let out = extended_generated();
2140 assert!(
2141 out.contains("impl Linkable for WeatherObservationValue"),
2142 "Missing Linkable impl:\n{out}"
2143 );
2144 assert!(
2145 out.contains("serde_json::to_vec"),
2146 "Missing serde_json::to_vec call:\n{out}"
2147 );
2148 assert!(
2149 out.contains("serde_json::from_slice"),
2150 "Missing serde_json::from_slice call:\n{out}"
2151 );
2152 }
2153
2154 #[test]
2155 fn observable_impl_generated() {
2156 let out = extended_generated();
2157 assert!(
2158 out.contains("impl Observable for WeatherObservationValue"),
2159 "Missing Observable impl:\n{out}"
2160 );
2161 assert!(
2162 out.contains("self.temperature_celsius"),
2163 "Missing signal field access:\n{out}"
2164 );
2165 assert!(out.contains("\"°C\""), "Missing unit:\n{out}");
2166 }
2167
2168 #[test]
2169 fn settable_impl_generated() {
2170 let out = extended_generated();
2171 assert!(
2172 out.contains("impl Settable for WeatherObservationValue"),
2173 "Missing Settable impl:\n{out}"
2174 );
2175 assert!(
2176 out.contains("(f32, f32)"),
2177 "Missing tuple value type:\n{out}"
2178 );
2179 }
2180
2181 #[test]
2182 fn configure_schema_with_real_deserializer() {
2183 let out = extended_generated();
2184 assert!(
2185 out.contains("with_deserializer(WeatherObservationValue::from_bytes)"),
2186 "Missing with_deserializer for inbound connector:\n{out}"
2187 );
2188 }
2189
2190 #[test]
2191 fn data_contracts_import_present() {
2192 let out = extended_generated();
2193 assert!(
2194 out.contains("use aimdb_data_contracts"),
2195 "Missing aimdb_data_contracts import:\n{out}"
2196 );
2197 assert!(
2198 out.contains("SchemaType"),
2199 "Missing SchemaType in import:\n{out}"
2200 );
2201 }
2202
2203 #[test]
2204 fn generate_cargo_toml_output() {
2205 let state = extended_state();
2206 let toml = generate_cargo_toml(&state);
2207 assert!(
2208 toml.contains("weather-sentinel-common"),
2209 "Missing crate name:\n{toml}"
2210 );
2211 assert!(
2212 toml.contains("serde_json"),
2213 "Missing serde_json dep:\n{toml}"
2214 );
2215 assert!(
2216 toml.contains("linkable"),
2217 "Missing linkable feature:\n{toml}"
2218 );
2219 }
2220
2221 #[test]
2222 fn generate_lib_rs_output() {
2223 let lib = generate_lib_rs();
2224 assert!(lib.contains("no_std"), "Missing no_std attribute:\n{lib}");
2225 assert!(
2226 lib.contains("extern crate alloc"),
2227 "Missing alloc extern:\n{lib}"
2228 );
2229 assert!(lib.contains("mod schema"), "Missing schema module:\n{lib}");
2230 assert!(
2231 lib.contains("pub use schema::*"),
2232 "Missing re-export:\n{lib}"
2233 );
2234 }
2235
2236 #[test]
2237 fn schema_rs_has_no_generated_header() {
2238 let out = generate_schema_rs(&extended_state());
2239 assert!(
2240 !out.contains("@generated"),
2241 "schema.rs should not have @generated header:\n{out}"
2242 );
2243 }
2244}