use super::types::{
Nag, PlanError, TodoItem, TodoItemInput, TodoList, TodoStatus, TodoWriteOutput,
};
pub fn todo_write(todos: &[TodoItemInput]) -> Result<TodoWriteOutput, PlanError> {
let mut nags: Vec<Nag> = Vec::new();
for (idx, item) in todos.iter().enumerate() {
if item.description.trim().is_empty() {
return Err(PlanError(format!(
"Description must be non-empty text (item {}).",
idx + 1
)));
}
}
let mut key_to_id: std::collections::HashMap<String, String> = std::collections::HashMap::new();
for (idx, item) in todos.iter().enumerate() {
let id = format!("task-{}", idx + 1);
if let Some(key) = &item.key {
if key.starts_with("task-")
&& key[5..].bytes().all(|b| b.is_ascii_digit())
&& !key[5..].is_empty()
{
return Err(PlanError(format!(
"Key '{key}' looks like a task id; keys must not be 'task-<number>' to avoid shadowing real ids."
)));
}
if key_to_id.insert(key.clone(), id.clone()).is_some() {
return Err(PlanError(format!(
"Duplicate key '{key}'; keys must be unique."
)));
}
}
}
let in_progress: Vec<usize> = todos
.iter()
.enumerate()
.filter(|(_, i)| i.status == TodoStatus::InProgress)
.map(|(idx, _)| idx)
.collect();
if in_progress.len() > 1 {
let ids = in_progress
.iter()
.map(|idx| format!("'task-{}'", idx + 1))
.collect::<Vec<_>>()
.join(", ");
return Err(PlanError(format!(
"Only one task at a time can be in_progress; got {ids}."
)));
}
let items: Vec<TodoItem> = todos
.iter()
.enumerate()
.map(|(idx, item)| TodoItem {
id: format!("task-{}", idx + 1),
description: item.description.clone(),
status: item.status,
depends_on: Vec::new(),
})
.collect();
let all_ids: Vec<String> = items.iter().map(|i| i.id.clone()).collect();
let mut resolved_items = Vec::with_capacity(items.len());
for (idx, item) in todos.iter().enumerate() {
let mut resolved: Vec<String> =
Vec::with_capacity(item.depends_on.as_ref().map_or(0, Vec::len));
for dep in item.depends_on.as_ref().into_iter().flatten() {
if let Some(id) = key_to_id.get(dep) {
if Some(dep.as_str()) == item.key.as_deref() {
nags.push(Nag {
message: format!("Dependency '{dep}' is a self-reference."),
});
continue;
}
resolved.push(id.clone());
} else if all_ids.iter().any(|id| id == dep) {
resolved.push(dep.clone());
} else {
nags.push(Nag {
message: format!(
"Dependency '{dep}' is neither a sibling key nor an existing task id."
),
});
}
}
let mut out = items[idx].clone();
out.depends_on = resolved;
resolved_items.push(out);
}
let mut cycle_reported: Vec<String> = Vec::new();
for i in &resolved_items {
if cycle_reported.contains(&i.id) {
continue;
}
if would_create_cycle(&resolved_items, &i.id, &i.id) {
nags.push(Nag {
message: format!(
"Dependencies of '{}' form a circular dependency chain.",
i.id
),
});
let reachable = reachable_from(&resolved_items, &i.id);
let members: Vec<String> = reachable
.iter()
.filter(|n| would_create_cycle(&resolved_items, &i.id, n))
.cloned()
.collect();
cycle_reported.extend(members);
}
}
for i in &resolved_items {
if i.status != TodoStatus::InProgress {
continue;
}
for dep_id in &i.depends_on {
if let Some(d) = resolved_items.iter().find(|d| &d.id == dep_id)
&& d.status != TodoStatus::Completed
{
nags.push(Nag {
message: format!(
"'{}' depends on '{dep_id}' which is still {}.",
i.id, d.status
),
});
}
}
}
Ok(TodoWriteOutput {
list: TodoList {
items: resolved_items,
},
nags,
})
}
pub(super) fn would_create_cycle(items: &[TodoItem], id: &str, dep_id: &str) -> bool {
let mut visited = vec![dep_id.to_owned()];
let mut queue = vec![dep_id.to_owned()];
while let Some(current) = queue.pop() {
for item in items {
if item.id == current {
for dep in &item.depends_on {
if dep == id {
return true;
}
if !visited.contains(dep) {
visited.push(dep.clone());
queue.push(dep.clone());
}
}
}
}
}
false
}
fn reachable_from(items: &[TodoItem], start: &str) -> Vec<String> {
let mut seen: Vec<String> = vec![start.to_owned()];
let mut queue = vec![start.to_owned()];
while let Some(current) = queue.pop() {
for it in items {
if it.id == current {
for dep in &it.depends_on {
if !seen.contains(dep) {
seen.push(dep.clone());
queue.push(dep.clone());
}
}
}
}
}
seen
}