ralph/queue/operations/
status.rs1use 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
10pub 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 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 task.completed_at = None;
50 }
51 }
52
53 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#[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 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
244fn 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}