Skip to main content

ralph/queue/operations/
mutation.rs

1//! Queue mutation workflows for cloning, splitting, and shared task reshaping.
2//!
3//! Responsibilities:
4//! - Clone existing tasks into new queue entries with fresh IDs and timestamps.
5//! - Split a parent task into rejected-source plus child-task outputs.
6//! - Re-export shared queue mutation helpers from `mutation/helpers.rs`.
7//!
8//! Does not handle:
9//! - Queue persistence, locking, or repair flows.
10//! - Batch orchestration around clone/split operations.
11//! - Schema validation beyond targeted queue-set checks before clone/split.
12//!
13//! Assumptions/invariants:
14//! - Caller-provided timestamps are already normalized RFC3339 UTC strings.
15//! - Queue and done IDs remain unique across the validated queue set.
16//! - Split children always clear dependency edges inherited from the source task.
17
18mod 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/// Options for cloning a task.
30#[derive(Debug, Clone)]
31pub struct CloneTaskOptions<'a> {
32    /// ID of the task to clone.
33    pub source_id: &'a str,
34    /// Status for the cloned task.
35    pub status: TaskStatus,
36    /// Optional prefix to prepend to the cloned task's title.
37    pub title_prefix: Option<&'a str>,
38    /// Current timestamp (RFC3339) for created_at/updated_at.
39    pub now_utc: &'a str,
40    /// Prefix for new task ID (e.g., "RQ").
41    pub id_prefix: &'a str,
42    /// Width of the numeric portion of the ID.
43    pub id_width: usize,
44    /// Max dependency depth for validation.
45    pub max_depth: u8,
46}
47
48impl<'a> CloneTaskOptions<'a> {
49    /// Create new clone options with required fields.
50    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    /// Set the title prefix.
69    pub fn with_title_prefix(mut self, prefix: Option<&'a str>) -> Self {
70        self.title_prefix = prefix;
71        self
72    }
73
74    /// Set the max dependency depth.
75    pub fn with_max_depth(mut self, depth: u8) -> Self {
76        self.max_depth = depth;
77        self
78    }
79}
80
81/// Clone an existing task to create a new task with the same fields.
82///
83/// The cloned task will have:
84/// - A new task ID (generated using the provided prefix/width)
85/// - Fresh timestamps (created_at, updated_at = now)
86/// - Cleared completed_at
87/// - Status set to the provided value (default: Draft)
88/// - Cleared depends_on (to avoid unintended dependencies)
89/// - Optional title prefix applied
90///
91/// # Arguments
92/// * `queue` - The active queue to insert the cloned task into
93/// * `done` - Optional done queue to search for source task
94/// * `opts` - Clone options (source_id, status, title_prefix, now_utc, id_prefix, id_width, max_depth)
95///
96/// # Returns
97/// A tuple of (new_task_id, cloned_task)
98pub 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    // Validate queues first
106    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    // Find source task in queue or done
114    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    // Generate new task ID
133    let new_id = next_id_across(queue, done, opts.id_prefix, opts.id_width, opts.max_depth)?;
134
135    // Clone the task
136    let mut cloned = source_task.clone();
137    cloned.id = new_id.clone();
138
139    // Apply title prefix if provided
140    if let Some(prefix) = opts.title_prefix
141        && !prefix.is_empty()
142    {
143        cloned.title = format!("{}{}", prefix, cloned.title);
144    }
145
146    // Set status
147    cloned.status = opts.status;
148
149    // Set fresh timestamps
150    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    // Clear dependencies to avoid unintended dependencies
155    cloned.depends_on.clear();
156
157    Ok((new_id, cloned))
158}
159
160/// Options for splitting a task.
161#[derive(Debug, Clone)]
162pub struct SplitTaskOptions<'a> {
163    /// ID of the task to split.
164    pub source_id: &'a str,
165    /// Number of child tasks to create.
166    pub number: usize,
167    /// Status for child tasks.
168    pub status: TaskStatus,
169    /// Optional prefix to prepend to child task titles.
170    pub title_prefix: Option<&'a str>,
171    /// Distribute plan items across children.
172    pub distribute_plan: bool,
173    /// Current timestamp (RFC3339) for created_at/updated_at.
174    pub now_utc: &'a str,
175    /// Prefix for new task ID (e.g., "RQ").
176    pub id_prefix: &'a str,
177    /// Width of the numeric portion of the ID.
178    pub id_width: usize,
179    /// Max dependency depth for validation.
180    pub max_depth: u8,
181}
182
183impl<'a> SplitTaskOptions<'a> {
184    /// Create new split options with required fields.
185    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    /// Set the title prefix.
207    pub fn with_title_prefix(mut self, prefix: Option<&'a str>) -> Self {
208        self.title_prefix = prefix;
209        self
210    }
211
212    /// Set distribute plan flag.
213    pub fn with_distribute_plan(mut self, distribute: bool) -> Self {
214        self.distribute_plan = distribute;
215        self
216    }
217
218    /// Set the max dependency depth.
219    pub fn with_max_depth(mut self, depth: u8) -> Self {
220        self.max_depth = depth;
221        self
222    }
223}
224
225/// Split an existing task into multiple child tasks.
226pub 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    // Validate queues first
234    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    // Find source task in queue only (splitting from done archive doesn't make sense)
242    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    // Mark source task as split
256    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    // Generate child tasks
271    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    // Distribute plan items if requested
275    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        // Build title with optional prefix and index
287        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        // Set status and timestamps
295        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        // Clear dependencies for children
301        child.depends_on.clear();
302        child.blocks.clear();
303        child.relates_to.clear();
304        child.duplicates = None;
305
306        // Distribute plan items
307        if opts.distribute_plan {
308            child.plan = plan_items.clone();
309        } else {
310            child.plan.clear();
311        }
312
313        // Add note about being a child task
314        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        // Generate next ID for the next child (simulate insertion)
324        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}