1use crate::model::{
4 is_safe_id, is_valid_vendor, CoreNodeType, FlowDefinition, FlowNodeType, SavedFlow,
5 SPEC_VERSION,
6};
7use std::collections::HashSet;
8use std::fmt;
9
10#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ValidationError {
13 InvalidFlowId(String),
15 UnsupportedSpecVersion(String),
17 MultipleEntryNodes(usize),
19 DuplicateNodeId(String),
21 DuplicateEdgeId(String),
23 DanglingEdgeSource {
25 edge: String,
27 source: String,
29 },
30 DanglingEdgeTarget {
32 edge: String,
34 target: String,
36 },
37 InvalidVendorNamespace {
39 node: String,
41 node_type: String,
43 },
44}
45
46impl fmt::Display for ValidationError {
47 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48 match self {
49 ValidationError::InvalidFlowId(id) => {
50 write!(f, "invalid flow id {id:?}: must match [A-Za-z0-9-]{{1,64}}")
51 }
52 ValidationError::UnsupportedSpecVersion(v) => {
53 write!(f, "unsupported spec_version {v:?}; this parser supports {SPEC_VERSION:?}")
54 }
55 ValidationError::MultipleEntryNodes(n) => {
56 write!(f, "flow has {n} entry nodes; at most one is allowed")
57 }
58 ValidationError::DuplicateNodeId(id) => {
59 write!(f, "duplicate node id {id:?}")
60 }
61 ValidationError::DuplicateEdgeId(id) => {
62 write!(f, "duplicate edge id {id:?}")
63 }
64 ValidationError::DanglingEdgeSource { edge, source } => {
65 write!(f, "edge {edge:?} references unknown source node {source:?}")
66 }
67 ValidationError::DanglingEdgeTarget { edge, target } => {
68 write!(f, "edge {edge:?} references unknown target node {target:?}")
69 }
70 ValidationError::InvalidVendorNamespace { node, node_type } => {
71 write!(
72 f,
73 "node {node:?} has malformed custom node_type {node_type:?}: \
74 vendor prefix must match [a-z][a-z0-9_-]{{0,31}}"
75 )
76 }
77 }
78 }
79}
80
81impl std::error::Error for ValidationError {}
82
83pub fn validate(flow: &SavedFlow) -> Vec<ValidationError> {
87 let mut errors = Vec::new();
88
89 if !is_safe_id(&flow.id) {
90 errors.push(ValidationError::InvalidFlowId(flow.id.clone()));
91 }
92
93 if flow.spec_version != SPEC_VERSION {
94 errors.push(ValidationError::UnsupportedSpecVersion(
95 flow.spec_version.clone(),
96 ));
97 }
98
99 validate_definition(&flow.flow, &mut errors);
100 errors
101}
102
103pub fn validate_definition_only(def: &FlowDefinition) -> Vec<ValidationError> {
105 let mut errors = Vec::new();
106 validate_definition(def, &mut errors);
107 errors
108}
109
110fn validate_definition(def: &FlowDefinition, errors: &mut Vec<ValidationError>) {
111 let entry_count = def
113 .nodes
114 .iter()
115 .filter(|n| matches!(n.node_type, FlowNodeType::Core(CoreNodeType::Entry)))
116 .count();
117 if entry_count > 1 {
118 errors.push(ValidationError::MultipleEntryNodes(entry_count));
119 }
120
121 let mut seen_nodes = HashSet::new();
123 for n in &def.nodes {
124 if !seen_nodes.insert(n.id.as_str()) {
125 errors.push(ValidationError::DuplicateNodeId(n.id.clone()));
126 }
127 if let FlowNodeType::Custom(ref s) = n.node_type {
129 if let Some((prefix, _)) = s.split_once(':') {
130 if !is_valid_vendor(prefix) {
131 errors.push(ValidationError::InvalidVendorNamespace {
132 node: n.id.clone(),
133 node_type: s.clone(),
134 });
135 }
136 } else {
137 errors.push(ValidationError::InvalidVendorNamespace {
141 node: n.id.clone(),
142 node_type: s.clone(),
143 });
144 }
145 }
146 }
147
148 let node_ids: HashSet<&str> = def.nodes.iter().map(|n| n.id.as_str()).collect();
150 let mut seen_edges = HashSet::new();
151 for e in &def.edges {
152 if !seen_edges.insert(e.id.as_str()) {
153 errors.push(ValidationError::DuplicateEdgeId(e.id.clone()));
154 }
155 if !node_ids.contains(e.source.as_str()) {
156 errors.push(ValidationError::DanglingEdgeSource {
157 edge: e.id.clone(),
158 source: e.source.clone(),
159 });
160 }
161 if !node_ids.contains(e.target.as_str()) {
162 errors.push(ValidationError::DanglingEdgeTarget {
163 edge: e.id.clone(),
164 target: e.target.clone(),
165 });
166 }
167 }
168}
169
170#[cfg(test)]
171mod tests {
172 use super::*;
173 use crate::model::{FlowEdge, FlowNode};
174 use serde_json::json;
175
176 fn entry(id: &str) -> FlowNode {
177 FlowNode {
178 id: id.into(),
179 node_type: FlowNodeType::Core(CoreNodeType::Entry),
180 data: json!({}),
181 position: [0.0, 0.0],
182 }
183 }
184 fn prompt(id: &str) -> FlowNode {
185 FlowNode {
186 id: id.into(),
187 node_type: FlowNodeType::Core(CoreNodeType::Prompt),
188 data: json!({}),
189 position: [0.0, 0.0],
190 }
191 }
192 fn edge(id: &str, src: &str, tgt: &str) -> FlowEdge {
193 FlowEdge {
194 id: id.into(),
195 source: src.into(),
196 target: tgt.into(),
197 source_handle: None,
198 target_handle: None,
199 }
200 }
201 fn saved(def: FlowDefinition) -> SavedFlow {
202 SavedFlow {
203 spec_version: "1".into(),
204 id: "ok-id".into(),
205 name: "X".into(),
206 created_at: "2026-01-01T00:00:00Z".into(),
207 updated_at: "2026-01-01T00:00:00Z".into(),
208 enabled: false,
209 flow: def,
210 }
211 }
212
213 #[test]
214 fn valid_minimal_flow_has_no_errors() {
215 let def = FlowDefinition {
216 nodes: vec![entry("e")],
217 edges: vec![],
218 };
219 assert!(validate(&saved(def)).is_empty());
220 }
221
222 #[test]
223 fn invalid_flow_id_caught() {
224 let mut sf = saved(FlowDefinition::default());
225 sf.id = "bad id with spaces".into();
226 let errs = validate(&sf);
227 assert!(errs.iter().any(|e| matches!(e, ValidationError::InvalidFlowId(_))));
228 }
229
230 #[test]
231 fn multiple_entries_caught() {
232 let def = FlowDefinition {
233 nodes: vec![entry("a"), entry("b")],
234 edges: vec![],
235 };
236 let errs = validate(&saved(def));
237 assert!(errs
238 .iter()
239 .any(|e| matches!(e, ValidationError::MultipleEntryNodes(2))));
240 }
241
242 #[test]
243 fn dangling_edge_caught() {
244 let def = FlowDefinition {
245 nodes: vec![entry("e")],
246 edges: vec![edge("x", "e", "missing")],
247 };
248 let errs = validate(&saved(def));
249 assert!(errs
250 .iter()
251 .any(|e| matches!(e, ValidationError::DanglingEdgeTarget { .. })));
252 }
253
254 #[test]
255 fn duplicate_node_id_caught() {
256 let def = FlowDefinition {
257 nodes: vec![entry("e"), prompt("e")],
258 edges: vec![],
259 };
260 let errs = validate(&saved(def));
261 assert!(errs.iter().any(|e| matches!(e, ValidationError::DuplicateNodeId(_))));
262 }
263
264 #[test]
265 fn unsupported_spec_version_caught() {
266 let mut sf = saved(FlowDefinition::default());
267 sf.spec_version = "2".into();
268 let errs = validate(&sf);
269 assert!(errs
270 .iter()
271 .any(|e| matches!(e, ValidationError::UnsupportedSpecVersion(_))));
272 }
273
274 #[test]
275 fn well_formed_custom_type_passes() {
276 let mut p = prompt("p");
277 p.node_type = FlowNodeType::Custom("slack:send_message".into());
278 let def = FlowDefinition {
279 nodes: vec![entry("e"), p],
280 edges: vec![edge("x", "e", "p")],
281 };
282 assert!(validate(&saved(def)).is_empty());
283 }
284
285 #[test]
286 fn malformed_custom_type_caught() {
287 let mut p = prompt("p");
288 p.node_type = FlowNodeType::Custom("BadVendor:thing".into());
289 let def = FlowDefinition {
290 nodes: vec![entry("e"), p],
291 edges: vec![],
292 };
293 let errs = validate(&saved(def));
294 assert!(errs
295 .iter()
296 .any(|e| matches!(e, ValidationError::InvalidVendorNamespace { .. })));
297 }
298
299 #[test]
300 fn custom_type_without_colon_caught() {
301 let mut p = prompt("p");
302 p.node_type = FlowNodeType::Custom("no_namespace".into());
303 let def = FlowDefinition {
304 nodes: vec![entry("e"), p],
305 edges: vec![],
306 };
307 let errs = validate(&saved(def));
308 assert!(errs
309 .iter()
310 .any(|e| matches!(e, ValidationError::InvalidVendorNamespace { .. })));
311 }
312}