use agileplus_domain::intent_graph::*;
use thiserror::Error;
mod canonical;
mod dag;
mod edges;
mod metadata;
mod nodes;
mod ontology;
pub mod ontology_ext;
pub use canonical::validate_canonical_map;
pub use dag::{validate_dag_acyclicity, validate_dag_flow};
pub use edges::validate_edge_constraints;
pub use metadata::validate_metadata;
pub use nodes::{validate_node_completeness, validate_node_ids_only};
pub use ontology::{
DagFlow, DagFlowStage, DagValidationRules, EdgeConstraint, EdgeConstraintEntry,
EdgeConstraints, Ontology,
};
pub use ontology_ext::{
extended_relationship_of, validate_extended_edge, validate_extended_edge_constraints,
EXTENDED_RELATIONSHIP_PROPERTY,
};
#[derive(Error, Debug, Clone, PartialEq)]
pub enum ValidationError {
#[error("Node ID format error: {0}")]
InvalidNodeId(String),
#[error("Missing required field: {0}")]
MissingRequiredField(String),
#[error("Edge constraint violation: {0}")]
EdgeConstraintViolation(String),
#[error("Cycle detected: {0}")]
CycleDetected(String),
#[error("DAG flow violation: {0}")]
DagFlowViolation(String),
#[error("Metadata error: {0}")]
MetadataError(String),
#[error("Canonical map error: {0}")]
CanonicalMapError(String),
#[error("Unknown node referenced in edge: {0}")]
UnknownNode(String),
#[error("Self-loop detected: {0}")]
SelfLoop(String),
}
pub type ValidationResult = Result<(), Vec<ValidationError>>;
pub fn validate_intent_graph(graph: &IntentGraph) -> ValidationResult {
let mut errors = Vec::new();
errors.extend(validate_metadata(&graph.metadata).err().unwrap_or_default());
errors.extend(validate_node_completeness(graph).err().unwrap_or_default());
errors.extend(validate_edge_constraints(graph).err().unwrap_or_default());
errors.extend(validate_dag_acyclicity(graph).err().unwrap_or_default());
errors.extend(validate_dag_flow(graph).err().unwrap_or_default());
errors.extend(validate_canonical_map(graph).err().unwrap_or_default());
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
pub fn is_valid(graph: &IntentGraph) -> bool {
validate_intent_graph(graph).is_ok()
}
pub fn first_error(graph: &IntentGraph) -> Option<ValidationError> {
match validate_intent_graph(graph) {
Ok(()) => None,
Err(errors) => errors.into_iter().next(),
}
}
#[cfg(test)]
mod tests {
#![allow(unused_imports)]
use super::*;
use agileplus_domain::builder::*;
use agileplus_domain::intent_graph::*;
fn make_valid_graph() -> IntentGraph {
let mut builder = IntentGraphBuilder::new().with_default_metadata();
let intent = NodeBuilder::new()
.id("Intent#user-auth")
.node_type(NodeType::Intent)
.dag_stage(DagStage::Intent)
.title("User Authentication")
.status(Status::Active)
.with_meta("user-prompt")
.build()
.unwrap();
let feature = NodeBuilder::new()
.id("Feature#oauth2-login")
.node_type(NodeType::Feature)
.dag_stage(DagStage::Feature)
.title("OAuth2 Login")
.status(Status::Draft)
.with_meta("agent-inference")
.build()
.unwrap();
let test = NodeBuilder::new()
.id("Test#oauth2-login")
.node_type(NodeType::Test)
.dag_stage(DagStage::Test)
.title("OAuth2 Login Tests")
.status(Status::Draft)
.with_meta("agent-inference")
.build()
.unwrap();
builder = builder
.node(intent.clone())
.node(feature.clone())
.node(test.clone());
builder = builder.edge(
EdgeBuilder::new()
.auto_id()
.source("Intent#user-auth")
.target("Feature#oauth2-login")
.relationship_type(RelationshipType::Implements)
.with_meta("agent-inference")
.build()
.unwrap(),
);
builder = builder.edge(
EdgeBuilder::new()
.auto_id()
.source("Feature#oauth2-login")
.target("Test#oauth2-login")
.relationship_type(RelationshipType::Tests)
.with_meta("agent-inference")
.build()
.unwrap(),
);
builder.build().unwrap()
}
#[test]
fn test_valid_graph() {
let graph = make_valid_graph();
assert!(validate_intent_graph(&graph).is_ok());
}
#[test]
fn test_dag_acyclicity_cycle() {
let mut graph = make_valid_graph();
let cycle_edge = EdgeBuilder::new()
.auto_id()
.source("Feature#oauth2-login")
.target("Intent#user-auth")
.relationship_type(RelationshipType::DerivesFrom)
.with_meta("test")
.build()
.unwrap();
graph.add_edge(cycle_edge);
let result = validate_dag_acyclicity(&graph);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs
.iter()
.any(|e| matches!(e, ValidationError::CycleDetected(_))));
}
#[test]
fn test_edge_constraint_violation() {
let mut graph = make_valid_graph();
let bad_edge = EdgeBuilder::new()
.auto_id()
.source("Intent#user-auth")
.target("Feature#oauth2-login")
.relationship_type(RelationshipType::Tests)
.with_meta("test")
.build()
.unwrap();
graph.edges.push(bad_edge);
let result = validate_edge_constraints(&graph);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs
.iter()
.any(|e| matches!(e, ValidationError::EdgeConstraintViolation(_))));
}
#[test]
fn test_node_completeness_missing_title() {
let mut graph = make_valid_graph();
graph.nodes[0].title = "".to_string();
let result = validate_node_completeness(&graph);
assert!(result.is_err());
}
#[test]
fn test_metadata_empty_version() {
let mut graph = make_valid_graph();
graph.metadata.version = "".to_string();
let result = validate_metadata(&graph.metadata);
assert!(result.is_err());
}
#[test]
fn test_self_loop() {
let mut graph = make_valid_graph();
let self_loop = EdgeBuilder::new()
.auto_id()
.source("Intent#user-auth")
.target("Intent#user-auth")
.relationship_type(RelationshipType::TracesTo)
.with_meta("test")
.build()
.unwrap();
graph.add_edge(self_loop);
let result = validate_edge_constraints(&graph);
assert!(result.is_err());
let errs = result.unwrap_err();
assert!(errs.iter().any(|e| matches!(e, ValidationError::SelfLoop(_))));
}
#[test]
fn test_invalid_node_id() {
let mut graph = make_valid_graph();
graph.nodes[0].id = NodeId::new("invalid-id".to_string());
let result = validate_node_completeness(&graph);
assert!(result.is_err());
}
#[test]
fn test_dag_flow_root_must_be_intent() {
let mut graph = make_valid_graph();
graph.nodes.remove(0);
graph.edges.clear();
let result = validate_dag_flow(&graph);
assert!(result.is_err());
}
#[test]
fn test_backward_compat_is_valid() {
let graph = make_valid_graph();
assert!(is_valid(&graph));
assert!(first_error(&graph).is_none());
}
}