use petgraph::algo::tarjan_scc;
use petgraph::visit::EdgeFiltered;
use crate::config::ValidationConfig;
use crate::error::ValidationError;
use crate::graph::KnowledgeGraph;
use crate::validation::rule::ValidationRule;
pub struct CyclesRule;
impl ValidationRule for CyclesRule {
fn validate(&self, graph: &KnowledgeGraph, _config: &ValidationConfig) -> Vec<ValidationError> {
let mut errors = Vec::new();
let inner = graph.inner();
let filtered = EdgeFiltered::from_fn(inner, |edge| edge.weight().is_primary());
let sccs = tarjan_scc(&filtered);
for scc in sccs {
if scc.len() >= 2 {
let cycle_ids: Vec<String> = scc
.iter()
.filter_map(|idx| inner.node_weight(*idx))
.map(|item| item.id.as_str().to_string())
.collect();
let cycle_str = cycle_ids.join(" -> ");
errors.push(ValidationError::CircularReference { cycle: cycle_str });
} else if scc.len() == 1 {
let idx = scc[0];
let has_self_loop = inner
.edges_connecting(idx, idx)
.any(|e| e.weight().is_primary());
if has_self_loop && let Some(item) = inner.node_weight(idx) {
errors.push(ValidationError::CircularReference {
cycle: format!("{} -> {}", item.id.as_str(), item.id.as_str()),
});
}
}
}
errors
}
}
#[cfg(test)]
fn would_create_cycle(
graph: &KnowledgeGraph,
from: &crate::model::ItemId,
to: &crate::model::ItemId,
) -> bool {
let inner = graph.inner();
let from_idx = match graph.node_index(from) {
Some(idx) => idx,
None => return false,
};
let to_idx = match graph.node_index(to) {
Some(idx) => idx,
None => return false,
};
petgraph::algo::has_path_connecting(inner, to_idx, from_idx, None)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::graph::KnowledgeGraphBuilder;
use crate::model::{ItemId, ItemType, UpstreamRefs};
use crate::test_utils::{create_test_item, create_test_item_with_upstream};
#[test]
fn test_no_cycles() {
let graph = KnowledgeGraphBuilder::new()
.add_item(create_test_item("SOL-001", ItemType::Solution))
.add_item(create_test_item_with_upstream(
"UC-001",
ItemType::UseCase,
UpstreamRefs {
refines: vec![ItemId::new_unchecked("SOL-001")],
..Default::default()
},
))
.build()
.unwrap();
let rule = CyclesRule;
let errors = rule.validate(&graph, &ValidationConfig::default());
assert!(errors.is_empty());
}
#[test]
fn test_cycle_detected() {
let scen1 = create_test_item_with_upstream(
"SCEN-001",
ItemType::Scenario,
UpstreamRefs {
refines: vec![ItemId::new_unchecked("SCEN-002")],
..Default::default()
},
);
let scen2 = create_test_item_with_upstream(
"SCEN-002",
ItemType::Scenario,
UpstreamRefs {
refines: vec![ItemId::new_unchecked("SCEN-001")],
..Default::default()
},
);
let graph = KnowledgeGraphBuilder::new()
.add_item(scen1)
.add_item(scen2)
.build()
.unwrap();
let rule = CyclesRule;
let errors = rule.validate(&graph, &ValidationConfig::default());
assert!(!errors.is_empty(), "Cycle should be detected");
}
#[test]
fn test_would_create_cycle() {
let sol = create_test_item("SOL-001", ItemType::Solution);
let uc = create_test_item_with_upstream(
"UC-001",
ItemType::UseCase,
UpstreamRefs {
refines: vec![ItemId::new_unchecked("SOL-001")],
..Default::default()
},
);
let graph = KnowledgeGraphBuilder::new()
.add_item(sol)
.add_item(uc)
.build()
.unwrap();
assert!(would_create_cycle(
&graph,
&ItemId::new_unchecked("SOL-001"),
&ItemId::new_unchecked("UC-001"),
));
assert!(!would_create_cycle(
&graph,
&ItemId::new_unchecked("UC-001"),
&ItemId::new_unchecked("SCEN-001"),
));
}
}