Skip to main content

ralph/queue/operations/mutation/
helpers.rs

1//! Queue mutation helper utilities.
2//!
3//! Responsibilities:
4//! - Provide shared queue-level helper mutations used by task creation, scan, and import flows.
5//! - Backfill missing task fields and terminal timestamps in loaded queue data.
6//! - Reposition, sort, and inspect tasks without changing clone/split semantics.
7//!
8//! Does not handle:
9//! - Cloning or splitting tasks (handled by the parent `mutation` module).
10//! - Queue validation, persistence, or lock management.
11//!
12//! Assumptions/invariants:
13//! - Task IDs are trimmed before comparisons and set membership checks.
14//! - Helpers operate on in-memory `QueueFile` values only.
15//! - `distribute_plan_items` preserves item order within each child bucket.
16
17use crate::contracts::{QueueFile, TaskStatus};
18use std::collections::HashSet;
19
20/// Suggests the insertion index for new tasks based on the first task's status.
21///
22/// Returns `1` if the first task has status `Doing` (insert after the in-progress task),
23/// otherwise returns `0` (insert at top of the queue). Returns `0` for empty queues.
24pub fn suggest_new_task_insert_index(queue: &QueueFile) -> usize {
25    match queue.tasks.first() {
26        Some(first_task) if matches!(first_task.status, TaskStatus::Doing) => 1,
27        _ => 0,
28    }
29}
30
31/// Repositions newly added tasks to the specified insertion index in the queue.
32///
33/// This function extracts tasks identified by `new_task_ids` from their current positions
34/// and splices them into the queue at `insert_at`, preserving the relative order of
35/// existing tasks and the new tasks themselves.
36///
37/// The `insert_at` index is clamped to `queue.tasks.len()` to prevent out-of-bounds errors.
38pub fn reposition_new_tasks(queue: &mut QueueFile, new_task_ids: &[String], insert_at: usize) {
39    if new_task_ids.is_empty() || queue.tasks.is_empty() {
40        return;
41    }
42
43    let insert_at = insert_at.min(queue.tasks.len());
44    let new_task_set: HashSet<String> = new_task_ids.iter().cloned().collect();
45
46    let mut new_tasks = Vec::new();
47    let mut retained_tasks = Vec::new();
48
49    for task in queue.tasks.drain(..) {
50        if new_task_set.contains(&task.id) {
51            new_tasks.push(task);
52        } else {
53            retained_tasks.push(task);
54        }
55    }
56
57    let split_index = insert_at.min(retained_tasks.len());
58    let mut before_split = Vec::new();
59    let mut after_split = retained_tasks;
60    for task in after_split.drain(..split_index) {
61        before_split.push(task);
62    }
63
64    queue.tasks = before_split
65        .into_iter()
66        .chain(new_tasks)
67        .chain(after_split)
68        .collect();
69}
70
71pub fn added_tasks(before: &HashSet<String>, after: &QueueFile) -> Vec<(String, String)> {
72    let mut added = Vec::new();
73    for task in &after.tasks {
74        let id = task.id.trim();
75        if id.is_empty() || before.contains(id) {
76            continue;
77        }
78        added.push((id.to_string(), task.title.trim().to_string()));
79    }
80    added
81}
82
83pub fn backfill_missing_fields(
84    queue: &mut QueueFile,
85    new_task_ids: &[String],
86    default_request: &str,
87    now_utc: &str,
88) {
89    let now = now_utc.trim();
90    if now.is_empty() || new_task_ids.is_empty() || queue.tasks.is_empty() {
91        return;
92    }
93
94    let new_task_set: HashSet<&str> = new_task_ids.iter().map(|id| id.as_str()).collect();
95    for task in queue.tasks.iter_mut() {
96        if !new_task_set.contains(task.id.trim()) {
97            continue;
98        }
99
100        if task.request.as_ref().is_none_or(|r| r.trim().is_empty()) {
101            let req = default_request.trim();
102            if !req.is_empty() {
103                task.request = Some(req.to_string());
104            }
105        }
106
107        if task.created_at.as_ref().is_none_or(|t| t.trim().is_empty()) {
108            task.created_at = Some(now.to_string());
109        }
110
111        if task.updated_at.as_ref().is_none_or(|t| t.trim().is_empty()) {
112            task.updated_at = Some(now.to_string());
113        }
114    }
115}
116
117/// Ensure terminal tasks have a completed_at timestamp.
118///
119/// Returns the number of tasks updated.
120pub fn backfill_terminal_completed_at(queue: &mut QueueFile, now_utc: &str) -> usize {
121    let now = now_utc.trim();
122    if now.is_empty() {
123        return 0;
124    }
125
126    let mut updated = 0;
127    for task in queue.tasks.iter_mut() {
128        if !matches!(task.status, TaskStatus::Done | TaskStatus::Rejected) {
129            continue;
130        }
131
132        if task
133            .completed_at
134            .as_ref()
135            .is_none_or(|t| t.trim().is_empty())
136        {
137            task.completed_at = Some(now.to_string());
138            updated += 1;
139        }
140    }
141
142    updated
143}
144
145pub fn sort_tasks_by_priority(queue: &mut QueueFile, descending: bool) {
146    queue.tasks.sort_by(|a, b| {
147        let ord = if descending {
148            a.priority.cmp(&b.priority).reverse()
149        } else {
150            a.priority.cmp(&b.priority)
151        };
152        match ord {
153            std::cmp::Ordering::Equal => a.id.cmp(&b.id),
154            other => other,
155        }
156    });
157}
158
159pub fn task_id_set(queue: &QueueFile) -> HashSet<String> {
160    let mut set = HashSet::new();
161    for task in &queue.tasks {
162        let id = task.id.trim();
163        if id.is_empty() {
164            continue;
165        }
166        set.insert(id.to_string());
167    }
168    set
169}
170
171/// Distribute plan items evenly across child tasks using round-robin.
172pub(crate) fn distribute_plan_items(plan: &[String], num_children: usize) -> Vec<Vec<String>> {
173    let mut distribution: Vec<Vec<String>> = vec![Vec::new(); num_children];
174
175    for (i, item) in plan.iter().enumerate() {
176        distribution[i % num_children].push(item.clone());
177    }
178
179    distribution
180}