use crate::error::{IntentError, Result};
use crate::plan::{FlatTask, TaskStatus};
use std::collections::{HashMap, HashSet};
pub fn validate_dependencies(flat_tasks: &[FlatTask]) -> Result<()> {
let task_names: HashSet<&str> = flat_tasks
.iter()
.filter_map(|t| t.name.as_deref())
.collect();
for task in flat_tasks {
for dep_name in &task.depends_on {
if !task_names.contains(dep_name.as_str()) {
let task_name = task.name.as_deref().unwrap_or("<unknown>");
return Err(IntentError::InvalidInput(format!(
"Task '{}' depends on '{}', but '{}' is not in the plan",
task_name, dep_name, dep_name
)));
}
}
}
Ok(())
}
pub fn validate_batch_single_doing(flat_tasks: &[FlatTask]) -> Result<()> {
let doing_tasks: Vec<&FlatTask> = flat_tasks
.iter()
.filter(|task| matches!(task.status, Some(TaskStatus::Doing)))
.collect();
if doing_tasks.len() > 1 {
let names: Vec<&str> = doing_tasks
.iter()
.map(|t| t.name.as_deref().unwrap_or("<unknown>"))
.collect();
return Err(IntentError::InvalidInput(format!(
"Batch single doing constraint violated: only one task per batch can have status='doing'. Found: {}",
names.join(", ")
)));
}
Ok(())
}
pub fn detect_circular_dependencies(flat_tasks: &[FlatTask]) -> Result<()> {
if flat_tasks.is_empty() {
return Ok(());
}
let name_to_idx: HashMap<&str, usize> = flat_tasks
.iter()
.enumerate()
.filter_map(|(i, t)| t.name.as_ref().map(|n| (n.as_str(), i)))
.collect();
let mut graph: Vec<Vec<usize>> = vec![Vec::new(); flat_tasks.len()];
for (idx, task) in flat_tasks.iter().enumerate() {
for dep_name in &task.depends_on {
if let Some(dep_idx) = name_to_idx.get(dep_name.as_str()) {
graph[idx].push(*dep_idx);
}
}
}
for task in flat_tasks {
if let Some(name) = &task.name {
if task.depends_on.contains(name) {
return Err(IntentError::InvalidInput(format!(
"Circular dependency detected: task '{}' depends on itself",
name
)));
}
}
}
let sccs = tarjan_scc(&graph);
for scc in sccs {
if scc.len() > 1 {
let cycle_names: Vec<&str> = scc
.iter()
.map(|&idx| flat_tasks[idx].name.as_deref().unwrap_or("<unknown>"))
.collect();
return Err(IntentError::InvalidInput(format!(
"Circular dependency detected: {}",
cycle_names.join(" → ")
)));
}
}
Ok(())
}
pub fn tarjan_scc(graph: &[Vec<usize>]) -> Vec<Vec<usize>> {
let n = graph.len();
let mut index_counter = 0;
let mut stack = Vec::new();
let mut on_stack = vec![false; n];
let mut index = vec![usize::MAX; n];
let mut lowlink = vec![0; n];
let mut result = Vec::new();
#[allow(clippy::too_many_arguments)]
fn strongconnect(
v: usize,
graph: &[Vec<usize>],
index_counter: &mut usize,
stack: &mut Vec<usize>,
on_stack: &mut Vec<bool>,
index: &mut Vec<usize>,
lowlink: &mut Vec<usize>,
result: &mut Vec<Vec<usize>>,
) {
index[v] = *index_counter;
lowlink[v] = *index_counter;
*index_counter += 1;
stack.push(v);
on_stack[v] = true;
for &w in &graph[v] {
if index[w] == usize::MAX {
strongconnect(
w,
graph,
index_counter,
stack,
on_stack,
index,
lowlink,
result,
);
lowlink[v] = lowlink[v].min(lowlink[w]);
} else if on_stack[w] {
lowlink[v] = lowlink[v].min(index[w]);
}
}
if lowlink[v] == index[v] {
let mut component = Vec::new();
loop {
let w = stack.pop().unwrap();
on_stack[w] = false;
component.push(w);
if w == v {
break;
}
}
result.push(component);
}
}
for v in 0..n {
if index[v] == usize::MAX {
strongconnect(
v,
graph,
&mut index_counter,
&mut stack,
&mut on_stack,
&mut index,
&mut lowlink,
&mut result,
);
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_batch_single_doing_ok() {
let tasks = vec![
FlatTask {
name: Some("A".to_string()),
status: Some(TaskStatus::Doing),
..Default::default()
},
FlatTask {
name: Some("B".to_string()),
status: Some(TaskStatus::Todo),
..Default::default()
},
];
assert!(validate_batch_single_doing(&tasks).is_ok());
}
#[test]
fn test_validate_batch_single_doing_violation() {
let tasks = vec![
FlatTask {
name: Some("A".to_string()),
status: Some(TaskStatus::Doing),
..Default::default()
},
FlatTask {
name: Some("B".to_string()),
status: Some(TaskStatus::Doing),
..Default::default()
},
];
assert!(validate_batch_single_doing(&tasks).is_err());
}
#[test]
fn test_validate_batch_single_doing_zero() {
let tasks = vec![FlatTask {
name: Some("A".to_string()),
status: Some(TaskStatus::Todo),
..Default::default()
}];
assert!(validate_batch_single_doing(&tasks).is_ok());
}
#[test]
fn test_validate_dependencies_ok() {
let tasks = vec![
FlatTask {
name: Some("A".to_string()),
depends_on: vec!["B".to_string()],
..Default::default()
},
FlatTask {
name: Some("B".to_string()),
..Default::default()
},
];
assert!(validate_dependencies(&tasks).is_ok());
}
#[test]
fn test_validate_dependencies_missing() {
let tasks = vec![FlatTask {
name: Some("A".to_string()),
depends_on: vec!["NonExistent".to_string()],
..Default::default()
}];
let err = validate_dependencies(&tasks).unwrap_err();
assert!(err.to_string().contains("NonExistent"));
}
#[test]
fn test_validate_dependencies_empty() {
assert!(validate_dependencies(&[]).is_ok());
}
#[test]
fn test_detect_circular_no_cycle() {
let tasks = vec![
FlatTask {
name: Some("A".to_string()),
depends_on: vec!["B".to_string()],
..Default::default()
},
FlatTask {
name: Some("B".to_string()),
..Default::default()
},
];
assert!(detect_circular_dependencies(&tasks).is_ok());
}
#[test]
fn test_detect_circular_self_loop() {
let tasks = vec![FlatTask {
name: Some("A".to_string()),
depends_on: vec!["A".to_string()],
..Default::default()
}];
let err = detect_circular_dependencies(&tasks).unwrap_err();
assert!(err.to_string().contains("depends on itself"));
}
#[test]
fn test_detect_circular_two_node_cycle() {
let tasks = vec![
FlatTask {
name: Some("A".to_string()),
depends_on: vec!["B".to_string()],
..Default::default()
},
FlatTask {
name: Some("B".to_string()),
depends_on: vec!["A".to_string()],
..Default::default()
},
];
assert!(detect_circular_dependencies(&tasks).is_err());
}
#[test]
fn test_detect_circular_three_node_cycle() {
let tasks = vec![
FlatTask {
name: Some("A".to_string()),
depends_on: vec!["B".to_string()],
..Default::default()
},
FlatTask {
name: Some("B".to_string()),
depends_on: vec!["C".to_string()],
..Default::default()
},
FlatTask {
name: Some("C".to_string()),
depends_on: vec!["A".to_string()],
..Default::default()
},
];
assert!(detect_circular_dependencies(&tasks).is_err());
}
#[test]
fn test_detect_circular_empty() {
assert!(detect_circular_dependencies(&[]).is_ok());
}
#[test]
fn test_tarjan_scc_no_cycle() {
let graph = vec![vec![1], vec![]]; let sccs = tarjan_scc(&graph);
assert!(sccs.iter().all(|scc| scc.len() == 1));
}
#[test]
fn test_tarjan_scc_with_cycle() {
let graph = vec![vec![1], vec![0]]; let sccs = tarjan_scc(&graph);
assert!(sccs.iter().any(|scc| scc.len() > 1));
}
#[test]
fn test_tarjan_scc_empty() {
let graph: Vec<Vec<usize>> = vec![];
let sccs = tarjan_scc(&graph);
assert!(sccs.is_empty());
}
#[test]
fn test_tarjan_scc_disconnected() {
let graph = vec![vec![], vec![]]; let sccs = tarjan_scc(&graph);
assert_eq!(sccs.len(), 2);
assert!(sccs.iter().all(|scc| scc.len() == 1));
}
}