1#![forbid(unsafe_code)]
2
3use std::collections::HashSet;
29
30#[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#[derive(Debug, Clone)]
49pub enum DemoStep {
50 Render {
52 widget: String,
53 description: String,
54 level: Option<String>,
55 signal: Option<String>,
56 seed: Option<u64>,
57 },
58 Resize {
60 width: u16,
61 height: u16,
62 description: String,
63 },
64 AssertChecksum { description: String },
66 AssertContent {
68 contains: Vec<String>,
69 description: String,
70 },
71 MeasureTiming {
73 metric: String,
74 max_us: Option<u64>,
75 description: String,
76 },
77}
78
79#[derive(Debug, Clone, PartialEq)]
81pub enum DemoParseError {
82 MissingField { demo_id: String, field: String },
84 InvalidValue {
86 demo_id: String,
87 field: String,
88 reason: String,
89 },
90 DuplicateId(String),
92 NoDemos,
94 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
120pub 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 if trimmed.is_empty() || trimmed.starts_with('#') {
144 continue;
145 }
146
147 let indent = line.len() - line.trim_start().len();
148 let _ = indent; if trimmed == "- demo_id:" || trimmed.starts_with("- demo_id:") {
152 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 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 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 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 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 if trimmed.starts_with("- type:") {
246 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 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 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
322struct 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
467pub 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 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#[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}