Skip to main content

metalcraft_flows/
validate.rs

1//! Spec-conformance validation for a [`SavedFlow`] or [`FlowDefinition`].
2
3use crate::eval::Operator;
4use crate::model::{
5    is_safe_id, is_valid_vendor, CoreNodeType, FlowDefinition, FlowNode, FlowNodeType, SavedFlow,
6    SUPPORTED_SPEC_VERSIONS,
7};
8use crate::nodes::{BranchData, ConditionalData};
9use std::collections::HashSet;
10use std::fmt;
11
12/// A single spec-conformance failure.
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub enum ValidationError {
15    /// `id` does not match `^[A-Za-z0-9-]{1,64}$`.
16    InvalidFlowId(String),
17    /// `spec_version` is a value this parser doesn't understand.
18    UnsupportedSpecVersion(String),
19    /// More than one node in the graph has `node_type = "entry"`.
20    MultipleEntryNodes(usize),
21    /// Two nodes share the same `id`.
22    DuplicateNodeId(String),
23    /// Two edges share the same `id`.
24    DuplicateEdgeId(String),
25    /// An edge's `source` does not match any node `id`.
26    DanglingEdgeSource {
27        /// Id of the offending edge.
28        edge: String,
29        /// The unknown source node id it referenced.
30        source: String,
31    },
32    /// An edge's `target` does not match any node `id`.
33    DanglingEdgeTarget {
34        /// Id of the offending edge.
35        edge: String,
36        /// The unknown target node id it referenced.
37        target: String,
38    },
39    /// A custom `node_type` has a malformed vendor namespace.
40    InvalidVendorNamespace {
41        /// Id of the offending node.
42        node: String,
43        /// The raw `node_type` string that failed the vendor-namespace check.
44        node_type: String,
45    },
46    /// A v2-only core node type is used in a document declaring `spec_version = "1"`.
47    V2NodeInV1Document {
48        /// Id of the offending node.
49        node: String,
50        /// The node's wire-format type string.
51        node_type: String,
52    },
53    /// A node's `data` payload does not match the schema for its `node_type`.
54    InvalidNodeData {
55        /// Id of the offending node.
56        node: String,
57        /// What was wrong.
58        message: String,
59    },
60    /// A `conditional` condition names an operator the runtime doesn't know.
61    UnknownOperator {
62        /// Id of the offending node.
63        node: String,
64        /// The unrecognized operator string.
65        operator: String,
66    },
67    /// A node declares an output `handle` that has no matching outgoing edge.
68    MissingHandleEdge {
69        /// Id of the offending node.
70        node: String,
71        /// The handle that has no `source_handle` edge leaving the node.
72        handle: String,
73    },
74    /// A `requires` entry is malformed: a bad pack id, an unparseable semver
75    /// range or resolved version, a malformed content hash, or a duplicate id.
76    InvalidRequires {
77        /// What was wrong.
78        message: String,
79    },
80    /// A flow-level [`schedule`](crate::FlowScheduleSpec) is malformed: an empty
81    /// or duplicate id, a non-positive interval, or an empty cron expression.
82    InvalidSchedule {
83        /// What was wrong.
84        message: String,
85    },
86}
87
88impl fmt::Display for ValidationError {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        match self {
91            ValidationError::InvalidFlowId(id) => {
92                write!(f, "invalid flow id {id:?}: must match [A-Za-z0-9-]{{1,64}}")
93            }
94            ValidationError::UnsupportedSpecVersion(v) => {
95                write!(f, "unsupported spec_version {v:?}; this parser supports {SUPPORTED_SPEC_VERSIONS:?}")
96            }
97            ValidationError::MultipleEntryNodes(n) => {
98                write!(f, "flow has {n} entry nodes; at most one is allowed")
99            }
100            ValidationError::DuplicateNodeId(id) => {
101                write!(f, "duplicate node id {id:?}")
102            }
103            ValidationError::DuplicateEdgeId(id) => {
104                write!(f, "duplicate edge id {id:?}")
105            }
106            ValidationError::DanglingEdgeSource { edge, source } => {
107                write!(f, "edge {edge:?} references unknown source node {source:?}")
108            }
109            ValidationError::DanglingEdgeTarget { edge, target } => {
110                write!(f, "edge {edge:?} references unknown target node {target:?}")
111            }
112            ValidationError::InvalidVendorNamespace { node, node_type } => {
113                write!(
114                    f,
115                    "node {node:?} has malformed custom node_type {node_type:?}: \
116                     vendor prefix must match [a-z][a-z0-9_-]{{0,31}}"
117                )
118            }
119            ValidationError::V2NodeInV1Document { node, node_type } => {
120                write!(
121                    f,
122                    "node {node:?} uses v2 node type {node_type:?} but the document \
123                     declares spec_version \"1\"; set spec_version to \"2\""
124                )
125            }
126            ValidationError::InvalidNodeData { node, message } => {
127                write!(f, "node {node:?} has invalid data: {message}")
128            }
129            ValidationError::UnknownOperator { node, operator } => {
130                write!(f, "node {node:?} uses unknown operator {operator:?}")
131            }
132            ValidationError::MissingHandleEdge { node, handle } => {
133                write!(f, "node {node:?} declares handle {handle:?} but no edge leaves it via that handle")
134            }
135            ValidationError::InvalidRequires { message } => {
136                write!(f, "invalid `requires`: {message}")
137            }
138            ValidationError::InvalidSchedule { message } => {
139                write!(f, "invalid schedule: {message}")
140            }
141        }
142    }
143}
144
145impl std::error::Error for ValidationError {}
146
147/// Validate a [`SavedFlow`] against the spec.
148///
149/// Returns all detected errors. An empty `Vec` means the document is conformant.
150pub fn validate(flow: &SavedFlow) -> Vec<ValidationError> {
151    let mut errors = Vec::new();
152
153    if !is_safe_id(&flow.id) {
154        errors.push(ValidationError::InvalidFlowId(flow.id.clone()));
155    }
156
157    if !SUPPORTED_SPEC_VERSIONS.contains(&flow.spec_version.as_str()) {
158        errors.push(ValidationError::UnsupportedSpecVersion(
159            flow.spec_version.clone(),
160        ));
161    }
162
163    if let Some(req) = &flow.requires {
164        validate_requires(req, &mut errors);
165    }
166
167    validate_schedules(&flow.schedules, &mut errors);
168
169    validate_definition(&flow.flow, &flow.spec_version, &mut errors);
170    errors
171}
172
173/// Validate the flow-level `schedules` array: non-empty unique ids, positive
174/// intervals, and non-empty cron strings. Cron *expression* parsing is the
175/// host runtime's concern (this crate has no cron dependency), so only
176/// emptiness is checked here.
177fn validate_schedules(
178    schedules: &[crate::model::FlowScheduleSpec],
179    errors: &mut Vec<ValidationError>,
180) {
181    use crate::model::ScheduleTrigger;
182
183    let mut seen: HashSet<&str> = HashSet::new();
184    for s in schedules {
185        if s.id.trim().is_empty() {
186            errors.push(ValidationError::InvalidSchedule {
187                message: "schedule id must not be empty".to_string(),
188            });
189        } else if !seen.insert(s.id.as_str()) {
190            errors.push(ValidationError::InvalidSchedule {
191                message: format!("duplicate schedule id {:?}", s.id),
192            });
193        }
194        match &s.trigger {
195            ScheduleTrigger::Manual => {}
196            ScheduleTrigger::Minutes { interval } | ScheduleTrigger::Hours { interval } => {
197                if *interval == 0 {
198                    errors.push(ValidationError::InvalidSchedule {
199                        message: format!("schedule {:?} interval must be positive", s.id),
200                    });
201                }
202            }
203            ScheduleTrigger::Cron { cron } => {
204                if cron.trim().is_empty() {
205                    errors.push(ValidationError::InvalidSchedule {
206                        message: format!("schedule {:?} has an empty cron expression", s.id),
207                    });
208                }
209            }
210        }
211    }
212}
213
214/// Validate the well-formedness of a [`Requires`](crate::requires::Requires)
215/// block. This checks *shape only* — that ids, ranges, and hashes parse — not
216/// whether the packs are installed or compatible (that is host-specific and
217/// belongs to [`crate::requires::check_requirements`]).
218fn validate_requires(req: &crate::requires::Requires, errors: &mut Vec<ValidationError>) {
219    use crate::requires::{is_valid_pack_id, is_valid_sha256};
220
221    let mut seen: HashSet<&str> = HashSet::new();
222    for pr in &req.packs {
223        if !is_valid_pack_id(&pr.id) {
224            errors.push(ValidationError::InvalidRequires {
225                message: format!(
226                    "pack id {:?} must match [a-z0-9][a-z0-9_-]{{0,63}}",
227                    pr.id
228                ),
229            });
230        }
231        if !seen.insert(pr.id.as_str()) {
232            errors.push(ValidationError::InvalidRequires {
233                message: format!("duplicate pack requirement {:?}", pr.id),
234            });
235        }
236        if let Some(range) = &pr.version
237            && semver::VersionReq::parse(range).is_err()
238        {
239            errors.push(ValidationError::InvalidRequires {
240                message: format!("pack {:?} has unparseable version range {range:?}", pr.id),
241            });
242        }
243        if let Some(rv) = &pr.resolved_version
244            && semver::Version::parse(rv).is_err()
245        {
246            errors.push(ValidationError::InvalidRequires {
247                message: format!("pack {:?} has unparseable resolved_version {rv:?}", pr.id),
248            });
249        }
250        if let Some(hash) = &pr.content_sha256
251            && !is_valid_sha256(hash)
252        {
253            errors.push(ValidationError::InvalidRequires {
254                message: format!(
255                    "pack {:?} content_sha256 must be 64 lowercase hex chars",
256                    pr.id
257                ),
258            });
259        }
260    }
261}
262
263/// Validate a bare [`FlowDefinition`] (the graph only, no envelope fields).
264///
265/// Node types are checked against the current [`crate::SPEC_VERSION`] (v2).
266pub fn validate_definition_only(def: &FlowDefinition) -> Vec<ValidationError> {
267    let mut errors = Vec::new();
268    validate_definition(def, crate::SPEC_VERSION, &mut errors);
269    errors
270}
271
272fn validate_definition(def: &FlowDefinition, spec_version: &str, errors: &mut Vec<ValidationError>) {
273    // Entry-node count.
274    let entry_count = def
275        .nodes
276        .iter()
277        .filter(|n| matches!(n.node_type, FlowNodeType::Core(CoreNodeType::Entry)))
278        .count();
279    if entry_count > 1 {
280        errors.push(ValidationError::MultipleEntryNodes(entry_count));
281    }
282
283    // Duplicate node ids.
284    let mut seen_nodes = HashSet::new();
285    for n in &def.nodes {
286        if !seen_nodes.insert(n.id.as_str()) {
287            errors.push(ValidationError::DuplicateNodeId(n.id.clone()));
288        }
289        match &n.node_type {
290            // Vendor prefix check for custom node types.
291            FlowNodeType::Custom(s) => {
292                if let Some((prefix, _)) = s.split_once(':') {
293                    if !is_valid_vendor(prefix) {
294                        errors.push(ValidationError::InvalidVendorNamespace {
295                            node: n.id.clone(),
296                            node_type: s.clone(),
297                        });
298                    }
299                } else {
300                    // Custom strings without a colon are treated as future core
301                    // types — not vendor-namespaced. They are accepted but flagged
302                    // here as an invalid vendor namespace so authors notice.
303                    errors.push(ValidationError::InvalidVendorNamespace {
304                        node: n.id.clone(),
305                        node_type: s.clone(),
306                    });
307                }
308            }
309            FlowNodeType::Core(core) => {
310                // v2 node types require spec_version "2".
311                if spec_version == "1" && core.is_v2() {
312                    errors.push(ValidationError::V2NodeInV1Document {
313                        node: n.id.clone(),
314                        node_type: core.as_str().to_string(),
315                    });
316                }
317                validate_core_node_data(n, *core, def, errors);
318            }
319        }
320    }
321
322    // Duplicate edge ids + dangling refs.
323    let node_ids: HashSet<&str> = def.nodes.iter().map(|n| n.id.as_str()).collect();
324    let mut seen_edges = HashSet::new();
325    for e in &def.edges {
326        if !seen_edges.insert(e.id.as_str()) {
327            errors.push(ValidationError::DuplicateEdgeId(e.id.clone()));
328        }
329        if !node_ids.contains(e.source.as_str()) {
330            errors.push(ValidationError::DanglingEdgeSource {
331                edge: e.id.clone(),
332                source: e.source.clone(),
333            });
334        }
335        if !node_ids.contains(e.target.as_str()) {
336            errors.push(ValidationError::DanglingEdgeTarget {
337                edge: e.id.clone(),
338                target: e.target.clone(),
339            });
340        }
341    }
342}
343
344/// Whether an edge leaves `node_id` via the named `source_handle`.
345fn has_handle_edge(def: &FlowDefinition, node_id: &str, handle: &str) -> bool {
346    def.edges
347        .iter()
348        .any(|e| e.source == node_id && e.source_handle.as_deref() == Some(handle))
349}
350
351/// Per-node-type `data` validation for the structured core nodes. Best-effort:
352/// only the nodes whose routing/behavior depends on `data` shape are checked
353/// here (`conditional`, `branch`). Effector nodes are validated by the runtime.
354fn validate_core_node_data(
355    node: &FlowNode,
356    core: CoreNodeType,
357    def: &FlowDefinition,
358    errors: &mut Vec<ValidationError>,
359) {
360    match core {
361        CoreNodeType::Conditional => {
362            match serde_json::from_value::<ConditionalData>(node.data.clone()) {
363                Ok(data) => {
364                    for cond in &data.conditions {
365                        if Operator::from_wire(&cond.operator).is_none() {
366                            errors.push(ValidationError::UnknownOperator {
367                                node: node.id.clone(),
368                                operator: cond.operator.clone(),
369                            });
370                        }
371                        if !has_handle_edge(def, &node.id, &cond.handle) {
372                            errors.push(ValidationError::MissingHandleEdge {
373                                node: node.id.clone(),
374                                handle: cond.handle.clone(),
375                            });
376                        }
377                    }
378                    if let Some(dh) = &data.default_handle
379                        && !has_handle_edge(def, &node.id, dh)
380                    {
381                        errors.push(ValidationError::MissingHandleEdge {
382                            node: node.id.clone(),
383                            handle: dh.clone(),
384                        });
385                    }
386                }
387                Err(e) => errors.push(ValidationError::InvalidNodeData {
388                    node: node.id.clone(),
389                    message: format!("expected conditional data: {e}"),
390                }),
391            }
392        }
393        CoreNodeType::Branch => {
394            match serde_json::from_value::<BranchData>(node.data.clone()) {
395                Ok(data) => {
396                    if data.outputs.is_empty() {
397                        errors.push(ValidationError::InvalidNodeData {
398                            node: node.id.clone(),
399                            message: "branch must declare at least one output".into(),
400                        });
401                    }
402                    for out in &data.outputs {
403                        if !has_handle_edge(def, &node.id, &out.handle) {
404                            errors.push(ValidationError::MissingHandleEdge {
405                                node: node.id.clone(),
406                                handle: out.handle.clone(),
407                            });
408                        }
409                        // The reserved `error` rail carries a string reason (see
410                        // `BRANCH_ERROR_HANDLE`). A branch may still declare it
411                        // explicitly so the model can select it, but its payload
412                        // type must stay coherent with the runtime-injected
413                        // reason: string, or schema omitted.
414                        if out.handle == crate::BRANCH_ERROR_HANDLE
415                            && out.schema.as_ref().is_some_and(|s| {
416                                s.get("type").and_then(|t| t.as_str()) != Some("string")
417                            })
418                        {
419                            errors.push(ValidationError::InvalidNodeData {
420                                node: node.id.clone(),
421                                message: "the reserved `error` handle carries a string reason; \
422                                          its schema must be omitted or {\"type\":\"string\"}"
423                                    .into(),
424                            });
425                        }
426                    }
427                    if let Some(dh) = &data.default_handle
428                        && !has_handle_edge(def, &node.id, dh)
429                    {
430                        errors.push(ValidationError::MissingHandleEdge {
431                            node: node.id.clone(),
432                            handle: dh.clone(),
433                        });
434                    }
435                }
436                Err(e) => errors.push(ValidationError::InvalidNodeData {
437                    node: node.id.clone(),
438                    message: format!("expected branch data: {e}"),
439                }),
440            }
441        }
442        // Other core nodes: no data-shape validation at the spec layer.
443        _ => {}
444    }
445}
446
447#[cfg(test)]
448mod tests {
449    use super::*;
450    use crate::model::{FlowEdge, FlowNode};
451    use serde_json::json;
452
453    fn entry(id: &str) -> FlowNode {
454        FlowNode {
455            id: id.into(),
456            node_type: FlowNodeType::Core(CoreNodeType::Entry),
457            data: json!({}),
458            position: [0.0, 0.0],
459        }
460    }
461    fn prompt(id: &str) -> FlowNode {
462        FlowNode {
463            id: id.into(),
464            node_type: FlowNodeType::Core(CoreNodeType::Prompt),
465            data: json!({}),
466            position: [0.0, 0.0],
467        }
468    }
469    fn edge(id: &str, src: &str, tgt: &str) -> FlowEdge {
470        FlowEdge {
471            id: id.into(),
472            source: src.into(),
473            target: tgt.into(),
474            source_handle: None,
475            target_handle: None,
476        }
477    }
478    fn saved(def: FlowDefinition) -> SavedFlow {
479        SavedFlow {
480            spec_version: "1".into(),
481            id: "ok-id".into(),
482            name: "X".into(),
483            created_at: "2026-01-01T00:00:00Z".into(),
484            updated_at: "2026-01-01T00:00:00Z".into(),
485            enabled: false,
486            schedules: vec![],
487            requires: None,
488            flow: def,
489        }
490    }
491
492    #[test]
493    fn valid_minimal_flow_has_no_errors() {
494        let def = FlowDefinition {
495            nodes: vec![entry("e")],
496            edges: vec![],
497        };
498        assert!(validate(&saved(def)).is_empty());
499    }
500
501    #[test]
502    fn invalid_flow_id_caught() {
503        let mut sf = saved(FlowDefinition::default());
504        sf.id = "bad id with spaces".into();
505        let errs = validate(&sf);
506        assert!(errs.iter().any(|e| matches!(e, ValidationError::InvalidFlowId(_))));
507    }
508
509    #[test]
510    fn multiple_entries_caught() {
511        let def = FlowDefinition {
512            nodes: vec![entry("a"), entry("b")],
513            edges: vec![],
514        };
515        let errs = validate(&saved(def));
516        assert!(errs
517            .iter()
518            .any(|e| matches!(e, ValidationError::MultipleEntryNodes(2))));
519    }
520
521    #[test]
522    fn dangling_edge_caught() {
523        let def = FlowDefinition {
524            nodes: vec![entry("e")],
525            edges: vec![edge("x", "e", "missing")],
526        };
527        let errs = validate(&saved(def));
528        assert!(errs
529            .iter()
530            .any(|e| matches!(e, ValidationError::DanglingEdgeTarget { .. })));
531    }
532
533    #[test]
534    fn duplicate_node_id_caught() {
535        let def = FlowDefinition {
536            nodes: vec![entry("e"), prompt("e")],
537            edges: vec![],
538        };
539        let errs = validate(&saved(def));
540        assert!(errs.iter().any(|e| matches!(e, ValidationError::DuplicateNodeId(_))));
541    }
542
543    #[test]
544    fn both_spec_versions_accepted() {
545        let mut sf = saved(FlowDefinition {
546            nodes: vec![entry("e")],
547            edges: vec![],
548        });
549        sf.spec_version = "1".into();
550        assert!(validate(&sf).is_empty());
551        sf.spec_version = "2".into();
552        assert!(validate(&sf).is_empty());
553    }
554
555    #[test]
556    fn unsupported_spec_version_caught() {
557        let mut sf = saved(FlowDefinition::default());
558        sf.spec_version = "3".into();
559        let errs = validate(&sf);
560        assert!(errs
561            .iter()
562            .any(|e| matches!(e, ValidationError::UnsupportedSpecVersion(_))));
563    }
564
565    fn core(id: &str, ty: CoreNodeType, data: serde_json::Value) -> FlowNode {
566        FlowNode {
567            id: id.into(),
568            node_type: FlowNodeType::Core(ty),
569            data,
570            position: [0.0, 0.0],
571        }
572    }
573    fn eh(id: &str, src: &str, tgt: &str, handle: &str) -> FlowEdge {
574        FlowEdge {
575            id: id.into(),
576            source: src.into(),
577            target: tgt.into(),
578            source_handle: Some(handle.into()),
579            target_handle: None,
580        }
581    }
582
583    #[test]
584    fn v2_node_in_v1_document_caught() {
585        let def = FlowDefinition {
586            nodes: vec![entry("e"), core("c", CoreNodeType::Conditional, json!({ "conditions": [] }))],
587            edges: vec![edge("x", "e", "c")],
588        };
589        let mut sf = saved(def);
590        sf.spec_version = "1".into();
591        let errs = validate(&sf);
592        assert!(errs.iter().any(|e| matches!(e, ValidationError::V2NodeInV1Document { .. })));
593    }
594
595    #[test]
596    fn valid_conditional_v2_passes() {
597        let def = FlowDefinition {
598            nodes: vec![
599                entry("e"),
600                core("c", CoreNodeType::Conditional, json!({
601                    "conditions": [ { "handle": "hot", "variable": "_last", "operator": "gt", "value": 50 } ],
602                    "default_handle": "cold"
603                })),
604                prompt("hot_node"),
605                prompt("cold_node"),
606            ],
607            edges: vec![
608                edge("e0", "e", "c"),
609                eh("e1", "c", "hot_node", "hot"),
610                eh("e2", "c", "cold_node", "cold"),
611            ],
612        };
613        let mut sf = saved(def);
614        sf.spec_version = "2".into();
615        assert!(validate(&sf).is_empty(), "{:?}", validate(&sf));
616    }
617
618    #[test]
619    fn conditional_unknown_operator_and_missing_edge_caught() {
620        let def = FlowDefinition {
621            nodes: vec![
622                entry("e"),
623                core("c", CoreNodeType::Conditional, json!({
624                    "conditions": [ { "handle": "hot", "variable": "_last", "operator": "bogus", "value": 1 } ]
625                })),
626            ],
627            edges: vec![edge("e0", "e", "c")], // no "hot" handle edge
628        };
629        let mut sf = saved(def);
630        sf.spec_version = "2".into();
631        let errs = validate(&sf);
632        assert!(errs.iter().any(|e| matches!(e, ValidationError::UnknownOperator { .. })));
633        assert!(errs.iter().any(|e| matches!(e, ValidationError::MissingHandleEdge { .. })));
634    }
635
636    #[test]
637    fn branch_bad_data_caught() {
638        let def = FlowDefinition {
639            nodes: vec![
640                entry("e"),
641                // missing required `query` and `outputs`
642                core("b", CoreNodeType::Branch, json!({ "persona": "weather-agent" })),
643            ],
644            edges: vec![edge("e0", "e", "b")],
645        };
646        let mut sf = saved(def);
647        sf.spec_version = "2".into();
648        let errs = validate(&sf);
649        assert!(errs.iter().any(|e| matches!(e, ValidationError::InvalidNodeData { .. })));
650    }
651
652    #[test]
653    fn branch_error_handle_with_nonstring_schema_caught() {
654        // A branch that declares the reserved `error` handle with an object
655        // schema conflicts with the string reason the runtime injects.
656        let def = FlowDefinition {
657            nodes: vec![
658                entry("e"),
659                core("b", CoreNodeType::Branch, json!({
660                    "query": "classify",
661                    "outputs": [
662                        { "handle": "ok", "schema": { "type": "string" } },
663                        { "handle": "error", "schema": { "type": "object" } }
664                    ]
665                })),
666                prompt("ok_t"),
667                prompt("err_t"),
668            ],
669            edges: vec![
670                edge("e0", "e", "b"),
671                eh("e1", "b", "ok_t", "ok"),
672                eh("e2", "b", "err_t", "error"),
673            ],
674        };
675        let mut sf = saved(def);
676        sf.spec_version = "2".into();
677        let errs = validate(&sf);
678        assert!(
679            errs.iter().any(|e| matches!(
680                e,
681                ValidationError::InvalidNodeData { node, message }
682                    if node == "b" && message.contains("`error` handle")
683            )),
684            "expected reserved-error-handle error, got {errs:?}"
685        );
686    }
687
688    #[test]
689    fn branch_error_handle_with_string_schema_passes() {
690        // The same branch with a string-typed (or omitted) `error` schema is fine.
691        let def = FlowDefinition {
692            nodes: vec![
693                entry("e"),
694                core("b", CoreNodeType::Branch, json!({
695                    "query": "classify",
696                    "outputs": [
697                        { "handle": "ok", "schema": { "type": "string" } },
698                        { "handle": "error", "schema": { "type": "string" } }
699                    ]
700                })),
701                prompt("ok_t"),
702                prompt("err_t"),
703            ],
704            edges: vec![
705                edge("e0", "e", "b"),
706                eh("e1", "b", "ok_t", "ok"),
707                eh("e2", "b", "err_t", "error"),
708            ],
709        };
710        let mut sf = saved(def);
711        sf.spec_version = "2".into();
712        assert!(validate(&sf).is_empty(), "{:?}", validate(&sf));
713    }
714
715    #[test]
716    fn well_formed_custom_type_passes() {
717        let mut p = prompt("p");
718        p.node_type = FlowNodeType::Custom("slack:send_message".into());
719        let def = FlowDefinition {
720            nodes: vec![entry("e"), p],
721            edges: vec![edge("x", "e", "p")],
722        };
723        assert!(validate(&saved(def)).is_empty());
724    }
725
726    #[test]
727    fn malformed_custom_type_caught() {
728        let mut p = prompt("p");
729        p.node_type = FlowNodeType::Custom("BadVendor:thing".into());
730        let def = FlowDefinition {
731            nodes: vec![entry("e"), p],
732            edges: vec![],
733        };
734        let errs = validate(&saved(def));
735        assert!(errs
736            .iter()
737            .any(|e| matches!(e, ValidationError::InvalidVendorNamespace { .. })));
738    }
739
740    #[test]
741    fn custom_type_without_colon_caught() {
742        let mut p = prompt("p");
743        p.node_type = FlowNodeType::Custom("no_namespace".into());
744        let def = FlowDefinition {
745            nodes: vec![entry("e"), p],
746            edges: vec![],
747        };
748        let errs = validate(&saved(def));
749        assert!(errs
750            .iter()
751            .any(|e| matches!(e, ValidationError::InvalidVendorNamespace { .. })));
752    }
753
754    #[test]
755    fn well_formed_requires_passes() {
756        let mut sf = saved(FlowDefinition {
757            nodes: vec![entry("e")],
758            edges: vec![],
759        });
760        sf.requires = Some(crate::requires::Requires {
761            packs: vec![crate::requires::PackRequirement {
762                id: "cloudflare".into(),
763                version: Some(">=1.2.0, <2.0.0".into()),
764                content_sha256: Some("a1b2c3d4".repeat(8)),
765                resolved_version: Some("1.3.1".into()),
766                ..crate::requires::PackRequirement::new("cloudflare")
767            }],
768            tools: vec!["cloudflare_purge_cache".into()],
769        });
770        assert!(validate(&sf).is_empty(), "{:?}", validate(&sf));
771    }
772
773    #[test]
774    fn malformed_requires_caught() {
775        for req in [
776            // bad id
777            crate::requires::Requires {
778                packs: vec![crate::requires::PackRequirement::new("Bad Id")],
779                tools: vec![],
780            },
781            // unparseable version range
782            crate::requires::Requires {
783                packs: vec![crate::requires::PackRequirement {
784                    version: Some("not-a-range".into()),
785                    ..crate::requires::PackRequirement::new("cloudflare")
786                }],
787                tools: vec![],
788            },
789            // bad hash
790            crate::requires::Requires {
791                packs: vec![crate::requires::PackRequirement {
792                    content_sha256: Some("tooshort".into()),
793                    ..crate::requires::PackRequirement::new("cloudflare")
794                }],
795                tools: vec![],
796            },
797            // duplicate id
798            crate::requires::Requires {
799                packs: vec![
800                    crate::requires::PackRequirement::new("cloudflare"),
801                    crate::requires::PackRequirement::new("cloudflare"),
802                ],
803                tools: vec![],
804            },
805        ] {
806            let mut sf = saved(FlowDefinition {
807                nodes: vec![entry("e")],
808                edges: vec![],
809            });
810            sf.requires = Some(req);
811            let errs = validate(&sf);
812            assert!(
813                errs.iter()
814                    .any(|e| matches!(e, ValidationError::InvalidRequires { .. })),
815                "expected InvalidRequires, got {errs:?}"
816            );
817        }
818    }
819}