1use serde::{Deserialize, Deserializer, Serialize, Serializer};
7
8pub const SPEC_VERSION: &str = "1";
12
13#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
15pub struct FlowNode {
16 pub id: String,
18 pub node_type: FlowNodeType,
20 pub data: serde_json::Value,
22 #[serde(default)]
24 pub position: [f64; 2],
25}
26
27#[derive(Debug, Clone, PartialEq, Eq)]
33pub enum FlowNodeType {
34 Core(CoreNodeType),
36 Custom(String),
40}
41
42#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
46pub enum CoreNodeType {
47 Entry,
49 Prompt,
51 Branch,
53 BranchTool,
55}
56
57impl CoreNodeType {
58 pub fn as_str(self) -> &'static str {
60 match self {
61 CoreNodeType::Entry => "entry",
62 CoreNodeType::Prompt => "prompt",
63 CoreNodeType::Branch => "branch",
64 CoreNodeType::BranchTool => "branch_tool",
65 }
66 }
67
68 pub fn from_wire(s: &str) -> Option<Self> {
70 match s {
71 "entry" => Some(CoreNodeType::Entry),
72 "prompt" => Some(CoreNodeType::Prompt),
73 "branch" => Some(CoreNodeType::Branch),
74 "branch_tool" => Some(CoreNodeType::BranchTool),
75 _ => None,
76 }
77 }
78}
79
80impl FlowNodeType {
81 pub fn as_wire(&self) -> &str {
83 match self {
84 FlowNodeType::Core(c) => c.as_str(),
85 FlowNodeType::Custom(s) => s.as_str(),
86 }
87 }
88}
89
90impl Serialize for FlowNodeType {
91 fn serialize<S: Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
92 ser.serialize_str(self.as_wire())
93 }
94}
95
96impl<'de> Deserialize<'de> for FlowNodeType {
97 fn deserialize<D: Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
98 let s = String::deserialize(de)?;
99 if let Some(core) = CoreNodeType::from_wire(&s) {
100 Ok(FlowNodeType::Core(core))
101 } else {
102 Ok(FlowNodeType::Custom(s))
103 }
104 }
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
109pub struct FlowEdge {
110 pub id: String,
112 pub source: String,
114 pub target: String,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub source_handle: Option<String>,
120 #[serde(default, skip_serializing_if = "Option::is_none")]
122 pub target_handle: Option<String>,
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
127pub struct FlowDefinition {
128 pub nodes: Vec<FlowNode>,
130 pub edges: Vec<FlowEdge>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
136pub struct SavedFlow {
137 #[serde(default = "default_spec_version")]
139 pub spec_version: String,
140 pub id: String,
142 pub name: String,
144 pub created_at: String,
146 pub updated_at: String,
148 #[serde(default)]
150 pub enabled: bool,
151 pub flow: FlowDefinition,
153}
154
155fn default_spec_version() -> String {
156 SPEC_VERSION.to_string()
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
163pub struct FlowSummary {
164 pub id: String,
166 pub name: String,
168 pub node_count: usize,
170 pub created_at: String,
172 pub updated_at: String,
174 #[serde(default)]
176 pub enabled: bool,
177}
178
179pub(crate) fn is_safe_id(id: &str) -> bool {
181 !id.is_empty()
182 && id.len() <= 64
183 && id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
184}
185
186pub(crate) fn is_valid_vendor(prefix: &str) -> bool {
188 let mut chars = prefix.chars();
189 let Some(first) = chars.next() else { return false };
190 if !first.is_ascii_lowercase() {
191 return false;
192 }
193 if prefix.len() > 32 {
194 return false;
195 }
196 chars.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '_' || c == '-')
197}
198
199#[cfg(test)]
200mod tests {
201 use super::*;
202 use serde_json::json;
203
204 #[test]
205 fn core_node_type_round_trips() {
206 for ct in [
207 CoreNodeType::Entry,
208 CoreNodeType::Prompt,
209 CoreNodeType::Branch,
210 CoreNodeType::BranchTool,
211 ] {
212 let nt = FlowNodeType::Core(ct);
213 let j = serde_json::to_string(&nt).unwrap();
214 let back: FlowNodeType = serde_json::from_str(&j).unwrap();
215 assert_eq!(nt, back);
216 }
217 }
218
219 #[test]
220 fn custom_node_type_round_trips() {
221 let nt = FlowNodeType::Custom("slack:send_message".to_string());
222 let j = serde_json::to_string(&nt).unwrap();
223 assert_eq!(j, "\"slack:send_message\"");
224 let back: FlowNodeType = serde_json::from_str(&j).unwrap();
225 assert_eq!(nt, back);
226 }
227
228 #[test]
229 fn unknown_bare_node_type_becomes_custom() {
230 let back: FlowNodeType = serde_json::from_str("\"future_core_type\"").unwrap();
231 assert_eq!(back, FlowNodeType::Custom("future_core_type".into()));
232 }
233
234 #[test]
235 fn missing_spec_version_defaults_to_v1() {
236 let doc = json!({
237 "id": "x",
238 "name": "X",
239 "created_at": "2026-01-01T00:00:00Z",
240 "updated_at": "2026-01-01T00:00:00Z",
241 "flow": { "nodes": [], "edges": [] }
242 });
243 let parsed: SavedFlow = serde_json::from_value(doc).unwrap();
244 assert_eq!(parsed.spec_version, "1");
245 assert!(!parsed.enabled);
246 }
247
248 #[test]
249 fn saved_flow_round_trips() {
250 let sf = SavedFlow {
251 spec_version: "1".into(),
252 id: "f1".into(),
253 name: "F1".into(),
254 created_at: "2026-01-01T00:00:00Z".into(),
255 updated_at: "2026-01-02T00:00:00Z".into(),
256 enabled: true,
257 flow: FlowDefinition {
258 nodes: vec![FlowNode {
259 id: "n1".into(),
260 node_type: FlowNodeType::Core(CoreNodeType::Entry),
261 data: json!({"schedule_type": "manual"}),
262 position: [10.0, 20.0],
263 }],
264 edges: vec![],
265 },
266 };
267 let j = serde_json::to_string(&sf).unwrap();
268 let back: SavedFlow = serde_json::from_str(&j).unwrap();
269 assert_eq!(sf, back);
270 }
271
272 #[test]
273 fn id_validation() {
274 assert!(is_safe_id("ok-id"));
275 assert!(is_safe_id("a"));
276 assert!(!is_safe_id(""));
277 assert!(!is_safe_id("has space"));
278 assert!(!is_safe_id("../escape"));
279 assert!(!is_safe_id(&"x".repeat(65)));
280 }
281
282 #[test]
283 fn vendor_validation() {
284 assert!(is_valid_vendor("slack"));
285 assert!(is_valid_vendor("my-co"));
286 assert!(is_valid_vendor("my_co"));
287 assert!(is_valid_vendor("co0"));
288 assert!(!is_valid_vendor(""));
289 assert!(!is_valid_vendor("0starts-with-digit"));
290 assert!(!is_valid_vendor("Capital"));
291 assert!(!is_valid_vendor(&"a".repeat(33)));
292 }
293}