1mod helpers;
19
20pub use helpers::{
21 added_tasks, backfill_missing_fields, backfill_terminal_completed_at, reposition_new_tasks,
22 sort_tasks_by_priority, suggest_new_task_insert_index, task_id_set,
23};
24
25use crate::contracts::{QueueFile, Task, TaskStatus};
26use anyhow::{Result, anyhow};
27use helpers::distribute_plan_items;
28
29#[derive(Debug, Clone)]
31pub struct CloneTaskOptions<'a> {
32 pub source_id: &'a str,
34 pub status: TaskStatus,
36 pub title_prefix: Option<&'a str>,
38 pub now_utc: &'a str,
40 pub id_prefix: &'a str,
42 pub id_width: usize,
44 pub max_depth: u8,
46}
47
48impl<'a> CloneTaskOptions<'a> {
49 pub fn new(
51 source_id: &'a str,
52 status: TaskStatus,
53 now_utc: &'a str,
54 id_prefix: &'a str,
55 id_width: usize,
56 ) -> Self {
57 Self {
58 source_id,
59 status,
60 title_prefix: None,
61 now_utc,
62 id_prefix,
63 id_width,
64 max_depth: 10,
65 }
66 }
67
68 pub fn with_title_prefix(mut self, prefix: Option<&'a str>) -> Self {
70 self.title_prefix = prefix;
71 self
72 }
73
74 pub fn with_max_depth(mut self, depth: u8) -> Self {
76 self.max_depth = depth;
77 self
78 }
79}
80
81pub fn clone_task(
99 queue: &mut QueueFile,
100 done: Option<&QueueFile>,
101 opts: &CloneTaskOptions<'_>,
102) -> Result<(String, Task)> {
103 use crate::queue::{next_id_across, validation::validate_queue_set};
104
105 let warnings = validate_queue_set(queue, done, opts.id_prefix, opts.id_width, opts.max_depth)?;
107 if !warnings.is_empty() {
108 for warning in &warnings {
109 log::warn!("Queue validation warning: {}", warning.message);
110 }
111 }
112
113 let source_task = queue
115 .tasks
116 .iter()
117 .find(|t| t.id.trim() == opts.source_id.trim())
118 .or_else(|| {
119 done.and_then(|d| {
120 d.tasks
121 .iter()
122 .find(|t| t.id.trim() == opts.source_id.trim())
123 })
124 })
125 .ok_or_else(|| {
126 anyhow!(
127 "{}",
128 crate::error_messages::source_task_not_found(opts.source_id, true)
129 )
130 })?;
131
132 let new_id = next_id_across(queue, done, opts.id_prefix, opts.id_width, opts.max_depth)?;
134
135 let mut cloned = source_task.clone();
137 cloned.id = new_id.clone();
138
139 if let Some(prefix) = opts.title_prefix
141 && !prefix.is_empty()
142 {
143 cloned.title = format!("{}{}", prefix, cloned.title);
144 }
145
146 cloned.status = opts.status;
148
149 cloned.created_at = Some(opts.now_utc.to_string());
151 cloned.updated_at = Some(opts.now_utc.to_string());
152 cloned.completed_at = None;
153
154 cloned.depends_on.clear();
156
157 Ok((new_id, cloned))
158}
159
160#[derive(Debug, Clone)]
162pub struct SplitTaskOptions<'a> {
163 pub source_id: &'a str,
165 pub number: usize,
167 pub status: TaskStatus,
169 pub title_prefix: Option<&'a str>,
171 pub distribute_plan: bool,
173 pub now_utc: &'a str,
175 pub id_prefix: &'a str,
177 pub id_width: usize,
179 pub max_depth: u8,
181}
182
183impl<'a> SplitTaskOptions<'a> {
184 pub fn new(
186 source_id: &'a str,
187 number: usize,
188 status: TaskStatus,
189 now_utc: &'a str,
190 id_prefix: &'a str,
191 id_width: usize,
192 ) -> Self {
193 Self {
194 source_id,
195 number,
196 status,
197 title_prefix: None,
198 distribute_plan: false,
199 now_utc,
200 id_prefix,
201 id_width,
202 max_depth: 10,
203 }
204 }
205
206 pub fn with_title_prefix(mut self, prefix: Option<&'a str>) -> Self {
208 self.title_prefix = prefix;
209 self
210 }
211
212 pub fn with_distribute_plan(mut self, distribute: bool) -> Self {
214 self.distribute_plan = distribute;
215 self
216 }
217
218 pub fn with_max_depth(mut self, depth: u8) -> Self {
220 self.max_depth = depth;
221 self
222 }
223}
224
225pub fn split_task(
227 queue: &mut QueueFile,
228 _done: Option<&QueueFile>,
229 opts: &SplitTaskOptions<'_>,
230) -> Result<(Task, Vec<Task>)> {
231 use crate::queue::{next_id_across, validation::validate_queue_set};
232
233 let warnings = validate_queue_set(queue, _done, opts.id_prefix, opts.id_width, opts.max_depth)?;
235 if !warnings.is_empty() {
236 for warning in &warnings {
237 log::warn!("Queue validation warning: {}", warning.message);
238 }
239 }
240
241 let source_index = queue
243 .tasks
244 .iter()
245 .position(|t| t.id.trim() == opts.source_id.trim())
246 .ok_or_else(|| {
247 anyhow!(
248 "{}",
249 crate::error_messages::source_task_not_found(opts.source_id, false)
250 )
251 })?;
252
253 let source_task = &queue.tasks[source_index];
254
255 let mut updated_source = source_task.clone();
257 updated_source
258 .custom_fields
259 .insert("split".to_string(), "true".to_string());
260 updated_source.status = TaskStatus::Rejected;
261 updated_source.updated_at = Some(opts.now_utc.to_string());
262 if updated_source.notes.is_empty() {
263 updated_source.notes = vec![format!("Task split into {} child tasks", opts.number)];
264 } else {
265 updated_source
266 .notes
267 .push(format!("Task split into {} child tasks", opts.number));
268 }
269
270 let mut child_tasks = Vec::with_capacity(opts.number);
272 let mut next_id = next_id_across(queue, _done, opts.id_prefix, opts.id_width, opts.max_depth)?;
273
274 let plan_distribution = if opts.distribute_plan && !source_task.plan.is_empty() {
276 distribute_plan_items(&source_task.plan, opts.number)
277 } else {
278 vec![Vec::new(); opts.number]
279 };
280
281 for (i, plan_items) in plan_distribution.iter().enumerate().take(opts.number) {
282 let mut child = source_task.clone();
283 child.id = next_id.clone();
284 child.parent_id = Some(opts.source_id.to_string());
285
286 let title_suffix = format!(" ({}/{})", i + 1, opts.number);
288 if let Some(prefix) = opts.title_prefix {
289 child.title = format!("{}{}{}", prefix, source_task.title, title_suffix);
290 } else {
291 child.title = format!("{}{}", source_task.title, title_suffix);
292 }
293
294 child.status = opts.status;
296 child.created_at = Some(opts.now_utc.to_string());
297 child.updated_at = Some(opts.now_utc.to_string());
298 child.completed_at = None;
299
300 child.depends_on.clear();
302 child.blocks.clear();
303 child.relates_to.clear();
304 child.duplicates = None;
305
306 if opts.distribute_plan {
308 child.plan = plan_items.clone();
309 } else {
310 child.plan.clear();
311 }
312
313 child.notes = vec![format!(
315 "Child task {} of {} from parent {}",
316 i + 1,
317 opts.number,
318 opts.source_id
319 )];
320
321 child_tasks.push(child);
322
323 let numeric_part = next_id
325 .strip_prefix(opts.id_prefix)
326 .and_then(|s| s.strip_prefix('-'))
327 .and_then(|s| s.parse::<u32>().ok())
328 .unwrap_or(0);
329 next_id = format!(
330 "{}-{:0>width$}",
331 opts.id_prefix,
332 numeric_part + 1,
333 width = opts.id_width
334 );
335 }
336
337 Ok((updated_source, child_tasks))
338}
339
340#[cfg(test)]
341mod tests {
342 use super::*;
343
344 #[test]
345 fn distribute_plan_items_distributes_evenly() {
346 let plan = vec![
347 "Step A".to_string(),
348 "Step B".to_string(),
349 "Step C".to_string(),
350 "Step D".to_string(),
351 ];
352
353 let distributed = distribute_plan_items(&plan, 2);
354 assert_eq!(distributed.len(), 2);
355 assert_eq!(distributed[0], vec!["Step A", "Step C"]);
356 assert_eq!(distributed[1], vec!["Step B", "Step D"]);
357 }
358
359 #[test]
360 fn distribute_plan_items_handles_uneven() {
361 let plan = vec![
362 "Step A".to_string(),
363 "Step B".to_string(),
364 "Step C".to_string(),
365 ];
366
367 let distributed = distribute_plan_items(&plan, 2);
368 assert_eq!(distributed.len(), 2);
369 assert_eq!(distributed[0], vec!["Step A", "Step C"]);
370 assert_eq!(distributed[1], vec!["Step B"]);
371 }
372
373 #[test]
374 fn distribute_plan_items_handles_empty() {
375 let plan: Vec<String> = vec![];
376 let distributed = distribute_plan_items(&plan, 2);
377 assert_eq!(distributed.len(), 2);
378 assert!(distributed[0].is_empty());
379 assert!(distributed[1].is_empty());
380 }
381}