Skip to main content

ralph/queue/operations/
mutation.rs

1//! Collection/mutation helpers for queue tasks.
2
3use crate::contracts::{QueueFile, Task, TaskStatus};
4use anyhow::{Result, anyhow};
5use std::collections::HashSet;
6
7/// Suggests the insertion index for new tasks based on the first task's status.
8///
9/// Returns `1` if the first task has status `Doing` (insert after the in-progress task),
10/// otherwise returns `0` (insert at top of the queue). Returns `0` for empty queues.
11pub 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
18/// Repositions newly added tasks to the specified insertion index in the queue.
19///
20/// This function extracts tasks identified by `new_task_ids` from their current positions
21/// and splices them into the queue at `insert_at`, preserving the relative order of
22/// existing tasks and the new tasks themselves.
23///
24/// The `insert_at` index is clamped to `queue.tasks.len()` to prevent out-of-bounds errors.
25pub 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    // Splice new tasks at the calculated insertion point
45    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
105/// Ensure terminal tasks have a completed_at timestamp.
106///
107/// Returns the number of tasks updated.
108pub 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/// Options for cloning a task.
160#[derive(Debug, Clone)]
161pub struct CloneTaskOptions<'a> {
162    /// ID of the task to clone.
163    pub source_id: &'a str,
164    /// Status for the cloned task.
165    pub status: TaskStatus,
166    /// Optional prefix to prepend to the cloned task's title.
167    pub title_prefix: Option<&'a str>,
168    /// Current timestamp (RFC3339) for created_at/updated_at.
169    pub now_utc: &'a str,
170    /// Prefix for new task ID (e.g., "RQ").
171    pub id_prefix: &'a str,
172    /// Width of the numeric portion of the ID.
173    pub id_width: usize,
174    /// Max dependency depth for validation.
175    pub max_depth: u8,
176}
177
178impl<'a> CloneTaskOptions<'a> {
179    /// Create new clone options with required fields.
180    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    /// Set the title prefix.
199    pub fn with_title_prefix(mut self, prefix: Option<&'a str>) -> Self {
200        self.title_prefix = prefix;
201        self
202    }
203
204    /// Set the max dependency depth.
205    pub fn with_max_depth(mut self, depth: u8) -> Self {
206        self.max_depth = depth;
207        self
208    }
209}
210
211/// Clone an existing task to create a new task with the same fields.
212///
213/// The cloned task will have:
214/// - A new task ID (generated using the provided prefix/width)
215/// - Fresh timestamps (created_at, updated_at = now)
216/// - Cleared completed_at
217/// - Status set to the provided value (default: Draft)
218/// - Cleared depends_on (to avoid unintended dependencies)
219/// - Optional title prefix applied
220///
221/// # Arguments
222/// * `queue` - The active queue to insert the cloned task into
223/// * `done` - Optional done queue to search for source task
224/// * `opts` - Clone options (source_id, status, title_prefix, now_utc, id_prefix, id_width, max_depth)
225///
226/// # Returns
227/// A tuple of (new_task_id, cloned_task)
228pub 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    // Validate queues first
236    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    // Find source task in queue or done
244    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    // Generate new task ID
263    let new_id = next_id_across(queue, done, opts.id_prefix, opts.id_width, opts.max_depth)?;
264
265    // Clone the task
266    let mut cloned = source_task.clone();
267    cloned.id = new_id.clone();
268
269    // Apply title prefix if provided
270    if let Some(prefix) = opts.title_prefix
271        && !prefix.is_empty()
272    {
273        cloned.title = format!("{}{}", prefix, cloned.title);
274    }
275
276    // Set status
277    cloned.status = opts.status;
278
279    // Set fresh timestamps
280    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    // Clear dependencies to avoid unintended dependencies
285    cloned.depends_on.clear();
286
287    Ok((new_id, cloned))
288}
289
290/// Options for splitting a task.
291#[derive(Debug, Clone)]
292pub struct SplitTaskOptions<'a> {
293    /// ID of the task to split.
294    pub source_id: &'a str,
295    /// Number of child tasks to create.
296    pub number: usize,
297    /// Status for child tasks.
298    pub status: TaskStatus,
299    /// Optional prefix to prepend to child task titles.
300    pub title_prefix: Option<&'a str>,
301    /// Distribute plan items across children.
302    pub distribute_plan: bool,
303    /// Current timestamp (RFC3339) for created_at/updated_at.
304    pub now_utc: &'a str,
305    /// Prefix for new task ID (e.g., "RQ").
306    pub id_prefix: &'a str,
307    /// Width of the numeric portion of the ID.
308    pub id_width: usize,
309    /// Max dependency depth for validation.
310    pub max_depth: u8,
311}
312
313impl<'a> SplitTaskOptions<'a> {
314    /// Create new split options with required fields.
315    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    /// Set the title prefix.
337    pub fn with_title_prefix(mut self, prefix: Option<&'a str>) -> Self {
338        self.title_prefix = prefix;
339        self
340    }
341
342    /// Set distribute plan flag.
343    pub fn with_distribute_plan(mut self, distribute: bool) -> Self {
344        self.distribute_plan = distribute;
345        self
346    }
347
348    /// Set the max dependency depth.
349    pub fn with_max_depth(mut self, depth: u8) -> Self {
350        self.max_depth = depth;
351        self
352    }
353}
354
355/// Split an existing task into multiple child tasks.
356pub 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    // Validate queues first
364    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    // Find source task in queue only (splitting from done archive doesn't make sense)
372    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    // Mark source task as split
386    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    // Generate child tasks
401    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    // Distribute plan items if requested
405    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        // Build title with optional prefix and index
417        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        // Set status and timestamps
425        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        // Clear dependencies for children
431        child.depends_on.clear();
432        child.blocks.clear();
433        child.relates_to.clear();
434        child.duplicates = None;
435
436        // Distribute plan items
437        if opts.distribute_plan {
438            child.plan = plan_items.clone();
439        } else {
440            child.plan.clear();
441        }
442
443        // Add note about being a child task
444        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        // Generate next ID for the next child (simulate insertion)
454        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
470/// Distribute plan items evenly across child tasks using round-robin.
471fn 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}