1use anyhow::{Context, bail};
2use serde::Deserialize;
3use std::collections::BTreeMap;
4
5#[derive(Debug, Clone, Deserialize)]
6pub struct Flow {
7 pub id: String,
8 #[allow(dead_code)]
9 pub title: Option<String>,
10 #[allow(dead_code)]
11 pub description: Option<String>,
12 #[serde(rename = "type")]
13 #[allow(dead_code)]
14 pub kind: String,
15 #[serde(rename = "in")]
16 pub r#in: String,
17 pub nodes: BTreeMap<String, Node>,
18}
19
20#[derive(Debug, Clone, Deserialize)]
21pub struct Node {
22 #[serde(default)]
23 pub qa: Option<QaNode>,
24 #[serde(default)]
25 pub tool: Option<ToolNode>,
26 #[serde(default)]
27 pub template: Option<TemplateNode>,
28 #[serde(default)]
29 pub card: Option<CardNode>,
30 #[serde(default)]
31 pub routes: Vec<String>,
32}
33
34#[derive(Debug, Clone, Deserialize)]
35pub struct QaNode {
36 #[allow(dead_code)]
37 pub welcome: Option<String>,
38 pub questions: Vec<Question>,
39 #[serde(default)]
40 pub fallback_agent: Option<AgentCfg>,
41}
42
43#[derive(Debug, Clone, Deserialize)]
44pub struct Question {
45 pub id: String,
46 #[allow(dead_code)]
47 pub prompt: String,
48 #[serde(default)]
49 pub answer_type: Option<String>,
50 #[serde(default)]
51 pub max_words: Option<usize>,
52 #[serde(default)]
53 pub default: Option<serde_json::Value>,
54 #[serde(default)]
55 pub validate: Option<Validate>,
56}
57
58#[derive(Debug, Clone, Deserialize)]
59pub struct Validate {
60 pub range: Option<[f64; 2]>,
61}
62
63#[derive(Debug, Clone, Deserialize)]
64pub struct AgentCfg {
65 #[serde(default)]
66 pub r#type: Option<String>,
67 #[serde(default)]
68 pub model: Option<String>,
69 pub task: Option<String>,
70 #[serde(default)]
71 pub endpoint: Option<String>,
72}
73
74#[derive(Debug, Clone, Deserialize)]
75pub struct ToolNode {
76 pub tool: String,
77 pub action: String,
78 #[serde(default)]
79 pub input: serde_json::Value,
80 #[serde(default)]
81 pub retry: Option<u32>,
82 #[serde(default)]
83 pub delay_secs: Option<u64>,
84 #[serde(default)]
85 pub timeout_secs: Option<u64>,
86}
87
88#[derive(Debug, Clone, Deserialize)]
89pub struct TemplateNode {
90 pub template: String,
91}
92
93#[derive(Debug, Clone, Deserialize)]
94pub struct CardNode {
95 pub title: Option<String>,
96 #[serde(default)]
97 pub body: Vec<CardBlock>,
98 #[serde(default)]
99 pub actions: Vec<CardAction>,
100}
101
102#[derive(Debug, Clone, Deserialize)]
103#[serde(tag = "type")]
104pub enum CardBlock {
105 #[serde(rename = "text")]
106 Text {
107 text: String,
108 #[serde(default)]
109 markdown: Option<bool>,
110 },
111 #[serde(rename = "fact")]
112 Fact { label: String, value: String },
113 #[serde(rename = "image")]
114 Image { url: String },
115}
116
117#[derive(Debug, Clone, Deserialize)]
118#[serde(tag = "type")]
119pub enum CardAction {
120 #[serde(rename = "openUrl")]
121 OpenUrl {
122 title: String,
123 url: String,
124 #[serde(default)]
125 jwt: Option<bool>,
126 },
127 #[serde(rename = "postback")]
128 Postback {
129 title: String,
130 data: serde_json::Value,
131 },
132}
133
134impl Flow {
135 pub fn load_from_file(path: &str) -> anyhow::Result<Self> {
136 let txt = std::fs::read_to_string(path)
137 .with_context(|| format!("reading flow definition at {path}"))?;
138 let flow: Flow = serde_yaml_bw::from_str(&txt)
139 .with_context(|| format!("parsing flow yaml at {path}"))?;
140 flow.validate()?;
141 Ok(flow)
142 }
143
144 pub fn load_from_str(label: &str, raw: &str) -> anyhow::Result<Self> {
145 let flow: Flow = serde_yaml_bw::from_str(raw)
146 .with_context(|| format!("parsing flow yaml at {label}"))?;
147 flow.validate()?;
148 Ok(flow)
149 }
150
151 fn validate(&self) -> anyhow::Result<()> {
152 if self.id.trim().is_empty() {
153 bail!("flow missing id");
154 }
155 if self.kind.trim().is_empty() {
156 bail!("flow {} missing type", self.id);
157 }
158 if self.r#in.trim().is_empty() {
159 bail!("flow {} missing entry point `in`", self.id);
160 }
161 if self.nodes.is_empty() {
162 bail!("flow {} defines no nodes", self.id);
163 }
164 if !self.nodes.contains_key(&self.r#in) {
165 bail!(
166 "flow {} entry point `{}` not found in nodes",
167 self.id,
168 self.r#in
169 );
170 }
171 Ok(())
172 }
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178 use std::{fs, path::PathBuf};
179
180 fn write_temp_flow(contents: &str) -> PathBuf {
181 let suffix = uuid::Uuid::new_v4();
182 let path = std::env::temp_dir().join(format!("flow-{suffix}.yaml"));
183 fs::write(&path, contents).unwrap();
184 path
185 }
186
187 #[test]
188 fn load_from_file_parses_flow_structure() {
189 let yaml = r#"
190id: flow-1
191title: Sample Flow
192type: qa
193in: start
194nodes:
195 start:
196 qa:
197 welcome: "Hello"
198 questions:
199 - id: q1
200 prompt: "What is your name?"
201 routes: []
202"#;
203 let path = write_temp_flow(yaml);
204 let flow = Flow::load_from_file(path.to_str().unwrap()).expect("flow");
205 fs::remove_file(path).ok();
206
207 assert_eq!(flow.id, "flow-1");
208 assert_eq!(flow.kind, "qa");
209 let start = flow.nodes.get("start").expect("start node");
210 let qa = start.qa.as_ref().expect("qa node");
211 assert_eq!(qa.questions.len(), 1);
212 assert_eq!(qa.questions[0].prompt, "What is your name?");
213 }
214
215 #[test]
216 fn missing_optional_sections_default() {
217 let yaml = r#"
218id: flow-2
219type: tool
220in: worker
221nodes:
222 worker:
223 tool:
224 tool: weather
225 action: fetch
226 routes: []
227"#;
228 let path = write_temp_flow(yaml);
229 let flow = Flow::load_from_file(path.to_str().unwrap()).expect("flow");
230 fs::remove_file(path).ok();
231
232 let worker = flow.nodes.get("worker").expect("worker node");
233 assert!(worker.qa.is_none());
234 assert!(worker.card.is_none());
235 assert_eq!(worker.routes, Vec::<String>::new());
236 }
237
238 #[test]
239 fn load_from_file_errors_on_invalid_yaml() {
240 let yaml = r#"
241id: flow-3
242type: qa
243nodes:
244 start:
245 qa: {}
246 "#;
247 let path = write_temp_flow(yaml);
248 let result = Flow::load_from_file(path.to_str().unwrap());
249 fs::remove_file(path).ok();
250 assert!(result.is_err());
251 }
252}