#![allow(dead_code)]
use std::collections::{HashMap, HashSet, VecDeque};
#[derive(Debug, Default, Clone)]
pub struct DepGraph {
pub nodes: Vec<u64>,
pub edges: Vec<(u64, u64)>,
}
impl DepGraph {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_node(&mut self, id: u64) {
if !self.nodes.contains(&id) {
self.nodes.push(id);
}
}
pub fn add_dependency(&mut self, from: u64, to: u64) {
self.add_node(from);
self.add_node(to);
let edge = (from, to);
if !self.edges.contains(&edge) {
self.edges.push(edge);
}
}
#[must_use]
pub fn dependencies_of(&self, id: u64) -> Vec<u64> {
self.edges
.iter()
.filter_map(|&(from, to)| if to == id { Some(from) } else { None })
.collect()
}
#[must_use]
pub fn dependents_of(&self, id: u64) -> Vec<u64> {
self.edges
.iter()
.filter_map(|&(from, to)| if from == id { Some(to) } else { None })
.collect()
}
#[must_use]
pub fn fan_out(&self, node: u64) -> usize {
self.edges.iter().filter(|&&(from, _)| from == node).count()
}
#[must_use]
pub fn fan_in(&self, node: u64) -> usize {
self.edges.iter().filter(|&&(_, to)| to == node).count()
}
#[must_use]
pub fn critical_path(&self) -> Vec<u64> {
let order = match topological_sort(self) {
Ok(o) => o,
Err(_) => return Vec::new(),
};
if order.is_empty() {
return Vec::new();
}
let mut dp: std::collections::HashMap<u64, (usize, Option<u64>)> =
order.iter().map(|&n| (n, (1, None))).collect();
for &node in &order {
let successors = self.dependents_of(node);
for succ in successors {
let new_len = dp[&node].0 + 1;
let entry = dp.entry(succ).or_insert((1, None));
if new_len > entry.0 {
*entry = (new_len, Some(node));
}
}
}
let best_end = dp.iter().max_by_key(|(_, &(len, _))| len).map(|(&n, _)| n);
let Some(mut cur) = best_end else {
return Vec::new();
};
let mut path = Vec::new();
loop {
path.push(cur);
match dp[&cur].1 {
Some(prev) => cur = prev,
None => break,
}
}
path.reverse();
path
}
#[must_use]
pub fn all_paths(&self, from: u64, to: u64) -> Vec<Vec<u64>> {
const MAX_PATHS: usize = 1_000;
if !self.nodes.contains(&from) || !self.nodes.contains(&to) {
return Vec::new();
}
let mut results: Vec<Vec<u64>> = Vec::new();
let mut current_path: Vec<u64> = vec![from];
let mut visited: std::collections::HashSet<u64> = std::collections::HashSet::new();
visited.insert(from);
Self::dfs_all_paths(
self,
from,
to,
&mut current_path,
&mut visited,
&mut results,
MAX_PATHS,
);
results
}
fn dfs_all_paths(
graph: &DepGraph,
current: u64,
target: u64,
path: &mut Vec<u64>,
visited: &mut std::collections::HashSet<u64>,
results: &mut Vec<Vec<u64>>,
max_paths: usize,
) {
if current == target {
results.push(path.clone());
return;
}
if results.len() >= max_paths {
return;
}
for &next in &graph
.edges
.iter()
.filter_map(|&(f, t)| if f == current { Some(t) } else { None })
.collect::<Vec<_>>()
{
if !visited.contains(&next) {
visited.insert(next);
path.push(next);
Self::dfs_all_paths(graph, next, target, path, visited, results, max_paths);
path.pop();
visited.remove(&next);
}
if results.len() >= max_paths {
return;
}
}
}
}
pub fn topological_sort(graph: &DepGraph) -> Result<Vec<u64>, String> {
let mut in_degree: HashMap<u64, usize> = graph.nodes.iter().map(|&n| (n, 0)).collect();
for &(_, to) in &graph.edges {
*in_degree.entry(to).or_insert(0) += 1;
}
let mut queue: VecDeque<u64> = in_degree
.iter()
.filter(|(_, °)| deg == 0)
.map(|(&n, _)| n)
.collect();
let mut queue_vec: Vec<u64> = queue.drain(..).collect();
queue_vec.sort_unstable();
queue.extend(queue_vec);
let mut order = Vec::with_capacity(graph.nodes.len());
while let Some(node) = queue.pop_front() {
order.push(node);
let mut successors: Vec<u64> = graph
.edges
.iter()
.filter_map(|&(from, to)| if from == node { Some(to) } else { None })
.collect();
successors.sort_unstable();
for succ in successors {
let deg = in_degree.entry(succ).or_insert(0);
*deg -= 1;
if *deg == 0 {
queue.push_back(succ);
}
}
}
if order.len() == graph.nodes.len() {
Ok(order)
} else {
Err("Cycle detected in dependency graph".to_string())
}
}
#[must_use]
pub fn has_cycle(graph: &DepGraph) -> bool {
topological_sort(graph).is_err()
}
#[derive(Debug)]
pub struct ReadyQueue {
pub graph: DepGraph,
pub completed: Vec<u64>,
}
impl ReadyQueue {
#[must_use]
pub fn new(graph: DepGraph) -> Self {
Self {
graph,
completed: Vec::new(),
}
}
pub fn mark_complete(&mut self, id: u64) {
if !self.completed.contains(&id) {
self.completed.push(id);
}
}
#[must_use]
pub fn ready_tasks(&self) -> Vec<u64> {
let completed_set: HashSet<u64> = self.completed.iter().copied().collect();
let mut ready: Vec<u64> = self
.graph
.nodes
.iter()
.filter(|&&id| {
if completed_set.contains(&id) {
return false;
}
let deps = self.graph.dependencies_of(id);
deps.iter().all(|dep| completed_set.contains(dep))
})
.copied()
.collect();
ready.sort_unstable();
ready
}
}
#[cfg(test)]
mod tests {
use super::*;
fn linear_graph() -> DepGraph {
let mut g = DepGraph::new();
g.add_dependency(1, 2);
g.add_dependency(2, 3);
g
}
#[test]
fn test_add_node_no_duplicate() {
let mut g = DepGraph::new();
g.add_node(1);
g.add_node(1);
assert_eq!(g.nodes.len(), 1);
}
#[test]
fn test_add_dependency_registers_nodes() {
let mut g = DepGraph::new();
g.add_dependency(10, 20);
assert!(g.nodes.contains(&10));
assert!(g.nodes.contains(&20));
}
#[test]
fn test_dependencies_of() {
let g = linear_graph();
assert_eq!(g.dependencies_of(2), vec![1]);
assert_eq!(g.dependencies_of(3), vec![2]);
assert!(g.dependencies_of(1).is_empty());
}
#[test]
fn test_dependents_of() {
let g = linear_graph();
assert_eq!(g.dependents_of(1), vec![2]);
assert_eq!(g.dependents_of(2), vec![3]);
assert!(g.dependents_of(3).is_empty());
}
#[test]
fn test_topological_sort_linear() {
let g = linear_graph();
let order = topological_sort(&g).expect("operation should succeed");
assert_eq!(order, vec![1, 2, 3]);
}
#[test]
fn test_topological_sort_diamond() {
let mut g = DepGraph::new();
g.add_dependency(1, 2);
g.add_dependency(1, 3);
g.add_dependency(2, 4);
g.add_dependency(3, 4);
let order = topological_sort(&g).expect("operation should succeed");
assert_eq!(order[0], 1);
assert_eq!(*order.last().expect("should have last element"), 4);
assert_eq!(order.len(), 4);
}
#[test]
fn test_topological_sort_cycle_returns_err() {
let mut g = DepGraph::new();
g.add_dependency(1, 2);
g.add_dependency(2, 3);
g.add_dependency(3, 1); assert!(topological_sort(&g).is_err());
}
#[test]
fn test_has_cycle_true() {
let mut g = DepGraph::new();
g.add_dependency(5, 6);
g.add_dependency(6, 5);
assert!(has_cycle(&g));
}
#[test]
fn test_has_cycle_false() {
let g = linear_graph();
assert!(!has_cycle(&g));
}
#[test]
fn test_ready_queue_initial_no_deps() {
let mut g = DepGraph::new();
g.add_node(1);
g.add_node(2);
g.add_dependency(1, 2);
let rq = ReadyQueue::new(g);
assert_eq!(rq.ready_tasks(), vec![1]);
}
#[test]
fn test_ready_queue_after_complete() {
let mut g = DepGraph::new();
g.add_dependency(1, 2);
g.add_dependency(2, 3);
let mut rq = ReadyQueue::new(g);
assert_eq!(rq.ready_tasks(), vec![1]);
rq.mark_complete(1);
assert_eq!(rq.ready_tasks(), vec![2]);
rq.mark_complete(2);
assert_eq!(rq.ready_tasks(), vec![3]);
rq.mark_complete(3);
assert!(rq.ready_tasks().is_empty());
}
#[test]
fn test_ready_queue_mark_complete_idempotent() {
let mut g = DepGraph::new();
g.add_node(7);
let mut rq = ReadyQueue::new(g);
rq.mark_complete(7);
rq.mark_complete(7);
assert_eq!(rq.completed.len(), 1);
}
#[test]
fn test_fan_out_root_node() {
let g = linear_graph(); assert_eq!(g.fan_out(1), 1);
assert_eq!(g.fan_out(2), 1);
assert_eq!(g.fan_out(3), 0);
}
#[test]
fn test_fan_in_leaf_node() {
let g = linear_graph();
assert_eq!(g.fan_in(1), 0);
assert_eq!(g.fan_in(2), 1);
assert_eq!(g.fan_in(3), 1);
}
#[test]
fn test_fan_out_diamond_center() {
let mut g = DepGraph::new();
g.add_dependency(1, 2);
g.add_dependency(1, 3);
g.add_dependency(2, 4);
g.add_dependency(3, 4);
assert_eq!(g.fan_out(1), 2);
assert_eq!(g.fan_in(4), 2);
}
#[test]
fn test_critical_path_linear() {
let g = linear_graph();
let cp = g.critical_path();
assert_eq!(cp, vec![1, 2, 3]);
}
#[test]
fn test_critical_path_selects_longest() {
let mut g = DepGraph::new();
g.add_dependency(1, 2);
g.add_dependency(2, 3);
g.add_dependency(3, 5);
g.add_dependency(1, 4);
g.add_dependency(4, 5);
let cp = g.critical_path();
assert_eq!(cp.len(), 4);
assert_eq!(cp[0], 1);
assert_eq!(*cp.last().expect("should have last"), 5);
}
#[test]
fn test_critical_path_empty_graph() {
let g = DepGraph::new();
assert!(g.critical_path().is_empty());
}
#[test]
fn test_critical_path_single_node() {
let mut g = DepGraph::new();
g.add_node(42);
let cp = g.critical_path();
assert_eq!(cp, vec![42]);
}
#[test]
fn test_all_paths_linear() {
let g = linear_graph();
let paths = g.all_paths(1, 3);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0], vec![1, 2, 3]);
}
#[test]
fn test_all_paths_diamond() {
let mut g = DepGraph::new();
g.add_dependency(1, 2);
g.add_dependency(1, 3);
g.add_dependency(2, 4);
g.add_dependency(3, 4);
let paths = g.all_paths(1, 4);
assert_eq!(paths.len(), 2);
assert!(paths
.iter()
.all(|p| p[0] == 1 && *p.last().expect("should have last") == 4));
}
#[test]
fn test_all_paths_no_path() {
let g = linear_graph();
let paths = g.all_paths(3, 1);
assert!(paths.is_empty());
}
#[test]
fn test_all_paths_unknown_node() {
let g = linear_graph();
let paths = g.all_paths(99, 1);
assert!(paths.is_empty());
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DependencyCondition {
OnSuccess,
OnFailure,
OnCompletion,
Threshold {
min_success_count: u32,
},
}
#[derive(Debug, Clone)]
pub struct JobDependency {
pub job_id: String,
pub condition: DependencyCondition,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum JobStatus {
Pending,
Running,
Succeeded,
Failed,
}
#[derive(Debug, Default)]
pub struct ConditionalDependencyGraph {
jobs: Vec<String>,
edges: Vec<(String, String, DependencyCondition)>,
statuses: std::collections::HashMap<String, JobStatus>,
}
impl ConditionalDependencyGraph {
#[must_use]
pub fn new() -> Self {
Self::default()
}
pub fn add_job(&mut self, job_id: impl Into<String>) {
let id = job_id.into();
if !self.jobs.contains(&id) {
self.statuses.insert(id.clone(), JobStatus::Pending);
self.jobs.push(id);
}
}
pub fn add_dependency(
&mut self,
from: impl Into<String>,
to: impl Into<String>,
condition: DependencyCondition,
) -> Result<(), String> {
let from_id = from.into();
let to_id = to.into();
if from_id == to_id {
return Err(format!("Self-dependency not allowed: {from_id}"));
}
self.add_job(from_id.clone());
self.add_job(to_id.clone());
self.edges.push((from_id.clone(), to_id.clone(), condition));
if self.has_cycle() {
self.edges.pop();
return Err(format!(
"Cycle detected: adding edge {from_id} -> {to_id} would create a cycle"
));
}
Ok(())
}
#[must_use]
pub fn job_status(&self, job_id: &str) -> Option<&JobStatus> {
self.statuses.get(job_id)
}
pub fn start_job(&mut self, job_id: &str) {
if let Some(s) = self.statuses.get_mut(job_id) {
*s = JobStatus::Running;
}
}
pub fn complete_job(&mut self, job_id: &str) {
if let Some(s) = self.statuses.get_mut(job_id) {
*s = JobStatus::Succeeded;
}
}
pub fn fail_job(&mut self, job_id: &str) {
if let Some(s) = self.statuses.get_mut(job_id) {
*s = JobStatus::Failed;
}
}
#[must_use]
pub fn get_ready_jobs(&self) -> Vec<String> {
let mut ready = Vec::new();
'outer: for job_id in &self.jobs {
if self.statuses.get(job_id.as_str()) != Some(&JobStatus::Pending) {
continue;
}
let incoming: Vec<&(String, String, DependencyCondition)> = self
.edges
.iter()
.filter(|(_, to, _)| to == job_id)
.collect();
if incoming.is_empty() {
ready.push(job_id.clone());
continue;
}
let mut threshold_required: Option<u32> = None;
let mut success_count: u32 = 0;
for (from, _, cond) in &incoming {
match cond {
DependencyCondition::Threshold { min_success_count } => {
threshold_required = Some(
threshold_required
.map(|v: u32| v.max(*min_success_count))
.unwrap_or(*min_success_count),
);
if self.statuses.get(from.as_str()) == Some(&JobStatus::Succeeded) {
success_count += 1;
}
}
DependencyCondition::OnSuccess => {
let status = self.statuses.get(from.as_str());
match status {
Some(JobStatus::Succeeded) => {} Some(JobStatus::Failed) => continue 'outer, _ => continue 'outer, }
}
DependencyCondition::OnFailure => {
let status = self.statuses.get(from.as_str());
match status {
Some(JobStatus::Failed) => {} Some(JobStatus::Succeeded) => continue 'outer, _ => continue 'outer, }
}
DependencyCondition::OnCompletion => {
let status = self.statuses.get(from.as_str());
match status {
Some(JobStatus::Succeeded) | Some(JobStatus::Failed) => {} _ => continue 'outer, }
}
}
}
if let Some(required) = threshold_required {
if success_count < required {
continue 'outer;
}
}
ready.push(job_id.clone());
}
ready
}
#[must_use]
pub fn has_cycle(&self) -> bool {
let mut adj: std::collections::HashMap<&str, Vec<&str>> = std::collections::HashMap::new();
for id in &self.jobs {
adj.entry(id.as_str()).or_default();
}
for (from, to, _) in &self.edges {
adj.entry(from.as_str()).or_default().push(to.as_str());
}
let mut color: std::collections::HashMap<&str, u8> = std::collections::HashMap::new();
for start in self.jobs.iter().map(String::as_str) {
if color.get(start).copied().unwrap_or(0) == 0
&& Self::dfs_cycle(start, &adj, &mut color)
{
return true;
}
}
false
}
fn dfs_cycle<'a>(
node: &'a str,
adj: &std::collections::HashMap<&'a str, Vec<&'a str>>,
color: &mut std::collections::HashMap<&'a str, u8>,
) -> bool {
color.insert(node, 1); if let Some(neighbors) = adj.get(node) {
for &next in neighbors {
let c = color.get(next).copied().unwrap_or(0);
if c == 1 {
return true; }
if c == 0 && Self::dfs_cycle(next, adj, color) {
return true;
}
}
}
color.insert(node, 2); false
}
#[must_use]
pub fn job_count(&self) -> usize {
self.jobs.len()
}
#[must_use]
pub fn edge_count(&self) -> usize {
self.edges.len()
}
}
#[cfg(test)]
mod conditional_tests {
use super::*;
fn linear_cdag() -> ConditionalDependencyGraph {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("a", "b", DependencyCondition::OnSuccess)
.expect("add dep a->b");
g.add_dependency("b", "c", DependencyCondition::OnSuccess)
.expect("add dep b->c");
g
}
#[test]
fn test_new_graph_is_empty() {
let g = ConditionalDependencyGraph::new();
assert_eq!(g.job_count(), 0);
assert_eq!(g.edge_count(), 0);
}
#[test]
fn test_add_job_idempotent() {
let mut g = ConditionalDependencyGraph::new();
g.add_job("alpha");
g.add_job("alpha");
assert_eq!(g.job_count(), 1);
}
#[test]
fn test_add_dependency_registers_jobs() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("x", "y", DependencyCondition::OnSuccess)
.expect("add dep");
assert_eq!(g.job_count(), 2);
assert_eq!(g.edge_count(), 1);
}
#[test]
fn test_self_dependency_rejected() {
let mut g = ConditionalDependencyGraph::new();
let result = g.add_dependency("a", "a", DependencyCondition::OnSuccess);
assert!(result.is_err());
let msg = result.expect_err("expected error");
assert!(msg.contains("Self-dependency"), "got: {msg}");
}
#[test]
fn test_initial_status_is_pending() {
let mut g = ConditionalDependencyGraph::new();
g.add_job("job1");
assert_eq!(g.job_status("job1"), Some(&JobStatus::Pending));
}
#[test]
fn test_start_job() {
let mut g = ConditionalDependencyGraph::new();
g.add_job("job1");
g.start_job("job1");
assert_eq!(g.job_status("job1"), Some(&JobStatus::Running));
}
#[test]
fn test_complete_job() {
let mut g = ConditionalDependencyGraph::new();
g.add_job("job1");
g.complete_job("job1");
assert_eq!(g.job_status("job1"), Some(&JobStatus::Succeeded));
}
#[test]
fn test_fail_job() {
let mut g = ConditionalDependencyGraph::new();
g.add_job("job1");
g.fail_job("job1");
assert_eq!(g.job_status("job1"), Some(&JobStatus::Failed));
}
#[test]
fn test_linear_chain_initial_ready() {
let g = linear_cdag();
let ready = g.get_ready_jobs();
assert_eq!(ready, vec!["a"]);
}
#[test]
fn test_linear_chain_step_through() {
let mut g = linear_cdag();
assert_eq!(g.get_ready_jobs(), vec!["a"]);
g.complete_job("a");
let ready = g.get_ready_jobs();
assert_eq!(ready, vec!["b"]);
g.complete_job("b");
let ready = g.get_ready_jobs();
assert_eq!(ready, vec!["c"]);
g.complete_job("c");
assert!(g.get_ready_jobs().is_empty());
}
#[test]
fn test_linear_chain_failure_blocks_on_success_dep() {
let mut g = linear_cdag();
g.fail_job("a");
assert!(g.get_ready_jobs().is_empty());
}
#[test]
fn test_on_failure_condition() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("a", "b", DependencyCondition::OnFailure)
.expect("add dep");
assert!(g.get_ready_jobs().is_empty() || g.get_ready_jobs() == vec!["a"]);
g.fail_job("a");
let ready = g.get_ready_jobs();
assert!(
ready.contains(&"b".to_string()),
"b should be ready after a fails"
);
}
#[test]
fn test_on_failure_condition_not_met_on_success() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("a", "b", DependencyCondition::OnFailure)
.expect("add dep");
g.complete_job("a"); let ready = g.get_ready_jobs();
assert!(!ready.contains(&"b".to_string()));
}
#[test]
fn test_on_completion_triggers_on_success() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("a", "b", DependencyCondition::OnCompletion)
.expect("add dep");
g.complete_job("a");
let ready = g.get_ready_jobs();
assert!(ready.contains(&"b".to_string()));
}
#[test]
fn test_on_completion_triggers_on_failure() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("a", "b", DependencyCondition::OnCompletion)
.expect("add dep");
g.fail_job("a");
let ready = g.get_ready_jobs();
assert!(ready.contains(&"b".to_string()));
}
#[test]
fn test_on_completion_not_ready_while_running() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("a", "b", DependencyCondition::OnCompletion)
.expect("add dep");
g.start_job("a");
let ready = g.get_ready_jobs();
assert!(!ready.contains(&"b".to_string()));
}
#[test]
fn test_diamond_fan_out_fan_in() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("root", "left", DependencyCondition::OnSuccess)
.expect("add dep");
g.add_dependency("root", "right", DependencyCondition::OnSuccess)
.expect("add dep");
g.add_dependency("left", "join", DependencyCondition::OnSuccess)
.expect("add dep");
g.add_dependency("right", "join", DependencyCondition::OnSuccess)
.expect("add dep");
let ready = g.get_ready_jobs();
assert_eq!(ready, vec!["root"]);
g.complete_job("root");
let mut ready = g.get_ready_jobs();
ready.sort();
assert_eq!(ready, vec!["left", "right"]);
g.complete_job("left");
assert!(!g.get_ready_jobs().contains(&"join".to_string()));
g.complete_job("right");
let ready = g.get_ready_jobs();
assert!(ready.contains(&"join".to_string()));
}
#[test]
fn test_threshold_met() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency(
"a",
"join",
DependencyCondition::Threshold {
min_success_count: 1,
},
)
.expect("add dep");
g.add_dependency(
"b",
"join",
DependencyCondition::Threshold {
min_success_count: 1,
},
)
.expect("add dep");
assert!(!g.get_ready_jobs().contains(&"join".to_string()));
g.complete_job("a");
assert!(g.get_ready_jobs().contains(&"join".to_string()));
}
#[test]
fn test_threshold_not_met() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency(
"a",
"join",
DependencyCondition::Threshold {
min_success_count: 2,
},
)
.expect("add dep");
g.add_dependency(
"b",
"join",
DependencyCondition::Threshold {
min_success_count: 2,
},
)
.expect("add dep");
g.complete_job("a"); assert!(!g.get_ready_jobs().contains(&"join".to_string()));
g.complete_job("b");
assert!(g.get_ready_jobs().contains(&"join".to_string()));
}
#[test]
fn test_threshold_with_failure_still_counts_successes() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency(
"a",
"join",
DependencyCondition::Threshold {
min_success_count: 1,
},
)
.expect("add dep");
g.add_dependency(
"b",
"join",
DependencyCondition::Threshold {
min_success_count: 1,
},
)
.expect("add dep");
g.fail_job("b");
g.complete_job("a");
assert!(g.get_ready_jobs().contains(&"join".to_string()));
}
#[test]
fn test_cycle_detection_self_loop() {
let mut g = ConditionalDependencyGraph::new();
let err = g.add_dependency("a", "a", DependencyCondition::OnSuccess);
assert!(err.is_err());
}
#[test]
fn test_cycle_detection_two_node() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("a", "b", DependencyCondition::OnSuccess)
.expect("add dep a->b");
let err = g.add_dependency("b", "a", DependencyCondition::OnSuccess);
assert!(err.is_err());
let msg = err.expect_err("expected error");
assert!(msg.contains("Cycle"), "got: {msg}");
}
#[test]
fn test_cycle_detection_three_node_cycle() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("a", "b", DependencyCondition::OnSuccess)
.expect("add dep a->b");
g.add_dependency("b", "c", DependencyCondition::OnSuccess)
.expect("add dep b->c");
let err = g.add_dependency("c", "a", DependencyCondition::OnSuccess);
assert!(err.is_err());
}
#[test]
fn test_no_cycle_acyclic_graph() {
let g = linear_cdag();
assert!(!g.has_cycle());
}
#[test]
fn test_no_cycle_diamond() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("root", "a", DependencyCondition::OnSuccess)
.expect("add dep");
g.add_dependency("root", "b", DependencyCondition::OnSuccess)
.expect("add dep");
g.add_dependency("a", "join", DependencyCondition::OnSuccess)
.expect("add dep");
g.add_dependency("b", "join", DependencyCondition::OnSuccess)
.expect("add dep");
assert!(!g.has_cycle());
}
#[test]
fn test_empty_graph_returns_no_ready_jobs() {
let g = ConditionalDependencyGraph::new();
assert!(g.get_ready_jobs().is_empty());
}
#[test]
fn test_single_node_immediately_ready() {
let mut g = ConditionalDependencyGraph::new();
g.add_job("solo");
assert_eq!(g.get_ready_jobs(), vec!["solo"]);
}
#[test]
fn test_fan_out_all_branches_ready_after_root() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("root", "a", DependencyCondition::OnSuccess)
.expect("add dep");
g.add_dependency("root", "b", DependencyCondition::OnSuccess)
.expect("add dep");
g.add_dependency("root", "c", DependencyCondition::OnSuccess)
.expect("add dep");
g.complete_job("root");
let mut ready = g.get_ready_jobs();
ready.sort();
assert_eq!(ready, vec!["a", "b", "c"]);
}
#[test]
fn test_fan_in_all_deps_must_succeed() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("a", "join", DependencyCondition::OnSuccess)
.expect("add dep");
g.add_dependency("b", "join", DependencyCondition::OnSuccess)
.expect("add dep");
g.add_dependency("c", "join", DependencyCondition::OnSuccess)
.expect("add dep");
g.complete_job("a");
g.complete_job("b");
assert!(!g.get_ready_jobs().contains(&"join".to_string()));
g.complete_job("c");
assert!(g.get_ready_jobs().contains(&"join".to_string()));
}
#[test]
fn test_mixed_conditions_in_same_graph() {
let mut g = ConditionalDependencyGraph::new();
g.add_dependency("work", "cleanup", DependencyCondition::OnCompletion)
.expect("add dep");
g.add_dependency("work", "notify-fail", DependencyCondition::OnFailure)
.expect("add dep");
g.fail_job("work");
let ready = g.get_ready_jobs();
assert!(
ready.contains(&"cleanup".to_string()),
"cleanup should be ready"
);
assert!(
ready.contains(&"notify-fail".to_string()),
"notify-fail should be ready"
);
}
}