use crate::model::{
is_safe_id, is_valid_vendor, CoreNodeType, FlowDefinition, FlowNodeType, SavedFlow,
SPEC_VERSION,
};
use std::collections::HashSet;
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
InvalidFlowId(String),
UnsupportedSpecVersion(String),
MultipleEntryNodes(usize),
DuplicateNodeId(String),
DuplicateEdgeId(String),
DanglingEdgeSource {
edge: String,
source: String,
},
DanglingEdgeTarget {
edge: String,
target: String,
},
InvalidVendorNamespace {
node: String,
node_type: String,
},
}
impl fmt::Display for ValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ValidationError::InvalidFlowId(id) => {
write!(f, "invalid flow id {id:?}: must match [A-Za-z0-9-]{{1,64}}")
}
ValidationError::UnsupportedSpecVersion(v) => {
write!(f, "unsupported spec_version {v:?}; this parser supports {SPEC_VERSION:?}")
}
ValidationError::MultipleEntryNodes(n) => {
write!(f, "flow has {n} entry nodes; at most one is allowed")
}
ValidationError::DuplicateNodeId(id) => {
write!(f, "duplicate node id {id:?}")
}
ValidationError::DuplicateEdgeId(id) => {
write!(f, "duplicate edge id {id:?}")
}
ValidationError::DanglingEdgeSource { edge, source } => {
write!(f, "edge {edge:?} references unknown source node {source:?}")
}
ValidationError::DanglingEdgeTarget { edge, target } => {
write!(f, "edge {edge:?} references unknown target node {target:?}")
}
ValidationError::InvalidVendorNamespace { node, node_type } => {
write!(
f,
"node {node:?} has malformed custom node_type {node_type:?}: \
vendor prefix must match [a-z][a-z0-9_-]{{0,31}}"
)
}
}
}
}
impl std::error::Error for ValidationError {}
pub fn validate(flow: &SavedFlow) -> Vec<ValidationError> {
let mut errors = Vec::new();
if !is_safe_id(&flow.id) {
errors.push(ValidationError::InvalidFlowId(flow.id.clone()));
}
if flow.spec_version != SPEC_VERSION {
errors.push(ValidationError::UnsupportedSpecVersion(
flow.spec_version.clone(),
));
}
validate_definition(&flow.flow, &mut errors);
errors
}
pub fn validate_definition_only(def: &FlowDefinition) -> Vec<ValidationError> {
let mut errors = Vec::new();
validate_definition(def, &mut errors);
errors
}
fn validate_definition(def: &FlowDefinition, errors: &mut Vec<ValidationError>) {
let entry_count = def
.nodes
.iter()
.filter(|n| matches!(n.node_type, FlowNodeType::Core(CoreNodeType::Entry)))
.count();
if entry_count > 1 {
errors.push(ValidationError::MultipleEntryNodes(entry_count));
}
let mut seen_nodes = HashSet::new();
for n in &def.nodes {
if !seen_nodes.insert(n.id.as_str()) {
errors.push(ValidationError::DuplicateNodeId(n.id.clone()));
}
if let FlowNodeType::Custom(ref s) = n.node_type {
if let Some((prefix, _)) = s.split_once(':') {
if !is_valid_vendor(prefix) {
errors.push(ValidationError::InvalidVendorNamespace {
node: n.id.clone(),
node_type: s.clone(),
});
}
} else {
errors.push(ValidationError::InvalidVendorNamespace {
node: n.id.clone(),
node_type: s.clone(),
});
}
}
}
let node_ids: HashSet<&str> = def.nodes.iter().map(|n| n.id.as_str()).collect();
let mut seen_edges = HashSet::new();
for e in &def.edges {
if !seen_edges.insert(e.id.as_str()) {
errors.push(ValidationError::DuplicateEdgeId(e.id.clone()));
}
if !node_ids.contains(e.source.as_str()) {
errors.push(ValidationError::DanglingEdgeSource {
edge: e.id.clone(),
source: e.source.clone(),
});
}
if !node_ids.contains(e.target.as_str()) {
errors.push(ValidationError::DanglingEdgeTarget {
edge: e.id.clone(),
target: e.target.clone(),
});
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{FlowEdge, FlowNode};
use serde_json::json;
fn entry(id: &str) -> FlowNode {
FlowNode {
id: id.into(),
node_type: FlowNodeType::Core(CoreNodeType::Entry),
data: json!({}),
position: [0.0, 0.0],
}
}
fn prompt(id: &str) -> FlowNode {
FlowNode {
id: id.into(),
node_type: FlowNodeType::Core(CoreNodeType::Prompt),
data: json!({}),
position: [0.0, 0.0],
}
}
fn edge(id: &str, src: &str, tgt: &str) -> FlowEdge {
FlowEdge {
id: id.into(),
source: src.into(),
target: tgt.into(),
source_handle: None,
target_handle: None,
}
}
fn saved(def: FlowDefinition) -> SavedFlow {
SavedFlow {
spec_version: "1".into(),
id: "ok-id".into(),
name: "X".into(),
created_at: "2026-01-01T00:00:00Z".into(),
updated_at: "2026-01-01T00:00:00Z".into(),
enabled: false,
flow: def,
}
}
#[test]
fn valid_minimal_flow_has_no_errors() {
let def = FlowDefinition {
nodes: vec![entry("e")],
edges: vec![],
};
assert!(validate(&saved(def)).is_empty());
}
#[test]
fn invalid_flow_id_caught() {
let mut sf = saved(FlowDefinition::default());
sf.id = "bad id with spaces".into();
let errs = validate(&sf);
assert!(errs.iter().any(|e| matches!(e, ValidationError::InvalidFlowId(_))));
}
#[test]
fn multiple_entries_caught() {
let def = FlowDefinition {
nodes: vec![entry("a"), entry("b")],
edges: vec![],
};
let errs = validate(&saved(def));
assert!(errs
.iter()
.any(|e| matches!(e, ValidationError::MultipleEntryNodes(2))));
}
#[test]
fn dangling_edge_caught() {
let def = FlowDefinition {
nodes: vec![entry("e")],
edges: vec![edge("x", "e", "missing")],
};
let errs = validate(&saved(def));
assert!(errs
.iter()
.any(|e| matches!(e, ValidationError::DanglingEdgeTarget { .. })));
}
#[test]
fn duplicate_node_id_caught() {
let def = FlowDefinition {
nodes: vec![entry("e"), prompt("e")],
edges: vec![],
};
let errs = validate(&saved(def));
assert!(errs.iter().any(|e| matches!(e, ValidationError::DuplicateNodeId(_))));
}
#[test]
fn unsupported_spec_version_caught() {
let mut sf = saved(FlowDefinition::default());
sf.spec_version = "2".into();
let errs = validate(&sf);
assert!(errs
.iter()
.any(|e| matches!(e, ValidationError::UnsupportedSpecVersion(_))));
}
#[test]
fn well_formed_custom_type_passes() {
let mut p = prompt("p");
p.node_type = FlowNodeType::Custom("slack:send_message".into());
let def = FlowDefinition {
nodes: vec![entry("e"), p],
edges: vec![edge("x", "e", "p")],
};
assert!(validate(&saved(def)).is_empty());
}
#[test]
fn malformed_custom_type_caught() {
let mut p = prompt("p");
p.node_type = FlowNodeType::Custom("BadVendor:thing".into());
let def = FlowDefinition {
nodes: vec![entry("e"), p],
edges: vec![],
};
let errs = validate(&saved(def));
assert!(errs
.iter()
.any(|e| matches!(e, ValidationError::InvalidVendorNamespace { .. })));
}
#[test]
fn custom_type_without_colon_caught() {
let mut p = prompt("p");
p.node_type = FlowNodeType::Custom("no_namespace".into());
let def = FlowDefinition {
nodes: vec![entry("e"), p],
edges: vec![],
};
let errs = validate(&saved(def));
assert!(errs
.iter()
.any(|e| matches!(e, ValidationError::InvalidVendorNamespace { .. })));
}
}