jugar_probar/brick/
worker.rs

1//! WorkerBrick: Web Worker code generation from brick definitions (PROBAR-SPEC-009-P7)
2//!
3//! Generates both JavaScript Worker code and Rust web_sys bindings
4//! from a single brick definition. Zero hand-written JavaScript.
5//!
6//! # Example
7//!
8//! ```rust,ignore
9//! use probar::brick::worker::{WorkerBrick, BrickWorkerMessage, BrickWorkerMessageDirection};
10//!
11//! let worker = WorkerBrick::new("transcription")
12//!     .message(BrickWorkerMessage::new("init", BrickWorkerMessageDirection::ToWorker)
13//!         .field("modelUrl", FieldType::String)
14//!         .field("buffer", FieldType::SharedArrayBuffer))
15//!     .message(BrickWorkerMessage::new("ready", BrickWorkerMessageDirection::FromWorker))
16//!     .transition("uninitialized", "init", "loading")
17//!     .transition("loading", "ready", "ready");
18//!
19//! // Generate JavaScript
20//! let js = worker.to_worker_js();
21//!
22//! // Generate Rust bindings
23//! let rust = worker.to_rust_bindings();
24//! ```
25
26use super::{Brick, BrickAssertion, BrickBudget, BrickVerification};
27use std::time::Duration;
28
29/// Direction of worker message
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum BrickWorkerMessageDirection {
32    /// Message sent to worker (main → worker)
33    ToWorker,
34    /// Message sent from worker (worker → main)
35    FromWorker,
36    /// Message can be sent in either direction
37    Bidirectional,
38}
39
40/// Field type for worker messages
41#[derive(Debug, Clone, PartialEq, Eq)]
42pub enum FieldType {
43    /// JavaScript string
44    String,
45    /// JavaScript number (f64)
46    Number,
47    /// JavaScript boolean
48    Boolean,
49    /// SharedArrayBuffer for audio/data transfer
50    SharedArrayBuffer,
51    /// Float32Array for audio samples
52    Float32Array,
53    /// Nested object with fields
54    Object(Vec<MessageField>),
55    /// Optional field
56    Optional(Box<FieldType>),
57}
58
59impl FieldType {
60    /// Get TypeScript type annotation
61    #[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    /// Get Rust type annotation
81    #[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/// A field in a worker message
96#[derive(Debug, Clone, PartialEq, Eq)]
97pub struct MessageField {
98    /// Field name
99    pub name: String,
100    /// Field type
101    pub field_type: FieldType,
102    /// Whether the field is required
103    pub required: bool,
104}
105
106impl MessageField {
107    /// Create a new required field
108    #[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    /// Create an optional field
118    #[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/// A worker message definition
129#[derive(Debug, Clone)]
130pub struct BrickWorkerMessage {
131    /// Message type name (PascalCase for Rust, lowercase for JS)
132    pub name: String,
133    /// Direction of the message
134    pub direction: BrickWorkerMessageDirection,
135    /// Message fields
136    pub fields: Vec<MessageField>,
137    /// Include trace context for distributed tracing
138    pub trace_context: bool,
139}
140
141impl BrickWorkerMessage {
142    /// Create a new worker message
143    #[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, // Default to including trace context
150        }
151    }
152
153    /// Add a field to the message
154    #[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    /// Add an optional field
161    #[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    /// Disable trace context for this message
168    #[must_use]
169    pub fn without_trace(mut self) -> Self {
170        self.trace_context = false;
171        self
172    }
173
174    /// Get the JavaScript type name (lowercase)
175    #[must_use]
176    pub fn js_type_name(&self) -> String {
177        self.name.to_lowercase()
178    }
179
180    /// Get the Rust type name (PascalCase)
181    #[must_use]
182    pub fn rust_type_name(&self) -> String {
183        // Convert to PascalCase
184        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/// A state transition in the worker state machine
201#[derive(Debug, Clone)]
202pub struct WorkerTransition {
203    /// Source state
204    pub from: String,
205    /// Message that triggers the transition
206    pub message: String,
207    /// Target state
208    pub to: String,
209    /// Optional action to execute
210    pub action: Option<String>,
211}
212
213impl WorkerTransition {
214    /// Create a new transition
215    #[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    /// Add an action to the transition
226    #[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/// WorkerBrick: Generates Web Worker code from brick definition
234#[derive(Debug, Clone)]
235pub struct WorkerBrick {
236    /// Worker name
237    name: String,
238    /// Message definitions
239    messages: Vec<BrickWorkerMessage>,
240    /// State machine transitions
241    transitions: Vec<WorkerTransition>,
242    /// Initial state
243    initial_state: String,
244    /// All states
245    states: Vec<String>,
246}
247
248impl WorkerBrick {
249    /// Create a new worker brick
250    #[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    /// Add a message definition
262    #[must_use]
263    pub fn message(mut self, msg: BrickWorkerMessage) -> Self {
264        self.messages.push(msg);
265        self
266    }
267
268    /// Add a state
269    #[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    /// Set the initial state
279    #[must_use]
280    pub fn initial(mut self, state: impl Into<String>) -> Self {
281        self.initial_state = state.into();
282        self
283    }
284
285    /// Add a state transition
286    #[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        // Auto-add states
297        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    /// Add a transition with action
310    #[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    /// Get messages sent to worker
334    #[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    /// Get messages sent from worker
349    #[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    /// Generate JavaScript Worker code
364    #[must_use]
365    pub fn to_worker_js(&self) -> String {
366        let mut js = String::new();
367
368        // Header
369        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        // State variable
376        js.push_str(&format!("let workerState = '{}';\n\n", self.initial_state));
377
378        // Message handler
379        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        // Generate case for each to-worker message
385        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            // Find transitions triggered by this message
391            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                // Generate state machine validation
404                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                // State transition
421                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        // Default case (Yuan Gate - no swallowing)
433        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        // Helper to post message back
439        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        // Log module loaded
444        js.push_str(&format!(
445            "console.log('[Worker] {} module loaded');\n",
446            to_pascal_case(&self.name)
447        ));
448
449        js
450    }
451
452    /// Generate Rust web_sys bindings
453    #[must_use]
454    pub fn to_rust_bindings(&self) -> String {
455        let mut rust = String::new();
456
457        // Header
458        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        // ToWorker enum
466        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        // FromWorker enum
490        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        // State enum
514        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    /// Generate TypeScript type definitions
530    #[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        // Trace context type
538        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        // Message types
545        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        // WorkerBrick assertions are verified by JS validator
571        &[]
572    }
573
574    fn budget(&self) -> BrickBudget {
575        // Worker code generation is not render-bound
576        BrickBudget::uniform(1000)
577    }
578
579    fn verify(&self) -> BrickVerification {
580        let mut passed = Vec::new();
581        let mut failed = Vec::new();
582
583        // Verify state machine completeness
584        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        // Verify messages have corresponding transitions
606        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        // WorkerBrick doesn't generate HTML
644        String::new()
645    }
646
647    fn to_css(&self) -> String {
648        // WorkerBrick doesn't generate CSS
649        String::new()
650    }
651
652    fn test_id(&self) -> Option<&str> {
653        None
654    }
655}
656
657/// Convert string to PascalCase
658fn 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
676/// Convert string to snake_case
677fn 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}