use std::collections::{HashMap, HashSet, VecDeque};
use crate::error::{Error, Result};
use crate::models::task::{TaskFile, TaskStatus};
#[derive(Debug, Clone)]
pub struct DependencyGraph {
deps: HashMap<String, HashSet<String>>,
rdeps: HashMap<String, HashSet<String>>,
}
impl DependencyGraph {
pub fn from_tasks(tasks: &[TaskFile]) -> Self {
let mut deps: HashMap<String, HashSet<String>> = HashMap::new();
let mut rdeps: HashMap<String, HashSet<String>> = HashMap::new();
for task in tasks {
deps.entry(task.id.clone()).or_default();
rdeps.entry(task.id.clone()).or_default();
for dep in &task.blocked_by {
deps.entry(task.id.clone()).or_default().insert(dep.clone());
rdeps.entry(dep.clone()).or_default().insert(task.id.clone());
}
}
Self { deps, rdeps }
}
pub fn would_create_cycle(&self, task_id: &str, depends_on: &str) -> bool {
if task_id == depends_on {
return true;
}
let mut visited = HashSet::new();
let mut queue = VecDeque::new();
queue.push_back(depends_on.to_string());
visited.insert(depends_on.to_string());
while let Some(current) = queue.pop_front() {
if let Some(current_deps) = self.deps.get(¤t) {
for dep in current_deps {
if dep == task_id {
return true;
}
if visited.insert(dep.clone()) {
queue.push_back(dep.clone());
}
}
}
}
false
}
pub fn add_dependency(&mut self, task_id: &str, depends_on: &str) -> Result<()> {
if self.would_create_cycle(task_id, depends_on) {
return Err(Error::CycleDetected {
from: task_id.to_string(),
to: depends_on.to_string(),
});
}
self.deps
.entry(task_id.to_string())
.or_default()
.insert(depends_on.to_string());
self.rdeps
.entry(depends_on.to_string())
.or_default()
.insert(task_id.to_string());
Ok(())
}
pub fn get_blocked_by(&self, task_id: &str) -> Vec<String> {
self.deps
.get(task_id)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default()
}
pub fn get_blocks(&self, task_id: &str) -> Vec<String> {
self.rdeps
.get(task_id)
.map(|s| s.iter().cloned().collect())
.unwrap_or_default()
}
pub fn is_ready(&self, task_id: &str, tasks: &[TaskFile]) -> bool {
let blocked_by = match self.deps.get(task_id) {
Some(deps) if !deps.is_empty() => deps,
_ => return true,
};
let status_map: HashMap<&str, TaskStatus> = tasks
.iter()
.map(|t| (t.id.as_str(), t.status))
.collect();
blocked_by.iter().all(|dep_id| {
matches!(
status_map.get(dep_id.as_str()),
Some(TaskStatus::Completed) | Some(TaskStatus::Deleted)
)
})
}
pub fn to_mermaid(&self, tasks: &[TaskFile]) -> String {
let mut out = String::from("graph TD\n");
let task_map: HashMap<&str, &TaskFile> = tasks
.iter()
.map(|t| (t.id.as_str(), t))
.collect();
let mut node_ids: Vec<&str> = self.deps.keys().map(|s| s.as_str()).collect();
node_ids.sort();
for id in &node_ids {
if let Some(task) = task_map.get(id) {
let escaped_subject = task.subject.replace('"', "#quot;");
let color = status_color(task.status);
out.push_str(&format!(
" {id}[\"{escaped_subject}\"]\n style {id} fill:{color}\n"
));
}
}
for id in &node_ids {
if let Some(deps) = self.deps.get(*id) {
let mut sorted_deps: Vec<&str> = deps.iter().map(|s| s.as_str()).collect();
sorted_deps.sort();
for dep in sorted_deps {
out.push_str(&format!(" {dep} --> {id}\n"));
}
}
}
out
}
pub fn to_dot(&self, tasks: &[TaskFile]) -> String {
let mut out = String::from("digraph tasks {\n rankdir=LR;\n");
let task_map: HashMap<&str, &TaskFile> = tasks
.iter()
.map(|t| (t.id.as_str(), t))
.collect();
let mut node_ids: Vec<&str> = self.deps.keys().map(|s| s.as_str()).collect();
node_ids.sort();
for id in &node_ids {
if let Some(task) = task_map.get(id) {
let escaped_subject = task.subject.replace('"', "\\\"");
let color = status_color_dot(task.status);
out.push_str(&format!(
" {id} [label=\"{escaped_subject}\" style=filled fillcolor=\"{color}\"];\n"
));
}
}
for id in &node_ids {
if let Some(deps) = self.deps.get(*id) {
let mut sorted_deps: Vec<&str> = deps.iter().map(|s| s.as_str()).collect();
sorted_deps.sort();
for dep in sorted_deps {
out.push_str(&format!(" {dep} -> {id};\n"));
}
}
}
out.push_str("}\n");
out
}
pub fn critical_path(&self, tasks: &[TaskFile]) -> Vec<String> {
let status_map: HashMap<&str, TaskStatus> = tasks
.iter()
.map(|t| (t.id.as_str(), t.status))
.collect();
let active_ids: HashSet<&str> = self
.deps
.keys()
.map(|s| s.as_str())
.filter(|id| !matches!(status_map.get(id), Some(TaskStatus::Deleted)))
.collect();
if active_ids.is_empty() {
return Vec::new();
}
let mut in_degree: HashMap<&str, usize> = HashMap::new();
for &id in &active_ids {
in_degree.insert(id, 0);
}
for &id in &active_ids {
if let Some(deps) = self.deps.get(id) {
let active_dep_count = deps.iter().filter(|d| active_ids.contains(d.as_str())).count();
in_degree.insert(id, active_dep_count);
}
}
let mut queue: VecDeque<&str> = in_degree
.iter()
.filter(|&(_, °)| deg == 0)
.map(|(&id, _)| id)
.collect();
let mut sorted_queue: Vec<&str> = queue.drain(..).collect();
sorted_queue.sort();
queue.extend(sorted_queue);
let mut topo_order: Vec<&str> = Vec::new();
while let Some(node) = queue.pop_front() {
topo_order.push(node);
if let Some(blocked) = self.rdeps.get(node) {
let mut next: Vec<&str> = blocked
.iter()
.map(|s| s.as_str())
.filter(|s| active_ids.contains(s))
.collect();
next.sort();
for dep in next {
if let Some(deg) = in_degree.get_mut(dep) {
*deg -= 1;
if *deg == 0 {
queue.push_back(dep);
}
}
}
}
}
let mut longest: HashMap<&str, usize> = HashMap::new();
let mut predecessor: HashMap<&str, &str> = HashMap::new();
for &node in &topo_order {
longest.insert(node, 1); }
for &node in &topo_order {
if let Some(blocked) = self.rdeps.get(node) {
for dependent in blocked.iter().map(|s| s.as_str()) {
if !active_ids.contains(dependent) {
continue;
}
let new_len = longest[node] + 1;
if new_len > *longest.get(dependent).unwrap_or(&0) {
longest.insert(dependent, new_len);
predecessor.insert(dependent, node);
}
}
}
}
let end_node = match longest.iter().max_by_key(|&(_, &len)| len) {
Some((&node, _)) => node,
None => return Vec::new(),
};
let mut path = vec![end_node];
let mut current = end_node;
while let Some(&prev) = predecessor.get(current) {
path.push(prev);
current = prev;
}
path.reverse();
path.into_iter().map(String::from).collect()
}
pub fn to_terminal(&self, tasks: &[TaskFile]) -> String {
let task_map: HashMap<&str, &TaskFile> = tasks
.iter()
.map(|t| (t.id.as_str(), t))
.collect();
let levels = self.compute_levels();
if levels.is_empty() {
return String::from(" (empty DAG)\n");
}
let max_level = levels.values().copied().max().unwrap_or(0);
let mut level_groups: Vec<Vec<&str>> = vec![Vec::new(); max_level + 1];
for (id, &level) in &levels {
level_groups[level].push(id.as_str());
}
for group in &mut level_groups {
group.sort();
}
let cp: HashSet<String> = self.critical_path(tasks).into_iter().collect();
let mut out = String::new();
let total = tasks.len();
let completed = tasks.iter().filter(|t| t.status == TaskStatus::Completed).count();
let in_progress = tasks.iter().filter(|t| t.status == TaskStatus::InProgress).count();
out.push_str(&format!(
" Task DAG \x1b[90m({total} tasks: {completed} done, {in_progress} active)\x1b[0m\n"
));
out.push_str(" ─────────────────────────────────────────────\n");
for (level, group) in level_groups.iter().enumerate() {
if group.is_empty() {
continue;
}
out.push_str(&format!("\n \x1b[1mPhase {level}\x1b[0m\n"));
for (i, &id) in group.iter().enumerate() {
let is_last = i == group.len() - 1;
let connector = if is_last { "└──" } else { "├──" };
if let Some(task) = task_map.get(id) {
let (symbol, color) = status_symbol_ansi(task.status);
let on_cp = if cp.contains(id) { " \x1b[33m★\x1b[0m" } else { "" };
let deps_hint = if task.blocked_by.is_empty() {
String::new()
} else {
let dep_ids: Vec<&str> = task.blocked_by.iter().map(|s| s.as_str()).collect();
format!(" \x1b[90m← {}\x1b[0m", dep_ids.join(", "))
};
let owner_hint = task.owner.as_deref()
.map(|o| format!(" \x1b[90m@{o}\x1b[0m"))
.unwrap_or_default();
out.push_str(&format!(
" {connector} {color}{symbol}\x1b[0m \x1b[1m#{id}\x1b[0m {subject}{on_cp}{deps_hint}{owner_hint}\n",
subject = task.subject,
));
}
}
if level < max_level {
out.push_str(" │\n");
out.push_str(" ▼\n");
}
}
if cp.len() > 1 {
let cp_ordered = self.critical_path(tasks);
let cp_str: Vec<String> = cp_ordered.iter().map(|id| format!("#{id}")).collect();
out.push_str(&format!(
"\n \x1b[33m★ Critical path:\x1b[0m {}\n",
cp_str.join(" → ")
));
}
out
}
pub fn to_terminal_plain(&self, tasks: &[TaskFile]) -> String {
let colored = self.to_terminal(tasks);
strip_ansi(&colored)
}
fn compute_levels(&self) -> HashMap<String, usize> {
let mut levels: HashMap<String, usize> = HashMap::new();
if let Ok(topo) = self.topological_sort() {
for id in &topo {
let level = if let Some(deps) = self.deps.get(id) {
deps.iter()
.filter_map(|d| levels.get(d))
.max()
.map(|max_dep| max_dep + 1)
.unwrap_or(0)
} else {
0
};
levels.insert(id.clone(), level);
}
}
levels
}
pub fn topological_sort(&self) -> Result<Vec<String>> {
let mut in_degree: HashMap<String, usize> = HashMap::new();
for node in self.deps.keys() {
in_degree.entry(node.clone()).or_insert(0);
}
for node in self.rdeps.keys() {
in_degree.entry(node.clone()).or_insert(0);
}
for (node, node_deps) in &self.deps {
*in_degree.entry(node.clone()).or_insert(0) = node_deps.len();
}
let mut queue: VecDeque<String> = in_degree
.iter()
.filter(|&(_, deg)| *deg == 0)
.map(|(id, _)| id.clone())
.collect();
let mut sorted_queue: Vec<String> = queue.drain(..).collect();
sorted_queue.sort();
queue.extend(sorted_queue);
let mut result = Vec::new();
while let Some(node) = queue.pop_front() {
result.push(node.clone());
if let Some(blocked) = self.rdeps.get(&node) {
let mut next: Vec<&String> = blocked.iter().collect();
next.sort(); for dependent in next {
if let Some(deg) = in_degree.get_mut(dependent) {
*deg -= 1;
if *deg == 0 {
queue.push_back(dependent.clone());
}
}
}
}
}
if result.len() != in_degree.len() {
let in_cycle: Vec<_> = in_degree
.iter()
.filter(|&(_, deg)| *deg > 0)
.map(|(id, _)| id.clone())
.collect();
return Err(Error::CycleDetected {
from: in_cycle.first().cloned().unwrap_or_default(),
to: "...".into(),
});
}
Ok(result)
}
}
fn status_color(status: TaskStatus) -> &'static str {
match status {
TaskStatus::Pending => "#D3D3D3",
TaskStatus::InProgress => "#87CEEB",
TaskStatus::Completed => "#90EE90",
TaskStatus::Deleted => "#FFB6C1",
}
}
fn status_color_dot(status: TaskStatus) -> &'static str {
match status {
TaskStatus::Pending => "#D3D3D3",
TaskStatus::InProgress => "#87CEEB",
TaskStatus::Completed => "#90EE90",
TaskStatus::Deleted => "#FFB6C1",
}
}
fn status_symbol_ansi(status: TaskStatus) -> (&'static str, &'static str) {
match status {
TaskStatus::Pending => ("○", "\x1b[90m"), TaskStatus::InProgress => ("◉", "\x1b[34m"), TaskStatus::Completed => ("●", "\x1b[32m"), TaskStatus::Deleted => ("✗", "\x1b[31m"), }
}
fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut in_escape = false;
for ch in s.chars() {
if ch == '\x1b' {
in_escape = true;
continue;
}
if in_escape {
if ch == 'm' {
in_escape = false;
}
continue;
}
out.push(ch);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::task::TaskFile;
fn make_task(id: &str, blocked_by: Vec<&str>) -> TaskFile {
TaskFile {
id: id.to_string(),
subject: format!("Task {id}"),
description: None,
active_form: None,
status: TaskStatus::Pending,
owner: None,
blocks: vec![],
blocked_by: blocked_by.into_iter().map(String::from).collect(),
metadata: None,
}
}
fn make_task_with_status(id: &str, status: TaskStatus, blocked_by: Vec<&str>) -> TaskFile {
let mut t = make_task(id, blocked_by);
t.status = status;
t
}
#[test]
fn simple_dag() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
make_task("C", vec!["B"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
assert_eq!(graph.get_blocked_by("C"), vec!["B".to_string()]);
assert_eq!(graph.get_blocked_by("B"), vec!["A".to_string()]);
assert!(graph.get_blocked_by("A").is_empty());
assert!(graph.get_blocks("A").contains(&"B".to_string()));
assert!(graph.get_blocks("B").contains(&"C".to_string()));
assert!(graph.get_blocks("C").is_empty());
}
#[test]
fn cycle_detection_direct() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
assert!(graph.would_create_cycle("A", "B"));
assert!(!graph.would_create_cycle("C", "A"));
}
#[test]
fn cycle_detection_indirect() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
make_task("C", vec!["B"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
assert!(graph.would_create_cycle("A", "C"));
}
#[test]
fn self_reference_is_cycle() {
let tasks = vec![make_task("A", vec![])];
let graph = DependencyGraph::from_tasks(&tasks);
assert!(graph.would_create_cycle("A", "A"));
}
#[test]
fn diamond_dependencies() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
make_task("C", vec!["A"]),
make_task("D", vec!["B", "C"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
let d_deps = graph.get_blocked_by("D");
assert!(d_deps.contains(&"B".to_string()));
assert!(d_deps.contains(&"C".to_string()));
assert_eq!(d_deps.len(), 2);
assert!(!graph.would_create_cycle("D", "A"));
assert!(graph.would_create_cycle("A", "D"));
}
#[test]
fn add_dependency_ok() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec![]),
];
let mut graph = DependencyGraph::from_tasks(&tasks);
graph.add_dependency("B", "A").unwrap();
assert!(graph.get_blocked_by("B").contains(&"A".to_string()));
}
#[test]
fn add_dependency_cycle_rejected() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
];
let mut graph = DependencyGraph::from_tasks(&tasks);
let err = graph.add_dependency("A", "B").unwrap_err();
assert!(err.to_string().contains("cycle"));
}
#[test]
fn is_ready_no_deps() {
let tasks = vec![make_task("A", vec![])];
let graph = DependencyGraph::from_tasks(&tasks);
assert!(graph.is_ready("A", &tasks));
}
#[test]
fn is_ready_deps_completed() {
let tasks = vec![
make_task_with_status("A", TaskStatus::Completed, vec![]),
make_task("B", vec!["A"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
assert!(graph.is_ready("B", &tasks));
}
#[test]
fn is_ready_deps_pending() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
assert!(!graph.is_ready("B", &tasks));
}
#[test]
fn is_ready_deps_deleted() {
let tasks = vec![
make_task_with_status("A", TaskStatus::Deleted, vec![]),
make_task("B", vec!["A"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
assert!(graph.is_ready("B", &tasks));
}
#[test]
fn topological_sort_linear() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
make_task("C", vec!["B"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
let order = graph.topological_sort().unwrap();
assert_eq!(order, vec!["A", "B", "C"]);
}
#[test]
fn topological_sort_diamond() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
make_task("C", vec!["A"]),
make_task("D", vec!["B", "C"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
let order = graph.topological_sort().unwrap();
assert_eq!(order[0], "A");
assert_eq!(order[3], "D");
assert!(order[1] == "B" || order[1] == "C");
assert!(order[2] == "B" || order[2] == "C");
}
#[test]
fn topological_sort_independent() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec![]),
make_task("C", vec![]),
];
let graph = DependencyGraph::from_tasks(&tasks);
let order = graph.topological_sort().unwrap();
assert_eq!(order, vec!["A", "B", "C"]);
}
#[test]
fn mermaid_simple_chain() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
make_task("C", vec!["B"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
let mermaid = graph.to_mermaid(&tasks);
assert!(mermaid.starts_with("graph TD\n"));
assert!(mermaid.contains("A[\"Task A\"]"));
assert!(mermaid.contains("B[\"Task B\"]"));
assert!(mermaid.contains("C[\"Task C\"]"));
assert!(mermaid.contains("A --> B"));
assert!(mermaid.contains("B --> C"));
}
#[test]
fn mermaid_with_status_colors() {
let tasks = vec![
make_task_with_status("A", TaskStatus::Completed, vec![]),
make_task_with_status("B", TaskStatus::InProgress, vec!["A"]),
make_task_with_status("C", TaskStatus::Pending, vec!["B"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
let mermaid = graph.to_mermaid(&tasks);
assert!(mermaid.contains("fill:#90EE90"), "Completed should be green");
assert!(mermaid.contains("fill:#87CEEB"), "InProgress should be blue");
assert!(mermaid.contains("fill:#D3D3D3"), "Pending should be gray");
}
#[test]
fn mermaid_empty_graph() {
let tasks: Vec<TaskFile> = vec![];
let graph = DependencyGraph::from_tasks(&tasks);
let mermaid = graph.to_mermaid(&tasks);
assert_eq!(mermaid, "graph TD\n");
}
#[test]
fn mermaid_escapes_quotes() {
let mut task = make_task("A", vec![]);
task.subject = r#"Task with "quotes""#.to_string();
let tasks = vec![task];
let graph = DependencyGraph::from_tasks(&tasks);
let mermaid = graph.to_mermaid(&tasks);
assert!(mermaid.contains("#quot;"), "Quotes should be escaped");
assert!(!mermaid.contains(r#"[""#) || mermaid.contains("#quot;"));
}
#[test]
fn dot_simple_chain() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
make_task("C", vec!["B"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
let dot = graph.to_dot(&tasks);
assert!(dot.starts_with("digraph tasks {"));
assert!(dot.contains("rankdir=LR"));
assert!(dot.contains("A [label=\"Task A\""));
assert!(dot.contains("A -> B;"));
assert!(dot.contains("B -> C;"));
assert!(dot.ends_with("}\n"));
}
#[test]
fn critical_path_linear() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
make_task("C", vec!["B"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
let path = graph.critical_path(&tasks);
assert_eq!(path, vec!["A", "B", "C"]);
}
#[test]
fn critical_path_diamond() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
make_task("C", vec!["A"]),
make_task("D", vec!["B", "C"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
let path = graph.critical_path(&tasks);
assert_eq!(path.len(), 3);
assert_eq!(path[0], "A");
assert_eq!(path[2], "D");
assert!(path[1] == "B" || path[1] == "C");
}
#[test]
fn critical_path_independent() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec![]),
make_task("C", vec![]),
];
let graph = DependencyGraph::from_tasks(&tasks);
let path = graph.critical_path(&tasks);
assert_eq!(path.len(), 1);
}
#[test]
fn critical_path_skips_deleted() {
let tasks = vec![
make_task("A", vec![]),
make_task_with_status("B", TaskStatus::Deleted, vec!["A"]),
make_task("C", vec!["B"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
let path = graph.critical_path(&tasks);
assert!(!path.contains(&"B".to_string()));
assert!(path.len() <= 2);
}
#[test]
fn terminal_diamond_dag() {
let tasks = vec![
make_task_with_status("1", TaskStatus::Completed, vec![]),
make_task_with_status("2", TaskStatus::Completed, vec![]),
make_task_with_status("3", TaskStatus::Completed, vec![]),
make_task_with_status("4", TaskStatus::InProgress, vec!["1", "2"]),
make_task_with_status("5", TaskStatus::InProgress, vec!["2", "3"]),
make_task("6", vec!["4", "5"]),
];
let mut tasks = tasks;
tasks[0].subject = "CC: Concurrency".into();
tasks[1].subject = "Codex: Security".into();
tasks[2].subject = "Gemini: API".into();
tasks[3].subject = "Codex: Fixes".into();
tasks[4].subject = "Gemini: Refactor".into();
tasks[5].subject = "CC: Synthesis".into();
let graph = DependencyGraph::from_tasks(&tasks);
let output = graph.to_terminal(&tasks);
assert!(output.contains("Phase 0"));
assert!(output.contains("Phase 1"));
assert!(output.contains("Phase 2"));
assert!(output.contains("●"), "Completed should show ●");
assert!(output.contains("◉"), "InProgress should show ◉");
assert!(output.contains("○"), "Pending should show ○");
assert!(output.contains("CC: Concurrency"));
assert!(output.contains("CC: Synthesis"));
assert!(output.contains("← 1, 2"));
assert!(output.contains("← 4, 5"));
assert!(output.contains("Critical path"));
assert!(output.contains("▼"));
}
#[test]
fn terminal_plain_strips_ansi() {
let tasks = vec![
make_task("A", vec![]),
make_task("B", vec!["A"]),
];
let graph = DependencyGraph::from_tasks(&tasks);
let plain = graph.to_terminal_plain(&tasks);
assert!(!plain.contains("\x1b"), "Plain output should have no ANSI escapes");
assert!(plain.contains("Phase 0"));
assert!(plain.contains("Task A"));
}
#[test]
fn terminal_empty_graph() {
let tasks: Vec<TaskFile> = vec![];
let graph = DependencyGraph::from_tasks(&tasks);
let output = graph.to_terminal(&tasks);
assert!(output.contains("empty DAG"));
}
#[test]
fn terminal_single_task() {
let tasks = vec![make_task("1", vec![])];
let graph = DependencyGraph::from_tasks(&tasks);
let output = graph.to_terminal(&tasks);
assert!(output.contains("Phase 0"));
assert!(output.contains("#1"));
assert!(!output.contains("Critical path"));
}
#[test]
fn terminal_with_owners() {
let mut tasks = vec![make_task("1", vec![])];
tasks[0].owner = Some("alice".into());
let graph = DependencyGraph::from_tasks(&tasks);
let plain = graph.to_terminal_plain(&tasks);
assert!(plain.contains("@alice"));
}
#[test]
fn strip_ansi_works() {
let input = "\x1b[32m●\x1b[0m hello \x1b[1mbold\x1b[0m";
let stripped = strip_ansi(input);
assert_eq!(stripped, "● hello bold");
}
}