1use std::time::Duration;
22
23use async_trait::async_trait;
24use time::OffsetDateTime;
25
26use crate::hours::{self, HoursError};
27use crate::model::{Flow, MessageTone, Node, Prompt};
28use crate::trace::{FlowOutcome, StepDetail, Trace};
29use crate::NodeId;
30
31const MAX_STEPS: u32 = 100;
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum Digit {
40 D0,
41 D1,
42 D2,
43 D3,
44 D4,
45 D5,
46 D6,
47 D7,
48 D8,
49 D9,
50 Star,
51 Hash,
52}
53
54impl Digit {
55 pub fn as_key(self) -> &'static str {
58 match self {
59 Digit::D0 => "0",
60 Digit::D1 => "1",
61 Digit::D2 => "2",
62 Digit::D3 => "3",
63 Digit::D4 => "4",
64 Digit::D5 => "5",
65 Digit::D6 => "6",
66 Digit::D7 => "7",
67 Digit::D8 => "8",
68 Digit::D9 => "9",
69 Digit::Star => "*",
70 Digit::Hash => "#",
71 }
72 }
73}
74
75#[derive(Debug, Clone, Copy, PartialEq, Eq)]
77pub enum RingOutcome {
78 Answered,
80 NoAnswer,
82}
83
84#[async_trait]
90pub trait FlowEffects: Send {
91 async fn speak(&mut self, prompt: &Prompt) -> anyhow::Result<()>;
94
95 async fn collect_digit(&mut self, timeout: Duration) -> anyhow::Result<Option<Digit>>;
98
99 async fn ring_human(&mut self, timeout: Duration) -> anyhow::Result<RingOutcome>;
101
102 async fn record_message(&mut self, tone: MessageTone, max: Duration) -> anyhow::Result<u32>;
107
108 async fn transfer(&mut self, target: &str) -> anyhow::Result<()>;
110
111 async fn hangup(&mut self, prompt: Option<&Prompt>) -> anyhow::Result<()>;
113
114 fn now(&self) -> OffsetDateTime;
117
118 fn on_enter(&mut self, _node: &NodeId, _kind: &'static str) {}
129}
130
131#[derive(Debug, thiserror::Error)]
135enum EngineError {
136 #[error("effect failed: {0}")]
137 Effect(anyhow::Error),
138 #[error("node {0:?} not found")]
139 UnknownNode(NodeId),
140 #[error("node {node:?} has no {exit:?} exit")]
141 MissingExit { node: NodeId, exit: String },
142 #[error("hours evaluation at {node:?} failed: {source}")]
143 Hours { node: NodeId, source: HoursError },
144}
145
146impl EngineError {
147 fn outcome(&self) -> FlowOutcome {
150 match self {
151 EngineError::Effect(_) => FlowOutcome::Aborted,
152 _ => FlowOutcome::Defect,
153 }
154 }
155}
156
157enum Step {
159 Goto(NodeId),
160 End(FlowOutcome),
161}
162
163pub async fn run<E: FlowEffects>(flow: &Flow, fx: &mut E, trace: &mut Trace) {
177 let mut current = flow.entry.clone();
178
179 for _ in 0..MAX_STEPS {
180 let node = match flow.nodes.get(¤t) {
181 Some(n) => n,
182 None => return abort(trace, EngineError::UnknownNode(current)),
183 };
184 match run_node(¤t, node, fx, trace).await {
185 Ok(Step::Goto(next)) => current = next,
186 Ok(Step::End(outcome)) => {
187 trace.outcome = outcome;
188 return;
189 }
190 Err(err) => return abort(trace, err),
191 }
192 }
193
194 trace.outcome = FlowOutcome::Defect;
196 trace.error = Some(format!(
197 "step cap {MAX_STEPS} exceeded — cycle in an unvalidated flow?"
198 ));
199}
200
201fn abort(trace: &mut Trace, err: EngineError) {
203 trace.outcome = err.outcome();
204 trace.error = Some(err.to_string());
205}
206
207async fn run_node<E: FlowEffects>(
208 id: &NodeId,
209 node: &Node,
210 fx: &mut E,
211 trace: &mut Trace,
212) -> Result<Step, EngineError> {
213 let kind = node.kind();
214 fx.on_enter(id, kind);
218 match node {
219 Node::Greeting { prompt, .. } => {
220 fx.speak(prompt).await.map_err(EngineError::Effect)?;
221 trace.push(id, kind, StepDetail::Spoke);
222 goto(id, node, "next")
223 }
224
225 Node::Hours { .. } => {
226 let result = hours::evaluate(node, fx.now()).map_err(|source| EngineError::Hours {
227 node: id.clone(),
228 source,
229 })?;
230 let open = result == hours::HoursResult::Open;
231 trace.push(id, kind, StepDetail::Hours { open });
232 goto(id, node, result.exit())
233 }
234
235 Node::Menu {
236 prompt,
237 options,
238 retries,
239 timeout_secs,
240 ..
241 } => {
242 run_menu(
243 id,
244 node,
245 fx,
246 trace,
247 prompt,
248 options,
249 *retries,
250 *timeout_secs,
251 )
252 .await
253 }
254
255 Node::Ring { timeout_secs, .. } => {
256 let outcome = fx
257 .ring_human(Duration::from_secs(*timeout_secs))
258 .await
259 .map_err(EngineError::Effect)?;
260 match outcome {
261 RingOutcome::Answered => {
262 trace.push(id, kind, StepDetail::Ring { answered: true });
263 Ok(Step::End(FlowOutcome::Answered))
264 }
265 RingOutcome::NoAnswer => {
266 trace.push(id, kind, StepDetail::Ring { answered: false });
267 goto(id, node, "no_answer")
268 }
269 }
270 }
271
272 Node::Message {
273 prompt,
274 max_secs,
275 tone,
276 ..
277 } => {
278 fx.speak(prompt).await.map_err(EngineError::Effect)?;
282 trace.push(id, kind, StepDetail::Spoke);
283 let secs = fx
284 .record_message(*tone, Duration::from_secs(*max_secs))
285 .await
286 .map_err(EngineError::Effect)?;
287 trace.push(id, kind, StepDetail::MessageRecorded { secs });
288 Ok(Step::End(FlowOutcome::MessageLeft))
289 }
290
291 Node::Transfer { target, .. } => {
292 fx.transfer(target).await.map_err(EngineError::Effect)?;
293 trace.push(
294 id,
295 kind,
296 StepDetail::Transferred {
297 target: target.clone(),
298 },
299 );
300 Ok(Step::End(FlowOutcome::Transferred))
301 }
302
303 Node::Hangup { prompt, .. } => {
304 fx.hangup(prompt.as_ref())
305 .await
306 .map_err(EngineError::Effect)?;
307 trace.push(id, kind, StepDetail::HungUp);
308 Ok(Step::End(FlowOutcome::HungUp))
309 }
310 }
311}
312
313#[allow(clippy::too_many_arguments)]
318async fn run_menu<E: FlowEffects>(
319 id: &NodeId,
320 node: &Node,
321 fx: &mut E,
322 trace: &mut Trace,
323 prompt: &Prompt,
324 options: &std::collections::HashMap<String, String>,
325 retries: u64,
326 timeout_secs: u64,
327) -> Result<Step, EngineError> {
328 let attempts = retries.saturating_add(1);
329 let mut heard_any_key = false;
330
331 for _ in 0..attempts {
332 fx.speak(prompt).await.map_err(EngineError::Effect)?;
333 let pressed = fx
334 .collect_digit(Duration::from_secs(timeout_secs))
335 .await
336 .map_err(EngineError::Effect)?;
337 match pressed {
338 Some(digit) if options.contains_key(digit.as_key()) => {
339 trace.push(
340 id,
341 "menu",
342 StepDetail::MenuChoice {
343 digit: digit.as_key().to_string(),
344 },
345 );
346 return goto(id, node, digit.as_key());
347 }
348 Some(_) => heard_any_key = true, None => {} }
351 }
352
353 if heard_any_key {
354 trace.push(id, "menu", StepDetail::MenuInvalid);
355 goto(id, node, "invalid")
356 } else {
357 trace.push(id, "menu", StepDetail::MenuNoInput);
358 goto(id, node, "no_input")
359 }
360}
361
362fn goto(id: &NodeId, node: &Node, exit: &str) -> Result<Step, EngineError> {
365 node.exits()
366 .and_then(|exits| exits.get(exit))
367 .map(|target| Step::Goto(target.clone()))
368 .ok_or_else(|| EngineError::MissingExit {
369 node: id.clone(),
370 exit: exit.to_string(),
371 })
372}
373
374#[cfg(test)]
375mod tests {
376 use std::collections::VecDeque;
377
378 use time::macros::datetime;
379
380 use super::*;
381 use crate::trace::FlowOutcome;
382 use crate::validate::validate;
383
384 struct MockEffects {
389 now: OffsetDateTime,
390 digits: VecDeque<Option<Digit>>,
391 ring: RingOutcome,
392 message_secs: u32,
393 spoken: Vec<String>,
395 transferred: Option<String>,
396 recorded: bool,
397 record_tone: Option<MessageTone>,
399 hung_up: bool,
400 fail_speak: bool,
401 entered: Vec<(String, &'static str)>,
404 }
405
406 impl MockEffects {
407 fn new(now: OffsetDateTime) -> Self {
408 MockEffects {
409 now,
410 digits: VecDeque::new(),
411 ring: RingOutcome::NoAnswer,
412 message_secs: 0,
413 spoken: Vec::new(),
414 transferred: None,
415 recorded: false,
416 record_tone: None,
417 hung_up: false,
418 fail_speak: false,
419 entered: Vec::new(),
420 }
421 }
422 fn digits(mut self, seq: impl IntoIterator<Item = Option<Digit>>) -> Self {
423 self.digits = seq.into_iter().collect();
424 self
425 }
426 fn ring(mut self, r: RingOutcome) -> Self {
427 self.ring = r;
428 self
429 }
430 fn message_secs(mut self, s: u32) -> Self {
431 self.message_secs = s;
432 self
433 }
434 }
435
436 fn prompt_label(p: &Prompt) -> String {
437 match p.as_text() {
438 Some(t) => t.to_string(),
439 None => "<audio>".to_string(),
440 }
441 }
442
443 #[async_trait]
444 impl FlowEffects for MockEffects {
445 async fn speak(&mut self, prompt: &Prompt) -> anyhow::Result<()> {
446 if self.fail_speak {
447 anyhow::bail!("caller hung up");
448 }
449 self.spoken.push(prompt_label(prompt));
450 Ok(())
451 }
452 async fn collect_digit(&mut self, _timeout: Duration) -> anyhow::Result<Option<Digit>> {
453 Ok(self.digits.pop_front().flatten())
455 }
456 async fn ring_human(&mut self, _timeout: Duration) -> anyhow::Result<RingOutcome> {
457 Ok(self.ring)
458 }
459 async fn record_message(
460 &mut self,
461 tone: MessageTone,
462 _max: Duration,
463 ) -> anyhow::Result<u32> {
464 self.recorded = true;
465 self.record_tone = Some(tone);
466 Ok(self.message_secs)
467 }
468 async fn transfer(&mut self, target: &str) -> anyhow::Result<()> {
469 self.transferred = Some(target.to_string());
470 Ok(())
471 }
472 async fn hangup(&mut self, prompt: Option<&Prompt>) -> anyhow::Result<()> {
473 if let Some(p) = prompt {
474 self.spoken.push(prompt_label(p));
475 }
476 self.hung_up = true;
477 Ok(())
478 }
479 fn now(&self) -> OffsetDateTime {
480 self.now
481 }
482 fn on_enter(&mut self, node: &NodeId, kind: &'static str) {
483 self.entered.push((node.clone(), kind));
484 }
485 }
486
487 const LUIGIS: &str = r#"
488schema_version: 1
489id: flow_luigi
490name: Luigi's — after hours
491version: 3
492entry: welcome
493nodes:
494 welcome:
495 kind: greeting
496 prompt: Thanks for calling Luigi's!
497 exits: { next: check_hours }
498 check_hours:
499 kind: hours
500 timezone: America/New_York
501 schedule:
502 tue: [{ open: "11:00", close: "22:00" }]
503 exits: { open: front_desk, closed: night_menu }
504 front_desk:
505 kind: ring
506 timeout_secs: 25
507 exits: { no_answer: take_message }
508 night_menu:
509 kind: menu
510 prompt: We're closed. Press 1 for hours, or hold for a message.
511 options: { "1": Hours }
512 retries: 1
513 exits: { "1": say_hours, no_input: take_message, invalid: take_message }
514 say_hours:
515 kind: greeting
516 prompt: We're open Tuesday to Sunday, eleven to ten.
517 exits: { next: take_message }
518 take_message:
519 kind: message
520 prompt: Please leave your name and number after the tone.
521"#;
522
523 fn luigis() -> Flow {
524 let flow = Flow::from_yaml(LUIGIS).expect("parses");
525 validate(&flow).expect("the scenario flow must be valid");
526 flow
527 }
528
529 fn open_time() -> OffsetDateTime {
531 datetime!(2026-07-07 19:00 UTC)
532 }
533 fn closed_time() -> OffsetDateTime {
535 datetime!(2026-07-08 03:00 UTC)
536 }
537
538 fn kinds(trace: &Trace) -> Vec<&str> {
539 trace.steps.iter().map(|s| s.kind).collect()
540 }
541
542 async fn run_trace(flow: &Flow, fx: &mut MockEffects) -> Trace {
545 let mut trace = Trace::new(&flow.id, flow.version);
546 run(flow, fx, &mut trace).await;
547 trace
548 }
549
550 #[tokio::test]
551 async fn open_hours_human_answers() {
552 let mut fx = MockEffects::new(open_time()).ring(RingOutcome::Answered);
553 let trace = run_trace(&luigis(), &mut fx).await;
554
555 assert_eq!(trace.outcome, FlowOutcome::Answered);
556 assert!(trace.is_clean());
557 assert_eq!(kinds(&trace), vec!["greeting", "hours", "ring"]);
558 assert_eq!(trace.steps[1].detail, StepDetail::Hours { open: true });
560 assert!(!fx.recorded, "a human answered — no voicemail");
561 }
562
563 #[tokio::test]
564 async fn on_enter_reports_each_node_as_the_engine_reaches_it() {
565 let mut fx = MockEffects::new(open_time()).ring(RingOutcome::Answered);
569 let _ = run_trace(&luigis(), &mut fx).await;
570 assert_eq!(
571 fx.entered,
572 vec![
573 ("welcome".to_string(), "greeting"),
574 ("check_hours".to_string(), "hours"),
575 ("front_desk".to_string(), "ring"),
576 ],
577 "on_enter fires once per visited node, in path order"
578 );
579
580 let mut fx = MockEffects::new(closed_time())
583 .digits([Some(Digit::D1)])
584 .message_secs(12);
585 let trace = run_trace(&luigis(), &mut fx).await;
586 let entered_ids: Vec<&str> = fx.entered.iter().map(|(id, _)| id.as_str()).collect();
587 assert_eq!(
588 entered_ids,
589 vec![
590 "welcome",
591 "check_hours",
592 "night_menu",
593 "say_hours",
594 "take_message"
595 ],
596 );
597 assert_eq!(
600 fx.entered
601 .iter()
602 .filter(|(id, _)| id == "night_menu")
603 .count(),
604 1,
605 );
606 let mut trace_nodes: Vec<&str> = trace.steps.iter().map(|s| s.node.as_str()).collect();
610 trace_nodes.dedup();
611 assert_eq!(entered_ids, trace_nodes);
612 }
613
614 #[tokio::test]
615 async fn open_hours_no_answer_falls_to_voicemail() {
616 let mut fx = MockEffects::new(open_time())
617 .ring(RingOutcome::NoAnswer)
618 .message_secs(40);
619 let trace = run_trace(&luigis(), &mut fx).await;
620
621 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
622 assert_eq!(
623 kinds(&trace),
624 vec!["greeting", "hours", "ring", "message", "message"]
625 );
626 assert_eq!(trace.steps[3].detail, StepDetail::Spoke);
630 assert_eq!(
631 trace.steps[4].detail,
632 StepDetail::MessageRecorded { secs: 40 }
633 );
634 assert!(fx.spoken.iter().any(|s| s.contains("leave your name")));
636 assert!(fx.recorded);
637 assert_eq!(fx.record_tone, Some(MessageTone::Beep));
639 }
640
641 #[tokio::test]
642 async fn closed_press_one_hears_hours_then_leaves_message() {
643 let mut fx = MockEffects::new(closed_time())
644 .digits([Some(Digit::D1)])
645 .message_secs(12);
646 let trace = run_trace(&luigis(), &mut fx).await;
647
648 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
649 assert_eq!(
650 kinds(&trace),
651 vec!["greeting", "hours", "menu", "greeting", "message", "message"]
652 );
653 assert_eq!(trace.steps[1].detail, StepDetail::Hours { open: false });
654 assert_eq!(
655 trace.steps[2].detail,
656 StepDetail::MenuChoice { digit: "1".into() }
657 );
658 assert!(fx
660 .spoken
661 .iter()
662 .any(|s| s.contains("open Tuesday to Sunday")));
663 }
664
665 #[tokio::test]
666 async fn closed_silence_takes_no_input_exit() {
667 let mut fx = MockEffects::new(closed_time());
669 let trace = run_trace(&luigis(), &mut fx).await;
670
671 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
672 assert_eq!(
673 kinds(&trace),
674 vec!["greeting", "hours", "menu", "message", "message"]
675 );
676 assert_eq!(trace.steps[2].detail, StepDetail::MenuNoInput);
677 }
678
679 #[tokio::test]
680 async fn closed_wrong_keys_take_invalid_exit_after_retry() {
681 let mut fx = MockEffects::new(closed_time()).digits([Some(Digit::D9), Some(Digit::D7)]);
683 let trace = run_trace(&luigis(), &mut fx).await;
684
685 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
686 assert_eq!(trace.steps[2].detail, StepDetail::MenuInvalid);
687 let menu_prompts = fx
689 .spoken
690 .iter()
691 .filter(|s| s.contains("Press 1 for hours"))
692 .count();
693 assert_eq!(menu_prompts, 2);
694 }
695
696 #[tokio::test]
697 async fn wrong_key_then_valid_digit_still_routes() {
698 let mut fx = MockEffects::new(closed_time())
700 .digits([Some(Digit::D9), Some(Digit::D1)])
701 .message_secs(5);
702 let trace = run_trace(&luigis(), &mut fx).await;
703
704 assert_eq!(
705 trace.steps[2].detail,
706 StepDetail::MenuChoice { digit: "1".into() }
707 );
708 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
709 }
710
711 #[tokio::test]
712 async fn steps_carry_monotonic_timeline_offsets() {
713 let mut fx = MockEffects::new(closed_time()).digits([Some(Digit::D1)]);
718 let trace = run_trace(&luigis(), &mut fx).await;
719
720 assert!(trace.steps.len() >= 3);
721 let offsets: Vec<u64> = trace.steps.iter().map(|s| s.at_ms).collect();
722 assert!(
723 offsets.windows(2).all(|w| w[0] <= w[1]),
724 "offsets must be non-decreasing: {offsets:?}"
725 );
726 }
727
728 #[tokio::test]
729 async fn effect_failure_aborts_with_partial_trace() {
730 let mut fx = MockEffects::new(open_time());
731 fx.fail_speak = true; let trace = run_trace(&luigis(), &mut fx).await;
733
734 assert_eq!(trace.outcome, FlowOutcome::Aborted);
735 assert!(!trace.is_clean());
736 assert!(trace.error.as_deref().unwrap().contains("caller hung up"));
737 assert!(trace.steps.is_empty());
739 }
740
741 #[tokio::test]
742 async fn transfer_and_hangup_terminals() {
743 let src = r#"
744schema_version: 1
745id: f
746name: n
747entry: g
748nodes:
749 g:
750 kind: greeting
751 prompt: one moment
752 exits: { next: t }
753 t:
754 kind: transfer
755 target: sip:desk@example.com
756"#;
757 let flow = Flow::from_yaml(src).unwrap();
758 validate(&flow).unwrap();
759 let mut fx = MockEffects::new(open_time());
760 let trace = run_trace(&flow, &mut fx).await;
761 assert_eq!(trace.outcome, FlowOutcome::Transferred);
762 assert_eq!(fx.transferred.as_deref(), Some("sip:desk@example.com"));
763 }
764}