1use 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
23const MAX_PROMPT_CHARS: usize = 2000;
28
29const VALID_DIGITS: &[&str] = &["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"];
32
33#[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 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 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
143fn kind_min_schema_version(kind: &str) -> i64 {
149 match kind {
150 "book" => 2,
151 _ => 1,
152 }
153}
154
155pub 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
173pub 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); }
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 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 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
230fn 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
268fn 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
334fn 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
408fn check_graph(flow: &Flow, errs: &mut Vec<ValidationError>) {
410 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 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 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 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 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 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 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 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}