Skip to main content

ralph/queue/operations/edit/
apply.rs

1//! Task edit application logic.
2//!
3//! Responsibilities:
4//! - Apply edits to task fields with proper validation.
5//! - Update timestamps and maintain task consistency.
6//! - Rollback changes if validation fails.
7//!
8//! Does not handle:
9//! - Queue persistence (callers must save the queue after editing).
10//! - Previewing changes without applying them (see `preview.rs`).
11//!
12//! Assumptions/invariants:
13//! - Callers provide a valid RFC3339 `now` value for timestamp updates.
14//! - Task IDs are matched after trimming and are case-sensitive.
15//! - Failed validation rolls back the task to its previous state.
16
17use super::key::TaskEditKey;
18use super::parsing::{
19    cycle_status, normalize_rfc3339_input, parse_list, parse_status, parse_task_agent_override,
20};
21use super::validate_input::ensure_now;
22use crate::contracts::{QueueFile, TaskPriority};
23use crate::queue;
24use crate::queue::operations::validate::{ensure_task_id, parse_custom_fields_with_context};
25use anyhow::{Context, Result, anyhow, bail};
26
27#[allow(clippy::too_many_arguments)]
28pub fn apply_task_edit(
29    queue: &mut QueueFile,
30    done: Option<&QueueFile>,
31    task_id: &str,
32    key: TaskEditKey,
33    input: &str,
34    now_rfc3339: &str,
35    id_prefix: &str,
36    id_width: usize,
37    max_dependency_depth: u8,
38) -> Result<()> {
39    let operation = "edit";
40    let needle = ensure_task_id(task_id, operation)?;
41
42    let index = queue
43        .tasks
44        .iter()
45        .position(|t| t.id.trim() == needle)
46        .ok_or_else(|| {
47            anyhow!(
48                "{}",
49                crate::error_messages::task_not_found_for_edit(operation, needle)
50            )
51        })?;
52
53    let previous = queue.tasks.get(index).cloned().ok_or_else(|| {
54        anyhow!(
55            "{}",
56            crate::error_messages::task_not_found_for_edit(operation, needle)
57        )
58    })?;
59
60    let task = queue.tasks.get_mut(index).ok_or_else(|| {
61        anyhow!(
62            "{}",
63            crate::error_messages::task_not_found_for_edit(operation, needle)
64        )
65    })?;
66
67    let trimmed = input.trim();
68
69    match key {
70        TaskEditKey::Title => {
71            if trimmed.is_empty() {
72                bail!(
73                    "Queue edit failed (task_id={}, field=title): title cannot be empty. Provide a non-empty title (e.g., 'Fix login bug').",
74                    needle
75                );
76            }
77            task.title = trimmed.to_string();
78        }
79        TaskEditKey::Description => {
80            task.description = if trimmed.is_empty() {
81                None
82            } else {
83                Some(trimmed.to_string())
84            };
85        }
86        TaskEditKey::Status => {
87            let next_status = if trimmed.is_empty() {
88                cycle_status(task.status)
89            } else {
90                parse_status(trimmed).with_context(|| {
91                    format!("Queue edit failed (task_id={}, field=status)", needle)
92                })?
93            };
94            let now = ensure_now(now_rfc3339)?;
95            queue::apply_status_policy(task, next_status, &now, None)?;
96        }
97        TaskEditKey::Priority => {
98            task.priority = if trimmed.is_empty() {
99                task.priority.cycle()
100            } else {
101                trimmed.parse::<TaskPriority>().with_context(|| {
102                    format!("Queue edit failed (task_id={}, field=priority)", needle)
103                })?
104            };
105        }
106        TaskEditKey::Tags => {
107            task.tags = parse_list(trimmed);
108        }
109        TaskEditKey::Scope => {
110            task.scope = parse_list(trimmed);
111        }
112        TaskEditKey::Evidence => {
113            task.evidence = parse_list(trimmed);
114        }
115        TaskEditKey::Plan => {
116            task.plan = parse_list(trimmed);
117        }
118        TaskEditKey::Notes => {
119            task.notes = parse_list(trimmed);
120        }
121        TaskEditKey::Request => {
122            task.request = if trimmed.is_empty() {
123                None
124            } else {
125                Some(trimmed.to_string())
126            };
127        }
128        TaskEditKey::DependsOn => {
129            task.depends_on = parse_list(trimmed);
130        }
131        TaskEditKey::Blocks => {
132            task.blocks = parse_list(trimmed);
133        }
134        TaskEditKey::RelatesTo => {
135            task.relates_to = parse_list(trimmed);
136        }
137        TaskEditKey::Duplicates => {
138            task.duplicates = if trimmed.is_empty() {
139                None
140            } else {
141                Some(trimmed.to_string())
142            };
143        }
144        TaskEditKey::CustomFields => {
145            task.custom_fields = parse_custom_fields_with_context(needle, trimmed, operation)?;
146        }
147        TaskEditKey::Agent => {
148            task.agent = parse_task_agent_override(trimmed)
149                .with_context(|| format!("Queue edit failed (task_id={}, field=agent)", needle))?;
150        }
151        TaskEditKey::CreatedAt => {
152            let normalized = normalize_rfc3339_input("created_at", trimmed).with_context(|| {
153                format!("Queue edit failed (task_id={}, field=created_at)", needle)
154            })?;
155            task.created_at = normalized;
156        }
157        TaskEditKey::UpdatedAt => {
158            let normalized = normalize_rfc3339_input("updated_at", trimmed).with_context(|| {
159                format!("Queue edit failed (task_id={}, field=updated_at)", needle)
160            })?;
161            task.updated_at = normalized;
162        }
163        TaskEditKey::CompletedAt => {
164            let normalized =
165                normalize_rfc3339_input("completed_at", trimmed).with_context(|| {
166                    format!("Queue edit failed (task_id={}, field=completed_at)", needle)
167                })?;
168            task.completed_at = normalized;
169        }
170        TaskEditKey::StartedAt => {
171            let normalized = normalize_rfc3339_input("started_at", trimmed).with_context(|| {
172                format!("Queue edit failed (task_id={}, field=started_at)", needle)
173            })?;
174            task.started_at = normalized;
175        }
176        TaskEditKey::ScheduledStart => {
177            let normalized =
178                normalize_rfc3339_input("scheduled_start", trimmed).with_context(|| {
179                    format!(
180                        "Queue edit failed (task_id={}, field=scheduled_start)",
181                        needle
182                    )
183                })?;
184            task.scheduled_start = normalized;
185        }
186        TaskEditKey::EstimatedMinutes => {
187            let minutes = if trimmed.is_empty() {
188                None
189            } else {
190                Some(trimmed.parse::<u32>().with_context(|| {
191                    format!(
192                        "Queue edit failed (task_id={}, field=estimated_minutes): must be a non-negative integer",
193                        needle
194                    )
195                })?)
196            };
197            task.estimated_minutes = minutes;
198        }
199        TaskEditKey::ActualMinutes => {
200            let minutes = if trimmed.is_empty() {
201                None
202            } else {
203                Some(trimmed.parse::<u32>().with_context(|| {
204                    format!(
205                        "Queue edit failed (task_id={}, field=actual_minutes): must be a non-negative integer",
206                        needle
207                    )
208                })?)
209            };
210            task.actual_minutes = minutes;
211        }
212    }
213
214    if !matches!(key, TaskEditKey::UpdatedAt) {
215        let now = ensure_now(now_rfc3339)?;
216        task.updated_at = Some(now.to_string());
217    }
218
219    match queue::validate_queue_set(queue, done, id_prefix, id_width, max_dependency_depth) {
220        Ok(warnings) => {
221            queue::log_warnings(&warnings);
222        }
223        Err(err) => {
224            queue.tasks[index] = previous;
225            return Err(err);
226        }
227    }
228
229    Ok(())
230}