use super::validate::parse_rfc3339_utc;
use crate::contracts::{QueueFile, Task, TaskStatus};
use crate::queue::{load_queue, load_queue_or_default, save_queue, validation};
use crate::redaction;
use anyhow::{Result, anyhow, bail};
use std::path::Path;
pub fn apply_status_policy(
task: &mut Task,
status: TaskStatus,
now_rfc3339: &str,
note: Option<&str>,
) -> Result<()> {
apply_status_fields(task, status, now_rfc3339)?;
if let Some(note) = note {
append_redacted_note(task, note);
}
Ok(())
}
fn apply_status_fields(task: &mut Task, status: TaskStatus, now_rfc3339: &str) -> Result<()> {
let now = parse_rfc3339_utc(now_rfc3339)?;
task.status = status;
task.updated_at = Some(now.clone());
match status {
TaskStatus::Done | TaskStatus::Rejected => {
if task
.completed_at
.as_ref()
.is_none_or(|t| t.trim().is_empty())
{
task.completed_at = Some(now.clone());
}
}
TaskStatus::Draft | TaskStatus::Todo | TaskStatus::Doing => {
task.completed_at = None;
}
}
if status == TaskStatus::Doing && task.started_at.is_none() {
task.started_at = Some(now);
}
Ok(())
}
fn append_redacted_note(task: &mut Task, note: &str) {
let redacted = redaction::redact_text(note);
let trimmed = redacted.trim();
if !trimmed.is_empty() {
task.notes.push(trimmed.to_string());
}
}
fn append_redacted_notes(task: &mut Task, notes: &[String]) {
for note in notes {
append_redacted_note(task, note);
}
}
#[allow(clippy::too_many_arguments)]
pub fn complete_task(
queue_path: &Path,
done_path: &Path,
task_id: &str,
status: TaskStatus,
now_rfc3339: &str,
notes: &[String],
id_prefix: &str,
id_width: usize,
max_dependency_depth: u8,
custom_fields_patch: Option<&std::collections::HashMap<String, String>>,
) -> Result<()> {
match status {
TaskStatus::Done | TaskStatus::Rejected => {}
TaskStatus::Draft | TaskStatus::Todo | TaskStatus::Doing => {
bail!(
"Invalid completion status: only 'done' or 'rejected' are allowed. Got: {:?}. Use 'ralph task done {}' or 'ralph task reject {}'.",
status,
task_id,
task_id
);
}
}
let mut active = load_queue(queue_path)?;
validation::validate_queue(&active, id_prefix, id_width)?;
let needle = task_id.trim();
if needle.is_empty() {
bail!(
"Missing task_id: a task ID is required for this operation. Provide a valid ID (e.g., 'RQ-0001')."
);
}
let task_idx = active
.tasks
.iter()
.position(|t| t.id.trim() == needle)
.ok_or_else(|| {
anyhow!(
"{}",
crate::error_messages::task_not_found_for_edit("status", needle)
)
})?;
let task = &active.tasks[task_idx];
match task.status {
TaskStatus::Todo | TaskStatus::Doing => {}
TaskStatus::Draft => {
bail!(
"task {} is still in draft status. Promote it to todo before completing.",
needle
);
}
TaskStatus::Done | TaskStatus::Rejected => {
bail!(
"task {} is already in a terminal state: {:?}. Cannot complete a task that is already done or rejected.",
needle,
task.status
);
}
}
let mut completed_task = active.tasks.remove(task_idx);
apply_status_fields(&mut completed_task, status, now_rfc3339)?;
append_redacted_notes(&mut completed_task, notes);
if let Some(patch) = custom_fields_patch {
apply_custom_fields_patch(&mut completed_task, patch);
}
let mut done = load_queue_or_default(done_path)?;
let mut done_with_completed = done.clone();
done_with_completed.tasks.push(completed_task.clone());
let warnings = validation::validate_queue_set(
&active,
Some(&done_with_completed),
id_prefix,
id_width,
max_dependency_depth,
)?;
validation::log_warnings(&warnings);
done.tasks.push(completed_task);
save_queue(done_path, &done)?;
save_queue(queue_path, &active)?;
Ok(())
}
pub fn set_status(
queue: &mut QueueFile,
task_id: &str,
status: TaskStatus,
now_rfc3339: &str,
note: Option<&str>,
) -> Result<()> {
let needle = task_id.trim();
if needle.is_empty() {
bail!(
"Missing task_id: a task ID is required for this operation. Provide a valid ID (e.g., 'RQ-0001')."
);
}
let task = queue
.tasks
.iter_mut()
.find(|t| t.id.trim() == needle)
.ok_or_else(|| anyhow!("{}", crate::error_messages::task_not_found(needle)))?;
apply_status_policy(task, status, now_rfc3339, note)?;
Ok(())
}
pub fn promote_draft_to_todo(
queue: &mut QueueFile,
task_id: &str,
now_rfc3339: &str,
note: Option<&str>,
) -> Result<()> {
let needle = task_id.trim();
if needle.is_empty() {
bail!(
"Missing task_id: a task ID is required for this operation. Provide a valid ID (e.g., 'RQ-0001')."
);
}
let task = queue
.tasks
.iter()
.find(|t| t.id.trim() == needle)
.ok_or_else(|| anyhow!("{}", crate::error_messages::task_not_found(needle)))?;
if task.status != TaskStatus::Draft {
bail!(
"task {} is not in draft status (current status: {}). Only draft tasks can be marked ready.",
needle,
task.status
);
}
set_status(queue, needle, TaskStatus::Todo, now_rfc3339, note)
}
fn apply_custom_fields_patch(task: &mut Task, patch: &std::collections::HashMap<String, String>) {
for (k, v) in patch {
let key: &str = k.trim();
let val: &str = v.trim();
if key.is_empty() || val.is_empty() {
continue;
}
task.custom_fields.insert(key.to_string(), val.to_string());
}
}