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 validate(flow: &Flow) -> Result<(), Vec<ValidationError>> {
158 let mut errs = Vec::new();
159
160 if !SUPPORTED_SCHEMA_VERSIONS
161 .iter()
162 .any(|&v| i64::from(v) == flow.schema_version)
163 {
164 errs.push(ValidationError::UnsupportedSchemaVersion(
165 flow.schema_version,
166 ));
167 }
168
169 if flow.nodes.is_empty() {
170 errs.push(ValidationError::EmptyFlow);
171 return Err(errs); }
173
174 let entry_exists = flow.nodes.contains_key(&flow.entry);
175 if !entry_exists {
176 errs.push(ValidationError::MissingEntry(flow.entry.clone()));
177 }
178
179 for (id, node) in &flow.nodes {
180 check_exits(id, node, flow, &mut errs);
181 check_node(id, node, &mut errs);
182
183 let required = kind_min_schema_version(node.kind());
190 if flow.schema_version < required {
191 errs.push(ValidationError::KindRequiresNewerSchema {
192 node: id.clone(),
193 kind: node.kind(),
194 required,
195 declared: flow.schema_version,
196 });
197 }
198 }
199
200 if entry_exists {
202 check_graph(flow, &mut errs);
203 }
204
205 if errs.is_empty() {
206 Ok(())
207 } else {
208 Err(errs)
209 }
210}
211
212fn check_exits(id: &NodeId, node: &Node, flow: &Flow, errs: &mut Vec<ValidationError>) {
215 let kind = node.kind();
216 let required: BTreeSet<String> = node.required_exits().into_iter().collect();
217 let present: BTreeSet<String> = node
218 .exits()
219 .map(|e| e.keys().cloned().collect())
220 .unwrap_or_default();
221
222 let missing: Vec<String> = required.difference(&present).cloned().collect();
223 if !missing.is_empty() {
224 errs.push(ValidationError::MissingExits {
225 node: id.clone(),
226 kind,
227 missing,
228 });
229 }
230 let unexpected: Vec<String> = present.difference(&required).cloned().collect();
231 if !unexpected.is_empty() {
232 errs.push(ValidationError::UnexpectedExits {
233 node: id.clone(),
234 kind,
235 unexpected,
236 });
237 }
238
239 for (exit, target) in node.exits().into_iter().flatten() {
240 if !flow.nodes.contains_key(target) {
241 errs.push(ValidationError::UnknownExitTarget {
242 node: id.clone(),
243 exit: exit.clone(),
244 target: target.clone(),
245 });
246 }
247 }
248}
249
250fn check_node(id: &NodeId, node: &Node, errs: &mut Vec<ValidationError>) {
252 match node {
253 Node::Greeting { prompt, .. } => check_prompt(id, prompt, errs),
254 Node::Hours {
255 schedule,
256 timezone,
257 exceptions,
258 ..
259 } => {
260 if let Err(source) = hours::validate_config(schedule, timezone, exceptions) {
261 errs.push(ValidationError::Hours {
262 node: id.clone(),
263 source,
264 });
265 }
266 }
267 Node::Menu {
268 prompt, options, ..
269 } => {
270 check_prompt(id, prompt, errs);
271 if options.is_empty() {
272 errs.push(ValidationError::EmptyMenu { node: id.clone() });
273 }
274 for key in options.keys() {
275 if !VALID_DIGITS.contains(&key.as_str()) {
276 errs.push(ValidationError::BadDigit {
277 node: id.clone(),
278 key: key.clone(),
279 });
280 }
281 }
282 }
283 Node::Ring { .. } => {}
284 Node::Message { prompt, .. } => check_prompt(id, prompt, errs),
285 Node::Transfer { target, .. } => {
286 if target.trim().is_empty() {
287 errs.push(ValidationError::EmptyTransferTarget { node: id.clone() });
288 }
289 }
290 Node::Hangup { prompt, .. } => {
291 if let Some(p) = prompt {
292 check_prompt(id, p, errs);
293 }
294 }
295 Node::Book {
296 prompt,
297 confirm_prompt,
298 schedule,
299 timezone,
300 exceptions,
301 ..
302 } => {
303 check_prompt(id, prompt, errs);
304 check_prompt(id, confirm_prompt, errs);
305 if let Err(source) = hours::validate_config(schedule, timezone, exceptions) {
306 errs.push(ValidationError::Hours {
307 node: id.clone(),
308 source,
309 });
310 }
311 check_book_bounds(id, node, errs);
312 }
313 }
314}
315
316fn check_book_bounds(id: &NodeId, node: &Node, errs: &mut Vec<ValidationError>) {
325 let Node::Book {
326 duration_mins,
327 buffer_mins,
328 lead_mins,
329 horizon_days,
330 max_offers,
331 ..
332 } = node
333 else {
334 return;
335 };
336
337 let mut range = |field: &'static str, value: u64, min: u64, max: u64| {
338 if value < min || value > max {
339 errs.push(ValidationError::BookOutOfRange {
340 node: id.clone(),
341 field,
342 value,
343 min,
344 max,
345 });
346 }
347 };
348 range(
349 "duration_mins",
350 *duration_mins,
351 book::MIN_BOOK_DURATION_MINS,
352 book::MAX_BOOK_DURATION_MINS,
353 );
354 range("buffer_mins", *buffer_mins, 0, book::MAX_BOOK_BUFFER_MINS);
355 range("lead_mins", *lead_mins, 0, book::MAX_BOOK_LEAD_MINS);
356 range(
357 "horizon_days",
358 *horizon_days,
359 1,
360 book::MAX_BOOK_HORIZON_DAYS,
361 );
362 range("max_offers", *max_offers, 1, book::MAX_BOOK_OFFERS);
363
364 let speaks_a_time = book::vocabulary_refs(node).iter().any(|r| {
365 matches!(
366 book::parse_vocabulary_ref(r),
367 Some(book::VocabularyRef::Time { .. })
368 )
369 });
370 if !speaks_a_time {
371 errs.push(ValidationError::BookNeverOpen {
372 node: id.clone(),
373 duration: *duration_mins,
374 });
375 }
376}
377
378fn check_prompt(id: &NodeId, prompt: &Prompt, errs: &mut Vec<ValidationError>) {
379 if let Some(text) = prompt.as_text() {
380 let len = text.chars().count();
381 if len > MAX_PROMPT_CHARS {
382 errs.push(ValidationError::PromptTooLong {
383 node: id.clone(),
384 len,
385 });
386 }
387 }
388}
389
390fn check_graph(flow: &Flow, errs: &mut Vec<ValidationError>) {
392 let mut reachable: BTreeSet<&str> = BTreeSet::new();
394 let mut queue: VecDeque<&str> = VecDeque::new();
395 queue.push_back(flow.entry.as_str());
396 reachable.insert(flow.entry.as_str());
397 while let Some(id) = queue.pop_front() {
398 let Some(node) = flow.nodes.get(id) else {
399 continue;
400 };
401 for (_, target) in node.exits().into_iter().flatten() {
402 if flow.nodes.contains_key(target) && reachable.insert(target.as_str()) {
403 queue.push_back(target.as_str());
404 }
405 }
406 }
407
408 for id in flow.nodes.keys() {
409 if !reachable.contains(id.as_str()) {
410 errs.push(ValidationError::Unreachable { node: id.clone() });
411 }
412 }
413
414 let mut can_end: BTreeSet<&str> = flow
418 .nodes
419 .iter()
420 .filter(|(_, n)| n.is_terminal())
421 .map(|(id, _)| id.as_str())
422 .collect();
423 loop {
424 let mut grew = false;
425 for (id, node) in &flow.nodes {
426 if can_end.contains(id.as_str()) {
427 continue;
428 }
429 if node
430 .exits()
431 .into_iter()
432 .flatten()
433 .any(|(_, t)| can_end.contains(t.as_str()))
434 && can_end.insert(id.as_str())
435 {
436 grew = true;
437 }
438 }
439 if !grew {
440 break;
441 }
442 }
443
444 for id in &reachable {
448 if !can_end.contains(id) {
449 errs.push(ValidationError::Trapped {
450 node: (*id).to_string(),
451 });
452 }
453 }
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 const LUIGIS: &str = r#"
463schema_version: 1
464id: flow_9f2
465name: Luigi's
466entry: welcome
467nodes:
468 welcome:
469 kind: greeting
470 prompt: Thanks for calling Luigi's!
471 exits: { next: check_hours }
472 check_hours:
473 kind: hours
474 timezone: America/New_York
475 schedule:
476 tue: [{ open: "11:00", close: "22:00" }]
477 exits: { open: front_desk, closed: night_menu }
478 front_desk:
479 kind: ring
480 timeout_secs: 25
481 exits: { no_answer: take_message }
482 night_menu:
483 kind: menu
484 prompt: We're closed. Press 1 for hours.
485 options: { "1": Hours }
486 exits: { "1": say_hours, no_input: take_message, invalid: take_message }
487 say_hours:
488 kind: greeting
489 prompt: Open Tuesday to Sunday.
490 exits: { next: take_message }
491 take_message:
492 kind: message
493 prompt: Leave a message after the tone.
494"#;
495
496 fn parse(src: &str) -> Flow {
497 Flow::from_yaml(src).expect("test flow should parse")
498 }
499
500 #[test]
501 fn the_documented_example_validates() {
502 assert_eq!(validate(&parse(LUIGIS)), Ok(()));
503 }
504
505 #[test]
506 fn rejects_unsupported_schema_version() {
507 let f = parse(&LUIGIS.replace("schema_version: 1", "schema_version: 99"));
508 let errs = validate(&f).unwrap_err();
509 assert!(errs.contains(&ValidationError::UnsupportedSchemaVersion(99)));
510 }
511
512 #[test]
513 fn rejects_missing_entry() {
514 let f = parse(&LUIGIS.replace("entry: welcome", "entry: nope"));
515 let errs = validate(&f).unwrap_err();
516 assert!(errs
517 .iter()
518 .any(|e| matches!(e, ValidationError::MissingEntry(n) if n == "nope")));
519 }
520
521 #[test]
522 fn rejects_dangling_exit_target() {
523 let f = parse(&LUIGIS.replace("next: check_hours", "next: ghost"));
524 let errs = validate(&f).unwrap_err();
525 assert!(errs.iter().any(|e| matches!(
526 e,
527 ValidationError::UnknownExitTarget { target, .. } if target == "ghost"
528 )));
529 }
530
531 #[test]
532 fn rejects_missing_required_exit() {
533 let src = r#"
535schema_version: 1
536id: f
537name: n
538entry: g
539nodes:
540 g:
541 kind: greeting
542 prompt: hi
543 bye:
544 kind: hangup
545"#;
546 let errs = validate(&parse(src)).unwrap_err();
547 assert!(errs.iter().any(|e| matches!(
548 e,
549 ValidationError::MissingExits { node, .. } if node == "g"
550 )));
551 }
552
553 #[test]
554 fn rejects_unexpected_exit_on_terminal() {
555 let src = r#"
556schema_version: 1
557id: f
558name: n
559entry: g
560nodes:
561 g:
562 kind: hangup
563 exits: { next: g }
564"#;
565 let errs = validate(&parse(src)).unwrap_err();
566 assert!(errs.iter().any(|e| matches!(
567 e,
568 ValidationError::UnexpectedExits { node, .. } if node == "g"
569 )));
570 }
571
572 #[test]
573 fn rejects_unreachable_node() {
574 let src = r#"
575schema_version: 1
576id: f
577name: n
578entry: g
579nodes:
580 g:
581 kind: hangup
582 orphan:
583 kind: hangup
584"#;
585 let errs = validate(&parse(src)).unwrap_err();
586 assert!(errs.iter().any(|e| matches!(
587 e,
588 ValidationError::Unreachable { node } if node == "orphan"
589 )));
590 }
591
592 #[test]
593 fn rejects_trapping_cycle() {
594 let src = r#"
596schema_version: 1
597id: f
598name: n
599entry: a
600nodes:
601 a:
602 kind: greeting
603 prompt: one
604 exits: { next: b }
605 b:
606 kind: greeting
607 prompt: two
608 exits: { next: a }
609"#;
610 let errs = validate(&parse(src)).unwrap_err();
611 assert!(
612 errs.iter()
613 .any(|e| matches!(e, ValidationError::Trapped { .. })),
614 "a terminal-less loop must be flagged as trapping: {errs:?}"
615 );
616 }
617
618 #[test]
619 fn a_loop_with_an_escape_is_fine() {
620 let src = r#"
623schema_version: 1
624id: f
625name: n
626entry: m
627nodes:
628 m:
629 kind: menu
630 prompt: press one
631 options: { "1": again }
632 exits: { "1": g, no_input: bye, invalid: bye }
633 g:
634 kind: greeting
635 prompt: again
636 exits: { next: m }
637 bye:
638 kind: hangup
639"#;
640 assert_eq!(validate(&parse(src)), Ok(()));
641 }
642
643 #[test]
644 fn rejects_empty_menu_and_bad_digit() {
645 let empty = r#"
646schema_version: 1
647id: f
648name: n
649entry: m
650nodes:
651 m:
652 kind: menu
653 prompt: hi
654 options: {}
655 exits: { no_input: bye, invalid: bye }
656 bye:
657 kind: hangup
658"#;
659 assert!(validate(&parse(empty))
660 .unwrap_err()
661 .iter()
662 .any(|e| matches!(e, ValidationError::EmptyMenu { .. })));
663
664 let bad = r#"
665schema_version: 1
666id: f
667name: n
668entry: m
669nodes:
670 m:
671 kind: menu
672 prompt: hi
673 options: { A: nope }
674 exits: { A: bye, no_input: bye, invalid: bye }
675 bye:
676 kind: hangup
677"#;
678 assert!(validate(&parse(bad))
679 .unwrap_err()
680 .iter()
681 .any(|e| matches!(e, ValidationError::BadDigit { key, .. } if key == "A")));
682 }
683
684 #[test]
685 fn rejects_empty_transfer_target() {
686 let src = r#"
687schema_version: 1
688id: f
689name: n
690entry: t
691nodes:
692 t:
693 kind: transfer
694 target: " "
695"#;
696 assert!(validate(&parse(src))
697 .unwrap_err()
698 .iter()
699 .any(|e| matches!(e, ValidationError::EmptyTransferTarget { .. })));
700 }
701
702 #[test]
703 fn rejects_overlong_prompt() {
704 let long = "a".repeat(MAX_PROMPT_CHARS + 1);
705 let src = format!(
706 r#"
707schema_version: 1
708id: f
709name: n
710entry: g
711nodes:
712 g:
713 kind: greeting
714 prompt: {long}
715 exits: {{ next: bye }}
716 bye:
717 kind: hangup
718"#
719 );
720 assert!(validate(&parse(&src))
721 .unwrap_err()
722 .iter()
723 .any(|e| matches!(e, ValidationError::PromptTooLong { .. })));
724 }
725
726 #[test]
727 fn surfaces_hours_config_errors() {
728 let src = r#"
729schema_version: 1
730id: f
731name: n
732entry: h
733nodes:
734 h:
735 kind: hours
736 timezone: Mars/Base
737 schedule: {}
738 exits: { open: bye, closed: bye }
739 bye:
740 kind: hangup
741"#;
742 assert!(validate(&parse(src))
743 .unwrap_err()
744 .iter()
745 .any(|e| matches!(e, ValidationError::Hours { .. })));
746 }
747}