Skip to main content

wavekat_flow/
validate.rs

1//! Publish-time / load-time validation — the gate that keeps a bad document
2//! from ever becoming a live phone line (doc 48). The platform runs the same
3//! checks before publish (`packages/flow-schema/src/validate.ts`); the daemon
4//! re-runs them on load so a corrupted cache or a version mismatch fails safe
5//! rather than executing undefined behavior. Every rule here has a TypeScript
6//! twin, and both are pinned by the shared conformance corpus.
7//!
8//! Structural guarantees enforced here: the schema version is one this engine
9//! runs; every exit is wired to an existing node and matches the node's exit
10//! set; every node is reachable from `entry`; **no caller can be trapped** —
11//! every reachable node can reach a terminal; prompt-length and menu/hours
12//! sanity caps. All errors are collected (not fail-fast) so the editor can
13//! show every problem at once.
14
15use std::collections::{BTreeSet, VecDeque};
16
17use crate::book::{self};
18use crate::hours::{self, HoursError};
19use crate::model::{Flow, Node, Prompt};
20use crate::model_ext::NodeId;
21use crate::SUPPORTED_SCHEMA_VERSIONS;
22
23/// Longest a spoken (text) prompt may be. A backstop against a pasted-essay
24/// prompt that would trap a caller under minutes of TTS; generous enough that
25/// no real greeting hits it. Audio-asset prompts are unbounded here. Twin:
26/// `packages/flow-schema/src/model.ts` `MAX_PROMPT_CHARS`.
27const MAX_PROMPT_CHARS: usize = 2000;
28
29/// The DTMF keys a `menu` option may be keyed by. Twin:
30/// `packages/flow-schema/src/model.ts` `VALID_DIGITS`.
31const VALID_DIGITS: &[&str] = &["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"];
32
33/// One thing wrong with a flow document. Carries enough context (node id,
34/// exit name, offending value) for the editor to point at it.
35#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
36pub enum ValidationError {
37    #[error("schema_version {0} is not supported by this app")]
38    UnsupportedSchemaVersion(i64),
39
40    #[error("flow has no nodes")]
41    EmptyFlow,
42
43    #[error("entry {0:?} is not a node")]
44    MissingEntry(NodeId),
45
46    #[error("node {node:?} exit {exit:?} points at {target:?}, which is not a node")]
47    UnknownExitTarget {
48        node: NodeId,
49        exit: String,
50        target: NodeId,
51    },
52
53    #[error("node {node:?} ({kind}) is missing required exits {missing:?}")]
54    MissingExits {
55        node: NodeId,
56        kind: &'static str,
57        missing: Vec<String>,
58    },
59
60    #[error("node {node:?} ({kind}) has exits {unexpected:?} it does not define")]
61    UnexpectedExits {
62        node: NodeId,
63        kind: &'static str,
64        unexpected: Vec<String>,
65    },
66
67    #[error("menu node {node:?} offers no options")]
68    EmptyMenu { node: NodeId },
69
70    #[error("menu node {node:?} option key {key:?} is not a DTMF digit (0-9, *, #)")]
71    BadDigit { node: NodeId, key: String },
72
73    #[error("hours node {node:?}: {source}")]
74    Hours { node: NodeId, source: HoursError },
75
76    #[error("transfer node {node:?} has an empty target")]
77    EmptyTransferTarget { node: NodeId },
78
79    #[error("node {node:?} prompt is {len} chars (max {MAX_PROMPT_CHARS})")]
80    PromptTooLong { node: NodeId, len: usize },
81
82    #[error("node {node:?} is unreachable from entry")]
83    Unreachable { node: NodeId },
84
85    #[error("node {node:?} can never reach a way to end the call (caller trapped)")]
86    Trapped { node: NodeId },
87
88    #[error("book node {node:?} {field} is {value} (allowed: {min}–{max})")]
89    BookOutOfRange {
90        node: NodeId,
91        field: &'static str,
92        value: u64,
93        min: u64,
94        max: u64,
95    },
96
97    #[error(
98        "book node {node:?} can never offer an appointment: no opening leaves room for {duration} minutes"
99    )]
100    BookNeverOpen { node: NodeId, duration: u64 },
101
102    #[error(
103        "node {node:?} is a {kind} step, which needs schema_version {required} (this document declares {declared})"
104    )]
105    KindRequiresNewerSchema {
106        node: NodeId,
107        kind: &'static str,
108        required: i64,
109        declared: i64,
110    },
111}
112
113impl ValidationError {
114    /// Stable snake_case code, shared with the TypeScript validator and the
115    /// conformance corpus (`conformance/v1/**/*.expected.json` `semantic`).
116    /// `unknown_target` matches the corpus (the pre-consolidation platform
117    /// copy called it `unknown_exit_target`; the frozen corpus is
118    /// authoritative, so both languages emit `unknown_target`).
119    pub fn code(&self) -> &'static str {
120        match self {
121            ValidationError::UnsupportedSchemaVersion(_) => "unsupported_schema_version",
122            ValidationError::EmptyFlow => "empty_flow",
123            ValidationError::MissingEntry(_) => "missing_entry",
124            ValidationError::UnknownExitTarget { .. } => "unknown_target",
125            ValidationError::MissingExits { .. } => "missing_exits",
126            ValidationError::UnexpectedExits { .. } => "unexpected_exits",
127            ValidationError::EmptyMenu { .. } => "empty_menu",
128            ValidationError::BadDigit { .. } => "bad_digit",
129            // Delegate to the inner hours code so both languages report the
130            // same code for a hours defect (TS surfaces it directly).
131            ValidationError::Hours { source, .. } => source.code(),
132            ValidationError::EmptyTransferTarget { .. } => "empty_transfer_target",
133            ValidationError::PromptTooLong { .. } => "prompt_too_long",
134            ValidationError::Unreachable { .. } => "unreachable",
135            ValidationError::Trapped { .. } => "trapped",
136            ValidationError::BookOutOfRange { .. } => "book_out_of_range",
137            ValidationError::BookNeverOpen { .. } => "book_never_open",
138            ValidationError::KindRequiresNewerSchema { .. } => "kind_requires_newer_schema",
139        }
140    }
141}
142
143/// The oldest `schema_version` that may carry each component — the whole
144/// of what "a version bump" means in this format, since versions grow by
145/// gaining components and nothing else so far. Documents are never
146/// rewritten, so a v1 flow keeps working forever; it simply may not use
147/// `book`. Twin: `model.ts` `KIND_MIN_SCHEMA_VERSION`.
148fn kind_min_schema_version(kind: &str) -> i64 {
149    match kind {
150        "book" => 2,
151        _ => 1,
152    }
153}
154
155/// The lowest `schema_version` that can carry this document's components —
156/// the version it *needs*, as against the one it declares.
157///
158/// The engine has no use for this; it is here for authoring tools, which
159/// set the declared version from it so the number tracks the steps the
160/// author placed rather than being one more thing they must know about.
161/// A document needing nothing newer stays at 1, which is also the widest:
162/// every engine in the field can run it.
163///
164/// Twin: `model.ts` `requiredSchemaVersion`.
165pub fn required_schema_version(flow: &Flow) -> i64 {
166    flow.nodes
167        .values()
168        .map(|node| kind_min_schema_version(node.kind()))
169        .max()
170        .unwrap_or(1)
171}
172
173/// Validate a parsed flow. `Ok(())` means safe to publish / execute; `Err`
174/// carries every problem found.
175pub fn validate(flow: &Flow) -> Result<(), Vec<ValidationError>> {
176    let mut errs = Vec::new();
177
178    if !SUPPORTED_SCHEMA_VERSIONS
179        .iter()
180        .any(|&v| i64::from(v) == flow.schema_version)
181    {
182        errs.push(ValidationError::UnsupportedSchemaVersion(
183            flow.schema_version,
184        ));
185    }
186
187    if flow.nodes.is_empty() {
188        errs.push(ValidationError::EmptyFlow);
189        return Err(errs); // graph checks below would be meaningless
190    }
191
192    let entry_exists = flow.nodes.contains_key(&flow.entry);
193    if !entry_exists {
194        errs.push(ValidationError::MissingEntry(flow.entry.clone()));
195    }
196
197    for (id, node) in &flow.nodes {
198        check_exits(id, node, flow, &mut errs);
199        check_node(id, node, &mut errs);
200
201        // A component the document's own version doesn't have. Checked
202        // per node rather than once per flow so the editor points at the
203        // step that has to change, and kept separate from the parser's
204        // `unknown_kind` (which a build predating the component reports
205        // for the same document) because the fix differs: bump the
206        // version, don't fix the typo.
207        let required = kind_min_schema_version(node.kind());
208        if flow.schema_version < required {
209            errs.push(ValidationError::KindRequiresNewerSchema {
210                node: id.clone(),
211                kind: node.kind(),
212                required,
213                declared: flow.schema_version,
214            });
215        }
216    }
217
218    // Reachability and trap analysis need a real entry to walk from.
219    if entry_exists {
220        check_graph(flow, &mut errs);
221    }
222
223    if errs.is_empty() {
224        Ok(())
225    } else {
226        Err(errs)
227    }
228}
229
230/// Exit keys must be exactly the set the node's kind defines, and every
231/// target must exist.
232fn check_exits(id: &NodeId, node: &Node, flow: &Flow, errs: &mut Vec<ValidationError>) {
233    let kind = node.kind();
234    let required: BTreeSet<String> = node.required_exits().into_iter().collect();
235    let present: BTreeSet<String> = node
236        .exits()
237        .map(|e| e.keys().cloned().collect())
238        .unwrap_or_default();
239
240    let missing: Vec<String> = required.difference(&present).cloned().collect();
241    if !missing.is_empty() {
242        errs.push(ValidationError::MissingExits {
243            node: id.clone(),
244            kind,
245            missing,
246        });
247    }
248    let unexpected: Vec<String> = present.difference(&required).cloned().collect();
249    if !unexpected.is_empty() {
250        errs.push(ValidationError::UnexpectedExits {
251            node: id.clone(),
252            kind,
253            unexpected,
254        });
255    }
256
257    for (exit, target) in node.exits().into_iter().flatten() {
258        if !flow.nodes.contains_key(target) {
259            errs.push(ValidationError::UnknownExitTarget {
260                node: id.clone(),
261                exit: exit.clone(),
262                target: target.clone(),
263            });
264        }
265    }
266}
267
268/// Per-kind config sanity.
269fn check_node(id: &NodeId, node: &Node, errs: &mut Vec<ValidationError>) {
270    match node {
271        Node::Greeting { prompt, .. } => check_prompt(id, prompt, errs),
272        Node::Hours {
273            schedule,
274            timezone,
275            exceptions,
276            ..
277        } => {
278            if let Err(source) = hours::validate_config(schedule, timezone, exceptions) {
279                errs.push(ValidationError::Hours {
280                    node: id.clone(),
281                    source,
282                });
283            }
284        }
285        Node::Menu {
286            prompt, options, ..
287        } => {
288            check_prompt(id, prompt, errs);
289            if options.is_empty() {
290                errs.push(ValidationError::EmptyMenu { node: id.clone() });
291            }
292            for key in options.keys() {
293                if !VALID_DIGITS.contains(&key.as_str()) {
294                    errs.push(ValidationError::BadDigit {
295                        node: id.clone(),
296                        key: key.clone(),
297                    });
298                }
299            }
300        }
301        Node::Ring { .. } => {}
302        Node::Message { prompt, .. } => check_prompt(id, prompt, errs),
303        Node::Transfer { target, .. } => {
304            if target.trim().is_empty() {
305                errs.push(ValidationError::EmptyTransferTarget { node: id.clone() });
306            }
307        }
308        Node::Hangup { prompt, .. } => {
309            if let Some(p) = prompt {
310                check_prompt(id, p, errs);
311            }
312        }
313        Node::Book {
314            prompt,
315            confirm_prompt,
316            schedule,
317            timezone,
318            exceptions,
319            ..
320        } => {
321            check_prompt(id, prompt, errs);
322            check_prompt(id, confirm_prompt, errs);
323            if let Err(source) = hours::validate_config(schedule, timezone, exceptions) {
324                errs.push(ValidationError::Hours {
325                    node: id.clone(),
326                    source,
327                });
328            }
329            check_book_bounds(id, node, errs);
330        }
331    }
332}
333
334/// The `book` node's numeric bounds, and the one structural question a
335/// schedule can fail: whether it leaves room for a single appointment.
336///
337/// A node that can never offer anything is worth an error rather than a
338/// shrug — "we're open 9:00 to 9:30 and appointments run an hour" is a
339/// flow whose every caller falls out the `no_slots` exit, and the author
340/// will read that as a broken calendar connection, not as arithmetic.
341/// [`book::vocabulary_refs`] answers it for free: no time refs, no times.
342fn check_book_bounds(id: &NodeId, node: &Node, errs: &mut Vec<ValidationError>) {
343    let Node::Book {
344        duration_mins,
345        buffer_mins,
346        lead_mins,
347        horizon_days,
348        max_offers,
349        ..
350    } = node
351    else {
352        return;
353    };
354
355    let mut range = |field: &'static str, value: u64, min: u64, max: u64| {
356        if value < min || value > max {
357            errs.push(ValidationError::BookOutOfRange {
358                node: id.clone(),
359                field,
360                value,
361                min,
362                max,
363            });
364        }
365    };
366    range(
367        "duration_mins",
368        *duration_mins,
369        book::MIN_BOOK_DURATION_MINS,
370        book::MAX_BOOK_DURATION_MINS,
371    );
372    range("buffer_mins", *buffer_mins, 0, book::MAX_BOOK_BUFFER_MINS);
373    range("lead_mins", *lead_mins, 0, book::MAX_BOOK_LEAD_MINS);
374    range(
375        "horizon_days",
376        *horizon_days,
377        1,
378        book::MAX_BOOK_HORIZON_DAYS,
379    );
380    range("max_offers", *max_offers, 1, book::MAX_BOOK_OFFERS);
381
382    let speaks_a_time = book::vocabulary_refs(node).iter().any(|r| {
383        matches!(
384            book::parse_vocabulary_ref(r),
385            Some(book::VocabularyRef::Time { .. })
386        )
387    });
388    if !speaks_a_time {
389        errs.push(ValidationError::BookNeverOpen {
390            node: id.clone(),
391            duration: *duration_mins,
392        });
393    }
394}
395
396fn check_prompt(id: &NodeId, prompt: &Prompt, errs: &mut Vec<ValidationError>) {
397    if let Some(text) = prompt.as_text() {
398        let len = text.chars().count();
399        if len > MAX_PROMPT_CHARS {
400            errs.push(ValidationError::PromptTooLong {
401                node: id.clone(),
402                len,
403            });
404        }
405    }
406}
407
408/// Reachability from `entry`, and the "no caller is trapped" guarantee.
409fn check_graph(flow: &Flow, errs: &mut Vec<ValidationError>) {
410    // BFS from entry over exit edges.
411    let mut reachable: BTreeSet<&str> = BTreeSet::new();
412    let mut queue: VecDeque<&str> = VecDeque::new();
413    queue.push_back(flow.entry.as_str());
414    reachable.insert(flow.entry.as_str());
415    while let Some(id) = queue.pop_front() {
416        let Some(node) = flow.nodes.get(id) else {
417            continue;
418        };
419        for (_, target) in node.exits().into_iter().flatten() {
420            if flow.nodes.contains_key(target) && reachable.insert(target.as_str()) {
421                queue.push_back(target.as_str());
422            }
423        }
424    }
425
426    for id in flow.nodes.keys() {
427        if !reachable.contains(id.as_str()) {
428            errs.push(ValidationError::Unreachable { node: id.clone() });
429        }
430    }
431
432    // "Can reach a terminal" by backward fixpoint from terminal-capable
433    // nodes. A node qualifies if it is itself terminal or any exit target
434    // qualifies.
435    let mut can_end: BTreeSet<&str> = flow
436        .nodes
437        .iter()
438        .filter(|(_, n)| n.is_terminal())
439        .map(|(id, _)| id.as_str())
440        .collect();
441    loop {
442        let mut grew = false;
443        for (id, node) in &flow.nodes {
444            if can_end.contains(id.as_str()) {
445                continue;
446            }
447            if node
448                .exits()
449                .into_iter()
450                .flatten()
451                .any(|(_, t)| can_end.contains(t.as_str()))
452                && can_end.insert(id.as_str())
453            {
454                grew = true;
455            }
456        }
457        if !grew {
458            break;
459        }
460    }
461
462    // A reachable node that can never reach a terminal traps the caller.
463    // (Report only reachable ones — an unreachable trap is already flagged as
464    // unreachable and would be noise.)
465    for id in &reachable {
466        if !can_end.contains(id) {
467            errs.push(ValidationError::Trapped {
468                node: (*id).to_string(),
469            });
470        }
471    }
472}
473
474#[cfg(test)]
475mod tests {
476    use super::*;
477
478    // The doc 48 example, reused across tests. Kept in sync with the one in
479    // the TS suite on purpose — both must stay valid.
480    const LUIGIS: &str = r#"
481schema_version: 1
482id: flow_9f2
483name: Luigi's
484entry: welcome
485nodes:
486  welcome:
487    kind: greeting
488    prompt: Thanks for calling Luigi's!
489    exits: { next: check_hours }
490  check_hours:
491    kind: hours
492    timezone: America/New_York
493    schedule:
494      tue: [{ open: "11:00", close: "22:00" }]
495    exits: { open: front_desk, closed: night_menu }
496  front_desk:
497    kind: ring
498    timeout_secs: 25
499    exits: { no_answer: take_message }
500  night_menu:
501    kind: menu
502    prompt: We're closed. Press 1 for hours.
503    options: { "1": Hours }
504    exits: { "1": say_hours, no_input: take_message, invalid: take_message }
505  say_hours:
506    kind: greeting
507    prompt: Open Tuesday to Sunday.
508    exits: { next: take_message }
509  take_message:
510    kind: message
511    prompt: Leave a message after the tone.
512"#;
513
514    fn parse(src: &str) -> Flow {
515        Flow::from_yaml(src).expect("test flow should parse")
516    }
517
518    #[test]
519    fn the_documented_example_validates() {
520        assert_eq!(validate(&parse(LUIGIS)), Ok(()));
521    }
522
523    #[test]
524    fn rejects_unsupported_schema_version() {
525        let f = parse(&LUIGIS.replace("schema_version: 1", "schema_version: 99"));
526        let errs = validate(&f).unwrap_err();
527        assert!(errs.contains(&ValidationError::UnsupportedSchemaVersion(99)));
528    }
529
530    #[test]
531    fn rejects_missing_entry() {
532        let f = parse(&LUIGIS.replace("entry: welcome", "entry: nope"));
533        let errs = validate(&f).unwrap_err();
534        assert!(errs
535            .iter()
536            .any(|e| matches!(e, ValidationError::MissingEntry(n) if n == "nope")));
537    }
538
539    #[test]
540    fn rejects_dangling_exit_target() {
541        let f = parse(&LUIGIS.replace("next: check_hours", "next: ghost"));
542        let errs = validate(&f).unwrap_err();
543        assert!(errs.iter().any(|e| matches!(
544            e,
545            ValidationError::UnknownExitTarget { target, .. } if target == "ghost"
546        )));
547    }
548
549    #[test]
550    fn rejects_missing_required_exit() {
551        // A greeting with no `next`.
552        let src = r#"
553schema_version: 1
554id: f
555name: n
556entry: g
557nodes:
558  g:
559    kind: greeting
560    prompt: hi
561  bye:
562    kind: hangup
563"#;
564        let errs = validate(&parse(src)).unwrap_err();
565        assert!(errs.iter().any(|e| matches!(
566            e,
567            ValidationError::MissingExits { node, .. } if node == "g"
568        )));
569    }
570
571    #[test]
572    fn rejects_unexpected_exit_on_terminal() {
573        let src = r#"
574schema_version: 1
575id: f
576name: n
577entry: g
578nodes:
579  g:
580    kind: hangup
581    exits: { next: g }
582"#;
583        let errs = validate(&parse(src)).unwrap_err();
584        assert!(errs.iter().any(|e| matches!(
585            e,
586            ValidationError::UnexpectedExits { node, .. } if node == "g"
587        )));
588    }
589
590    #[test]
591    fn rejects_unreachable_node() {
592        let src = r#"
593schema_version: 1
594id: f
595name: n
596entry: g
597nodes:
598  g:
599    kind: hangup
600  orphan:
601    kind: hangup
602"#;
603        let errs = validate(&parse(src)).unwrap_err();
604        assert!(errs.iter().any(|e| matches!(
605            e,
606            ValidationError::Unreachable { node } if node == "orphan"
607        )));
608    }
609
610    #[test]
611    fn rejects_trapping_cycle() {
612        // a -> b -> a, with no terminal anywhere: every caller is stuck.
613        let src = r#"
614schema_version: 1
615id: f
616name: n
617entry: a
618nodes:
619  a:
620    kind: greeting
621    prompt: one
622    exits: { next: b }
623  b:
624    kind: greeting
625    prompt: two
626    exits: { next: a }
627"#;
628        let errs = validate(&parse(src)).unwrap_err();
629        assert!(
630            errs.iter()
631                .any(|e| matches!(e, ValidationError::Trapped { .. })),
632            "a terminal-less loop must be flagged as trapping: {errs:?}"
633        );
634    }
635
636    #[test]
637    fn a_loop_with_an_escape_is_fine() {
638        // Menu loops back to a greeting but no_input/invalid escape to a
639        // terminal — not trapped.
640        let src = r#"
641schema_version: 1
642id: f
643name: n
644entry: m
645nodes:
646  m:
647    kind: menu
648    prompt: press one
649    options: { "1": again }
650    exits: { "1": g, no_input: bye, invalid: bye }
651  g:
652    kind: greeting
653    prompt: again
654    exits: { next: m }
655  bye:
656    kind: hangup
657"#;
658        assert_eq!(validate(&parse(src)), Ok(()));
659    }
660
661    #[test]
662    fn rejects_empty_menu_and_bad_digit() {
663        let empty = r#"
664schema_version: 1
665id: f
666name: n
667entry: m
668nodes:
669  m:
670    kind: menu
671    prompt: hi
672    options: {}
673    exits: { no_input: bye, invalid: bye }
674  bye:
675    kind: hangup
676"#;
677        assert!(validate(&parse(empty))
678            .unwrap_err()
679            .iter()
680            .any(|e| matches!(e, ValidationError::EmptyMenu { .. })));
681
682        let bad = r#"
683schema_version: 1
684id: f
685name: n
686entry: m
687nodes:
688  m:
689    kind: menu
690    prompt: hi
691    options: { A: nope }
692    exits: { A: bye, no_input: bye, invalid: bye }
693  bye:
694    kind: hangup
695"#;
696        assert!(validate(&parse(bad))
697            .unwrap_err()
698            .iter()
699            .any(|e| matches!(e, ValidationError::BadDigit { key, .. } if key == "A")));
700    }
701
702    #[test]
703    fn rejects_empty_transfer_target() {
704        let src = r#"
705schema_version: 1
706id: f
707name: n
708entry: t
709nodes:
710  t:
711    kind: transfer
712    target: "   "
713"#;
714        assert!(validate(&parse(src))
715            .unwrap_err()
716            .iter()
717            .any(|e| matches!(e, ValidationError::EmptyTransferTarget { .. })));
718    }
719
720    #[test]
721    fn rejects_overlong_prompt() {
722        let long = "a".repeat(MAX_PROMPT_CHARS + 1);
723        let src = format!(
724            r#"
725schema_version: 1
726id: f
727name: n
728entry: g
729nodes:
730  g:
731    kind: greeting
732    prompt: {long}
733    exits: {{ next: bye }}
734  bye:
735    kind: hangup
736"#
737        );
738        assert!(validate(&parse(&src))
739            .unwrap_err()
740            .iter()
741            .any(|e| matches!(e, ValidationError::PromptTooLong { .. })));
742    }
743
744    #[test]
745    fn surfaces_hours_config_errors() {
746        let src = r#"
747schema_version: 1
748id: f
749name: n
750entry: h
751nodes:
752  h:
753    kind: hours
754    timezone: Mars/Base
755    schedule: {}
756    exits: { open: bye, closed: bye }
757  bye:
758    kind: hangup
759"#;
760        assert!(validate(&parse(src))
761            .unwrap_err()
762            .iter()
763            .any(|e| matches!(e, ValidationError::Hours { .. })));
764    }
765
766    // ── `required_schema_version` ────────────────────────────────────────
767
768    const BOOKING: &str = r#"
769schema_version: 2
770id: f
771name: n
772entry: appointment
773nodes:
774  appointment:
775    kind: book
776    prompt: When suits you?
777    confirm_prompt: You're booked for
778    timezone: UTC
779    schedule:
780      tue: [{ open: "09:00", close: "17:00" }]
781    duration_mins: 30
782    exits:
783      booked: bye
784      no_slots: bye
785      no_input: bye
786      unavailable: bye
787  bye:
788    kind: hangup
789"#;
790
791    #[test]
792    fn a_document_of_v1_components_needs_only_v1() {
793        assert_eq!(required_schema_version(&parse(LUIGIS)), 1);
794    }
795
796    #[test]
797    fn a_document_rises_to_what_its_newest_component_needs() {
798        assert_eq!(required_schema_version(&parse(BOOKING)), 2);
799    }
800
801    #[test]
802    fn a_document_with_no_steps_needs_v1() {
803        let src = r#"
804schema_version: 1
805id: f
806name: n
807entry: bye
808nodes: {}
809"#;
810        assert_eq!(required_schema_version(&parse(src)), 1);
811    }
812}