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
119#[derive(Debug, thiserror::Error)]
123enum EngineError {
124 #[error("effect failed: {0}")]
125 Effect(anyhow::Error),
126 #[error("node {0:?} not found")]
127 UnknownNode(NodeId),
128 #[error("node {node:?} has no {exit:?} exit")]
129 MissingExit { node: NodeId, exit: String },
130 #[error("hours evaluation at {node:?} failed: {source}")]
131 Hours { node: NodeId, source: HoursError },
132}
133
134impl EngineError {
135 fn outcome(&self) -> FlowOutcome {
138 match self {
139 EngineError::Effect(_) => FlowOutcome::Aborted,
140 _ => FlowOutcome::Defect,
141 }
142 }
143}
144
145enum Step {
147 Goto(NodeId),
148 End(FlowOutcome),
149}
150
151pub async fn run<E: FlowEffects>(flow: &Flow, fx: &mut E, trace: &mut Trace) {
165 let mut current = flow.entry.clone();
166
167 for _ in 0..MAX_STEPS {
168 let node = match flow.nodes.get(¤t) {
169 Some(n) => n,
170 None => return abort(trace, EngineError::UnknownNode(current)),
171 };
172 match run_node(¤t, node, fx, trace).await {
173 Ok(Step::Goto(next)) => current = next,
174 Ok(Step::End(outcome)) => {
175 trace.outcome = outcome;
176 return;
177 }
178 Err(err) => return abort(trace, err),
179 }
180 }
181
182 trace.outcome = FlowOutcome::Defect;
184 trace.error = Some(format!(
185 "step cap {MAX_STEPS} exceeded — cycle in an unvalidated flow?"
186 ));
187}
188
189fn abort(trace: &mut Trace, err: EngineError) {
191 trace.outcome = err.outcome();
192 trace.error = Some(err.to_string());
193}
194
195async fn run_node<E: FlowEffects>(
196 id: &NodeId,
197 node: &Node,
198 fx: &mut E,
199 trace: &mut Trace,
200) -> Result<Step, EngineError> {
201 let kind = node.kind();
202 match node {
203 Node::Greeting { prompt, .. } => {
204 fx.speak(prompt).await.map_err(EngineError::Effect)?;
205 trace.push(id, kind, StepDetail::Spoke);
206 goto(id, node, "next")
207 }
208
209 Node::Hours { .. } => {
210 let result = hours::evaluate(node, fx.now()).map_err(|source| EngineError::Hours {
211 node: id.clone(),
212 source,
213 })?;
214 let open = result == hours::HoursResult::Open;
215 trace.push(id, kind, StepDetail::Hours { open });
216 goto(id, node, result.exit())
217 }
218
219 Node::Menu {
220 prompt,
221 options,
222 retries,
223 timeout_secs,
224 ..
225 } => {
226 run_menu(
227 id,
228 node,
229 fx,
230 trace,
231 prompt,
232 options,
233 *retries,
234 *timeout_secs,
235 )
236 .await
237 }
238
239 Node::Ring { timeout_secs, .. } => {
240 let outcome = fx
241 .ring_human(Duration::from_secs(*timeout_secs))
242 .await
243 .map_err(EngineError::Effect)?;
244 match outcome {
245 RingOutcome::Answered => {
246 trace.push(id, kind, StepDetail::Ring { answered: true });
247 Ok(Step::End(FlowOutcome::Answered))
248 }
249 RingOutcome::NoAnswer => {
250 trace.push(id, kind, StepDetail::Ring { answered: false });
251 goto(id, node, "no_answer")
252 }
253 }
254 }
255
256 Node::Message {
257 prompt,
258 max_secs,
259 tone,
260 ..
261 } => {
262 fx.speak(prompt).await.map_err(EngineError::Effect)?;
266 trace.push(id, kind, StepDetail::Spoke);
267 let secs = fx
268 .record_message(*tone, Duration::from_secs(*max_secs))
269 .await
270 .map_err(EngineError::Effect)?;
271 trace.push(id, kind, StepDetail::MessageRecorded { secs });
272 Ok(Step::End(FlowOutcome::MessageLeft))
273 }
274
275 Node::Transfer { target, .. } => {
276 fx.transfer(target).await.map_err(EngineError::Effect)?;
277 trace.push(
278 id,
279 kind,
280 StepDetail::Transferred {
281 target: target.clone(),
282 },
283 );
284 Ok(Step::End(FlowOutcome::Transferred))
285 }
286
287 Node::Hangup { prompt, .. } => {
288 fx.hangup(prompt.as_ref())
289 .await
290 .map_err(EngineError::Effect)?;
291 trace.push(id, kind, StepDetail::HungUp);
292 Ok(Step::End(FlowOutcome::HungUp))
293 }
294 }
295}
296
297#[allow(clippy::too_many_arguments)]
302async fn run_menu<E: FlowEffects>(
303 id: &NodeId,
304 node: &Node,
305 fx: &mut E,
306 trace: &mut Trace,
307 prompt: &Prompt,
308 options: &std::collections::HashMap<String, String>,
309 retries: u64,
310 timeout_secs: u64,
311) -> Result<Step, EngineError> {
312 let attempts = retries.saturating_add(1);
313 let mut heard_any_key = false;
314
315 for _ in 0..attempts {
316 fx.speak(prompt).await.map_err(EngineError::Effect)?;
317 let pressed = fx
318 .collect_digit(Duration::from_secs(timeout_secs))
319 .await
320 .map_err(EngineError::Effect)?;
321 match pressed {
322 Some(digit) if options.contains_key(digit.as_key()) => {
323 trace.push(
324 id,
325 "menu",
326 StepDetail::MenuChoice {
327 digit: digit.as_key().to_string(),
328 },
329 );
330 return goto(id, node, digit.as_key());
331 }
332 Some(_) => heard_any_key = true, None => {} }
335 }
336
337 if heard_any_key {
338 trace.push(id, "menu", StepDetail::MenuInvalid);
339 goto(id, node, "invalid")
340 } else {
341 trace.push(id, "menu", StepDetail::MenuNoInput);
342 goto(id, node, "no_input")
343 }
344}
345
346fn goto(id: &NodeId, node: &Node, exit: &str) -> Result<Step, EngineError> {
349 node.exits()
350 .and_then(|exits| exits.get(exit))
351 .map(|target| Step::Goto(target.clone()))
352 .ok_or_else(|| EngineError::MissingExit {
353 node: id.clone(),
354 exit: exit.to_string(),
355 })
356}
357
358#[cfg(test)]
359mod tests {
360 use std::collections::VecDeque;
361
362 use time::macros::datetime;
363
364 use super::*;
365 use crate::trace::FlowOutcome;
366 use crate::validate::validate;
367
368 struct MockEffects {
373 now: OffsetDateTime,
374 digits: VecDeque<Option<Digit>>,
375 ring: RingOutcome,
376 message_secs: u32,
377 spoken: Vec<String>,
379 transferred: Option<String>,
380 recorded: bool,
381 record_tone: Option<MessageTone>,
383 hung_up: bool,
384 fail_speak: bool,
385 }
386
387 impl MockEffects {
388 fn new(now: OffsetDateTime) -> Self {
389 MockEffects {
390 now,
391 digits: VecDeque::new(),
392 ring: RingOutcome::NoAnswer,
393 message_secs: 0,
394 spoken: Vec::new(),
395 transferred: None,
396 recorded: false,
397 record_tone: None,
398 hung_up: false,
399 fail_speak: false,
400 }
401 }
402 fn digits(mut self, seq: impl IntoIterator<Item = Option<Digit>>) -> Self {
403 self.digits = seq.into_iter().collect();
404 self
405 }
406 fn ring(mut self, r: RingOutcome) -> Self {
407 self.ring = r;
408 self
409 }
410 fn message_secs(mut self, s: u32) -> Self {
411 self.message_secs = s;
412 self
413 }
414 }
415
416 fn prompt_label(p: &Prompt) -> String {
417 match p.as_text() {
418 Some(t) => t.to_string(),
419 None => "<audio>".to_string(),
420 }
421 }
422
423 #[async_trait]
424 impl FlowEffects for MockEffects {
425 async fn speak(&mut self, prompt: &Prompt) -> anyhow::Result<()> {
426 if self.fail_speak {
427 anyhow::bail!("caller hung up");
428 }
429 self.spoken.push(prompt_label(prompt));
430 Ok(())
431 }
432 async fn collect_digit(&mut self, _timeout: Duration) -> anyhow::Result<Option<Digit>> {
433 Ok(self.digits.pop_front().flatten())
435 }
436 async fn ring_human(&mut self, _timeout: Duration) -> anyhow::Result<RingOutcome> {
437 Ok(self.ring)
438 }
439 async fn record_message(
440 &mut self,
441 tone: MessageTone,
442 _max: Duration,
443 ) -> anyhow::Result<u32> {
444 self.recorded = true;
445 self.record_tone = Some(tone);
446 Ok(self.message_secs)
447 }
448 async fn transfer(&mut self, target: &str) -> anyhow::Result<()> {
449 self.transferred = Some(target.to_string());
450 Ok(())
451 }
452 async fn hangup(&mut self, prompt: Option<&Prompt>) -> anyhow::Result<()> {
453 if let Some(p) = prompt {
454 self.spoken.push(prompt_label(p));
455 }
456 self.hung_up = true;
457 Ok(())
458 }
459 fn now(&self) -> OffsetDateTime {
460 self.now
461 }
462 }
463
464 const LUIGIS: &str = r#"
465schema_version: 1
466id: flow_luigi
467name: Luigi's — after hours
468version: 3
469entry: welcome
470nodes:
471 welcome:
472 kind: greeting
473 prompt: Thanks for calling Luigi's!
474 exits: { next: check_hours }
475 check_hours:
476 kind: hours
477 timezone: America/New_York
478 schedule:
479 tue: [{ open: "11:00", close: "22:00" }]
480 exits: { open: front_desk, closed: night_menu }
481 front_desk:
482 kind: ring
483 timeout_secs: 25
484 exits: { no_answer: take_message }
485 night_menu:
486 kind: menu
487 prompt: We're closed. Press 1 for hours, or hold for a message.
488 options: { "1": Hours }
489 retries: 1
490 exits: { "1": say_hours, no_input: take_message, invalid: take_message }
491 say_hours:
492 kind: greeting
493 prompt: We're open Tuesday to Sunday, eleven to ten.
494 exits: { next: take_message }
495 take_message:
496 kind: message
497 prompt: Please leave your name and number after the tone.
498"#;
499
500 fn luigis() -> Flow {
501 let flow = Flow::from_yaml(LUIGIS).expect("parses");
502 validate(&flow).expect("the scenario flow must be valid");
503 flow
504 }
505
506 fn open_time() -> OffsetDateTime {
508 datetime!(2026-07-07 19:00 UTC)
509 }
510 fn closed_time() -> OffsetDateTime {
512 datetime!(2026-07-08 03:00 UTC)
513 }
514
515 fn kinds(trace: &Trace) -> Vec<&str> {
516 trace.steps.iter().map(|s| s.kind).collect()
517 }
518
519 async fn run_trace(flow: &Flow, fx: &mut MockEffects) -> Trace {
522 let mut trace = Trace::new(&flow.id, flow.version);
523 run(flow, fx, &mut trace).await;
524 trace
525 }
526
527 #[tokio::test]
528 async fn open_hours_human_answers() {
529 let mut fx = MockEffects::new(open_time()).ring(RingOutcome::Answered);
530 let trace = run_trace(&luigis(), &mut fx).await;
531
532 assert_eq!(trace.outcome, FlowOutcome::Answered);
533 assert!(trace.is_clean());
534 assert_eq!(kinds(&trace), vec!["greeting", "hours", "ring"]);
535 assert_eq!(trace.steps[1].detail, StepDetail::Hours { open: true });
537 assert!(!fx.recorded, "a human answered — no voicemail");
538 }
539
540 #[tokio::test]
541 async fn open_hours_no_answer_falls_to_voicemail() {
542 let mut fx = MockEffects::new(open_time())
543 .ring(RingOutcome::NoAnswer)
544 .message_secs(40);
545 let trace = run_trace(&luigis(), &mut fx).await;
546
547 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
548 assert_eq!(
549 kinds(&trace),
550 vec!["greeting", "hours", "ring", "message", "message"]
551 );
552 assert_eq!(trace.steps[3].detail, StepDetail::Spoke);
556 assert_eq!(
557 trace.steps[4].detail,
558 StepDetail::MessageRecorded { secs: 40 }
559 );
560 assert!(fx.spoken.iter().any(|s| s.contains("leave your name")));
562 assert!(fx.recorded);
563 assert_eq!(fx.record_tone, Some(MessageTone::Beep));
565 }
566
567 #[tokio::test]
568 async fn closed_press_one_hears_hours_then_leaves_message() {
569 let mut fx = MockEffects::new(closed_time())
570 .digits([Some(Digit::D1)])
571 .message_secs(12);
572 let trace = run_trace(&luigis(), &mut fx).await;
573
574 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
575 assert_eq!(
576 kinds(&trace),
577 vec!["greeting", "hours", "menu", "greeting", "message", "message"]
578 );
579 assert_eq!(trace.steps[1].detail, StepDetail::Hours { open: false });
580 assert_eq!(
581 trace.steps[2].detail,
582 StepDetail::MenuChoice { digit: "1".into() }
583 );
584 assert!(fx
586 .spoken
587 .iter()
588 .any(|s| s.contains("open Tuesday to Sunday")));
589 }
590
591 #[tokio::test]
592 async fn closed_silence_takes_no_input_exit() {
593 let mut fx = MockEffects::new(closed_time());
595 let trace = run_trace(&luigis(), &mut fx).await;
596
597 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
598 assert_eq!(
599 kinds(&trace),
600 vec!["greeting", "hours", "menu", "message", "message"]
601 );
602 assert_eq!(trace.steps[2].detail, StepDetail::MenuNoInput);
603 }
604
605 #[tokio::test]
606 async fn closed_wrong_keys_take_invalid_exit_after_retry() {
607 let mut fx = MockEffects::new(closed_time()).digits([Some(Digit::D9), Some(Digit::D7)]);
609 let trace = run_trace(&luigis(), &mut fx).await;
610
611 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
612 assert_eq!(trace.steps[2].detail, StepDetail::MenuInvalid);
613 let menu_prompts = fx
615 .spoken
616 .iter()
617 .filter(|s| s.contains("Press 1 for hours"))
618 .count();
619 assert_eq!(menu_prompts, 2);
620 }
621
622 #[tokio::test]
623 async fn wrong_key_then_valid_digit_still_routes() {
624 let mut fx = MockEffects::new(closed_time())
626 .digits([Some(Digit::D9), Some(Digit::D1)])
627 .message_secs(5);
628 let trace = run_trace(&luigis(), &mut fx).await;
629
630 assert_eq!(
631 trace.steps[2].detail,
632 StepDetail::MenuChoice { digit: "1".into() }
633 );
634 assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
635 }
636
637 #[tokio::test]
638 async fn steps_carry_monotonic_timeline_offsets() {
639 let mut fx = MockEffects::new(closed_time()).digits([Some(Digit::D1)]);
644 let trace = run_trace(&luigis(), &mut fx).await;
645
646 assert!(trace.steps.len() >= 3);
647 let offsets: Vec<u64> = trace.steps.iter().map(|s| s.at_ms).collect();
648 assert!(
649 offsets.windows(2).all(|w| w[0] <= w[1]),
650 "offsets must be non-decreasing: {offsets:?}"
651 );
652 }
653
654 #[tokio::test]
655 async fn effect_failure_aborts_with_partial_trace() {
656 let mut fx = MockEffects::new(open_time());
657 fx.fail_speak = true; let trace = run_trace(&luigis(), &mut fx).await;
659
660 assert_eq!(trace.outcome, FlowOutcome::Aborted);
661 assert!(!trace.is_clean());
662 assert!(trace.error.as_deref().unwrap().contains("caller hung up"));
663 assert!(trace.steps.is_empty());
665 }
666
667 #[tokio::test]
668 async fn transfer_and_hangup_terminals() {
669 let src = r#"
670schema_version: 1
671id: f
672name: n
673entry: g
674nodes:
675 g:
676 kind: greeting
677 prompt: one moment
678 exits: { next: t }
679 t:
680 kind: transfer
681 target: sip:desk@example.com
682"#;
683 let flow = Flow::from_yaml(src).unwrap();
684 validate(&flow).unwrap();
685 let mut fx = MockEffects::new(open_time());
686 let trace = run_trace(&flow, &mut fx).await;
687 assert_eq!(trace.outcome, FlowOutcome::Transferred);
688 assert_eq!(fx.transferred.as_deref(), Some("sip:desk@example.com"));
689 }
690}