use std::time::Duration;
use async_trait::async_trait;
use time::OffsetDateTime;
use crate::hours::{self, HoursError};
use crate::model::{Flow, MessageTone, Node, Prompt};
use crate::trace::{FlowOutcome, StepDetail, Trace};
use crate::NodeId;
const MAX_STEPS: u32 = 100;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Digit {
D0,
D1,
D2,
D3,
D4,
D5,
D6,
D7,
D8,
D9,
Star,
Hash,
}
impl Digit {
pub fn as_key(self) -> &'static str {
match self {
Digit::D0 => "0",
Digit::D1 => "1",
Digit::D2 => "2",
Digit::D3 => "3",
Digit::D4 => "4",
Digit::D5 => "5",
Digit::D6 => "6",
Digit::D7 => "7",
Digit::D8 => "8",
Digit::D9 => "9",
Digit::Star => "*",
Digit::Hash => "#",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RingOutcome {
Answered,
NoAnswer,
}
#[async_trait]
pub trait FlowEffects: Send {
async fn speak(&mut self, prompt: &Prompt) -> anyhow::Result<()>;
async fn collect_digit(&mut self, timeout: Duration) -> anyhow::Result<Option<Digit>>;
async fn ring_human(&mut self, timeout: Duration) -> anyhow::Result<RingOutcome>;
async fn record_message(&mut self, tone: MessageTone, max: Duration) -> anyhow::Result<u32>;
async fn transfer(&mut self, target: &str) -> anyhow::Result<()>;
async fn hangup(&mut self, prompt: Option<&Prompt>) -> anyhow::Result<()>;
fn now(&self) -> OffsetDateTime;
}
#[derive(Debug, thiserror::Error)]
enum EngineError {
#[error("effect failed: {0}")]
Effect(anyhow::Error),
#[error("node {0:?} not found")]
UnknownNode(NodeId),
#[error("node {node:?} has no {exit:?} exit")]
MissingExit { node: NodeId, exit: String },
#[error("hours evaluation at {node:?} failed: {source}")]
Hours { node: NodeId, source: HoursError },
}
impl EngineError {
fn outcome(&self) -> FlowOutcome {
match self {
EngineError::Effect(_) => FlowOutcome::Aborted,
_ => FlowOutcome::Defect,
}
}
}
enum Step {
Goto(NodeId),
End(FlowOutcome),
}
pub async fn run<E: FlowEffects>(flow: &Flow, fx: &mut E, trace: &mut Trace) {
let mut current = flow.entry.clone();
for _ in 0..MAX_STEPS {
let node = match flow.nodes.get(¤t) {
Some(n) => n,
None => return abort(trace, EngineError::UnknownNode(current)),
};
match run_node(¤t, node, fx, trace).await {
Ok(Step::Goto(next)) => current = next,
Ok(Step::End(outcome)) => {
trace.outcome = outcome;
return;
}
Err(err) => return abort(trace, err),
}
}
trace.outcome = FlowOutcome::Defect;
trace.error = Some(format!(
"step cap {MAX_STEPS} exceeded — cycle in an unvalidated flow?"
));
}
fn abort(trace: &mut Trace, err: EngineError) {
trace.outcome = err.outcome();
trace.error = Some(err.to_string());
}
async fn run_node<E: FlowEffects>(
id: &NodeId,
node: &Node,
fx: &mut E,
trace: &mut Trace,
) -> Result<Step, EngineError> {
let kind = node.kind();
match node {
Node::Greeting { prompt, .. } => {
fx.speak(prompt).await.map_err(EngineError::Effect)?;
trace.push(id, kind, StepDetail::Spoke);
goto(id, node, "next")
}
Node::Hours { .. } => {
let result = hours::evaluate(node, fx.now()).map_err(|source| EngineError::Hours {
node: id.clone(),
source,
})?;
let open = result == hours::HoursResult::Open;
trace.push(id, kind, StepDetail::Hours { open });
goto(id, node, result.exit())
}
Node::Menu {
prompt,
options,
retries,
timeout_secs,
..
} => {
run_menu(
id,
node,
fx,
trace,
prompt,
options,
*retries,
*timeout_secs,
)
.await
}
Node::Ring { timeout_secs, .. } => {
let outcome = fx
.ring_human(Duration::from_secs(*timeout_secs))
.await
.map_err(EngineError::Effect)?;
match outcome {
RingOutcome::Answered => {
trace.push(id, kind, StepDetail::Ring { answered: true });
Ok(Step::End(FlowOutcome::Answered))
}
RingOutcome::NoAnswer => {
trace.push(id, kind, StepDetail::Ring { answered: false });
goto(id, node, "no_answer")
}
}
}
Node::Message {
prompt,
max_secs,
tone,
..
} => {
fx.speak(prompt).await.map_err(EngineError::Effect)?;
trace.push(id, kind, StepDetail::Spoke);
let secs = fx
.record_message(*tone, Duration::from_secs(*max_secs))
.await
.map_err(EngineError::Effect)?;
trace.push(id, kind, StepDetail::MessageRecorded { secs });
Ok(Step::End(FlowOutcome::MessageLeft))
}
Node::Transfer { target, .. } => {
fx.transfer(target).await.map_err(EngineError::Effect)?;
trace.push(
id,
kind,
StepDetail::Transferred {
target: target.clone(),
},
);
Ok(Step::End(FlowOutcome::Transferred))
}
Node::Hangup { prompt, .. } => {
fx.hangup(prompt.as_ref())
.await
.map_err(EngineError::Effect)?;
trace.push(id, kind, StepDetail::HungUp);
Ok(Step::End(FlowOutcome::HungUp))
}
}
}
#[allow(clippy::too_many_arguments)]
async fn run_menu<E: FlowEffects>(
id: &NodeId,
node: &Node,
fx: &mut E,
trace: &mut Trace,
prompt: &Prompt,
options: &std::collections::HashMap<String, String>,
retries: u64,
timeout_secs: u64,
) -> Result<Step, EngineError> {
let attempts = retries.saturating_add(1);
let mut heard_any_key = false;
for _ in 0..attempts {
fx.speak(prompt).await.map_err(EngineError::Effect)?;
let pressed = fx
.collect_digit(Duration::from_secs(timeout_secs))
.await
.map_err(EngineError::Effect)?;
match pressed {
Some(digit) if options.contains_key(digit.as_key()) => {
trace.push(
id,
"menu",
StepDetail::MenuChoice {
digit: digit.as_key().to_string(),
},
);
return goto(id, node, digit.as_key());
}
Some(_) => heard_any_key = true, None => {} }
}
if heard_any_key {
trace.push(id, "menu", StepDetail::MenuInvalid);
goto(id, node, "invalid")
} else {
trace.push(id, "menu", StepDetail::MenuNoInput);
goto(id, node, "no_input")
}
}
fn goto(id: &NodeId, node: &Node, exit: &str) -> Result<Step, EngineError> {
node.exits()
.and_then(|exits| exits.get(exit))
.map(|target| Step::Goto(target.clone()))
.ok_or_else(|| EngineError::MissingExit {
node: id.clone(),
exit: exit.to_string(),
})
}
#[cfg(test)]
mod tests {
use std::collections::VecDeque;
use time::macros::datetime;
use super::*;
use crate::trace::FlowOutcome;
use crate::validate::validate;
struct MockEffects {
now: OffsetDateTime,
digits: VecDeque<Option<Digit>>,
ring: RingOutcome,
message_secs: u32,
spoken: Vec<String>,
transferred: Option<String>,
recorded: bool,
record_tone: Option<MessageTone>,
hung_up: bool,
fail_speak: bool,
}
impl MockEffects {
fn new(now: OffsetDateTime) -> Self {
MockEffects {
now,
digits: VecDeque::new(),
ring: RingOutcome::NoAnswer,
message_secs: 0,
spoken: Vec::new(),
transferred: None,
recorded: false,
record_tone: None,
hung_up: false,
fail_speak: false,
}
}
fn digits(mut self, seq: impl IntoIterator<Item = Option<Digit>>) -> Self {
self.digits = seq.into_iter().collect();
self
}
fn ring(mut self, r: RingOutcome) -> Self {
self.ring = r;
self
}
fn message_secs(mut self, s: u32) -> Self {
self.message_secs = s;
self
}
}
fn prompt_label(p: &Prompt) -> String {
match p.as_text() {
Some(t) => t.to_string(),
None => "<audio>".to_string(),
}
}
#[async_trait]
impl FlowEffects for MockEffects {
async fn speak(&mut self, prompt: &Prompt) -> anyhow::Result<()> {
if self.fail_speak {
anyhow::bail!("caller hung up");
}
self.spoken.push(prompt_label(prompt));
Ok(())
}
async fn collect_digit(&mut self, _timeout: Duration) -> anyhow::Result<Option<Digit>> {
Ok(self.digits.pop_front().flatten())
}
async fn ring_human(&mut self, _timeout: Duration) -> anyhow::Result<RingOutcome> {
Ok(self.ring)
}
async fn record_message(
&mut self,
tone: MessageTone,
_max: Duration,
) -> anyhow::Result<u32> {
self.recorded = true;
self.record_tone = Some(tone);
Ok(self.message_secs)
}
async fn transfer(&mut self, target: &str) -> anyhow::Result<()> {
self.transferred = Some(target.to_string());
Ok(())
}
async fn hangup(&mut self, prompt: Option<&Prompt>) -> anyhow::Result<()> {
if let Some(p) = prompt {
self.spoken.push(prompt_label(p));
}
self.hung_up = true;
Ok(())
}
fn now(&self) -> OffsetDateTime {
self.now
}
}
const LUIGIS: &str = r#"
schema_version: 1
id: flow_luigi
name: Luigi's — after hours
version: 3
entry: welcome
nodes:
welcome:
kind: greeting
prompt: Thanks for calling Luigi's!
exits: { next: check_hours }
check_hours:
kind: hours
timezone: America/New_York
schedule:
tue: [{ open: "11:00", close: "22:00" }]
exits: { open: front_desk, closed: night_menu }
front_desk:
kind: ring
timeout_secs: 25
exits: { no_answer: take_message }
night_menu:
kind: menu
prompt: We're closed. Press 1 for hours, or hold for a message.
options: { "1": Hours }
retries: 1
exits: { "1": say_hours, no_input: take_message, invalid: take_message }
say_hours:
kind: greeting
prompt: We're open Tuesday to Sunday, eleven to ten.
exits: { next: take_message }
take_message:
kind: message
prompt: Please leave your name and number after the tone.
"#;
fn luigis() -> Flow {
let flow = Flow::from_yaml(LUIGIS).expect("parses");
validate(&flow).expect("the scenario flow must be valid");
flow
}
fn open_time() -> OffsetDateTime {
datetime!(2026-07-07 19:00 UTC)
}
fn closed_time() -> OffsetDateTime {
datetime!(2026-07-08 03:00 UTC)
}
fn kinds(trace: &Trace) -> Vec<&str> {
trace.steps.iter().map(|s| s.kind).collect()
}
async fn run_trace(flow: &Flow, fx: &mut MockEffects) -> Trace {
let mut trace = Trace::new(&flow.id, flow.version);
run(flow, fx, &mut trace).await;
trace
}
#[tokio::test]
async fn open_hours_human_answers() {
let mut fx = MockEffects::new(open_time()).ring(RingOutcome::Answered);
let trace = run_trace(&luigis(), &mut fx).await;
assert_eq!(trace.outcome, FlowOutcome::Answered);
assert!(trace.is_clean());
assert_eq!(kinds(&trace), vec!["greeting", "hours", "ring"]);
assert_eq!(trace.steps[1].detail, StepDetail::Hours { open: true });
assert!(!fx.recorded, "a human answered — no voicemail");
}
#[tokio::test]
async fn open_hours_no_answer_falls_to_voicemail() {
let mut fx = MockEffects::new(open_time())
.ring(RingOutcome::NoAnswer)
.message_secs(40);
let trace = run_trace(&luigis(), &mut fx).await;
assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
assert_eq!(
kinds(&trace),
vec!["greeting", "hours", "ring", "message", "message"]
);
assert_eq!(trace.steps[3].detail, StepDetail::Spoke);
assert_eq!(
trace.steps[4].detail,
StepDetail::MessageRecorded { secs: 40 }
);
assert!(fx.spoken.iter().any(|s| s.contains("leave your name")));
assert!(fx.recorded);
assert_eq!(fx.record_tone, Some(MessageTone::Beep));
}
#[tokio::test]
async fn closed_press_one_hears_hours_then_leaves_message() {
let mut fx = MockEffects::new(closed_time())
.digits([Some(Digit::D1)])
.message_secs(12);
let trace = run_trace(&luigis(), &mut fx).await;
assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
assert_eq!(
kinds(&trace),
vec!["greeting", "hours", "menu", "greeting", "message", "message"]
);
assert_eq!(trace.steps[1].detail, StepDetail::Hours { open: false });
assert_eq!(
trace.steps[2].detail,
StepDetail::MenuChoice { digit: "1".into() }
);
assert!(fx
.spoken
.iter()
.any(|s| s.contains("open Tuesday to Sunday")));
}
#[tokio::test]
async fn closed_silence_takes_no_input_exit() {
let mut fx = MockEffects::new(closed_time());
let trace = run_trace(&luigis(), &mut fx).await;
assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
assert_eq!(
kinds(&trace),
vec!["greeting", "hours", "menu", "message", "message"]
);
assert_eq!(trace.steps[2].detail, StepDetail::MenuNoInput);
}
#[tokio::test]
async fn closed_wrong_keys_take_invalid_exit_after_retry() {
let mut fx = MockEffects::new(closed_time()).digits([Some(Digit::D9), Some(Digit::D7)]);
let trace = run_trace(&luigis(), &mut fx).await;
assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
assert_eq!(trace.steps[2].detail, StepDetail::MenuInvalid);
let menu_prompts = fx
.spoken
.iter()
.filter(|s| s.contains("Press 1 for hours"))
.count();
assert_eq!(menu_prompts, 2);
}
#[tokio::test]
async fn wrong_key_then_valid_digit_still_routes() {
let mut fx = MockEffects::new(closed_time())
.digits([Some(Digit::D9), Some(Digit::D1)])
.message_secs(5);
let trace = run_trace(&luigis(), &mut fx).await;
assert_eq!(
trace.steps[2].detail,
StepDetail::MenuChoice { digit: "1".into() }
);
assert_eq!(trace.outcome, FlowOutcome::MessageLeft);
}
#[tokio::test]
async fn steps_carry_monotonic_timeline_offsets() {
let mut fx = MockEffects::new(closed_time()).digits([Some(Digit::D1)]);
let trace = run_trace(&luigis(), &mut fx).await;
assert!(trace.steps.len() >= 3);
let offsets: Vec<u64> = trace.steps.iter().map(|s| s.at_ms).collect();
assert!(
offsets.windows(2).all(|w| w[0] <= w[1]),
"offsets must be non-decreasing: {offsets:?}"
);
}
#[tokio::test]
async fn effect_failure_aborts_with_partial_trace() {
let mut fx = MockEffects::new(open_time());
fx.fail_speak = true; let trace = run_trace(&luigis(), &mut fx).await;
assert_eq!(trace.outcome, FlowOutcome::Aborted);
assert!(!trace.is_clean());
assert!(trace.error.as_deref().unwrap().contains("caller hung up"));
assert!(trace.steps.is_empty());
}
#[tokio::test]
async fn transfer_and_hangup_terminals() {
let src = r#"
schema_version: 1
id: f
name: n
entry: g
nodes:
g:
kind: greeting
prompt: one moment
exits: { next: t }
t:
kind: transfer
target: sip:desk@example.com
"#;
let flow = Flow::from_yaml(src).unwrap();
validate(&flow).unwrap();
let mut fx = MockEffects::new(open_time());
let trace = run_trace(&flow, &mut fx).await;
assert_eq!(trace.outcome, FlowOutcome::Transferred);
assert_eq!(fx.transferred.as_deref(), Some("sip:desk@example.com"));
}
}