Skip to main content

probador/
generate.rs

1//! Zero-Artifact Code Generation (PROBAR-SPEC-009-P7)
2//!
3//! Generates web artifacts (HTML, CSS, JS) from brick definitions.
4//! Zero hand-written web code - everything derived from Rust types.
5
6use jugar_probar::brick::{
7    AudioBrick, BrickWorkerMessage, BrickWorkerMessageDirection, EventBrick, EventHandler,
8    EventType, FieldType, WorkerBrick,
9};
10use std::fs;
11
12/// Configuration for artifact generation
13#[derive(Debug, Clone)]
14pub struct GenerateConfig {
15    /// Application name
16    pub app_name: String,
17    /// WASM module path
18    pub wasm_module: String,
19    /// Model path (optional, for ML apps)
20    pub model_path: Option<String>,
21    /// Page title
22    pub title: String,
23    /// Output directory
24    pub output_dir: std::path::PathBuf,
25}
26
27impl Default for GenerateConfig {
28    fn default() -> Self {
29        Self {
30            app_name: "app".into(),
31            wasm_module: "./pkg/app.js".into(),
32            model_path: None,
33            title: "Probar Application".into(),
34            output_dir: std::path::PathBuf::from("."),
35        }
36    }
37}
38
39/// Result of artifact generation
40#[derive(Debug)]
41pub struct GenerateResult {
42    /// Generated HTML
43    pub html: String,
44    /// Generated CSS
45    pub css: String,
46    /// Generated main JS
47    pub main_js: String,
48    /// Generated worker JS (if any)
49    pub worker_js: Option<String>,
50    /// Generated audio worklet JS (if any)
51    pub worklet_js: Option<String>,
52    /// Files written
53    pub files_written: Vec<std::path::PathBuf>,
54}
55
56/// Generate web artifacts from brick definitions
57pub fn generate_from_bricks(
58    worker: Option<&WorkerBrick>,
59    events: Option<&EventBrick>,
60    audio: Option<&AudioBrick>,
61    config: &GenerateConfig,
62) -> Result<GenerateResult, String> {
63    let mut files_written = Vec::new();
64
65    // Generate HTML
66    let html = generate_html(config);
67
68    // Generate CSS
69    let css = generate_css();
70
71    // Generate main JS (event handlers + WASM init)
72    let main_js = generate_main_js(events, config);
73
74    // Generate worker JS if WorkerBrick provided
75    let worker_js = worker.map(jugar_probar::WorkerBrick::to_worker_js);
76
77    // Generate audio worklet JS if AudioBrick provided
78    let worklet_js = audio.map(jugar_probar::AudioBrick::to_worklet_js);
79
80    // Write files
81    let output_dir = &config.output_dir;
82    fs::create_dir_all(output_dir).map_err(|e| format!("Failed to create output dir: {e}"))?;
83
84    // Write index.html
85    let html_path = output_dir.join("index.html");
86    fs::write(&html_path, &html).map_err(|e| format!("Failed to write index.html: {e}"))?;
87    files_written.push(html_path);
88
89    // Write style.css
90    let css_path = output_dir.join("style.css");
91    fs::write(&css_path, &css).map_err(|e| format!("Failed to write style.css: {e}"))?;
92    files_written.push(css_path);
93
94    // Write main.js
95    let main_js_path = output_dir.join("main.js");
96    fs::write(&main_js_path, &main_js).map_err(|e| format!("Failed to write main.js: {e}"))?;
97    files_written.push(main_js_path);
98
99    // Write worker.js if present
100    if let Some(ref wjs) = worker_js {
101        let worker_path = output_dir.join("worker.js");
102        fs::write(&worker_path, wjs).map_err(|e| format!("Failed to write worker.js: {e}"))?;
103        files_written.push(worker_path);
104    }
105
106    // Write audio-worklet.js if present
107    if let Some(ref ajs) = worklet_js {
108        let worklet_path = output_dir.join("audio-worklet.js");
109        fs::write(&worklet_path, ajs)
110            .map_err(|e| format!("Failed to write audio-worklet.js: {e}"))?;
111        files_written.push(worklet_path);
112    }
113
114    Ok(GenerateResult {
115        html,
116        css,
117        main_js,
118        worker_js,
119        worklet_js,
120        files_written,
121    })
122}
123
124/// Generate HTML from config
125fn generate_html(config: &GenerateConfig) -> String {
126    format!(
127        r#"<!DOCTYPE html>
128<html lang="en">
129<head>
130    <meta charset="UTF-8">
131    <meta name="viewport" content="width=device-width, initial-scale=1.0">
132    <title>{title}</title>
133    <link rel="stylesheet" href="style.css">
134</head>
135<body>
136    <div id="app" class="container">
137        <h1>{title}</h1>
138        <div id="status" class="status-brick" aria-live="polite">Loading...</div>
139        <div id="controls" class="controls">
140            <button id="record" disabled aria-label="Start/Stop Recording">Record</button>
141            <button id="clear" aria-label="Clear">Clear</button>
142        </div>
143        <div id="output" class="output">
144            <div id="partial" class="transcription-partial"></div>
145            <div id="transcript" class="transcription-final" aria-live="polite"></div>
146        </div>
147    </div>
148    <script type="module" src="main.js"></script>
149</body>
150</html>
151"#,
152        title = config.title,
153    )
154}
155
156/// Generate CSS
157fn generate_css() -> String {
158    r"/* Generated by probar - DO NOT EDIT MANUALLY */
159
160* {
161    box-sizing: border-box;
162    margin: 0;
163    padding: 0;
164}
165
166body {
167    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
168    background: #1a1a2e;
169    color: #eee;
170    min-height: 100vh;
171    display: flex;
172    flex-direction: column;
173    align-items: center;
174    padding: 2rem;
175}
176
177.container {
178    max-width: 800px;
179    width: 100%;
180}
181
182h1 {
183    text-align: center;
184    margin-bottom: 2rem;
185    color: #4dc3ff;
186}
187
188.status-brick {
189    background: #16213e;
190    padding: 1rem;
191    border-radius: 8px;
192    font-weight: 500;
193    margin-bottom: 1rem;
194}
195
196.status-loading { color: #4dc3ff; }
197.status-ready { color: #50fa7b; }
198.status-recording { color: #50fa7b; animation: pulse 1s infinite; }
199.status-error { color: #ff6b6b; }
200
201.controls {
202    display: flex;
203    gap: 1rem;
204    margin-bottom: 1rem;
205}
206
207button {
208    padding: 1rem 2rem;
209    font-size: 1rem;
210    border: none;
211    border-radius: 8px;
212    cursor: pointer;
213    transition: all 0.2s;
214}
215
216#record {
217    background: #e94560;
218    color: white;
219    flex: 1;
220}
221
222#record:hover:not(:disabled) { background: #ff6b6b; }
223#record:disabled { background: #666; cursor: not-allowed; }
224#record.recording { background: #50fa7b; animation: pulse 1s infinite; }
225
226#clear {
227    background: #4dc3ff;
228    color: #1a1a2e;
229}
230
231#clear:hover { background: #7dd5ff; }
232
233.output {
234    background: #16213e;
235    border-radius: 8px;
236    padding: 1.5rem;
237    min-height: 200px;
238}
239
240.transcription-partial {
241    color: #888;
242    font-style: italic;
243    margin-bottom: 1rem;
244    min-height: 1.5em;
245}
246
247.transcription-final {
248    color: #eee;
249    font-size: 1.2rem;
250    line-height: 1.6;
251}
252
253@keyframes pulse {
254    0%, 100% { opacity: 1; }
255    50% { opacity: 0.7; }
256}
257"
258    .into()
259}
260
261/// Generate main.js from event handlers
262fn generate_main_js(events: Option<&EventBrick>, config: &GenerateConfig) -> String {
263    let mut js = String::new();
264
265    js.push_str("// Generated by probar - DO NOT EDIT MANUALLY\n\n");
266
267    // Import WASM module
268    js.push_str(&format!(
269        "import init, {{ WorkerManager }} from '{}';\n\n",
270        config.wasm_module
271    ));
272
273    // State variables
274    js.push_str("let manager = null;\n");
275    js.push_str("let isRecording = false;\n");
276    js.push_str("let audioContext = null;\n");
277    js.push_str("let mediaStream = null;\n\n");
278
279    // DOM references
280    js.push_str("const status = document.getElementById('status');\n");
281    js.push_str("const recordBtn = document.getElementById('record');\n");
282    js.push_str("const clearBtn = document.getElementById('clear');\n\n");
283
284    // Init function
285    js.push_str("async function initApp() {\n");
286    js.push_str("    try {\n");
287    js.push_str("        status.textContent = 'Loading WASM...';\n");
288    js.push_str("        status.className = 'status-brick status-loading';\n");
289    js.push_str("        await init();\n\n");
290
291    // Worker initialization if model_path provided
292    if let Some(ref model_path) = config.model_path {
293        js.push_str("        status.textContent = 'Spawning worker...';\n");
294        js.push_str("        manager = new WorkerManager();\n");
295        js.push_str(&format!("        await manager.spawn('{model_path}');\n\n"));
296
297        js.push_str("        window.addEventListener('whisper-worker-ready', () => {\n");
298        js.push_str("            status.textContent = 'Loading model...';\n");
299        js.push_str("            manager.send_init();\n");
300        js.push_str("        }, { once: true });\n\n");
301
302        js.push_str("        window.addEventListener('whisper-model-loaded', (e) => {\n");
303        js.push_str("            const { sizeMb, loadTimeMs } = e.detail;\n");
304        js.push_str("            status.textContent = `Ready (${sizeMb.toFixed(1)}MB in ${(loadTimeMs/1000).toFixed(1)}s)`;\n");
305        js.push_str("            status.className = 'status-brick status-ready';\n");
306        js.push_str("            recordBtn.disabled = false;\n");
307        js.push_str("        });\n\n");
308
309        js.push_str("        window.addEventListener('whisper-transcription', (e) => {\n");
310        js.push_str("            const { text, isFinal } = e.detail;\n");
311        js.push_str("            if (isFinal) {\n");
312        js.push_str(
313            "                document.getElementById('transcript').textContent += text + ' ';\n",
314        );
315        js.push_str("                document.getElementById('partial').textContent = '';\n");
316        js.push_str("            } else {\n");
317        js.push_str("                document.getElementById('partial').textContent = text;\n");
318        js.push_str("            }\n");
319        js.push_str("        });\n");
320    } else {
321        js.push_str("        status.textContent = 'Ready';\n");
322        js.push_str("        status.className = 'status-brick status-ready';\n");
323        js.push_str("        recordBtn.disabled = false;\n");
324    }
325
326    js.push_str("    } catch (err) {\n");
327    js.push_str("        status.textContent = 'Error: ' + err.message;\n");
328    js.push_str("        status.className = 'status-brick status-error';\n");
329    js.push_str("        console.error('[App] Init failed:', err);\n");
330    js.push_str("    }\n");
331    js.push_str("}\n\n");
332
333    // Add event handlers from EventBrick
334    if let Some(event_brick) = events {
335        js.push_str(&event_brick.to_event_js());
336        js.push('\n');
337    } else {
338        // Default event handlers
339        js.push_str("// Default event handlers\n");
340        js.push_str("recordBtn.addEventListener('click', async () => {\n");
341        js.push_str("    if (isRecording) {\n");
342        js.push_str("        if (manager) manager.stopRecording();\n");
343        js.push_str("        if (mediaStream) mediaStream.getTracks().forEach(t => t.stop());\n");
344        js.push_str("        if (audioContext) audioContext.close();\n");
345        js.push_str("        isRecording = false;\n");
346        js.push_str("        recordBtn.textContent = 'Record';\n");
347        js.push_str("        recordBtn.classList.remove('recording');\n");
348        js.push_str("        status.textContent = 'Ready';\n");
349        js.push_str("        status.className = 'status-brick status-ready';\n");
350        js.push_str("    } else {\n");
351        js.push_str("        try {\n");
352        js.push_str("            mediaStream = await navigator.mediaDevices.getUserMedia({ audio: true });\n");
353        js.push_str("            audioContext = new AudioContext();\n");
354        js.push_str("            if (manager) manager.startRecording(audioContext.sampleRate);\n");
355        js.push_str("            isRecording = true;\n");
356        js.push_str("            recordBtn.textContent = 'Stop';\n");
357        js.push_str("            recordBtn.classList.add('recording');\n");
358        js.push_str("            status.textContent = 'Recording...';\n");
359        js.push_str("            status.className = 'status-brick status-recording';\n");
360        js.push_str("        } catch (err) {\n");
361        js.push_str("            status.textContent = 'Mic access denied';\n");
362        js.push_str("            status.className = 'status-brick status-error';\n");
363        js.push_str("        }\n");
364        js.push_str("    }\n");
365        js.push_str("});\n\n");
366
367        js.push_str("clearBtn.addEventListener('click', () => {\n");
368        js.push_str("    document.getElementById('transcript').textContent = '';\n");
369        js.push_str("    document.getElementById('partial').textContent = '';\n");
370        js.push_str("});\n\n");
371    }
372
373    // Call init
374    js.push_str("initApp();\n");
375
376    js
377}
378
379/// Create a demo `WorkerBrick` for whisper.apr
380#[must_use]
381pub fn create_whisper_worker_brick() -> WorkerBrick {
382    WorkerBrick::new("whisper-transcription")
383        .message(
384            BrickWorkerMessage::new("bootstrap", BrickWorkerMessageDirection::ToWorker)
385                .field("wasmUrl", FieldType::String)
386                .field("modelUrl", FieldType::String),
387        )
388        .message(BrickWorkerMessage::new(
389            "ready",
390            BrickWorkerMessageDirection::FromWorker,
391        ))
392        .message(
393            BrickWorkerMessage::new("init", BrickWorkerMessageDirection::ToWorker)
394                .field("ringBuffer", FieldType::SharedArrayBuffer),
395        )
396        .message(
397            BrickWorkerMessage::new("model_loaded", BrickWorkerMessageDirection::FromWorker)
398                .field("sizeMb", FieldType::Number)
399                .field("loadTimeMs", FieldType::Number),
400        )
401        .message(
402            BrickWorkerMessage::new("start", BrickWorkerMessageDirection::ToWorker)
403                .field("sampleRate", FieldType::Number),
404        )
405        .message(BrickWorkerMessage::new(
406            "stop",
407            BrickWorkerMessageDirection::ToWorker,
408        ))
409        .message(
410            BrickWorkerMessage::new("transcription", BrickWorkerMessageDirection::FromWorker)
411                .field("text", FieldType::String)
412                .field("isFinal", FieldType::Boolean),
413        )
414        .state("uninitialized")
415        .state("bootstrapped")
416        .state("loading")
417        .state("ready")
418        .state("recording")
419        .initial("uninitialized")
420        .transition("uninitialized", "bootstrap", "bootstrapped")
421        .transition("bootstrapped", "ready", "loading")
422        .transition("loading", "init", "loading")
423        .transition("loading", "model_loaded", "ready")
424        .transition("ready", "start", "recording")
425        .transition("recording", "stop", "ready")
426        .transition("recording", "transcription", "recording")
427}
428
429/// Create demo `EventBrick` for whisper.apr
430#[must_use]
431pub fn create_whisper_event_brick() -> EventBrick {
432    EventBrick::new()
433        .on(
434            "#record",
435            EventType::Click,
436            EventHandler::dispatch_state("toggle_recording"),
437        )
438        .on(
439            "#clear",
440            EventType::Click,
441            EventHandler::call_wasm("clear_transcript"),
442        )
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    use tempfile::TempDir;
450
451    #[test]
452    fn test_generate_html() {
453        let config = GenerateConfig {
454            title: "Test App".into(),
455            ..Default::default()
456        };
457
458        let html = generate_html(&config);
459
460        assert!(html.contains("<!DOCTYPE html>"));
461        assert!(html.contains("<title>Test App</title>"));
462        assert!(html.contains("id=\"status\""));
463        assert!(html.contains("id=\"record\""));
464        assert!(html.contains("aria-label"));
465    }
466
467    #[test]
468    fn test_generate_css() {
469        let css = generate_css();
470
471        assert!(css.contains("Generated by probar"));
472        assert!(css.contains(".status-brick"));
473        assert!(css.contains(".transcription-final"));
474        assert!(css.contains("@keyframes pulse"));
475    }
476
477    #[test]
478    fn test_generate_main_js() {
479        let config = GenerateConfig {
480            wasm_module: "./pkg/app.js".into(),
481            model_path: Some("/models/test.bin".into()),
482            ..Default::default()
483        };
484
485        let js = generate_main_js(None, &config);
486
487        assert!(js.contains("Generated by probar"));
488        assert!(js.contains("import init"));
489        assert!(js.contains("initApp"));
490        assert!(js.contains("whisper-worker-ready"));
491    }
492
493    #[test]
494    fn test_generate_from_bricks() {
495        let temp_dir = TempDir::new().unwrap();
496        let config = GenerateConfig {
497            app_name: "test".into(),
498            title: "Test".into(),
499            output_dir: temp_dir.path().to_path_buf(),
500            ..Default::default()
501        };
502
503        let worker = create_whisper_worker_brick();
504        let events = create_whisper_event_brick();
505
506        let result = generate_from_bricks(Some(&worker), Some(&events), None, &config);
507
508        assert!(result.is_ok());
509        let result = result.unwrap();
510
511        assert!(result.files_written.len() >= 4);
512        assert!(result.worker_js.is_some());
513        assert!(temp_dir.path().join("index.html").exists());
514        assert!(temp_dir.path().join("style.css").exists());
515        assert!(temp_dir.path().join("main.js").exists());
516        assert!(temp_dir.path().join("worker.js").exists());
517    }
518
519    #[test]
520    fn test_create_whisper_worker_brick() {
521        let worker = create_whisper_worker_brick();
522
523        let js = worker.to_worker_js();
524        // Name is converted to PascalCase in the header comment
525        assert!(
526            js.contains("WhisperTranscription"),
527            "Missing WhisperTranscription"
528        );
529        // ToWorker messages appear in case statements
530        assert!(js.contains("bootstrap"), "Missing bootstrap (ToWorker)");
531        assert!(js.contains("init"), "Missing init (ToWorker)");
532        assert!(js.contains("start"), "Missing start (ToWorker)");
533        assert!(js.contains("stop"), "Missing stop (ToWorker)");
534
535        let rust = worker.to_rust_bindings();
536        assert!(rust.contains("pub enum ToWorker"));
537        assert!(rust.contains("pub enum FromWorker"));
538        // FromWorker messages appear in Rust bindings
539        assert!(
540            rust.contains("ModelLoaded"),
541            "Missing ModelLoaded (FromWorker)"
542        );
543        assert!(
544            rust.contains("Transcription"),
545            "Missing Transcription (FromWorker)"
546        );
547    }
548
549    // Additional coverage tests
550
551    #[test]
552    fn test_generate_config_default() {
553        let config = GenerateConfig::default();
554        assert_eq!(config.app_name, "app");
555        assert_eq!(config.wasm_module, "./pkg/app.js");
556        assert!(config.model_path.is_none());
557        assert_eq!(config.title, "Probar Application");
558        assert_eq!(config.output_dir, std::path::PathBuf::from("."));
559    }
560
561    #[test]
562    fn test_generate_main_js_no_model_path() {
563        let config = GenerateConfig {
564            model_path: None,
565            ..Default::default()
566        };
567
568        let js = generate_main_js(None, &config);
569
570        // Should have direct "Ready" status without model loading
571        assert!(js.contains("status.textContent = 'Ready'"));
572        assert!(js.contains("status.className = 'status-brick status-ready'"));
573        assert!(js.contains("recordBtn.disabled = false"));
574        // Should not have model-related events
575        assert!(!js.contains("whisper-worker-ready"));
576    }
577
578    #[test]
579    fn test_generate_main_js_with_event_brick() {
580        let config = GenerateConfig::default();
581        let events = create_whisper_event_brick();
582
583        let js = generate_main_js(Some(&events), &config);
584
585        // Should contain event brick's generated JS
586        assert!(js.contains("#record"));
587        assert!(js.contains("#clear"));
588        // Should not contain default event handlers
589        assert!(!js.contains("// Default event handlers"));
590    }
591
592    #[test]
593    fn test_create_whisper_event_brick() {
594        let events = create_whisper_event_brick();
595
596        let js = events.to_event_js();
597
598        assert!(js.contains("#record"));
599        assert!(js.contains("#clear"));
600        assert!(js.contains("click"));
601    }
602
603    #[test]
604    fn test_generate_from_bricks_no_worker_no_audio() {
605        let temp_dir = TempDir::new().unwrap();
606        let config = GenerateConfig {
607            output_dir: temp_dir.path().to_path_buf(),
608            ..Default::default()
609        };
610
611        let result = generate_from_bricks(None, None, None, &config);
612
613        assert!(result.is_ok());
614        let result = result.unwrap();
615
616        assert!(result.worker_js.is_none());
617        assert!(result.worklet_js.is_none());
618        assert_eq!(result.files_written.len(), 3); // html, css, main.js only
619    }
620
621    #[test]
622    fn test_generate_from_bricks_with_audio() {
623        use jugar_probar::brick::AudioBrick;
624
625        let temp_dir = TempDir::new().unwrap();
626        let config = GenerateConfig {
627            output_dir: temp_dir.path().to_path_buf(),
628            ..Default::default()
629        };
630
631        let audio = AudioBrick::new("test-audio")
632            .inputs(1)
633            .outputs(1)
634            .sample_rate(16000);
635
636        let result = generate_from_bricks(None, None, Some(&audio), &config);
637
638        assert!(result.is_ok());
639        let result = result.unwrap();
640
641        assert!(result.worklet_js.is_some());
642        assert!(temp_dir.path().join("audio-worklet.js").exists());
643    }
644
645    #[test]
646    fn test_generate_from_bricks_invalid_output_dir() {
647        let config = GenerateConfig {
648            output_dir: std::path::PathBuf::from("/nonexistent/readonly/path"),
649            ..Default::default()
650        };
651
652        let result = generate_from_bricks(None, None, None, &config);
653
654        assert!(result.is_err());
655        assert!(result.unwrap_err().contains("Failed to create output dir"));
656    }
657
658    #[test]
659    fn test_generate_result_fields() {
660        let temp_dir = TempDir::new().unwrap();
661        let config = GenerateConfig {
662            title: "Test Result Fields".into(),
663            output_dir: temp_dir.path().to_path_buf(),
664            ..Default::default()
665        };
666
667        let result = generate_from_bricks(None, None, None, &config).unwrap();
668
669        assert!(result.html.contains("Test Result Fields"));
670        assert!(result.css.contains("Generated by probar"));
671        assert!(result.main_js.contains("initApp"));
672    }
673
674    #[test]
675    fn test_generate_html_escaping() {
676        let config = GenerateConfig {
677            title: "Test & Special <chars>".into(),
678            ..Default::default()
679        };
680
681        let html = generate_html(&config);
682
683        // Title should be present
684        assert!(html.contains("Test & Special <chars>"));
685    }
686
687    #[test]
688    fn test_generate_main_js_imports() {
689        let config = GenerateConfig {
690            wasm_module: "./custom/path.js".into(),
691            ..Default::default()
692        };
693
694        let js = generate_main_js(None, &config);
695
696        assert!(js.contains("import init, { WorkerManager } from './custom/path.js'"));
697    }
698}