use crate::story::Story;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Default)]
pub struct DependencyGraph {
forward: HashMap<String, Vec<String>>,
reverse: HashMap<String, Vec<String>>,
}
impl DependencyGraph {
pub fn from_stories(stories: &[Story]) -> Self {
let mut graph = Self::default();
for story in stories {
graph.forward.entry(story.id.clone()).or_default();
graph.reverse.entry(story.id.clone()).or_default();
for blocker in &story.blockers {
graph
.forward
.entry(blocker.clone())
.or_default()
.push(story.id.clone());
graph
.reverse
.entry(story.id.clone())
.or_default()
.push(blocker.clone());
}
}
graph
}
pub fn blocks_count(&self, story_id: &str) -> usize {
self.forward.get(story_id).map(|v| v.len()).unwrap_or(0)
}
#[allow(dead_code)]
pub fn blocked_by_me(&self, story_id: &str) -> Vec<&str> {
self.forward
.get(story_id)
.map(|v| v.iter().map(|s| s.as_str()).collect())
.unwrap_or_default()
}
pub fn has_cycle_from(&self, story_id: &str) -> bool {
let mut color: HashMap<&str, u8> = self.forward.keys().map(|k| (k.as_str(), 0u8)).collect();
self.dfs_has_cycle(story_id, &mut color)
}
#[allow(dead_code)]
pub fn has_any_cycle(&self) -> bool {
let mut color: HashMap<&str, u8> = self.forward.keys().map(|k| (k.as_str(), 0u8)).collect();
for node in self.forward.keys() {
if color.get(node.as_str()) == Some(&0) && self.dfs_has_cycle(node, &mut color) {
return true;
}
}
false
}
fn dfs_has_cycle(&self, node: &str, color: &mut HashMap<&str, u8>) -> bool {
*color.get_mut(node).unwrap() = 1;
if let Some(neighbors) = self.forward.get(node) {
for neighbor in neighbors {
let neighbor_color = *color.get(neighbor.as_str()).unwrap_or(&0);
if neighbor_color == 1 {
return true; }
if neighbor_color == 0 && self.dfs_has_cycle(neighbor, color) {
return true;
}
}
}
*color.get_mut(node).unwrap() = 2; false
}
#[allow(dead_code)]
pub fn find_cycle_members(&self) -> HashSet<String> {
let mut color: HashMap<&str, u8> = self.forward.keys().map(|k| (k.as_str(), 0u8)).collect();
let mut in_stack = HashSet::new();
let mut cycle_members = HashSet::new();
for node in self.forward.keys() {
if color.get(node.as_str()) == Some(&0) {
self.dfs_find_cycle(node, &mut color, &mut in_stack, &mut cycle_members);
}
}
cycle_members
}
#[allow(dead_code)]
fn dfs_find_cycle(
&self,
node: &str,
color: &mut HashMap<&str, u8>,
in_stack: &mut HashSet<String>,
cycle_members: &mut HashSet<String>,
) {
*color.get_mut(node).unwrap() = 1;
in_stack.insert(node.to_string());
if let Some(neighbors) = self.forward.get(node) {
for neighbor in neighbors {
let neighbor_color = *color.get(neighbor.as_str()).unwrap_or(&0);
if neighbor_color == 1 {
cycle_members.extend(in_stack.iter().cloned());
} else if neighbor_color == 0 {
self.dfs_find_cycle(neighbor, color, in_stack, cycle_members);
}
}
}
in_stack.remove(node);
*color.get_mut(node).unwrap() = 2;
}
}
#[cfg(test)]
mod tests {
use super::*;
fn story(id: &str, blockers: &[&str]) -> Story {
Story {
id: id.to_string(),
path: format!("stories/{id}.md").into(),
status: crate::state::Status::Blocked,
epic: None,
blockers: blockers.iter().map(|s| s.to_string()).collect(),
last_rejection: None,
raw_content: String::new(),
}
}
#[test]
fn no_cycle_linear_chain() {
let stories = vec![
story("STORY-001", &[]),
story("STORY-002", &["STORY-001"]),
story("STORY-003", &["STORY-002"]),
];
let graph = DependencyGraph::from_stories(&stories);
assert!(!graph.has_any_cycle());
assert!(!graph.has_cycle_from("STORY-001"));
assert!(!graph.has_cycle_from("STORY-003"));
}
#[test]
fn cycle_two_nodes() {
let stories = vec![
story("STORY-001", &["STORY-002"]),
story("STORY-002", &["STORY-001"]),
];
let graph = DependencyGraph::from_stories(&stories);
assert!(graph.has_any_cycle());
assert!(graph.has_cycle_from("STORY-001"));
assert!(graph.has_cycle_from("STORY-002"));
}
#[test]
fn cycle_three_nodes() {
let stories = vec![
story("STORY-001", &["STORY-003"]),
story("STORY-002", &["STORY-001"]),
story("STORY-003", &["STORY-002"]),
];
let graph = DependencyGraph::from_stories(&stories);
assert!(graph.has_any_cycle());
let members = graph.find_cycle_members();
assert!(members.len() >= 3);
assert!(members.contains("STORY-001"));
assert!(members.contains("STORY-002"));
assert!(members.contains("STORY-003"));
}
#[test]
fn blocks_count_works() {
let stories = vec![
story("STORY-001", &[]),
story("STORY-002", &["STORY-001"]),
story("STORY-003", &["STORY-001"]),
];
let graph = DependencyGraph::from_stories(&stories);
assert_eq!(graph.blocks_count("STORY-001"), 2);
assert_eq!(graph.blocks_count("STORY-002"), 0);
}
}