metalcraft-flows 0.1.0

Specification and reference types for Flows — a serializable DAG format for AI agent workflows
Documentation
//! Spec-conformance validation for a [`SavedFlow`] or [`FlowDefinition`].

use crate::model::{
    is_safe_id, is_valid_vendor, CoreNodeType, FlowDefinition, FlowNodeType, SavedFlow,
    SPEC_VERSION,
};
use std::collections::HashSet;
use std::fmt;

/// A single spec-conformance failure.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationError {
    /// `id` does not match `^[A-Za-z0-9-]{1,64}$`.
    InvalidFlowId(String),
    /// `spec_version` is a value this parser doesn't understand.
    UnsupportedSpecVersion(String),
    /// More than one node in the graph has `node_type = "entry"`.
    MultipleEntryNodes(usize),
    /// Two nodes share the same `id`.
    DuplicateNodeId(String),
    /// Two edges share the same `id`.
    DuplicateEdgeId(String),
    /// An edge's `source` does not match any node `id`.
    DanglingEdgeSource {
        /// Id of the offending edge.
        edge: String,
        /// The unknown source node id it referenced.
        source: String,
    },
    /// An edge's `target` does not match any node `id`.
    DanglingEdgeTarget {
        /// Id of the offending edge.
        edge: String,
        /// The unknown target node id it referenced.
        target: String,
    },
    /// A custom `node_type` has a malformed vendor namespace.
    InvalidVendorNamespace {
        /// Id of the offending node.
        node: String,
        /// The raw `node_type` string that failed the vendor-namespace check.
        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 {}

/// Validate a [`SavedFlow`] against the spec.
///
/// Returns all detected errors. An empty `Vec` means the document is conformant.
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
}

/// Validate a bare [`FlowDefinition`] (the graph only, no envelope fields).
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>) {
    // Entry-node count.
    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));
    }

    // Duplicate node ids.
    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()));
        }
        // Vendor prefix check for custom node types.
        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 {
                // Custom strings without a colon are treated as future core
                // types — not vendor-namespaced. They are accepted but flagged
                // here as an invalid vendor namespace so authors notice.
                errors.push(ValidationError::InvalidVendorNamespace {
                    node: n.id.clone(),
                    node_type: s.clone(),
                });
            }
        }
    }

    // Duplicate edge ids + dangling refs.
    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 { .. })));
    }
}