use std::collections::{BTreeSet, HashMap, HashSet};
use crate::document::{BranchCondition, FoldBody, FoldJoin, Graph, MapBody, Node, SCHEMA_VERSION};
use crate::expr;
pub const MAX_NODE_NAME_LEN: usize = 64;
#[derive(Clone, Debug, PartialEq, Eq, thiserror::Error)]
pub enum GraphError {
#[error("unsupported schema_version {found}: this build understands versions 1..={supported}")]
UnsupportedSchemaVersion {
found: u32,
supported: u32,
},
#[error("duplicate node id `{id}`")]
DuplicateNodeId {
id: String,
},
#[error("edge `{from}` -> `{to}` references unknown node id `{missing}`{}", suggest(.suggestion))]
DanglingEdge {
from: String,
to: String,
missing: String,
suggestion: Option<String>,
},
#[error("map node `{id}` maps unknown node id `{missing}`{}", suggest(.suggestion))]
DanglingMapBody {
id: String,
missing: String,
suggestion: Option<String>,
},
#[error("fold node `{id}` folds unknown node id `{missing}`{}", suggest(.suggestion))]
DanglingFoldBody {
id: String,
missing: String,
suggestion: Option<String>,
},
#[error("agent node `{id}`: `{hash}` is not a well-formed `sha256:<64 hex>` agent hash")]
MalformedAgentHash {
id: String,
hash: String,
},
#[error("map node `{id}`: concurrency cap must be at least 1, found {found}")]
NonPositiveConcurrency {
id: String,
found: u32,
},
#[error("fold node `{id}`: max_iterations must be at least 1, found {found}")]
NonPositiveMaxIterations {
id: String,
found: u32,
},
#[error("gate node `{id}`: approval_schema must be a JSON object")]
ApprovalSchemaNotObject {
id: String,
},
#[error("cycle detected: {path}")]
Cycle {
path: String,
},
#[error(
"edge `{from}` -> `{to}`: the output schema of `{from}` does not match the input schema of `{to}`"
)]
EdgeTypeMismatch {
from: String,
to: String,
},
#[error("branch node `{node}`: case `{case}` has an invalid condition expression: {error}")]
InvalidBranchExpression {
node: String,
case: String,
error: String,
},
#[error(
"branch node `{node}`: case `{case}` is a model decision but the branch declares no `agent_hash`"
)]
ModelDecisionWithoutAgent {
node: String,
case: String,
},
#[error("fold node `{node}`: `stop_when` is not a valid condition expression: {error}")]
InvalidFoldStopExpression {
node: String,
error: String,
},
#[error(
"fold node `{node}`: the `best_by` join reference `{reference}` is not a valid path: {error}"
)]
InvalidFoldJoinReference {
node: String,
reference: String,
error: String,
},
#[error("node `{id}`: `name` is {len} characters, over the {max}-character cap")]
NodeNameTooLong {
id: String,
len: usize,
max: usize,
},
#[error("node `{id}`: `name`, if set, must not be empty or all whitespace")]
BlankNodeName {
id: String,
},
}
fn suggest(suggestion: &Option<String>) -> String {
match suggestion {
Some(name) => format!(" (did you mean `{name}`?)"),
None => String::new(),
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct GraphSummary {
pub node_count: usize,
pub edge_count: usize,
pub entry_nodes: Vec<String>,
pub terminal_nodes: Vec<String>,
}
pub fn validate(graph: &Graph) -> Result<GraphSummary, Vec<GraphError>> {
let mut errors = Vec::new();
check_schema_version(graph, &mut errors);
check_unique_node_ids(graph, &mut errors);
check_referential_integrity(graph, &mut errors);
check_node_fields(graph, &mut errors);
check_node_names(graph, &mut errors);
check_branch_expressions(graph, &mut errors);
check_fold_expressions(graph, &mut errors);
check_acyclic(graph, &mut errors);
check_edge_type_compat(graph, &mut errors);
if errors.is_empty() {
Ok(summarize(graph))
} else {
Err(errors)
}
}
fn check_schema_version(graph: &Graph, errors: &mut Vec<GraphError>) {
if graph.schema_version == 0 || graph.schema_version > SCHEMA_VERSION {
errors.push(GraphError::UnsupportedSchemaVersion {
found: graph.schema_version,
supported: SCHEMA_VERSION,
});
}
}
fn check_unique_node_ids(graph: &Graph, errors: &mut Vec<GraphError>) {
let mut seen = HashSet::new();
for node in &graph.nodes {
if !seen.insert(node.id()) {
errors.push(GraphError::DuplicateNodeId {
id: node.id().to_owned(),
});
}
}
}
fn check_referential_integrity(graph: &Graph, errors: &mut Vec<GraphError>) {
let ids: BTreeSet<&str> = graph.nodes.iter().map(Node::id).collect();
for edge in &graph.edges {
if !ids.contains(edge.from.as_str()) {
errors.push(GraphError::DanglingEdge {
from: edge.from.clone(),
to: edge.to.clone(),
missing: edge.from.clone(),
suggestion: nearest(&edge.from, &ids),
});
}
if !ids.contains(edge.to.as_str()) {
errors.push(GraphError::DanglingEdge {
from: edge.from.clone(),
to: edge.to.clone(),
missing: edge.to.clone(),
suggestion: nearest(&edge.to, &ids),
});
}
}
for node in &graph.nodes {
if let Node::Map(map) = node
&& let MapBody::Node(target) = &map.body
&& !ids.contains(target.as_str())
{
errors.push(GraphError::DanglingMapBody {
id: map.id.clone(),
missing: target.clone(),
suggestion: nearest(target, &ids),
});
}
if let Node::Fold(fold) = node
&& let FoldBody::Node(target) = &fold.body
&& !ids.contains(target.as_str())
{
errors.push(GraphError::DanglingFoldBody {
id: fold.id.clone(),
missing: target.clone(),
suggestion: nearest(target, &ids),
});
}
}
}
fn check_node_fields(graph: &Graph, errors: &mut Vec<GraphError>) {
for node in &graph.nodes {
match node {
Node::Agent(agent) => {
if !is_well_formed_agent_hash(&agent.agent_hash) {
errors.push(GraphError::MalformedAgentHash {
id: agent.id.clone(),
hash: agent.agent_hash.clone(),
});
}
}
Node::Map(map) => {
if map.concurrency < 1 {
errors.push(GraphError::NonPositiveConcurrency {
id: map.id.clone(),
found: map.concurrency,
});
}
}
Node::Gate(gate) => {
if !gate.approval_schema.is_object() {
errors.push(GraphError::ApprovalSchemaNotObject {
id: gate.id.clone(),
});
}
}
Node::Fold(fold) => {
if fold.max_iterations < 1 {
errors.push(GraphError::NonPositiveMaxIterations {
id: fold.id.clone(),
found: fold.max_iterations,
});
}
}
Node::Tool(_) | Node::Branch(_) => {}
}
}
}
fn check_node_names(graph: &Graph, errors: &mut Vec<GraphError>) {
for node in &graph.nodes {
let Some(name) = node.name() else {
continue;
};
if name.trim().is_empty() {
errors.push(GraphError::BlankNodeName {
id: node.id().to_owned(),
});
continue;
}
let len = name.chars().count();
if len > MAX_NODE_NAME_LEN {
errors.push(GraphError::NodeNameTooLong {
id: node.id().to_owned(),
len,
max: MAX_NODE_NAME_LEN,
});
}
}
}
fn check_branch_expressions(graph: &Graph, errors: &mut Vec<GraphError>) {
for node in &graph.nodes {
let Node::Branch(branch) = node else {
continue;
};
if let Some(hash) = &branch.agent_hash
&& !is_well_formed_agent_hash(hash)
{
errors.push(GraphError::MalformedAgentHash {
id: branch.id.clone(),
hash: hash.clone(),
});
}
for case in &branch.cases {
match &case.when {
BranchCondition::Expression(source) => {
if let Err(error) = expr::parse(source) {
errors.push(GraphError::InvalidBranchExpression {
node: branch.id.clone(),
case: case.name.clone(),
error: error.to_string(),
});
}
}
BranchCondition::ModelDecision => {
if branch.agent_hash.is_none() {
errors.push(GraphError::ModelDecisionWithoutAgent {
node: branch.id.clone(),
case: case.name.clone(),
});
}
}
}
}
}
}
fn check_fold_expressions(graph: &Graph, errors: &mut Vec<GraphError>) {
for node in &graph.nodes {
let Node::Fold(fold) = node else {
continue;
};
if let Err(error) = expr::parse(&fold.stop_when) {
errors.push(GraphError::InvalidFoldStopExpression {
node: fold.id.clone(),
error: error.to_string(),
});
}
if let FoldJoin::BestBy(reference) = &fold.join
&& let Err(error) = expr::parse_reference(reference)
{
errors.push(GraphError::InvalidFoldJoinReference {
node: fold.id.clone(),
reference: reference.clone(),
error: error.to_string(),
});
}
}
}
fn is_well_formed_agent_hash(hash: &str) -> bool {
let Some(hex) = hash.strip_prefix("sha256:") else {
return false;
};
hex.len() == 64
&& hex
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b))
}
fn check_acyclic(graph: &Graph, errors: &mut Vec<GraphError>) {
let ids: HashSet<&str> = graph.nodes.iter().map(Node::id).collect();
let mut adjacency: HashMap<&str, Vec<&str>> = HashMap::new();
for edge in &graph.edges {
if ids.contains(edge.from.as_str()) && ids.contains(edge.to.as_str()) {
adjacency
.entry(edge.from.as_str())
.or_default()
.push(edge.to.as_str());
}
}
#[derive(Clone, Copy, PartialEq)]
enum Color {
White,
Gray,
Black,
}
let mut color: HashMap<&str, Color> = ids.iter().map(|id| (*id, Color::White)).collect();
let mut stack: Vec<&str> = Vec::new();
for start in graph.nodes.iter().map(Node::id) {
if color[start] != Color::White {
continue;
}
let mut frames: Vec<(&str, usize)> = vec![(start, 0)];
color.insert(start, Color::Gray);
stack.push(start);
while let Some(&mut (node, ref mut next)) = frames.last_mut() {
let neighbors = adjacency.get(node).map_or(&[][..], Vec::as_slice);
if *next < neighbors.len() {
let neighbor = neighbors[*next];
*next += 1;
match color[neighbor] {
Color::White => {
color.insert(neighbor, Color::Gray);
stack.push(neighbor);
frames.push((neighbor, 0));
}
Color::Gray => {
let start_at = stack.iter().position(|n| *n == neighbor).unwrap_or(0);
let mut path: Vec<&str> = stack[start_at..].to_vec();
path.push(neighbor);
errors.push(GraphError::Cycle {
path: path.join(" -> "),
});
return;
}
Color::Black => {}
}
} else {
color.insert(node, Color::Black);
stack.pop();
frames.pop();
}
}
}
}
fn check_edge_type_compat(graph: &Graph, errors: &mut Vec<GraphError>) {
let by_id: HashMap<&str, &Node> = graph.nodes.iter().map(|n| (n.id(), n)).collect();
for edge in &graph.edges {
let (Some(from), Some(to)) = (by_id.get(edge.from.as_str()), by_id.get(edge.to.as_str()))
else {
continue;
};
if let (Some(out), Some(inp)) = (from.output_schema(), to.input_schema())
&& out != inp
{
errors.push(GraphError::EdgeTypeMismatch {
from: edge.from.clone(),
to: edge.to.clone(),
});
}
}
}
fn summarize(graph: &Graph) -> GraphSummary {
let has_inbound: HashSet<&str> = graph.edges.iter().map(|e| e.to.as_str()).collect();
let has_outbound: HashSet<&str> = graph.edges.iter().map(|e| e.from.as_str()).collect();
let mut entry_nodes: Vec<String> = graph
.nodes
.iter()
.map(Node::id)
.filter(|id| !has_inbound.contains(id))
.map(str::to_owned)
.collect();
let mut terminal_nodes: Vec<String> = graph
.nodes
.iter()
.map(Node::id)
.filter(|id| !has_outbound.contains(id))
.map(str::to_owned)
.collect();
entry_nodes.sort();
terminal_nodes.sort();
GraphSummary {
node_count: graph.nodes.len(),
edge_count: graph.edges.len(),
entry_nodes,
terminal_nodes,
}
}
fn nearest(missing: &str, ids: &BTreeSet<&str>) -> Option<String> {
let mut best: Option<(usize, &str)> = None;
for candidate in ids {
let distance = levenshtein(missing, candidate);
if best.is_none_or(|(d, _)| distance < d) {
best = Some((distance, candidate));
}
}
best.and_then(|(distance, candidate)| {
let threshold = (missing.len().max(candidate.len()) / 3).max(1);
(distance <= threshold).then(|| candidate.to_owned())
})
}
fn levenshtein(a: &str, b: &str) -> usize {
let a = a.as_bytes();
let b = b.as_bytes();
let mut previous: Vec<usize> = (0..=b.len()).collect();
let mut current = vec![0usize; b.len() + 1];
for (i, &ac) in a.iter().enumerate() {
current[0] = i + 1;
for (j, &bc) in b.iter().enumerate() {
let cost = usize::from(ac != bc);
current[j + 1] = (previous[j + 1] + 1)
.min(current[j] + 1)
.min(previous[j] + cost);
}
std::mem::swap(&mut previous, &mut current);
}
previous[b.len()]
}
#[cfg(test)]
mod tests {
use super::*;
use crate::document::{
AgentNode, BranchCase, BranchCondition, BranchNode, Edge, FoldBody, FoldJoin, FoldNode,
GateNode, MapBody, MapNode, ToolNode,
};
use serde_json::json;
use std::collections::BTreeMap;
fn hash() -> String {
format!("sha256:{}", "a".repeat(64))
}
fn agent(id: &str) -> Node {
Node::Agent(AgentNode {
name: None,
id: id.into(),
agent_hash: hash(),
input_schema: None,
output_schema: None,
})
}
fn gate(id: &str) -> Node {
Node::Gate(GateNode {
name: None,
id: id.into(),
prompt: None,
approval_schema: json!({"type": "object"}),
})
}
fn edge(from: &str, to: &str) -> Edge {
Edge {
from: from.into(),
to: to.into(),
label: None,
}
}
fn graph(nodes: Vec<Node>, edges: Vec<Edge>) -> Graph {
Graph {
schema_version: SCHEMA_VERSION,
nodes,
edges,
}
}
#[test]
fn valid_linear_graph_summarizes() {
let g = graph(
vec![agent("research"), agent("review"), gate("approve")],
vec![edge("research", "review"), edge("review", "approve")],
);
let summary = validate(&g).expect("valid");
assert_eq!(summary.node_count, 3);
assert_eq!(summary.edge_count, 2);
assert_eq!(summary.entry_nodes, vec!["research"]);
assert_eq!(summary.terminal_nodes, vec!["approve"]);
}
#[test]
fn dangling_edge_is_reported_with_suggestion() {
let g = graph(vec![agent("research")], vec![edge("research", "reviewx")]);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::DanglingEdge {
from: "research".into(),
to: "reviewx".into(),
missing: "reviewx".into(),
suggestion: Some("research".into()),
}) || matches!(
errors.first(),
Some(GraphError::DanglingEdge { missing, .. }) if missing == "reviewx"
)
);
let message = errors[0].to_string();
assert!(
message.contains("reviewx"),
"names the missing id: {message}"
);
}
#[test]
fn malformed_agent_hash_is_reported() {
let g = graph(
vec![Node::Agent(AgentNode {
name: None,
id: "research".into(),
agent_hash: "sha256:not-hex".into(),
input_schema: None,
output_schema: None,
})],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert_eq!(
errors,
vec![GraphError::MalformedAgentHash {
id: "research".into(),
hash: "sha256:not-hex".into(),
}]
);
}
#[test]
fn non_positive_concurrency_is_reported() {
let g = graph(
vec![
agent("worker"),
Node::Map(MapNode {
name: None,
id: "fanout".into(),
over: "items".into(),
concurrency: 0,
body: MapBody::Node("worker".into()),
output_schema: None,
}),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::NonPositiveConcurrency {
id: "fanout".into(),
found: 0,
}));
}
#[test]
fn dangling_map_body_is_reported() {
let g = graph(
vec![Node::Map(MapNode {
name: None,
id: "fanout".into(),
over: "items".into(),
concurrency: 2,
body: MapBody::Node("ghost".into()),
output_schema: None,
})],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::DanglingMapBody {
id: "fanout".into(),
missing: "ghost".into(),
suggestion: None,
}));
}
#[test]
fn cycle_is_reported_with_path() {
let g = graph(
vec![agent("a"), agent("b"), agent("c")],
vec![edge("a", "b"), edge("b", "c"), edge("c", "a")],
);
let errors = validate(&g).expect_err("invalid");
let cycle = errors
.iter()
.find_map(|e| match e {
GraphError::Cycle { path } => Some(path.clone()),
_ => None,
})
.expect("a cycle error");
assert!(cycle.starts_with("a -> "), "path from a: {cycle}");
assert!(cycle.ends_with("-> a"), "path closes on a: {cycle}");
}
#[test]
fn edge_type_mismatch_is_reported() {
let producer = Node::Agent(AgentNode {
name: None,
id: "producer".into(),
agent_hash: hash(),
input_schema: None,
output_schema: Some(json!({"type": "string"})),
});
let consumer = Node::Tool(ToolNode {
name: None,
id: "consumer".into(),
tool: "t".into(),
input: BTreeMap::new(),
input_schema: Some(json!({"type": "number"})),
output_schema: None,
});
let g = graph(vec![producer, consumer], vec![edge("producer", "consumer")]);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::EdgeTypeMismatch {
from: "producer".into(),
to: "consumer".into(),
}));
}
#[test]
fn matching_edge_schemas_pass() {
let producer = Node::Agent(AgentNode {
name: None,
id: "producer".into(),
agent_hash: hash(),
input_schema: None,
output_schema: Some(json!({"type": "string"})),
});
let consumer = Node::Tool(ToolNode {
name: None,
id: "consumer".into(),
tool: "t".into(),
input: BTreeMap::new(),
input_schema: Some(json!({"type": "string"})),
output_schema: None,
});
let g = graph(vec![producer, consumer], vec![edge("producer", "consumer")]);
assert!(validate(&g).is_ok());
}
#[test]
fn future_schema_version_is_rejected() {
let mut g = graph(vec![agent("a")], vec![]);
g.schema_version = SCHEMA_VERSION + 1;
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::UnsupportedSchemaVersion {
found: SCHEMA_VERSION + 1,
supported: SCHEMA_VERSION,
}));
}
#[test]
fn all_errors_are_collected() {
let g = graph(
vec![
Node::Agent(AgentNode {
name: None,
id: "bad".into(),
agent_hash: "nope".into(),
input_schema: None,
output_schema: None,
}),
agent("bad"), ],
vec![edge("bad", "missing")],
);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.len() >= 3,
"duplicate id, malformed hash, and dangling edge: {errors:?}"
);
}
#[test]
fn duplicate_node_id_is_reported() {
let g = graph(vec![agent("dup"), gate("dup")], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::DuplicateNodeId { id: "dup".into() }));
}
#[test]
fn valid_branch_expression_passes() {
let branch = Node::Branch(BranchNode {
name: None,
id: "route".into(),
on: Some("score".into()),
agent_hash: Some(hash()),
cases: vec![
BranchCase {
name: "high".into(),
when: BranchCondition::Expression("score > 0.8".into()),
},
BranchCase {
name: "review".into(),
when: BranchCondition::ModelDecision,
},
],
});
let g = graph(vec![agent("score"), branch], vec![edge("score", "route")]);
assert!(validate(&g).is_ok());
}
#[test]
fn invalid_branch_expression_is_reported() {
let branch = Node::Branch(BranchNode {
name: None,
id: "route".into(),
on: None,
agent_hash: Some(hash()),
cases: vec![
BranchCase {
name: "broken".into(),
when: BranchCondition::Expression("score >".into()),
},
BranchCase {
name: "fallback".into(),
when: BranchCondition::ModelDecision,
},
],
});
let g = graph(vec![branch], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(
matches!(
errors.as_slice(),
[GraphError::InvalidBranchExpression { node, case, .. }]
if node == "route" && case == "broken"
),
"one node/case-precise expression error: {errors:?}"
);
}
#[test]
fn model_decision_without_agent_is_reported() {
let branch = Node::Branch(BranchNode {
name: None,
id: "route".into(),
on: None,
agent_hash: None,
cases: vec![BranchCase {
name: "ask".into(),
when: BranchCondition::ModelDecision,
}],
});
let g = graph(vec![branch], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::ModelDecisionWithoutAgent {
node: "route".into(),
case: "ask".into(),
}),
"names the node and case: {errors:?}"
);
}
#[test]
fn malformed_branch_agent_hash_is_reported() {
let branch = Node::Branch(BranchNode {
name: None,
id: "route".into(),
on: None,
agent_hash: Some("sha256:not-hex".into()),
cases: vec![BranchCase {
name: "ask".into(),
when: BranchCondition::ModelDecision,
}],
});
let g = graph(vec![branch], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::MalformedAgentHash {
id: "route".into(),
hash: "sha256:not-hex".into(),
}),
"names the branch node and its malformed hash: {errors:?}"
);
}
fn fold(id: &str, body: &str, max_iterations: u32, stop_when: &str, join: FoldJoin) -> Node {
Node::Fold(FoldNode {
id: id.into(),
name: None,
body: FoldBody::Node(body.into()),
max_iterations,
stop_when: stop_when.into(),
join,
accumulator_schema: None,
})
}
#[test]
fn valid_fold_node_passes() {
let g = graph(
vec![
agent("tailor"),
fold(
"refine",
"tailor",
3,
"score >= 0.85",
FoldJoin::BestBy("score".into()),
),
],
vec![],
);
assert!(validate(&g).is_ok(), "{:?}", validate(&g));
}
#[test]
fn fold_with_unit_joins_passes() {
for join in [FoldJoin::Last, FoldJoin::All] {
let g = graph(
vec![agent("tailor"), fold("refine", "tailor", 2, "done", join)],
vec![],
);
assert!(validate(&g).is_ok());
}
}
#[test]
fn non_positive_max_iterations_is_reported() {
let g = graph(
vec![
agent("tailor"),
fold("refine", "tailor", 0, "done", FoldJoin::Last),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::NonPositiveMaxIterations {
id: "refine".into(),
found: 0,
}));
}
#[test]
fn dangling_fold_body_is_reported() {
let g = graph(
vec![fold("refine", "ghost", 2, "done", FoldJoin::Last)],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(errors.contains(&GraphError::DanglingFoldBody {
id: "refine".into(),
missing: "ghost".into(),
suggestion: None,
}));
}
#[test]
fn invalid_fold_stop_expression_is_reported() {
let g = graph(
vec![
agent("tailor"),
fold("refine", "tailor", 2, "score >", FoldJoin::Last),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(
matches!(
errors.as_slice(),
[GraphError::InvalidFoldStopExpression { node, .. }] if node == "refine"
),
"one node-precise stop-expression error: {errors:?}"
);
}
#[test]
fn invalid_fold_join_reference_is_reported() {
let g = graph(
vec![
agent("tailor"),
fold("refine", "tailor", 2, "done", FoldJoin::BestBy("42".into())),
],
vec![],
);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.iter().any(
|e| matches!(e, GraphError::InvalidFoldJoinReference { node, reference, .. }
if node == "refine" && reference == "42")
),
"names the node and the bad reference: {errors:?}"
);
}
#[test]
fn node_name_at_the_cap_is_valid() {
let mut named = agent("research");
if let Node::Agent(a) = &mut named {
a.name = Some("a".repeat(MAX_NODE_NAME_LEN));
}
let g = graph(
vec![named, agent("review")],
vec![edge("research", "review")],
);
assert!(validate(&g).is_ok());
}
#[test]
fn node_name_too_long_is_reported() {
let mut named = agent("research");
let long_name = "é".repeat(MAX_NODE_NAME_LEN + 1);
if let Node::Agent(a) = &mut named {
a.name = Some(long_name.clone());
}
let g = graph(vec![named], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::NodeNameTooLong {
id: "research".into(),
len: MAX_NODE_NAME_LEN + 1,
max: MAX_NODE_NAME_LEN,
}),
"names the node and the character count, not the byte count: {errors:?}"
);
}
#[test]
fn blank_node_name_is_reported() {
for blank in ["", " ", "\t\n"] {
let mut named = gate("approve");
if let Node::Gate(g) = &mut named {
g.name = Some(blank.to_owned());
}
let g = graph(vec![named], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::BlankNodeName {
id: "approve".into(),
}),
"blank name {blank:?} should be reported: {errors:?}"
);
}
}
#[test]
fn multiple_node_name_errors_are_all_collected() {
let mut blank = agent("research");
if let Node::Agent(a) = &mut blank {
a.name = Some(" ".into());
}
let mut long = gate("approve");
if let Node::Gate(g) = &mut long {
g.name = Some("x".repeat(MAX_NODE_NAME_LEN + 5));
}
let g = graph(vec![blank, long], vec![]);
let errors = validate(&g).expect_err("invalid");
assert!(
errors.contains(&GraphError::BlankNodeName {
id: "research".into(),
}),
"{errors:?}"
);
assert!(
errors.contains(&GraphError::NodeNameTooLong {
id: "approve".into(),
len: MAX_NODE_NAME_LEN + 5,
max: MAX_NODE_NAME_LEN,
}),
"{errors:?}"
);
}
}