Skip to main content

ftui_runtime/
demo.rs

1#![forbid(unsafe_code)]
2
3//! Demo definition parser and runner (bd-2xj.3).
4//!
5//! Parses `demo.yaml` into structured demo definitions and provides
6//! validation for demo schemas.
7//!
8//! # demo.yaml schema
9//!
10//! ```yaml
11//! demos:
12//!   - demo_id: widget_gallery
13//!     title: "Widget Gallery"
14//!     claim: "Renders 12+ widgets correctly"
15//!     timeout_seconds: 10
16//!     terminal_size: [120, 40]
17//!     tags: [widgets, rendering]
18//!     steps:
19//!       - type: render
20//!         widget: block
21//!       - type: assert_content
22//!         contains: ["Block"]
23//!       - type: measure_timing
24//!         metric: render_frame_us
25//!         max_us: 4000
26//! ```
27
28use std::collections::HashSet;
29
30// ============================================================================
31// Types
32// ============================================================================
33
34/// A parsed demo definition.
35#[derive(Debug, Clone)]
36pub struct DemoDefinition {
37    pub demo_id: String,
38    pub title: String,
39    pub claim: String,
40    pub timeout_seconds: u32,
41    pub terminal_width: u16,
42    pub terminal_height: u16,
43    pub tags: Vec<String>,
44    pub steps: Vec<DemoStep>,
45}
46
47/// A single step in a demo.
48#[derive(Debug, Clone)]
49pub enum DemoStep {
50    /// Render a widget.
51    Render {
52        widget: String,
53        description: String,
54        level: Option<String>,
55        signal: Option<String>,
56        seed: Option<u64>,
57    },
58    /// Resize the terminal.
59    Resize {
60        width: u16,
61        height: u16,
62        description: String,
63    },
64    /// Assert a BLAKE3 checksum matches.
65    AssertChecksum { description: String },
66    /// Assert rendered content contains strings.
67    AssertContent {
68        contains: Vec<String>,
69        description: String,
70    },
71    /// Measure timing of an operation.
72    MeasureTiming {
73        metric: String,
74        max_us: Option<u64>,
75        description: String,
76    },
77}
78
79/// Demo parsing error.
80#[derive(Debug, Clone, PartialEq)]
81pub enum DemoParseError {
82    /// Missing required field.
83    MissingField { demo_id: String, field: String },
84    /// Invalid value.
85    InvalidValue {
86        demo_id: String,
87        field: String,
88        reason: String,
89    },
90    /// Duplicate demo_id.
91    DuplicateId(String),
92    /// No demos found.
93    NoDemos,
94    /// Structural error.
95    StructuralError(String),
96}
97
98impl std::fmt::Display for DemoParseError {
99    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
100        match self {
101            Self::MissingField { demo_id, field } => {
102                write!(f, "demo '{demo_id}': missing required field '{field}'")
103            }
104            Self::InvalidValue {
105                demo_id,
106                field,
107                reason,
108            } => {
109                write!(f, "demo '{demo_id}': invalid '{field}': {reason}")
110            }
111            Self::DuplicateId(id) => write!(f, "duplicate demo_id: '{id}'"),
112            Self::NoDemos => write!(f, "no demos defined"),
113            Self::StructuralError(msg) => write!(f, "structural error: {msg}"),
114        }
115    }
116}
117
118impl std::error::Error for DemoParseError {}
119
120// ============================================================================
121// Parser
122// ============================================================================
123
124/// Parse demo.yaml content into structured definitions.
125///
126/// This is a lightweight line-based parser (no serde_yaml dependency).
127pub fn parse_demo_yaml(yaml: &str) -> Result<Vec<DemoDefinition>, Vec<DemoParseError>> {
128    let mut demos = Vec::new();
129    let mut errors = Vec::new();
130    let mut seen_ids = HashSet::new();
131
132    let mut current_demo: Option<DemoBuilder> = None;
133    let mut in_steps = false;
134    let mut in_contains = false;
135    let mut in_tags = false;
136    let mut in_terminal_size = false;
137    let mut current_step: Option<StepBuilder> = None;
138
139    for line in yaml.lines() {
140        let trimmed = line.trim();
141
142        // Skip blanks and comments
143        if trimmed.is_empty() || trimmed.starts_with('#') {
144            continue;
145        }
146
147        let indent = line.len() - line.trim_start().len();
148        let _ = indent; // used for terminal_size list items
149
150        // New demo entry
151        if trimmed == "- demo_id:" || trimmed.starts_with("- demo_id:") {
152            // Flush previous demo
153            if let Some(mut builder) = current_demo.take() {
154                flush_step(&mut current_step, &mut builder.steps);
155                match builder.build() {
156                    Ok(demo) => demos.push(demo),
157                    Err(errs) => errors.extend(errs),
158                }
159            }
160            let id = trimmed
161                .strip_prefix("- demo_id:")
162                .unwrap_or("")
163                .trim()
164                .to_string();
165            if !id.is_empty() && !seen_ids.insert(id.clone()) {
166                errors.push(DemoParseError::DuplicateId(id.clone()));
167            }
168            current_demo = Some(DemoBuilder::new(id));
169            in_steps = false;
170            in_contains = false;
171            in_tags = false;
172            in_terminal_size = false;
173            current_step = None;
174            continue;
175        }
176
177        let Some(ref mut demo) = current_demo else {
178            continue;
179        };
180
181        // Parse demo-level fields
182        if let Some(val) = trimmed.strip_prefix("title:") {
183            demo.title = Some(unquote(val.trim()));
184            in_steps = false;
185            in_contains = false;
186            in_tags = false;
187            in_terminal_size = false;
188        } else if let Some(val) = trimmed.strip_prefix("claim:") {
189            demo.claim = Some(unquote(val.trim()));
190            in_steps = false;
191            in_contains = false;
192            in_tags = false;
193            in_terminal_size = false;
194        } else if let Some(val) = trimmed.strip_prefix("timeout_seconds:") {
195            demo.timeout_seconds = val.trim().parse().ok();
196            in_steps = false;
197            in_contains = false;
198            in_tags = false;
199            in_terminal_size = false;
200        } else if trimmed.starts_with("terminal_size:") {
201            let val = trimmed.strip_prefix("terminal_size:").unwrap().trim();
202            // Inline array: [120, 40]
203            if let Some(inner) = val.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
204                let parts: Vec<&str> = inner.split(',').collect();
205                if parts.len() == 2 {
206                    demo.terminal_width = parts[0].trim().parse().ok();
207                    demo.terminal_height = parts[1].trim().parse().ok();
208                }
209            } else {
210                in_terminal_size = true;
211            }
212            in_steps = false;
213            in_contains = false;
214            in_tags = false;
215        } else if trimmed.starts_with("tags:") {
216            let val = trimmed.strip_prefix("tags:").unwrap().trim();
217            // Inline array: [widgets, rendering]
218            if let Some(inner) = val.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
219                demo.tags = inner.split(',').map(|s| s.trim().to_string()).collect();
220            } else {
221                in_tags = true;
222                demo.tags.clear();
223            }
224            in_steps = false;
225            in_contains = false;
226            in_terminal_size = false;
227        } else if trimmed == "steps:" {
228            in_steps = true;
229            in_contains = false;
230            in_tags = false;
231            in_terminal_size = false;
232        } else if in_tags && trimmed.starts_with("- ") {
233            demo.tags.push(trimmed[2..].trim().to_string());
234        } else if in_terminal_size && indent >= 6 {
235            // YAML list items for terminal_size
236            if let Some(val) = trimmed.strip_prefix("- ") {
237                if demo.terminal_width.is_none() {
238                    demo.terminal_width = val.trim().parse().ok();
239                } else {
240                    demo.terminal_height = val.trim().parse().ok();
241                }
242            }
243        } else if in_steps {
244            // Step parsing
245            if trimmed.starts_with("- type:") {
246                // Flush previous step
247                flush_step(&mut current_step, &mut demo.steps);
248                let step_type = trimmed.strip_prefix("- type:").unwrap().trim();
249                current_step = Some(StepBuilder::new(step_type));
250            } else if let Some(ref mut step) = current_step {
251                if let Some(val) = trimmed.strip_prefix("widget:") {
252                    step.widget = Some(val.trim().to_string());
253                } else if let Some(val) = trimmed.strip_prefix("description:") {
254                    step.description = Some(unquote(val.trim()));
255                } else if let Some(val) = trimmed.strip_prefix("level:") {
256                    step.level = Some(val.trim().to_string());
257                } else if let Some(val) = trimmed.strip_prefix("signal:") {
258                    step.signal = Some(val.trim().to_string());
259                } else if let Some(val) = trimmed.strip_prefix("seed:") {
260                    step.seed = val.trim().parse().ok();
261                } else if let Some(val) = trimmed.strip_prefix("metric:") {
262                    step.metric = Some(val.trim().to_string());
263                } else if let Some(val) = trimmed.strip_prefix("max_us:") {
264                    step.max_us = val.trim().parse().ok();
265                } else if let Some(val) = trimmed.strip_prefix("to:") {
266                    // Inline array: [80, 24]
267                    let val = val.trim();
268                    if let Some(inner) = val.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
269                        let parts: Vec<&str> = inner.split(',').collect();
270                        if parts.len() == 2 {
271                            step.to_width = parts[0].trim().parse().ok();
272                            step.to_height = parts[1].trim().parse().ok();
273                        }
274                    }
275                } else if trimmed.starts_with("contains:") {
276                    let val = trimmed.strip_prefix("contains:").unwrap().trim();
277                    if let Some(inner) = val.strip_prefix('[').and_then(|s| s.strip_suffix(']')) {
278                        step.contains = inner.split(',').map(|s| unquote(s.trim())).collect();
279                    } else {
280                        in_contains = true;
281                        step.contains.clear();
282                    }
283                } else if in_contains && trimmed.starts_with("- ") {
284                    step.contains.push(unquote(trimmed[2..].trim()));
285                }
286            }
287        }
288    }
289
290    // Flush last demo
291    if let Some(mut builder) = current_demo.take() {
292        flush_step(&mut current_step, &mut builder.steps);
293        match builder.build() {
294            Ok(demo) => demos.push(demo),
295            Err(errs) => errors.extend(errs),
296        }
297    }
298
299    if demos.is_empty() && errors.is_empty() {
300        errors.push(DemoParseError::NoDemos);
301    }
302
303    if errors.is_empty() {
304        Ok(demos)
305    } else {
306        Err(errors)
307    }
308}
309
310fn unquote(s: &str) -> String {
311    s.trim_matches('"').trim_matches('\'').to_string()
312}
313
314fn flush_step(current_step: &mut Option<StepBuilder>, steps: &mut Vec<DemoStep>) {
315    if let Some(step) = current_step.take()
316        && let Some(built) = step.build()
317    {
318        steps.push(built);
319    }
320}
321
322// ============================================================================
323// Builders
324// ============================================================================
325
326struct DemoBuilder {
327    demo_id: String,
328    title: Option<String>,
329    claim: Option<String>,
330    timeout_seconds: Option<u32>,
331    terminal_width: Option<u16>,
332    terminal_height: Option<u16>,
333    tags: Vec<String>,
334    steps: Vec<DemoStep>,
335}
336
337impl DemoBuilder {
338    fn new(demo_id: String) -> Self {
339        Self {
340            demo_id,
341            title: None,
342            claim: None,
343            timeout_seconds: None,
344            terminal_width: None,
345            terminal_height: None,
346            tags: Vec::new(),
347            steps: Vec::new(),
348        }
349    }
350
351    fn build(self) -> Result<DemoDefinition, Vec<DemoParseError>> {
352        let mut errors = Vec::new();
353        let id = &self.demo_id;
354
355        if self.title.is_none() {
356            errors.push(DemoParseError::MissingField {
357                demo_id: id.clone(),
358                field: "title".into(),
359            });
360        }
361        if self.claim.is_none() {
362            errors.push(DemoParseError::MissingField {
363                demo_id: id.clone(),
364                field: "claim".into(),
365            });
366        }
367        if self.timeout_seconds.is_none() {
368            errors.push(DemoParseError::MissingField {
369                demo_id: id.clone(),
370                field: "timeout_seconds".into(),
371            });
372        }
373        if let Some(t) = self.timeout_seconds
374            && t > 60
375        {
376            errors.push(DemoParseError::InvalidValue {
377                demo_id: id.clone(),
378                field: "timeout_seconds".into(),
379                reason: format!("{t} exceeds 60-second limit"),
380            });
381        }
382        if self.terminal_width.is_none() || self.terminal_height.is_none() {
383            errors.push(DemoParseError::MissingField {
384                demo_id: id.clone(),
385                field: "terminal_size".into(),
386            });
387        }
388
389        if !errors.is_empty() {
390            return Err(errors);
391        }
392
393        Ok(DemoDefinition {
394            demo_id: self.demo_id,
395            title: self.title.unwrap(),
396            claim: self.claim.unwrap(),
397            timeout_seconds: self.timeout_seconds.unwrap(),
398            terminal_width: self.terminal_width.unwrap(),
399            terminal_height: self.terminal_height.unwrap(),
400            tags: self.tags,
401            steps: self.steps,
402        })
403    }
404}
405
406struct StepBuilder {
407    step_type: String,
408    widget: Option<String>,
409    description: Option<String>,
410    level: Option<String>,
411    signal: Option<String>,
412    seed: Option<u64>,
413    metric: Option<String>,
414    max_us: Option<u64>,
415    to_width: Option<u16>,
416    to_height: Option<u16>,
417    contains: Vec<String>,
418}
419
420impl StepBuilder {
421    fn new(step_type: &str) -> Self {
422        Self {
423            step_type: step_type.to_string(),
424            widget: None,
425            description: None,
426            level: None,
427            signal: None,
428            seed: None,
429            metric: None,
430            max_us: None,
431            to_width: None,
432            to_height: None,
433            contains: Vec::new(),
434        }
435    }
436
437    fn build(self) -> Option<DemoStep> {
438        let desc = self.description.unwrap_or_default();
439        match self.step_type.as_str() {
440            "render" => Some(DemoStep::Render {
441                widget: self.widget.unwrap_or_default(),
442                description: desc,
443                level: self.level,
444                signal: self.signal,
445                seed: self.seed,
446            }),
447            "resize" => Some(DemoStep::Resize {
448                width: self.to_width.unwrap_or(80),
449                height: self.to_height.unwrap_or(24),
450                description: desc,
451            }),
452            "assert_checksum" => Some(DemoStep::AssertChecksum { description: desc }),
453            "assert_content" => Some(DemoStep::AssertContent {
454                contains: self.contains,
455                description: desc,
456            }),
457            "measure_timing" => Some(DemoStep::MeasureTiming {
458                metric: self.metric.unwrap_or_default(),
459                max_us: self.max_us,
460                description: desc,
461            }),
462            _ => None,
463        }
464    }
465}
466
467// ============================================================================
468// Validation
469// ============================================================================
470
471/// Validate demo definitions for consistency.
472pub fn validate_demos(demos: &[DemoDefinition]) -> Vec<DemoParseError> {
473    let mut errors = Vec::new();
474
475    for demo in demos {
476        if demo.steps.is_empty() {
477            errors.push(DemoParseError::MissingField {
478                demo_id: demo.demo_id.clone(),
479                field: "steps".into(),
480            });
481        }
482
483        if demo.terminal_width == 0 || demo.terminal_height == 0 {
484            errors.push(DemoParseError::InvalidValue {
485                demo_id: demo.demo_id.clone(),
486                field: "terminal_size".into(),
487                reason: "width and height must be > 0".into(),
488            });
489        }
490
491        // Check that render steps have widget names
492        for (i, step) in demo.steps.iter().enumerate() {
493            if let DemoStep::Render { widget, .. } = step
494                && widget.is_empty()
495            {
496                errors.push(DemoParseError::MissingField {
497                    demo_id: demo.demo_id.clone(),
498                    field: format!("steps[{i}].widget"),
499                });
500            }
501        }
502    }
503
504    errors
505}
506
507// ============================================================================
508// Tests
509// ============================================================================
510
511#[cfg(test)]
512mod tests {
513    use super::*;
514
515    const MINIMAL_YAML: &str = r#"
516demos:
517  - demo_id: test_demo
518    title: "Test"
519    claim: "It works"
520    timeout_seconds: 5
521    terminal_size: [80, 24]
522    tags: [test]
523    steps:
524      - type: render
525        widget: block
526        description: "Render a block"
527"#;
528
529    #[test]
530    fn parse_minimal_demo() {
531        let demos = parse_demo_yaml(MINIMAL_YAML).unwrap();
532        assert_eq!(demos.len(), 1);
533        assert_eq!(demos[0].demo_id, "test_demo");
534        assert_eq!(demos[0].title, "Test");
535        assert_eq!(demos[0].claim, "It works");
536        assert_eq!(demos[0].timeout_seconds, 5);
537        assert_eq!(demos[0].terminal_width, 80);
538        assert_eq!(demos[0].terminal_height, 24);
539        assert_eq!(demos[0].tags, vec!["test"]);
540        assert_eq!(demos[0].steps.len(), 1);
541    }
542
543    #[test]
544    fn parse_multiple_demos() {
545        let yaml = r#"
546demos:
547  - demo_id: a
548    title: "A"
549    claim: "Claim A"
550    timeout_seconds: 10
551    terminal_size: [120, 40]
552    tags: [x]
553    steps:
554      - type: render
555        widget: block
556        description: "block"
557  - demo_id: b
558    title: "B"
559    claim: "Claim B"
560    timeout_seconds: 15
561    terminal_size: [80, 24]
562    tags: [y]
563    steps:
564      - type: assert_checksum
565        description: "check"
566"#;
567        let demos = parse_demo_yaml(yaml).unwrap();
568        assert_eq!(demos.len(), 2);
569        assert_eq!(demos[0].demo_id, "a");
570        assert_eq!(demos[1].demo_id, "b");
571    }
572
573    #[test]
574    fn parse_all_step_types() {
575        let yaml = r#"
576demos:
577  - demo_id: steps
578    title: "Steps"
579    claim: "All step types"
580    timeout_seconds: 10
581    terminal_size: [80, 24]
582    tags: [test]
583    steps:
584      - type: render
585        widget: block
586        level: full_bayesian
587        signal: red
588        seed: 42
589        description: "render"
590      - type: resize
591        to: [120, 40]
592        description: "resize"
593      - type: assert_checksum
594        description: "checksum"
595      - type: assert_content
596        contains: ["hello", "world"]
597        description: "content"
598      - type: measure_timing
599        metric: render_frame_us
600        max_us: 4000
601        description: "timing"
602"#;
603        let demos = parse_demo_yaml(yaml).unwrap();
604        let steps = &demos[0].steps;
605        assert_eq!(steps.len(), 5);
606
607        assert!(matches!(&steps[0], DemoStep::Render { widget, seed, .. }
608            if widget == "block" && *seed == Some(42)));
609        assert!(matches!(
610            &steps[1],
611            DemoStep::Resize {
612                width: 120,
613                height: 40,
614                ..
615            }
616        ));
617        assert!(matches!(&steps[2], DemoStep::AssertChecksum { .. }));
618        assert!(matches!(&steps[3], DemoStep::AssertContent { contains, .. }
619            if contains == &["hello", "world"]));
620        assert!(
621            matches!(&steps[4], DemoStep::MeasureTiming { metric, max_us, .. }
622            if metric == "render_frame_us" && *max_us == Some(4000))
623        );
624    }
625
626    #[test]
627    fn reject_duplicate_ids() {
628        let yaml = r#"
629demos:
630  - demo_id: dup
631    title: "A"
632    claim: "A"
633    timeout_seconds: 5
634    terminal_size: [80, 24]
635    tags: [x]
636    steps:
637      - type: render
638        widget: block
639        description: "r"
640  - demo_id: dup
641    title: "B"
642    claim: "B"
643    timeout_seconds: 5
644    terminal_size: [80, 24]
645    tags: [x]
646    steps:
647      - type: render
648        widget: block
649        description: "r"
650"#;
651        let errors = parse_demo_yaml(yaml).unwrap_err();
652        assert!(
653            errors
654                .iter()
655                .any(|e| matches!(e, DemoParseError::DuplicateId(id) if id == "dup"))
656        );
657    }
658
659    #[test]
660    fn reject_timeout_over_60() {
661        let yaml = r#"
662demos:
663  - demo_id: slow
664    title: "Slow"
665    claim: "Too slow"
666    timeout_seconds: 90
667    terminal_size: [80, 24]
668    tags: [x]
669    steps:
670      - type: render
671        widget: block
672        description: "r"
673"#;
674        let errors = parse_demo_yaml(yaml).unwrap_err();
675        assert!(errors.iter().any(|e| matches!(
676            e,
677            DemoParseError::InvalidValue { field, .. } if field == "timeout_seconds"
678        )));
679    }
680
681    #[test]
682    fn reject_missing_title() {
683        let yaml = r#"
684demos:
685  - demo_id: notitle
686    claim: "C"
687    timeout_seconds: 5
688    terminal_size: [80, 24]
689    tags: [x]
690    steps:
691      - type: render
692        widget: block
693        description: "r"
694"#;
695        let errors = parse_demo_yaml(yaml).unwrap_err();
696        assert!(errors.iter().any(|e| matches!(
697            e,
698            DemoParseError::MissingField { field, .. } if field == "title"
699        )));
700    }
701
702    #[test]
703    fn reject_empty_yaml() {
704        let errors = parse_demo_yaml("").unwrap_err();
705        assert!(errors.iter().any(|e| matches!(e, DemoParseError::NoDemos)));
706    }
707
708    #[test]
709    fn validate_empty_steps() {
710        let demo = DemoDefinition {
711            demo_id: "empty".into(),
712            title: "E".into(),
713            claim: "C".into(),
714            timeout_seconds: 5,
715            terminal_width: 80,
716            terminal_height: 24,
717            tags: vec![],
718            steps: vec![],
719        };
720        let errors = validate_demos(&[demo]);
721        assert!(errors.iter().any(|e| matches!(
722            e,
723            DemoParseError::MissingField { field, .. } if field == "steps"
724        )));
725    }
726
727    #[test]
728    fn validate_zero_terminal_size() {
729        let demo = DemoDefinition {
730            demo_id: "zero".into(),
731            title: "Z".into(),
732            claim: "C".into(),
733            timeout_seconds: 5,
734            terminal_width: 0,
735            terminal_height: 24,
736            tags: vec![],
737            steps: vec![DemoStep::Render {
738                widget: "block".into(),
739                description: "r".into(),
740                level: None,
741                signal: None,
742                seed: None,
743            }],
744        };
745        let errors = validate_demos(&[demo]);
746        assert!(errors.iter().any(|e| matches!(
747            e,
748            DemoParseError::InvalidValue { field, .. } if field == "terminal_size"
749        )));
750    }
751
752    #[test]
753    fn error_display() {
754        let err = DemoParseError::MissingField {
755            demo_id: "test".into(),
756            field: "title".into(),
757        };
758        assert!(err.to_string().contains("title"));
759    }
760
761    #[test]
762    fn comments_and_blanks_ignored() {
763        let yaml = r#"
764# This is a comment
765demos:
766  # Demo comment
767  - demo_id: commented
768    title: "C"
769    claim: "C"
770    timeout_seconds: 5
771    terminal_size: [80, 24]
772
773    tags: [x]
774    steps:
775      - type: render
776        widget: block
777        description: "r"
778"#;
779        let demos = parse_demo_yaml(yaml).unwrap();
780        assert_eq!(demos.len(), 1);
781    }
782}