1use std::collections::{BTreeSet, VecDeque};
16
17use crate::hours::{self, HoursError};
18use crate::model::{Flow, Node, Prompt};
19use crate::model_ext::NodeId;
20use crate::SUPPORTED_SCHEMA_VERSIONS;
21
22const MAX_PROMPT_CHARS: usize = 2000;
27
28const VALID_DIGITS: &[&str] = &["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "*", "#"];
31
32#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
35pub enum ValidationError {
36 #[error("schema_version {0} is not supported by this app")]
37 UnsupportedSchemaVersion(i64),
38
39 #[error("flow has no nodes")]
40 EmptyFlow,
41
42 #[error("entry {0:?} is not a node")]
43 MissingEntry(NodeId),
44
45 #[error("node {node:?} exit {exit:?} points at {target:?}, which is not a node")]
46 UnknownExitTarget {
47 node: NodeId,
48 exit: String,
49 target: NodeId,
50 },
51
52 #[error("node {node:?} ({kind}) is missing required exits {missing:?}")]
53 MissingExits {
54 node: NodeId,
55 kind: &'static str,
56 missing: Vec<String>,
57 },
58
59 #[error("node {node:?} ({kind}) has exits {unexpected:?} it does not define")]
60 UnexpectedExits {
61 node: NodeId,
62 kind: &'static str,
63 unexpected: Vec<String>,
64 },
65
66 #[error("menu node {node:?} offers no options")]
67 EmptyMenu { node: NodeId },
68
69 #[error("menu node {node:?} option key {key:?} is not a DTMF digit (0-9, *, #)")]
70 BadDigit { node: NodeId, key: String },
71
72 #[error("hours node {node:?}: {source}")]
73 Hours { node: NodeId, source: HoursError },
74
75 #[error("transfer node {node:?} has an empty target")]
76 EmptyTransferTarget { node: NodeId },
77
78 #[error("node {node:?} prompt is {len} chars (max {MAX_PROMPT_CHARS})")]
79 PromptTooLong { node: NodeId, len: usize },
80
81 #[error("node {node:?} is unreachable from entry")]
82 Unreachable { node: NodeId },
83
84 #[error("node {node:?} can never reach a way to end the call (caller trapped)")]
85 Trapped { node: NodeId },
86}
87
88impl ValidationError {
89 pub fn code(&self) -> &'static str {
95 match self {
96 ValidationError::UnsupportedSchemaVersion(_) => "unsupported_schema_version",
97 ValidationError::EmptyFlow => "empty_flow",
98 ValidationError::MissingEntry(_) => "missing_entry",
99 ValidationError::UnknownExitTarget { .. } => "unknown_target",
100 ValidationError::MissingExits { .. } => "missing_exits",
101 ValidationError::UnexpectedExits { .. } => "unexpected_exits",
102 ValidationError::EmptyMenu { .. } => "empty_menu",
103 ValidationError::BadDigit { .. } => "bad_digit",
104 ValidationError::Hours { source, .. } => source.code(),
107 ValidationError::EmptyTransferTarget { .. } => "empty_transfer_target",
108 ValidationError::PromptTooLong { .. } => "prompt_too_long",
109 ValidationError::Unreachable { .. } => "unreachable",
110 ValidationError::Trapped { .. } => "trapped",
111 }
112 }
113}
114
115pub fn validate(flow: &Flow) -> Result<(), Vec<ValidationError>> {
118 let mut errs = Vec::new();
119
120 if !SUPPORTED_SCHEMA_VERSIONS
121 .iter()
122 .any(|&v| i64::from(v) == flow.schema_version)
123 {
124 errs.push(ValidationError::UnsupportedSchemaVersion(
125 flow.schema_version,
126 ));
127 }
128
129 if flow.nodes.is_empty() {
130 errs.push(ValidationError::EmptyFlow);
131 return Err(errs); }
133
134 let entry_exists = flow.nodes.contains_key(&flow.entry);
135 if !entry_exists {
136 errs.push(ValidationError::MissingEntry(flow.entry.clone()));
137 }
138
139 for (id, node) in &flow.nodes {
140 check_exits(id, node, flow, &mut errs);
141 check_node(id, node, &mut errs);
142 }
143
144 if entry_exists {
146 check_graph(flow, &mut errs);
147 }
148
149 if errs.is_empty() {
150 Ok(())
151 } else {
152 Err(errs)
153 }
154}
155
156fn check_exits(id: &NodeId, node: &Node, flow: &Flow, errs: &mut Vec<ValidationError>) {
159 let kind = node.kind();
160 let required: BTreeSet<String> = node.required_exits().into_iter().collect();
161 let present: BTreeSet<String> = node
162 .exits()
163 .map(|e| e.keys().cloned().collect())
164 .unwrap_or_default();
165
166 let missing: Vec<String> = required.difference(&present).cloned().collect();
167 if !missing.is_empty() {
168 errs.push(ValidationError::MissingExits {
169 node: id.clone(),
170 kind,
171 missing,
172 });
173 }
174 let unexpected: Vec<String> = present.difference(&required).cloned().collect();
175 if !unexpected.is_empty() {
176 errs.push(ValidationError::UnexpectedExits {
177 node: id.clone(),
178 kind,
179 unexpected,
180 });
181 }
182
183 for (exit, target) in node.exits().into_iter().flatten() {
184 if !flow.nodes.contains_key(target) {
185 errs.push(ValidationError::UnknownExitTarget {
186 node: id.clone(),
187 exit: exit.clone(),
188 target: target.clone(),
189 });
190 }
191 }
192}
193
194fn check_node(id: &NodeId, node: &Node, errs: &mut Vec<ValidationError>) {
196 match node {
197 Node::Greeting { prompt, .. } => check_prompt(id, prompt, errs),
198 Node::Hours {
199 schedule,
200 timezone,
201 exceptions,
202 ..
203 } => {
204 if let Err(source) = hours::validate_config(schedule, timezone, exceptions) {
205 errs.push(ValidationError::Hours {
206 node: id.clone(),
207 source,
208 });
209 }
210 }
211 Node::Menu {
212 prompt, options, ..
213 } => {
214 check_prompt(id, prompt, errs);
215 if options.is_empty() {
216 errs.push(ValidationError::EmptyMenu { node: id.clone() });
217 }
218 for key in options.keys() {
219 if !VALID_DIGITS.contains(&key.as_str()) {
220 errs.push(ValidationError::BadDigit {
221 node: id.clone(),
222 key: key.clone(),
223 });
224 }
225 }
226 }
227 Node::Ring { .. } => {}
228 Node::Message { prompt, .. } => check_prompt(id, prompt, errs),
229 Node::Transfer { target, .. } => {
230 if target.trim().is_empty() {
231 errs.push(ValidationError::EmptyTransferTarget { node: id.clone() });
232 }
233 }
234 Node::Hangup { prompt, .. } => {
235 if let Some(p) = prompt {
236 check_prompt(id, p, errs);
237 }
238 }
239 }
240}
241
242fn check_prompt(id: &NodeId, prompt: &Prompt, errs: &mut Vec<ValidationError>) {
243 if let Some(text) = prompt.as_text() {
244 let len = text.chars().count();
245 if len > MAX_PROMPT_CHARS {
246 errs.push(ValidationError::PromptTooLong {
247 node: id.clone(),
248 len,
249 });
250 }
251 }
252}
253
254fn check_graph(flow: &Flow, errs: &mut Vec<ValidationError>) {
256 let mut reachable: BTreeSet<&str> = BTreeSet::new();
258 let mut queue: VecDeque<&str> = VecDeque::new();
259 queue.push_back(flow.entry.as_str());
260 reachable.insert(flow.entry.as_str());
261 while let Some(id) = queue.pop_front() {
262 let Some(node) = flow.nodes.get(id) else {
263 continue;
264 };
265 for (_, target) in node.exits().into_iter().flatten() {
266 if flow.nodes.contains_key(target) && reachable.insert(target.as_str()) {
267 queue.push_back(target.as_str());
268 }
269 }
270 }
271
272 for id in flow.nodes.keys() {
273 if !reachable.contains(id.as_str()) {
274 errs.push(ValidationError::Unreachable { node: id.clone() });
275 }
276 }
277
278 let mut can_end: BTreeSet<&str> = flow
282 .nodes
283 .iter()
284 .filter(|(_, n)| n.is_terminal())
285 .map(|(id, _)| id.as_str())
286 .collect();
287 loop {
288 let mut grew = false;
289 for (id, node) in &flow.nodes {
290 if can_end.contains(id.as_str()) {
291 continue;
292 }
293 if node
294 .exits()
295 .into_iter()
296 .flatten()
297 .any(|(_, t)| can_end.contains(t.as_str()))
298 && can_end.insert(id.as_str())
299 {
300 grew = true;
301 }
302 }
303 if !grew {
304 break;
305 }
306 }
307
308 for id in &reachable {
312 if !can_end.contains(id) {
313 errs.push(ValidationError::Trapped {
314 node: (*id).to_string(),
315 });
316 }
317 }
318}
319
320#[cfg(test)]
321mod tests {
322 use super::*;
323
324 const LUIGIS: &str = r#"
327schema_version: 1
328id: flow_9f2
329name: Luigi's
330entry: welcome
331nodes:
332 welcome:
333 kind: greeting
334 prompt: Thanks for calling Luigi's!
335 exits: { next: check_hours }
336 check_hours:
337 kind: hours
338 timezone: America/New_York
339 schedule:
340 tue: [{ open: "11:00", close: "22:00" }]
341 exits: { open: front_desk, closed: night_menu }
342 front_desk:
343 kind: ring
344 timeout_secs: 25
345 exits: { no_answer: take_message }
346 night_menu:
347 kind: menu
348 prompt: We're closed. Press 1 for hours.
349 options: { "1": Hours }
350 exits: { "1": say_hours, no_input: take_message, invalid: take_message }
351 say_hours:
352 kind: greeting
353 prompt: Open Tuesday to Sunday.
354 exits: { next: take_message }
355 take_message:
356 kind: message
357 prompt: Leave a message after the tone.
358"#;
359
360 fn parse(src: &str) -> Flow {
361 Flow::from_yaml(src).expect("test flow should parse")
362 }
363
364 #[test]
365 fn the_documented_example_validates() {
366 assert_eq!(validate(&parse(LUIGIS)), Ok(()));
367 }
368
369 #[test]
370 fn rejects_unsupported_schema_version() {
371 let f = parse(&LUIGIS.replace("schema_version: 1", "schema_version: 99"));
372 let errs = validate(&f).unwrap_err();
373 assert!(errs.contains(&ValidationError::UnsupportedSchemaVersion(99)));
374 }
375
376 #[test]
377 fn rejects_missing_entry() {
378 let f = parse(&LUIGIS.replace("entry: welcome", "entry: nope"));
379 let errs = validate(&f).unwrap_err();
380 assert!(errs
381 .iter()
382 .any(|e| matches!(e, ValidationError::MissingEntry(n) if n == "nope")));
383 }
384
385 #[test]
386 fn rejects_dangling_exit_target() {
387 let f = parse(&LUIGIS.replace("next: check_hours", "next: ghost"));
388 let errs = validate(&f).unwrap_err();
389 assert!(errs.iter().any(|e| matches!(
390 e,
391 ValidationError::UnknownExitTarget { target, .. } if target == "ghost"
392 )));
393 }
394
395 #[test]
396 fn rejects_missing_required_exit() {
397 let src = r#"
399schema_version: 1
400id: f
401name: n
402entry: g
403nodes:
404 g:
405 kind: greeting
406 prompt: hi
407 bye:
408 kind: hangup
409"#;
410 let errs = validate(&parse(src)).unwrap_err();
411 assert!(errs.iter().any(|e| matches!(
412 e,
413 ValidationError::MissingExits { node, .. } if node == "g"
414 )));
415 }
416
417 #[test]
418 fn rejects_unexpected_exit_on_terminal() {
419 let src = r#"
420schema_version: 1
421id: f
422name: n
423entry: g
424nodes:
425 g:
426 kind: hangup
427 exits: { next: g }
428"#;
429 let errs = validate(&parse(src)).unwrap_err();
430 assert!(errs.iter().any(|e| matches!(
431 e,
432 ValidationError::UnexpectedExits { node, .. } if node == "g"
433 )));
434 }
435
436 #[test]
437 fn rejects_unreachable_node() {
438 let src = r#"
439schema_version: 1
440id: f
441name: n
442entry: g
443nodes:
444 g:
445 kind: hangup
446 orphan:
447 kind: hangup
448"#;
449 let errs = validate(&parse(src)).unwrap_err();
450 assert!(errs.iter().any(|e| matches!(
451 e,
452 ValidationError::Unreachable { node } if node == "orphan"
453 )));
454 }
455
456 #[test]
457 fn rejects_trapping_cycle() {
458 let src = r#"
460schema_version: 1
461id: f
462name: n
463entry: a
464nodes:
465 a:
466 kind: greeting
467 prompt: one
468 exits: { next: b }
469 b:
470 kind: greeting
471 prompt: two
472 exits: { next: a }
473"#;
474 let errs = validate(&parse(src)).unwrap_err();
475 assert!(
476 errs.iter()
477 .any(|e| matches!(e, ValidationError::Trapped { .. })),
478 "a terminal-less loop must be flagged as trapping: {errs:?}"
479 );
480 }
481
482 #[test]
483 fn a_loop_with_an_escape_is_fine() {
484 let src = r#"
487schema_version: 1
488id: f
489name: n
490entry: m
491nodes:
492 m:
493 kind: menu
494 prompt: press one
495 options: { "1": again }
496 exits: { "1": g, no_input: bye, invalid: bye }
497 g:
498 kind: greeting
499 prompt: again
500 exits: { next: m }
501 bye:
502 kind: hangup
503"#;
504 assert_eq!(validate(&parse(src)), Ok(()));
505 }
506
507 #[test]
508 fn rejects_empty_menu_and_bad_digit() {
509 let empty = r#"
510schema_version: 1
511id: f
512name: n
513entry: m
514nodes:
515 m:
516 kind: menu
517 prompt: hi
518 options: {}
519 exits: { no_input: bye, invalid: bye }
520 bye:
521 kind: hangup
522"#;
523 assert!(validate(&parse(empty))
524 .unwrap_err()
525 .iter()
526 .any(|e| matches!(e, ValidationError::EmptyMenu { .. })));
527
528 let bad = r#"
529schema_version: 1
530id: f
531name: n
532entry: m
533nodes:
534 m:
535 kind: menu
536 prompt: hi
537 options: { A: nope }
538 exits: { A: bye, no_input: bye, invalid: bye }
539 bye:
540 kind: hangup
541"#;
542 assert!(validate(&parse(bad))
543 .unwrap_err()
544 .iter()
545 .any(|e| matches!(e, ValidationError::BadDigit { key, .. } if key == "A")));
546 }
547
548 #[test]
549 fn rejects_empty_transfer_target() {
550 let src = r#"
551schema_version: 1
552id: f
553name: n
554entry: t
555nodes:
556 t:
557 kind: transfer
558 target: " "
559"#;
560 assert!(validate(&parse(src))
561 .unwrap_err()
562 .iter()
563 .any(|e| matches!(e, ValidationError::EmptyTransferTarget { .. })));
564 }
565
566 #[test]
567 fn rejects_overlong_prompt() {
568 let long = "a".repeat(MAX_PROMPT_CHARS + 1);
569 let src = format!(
570 r#"
571schema_version: 1
572id: f
573name: n
574entry: g
575nodes:
576 g:
577 kind: greeting
578 prompt: {long}
579 exits: {{ next: bye }}
580 bye:
581 kind: hangup
582"#
583 );
584 assert!(validate(&parse(&src))
585 .unwrap_err()
586 .iter()
587 .any(|e| matches!(e, ValidationError::PromptTooLong { .. })));
588 }
589
590 #[test]
591 fn surfaces_hours_config_errors() {
592 let src = r#"
593schema_version: 1
594id: f
595name: n
596entry: h
597nodes:
598 h:
599 kind: hours
600 timezone: Mars/Base
601 schedule: {}
602 exits: { open: bye, closed: bye }
603 bye:
604 kind: hangup
605"#;
606 assert!(validate(&parse(src))
607 .unwrap_err()
608 .iter()
609 .any(|e| matches!(e, ValidationError::Hours { .. })));
610 }
611}