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::Status => {
97            let next_status = if trimmed.is_empty() {
98                cycle_status(preview_task.status)
99            } else {
100                parse_status(trimmed).with_context(|| {
101                    format!(
102                        "Queue edit preview failed (task_id={}, field=status)",
103                        needle
104                    )
105                })?
106            };
107            preview_task.status = next_status;
108            if next_status == TaskStatus::Done || next_status == TaskStatus::Rejected {
109                preview_task.completed_at = Some(now_rfc3339.to_string());
110            } else {
111                preview_task.completed_at = None;
112            }
113        }
114        TaskEditKey::Priority => {
115            preview_task.priority = if trimmed.is_empty() {
116                preview_task.priority.cycle()
117            } else {
118                trimmed.parse::<TaskPriority>().with_context(|| {
119                    format!(
120                        "Queue edit preview failed (task_id={}, field=priority)",
121                        needle
122                    )
123                })?
124            };
125        }
126        TaskEditKey::Tags => {
127            preview_task.tags = parse_list(trimmed);
128        }
129        TaskEditKey::Scope => {
130            preview_task.scope = parse_list(trimmed);
131        }
132        TaskEditKey::Evidence => {
133            preview_task.evidence = parse_list(trimmed);
134        }
135        TaskEditKey::Plan => {
136            preview_task.plan = parse_list(trimmed);
137        }
138        TaskEditKey::Notes => {
139            preview_task.notes = parse_list(trimmed);
140        }
141        TaskEditKey::Request => {
142            preview_task.request = if trimmed.is_empty() {
143                None
144            } else {
145                Some(trimmed.to_string())
146            };
147        }
148        TaskEditKey::DependsOn => {
149            preview_task.depends_on = parse_list(trimmed);
150        }
151        TaskEditKey::Blocks => {
152            preview_task.blocks = parse_list(trimmed);
153        }
154        TaskEditKey::RelatesTo => {
155            preview_task.relates_to = parse_list(trimmed);
156        }
157        TaskEditKey::Duplicates => {
158            preview_task.duplicates = if trimmed.is_empty() {
159                None
160            } else {
161                Some(trimmed.to_string())
162            };
163        }
164        TaskEditKey::CustomFields => {
165            preview_task.custom_fields =
166                parse_custom_fields_with_context(needle, trimmed, operation)?;
167        }
168        TaskEditKey::Agent => {
169            preview_task.agent = parse_task_agent_override(trimmed).with_context(|| {
170                format!(
171                    "Queue edit preview failed (task_id={}, field=agent)",
172                    needle
173                )
174            })?;
175        }
176        TaskEditKey::CreatedAt => {
177            let normalized = normalize_rfc3339_input("created_at", trimmed).with_context(|| {
178                format!(
179                    "Queue edit preview failed (task_id={}, field=created_at)",
180                    needle
181                )
182            })?;
183            preview_task.created_at = normalized;
184        }
185        TaskEditKey::UpdatedAt => {
186            let normalized = normalize_rfc3339_input("updated_at", trimmed).with_context(|| {
187                format!(
188                    "Queue edit preview failed (task_id={}, field=updated_at)",
189                    needle
190                )
191            })?;
192            preview_task.updated_at = normalized;
193        }
194        TaskEditKey::CompletedAt => {
195            let normalized =
196                normalize_rfc3339_input("completed_at", trimmed).with_context(|| {
197                    format!(
198                        "Queue edit preview failed (task_id={}, field=completed_at)",
199                        needle
200                    )
201                })?;
202            preview_task.completed_at = normalized;
203        }
204        TaskEditKey::StartedAt => {
205            let normalized = normalize_rfc3339_input("started_at", trimmed).with_context(|| {
206                format!(
207                    "Queue edit preview failed (task_id={}, field=started_at)",
208                    needle
209                )
210            })?;
211            preview_task.started_at = normalized;
212        }
213        TaskEditKey::ScheduledStart => {
214            let normalized =
215                normalize_rfc3339_input("scheduled_start", trimmed).with_context(|| {
216                    format!(
217                        "Queue edit preview failed (task_id={}, field=scheduled_start)",
218                        needle
219                    )
220                })?;
221            preview_task.scheduled_start = normalized;
222        }
223        TaskEditKey::EstimatedMinutes => {
224            let minutes = if trimmed.is_empty() {
225                None
226            } else {
227                Some(trimmed.parse::<u32>().with_context(|| {
228                    format!(
229                        "Queue edit preview failed (task_id={}, field=estimated_minutes): must be a non-negative integer",
230                        needle
231                    )
232                })?)
233            };
234            preview_task.estimated_minutes = minutes;
235        }
236        TaskEditKey::ActualMinutes => {
237            let minutes = if trimmed.is_empty() {
238                None
239            } else {
240                Some(trimmed.parse::<u32>().with_context(|| {
241                    format!(
242                        "Queue edit preview failed (task_id={}, field=actual_minutes): must be a non-negative integer",
243                        needle
244                    )
245                })?)
246            };
247            preview_task.actual_minutes = minutes;
248        }
249    }
250
251    // Update timestamp unless we're setting updated_at explicitly
252    if !matches!(key, TaskEditKey::UpdatedAt) {
253        preview_task.updated_at = Some(now_rfc3339.to_string());
254    }
255
256    // Validate the modified task by creating a temporary queue
257    let mut preview_queue = queue.clone();
258    if let Some(index) = preview_queue
259        .tasks
260        .iter()
261        .position(|t| t.id.trim() == needle)
262    {
263        preview_queue.tasks[index] = preview_task.clone();
264    }
265
266    let warnings = match queue::validate_queue_set(
267        &preview_queue,
268        done,
269        id_prefix,
270        id_width,
271        max_dependency_depth,
272    ) {
273        Ok(warnings) => warnings,
274        Err(err) => {
275            bail!(
276                "Queue edit preview failed (task_id={}): validation error - {}",
277                needle,
278                err
279            );
280        }
281    };
282
283    // Format old and new values for display
284    let old_value = format_field_value(task, key);
285    let new_value = format_field_value(&preview_task, key);
286
287    Ok(TaskEditPreview {
288        task_id: needle.to_string(),
289        field: key.as_str().to_string(),
290        old_value,
291        new_value,
292        warnings,
293    })
294}
295
296/// Format a field value for display in previews.
297///
298/// Uses semicolon separator for Evidence, Plan, Notes (longer text items)
299/// and comma separator for other list fields.
300pub(crate) fn format_field_value(task: &Task, key: TaskEditKey) -> String {
301    let sep = match key {
302        TaskEditKey::Evidence | TaskEditKey::Plan | TaskEditKey::Notes => "; ",
303        _ => ", ",
304    };
305    key.format_value(task, sep)
306}