1use super::{Brick, BrickAssertion, BrickBudget, BrickVerification};
27use std::time::Duration;
28
29#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum BrickWorkerMessageDirection {
32 ToWorker,
34 FromWorker,
36 Bidirectional,
38}
39
40#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum FieldType {
43 String,
45 Number,
47 Boolean,
49 SharedArrayBuffer,
51 Float32Array,
53 Object(Vec<MessageField>),
55 Optional(Box<FieldType>),
57}
58
59impl FieldType {
60 #[must_use]
62 pub fn to_typescript(&self) -> String {
63 match self {
64 Self::String => "string".into(),
65 Self::Number => "number".into(),
66 Self::Boolean => "boolean".into(),
67 Self::SharedArrayBuffer => "SharedArrayBuffer".into(),
68 Self::Float32Array => "Float32Array".into(),
69 Self::Object(fields) => {
70 let field_types: Vec<_> = fields
71 .iter()
72 .map(|f| format!("{}: {}", f.name, f.field_type.to_typescript()))
73 .collect();
74 format!("{{ {} }}", field_types.join(", "))
75 }
76 Self::Optional(inner) => format!("{} | undefined", inner.to_typescript()),
77 }
78 }
79
80 #[must_use]
82 pub fn to_rust(&self) -> String {
83 match self {
84 Self::String => "String".into(),
85 Self::Number => "f64".into(),
86 Self::Boolean => "bool".into(),
87 Self::SharedArrayBuffer => "js_sys::SharedArrayBuffer".into(),
88 Self::Float32Array => "js_sys::Float32Array".into(),
89 Self::Object(_) => "serde_json::Value".into(),
90 Self::Optional(inner) => format!("Option<{}>", inner.to_rust()),
91 }
92 }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct MessageField {
98 pub name: String,
100 pub field_type: FieldType,
102 pub required: bool,
104}
105
106impl MessageField {
107 #[must_use]
109 pub fn new(name: impl Into<String>, field_type: FieldType) -> Self {
110 Self {
111 name: name.into(),
112 field_type,
113 required: true,
114 }
115 }
116
117 #[must_use]
119 pub fn optional(name: impl Into<String>, field_type: FieldType) -> Self {
120 Self {
121 name: name.into(),
122 field_type: FieldType::Optional(Box::new(field_type)),
123 required: false,
124 }
125 }
126}
127
128#[derive(Debug, Clone)]
130pub struct BrickWorkerMessage {
131 pub name: String,
133 pub direction: BrickWorkerMessageDirection,
135 pub fields: Vec<MessageField>,
137 pub trace_context: bool,
139}
140
141impl BrickWorkerMessage {
142 #[must_use]
144 pub fn new(name: impl Into<String>, direction: BrickWorkerMessageDirection) -> Self {
145 Self {
146 name: name.into(),
147 direction,
148 fields: Vec::new(),
149 trace_context: true, }
151 }
152
153 #[must_use]
155 pub fn field(mut self, name: impl Into<String>, field_type: FieldType) -> Self {
156 self.fields.push(MessageField::new(name, field_type));
157 self
158 }
159
160 #[must_use]
162 pub fn optional_field(mut self, name: impl Into<String>, field_type: FieldType) -> Self {
163 self.fields.push(MessageField::optional(name, field_type));
164 self
165 }
166
167 #[must_use]
169 pub fn without_trace(mut self) -> Self {
170 self.trace_context = false;
171 self
172 }
173
174 #[must_use]
176 pub fn js_type_name(&self) -> String {
177 self.name.to_lowercase()
178 }
179
180 #[must_use]
182 pub fn rust_type_name(&self) -> String {
183 let mut result = String::new();
185 let mut capitalize_next = true;
186 for c in self.name.chars() {
187 if c == '_' || c == '-' {
188 capitalize_next = true;
189 } else if capitalize_next {
190 result.push(c.to_ascii_uppercase());
191 capitalize_next = false;
192 } else {
193 result.push(c);
194 }
195 }
196 result
197 }
198}
199
200#[derive(Debug, Clone)]
202pub struct WorkerTransition {
203 pub from: String,
205 pub message: String,
207 pub to: String,
209 pub action: Option<String>,
211}
212
213impl WorkerTransition {
214 #[must_use]
216 pub fn new(from: impl Into<String>, message: impl Into<String>, to: impl Into<String>) -> Self {
217 Self {
218 from: from.into(),
219 message: message.into(),
220 to: to.into(),
221 action: None,
222 }
223 }
224
225 #[must_use]
227 pub fn with_action(mut self, action: impl Into<String>) -> Self {
228 self.action = Some(action.into());
229 self
230 }
231}
232
233#[derive(Debug, Clone)]
235pub struct WorkerBrick {
236 name: String,
238 messages: Vec<BrickWorkerMessage>,
240 transitions: Vec<WorkerTransition>,
242 initial_state: String,
244 states: Vec<String>,
246}
247
248impl WorkerBrick {
249 #[must_use]
251 pub fn new(name: impl Into<String>) -> Self {
252 Self {
253 name: name.into(),
254 messages: Vec::new(),
255 transitions: Vec::new(),
256 initial_state: "uninitialized".into(),
257 states: vec!["uninitialized".into()],
258 }
259 }
260
261 #[must_use]
263 pub fn message(mut self, msg: BrickWorkerMessage) -> Self {
264 self.messages.push(msg);
265 self
266 }
267
268 #[must_use]
270 pub fn state(mut self, state: impl Into<String>) -> Self {
271 let state = state.into();
272 if !self.states.contains(&state) {
273 self.states.push(state);
274 }
275 self
276 }
277
278 #[must_use]
280 pub fn initial(mut self, state: impl Into<String>) -> Self {
281 self.initial_state = state.into();
282 self
283 }
284
285 #[must_use]
287 pub fn transition(
288 mut self,
289 from: impl Into<String>,
290 message: impl Into<String>,
291 to: impl Into<String>,
292 ) -> Self {
293 let from = from.into();
294 let to = to.into();
295
296 if !self.states.contains(&from) {
298 self.states.push(from.clone());
299 }
300 if !self.states.contains(&to) {
301 self.states.push(to.clone());
302 }
303
304 self.transitions
305 .push(WorkerTransition::new(from, message, to));
306 self
307 }
308
309 #[must_use]
311 pub fn transition_with_action(
312 mut self,
313 from: impl Into<String>,
314 message: impl Into<String>,
315 to: impl Into<String>,
316 action: impl Into<String>,
317 ) -> Self {
318 let from = from.into();
319 let to = to.into();
320
321 if !self.states.contains(&from) {
322 self.states.push(from.clone());
323 }
324 if !self.states.contains(&to) {
325 self.states.push(to.clone());
326 }
327
328 self.transitions
329 .push(WorkerTransition::new(from, message, to).with_action(action));
330 self
331 }
332
333 #[must_use]
335 pub fn to_worker_messages(&self) -> Vec<&BrickWorkerMessage> {
336 self.messages
337 .iter()
338 .filter(|m| {
339 matches!(
340 m.direction,
341 BrickWorkerMessageDirection::ToWorker
342 | BrickWorkerMessageDirection::Bidirectional
343 )
344 })
345 .collect()
346 }
347
348 #[must_use]
350 pub fn from_worker_messages(&self) -> Vec<&BrickWorkerMessage> {
351 self.messages
352 .iter()
353 .filter(|m| {
354 matches!(
355 m.direction,
356 BrickWorkerMessageDirection::FromWorker
357 | BrickWorkerMessageDirection::Bidirectional
358 )
359 })
360 .collect()
361 }
362
363 #[must_use]
365 pub fn to_worker_js(&self) -> String {
366 let mut js = String::new();
367
368 js.push_str(&format!(
370 "// {} Worker (ES Module)\n",
371 to_pascal_case(&self.name)
372 ));
373 js.push_str("// Generated by probar - DO NOT EDIT MANUALLY\n\n");
374
375 js.push_str(&format!("let workerState = '{}';\n\n", self.initial_state));
377
378 js.push_str("self.onmessage = async (e) => {\n");
380 js.push_str(" const msg = e.data;\n");
381 js.push_str(" const _trace = msg._trace; // Dapper trace context\n\n");
382 js.push_str(" switch (msg.type) {\n");
383
384 for msg in self.to_worker_messages() {
386 let js_type = msg.js_type_name();
387
388 js.push_str(&format!(" case '{}':\n", js_type));
389
390 let transitions: Vec<_> = self
392 .transitions
393 .iter()
394 .filter(|t| t.message.to_lowercase() == js_type)
395 .collect();
396
397 if transitions.is_empty() {
398 js.push_str(&format!(
399 " console.log('[Worker] Received {} (no state change)');\n",
400 js_type
401 ));
402 } else {
403 let valid_from_states: Vec<_> = transitions
405 .iter()
406 .map(|t| format!("'{}'", t.from))
407 .collect();
408
409 js.push_str(&format!(
410 " if (![{}].includes(workerState)) {{\n",
411 valid_from_states.join(", ")
412 ));
413 js.push_str(&format!(
414 " console.warn('[Worker] Invalid state for {}: ' + workerState);\n",
415 js_type
416 ));
417 js.push_str(" return;\n");
418 js.push_str(" }\n");
419
420 if let Some(t) = transitions.first() {
422 js.push_str(&format!(" workerState = '{}';\n", t.to));
423 if let Some(ref action) = t.action {
424 js.push_str(&format!(" {};\n", action));
425 }
426 }
427 }
428
429 js.push_str(" break;\n\n");
430 }
431
432 js.push_str(" default:\n");
434 js.push_str(" throw new Error('[Worker] Unknown message type: ' + msg.type);\n");
435 js.push_str(" }\n");
436 js.push_str("};\n\n");
437
438 js.push_str("function postResult(type, data, trace) {\n");
440 js.push_str(" self.postMessage({ type, ...data, _trace: trace });\n");
441 js.push_str("}\n\n");
442
443 js.push_str(&format!(
445 "console.log('[Worker] {} module loaded');\n",
446 to_pascal_case(&self.name)
447 ));
448
449 js
450 }
451
452 #[must_use]
454 pub fn to_rust_bindings(&self) -> String {
455 let mut rust = String::new();
456
457 rust.push_str(&format!(
459 "//! {} Worker Bindings\n",
460 to_pascal_case(&self.name)
461 ));
462 rust.push_str("//! Generated by probar - DO NOT EDIT MANUALLY\n\n");
463 rust.push_str("use serde::{Deserialize, Serialize};\n\n");
464
465 rust.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\n");
467 rust.push_str("#[serde(tag = \"type\", rename_all = \"lowercase\")]\n");
468 rust.push_str("pub enum ToWorker {\n");
469
470 for msg in self.to_worker_messages() {
471 let name = msg.rust_type_name();
472 if msg.fields.is_empty() {
473 rust.push_str(&format!(" {},\n", name));
474 } else {
475 rust.push_str(&format!(" {} {{\n", name));
476 for field in &msg.fields {
477 let rust_type = field.field_type.to_rust();
478 rust.push_str(&format!(
479 " {}: {},\n",
480 to_snake_case(&field.name),
481 rust_type
482 ));
483 }
484 rust.push_str(" },\n");
485 }
486 }
487 rust.push_str("}\n\n");
488
489 rust.push_str("#[derive(Debug, Clone, Serialize, Deserialize)]\n");
491 rust.push_str("#[serde(tag = \"type\", rename_all = \"lowercase\")]\n");
492 rust.push_str("pub enum FromWorker {\n");
493
494 for msg in self.from_worker_messages() {
495 let name = msg.rust_type_name();
496 if msg.fields.is_empty() {
497 rust.push_str(&format!(" {},\n", name));
498 } else {
499 rust.push_str(&format!(" {} {{\n", name));
500 for field in &msg.fields {
501 let rust_type = field.field_type.to_rust();
502 rust.push_str(&format!(
503 " {}: {},\n",
504 to_snake_case(&field.name),
505 rust_type
506 ));
507 }
508 rust.push_str(" },\n");
509 }
510 }
511 rust.push_str("}\n\n");
512
513 rust.push_str("#[derive(Debug, Clone, Copy, PartialEq, Eq)]\n");
515 rust.push_str("pub enum WorkerState {\n");
516 for state in &self.states {
517 rust.push_str(&format!(" {},\n", to_pascal_case(state)));
518 }
519 rust.push_str("}\n\n");
520
521 rust.push_str(&format!(
522 "impl Default for WorkerState {{\n fn default() -> Self {{\n Self::{}\n }}\n}}\n",
523 to_pascal_case(&self.initial_state)
524 ));
525
526 rust
527 }
528
529 #[must_use]
531 pub fn to_typescript_defs(&self) -> String {
532 let mut ts = String::new();
533
534 ts.push_str(&format!("// {} Worker Types\n", to_pascal_case(&self.name)));
535 ts.push_str("// Generated by probar - DO NOT EDIT MANUALLY\n\n");
536
537 ts.push_str("interface TraceContext {\n");
539 ts.push_str(" trace_id: string;\n");
540 ts.push_str(" parent_span_id: string;\n");
541 ts.push_str(" span_id: string;\n");
542 ts.push_str("}\n\n");
543
544 for msg in &self.messages {
546 ts.push_str(&format!("interface {}Message {{\n", msg.rust_type_name()));
547 ts.push_str(&format!(" type: '{}';\n", msg.js_type_name()));
548 for field in &msg.fields {
549 let ts_type = field.field_type.to_typescript();
550 if field.required {
551 ts.push_str(&format!(" {}: {};\n", field.name, ts_type));
552 } else {
553 ts.push_str(&format!(" {}?: {};\n", field.name, ts_type));
554 }
555 }
556 ts.push_str(" _trace?: TraceContext;\n");
557 ts.push_str("}\n\n");
558 }
559
560 ts
561 }
562}
563
564impl Brick for WorkerBrick {
565 fn brick_name(&self) -> &'static str {
566 "WorkerBrick"
567 }
568
569 fn assertions(&self) -> &[BrickAssertion] {
570 &[]
572 }
573
574 fn budget(&self) -> BrickBudget {
575 BrickBudget::uniform(1000)
577 }
578
579 fn verify(&self) -> BrickVerification {
580 let mut passed = Vec::new();
581 let mut failed = Vec::new();
582
583 for transition in &self.transitions {
585 if !self.states.contains(&transition.from) {
586 failed.push((
587 BrickAssertion::Custom {
588 name: "state_exists".into(),
589 validator_id: 1,
590 },
591 format!("State '{}' not defined", transition.from),
592 ));
593 }
594 if !self.states.contains(&transition.to) {
595 failed.push((
596 BrickAssertion::Custom {
597 name: "state_exists".into(),
598 validator_id: 1,
599 },
600 format!("State '{}' not defined", transition.to),
601 ));
602 }
603 }
604
605 for msg in self.to_worker_messages() {
607 let has_transition = self
608 .transitions
609 .iter()
610 .any(|t| t.message.to_lowercase() == msg.js_type_name());
611
612 if has_transition {
613 passed.push(BrickAssertion::Custom {
614 name: format!("message_{}_handled", msg.name),
615 validator_id: 2,
616 });
617 } else {
618 failed.push((
619 BrickAssertion::Custom {
620 name: format!("message_{}_handled", msg.name),
621 validator_id: 2,
622 },
623 format!("Message '{}' has no state transition", msg.name),
624 ));
625 }
626 }
627
628 if failed.is_empty() {
629 passed.push(BrickAssertion::Custom {
630 name: "state_machine_valid".into(),
631 validator_id: 3,
632 });
633 }
634
635 BrickVerification {
636 passed,
637 failed,
638 verification_time: Duration::from_micros(100),
639 }
640 }
641
642 fn to_html(&self) -> String {
643 String::new()
645 }
646
647 fn to_css(&self) -> String {
648 String::new()
650 }
651
652 fn test_id(&self) -> Option<&str> {
653 None
654 }
655}
656
657fn to_pascal_case(s: &str) -> String {
659 let mut result = String::new();
660 let mut capitalize_next = true;
661
662 for c in s.chars() {
663 if c == '_' || c == '-' || c == ' ' {
664 capitalize_next = true;
665 } else if capitalize_next {
666 result.push(c.to_ascii_uppercase());
667 capitalize_next = false;
668 } else {
669 result.push(c);
670 }
671 }
672
673 result
674}
675
676fn to_snake_case(s: &str) -> String {
678 let mut result = String::new();
679
680 for (i, c) in s.chars().enumerate() {
681 if c.is_ascii_uppercase() {
682 if i > 0 {
683 result.push('_');
684 }
685 result.push(c.to_ascii_lowercase());
686 } else if c == '-' {
687 result.push('_');
688 } else {
689 result.push(c);
690 }
691 }
692
693 result
694}
695
696#[cfg(test)]
697mod tests {
698 use super::*;
699
700 #[test]
701 fn test_worker_brick_basic() {
702 let worker = WorkerBrick::new("transcription")
703 .message(BrickWorkerMessage::new(
704 "init",
705 BrickWorkerMessageDirection::ToWorker,
706 ))
707 .message(BrickWorkerMessage::new(
708 "ready",
709 BrickWorkerMessageDirection::FromWorker,
710 ))
711 .transition("uninitialized", "init", "ready");
712
713 assert_eq!(worker.name, "transcription");
714 assert_eq!(worker.messages.len(), 2);
715 assert_eq!(worker.transitions.len(), 1);
716 }
717
718 #[test]
719 fn test_worker_brick_js_generation() {
720 let worker = WorkerBrick::new("test")
721 .message(BrickWorkerMessage::new(
722 "ping",
723 BrickWorkerMessageDirection::ToWorker,
724 ))
725 .message(BrickWorkerMessage::new(
726 "pong",
727 BrickWorkerMessageDirection::FromWorker,
728 ))
729 .transition("uninitialized", "ping", "ready");
730
731 let js = worker.to_worker_js();
732
733 assert!(js.contains("self.onmessage"));
734 assert!(js.contains("case 'ping':"));
735 assert!(js.contains("workerState = 'ready'"));
736 assert!(js.contains("Generated by probar"));
737 }
738
739 #[test]
740 fn test_worker_brick_rust_bindings() {
741 let worker = WorkerBrick::new("test")
742 .message(
743 BrickWorkerMessage::new("init", BrickWorkerMessageDirection::ToWorker)
744 .field("url", FieldType::String),
745 )
746 .message(BrickWorkerMessage::new(
747 "ready",
748 BrickWorkerMessageDirection::FromWorker,
749 ))
750 .transition("uninitialized", "init", "ready");
751
752 let rust = worker.to_rust_bindings();
753
754 assert!(rust.contains("pub enum ToWorker"));
755 assert!(rust.contains("pub enum FromWorker"));
756 assert!(rust.contains("pub enum WorkerState"));
757 assert!(rust.contains("url: String"));
758 }
759
760 #[test]
761 fn test_worker_brick_verification() {
762 let worker = WorkerBrick::new("test")
763 .message(BrickWorkerMessage::new(
764 "init",
765 BrickWorkerMessageDirection::ToWorker,
766 ))
767 .transition("uninitialized", "init", "ready");
768
769 let result = worker.verify();
770 assert!(result.is_valid());
771 }
772
773 #[test]
774 fn test_field_type_typescript() {
775 assert_eq!(FieldType::String.to_typescript(), "string");
776 assert_eq!(FieldType::Number.to_typescript(), "number");
777 assert_eq!(FieldType::Boolean.to_typescript(), "boolean");
778 assert_eq!(
779 FieldType::SharedArrayBuffer.to_typescript(),
780 "SharedArrayBuffer"
781 );
782 }
783
784 #[test]
785 fn test_field_type_rust() {
786 assert_eq!(FieldType::String.to_rust(), "String");
787 assert_eq!(FieldType::Number.to_rust(), "f64");
788 assert_eq!(FieldType::Boolean.to_rust(), "bool");
789 }
790
791 #[test]
792 fn test_to_pascal_case() {
793 assert_eq!(to_pascal_case("hello_world"), "HelloWorld");
794 assert_eq!(to_pascal_case("hello-world"), "HelloWorld");
795 assert_eq!(to_pascal_case("helloWorld"), "HelloWorld");
796 }
797
798 #[test]
799 fn test_to_snake_case() {
800 assert_eq!(to_snake_case("helloWorld"), "hello_world");
801 assert_eq!(to_snake_case("HelloWorld"), "hello_world");
802 assert_eq!(to_snake_case("model-url"), "model_url");
803 }
804}