Skip to main content

metalcraft_flows/
validate.rs

1//! Spec-conformance validation for a [`SavedFlow`] or [`FlowDefinition`].
2
3use 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/// A single spec-conformance failure.
11#[derive(Debug, Clone, PartialEq, Eq)]
12pub enum ValidationError {
13    /// `id` does not match `^[A-Za-z0-9-]{1,64}$`.
14    InvalidFlowId(String),
15    /// `spec_version` is a value this parser doesn't understand.
16    UnsupportedSpecVersion(String),
17    /// More than one node in the graph has `node_type = "entry"`.
18    MultipleEntryNodes(usize),
19    /// Two nodes share the same `id`.
20    DuplicateNodeId(String),
21    /// Two edges share the same `id`.
22    DuplicateEdgeId(String),
23    /// An edge's `source` does not match any node `id`.
24    DanglingEdgeSource {
25        /// Id of the offending edge.
26        edge: String,
27        /// The unknown source node id it referenced.
28        source: String,
29    },
30    /// An edge's `target` does not match any node `id`.
31    DanglingEdgeTarget {
32        /// Id of the offending edge.
33        edge: String,
34        /// The unknown target node id it referenced.
35        target: String,
36    },
37    /// A custom `node_type` has a malformed vendor namespace.
38    InvalidVendorNamespace {
39        /// Id of the offending node.
40        node: String,
41        /// The raw `node_type` string that failed the vendor-namespace check.
42        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
83/// Validate a [`SavedFlow`] against the spec.
84///
85/// Returns all detected errors. An empty `Vec` means the document is conformant.
86pub 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
103/// Validate a bare [`FlowDefinition`] (the graph only, no envelope fields).
104pub 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    // Entry-node count.
112    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    // Duplicate node ids.
122    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        // Vendor prefix check for custom node types.
128        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                // Custom strings without a colon are treated as future core
138                // types — not vendor-namespaced. They are accepted but flagged
139                // here as an invalid vendor namespace so authors notice.
140                errors.push(ValidationError::InvalidVendorNamespace {
141                    node: n.id.clone(),
142                    node_type: s.clone(),
143                });
144            }
145        }
146    }
147
148    // Duplicate edge ids + dangling refs.
149    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}