wavekat_flow/model_ext.rs
1//! Hand-written model helpers layered on the SCHEMA-GENERATED types in
2//! [`crate::model`]. The schema owns the document *shape*; the accessors
3//! here are *logic*, not shape (the exit set a kind must wire, terminal-ness,
4//! the `kind` tag, prompt text extraction) — so they live in code, one twin
5//! per language. The TypeScript twin is `packages/flow-schema/src/model.ts`;
6//! keep the two in lockstep.
7//!
8//! typify emits `Node` as an internally-tagged enum (`#[serde(tag = "kind")]`,
9//! wired by the `build.rs` schema normalization) whose variants carry their
10//! config fields *and* `exits` together. The pre-consolidation crate used a
11//! `Node { #[flatten] config: Component, exits }` split with a `Component`
12//! enum; these methods present the same query surface over the generated
13//! shape so the validator/engine read the same.
14
15use std::collections::HashMap;
16
17use crate::model::{Flow, Node, Prompt};
18
19/// A node's key within a flow. Human-meaningful (`night_menu`, not `n7`).
20pub type NodeId = String;
21
22impl Node {
23 /// The `kind` tag, for traces and diagnostics.
24 pub fn kind(&self) -> &'static str {
25 match self {
26 Node::Greeting { .. } => "greeting",
27 Node::Hours { .. } => "hours",
28 Node::Menu { .. } => "menu",
29 Node::Ring { .. } => "ring",
30 Node::Message { .. } => "message",
31 Node::Transfer { .. } => "transfer",
32 Node::Hangup { .. } => "hangup",
33 }
34 }
35
36 /// Whether a caller who reaches this node can have the call *end* here.
37 /// `ring` counts — its implicit `answered` hands the call to a human,
38 /// which ends the flow. Used by the "no caller is ever trapped" check.
39 pub fn is_terminal(&self) -> bool {
40 matches!(
41 self,
42 Node::Message { .. } | Node::Transfer { .. } | Node::Hangup { .. } | Node::Ring { .. }
43 )
44 }
45
46 /// The exit names this node **must** wire, given its config. Validation
47 /// checks the node's `exits` keys are exactly this set — no missing exit
48 /// (a dead choice) and no stray exit (a typo the engine would never
49 /// follow).
50 pub fn required_exits(&self) -> Vec<String> {
51 match self {
52 Node::Greeting { .. } => vec!["next".into()],
53 Node::Hours { .. } => vec!["open".into(), "closed".into()],
54 Node::Menu { options, .. } => {
55 let mut names: Vec<String> = options.keys().cloned().collect();
56 names.push("no_input".into());
57 names.push("invalid".into());
58 names
59 }
60 Node::Ring { .. } => vec!["no_answer".into()],
61 Node::Message { .. } | Node::Transfer { .. } | Node::Hangup { .. } => Vec::new(),
62 }
63 }
64
65 /// The node's wired exits (exit name → target node id), or `None` when
66 /// the document omits the block. Every generated variant carries an
67 /// `exits: Option<Exits>`, so this reads them uniformly.
68 pub fn exits(&self) -> Option<&HashMap<String, String>> {
69 let exits = match self {
70 Node::Greeting { exits, .. }
71 | Node::Hours { exits, .. }
72 | Node::Menu { exits, .. }
73 | Node::Ring { exits, .. }
74 | Node::Message { exits, .. }
75 | Node::Transfer { exits, .. }
76 | Node::Hangup { exits, .. } => exits,
77 };
78 exits.as_deref()
79 }
80}
81
82impl Prompt {
83 /// The spoken text, or `None` for an audio-asset prompt. Handy for
84 /// traces and length validation.
85 pub fn as_text(&self) -> Option<&str> {
86 match self {
87 Prompt::Text(t) => Some(t.as_str()),
88 Prompt::Audio { .. } => None,
89 }
90 }
91}
92
93impl Flow {
94 /// Parse a flow document from YAML text. This is the only surface-syntax
95 /// entry point; everything downstream works on the typed tree. Parsing
96 /// does **not** validate semantics (reachability, exit wiring, …) — call
97 /// [`crate::validate::validate`] on the result.
98 ///
99 /// Routes through `serde_yaml_ng::Value` first, on purpose: the
100 /// duplicate-key check lives in that crate's `Mapping` deserializer, not
101 /// on the direct-into-struct path — a `HashMap` field would otherwise
102 /// silently take last-wins. Routing through `Value` rejects a duplicate
103 /// key at *any* nesting level, the footgun doc 48 fences off.
104 pub fn from_yaml(src: &str) -> Result<Flow, serde_yaml_ng::Error> {
105 let value: serde_yaml_ng::Value = serde_yaml_ng::from_str(src)?;
106 serde_yaml_ng::from_value(value)
107 }
108
109 /// Serialize back to YAML (lossless for the model; the opaque `ui` block
110 /// round-trips).
111 pub fn to_yaml(&self) -> Result<String, serde_yaml_ng::Error> {
112 serde_yaml_ng::to_string(self)
113 }
114}