Jesús Pérez 2d87d60bb5
Some checks failed
Rust CI / Security Audit (push) Has been cancelled
Rust CI / Check + Test + Lint (nightly) (push) Has been cancelled
Rust CI / Check + Test + Lint (stable) (push) Has been cancelled
chore: add src code
2026-03-13 00:18:14 +00:00

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());
}
}