1use crate::contracts::{QueueFile, Task, TaskStatus};
4use anyhow::{Result, anyhow};
5use std::collections::HashSet;
6
7pub fn suggest_new_task_insert_index(queue: &QueueFile) -> usize {
12 match queue.tasks.first() {
13 Some(first_task) if matches!(first_task.status, TaskStatus::Doing) => 1,
14 _ => 0,
15 }
16}
17
18pub fn reposition_new_tasks(queue: &mut QueueFile, new_task_ids: &[String], insert_at: usize) {
26 if new_task_ids.is_empty() || queue.tasks.is_empty() {
27 return;
28 }
29
30 let insert_at = insert_at.min(queue.tasks.len());
31 let new_task_set: HashSet<String> = new_task_ids.iter().cloned().collect();
32
33 let mut new_tasks = Vec::new();
34 let mut retained_tasks = Vec::new();
35
36 for task in queue.tasks.drain(..) {
37 if new_task_set.contains(&task.id) {
38 new_tasks.push(task);
39 } else {
40 retained_tasks.push(task);
41 }
42 }
43
44 let split_index = insert_at.min(retained_tasks.len());
46 let mut before_split = Vec::new();
47 let mut after_split = retained_tasks;
48 for task in after_split.drain(..split_index) {
49 before_split.push(task);
50 }
51
52 queue.tasks = before_split
53 .into_iter()
54 .chain(new_tasks)
55 .chain(after_split)
56 .collect();
57}
58
59pub fn added_tasks(before: &HashSet<String>, after: &QueueFile) -> Vec<(String, String)> {
60 let mut added = Vec::new();
61 for task in &after.tasks {
62 let id = task.id.trim();
63 if id.is_empty() || before.contains(id) {
64 continue;
65 }
66 added.push((id.to_string(), task.title.trim().to_string()));
67 }
68 added
69}
70
71pub fn backfill_missing_fields(
72 queue: &mut QueueFile,
73 new_task_ids: &[String],
74 default_request: &str,
75 now_utc: &str,
76) {
77 let now = now_utc.trim();
78 if now.is_empty() || new_task_ids.is_empty() || queue.tasks.is_empty() {
79 return;
80 }
81
82 let new_task_set: HashSet<&str> = new_task_ids.iter().map(|id| id.as_str()).collect();
83 for task in queue.tasks.iter_mut() {
84 if !new_task_set.contains(task.id.trim()) {
85 continue;
86 }
87
88 if task.request.as_ref().is_none_or(|r| r.trim().is_empty()) {
89 let req = default_request.trim();
90 if !req.is_empty() {
91 task.request = Some(req.to_string());
92 }
93 }
94
95 if task.created_at.as_ref().is_none_or(|t| t.trim().is_empty()) {
96 task.created_at = Some(now.to_string());
97 }
98
99 if task.updated_at.as_ref().is_none_or(|t| t.trim().is_empty()) {
100 task.updated_at = Some(now.to_string());
101 }
102 }
103}
104
105pub fn backfill_terminal_completed_at(queue: &mut QueueFile, now_utc: &str) -> usize {
109 let now = now_utc.trim();
110 if now.is_empty() {
111 return 0;
112 }
113
114 let mut updated = 0;
115 for task in queue.tasks.iter_mut() {
116 if !matches!(task.status, TaskStatus::Done | TaskStatus::Rejected) {
117 continue;
118 }
119
120 if task
121 .completed_at
122 .as_ref()
123 .is_none_or(|t| t.trim().is_empty())
124 {
125 task.completed_at = Some(now.to_string());
126 updated += 1;
127 }
128 }
129
130 updated
131}
132
133pub fn sort_tasks_by_priority(queue: &mut QueueFile, descending: bool) {
134 queue.tasks.sort_by(|a, b| {
135 let ord = if descending {
136 a.priority.cmp(&b.priority).reverse()
137 } else {
138 a.priority.cmp(&b.priority)
139 };
140 match ord {
141 std::cmp::Ordering::Equal => a.id.cmp(&b.id),
142 other => other,
143 }
144 });
145}
146
147pub fn task_id_set(queue: &QueueFile) -> HashSet<String> {
148 let mut set = HashSet::new();
149 for task in &queue.tasks {
150 let id = task.id.trim();
151 if id.is_empty() {
152 continue;
153 }
154 set.insert(id.to_string());
155 }
156 set
157}
158
159#[derive(Debug, Clone)]
161pub struct CloneTaskOptions<'a> {
162 pub source_id: &'a str,
164 pub status: TaskStatus,
166 pub title_prefix: Option<&'a str>,
168 pub now_utc: &'a str,
170 pub id_prefix: &'a str,
172 pub id_width: usize,
174 pub max_depth: u8,
176}
177
178impl<'a> CloneTaskOptions<'a> {
179 pub fn new(
181 source_id: &'a str,
182 status: TaskStatus,
183 now_utc: &'a str,
184 id_prefix: &'a str,
185 id_width: usize,
186 ) -> Self {
187 Self {
188 source_id,
189 status,
190 title_prefix: None,
191 now_utc,
192 id_prefix,
193 id_width,
194 max_depth: 10,
195 }
196 }
197
198 pub fn with_title_prefix(mut self, prefix: Option<&'a str>) -> Self {
200 self.title_prefix = prefix;
201 self
202 }
203
204 pub fn with_max_depth(mut self, depth: u8) -> Self {
206 self.max_depth = depth;
207 self
208 }
209}
210
211pub fn clone_task(
229 queue: &mut QueueFile,
230 done: Option<&QueueFile>,
231 opts: &CloneTaskOptions<'_>,
232) -> Result<(String, Task)> {
233 use crate::queue::{next_id_across, validation::validate_queue_set};
234
235 let warnings = validate_queue_set(queue, done, opts.id_prefix, opts.id_width, opts.max_depth)?;
237 if !warnings.is_empty() {
238 for warning in &warnings {
239 log::warn!("Queue validation warning: {}", warning.message);
240 }
241 }
242
243 let source_task = queue
245 .tasks
246 .iter()
247 .find(|t| t.id.trim() == opts.source_id.trim())
248 .or_else(|| {
249 done.and_then(|d| {
250 d.tasks
251 .iter()
252 .find(|t| t.id.trim() == opts.source_id.trim())
253 })
254 })
255 .ok_or_else(|| {
256 anyhow!(
257 "{}",
258 crate::error_messages::source_task_not_found(opts.source_id, true)
259 )
260 })?;
261
262 let new_id = next_id_across(queue, done, opts.id_prefix, opts.id_width, opts.max_depth)?;
264
265 let mut cloned = source_task.clone();
267 cloned.id = new_id.clone();
268
269 if let Some(prefix) = opts.title_prefix
271 && !prefix.is_empty()
272 {
273 cloned.title = format!("{}{}", prefix, cloned.title);
274 }
275
276 cloned.status = opts.status;
278
279 cloned.created_at = Some(opts.now_utc.to_string());
281 cloned.updated_at = Some(opts.now_utc.to_string());
282 cloned.completed_at = None;
283
284 cloned.depends_on.clear();
286
287 Ok((new_id, cloned))
288}
289
290#[derive(Debug, Clone)]
292pub struct SplitTaskOptions<'a> {
293 pub source_id: &'a str,
295 pub number: usize,
297 pub status: TaskStatus,
299 pub title_prefix: Option<&'a str>,
301 pub distribute_plan: bool,
303 pub now_utc: &'a str,
305 pub id_prefix: &'a str,
307 pub id_width: usize,
309 pub max_depth: u8,
311}
312
313impl<'a> SplitTaskOptions<'a> {
314 pub fn new(
316 source_id: &'a str,
317 number: usize,
318 status: TaskStatus,
319 now_utc: &'a str,
320 id_prefix: &'a str,
321 id_width: usize,
322 ) -> Self {
323 Self {
324 source_id,
325 number,
326 status,
327 title_prefix: None,
328 distribute_plan: false,
329 now_utc,
330 id_prefix,
331 id_width,
332 max_depth: 10,
333 }
334 }
335
336 pub fn with_title_prefix(mut self, prefix: Option<&'a str>) -> Self {
338 self.title_prefix = prefix;
339 self
340 }
341
342 pub fn with_distribute_plan(mut self, distribute: bool) -> Self {
344 self.distribute_plan = distribute;
345 self
346 }
347
348 pub fn with_max_depth(mut self, depth: u8) -> Self {
350 self.max_depth = depth;
351 self
352 }
353}
354
355pub fn split_task(
357 queue: &mut QueueFile,
358 _done: Option<&QueueFile>,
359 opts: &SplitTaskOptions<'_>,
360) -> Result<(Task, Vec<Task>)> {
361 use crate::queue::{next_id_across, validation::validate_queue_set};
362
363 let warnings = validate_queue_set(queue, _done, opts.id_prefix, opts.id_width, opts.max_depth)?;
365 if !warnings.is_empty() {
366 for warning in &warnings {
367 log::warn!("Queue validation warning: {}", warning.message);
368 }
369 }
370
371 let source_index = queue
373 .tasks
374 .iter()
375 .position(|t| t.id.trim() == opts.source_id.trim())
376 .ok_or_else(|| {
377 anyhow!(
378 "{}",
379 crate::error_messages::source_task_not_found(opts.source_id, false)
380 )
381 })?;
382
383 let source_task = &queue.tasks[source_index];
384
385 let mut updated_source = source_task.clone();
387 updated_source
388 .custom_fields
389 .insert("split".to_string(), "true".to_string());
390 updated_source.status = TaskStatus::Rejected;
391 updated_source.updated_at = Some(opts.now_utc.to_string());
392 if updated_source.notes.is_empty() {
393 updated_source.notes = vec![format!("Task split into {} child tasks", opts.number)];
394 } else {
395 updated_source
396 .notes
397 .push(format!("Task split into {} child tasks", opts.number));
398 }
399
400 let mut child_tasks = Vec::with_capacity(opts.number);
402 let mut next_id = next_id_across(queue, _done, opts.id_prefix, opts.id_width, opts.max_depth)?;
403
404 let plan_distribution = if opts.distribute_plan && !source_task.plan.is_empty() {
406 distribute_plan_items(&source_task.plan, opts.number)
407 } else {
408 vec![Vec::new(); opts.number]
409 };
410
411 for (i, plan_items) in plan_distribution.iter().enumerate().take(opts.number) {
412 let mut child = source_task.clone();
413 child.id = next_id.clone();
414 child.parent_id = Some(opts.source_id.to_string());
415
416 let title_suffix = format!(" ({}/{})", i + 1, opts.number);
418 if let Some(prefix) = opts.title_prefix {
419 child.title = format!("{}{}{}", prefix, source_task.title, title_suffix);
420 } else {
421 child.title = format!("{}{}", source_task.title, title_suffix);
422 }
423
424 child.status = opts.status;
426 child.created_at = Some(opts.now_utc.to_string());
427 child.updated_at = Some(opts.now_utc.to_string());
428 child.completed_at = None;
429
430 child.depends_on.clear();
432 child.blocks.clear();
433 child.relates_to.clear();
434 child.duplicates = None;
435
436 if opts.distribute_plan {
438 child.plan = plan_items.clone();
439 } else {
440 child.plan.clear();
441 }
442
443 child.notes = vec![format!(
445 "Child task {} of {} from parent {}",
446 i + 1,
447 opts.number,
448 opts.source_id
449 )];
450
451 child_tasks.push(child);
452
453 let numeric_part = next_id
455 .strip_prefix(opts.id_prefix)
456 .and_then(|s| s.strip_prefix('-'))
457 .and_then(|s| s.parse::<u32>().ok())
458 .unwrap_or(0);
459 next_id = format!(
460 "{}-{:0>width$}",
461 opts.id_prefix,
462 numeric_part + 1,
463 width = opts.id_width
464 );
465 }
466
467 Ok((updated_source, child_tasks))
468}
469
470fn distribute_plan_items(plan: &[String], num_children: usize) -> Vec<Vec<String>> {
472 let mut distribution: Vec<Vec<String>> = vec![Vec::new(); num_children];
473
474 for (i, item) in plan.iter().enumerate() {
475 distribution[i % num_children].push(item.clone());
476 }
477
478 distribution
479}
480
481#[cfg(test)]
482mod tests {
483 use super::*;
484
485 #[test]
486 fn distribute_plan_items_distributes_evenly() {
487 let plan = vec![
488 "Step A".to_string(),
489 "Step B".to_string(),
490 "Step C".to_string(),
491 "Step D".to_string(),
492 ];
493
494 let distributed = distribute_plan_items(&plan, 2);
495 assert_eq!(distributed.len(), 2);
496 assert_eq!(distributed[0], vec!["Step A", "Step C"]);
497 assert_eq!(distributed[1], vec!["Step B", "Step D"]);
498 }
499
500 #[test]
501 fn distribute_plan_items_handles_uneven() {
502 let plan = vec![
503 "Step A".to_string(),
504 "Step B".to_string(),
505 "Step C".to_string(),
506 ];
507
508 let distributed = distribute_plan_items(&plan, 2);
509 assert_eq!(distributed.len(), 2);
510 assert_eq!(distributed[0], vec!["Step A", "Step C"]);
511 assert_eq!(distributed[1], vec!["Step B"]);
512 }
513
514 #[test]
515 fn distribute_plan_items_handles_empty() {
516 let plan: Vec<String> = vec![];
517 let distributed = distribute_plan_items(&plan, 2);
518 assert_eq!(distributed.len(), 2);
519 assert!(distributed[0].is_empty());
520 assert!(distributed[1].is_empty());
521 }
522}