use std::collections::HashMap;
use crate::{ValidationError, ValidationResult};
use agileplus_domain::intent_graph::{
Edge, ExtendedRelationshipType, IntentGraph, NodeType,
};
const DECOMPOSES_INTO_SOURCES: &[NodeType] =
&[NodeType::Intent, NodeType::Story, NodeType::Feature];
const DECOMPOSES_INTO_TARGETS: &[NodeType] =
&[NodeType::Feature, NodeType::Task, NodeType::Story, NodeType::Bug];
const MITIGATES_SOURCES: &[NodeType] = &[NodeType::Bug, NodeType::Intent];
const MITIGATES_TARGETS: &[NodeType] = &[NodeType::Bug, NodeType::Intent];
const VALIDATES_SOURCES: &[NodeType] = &[NodeType::Test, NodeType::Artifact];
const VALIDATES_TARGETS: &[NodeType] =
&[NodeType::Feature, NodeType::Intent, NodeType::Story, NodeType::Artifact];
pub const EXTENDED_RELATIONSHIP_PROPERTY: &str = "relationship_type_extended";
pub fn extended_relationship_of(edge: &Edge) -> Option<ExtendedRelationshipType> {
let prop = edge.properties.as_ref()?.get(EXTENDED_RELATIONSHIP_PROPERTY)?;
let s = prop.as_str()?;
s.parse().ok()
}
pub fn validate_extended_edge(
variant: ExtendedRelationshipType,
source: NodeType,
target: NodeType,
) -> Result<(), String> {
let (allowed_sources, allowed_targets, label) = match variant {
ExtendedRelationshipType::DecomposesInto => (
DECOMPOSES_INTO_SOURCES,
DECOMPOSES_INTO_TARGETS,
"DecomposesInto: source must be Intent|Story|Feature; target must be Feature|Task|Story|Bug",
),
ExtendedRelationshipType::Mitigates => (
MITIGATES_SOURCES,
MITIGATES_TARGETS,
"Mitigates: source must be Bug|Intent; target must be Bug|Intent",
),
ExtendedRelationshipType::Validates => (
VALIDATES_SOURCES,
VALIDATES_TARGETS,
"Validates: source must be Test|Artifact (Metric); target must be Feature|Intent|Story|Artifact (Hypothesis|Metric)",
),
};
if !allowed_sources.contains(&source) {
return Err(format!(
"{label} (source {source} not in {:?})",
allowed_sources
));
}
if !allowed_targets.contains(&target) {
return Err(format!(
"{label} (target {target} not in {:?})",
allowed_targets
));
}
Ok(())
}
pub fn validate_extended_edge_constraints(graph: &IntentGraph) -> ValidationResult {
let mut errors = Vec::new();
let node_types: HashMap<&agileplus_domain::intent_graph::NodeId, NodeType> =
graph.nodes.iter().map(|n| (&n.id, n.node_type)).collect();
for edge in &graph.edges {
let Some(variant) = extended_relationship_of(edge) else {
continue;
};
let source_type = match node_types.get(&edge.source) {
Some(t) => *t,
None => {
errors.push(ValidationError::UnknownNode(format!(
"Extended edge {} references unknown source node: {}",
edge.id, edge.source
)));
continue;
}
};
let target_type = match node_types.get(&edge.target) {
Some(t) => *t,
None => {
errors.push(ValidationError::UnknownNode(format!(
"Extended edge {} references unknown target node: {}",
edge.id, edge.target
)));
continue;
}
};
if edge.source == edge.target {
errors.push(ValidationError::SelfLoop(format!(
"Extended edge {} is a self-loop on {}",
edge.id, edge.source
)));
continue;
}
if let Err(msg) = validate_extended_edge(variant, source_type, target_type) {
errors.push(ValidationError::EdgeConstraintViolation(format!(
"Extended edge {} ({variant:?}) violates constraint: {msg}",
edge.id
)));
}
}
if errors.is_empty() {
Ok(())
} else {
Err(errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
use agileplus_domain::intent_graph::{Edge, NodeId};
fn edge(
source: &str,
target: &str,
variant: ExtendedRelationshipType,
) -> Edge {
let json = serde_json::json!({
EXTENDED_RELATIONSHIP_PROPERTY: variant.as_kebab(),
});
Edge {
id: format!("edge-{}", source),
source: NodeId(source.to_string()),
target: NodeId(target.to_string()),
relationship_type: variant.as_core_relationship_type(),
canonical_map: None,
meta: agileplus_domain::intent_graph::Meta {
source: "test".to_string(),
timestamp: "1970-01-01T00:00:00Z".to_string(),
confidence: None,
agent_id: None,
},
properties: Some(json),
created_at: None,
}
}
#[test]
fn decomposes_into_accepts_intent_to_feature() {
assert!(validate_extended_edge(
ExtendedRelationshipType::DecomposesInto,
NodeType::Intent,
NodeType::Feature
)
.is_ok());
}
#[test]
fn decomposes_into_rejects_task_source() {
let err = validate_extended_edge(
ExtendedRelationshipType::DecomposesInto,
NodeType::Task,
NodeType::Task,
)
.unwrap_err();
assert!(err.contains("DecomposesInto"));
assert!(err.contains("source"));
}
#[test]
fn mitigates_accepts_bug_to_intent() {
assert!(validate_extended_edge(
ExtendedRelationshipType::Mitigates,
NodeType::Bug,
NodeType::Intent
)
.is_ok());
}
#[test]
fn mitigates_rejects_feature_source() {
let err = validate_extended_edge(
ExtendedRelationshipType::Mitigates,
NodeType::Feature,
NodeType::Bug,
)
.unwrap_err();
assert!(err.contains("Mitigates"));
}
#[test]
fn validates_accepts_test_to_feature() {
assert!(validate_extended_edge(
ExtendedRelationshipType::Validates,
NodeType::Test,
NodeType::Feature,
)
.is_ok());
}
#[test]
fn validates_accepts_artifact_metric_to_intent_hypothesis() {
assert!(validate_extended_edge(
ExtendedRelationshipType::Validates,
NodeType::Artifact,
NodeType::Intent,
)
.is_ok());
}
#[test]
fn validates_rejects_bug_source() {
let err = validate_extended_edge(
ExtendedRelationshipType::Validates,
NodeType::Bug,
NodeType::Feature,
)
.unwrap_err();
assert!(err.contains("Validates"));
}
#[test]
fn extended_relationship_of_recovers_variant() {
let e = edge("Story#a", "Task#b", ExtendedRelationshipType::DecomposesInto);
assert_eq!(
extended_relationship_of(&e),
Some(ExtendedRelationshipType::DecomposesInto)
);
}
#[test]
fn extended_relationship_of_none_for_plain_edge() {
let mut e = edge("Story#a", "Task#b", ExtendedRelationshipType::Mitigates);
e.properties = None;
assert_eq!(extended_relationship_of(&e), None);
}
}