Skip to main content

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}