201 lines
6.0 KiB
Rust
201 lines
6.0 KiB
Rust
use std::collections::{HashMap, HashSet, VecDeque};
|
|
|
|
use crate::{error::ReflectionError, mode::ActionStep};
|
|
|
|
/// Full DAG validation: uniqueness + referential integrity + cycle detection.
|
|
pub fn validate(steps: &[ActionStep]) -> Result<(), ReflectionError> {
|
|
check_uniqueness(steps)?;
|
|
check_referential_integrity(steps)?;
|
|
detect_cycles(steps)?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Kahn's algorithm: returns steps grouped into parallel execution layers.
|
|
/// Layer 0 has no dependencies; layer N depends only on layers < N.
|
|
/// Returns `Err(CycleDetected)` if the graph is not a DAG.
|
|
pub fn topological_layers(steps: &[ActionStep]) -> Result<Vec<Vec<String>>, ReflectionError> {
|
|
// in_degree[id] = number of steps id depends on
|
|
let mut in_degree: HashMap<&str, usize> = steps
|
|
.iter()
|
|
.map(|s| (s.id.as_str(), s.depends_on.len()))
|
|
.collect();
|
|
|
|
// dependents[id] = steps that depend on id (reverse edges)
|
|
let mut dependents: HashMap<&str, Vec<&str>> =
|
|
steps.iter().map(|s| (s.id.as_str(), vec![])).collect();
|
|
|
|
for step in steps {
|
|
for dep in &step.depends_on {
|
|
dependents
|
|
.entry(dep.step.as_str())
|
|
.or_default()
|
|
.push(step.id.as_str());
|
|
}
|
|
}
|
|
|
|
let mut queue: VecDeque<&str> = in_degree
|
|
.iter()
|
|
.filter_map(|(&id, &d)| (d == 0).then_some(id))
|
|
.collect();
|
|
|
|
let mut layers: Vec<Vec<String>> = Vec::new();
|
|
let mut visited = 0usize;
|
|
|
|
while !queue.is_empty() {
|
|
let layer: Vec<&str> = queue.drain(..).collect();
|
|
visited += layer.len();
|
|
|
|
let mut next: Vec<&str> = Vec::new();
|
|
for &node in &layer {
|
|
for &dep in dependents.get(node).map(Vec::as_slice).unwrap_or(&[]) {
|
|
let d = in_degree
|
|
.get_mut(dep)
|
|
.expect("all step ids must be in in_degree — check uniqueness first");
|
|
*d -= 1;
|
|
if *d == 0 {
|
|
next.push(dep);
|
|
}
|
|
}
|
|
}
|
|
|
|
layers.push(layer.iter().map(|&s| s.to_string()).collect());
|
|
queue.extend(next);
|
|
}
|
|
|
|
if visited != steps.len() {
|
|
Err(ReflectionError::CycleDetected)
|
|
} else {
|
|
Ok(layers)
|
|
}
|
|
}
|
|
|
|
fn check_uniqueness(steps: &[ActionStep]) -> Result<(), ReflectionError> {
|
|
let mut seen: HashSet<&str> = HashSet::with_capacity(steps.len());
|
|
for step in steps {
|
|
if !seen.insert(step.id.as_str()) {
|
|
return Err(ReflectionError::DuplicateStepId(step.id.clone()));
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn check_referential_integrity(steps: &[ActionStep]) -> Result<(), ReflectionError> {
|
|
let ids: HashSet<&str> = steps.iter().map(|s| s.id.as_str()).collect();
|
|
let bad: Vec<String> = steps
|
|
.iter()
|
|
.flat_map(|step| {
|
|
step.depends_on.iter().filter_map(|dep| {
|
|
if ids.contains(dep.step.as_str()) {
|
|
None
|
|
} else {
|
|
Some(format!(
|
|
"step '{}' depends_on unknown '{}'",
|
|
step.id, dep.step
|
|
))
|
|
}
|
|
})
|
|
})
|
|
.collect();
|
|
|
|
if bad.is_empty() {
|
|
Ok(())
|
|
} else {
|
|
Err(ReflectionError::BadDependencyRefs(bad))
|
|
}
|
|
}
|
|
|
|
fn detect_cycles(steps: &[ActionStep]) -> Result<(), ReflectionError> {
|
|
topological_layers(steps).map(|_| ())
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::mode::{Dependency, DependencyKind, OnError};
|
|
|
|
fn step(id: &str, deps: &[&str]) -> ActionStep {
|
|
ActionStep {
|
|
id: id.to_string(),
|
|
action: id.to_string(),
|
|
actor: crate::mode::Actor::Both,
|
|
cmd: None,
|
|
depends_on: deps
|
|
.iter()
|
|
.map(|d| Dependency {
|
|
step: d.to_string(),
|
|
kind: DependencyKind::Always,
|
|
condition: None,
|
|
})
|
|
.collect(),
|
|
on_error: OnError::default(),
|
|
verify: None,
|
|
note: None,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn linear_chain_produces_single_layers() {
|
|
let steps = vec![step("a", &[]), step("b", &["a"]), step("c", &["b"])];
|
|
let layers = topological_layers(&steps).unwrap();
|
|
assert_eq!(layers.len(), 3);
|
|
assert_eq!(layers[0], vec!["a"]);
|
|
assert_eq!(layers[1], vec!["b"]);
|
|
assert_eq!(layers[2], vec!["c"]);
|
|
}
|
|
|
|
#[test]
|
|
fn parallel_deps_form_single_layer() {
|
|
// a → {b, c} → d
|
|
let steps = vec![
|
|
step("a", &[]),
|
|
step("b", &["a"]),
|
|
step("c", &["a"]),
|
|
step("d", &["b", "c"]),
|
|
];
|
|
let layers = topological_layers(&steps).unwrap();
|
|
assert_eq!(layers.len(), 3);
|
|
assert_eq!(layers[0], vec!["a"]);
|
|
assert!(layers[1].contains(&"b".to_string()));
|
|
assert!(layers[1].contains(&"c".to_string()));
|
|
assert_eq!(layers[2], vec!["d"]);
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_detected() {
|
|
let steps = vec![step("a", &["b"]), step("b", &["a"])];
|
|
assert!(matches!(
|
|
topological_layers(&steps),
|
|
Err(ReflectionError::CycleDetected)
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_id_rejected() {
|
|
let steps = vec![step("a", &[]), step("a", &[])];
|
|
assert!(matches!(
|
|
check_uniqueness(&steps),
|
|
Err(ReflectionError::DuplicateStepId(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn bad_ref_rejected() {
|
|
let steps = vec![step("a", &["nonexistent"])];
|
|
assert!(matches!(
|
|
check_referential_integrity(&steps),
|
|
Err(ReflectionError::BadDependencyRefs(_))
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn validate_passes_for_valid_dag() {
|
|
let steps = vec![
|
|
step("init_repo", &[]),
|
|
step("copy_ontology", &["init_repo"]),
|
|
step("init_kogral", &["init_repo"]),
|
|
step("publish", &["copy_ontology", "init_kogral"]),
|
|
];
|
|
assert!(validate(&steps).is_ok());
|
|
}
|
|
}
|