Skip to main content

ralph/queue/operations/edit/
preview.rs

1//! Task edit preview functionality.
2//!
3//! Responsibilities:
4//! - Preview task edit changes without applying them to the actual queue.
5//! - Generate a TaskEditPreview showing old/new values and any validation warnings.
6//!
7//! Does not handle:
8//! - Actually modifying the queue (see `apply.rs`).
9//!
10//! Assumptions/invariants:
11//! - Creates a clone of the queue to simulate edits without side effects.
12//! - Validation warnings are collected and returned in the preview.
13
14use super::key::TaskEditKey;
15use super::parsing::{
16    cycle_status, normalize_rfc3339_input, parse_list, parse_status, parse_task_agent_override,
17};
18use crate::contracts::{QueueFile, Task, TaskPriority, TaskStatus};
19use crate::queue;
20use crate::queue::ValidationWarning;
21use crate::queue::operations::validate::{ensure_task_id, parse_custom_fields_with_context};
22use anyhow::{Context, Result, anyhow, bail};
23
24/// Preview of what would change in a task edit operation.
25///
26/// Used by dry-run mode to show changes without applying them.
27#[derive(Debug, Clone)]
28pub struct TaskEditPreview {
29    pub task_id: String,
30    pub field: String,
31    pub old_value: String,
32    pub new_value: String,
33    pub warnings: Vec<ValidationWarning>,
34}
35
36/// Preview task edit changes without applying them.
37///
38/// Clones the queue, applies the edit to the clone, validates the result,
39/// and returns a preview describing what would change.
40///
41/// # Arguments
42/// * `queue` - The queue file containing the task to edit
43/// * `done` - Optional done file for validation
44/// * `task_id` - ID of the task to edit
45/// * `key` - Field to edit
46/// * `input` - New value for the field
47/// * `now_rfc3339` - Current timestamp for updated_at
48/// * `id_prefix` - Task ID prefix for validation
49/// * `id_width` - Task ID width for validation
50/// * `max_dependency_depth` - Maximum dependency depth for validation
51///
52/// # Returns
53/// A `TaskEditPreview` describing the changes that would be made.
54#[allow(clippy::too_many_arguments)]
55pub fn preview_task_edit(
56    queue: &QueueFile,
57    done: Option<&QueueFile>,
58    task_id: &str,
59    key: TaskEditKey,
60    input: &str,
61    now_rfc3339: &str,
62    id_prefix: &str,
63    id_width: usize,
64    max_dependency_depth: u8,
65) -> Result<TaskEditPreview> {
66    let operation = "preview edit";
67    let needle = ensure_task_id(task_id, operation)?;
68
69    // Find the task
70    let task = queue
71        .tasks
72        .iter()
73        .find(|t| t.id.trim() == needle)
74        .ok_or_else(|| {
75            anyhow!(
76                "{}",
77                crate::error_messages::task_not_found_for_edit("edit preview", needle)
78            )
79        })?;
80
81    // Clone the task to simulate the edit
82    let mut preview_task = task.clone();
83    let trimmed = input.trim();
84
85    // Apply the edit to the cloned task (similar to apply_task_edit but on clone)
86    match key {
87        TaskEditKey::Title => {
88            if trimmed.is_empty() {
89                bail!(
90                    "Queue edit preview failed (task_id={}, field=title): title cannot be empty. Provide a non-empty title (e.g., 'Fix login bug').",
91                    needle
92                );
93            }
94            preview_task.title = trimmed.to_string();
95        }
96        TaskEditKey::Description => {
97            preview_task.description = if trimmed.is_empty() {
98                None
99            } else {
100                Some(trimmed.to_string())
101            };
102        }
103        TaskEditKey::Status => {
104            let next_status = if trimmed.is_empty() {
105                cycle_status(preview_task.status)
106            } else {
107                parse_status(trimmed).with_context(|| {
108                    format!(
109                        "Queue edit preview failed (task_id={}, field=status)",
110                        needle
111                    )
112                })?
113            };
114            preview_task.status = next_status;
115            if next_status == TaskStatus::Done || next_status == TaskStatus::Rejected {
116                preview_task.completed_at = Some(now_rfc3339.to_string());
117            } else {
118                preview_task.completed_at = None;
119            }
120        }
121        TaskEditKey::Priority => {
122            preview_task.priority = if trimmed.is_empty() {
123                preview_task.priority.cycle()
124            } else {
125                trimmed.parse::<TaskPriority>().with_context(|| {
126                    format!(
127                        "Queue edit preview failed (task_id={}, field=priority)",
128                        needle
129                    )
130                })?
131            };
132        }
133        TaskEditKey::Tags => {
134            preview_task.tags = parse_list(trimmed);
135        }
136        TaskEditKey::Scope => {
137            preview_task.scope = parse_list(trimmed);
138        }
139        TaskEditKey::Evidence => {
140            preview_task.evidence = parse_list(trimmed);
141        }
142        TaskEditKey::Plan => {
143            preview_task.plan = parse_list(trimmed);
144        }
145        TaskEditKey::Notes => {
146            preview_task.notes = parse_list(trimmed);
147        }
148        TaskEditKey::Request => {
149            preview_task.request = if trimmed.is_empty() {
150                None
151            } else {
152                Some(trimmed.to_string())
153            };
154        }
155        TaskEditKey::DependsOn => {
156            preview_task.depends_on = parse_list(trimmed);
157        }
158        TaskEditKey::Blocks => {
159            preview_task.blocks = parse_list(trimmed);
160        }
161        TaskEditKey::RelatesTo => {
162            preview_task.relates_to = parse_list(trimmed);
163        }
164        TaskEditKey::Duplicates => {
165            preview_task.duplicates = if trimmed.is_empty() {
166                None
167            } else {
168                Some(trimmed.to_string())
169            };
170        }
171        TaskEditKey::CustomFields => {
172            preview_task.custom_fields =
173                parse_custom_fields_with_context(needle, trimmed, operation)?;
174        }
175        TaskEditKey::Agent => {
176            preview_task.agent = parse_task_agent_override(trimmed).with_context(|| {
177                format!(
178                    "Queue edit preview failed (task_id={}, field=agent)",
179                    needle
180                )
181            })?;
182        }
183        TaskEditKey::CreatedAt => {
184            let normalized = normalize_rfc3339_input("created_at", trimmed).with_context(|| {
185                format!(
186                    "Queue edit preview failed (task_id={}, field=created_at)",
187                    needle
188                )
189            })?;
190            preview_task.created_at = normalized;
191        }
192        TaskEditKey::UpdatedAt => {
193            let normalized = normalize_rfc3339_input("updated_at", trimmed).with_context(|| {
194                format!(
195                    "Queue edit preview failed (task_id={}, field=updated_at)",
196                    needle
197                )
198            })?;
199            preview_task.updated_at = normalized;
200        }
201        TaskEditKey::CompletedAt => {
202            let normalized =
203                normalize_rfc3339_input("completed_at", trimmed).with_context(|| {
204                    format!(
205                        "Queue edit preview failed (task_id={}, field=completed_at)",
206                        needle
207                    )
208                })?;
209            preview_task.completed_at = normalized;
210        }
211        TaskEditKey::StartedAt => {
212            let normalized = normalize_rfc3339_input("started_at", trimmed).with_context(|| {
213                format!(
214                    "Queue edit preview failed (task_id={}, field=started_at)",
215                    needle
216                )
217            })?;
218            preview_task.started_at = normalized;
219        }
220        TaskEditKey::ScheduledStart => {
221            let normalized =
222                normalize_rfc3339_input("scheduled_start", trimmed).with_context(|| {
223                    format!(
224                        "Queue edit preview failed (task_id={}, field=scheduled_start)",
225                        needle
226                    )
227                })?;
228            preview_task.scheduled_start = normalized;
229        }
230        TaskEditKey::EstimatedMinutes => {
231            let minutes = if trimmed.is_empty() {
232                None
233            } else {
234                Some(trimmed.parse::<u32>().with_context(|| {
235                    format!(
236                        "Queue edit preview failed (task_id={}, field=estimated_minutes): must be a non-negative integer",
237                        needle
238                    )
239                })?)
240            };
241            preview_task.estimated_minutes = minutes;
242        }
243        TaskEditKey::ActualMinutes => {
244            let minutes = if trimmed.is_empty() {
245                None
246            } else {
247                Some(trimmed.parse::<u32>().with_context(|| {
248                    format!(
249                        "Queue edit preview failed (task_id={}, field=actual_minutes): must be a non-negative integer",
250                        needle
251                    )
252                })?)
253            };
254            preview_task.actual_minutes = minutes;
255        }
256    }
257
258    // Update timestamp unless we're setting updated_at explicitly
259    if !matches!(key, TaskEditKey::UpdatedAt) {
260        preview_task.updated_at = Some(now_rfc3339.to_string());
261    }
262
263    // Validate the modified task by creating a temporary queue
264    let mut preview_queue = queue.clone();
265    if let Some(index) = preview_queue
266        .tasks
267        .iter()
268        .position(|t| t.id.trim() == needle)
269    {
270        preview_queue.tasks[index] = preview_task.clone();
271    }
272
273    let warnings = match queue::validate_queue_set(
274        &preview_queue,
275        done,
276        id_prefix,
277        id_width,
278        max_dependency_depth,
279    ) {
280        Ok(warnings) => warnings,
281        Err(err) => {
282            bail!(
283                "Queue edit preview failed (task_id={}): validation error - {}",
284                needle,
285                err
286            );
287        }
288    };
289
290    // Format old and new values for display
291    let old_value = format_field_value(task, key);
292    let new_value = format_field_value(&preview_task, key);
293
294    Ok(TaskEditPreview {
295        task_id: needle.to_string(),
296        field: key.as_str().to_string(),
297        old_value,
298        new_value,
299        warnings,
300    })
301}
302
303/// Format a field value for display in previews.
304///
305/// Uses semicolon separator for Evidence, Plan, Notes (longer text items)
306/// and comma separator for other list fields.
307pub(crate) fn format_field_value(task: &Task, key: TaskEditKey) -> String {
308    let sep = match key {
309        TaskEditKey::Evidence | TaskEditKey::Plan | TaskEditKey::Notes => "; ",
310        _ => ", ",
311    };
312    key.format_value(task, sep)
313}