mod helpers;
pub use helpers::{
added_tasks, backfill_missing_fields, backfill_terminal_completed_at, reposition_new_tasks,
sort_tasks_by_priority, suggest_new_task_insert_index, task_id_set,
};
use crate::contracts::{QueueFile, Task, TaskStatus};
use anyhow::{Result, anyhow};
use helpers::distribute_plan_items;
#[derive(Debug, Clone)]
pub struct CloneTaskOptions<'a> {
pub source_id: &'a str,
pub status: TaskStatus,
pub title_prefix: Option<&'a str>,
pub now_utc: &'a str,
pub id_prefix: &'a str,
pub id_width: usize,
pub max_depth: u8,
}
impl<'a> CloneTaskOptions<'a> {
pub fn new(
source_id: &'a str,
status: TaskStatus,
now_utc: &'a str,
id_prefix: &'a str,
id_width: usize,
) -> Self {
Self {
source_id,
status,
title_prefix: None,
now_utc,
id_prefix,
id_width,
max_depth: 10,
}
}
pub fn with_title_prefix(mut self, prefix: Option<&'a str>) -> Self {
self.title_prefix = prefix;
self
}
pub fn with_max_depth(mut self, depth: u8) -> Self {
self.max_depth = depth;
self
}
}
pub fn clone_task(
queue: &mut QueueFile,
done: Option<&QueueFile>,
opts: &CloneTaskOptions<'_>,
) -> Result<(String, Task)> {
use crate::queue::{next_id_across, validation::validate_queue_set};
let warnings = validate_queue_set(queue, done, opts.id_prefix, opts.id_width, opts.max_depth)?;
if !warnings.is_empty() {
for warning in &warnings {
log::warn!("Queue validation warning: {}", warning.message);
}
}
let source_task = queue
.tasks
.iter()
.find(|t| t.id.trim() == opts.source_id.trim())
.or_else(|| {
done.and_then(|d| {
d.tasks
.iter()
.find(|t| t.id.trim() == opts.source_id.trim())
})
})
.ok_or_else(|| {
anyhow!(
"{}",
crate::error_messages::source_task_not_found(opts.source_id, true)
)
})?;
let new_id = next_id_across(queue, done, opts.id_prefix, opts.id_width, opts.max_depth)?;
let mut cloned = source_task.clone();
cloned.id = new_id.clone();
if let Some(prefix) = opts.title_prefix
&& !prefix.is_empty()
{
cloned.title = format!("{}{}", prefix, cloned.title);
}
cloned.status = opts.status;
cloned.created_at = Some(opts.now_utc.to_string());
cloned.updated_at = Some(opts.now_utc.to_string());
cloned.completed_at = None;
cloned.depends_on.clear();
Ok((new_id, cloned))
}
#[derive(Debug, Clone)]
pub struct SplitTaskOptions<'a> {
pub source_id: &'a str,
pub number: usize,
pub status: TaskStatus,
pub title_prefix: Option<&'a str>,
pub distribute_plan: bool,
pub now_utc: &'a str,
pub id_prefix: &'a str,
pub id_width: usize,
pub max_depth: u8,
}
impl<'a> SplitTaskOptions<'a> {
pub fn new(
source_id: &'a str,
number: usize,
status: TaskStatus,
now_utc: &'a str,
id_prefix: &'a str,
id_width: usize,
) -> Self {
Self {
source_id,
number,
status,
title_prefix: None,
distribute_plan: false,
now_utc,
id_prefix,
id_width,
max_depth: 10,
}
}
pub fn with_title_prefix(mut self, prefix: Option<&'a str>) -> Self {
self.title_prefix = prefix;
self
}
pub fn with_distribute_plan(mut self, distribute: bool) -> Self {
self.distribute_plan = distribute;
self
}
pub fn with_max_depth(mut self, depth: u8) -> Self {
self.max_depth = depth;
self
}
}
pub fn split_task(
queue: &mut QueueFile,
_done: Option<&QueueFile>,
opts: &SplitTaskOptions<'_>,
) -> Result<(Task, Vec<Task>)> {
use crate::queue::{next_id_across, validation::validate_queue_set};
let warnings = validate_queue_set(queue, _done, opts.id_prefix, opts.id_width, opts.max_depth)?;
if !warnings.is_empty() {
for warning in &warnings {
log::warn!("Queue validation warning: {}", warning.message);
}
}
let source_index = queue
.tasks
.iter()
.position(|t| t.id.trim() == opts.source_id.trim())
.ok_or_else(|| {
anyhow!(
"{}",
crate::error_messages::source_task_not_found(opts.source_id, false)
)
})?;
let source_task = &queue.tasks[source_index];
let mut updated_source = source_task.clone();
updated_source
.custom_fields
.insert("split".to_string(), "true".to_string());
updated_source.status = TaskStatus::Rejected;
updated_source.updated_at = Some(opts.now_utc.to_string());
if updated_source.notes.is_empty() {
updated_source.notes = vec![format!("Task split into {} child tasks", opts.number)];
} else {
updated_source
.notes
.push(format!("Task split into {} child tasks", opts.number));
}
let mut child_tasks = Vec::with_capacity(opts.number);
let mut next_id = next_id_across(queue, _done, opts.id_prefix, opts.id_width, opts.max_depth)?;
let plan_distribution = if opts.distribute_plan && !source_task.plan.is_empty() {
distribute_plan_items(&source_task.plan, opts.number)
} else {
vec![Vec::new(); opts.number]
};
for (i, plan_items) in plan_distribution.iter().enumerate().take(opts.number) {
let mut child = source_task.clone();
child.id = next_id.clone();
child.parent_id = Some(opts.source_id.to_string());
let title_suffix = format!(" ({}/{})", i + 1, opts.number);
if let Some(prefix) = opts.title_prefix {
child.title = format!("{}{}{}", prefix, source_task.title, title_suffix);
} else {
child.title = format!("{}{}", source_task.title, title_suffix);
}
child.status = opts.status;
child.created_at = Some(opts.now_utc.to_string());
child.updated_at = Some(opts.now_utc.to_string());
child.completed_at = None;
child.depends_on.clear();
child.blocks.clear();
child.relates_to.clear();
child.duplicates = None;
if opts.distribute_plan {
child.plan = plan_items.clone();
} else {
child.plan.clear();
}
child.notes = vec![format!(
"Child task {} of {} from parent {}",
i + 1,
opts.number,
opts.source_id
)];
child_tasks.push(child);
let numeric_part = next_id
.strip_prefix(opts.id_prefix)
.and_then(|s| s.strip_prefix('-'))
.and_then(|s| s.parse::<u32>().ok())
.unwrap_or(0);
next_id = format!(
"{}-{:0>width$}",
opts.id_prefix,
numeric_part + 1,
width = opts.id_width
);
}
Ok((updated_source, child_tasks))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn distribute_plan_items_distributes_evenly() {
let plan = vec![
"Step A".to_string(),
"Step B".to_string(),
"Step C".to_string(),
"Step D".to_string(),
];
let distributed = distribute_plan_items(&plan, 2);
assert_eq!(distributed.len(), 2);
assert_eq!(distributed[0], vec!["Step A", "Step C"]);
assert_eq!(distributed[1], vec!["Step B", "Step D"]);
}
#[test]
fn distribute_plan_items_handles_uneven() {
let plan = vec![
"Step A".to_string(),
"Step B".to_string(),
"Step C".to_string(),
];
let distributed = distribute_plan_items(&plan, 2);
assert_eq!(distributed.len(), 2);
assert_eq!(distributed[0], vec!["Step A", "Step C"]);
assert_eq!(distributed[1], vec!["Step B"]);
}
#[test]
fn distribute_plan_items_handles_empty() {
let plan: Vec<String> = vec![];
let distributed = distribute_plan_items(&plan, 2);
assert_eq!(distributed.len(), 2);
assert!(distributed[0].is_empty());
assert!(distributed[1].is_empty());
}
}