Skip to main content

ralph/queue/operations/
status.rs

1//! Status mutation helpers for queue tasks.
2
3use super::validate::parse_rfc3339_utc;
4use crate::contracts::{QueueFile, Task, TaskStatus};
5use crate::queue::{load_queue, load_queue_or_default, save_queue, validation};
6use crate::redaction;
7use anyhow::{Result, anyhow, bail};
8use std::path::Path;
9
10/// Apply the shared status-transition policy to a task.
11///
12/// This updates status, updated_at, and completed_at based on terminal states,
13/// and optionally appends a redacted note.
14pub fn apply_status_policy(
15    task: &mut Task,
16    status: TaskStatus,
17    now_rfc3339: &str,
18    note: Option<&str>,
19) -> Result<()> {
20    apply_status_fields(task, status, now_rfc3339)?;
21
22    if let Some(note) = note {
23        append_redacted_note(task, note);
24    }
25
26    Ok(())
27}
28
29fn apply_status_fields(task: &mut Task, status: TaskStatus, now_rfc3339: &str) -> Result<()> {
30    let now = parse_rfc3339_utc(now_rfc3339)?;
31
32    task.status = status;
33    task.updated_at = Some(now.clone());
34
35    match status {
36        TaskStatus::Done | TaskStatus::Rejected => {
37            // Preserve an existing completed_at (e.g., manual backfill) but ensure
38            // terminal tasks never lack a completion timestamp.
39            if task
40                .completed_at
41                .as_ref()
42                .is_none_or(|t| t.trim().is_empty())
43            {
44                task.completed_at = Some(now.clone());
45            }
46        }
47        TaskStatus::Draft | TaskStatus::Todo | TaskStatus::Doing => {
48            // Non-terminal tasks must not carry a completed timestamp.
49            task.completed_at = None;
50        }
51    }
52
53    // Set started_at when transitioning to Doing (only if not already set)
54    if status == TaskStatus::Doing && task.started_at.is_none() {
55        task.started_at = Some(now);
56    }
57
58    Ok(())
59}
60
61fn append_redacted_note(task: &mut Task, note: &str) {
62    let redacted = redaction::redact_text(note);
63    let trimmed = redacted.trim();
64    if !trimmed.is_empty() {
65        task.notes.push(trimmed.to_string());
66    }
67}
68
69fn append_redacted_notes(task: &mut Task, notes: &[String]) {
70    for note in notes {
71        append_redacted_note(task, note);
72    }
73}
74
75/// Complete a single task and move it to the done archive.
76///
77/// Validates that the task exists in the active queue, is in a valid
78/// starting state (todo or doing), updates its status and timestamps,
79/// appends any provided notes, applies optional custom_fields patch,
80/// and atomically moves it from the active queue file to the end of the done archive file.
81///
82/// # Arguments
83/// * `queue_path` - Path to the active queue file
84/// * `done_path` - Path to the done archive file (created if missing)
85/// * `task_id` - ID of the task to complete
86/// * `status` - Terminal status (Done or Rejected)
87/// * `now_rfc3339` - Current UTC timestamp as RFC3339 string
88/// * `notes` - Optional notes to append to the task
89/// * `id_prefix` - Expected task ID prefix (e.g., "RQ")
90/// * `id_width` - Expected numeric width for task IDs (e.g., 4)
91/// * `max_dependency_depth` - Maximum dependency depth for validation
92/// * `custom_fields_patch` - Optional custom fields to apply to the task (observational data)
93#[allow(clippy::too_many_arguments)]
94pub fn complete_task(
95    queue_path: &Path,
96    done_path: &Path,
97    task_id: &str,
98    status: TaskStatus,
99    now_rfc3339: &str,
100    notes: &[String],
101    id_prefix: &str,
102    id_width: usize,
103    max_dependency_depth: u8,
104    custom_fields_patch: Option<&std::collections::HashMap<String, String>>,
105) -> Result<()> {
106    match status {
107        TaskStatus::Done | TaskStatus::Rejected => {}
108        TaskStatus::Draft | TaskStatus::Todo | TaskStatus::Doing => {
109            bail!(
110                "Invalid completion status: only 'done' or 'rejected' are allowed. Got: {:?}. Use 'ralph task done {}' or 'ralph task reject {}'.",
111                status,
112                task_id,
113                task_id
114            );
115        }
116    }
117
118    let mut active = load_queue(queue_path)?;
119    validation::validate_queue(&active, id_prefix, id_width)?;
120
121    let needle = task_id.trim();
122    if needle.is_empty() {
123        bail!(
124            "Missing task_id: a task ID is required for this operation. Provide a valid ID (e.g., 'RQ-0001')."
125        );
126    }
127
128    let task_idx = active
129        .tasks
130        .iter()
131        .position(|t| t.id.trim() == needle)
132        .ok_or_else(|| {
133            anyhow!(
134                "{}",
135                crate::error_messages::task_not_found_for_edit("status", needle)
136            )
137        })?;
138
139    let task = &active.tasks[task_idx];
140
141    match task.status {
142        TaskStatus::Todo | TaskStatus::Doing => {}
143        TaskStatus::Draft => {
144            bail!(
145                "task {} is still in draft status. Promote it to todo before completing.",
146                needle
147            );
148        }
149        TaskStatus::Done | TaskStatus::Rejected => {
150            bail!(
151                "task {} is already in a terminal state: {:?}. Cannot complete a task that is already done or rejected.",
152                needle,
153                task.status
154            );
155        }
156    }
157
158    let mut completed_task = active.tasks.remove(task_idx);
159
160    apply_status_fields(&mut completed_task, status, now_rfc3339)?;
161    append_redacted_notes(&mut completed_task, notes);
162
163    // Apply custom fields patch for observational analytics
164    if let Some(patch) = custom_fields_patch {
165        apply_custom_fields_patch(&mut completed_task, patch);
166    }
167
168    let mut done = load_queue_or_default(done_path)?;
169
170    let mut done_with_completed = done.clone();
171    done_with_completed.tasks.push(completed_task.clone());
172    let warnings = validation::validate_queue_set(
173        &active,
174        Some(&done_with_completed),
175        id_prefix,
176        id_width,
177        max_dependency_depth,
178    )?;
179    validation::log_warnings(&warnings);
180
181    done.tasks.push(completed_task);
182
183    save_queue(done_path, &done)?;
184    save_queue(queue_path, &active)?;
185
186    Ok(())
187}
188
189pub fn set_status(
190    queue: &mut QueueFile,
191    task_id: &str,
192    status: TaskStatus,
193    now_rfc3339: &str,
194    note: Option<&str>,
195) -> Result<()> {
196    let needle = task_id.trim();
197    if needle.is_empty() {
198        bail!(
199            "Missing task_id: a task ID is required for this operation. Provide a valid ID (e.g., 'RQ-0001')."
200        );
201    }
202
203    let task = queue
204        .tasks
205        .iter_mut()
206        .find(|t| t.id.trim() == needle)
207        .ok_or_else(|| anyhow!("{}", crate::error_messages::task_not_found(needle)))?;
208
209    apply_status_policy(task, status, now_rfc3339, note)?;
210
211    Ok(())
212}
213
214pub fn promote_draft_to_todo(
215    queue: &mut QueueFile,
216    task_id: &str,
217    now_rfc3339: &str,
218    note: Option<&str>,
219) -> Result<()> {
220    let needle = task_id.trim();
221    if needle.is_empty() {
222        bail!(
223            "Missing task_id: a task ID is required for this operation. Provide a valid ID (e.g., 'RQ-0001')."
224        );
225    }
226
227    let task = queue
228        .tasks
229        .iter()
230        .find(|t| t.id.trim() == needle)
231        .ok_or_else(|| anyhow!("{}", crate::error_messages::task_not_found(needle)))?;
232
233    if task.status != TaskStatus::Draft {
234        bail!(
235            "task {} is not in draft status (current status: {}). Only draft tasks can be marked ready.",
236            needle,
237            task.status
238        );
239    }
240
241    set_status(queue, needle, TaskStatus::Todo, now_rfc3339, note)
242}
243
244/// Apply custom fields patch to a task, overwriting existing values.
245/// Skips empty keys or values.
246fn apply_custom_fields_patch(task: &mut Task, patch: &std::collections::HashMap<String, String>) {
247    for (k, v) in patch {
248        let key: &str = k.trim();
249        let val: &str = v.trim();
250        if key.is_empty() || val.is_empty() {
251            continue;
252        }
253        task.custom_fields.insert(key.to_string(), val.to_string());
254    }
255}