Skip to main content

aven_core/
undo.rs

1use crate::ids::{ProjectId, WorkspaceId};
2use crate::operations::{RecurrenceStructuralMutation, RecurrenceTaskMutation};
3use std::collections::{BTreeMap, BTreeSet};
4
5use anyhow::{Result, bail, ensure};
6use sqlx::{Row, SqliteConnection};
7
8use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
9use crate::db::{
10    Database, begin_immediate, conflict_exists, field_version, insert_change, set_field_version,
11    task_from_row,
12};
13use crate::error::CoreError;
14use crate::ids::{new_id, now};
15use crate::mutation::{apply_field_value_in_workspace, apply_project_id_in_workspace};
16use crate::operations::{
17    ProjectMetadata, insert_project_metadata_change, set_project_metadata,
18    update_task_labels_in_workspace,
19};
20use crate::projects::resolve_project_for_stored_value;
21use crate::task_fields::TaskField;
22use crate::workspaces::workspace_key_for_id;
23
24tokio::task_local! {
25    static APPLYING_UNDO: ();
26}
27
28pub fn is_applying_undo() -> bool {
29    APPLYING_UNDO.try_with(|_| ()).is_ok()
30}
31
32#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
33pub struct UndoPayload {
34    pub commands: Vec<UndoCommand>,
35}
36
37#[derive(Debug, Clone, PartialEq, Eq)]
38pub struct PendingUndoPresentation {
39    pub id: String,
40    pub operation: String,
41    pub task_ids: Vec<crate::ids::TaskId>,
42}
43
44impl PendingUndoPresentation {
45    pub fn operation_phrase(&self) -> &str {
46        &self.operation
47    }
48}
49
50#[derive(Debug, Clone, Default)]
51pub enum UndoContext {
52    #[default]
53    None,
54    Tui {
55        summary: String,
56    },
57    TuiTaskMutation {
58        single_summary: Option<String>,
59        batch_action: String,
60    },
61}
62
63impl UndoContext {
64    pub fn tui(summary: impl Into<String>) -> Self {
65        Self::Tui {
66            summary: summary.into(),
67        }
68    }
69
70    pub fn tui_task_mutation(
71        single_summary: Option<String>,
72        batch_action: impl Into<String>,
73    ) -> Self {
74        Self::TuiTaskMutation {
75            single_summary,
76            batch_action: batch_action.into(),
77        }
78    }
79
80    pub(crate) fn task_mutation_summary(self, changed_count: usize) -> Option<String> {
81        match self {
82            Self::None => None,
83            Self::Tui { summary } => Some(summary),
84            Self::TuiTaskMutation {
85                single_summary,
86                batch_action,
87            } => single_summary.filter(|_| changed_count == 1).or_else(|| {
88                let noun = if changed_count == 1 { "task" } else { "tasks" };
89                Some(format!("{batch_action} {changed_count} {noun}"))
90            }),
91        }
92    }
93}
94
95#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
96#[serde(tag = "op", rename_all = "snake_case")]
97pub enum UndoCommand {
98    SetTaskField {
99        task_id: crate::ids::TaskId,
100        field: String,
101        before: String,
102        after: String,
103        #[serde(default)]
104        queue_activity_before: Option<String>,
105        #[serde(default)]
106        queue_activity_after: Option<String>,
107    },
108    SetTaskLabels {
109        task_id: crate::ids::TaskId,
110        before: Vec<String>,
111        after: Vec<String>,
112    },
113    SetTaskMetadata {
114        task_id: crate::ids::TaskId,
115        field_id: crate::ids::MetadataFieldId,
116        before: Option<String>,
117        after: Option<String>,
118    },
119    DeleteCreatedTask {
120        task_id: crate::ids::TaskId,
121        create_change_id: Option<String>,
122        expected: TaskUndoSnapshot,
123        #[serde(default)]
124        attachment_ids: Vec<String>,
125        #[serde(default)]
126        attachment_change_ids: Vec<String>,
127    },
128    SetNoteBody {
129        task_id: crate::ids::TaskId,
130        note_id: String,
131        before: String,
132        after: String,
133    },
134    RestoreDeletedNote {
135        task_id: crate::ids::TaskId,
136        note_id: String,
137        body: String,
138        created_at: String,
139    },
140    DeleteCreatedNote {
141        task_id: crate::ids::TaskId,
142        note_id: String,
143        note_add_change_id: String,
144    },
145    DeleteCreatedProject {
146        project_key: String,
147        create_change_id: String,
148        expected_name: String,
149        expected_prefix: String,
150    },
151    SetProjectMetadata {
152        project_id: ProjectId,
153        before_key: String,
154        before_name: String,
155        before_prefix: String,
156        after_key: String,
157        after_name: String,
158        after_prefix: String,
159    },
160    DeleteCreatedLabel {
161        label: String,
162        create_change_id: String,
163    },
164    SetLabelName {
165        before: String,
166        after: String,
167    },
168    RestoreDeletedLabel {
169        name: String,
170        created_at: String,
171        task_ids: Vec<crate::ids::TaskId>,
172        series_ids: Vec<crate::recurrence::RecurrenceSeriesId>,
173    },
174    RestoreConflictResolution {
175        task_id: crate::ids::TaskId,
176        field: String,
177        before: String,
178        after: String,
179        conflict_id: i64,
180    },
181    AddTaskDependency {
182        task_id: crate::ids::TaskId,
183        depends_on_task_id: crate::ids::TaskId,
184    },
185    RemoveTaskDependency {
186        task_id: crate::ids::TaskId,
187        depends_on_task_id: crate::ids::TaskId,
188    },
189    SetTaskRelatedLink {
190        task_id: crate::ids::TaskId,
191        related_task_id: crate::ids::TaskId,
192        forward_change_id: String,
193        linked: bool,
194    },
195    AddEpicChild {
196        epic_id: crate::ids::TaskId,
197        child_id: crate::ids::TaskId,
198    },
199    RemoveEpicChild {
200        epic_id: crate::ids::TaskId,
201        child_id: crate::ids::TaskId,
202    },
203}
204
205#[derive(Debug, Clone, PartialEq, serde::Serialize, serde::Deserialize)]
206pub struct TaskUndoSnapshot {
207    pub title: String,
208    pub description: String,
209    pub project_id: ProjectId,
210    pub project_key: String,
211    pub status: String,
212    pub priority: String,
213    #[serde(default)]
214    pub queue_activity_at: String,
215    pub available_at: String,
216    #[serde(default)]
217    pub due_on: String,
218    pub deleted: bool,
219    #[serde(default)]
220    pub is_epic: bool,
221    pub labels: Vec<String>,
222    #[serde(default)]
223    pub metadata: BTreeMap<String, String>,
224}
225
226pub struct UndoOutcome {
227    pub presentation: PendingUndoPresentation,
228    pub task_id: Option<crate::ids::TaskId>,
229    pub include_deleted: Option<bool>,
230    pub project_rename: Option<ProjectRenameUndoOutcome>,
231    pub label_rename: Option<LabelRenameUndoOutcome>,
232}
233
234pub struct LabelRenameUndoOutcome {
235    pub before: String,
236    pub after: String,
237}
238
239pub struct ProjectRenameUndoOutcome {
240    pub before_key: String,
241    pub after_key: String,
242}
243
244impl Database {
245    pub async fn task_field_value(
246        &self,
247        workspace_id: &WorkspaceId,
248        task_id: &crate::ids::TaskId,
249        field: &str,
250    ) -> Result<String> {
251        let mut conn = self.acquire_reader().await?;
252        task_field_value(&mut conn, workspace_id, task_id, field).await
253    }
254
255    pub async fn task_labels(
256        &self,
257        workspace_id: &WorkspaceId,
258        task_id: &crate::ids::TaskId,
259    ) -> Result<Vec<String>> {
260        let mut conn = self.acquire_reader().await?;
261        task_labels(&mut conn, workspace_id, task_id).await
262    }
263
264    pub async fn task_undo_snapshot(
265        &self,
266        workspace_id: &WorkspaceId,
267        task_id: &crate::ids::TaskId,
268    ) -> Result<TaskUndoSnapshot> {
269        let mut conn = self.acquire_reader().await?;
270        task_snapshot(&mut conn, workspace_id, task_id).await
271    }
272
273    pub async fn conflict_row_id(
274        &self,
275        workspace_id: &WorkspaceId,
276        task_id: &crate::ids::TaskId,
277        field: &str,
278    ) -> Result<i64> {
279        let mut conn = self.acquire_reader().await?;
280        conflict_row_id(&mut conn, workspace_id, task_id, field).await
281    }
282
283    pub async fn clear_pending_tui_undo_entries(&self) -> Result<()> {
284        let mut conn = self.acquire_writer().await?;
285        clear_pending_tui_undo_entries(&mut conn).await
286    }
287
288    pub async fn latest_tui_undo_presentation(
289        &self,
290        workspace_id: &WorkspaceId,
291    ) -> Result<Option<PendingUndoPresentation>> {
292        let mut conn = self.acquire_reader().await?;
293        latest_tui_undo_presentation(&mut conn, workspace_id).await
294    }
295
296    pub async fn apply_latest_tui_undo(
297        &self,
298        workspace_id: &WorkspaceId,
299    ) -> Result<Option<UndoOutcome>> {
300        let mut conn = self.acquire_writer().await?;
301        apply_latest_tui_undo(&mut conn, workspace_id).await
302    }
303}
304
305pub(crate) async fn task_field_value(
306    conn: &mut SqliteConnection,
307    workspace_id: &WorkspaceId,
308    task_id: &crate::ids::TaskId,
309    field: &str,
310) -> Result<String> {
311    let task_field = TaskField::parse_or_unknown(field)?;
312    task_field_value_for_field(conn, workspace_id, task_id, task_field).await
313}
314
315async fn task_field_value_for_field(
316    conn: &mut SqliteConnection,
317    workspace_id: &WorkspaceId,
318    task_id: &crate::ids::TaskId,
319    task_field: TaskField,
320) -> Result<String> {
321    let row = sqlx::query(
322        "SELECT t.id, t.workspace_id, t.title, t.description, t.project_id, p.key AS project_key, p.prefix AS project_prefix, t.status, t.priority, t.source, t.created_at, t.updated_at, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic
323         FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
324         WHERE t.workspace_id = ? AND t.id = ?",
325    )
326    .bind(workspace_id)
327    .bind(task_id)
328    .fetch_optional(&mut *conn)
329    .await?
330    .ok_or_else(|| anyhow::anyhow!("error task-not-found task_id={task_id}"))?;
331    let task = task_from_row(&row)?;
332    Ok(task_field.current_value(&task))
333}
334
335pub(crate) async fn task_labels(
336    conn: &mut SqliteConnection,
337    workspace_id: &WorkspaceId,
338    task_id: &crate::ids::TaskId,
339) -> Result<Vec<String>> {
340    let rows = sqlx::query(
341        "SELECT label FROM task_labels WHERE workspace_id = ? AND task_id = ? ORDER BY label",
342    )
343    .bind(workspace_id)
344    .bind(task_id)
345    .fetch_all(&mut *conn)
346    .await?;
347    Ok(rows.into_iter().map(|row| row.get("label")).collect())
348}
349
350pub(crate) async fn task_snapshot(
351    conn: &mut SqliteConnection,
352    workspace_id: &WorkspaceId,
353    task_id: &crate::ids::TaskId,
354) -> Result<TaskUndoSnapshot> {
355    let row = sqlx::query(
356        "SELECT t.title, t.description, t.project_id, p.key AS project_key, t.status, t.priority, t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic
357         FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id
358         WHERE t.workspace_id = ? AND t.id = ?",
359    )
360    .bind(workspace_id)
361    .bind(task_id)
362    .fetch_optional(&mut *conn)
363    .await?
364    .ok_or_else(|| anyhow::anyhow!("error task-not-found task_id={task_id}"))?;
365    let labels = task_labels(conn, workspace_id, task_id).await?;
366    let metadata = sqlx::query_as::<_, (String, String)>(
367        "SELECT field_id, value FROM task_metadata
368         WHERE workspace_id = ? AND task_id = ? ORDER BY field_id",
369    )
370    .bind(workspace_id)
371    .bind(task_id)
372    .fetch_all(&mut *conn)
373    .await?
374    .into_iter()
375    .collect();
376    Ok(TaskUndoSnapshot {
377        title: row.get("title"),
378        description: row.get("description"),
379        project_id: row.get("project_id"),
380        project_key: row.get("project_key"),
381        status: row.get("status"),
382        priority: row.get("priority"),
383        queue_activity_at: row.get("queue_activity_at"),
384        available_at: row.get("available_at"),
385        due_on: row.get("due_on"),
386        deleted: row.get::<i64, _>("deleted") != 0,
387        is_epic: row.get::<i64, _>("is_epic") != 0,
388        labels,
389        metadata,
390    })
391}
392
393pub(crate) async fn conflict_row_id(
394    conn: &mut SqliteConnection,
395    workspace_id: &WorkspaceId,
396    task_id: &crate::ids::TaskId,
397    field: &str,
398) -> Result<i64> {
399    sqlx::query_scalar(
400        "SELECT id FROM conflicts
401         WHERE workspace_id = ? AND task_id = ? AND field = ? AND resolved = 0
402         ORDER BY id LIMIT 1",
403    )
404    .bind(workspace_id)
405    .bind(task_id)
406    .bind(field)
407    .fetch_optional(&mut *conn)
408    .await?
409    .ok_or_else(|| {
410        CoreError::not_found(format!(
411            "error conflict-not-found task_id={task_id} field={field}"
412        ))
413    })
414    .map_err(Into::into)
415}
416
417pub(crate) async fn record_tui_undo(
418    conn: &mut SqliteConnection,
419    workspace_id: &WorkspaceId,
420    summary: &str,
421    payload: UndoPayload,
422) -> Result<()> {
423    if is_applying_undo() || !undo_payload_has_effect(&payload) {
424        return Ok(());
425    }
426    let id = new_id();
427    let created_at = now();
428    let seq: i64 = sqlx::query_scalar(
429        "SELECT COALESCE(MAX(seq), 0) + 1 FROM tui_undo_entries WHERE workspace_id = ?",
430    )
431    .bind(workspace_id)
432    .fetch_one(&mut *conn)
433    .await?;
434    let payload = serde_json::to_string(&payload)?;
435    sqlx::query(
436        "INSERT INTO tui_undo_entries(id, workspace_id, summary, payload_version, payload, seq, created_at)
437         VALUES (?, ?, ?, 1, ?, ?, ?)",
438    )
439    .bind(&id)
440    .bind(workspace_id)
441    .bind(summary)
442    .bind(&payload)
443    .bind(seq)
444    .bind(&created_at)
445    .execute(&mut *conn)
446    .await?;
447    prune_consumed_undo_entries(conn, workspace_id).await?;
448    Ok(())
449}
450
451fn undo_payload_has_effect(payload: &UndoPayload) -> bool {
452    payload.commands.iter().any(|command| match command {
453        UndoCommand::SetTaskField { before, after, .. } => before != after,
454        UndoCommand::SetTaskLabels { before, after, .. } => !label_sets_equal(before, after),
455        UndoCommand::SetTaskMetadata { before, after, .. } => before != after,
456        UndoCommand::SetProjectMetadata {
457            before_key,
458            before_name,
459            before_prefix,
460            after_key,
461            after_name,
462            after_prefix,
463            ..
464        } => before_key != after_key || before_name != after_name || before_prefix != after_prefix,
465        UndoCommand::SetNoteBody { before, after, .. } => before != after,
466        UndoCommand::SetLabelName { before, after } => before != after,
467        UndoCommand::DeleteCreatedTask { .. }
468        | UndoCommand::RestoreDeletedNote { .. }
469        | UndoCommand::DeleteCreatedNote { .. }
470        | UndoCommand::DeleteCreatedProject { .. }
471        | UndoCommand::DeleteCreatedLabel { .. }
472        | UndoCommand::RestoreDeletedLabel { .. }
473        | UndoCommand::RestoreConflictResolution { .. }
474        | UndoCommand::AddTaskDependency { .. }
475        | UndoCommand::RemoveTaskDependency { .. }
476        | UndoCommand::SetTaskRelatedLink { .. }
477        | UndoCommand::AddEpicChild { .. }
478        | UndoCommand::RemoveEpicChild { .. } => true,
479    })
480}
481
482fn classify_undo_commands(id: String, commands: &[UndoCommand]) -> PendingUndoPresentation {
483    let mut task_ids = BTreeSet::new();
484    for command in commands {
485        match command {
486            UndoCommand::SetTaskField { task_id, .. }
487            | UndoCommand::SetTaskLabels { task_id, .. }
488            | UndoCommand::SetTaskMetadata { task_id, .. }
489            | UndoCommand::DeleteCreatedTask { task_id, .. }
490            | UndoCommand::SetNoteBody { task_id, .. }
491            | UndoCommand::RestoreDeletedNote { task_id, .. }
492            | UndoCommand::DeleteCreatedNote { task_id, .. }
493            | UndoCommand::RestoreConflictResolution { task_id, .. }
494            | UndoCommand::AddTaskDependency { task_id, .. }
495            | UndoCommand::RemoveTaskDependency { task_id, .. }
496            | UndoCommand::SetTaskRelatedLink { task_id, .. } => {
497                task_ids.insert(task_id.clone());
498            }
499            UndoCommand::AddEpicChild { child_id, .. }
500            | UndoCommand::RemoveEpicChild { child_id, .. } => {
501                task_ids.insert(child_id.clone());
502            }
503            UndoCommand::DeleteCreatedProject { .. }
504            | UndoCommand::SetProjectMetadata { .. }
505            | UndoCommand::DeleteCreatedLabel { .. }
506            | UndoCommand::SetLabelName { .. }
507            | UndoCommand::RestoreDeletedLabel { .. } => {}
508        }
509    }
510
511    let operation = if commands
512        .iter()
513        .any(|command| matches!(command, UndoCommand::DeleteCreatedTask { .. }))
514    {
515        "task creation"
516    } else if commands
517        .iter()
518        .any(|command| matches!(command, UndoCommand::SetTaskLabels { .. }))
519    {
520        "label change"
521    } else if let Some(command) = commands.iter().find(|command| {
522        matches!(
523            command,
524            UndoCommand::AddEpicChild { .. } | UndoCommand::RemoveEpicChild { .. }
525        )
526    }) {
527        match command {
528            UndoCommand::AddEpicChild { .. } => "epic membership addition",
529            UndoCommand::RemoveEpicChild { .. } => "epic membership removal",
530            _ => unreachable!(),
531        }
532    } else {
533        commands
534            .iter()
535            .find_map(undo_command_operation)
536            .unwrap_or("last TUI mutation")
537    };
538
539    PendingUndoPresentation {
540        id,
541        operation: operation.to_string(),
542        task_ids: task_ids.into_iter().collect(),
543    }
544}
545
546fn undo_command_operation(command: &UndoCommand) -> Option<&'static str> {
547    Some(match command {
548        UndoCommand::SetTaskField { field, after, .. } => match field.as_str() {
549            "title" => "title change",
550            "description" => "description change",
551            "project" => "project change",
552            "status" => "status change",
553            "priority" => "priority change",
554            "available_at" => "availability change",
555            "due_on" => "due date change",
556            "deleted" if after == "1" => "task deletion",
557            "deleted" => "task restoration",
558            "is_epic" => "epic status change",
559            _ => "task change",
560        },
561        UndoCommand::SetTaskLabels { .. } => "label change",
562        UndoCommand::SetTaskMetadata { .. } => "metadata change",
563        UndoCommand::DeleteCreatedTask { .. } => "task creation",
564        UndoCommand::SetNoteBody { .. } => "note edit",
565        UndoCommand::RestoreDeletedNote { .. } => "note deletion",
566        UndoCommand::DeleteCreatedNote { .. } => "note creation",
567        UndoCommand::DeleteCreatedProject { .. } => "project creation",
568        UndoCommand::SetProjectMetadata { .. } => "project change",
569        UndoCommand::DeleteCreatedLabel { .. } => "label creation",
570        UndoCommand::SetLabelName { .. } => "label rename",
571        UndoCommand::RestoreDeletedLabel { .. } => "label deletion",
572        UndoCommand::RestoreConflictResolution { .. } => "conflict resolution",
573        UndoCommand::AddTaskDependency { .. } => "dependency addition",
574        UndoCommand::RemoveTaskDependency { .. } => "dependency removal",
575        UndoCommand::SetTaskRelatedLink { linked, .. } => {
576            if *linked {
577                "related link addition"
578            } else {
579                "related link removal"
580            }
581        }
582        UndoCommand::AddEpicChild { .. } => "epic membership addition",
583        UndoCommand::RemoveEpicChild { .. } => "epic membership removal",
584    })
585}
586
587fn empty_command_outcome() -> CommandOutcome {
588    CommandOutcome {
589        task_id: None,
590        include_deleted: None,
591        project_rename: None,
592        label_rename: None,
593    }
594}
595
596async fn prune_consumed_undo_entries(
597    conn: &mut SqliteConnection,
598    workspace_id: &WorkspaceId,
599) -> Result<()> {
600    sqlx::query(
601        "DELETE FROM tui_undo_entries
602         WHERE workspace_id = ? AND undone_at IS NOT NULL AND id NOT IN (
603             SELECT id FROM tui_undo_entries
604             WHERE workspace_id = ? AND undone_at IS NOT NULL
605             ORDER BY undone_at DESC, seq DESC
606             LIMIT 20
607         )",
608    )
609    .bind(workspace_id)
610    .bind(workspace_id)
611    .execute(&mut *conn)
612    .await?;
613    Ok(())
614}
615
616pub(crate) async fn clear_pending_tui_undo_entries(conn: &mut SqliteConnection) -> Result<()> {
617    sqlx::query("DELETE FROM tui_undo_entries WHERE undone_at IS NULL")
618        .execute(&mut *conn)
619        .await?;
620    Ok(())
621}
622
623pub(crate) async fn latest_tui_undo_presentation(
624    conn: &mut SqliteConnection,
625    workspace_id: &WorkspaceId,
626) -> Result<Option<PendingUndoPresentation>> {
627    let row = sqlx::query(
628        "SELECT id, payload FROM tui_undo_entries
629         WHERE workspace_id = ? AND undone_at IS NULL
630         ORDER BY seq DESC
631         LIMIT 1",
632    )
633    .bind(workspace_id)
634    .fetch_optional(&mut *conn)
635    .await?;
636    let Some(row) = row else {
637        return Ok(None);
638    };
639    let id: String = row.get("id");
640    let payload: String = row.get("payload");
641    let payload: UndoPayload = serde_json::from_str(&payload)?;
642    Ok(Some(classify_undo_commands(id, &payload.commands)))
643}
644
645pub(crate) async fn apply_latest_tui_undo(
646    conn: &mut SqliteConnection,
647    workspace_id: &WorkspaceId,
648) -> Result<Option<UndoOutcome>> {
649    let mut tx = begin_immediate(conn).await?;
650    let row = sqlx::query(
651        "SELECT id, payload FROM tui_undo_entries
652         WHERE workspace_id = ? AND undone_at IS NULL
653         ORDER BY seq DESC
654         LIMIT 1",
655    )
656    .bind(workspace_id)
657    .fetch_optional(&mut *tx)
658    .await?;
659    let Some(row) = row else {
660        return Ok(None);
661    };
662    let entry_id: String = row.get("id");
663    let payload_text: String = row.get("payload");
664    let undone_at = now();
665    let claimed =
666        sqlx::query("UPDATE tui_undo_entries SET undone_at = ? WHERE id = ? AND undone_at IS NULL")
667            .bind(&undone_at)
668            .bind(&entry_id)
669            .execute(&mut *tx)
670            .await?;
671    ensure!(
672        claimed.rows_affected() == 1,
673        "error undo-entry-claim-failed id={entry_id}"
674    );
675    let payload: UndoPayload = serde_json::from_str(&payload_text)?;
676    let presentation = classify_undo_commands(entry_id.clone(), &payload.commands);
677    let apply_result = APPLYING_UNDO
678        .scope(
679            (),
680            apply_undo_commands(&mut tx, workspace_id, &payload.commands, &undone_at),
681        )
682        .await;
683    match apply_result {
684        Ok(outcome) => {
685            tx.commit().await?;
686            Ok(Some(UndoOutcome {
687                presentation,
688                task_id: outcome.task_id,
689                include_deleted: outcome.include_deleted,
690                project_rename: outcome.project_rename,
691                label_rename: outcome.label_rename,
692            }))
693        }
694        Err(error) => {
695            tx.rollback().await?;
696            Err(error)
697        }
698    }
699}
700
701struct CommandOutcome {
702    task_id: Option<crate::ids::TaskId>,
703    include_deleted: Option<bool>,
704    project_rename: Option<ProjectRenameUndoOutcome>,
705    label_rename: Option<LabelRenameUndoOutcome>,
706}
707
708async fn apply_undo_commands(
709    conn: &mut SqliteConnection,
710    workspace_id: &WorkspaceId,
711    commands: &[UndoCommand],
712    mutation_at: &str,
713) -> Result<CommandOutcome> {
714    let mut task_id = None;
715    let mut include_deleted = None;
716    let mut project_rename = None;
717    let mut label_rename = None;
718    let mut affected_attachment_hashes = BTreeSet::new();
719    for command in commands {
720        let outcome = apply_undo_command(
721            conn,
722            workspace_id,
723            command,
724            &mut affected_attachment_hashes,
725            mutation_at,
726        )
727        .await?;
728        if outcome.task_id.is_some() {
729            task_id = outcome.task_id;
730        }
731        if outcome.include_deleted.is_some() {
732            include_deleted = outcome.include_deleted;
733        }
734        if outcome.project_rename.is_some() {
735            project_rename = outcome.project_rename;
736        }
737        if outcome.label_rename.is_some() {
738            label_rename = outcome.label_rename;
739        }
740    }
741    let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
742    crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
743        conn,
744        &affected_attachment_hashes,
745        &crate::attachments::lifecycle::SystemClock,
746    )
747    .await?;
748    Ok(CommandOutcome {
749        task_id,
750        include_deleted,
751        project_rename,
752        label_rename,
753    })
754}
755
756async fn restore_queue_activity(
757    conn: &mut SqliteConnection,
758    workspace_id: &WorkspaceId,
759    task_id: &crate::ids::TaskId,
760    queue_activity_at: &str,
761) -> Result<()> {
762    sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
763        .bind(queue_activity_at)
764        .bind(workspace_id)
765        .bind(task_id)
766        .execute(&mut *conn)
767        .await?;
768    Ok(())
769}
770
771async fn apply_undo_command(
772    conn: &mut SqliteConnection,
773    workspace_id: &WorkspaceId,
774    command: &UndoCommand,
775    affected_attachment_hashes: &mut BTreeSet<String>,
776    mutation_at: &str,
777) -> Result<CommandOutcome> {
778    match command {
779        UndoCommand::SetTaskField {
780            task_id,
781            field,
782            before,
783            after,
784            queue_activity_before,
785            queue_activity_after,
786        } => {
787            let task_field = TaskField::parse_or_unknown(field)?;
788            let current =
789                task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
790            if current != *after {
791                bail!("error undo-state-changed task_id={task_id} field={field}");
792            }
793            let queue_activity_to_restore = if let (Some(before), Some(after)) =
794                (queue_activity_before, queue_activity_after)
795            {
796                let current: String = sqlx::query_scalar(
797                    "SELECT queue_activity_at FROM tasks WHERE workspace_id = ? AND id = ?",
798                )
799                .bind(workspace_id)
800                .bind(task_id)
801                .fetch_one(&mut *conn)
802                .await?;
803                Some(if current == *after {
804                    before.clone()
805                } else {
806                    current
807                })
808            } else {
809                None
810            };
811            if before != after {
812                let recurrence_undone = if task_field == TaskField::Status {
813                    crate::operations::undo_recurrence_resolution(
814                        conn,
815                        workspace_id,
816                        task_id,
817                        before,
818                        after,
819                    )
820                    .await?
821                } else {
822                    false
823                };
824                if recurrence_undone {
825                    if let Some(queue_activity_at) = queue_activity_to_restore {
826                        restore_queue_activity(conn, workspace_id, task_id, &queue_activity_at)
827                            .await?;
828                    }
829                    return Ok(CommandOutcome {
830                        task_id: Some(task_id.clone()),
831                        include_deleted: None,
832                        project_rename: None,
833                        label_rename: None,
834                    });
835                }
836                if task_field == TaskField::Project {
837                    let project_id = before.parse().map_err(|_| {
838                        anyhow::anyhow!("error undo-state-changed task_id={task_id} field={field}")
839                    })?;
840                    if !project_id_exists(conn, workspace_id, &project_id).await? {
841                        bail!("error undo-state-changed task_id={task_id} field={field}");
842                    }
843                }
844                if task_field == TaskField::Deleted {
845                    collect_task_attachment_hashes(
846                        conn,
847                        workspace_id,
848                        task_id,
849                        affected_attachment_hashes,
850                    )
851                    .await?;
852                }
853                set_task_field_in_workspace(
854                    conn,
855                    workspace_id,
856                    task_id,
857                    task_field,
858                    before,
859                    mutation_at,
860                )
861                .await?;
862                if let Some(queue_activity_at) = queue_activity_to_restore {
863                    restore_queue_activity(conn, workspace_id, task_id, &queue_activity_at).await?;
864                }
865            }
866            let include_deleted = if task_field == TaskField::Deleted {
867                Some(before == "1")
868            } else {
869                None
870            };
871            Ok(CommandOutcome {
872                task_id: Some(task_id.clone()),
873                include_deleted,
874                project_rename: None,
875                label_rename: None,
876            })
877        }
878        UndoCommand::SetTaskLabels {
879            task_id,
880            before,
881            after,
882        } => {
883            let current = task_labels(conn, workspace_id, task_id).await?;
884            if !label_sets_equal(&current, after) {
885                bail!("error undo-state-changed task_id={task_id} field=labels");
886            }
887            let (add_labels, remove_labels) = label_delta(&current, before);
888            update_task_labels_in_workspace(
889                conn,
890                workspace_id,
891                task_id,
892                &add_labels,
893                &remove_labels,
894            )
895            .await?;
896            Ok(CommandOutcome {
897                task_id: Some(task_id.clone()),
898                include_deleted: None,
899                project_rename: None,
900                label_rename: None,
901            })
902        }
903        UndoCommand::SetTaskMetadata {
904            task_id,
905            field_id,
906            before,
907            after,
908        } => {
909            let current: Option<String> = sqlx::query_scalar(
910                "SELECT value FROM task_metadata
911                 WHERE workspace_id = ? AND task_id = ? AND field_id = ?",
912            )
913            .bind(workspace_id)
914            .bind(task_id)
915            .bind(field_id)
916            .fetch_optional(&mut *conn)
917            .await?;
918            if current != *after {
919                bail!("error undo-state-changed task_id={task_id} field=metadata");
920            }
921            let field = crate::metadata::metadata_field_by_id(conn, workspace_id, field_id)
922                .await?
923                .ok_or_else(|| anyhow::anyhow!("error metadata-field-not-found"))?;
924            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
925            if let Some(value) = before {
926                crate::metadata::set_task_metadata(
927                    conn,
928                    &workspace,
929                    task_id,
930                    &crate::metadata::TaskMetadataInput {
931                        expected_field_id: None,
932                        key: field.key,
933                        value: value.clone(),
934                    },
935                )
936                .await?;
937            } else {
938                crate::metadata::remove_task_metadata(conn, &workspace, task_id, &field.key)
939                    .await?;
940            }
941            Ok(CommandOutcome {
942                task_id: Some(task_id.clone()),
943                include_deleted: None,
944                project_rename: None,
945                label_rename: None,
946            })
947        }
948        UndoCommand::DeleteCreatedTask {
949            task_id,
950            create_change_id,
951            expected,
952            attachment_ids,
953            attachment_change_ids,
954        } => {
955            let current = task_snapshot(conn, workspace_id, task_id).await?;
956            if current != *expected {
957                bail!("error undo-state-changed task_id={task_id} field=task");
958            }
959            let current_attachment_ids: Vec<String> = sqlx::query_scalar(
960                "SELECT attachment_id FROM task_attachments
961                 WHERE workspace_id = ? AND task_id = ? AND deleted = 0
962                 ORDER BY created_at, attachment_id",
963            )
964            .bind(workspace_id)
965            .bind(task_id)
966            .fetch_all(&mut *conn)
967            .await?;
968            if let Some(change_id) = create_change_id {
969                let labels_clear = expected.labels.is_empty()
970                    || labels_match_create_change(conn, change_id, &expected.labels).await?;
971                let attachment_changes_clear =
972                    all_changes_unsynced(conn, attachment_change_ids).await?;
973                let related_state_clear =
974                    !crate::operations::task_has_related_state(conn, workspace_id, task_id).await?;
975                if change_is_unsynced(conn, change_id).await?
976                    && labels_clear
977                    && attachment_changes_clear
978                    && related_state_clear
979                    && !task_has_independent_creation_state(conn, workspace_id, task_id).await?
980                    && current_attachment_ids == *attachment_ids
981                {
982                    hard_delete_created_task(
983                        conn,
984                        workspace_id,
985                        task_id,
986                        change_id,
987                        attachment_change_ids,
988                        affected_attachment_hashes,
989                    )
990                    .await?;
991                    return Ok(CommandOutcome {
992                        task_id: Some(task_id.clone()),
993                        include_deleted: None,
994                        project_rename: None,
995                        label_rename: None,
996                    });
997                }
998            }
999            collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes)
1000                .await?;
1001            set_task_field_in_workspace(
1002                conn,
1003                workspace_id,
1004                task_id,
1005                TaskField::Deleted,
1006                "1",
1007                mutation_at,
1008            )
1009            .await?;
1010            Ok(CommandOutcome {
1011                task_id: Some(task_id.clone()),
1012                include_deleted: None,
1013                project_rename: None,
1014                label_rename: None,
1015            })
1016        }
1017        UndoCommand::SetNoteBody {
1018            task_id,
1019            note_id,
1020            before,
1021            after,
1022        } => {
1023            let current = sqlx::query_scalar::<_, String>(
1024                "SELECT body FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1025            )
1026            .bind(workspace_id)
1027            .bind(task_id)
1028            .bind(note_id)
1029            .fetch_optional(&mut *conn)
1030            .await?;
1031            if current.as_deref() != Some(after) {
1032                bail!("error undo-state-changed task_id={task_id} field=notes");
1033            }
1034            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1035            crate::operations::route_recurrence_task_mutation(
1036                conn,
1037                &workspace,
1038                task_id,
1039                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1040                mutation_at,
1041            )
1042            .await?;
1043            let edited_at = mutation_at;
1044            sqlx::query(
1045                "UPDATE notes SET body = ? WHERE workspace_id = ? AND task_id = ? AND id = ?",
1046            )
1047            .bind(before)
1048            .bind(workspace_id)
1049            .bind(task_id)
1050            .bind(note_id)
1051            .execute(&mut *conn)
1052            .await?;
1053            crate::change_log::append_change(
1054                conn,
1055                crate::change_log::ChangeEntity::Task,
1056                task_id,
1057                Some("notes"),
1058                crate::change_log::op_type::NOTE_EDIT,
1059                crate::change_log::ChangePayload::workspace(&workspace)
1060                    .set("note_id", note_id)
1061                    .set("body", before)
1062                    .set("edited_at", edited_at),
1063            )
1064            .await?;
1065            sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1066                .bind(edited_at)
1067                .bind(workspace_id)
1068                .bind(task_id)
1069                .execute(&mut *conn)
1070                .await?;
1071            Ok(CommandOutcome {
1072                task_id: Some(task_id.clone()),
1073                include_deleted: None,
1074                project_rename: None,
1075                label_rename: None,
1076            })
1077        }
1078        UndoCommand::RestoreDeletedNote {
1079            task_id,
1080            note_id,
1081            body,
1082            created_at,
1083        } => {
1084            let exists: i64 = sqlx::query_scalar(
1085                "SELECT count(*) FROM notes WHERE workspace_id = ? AND task_id = ? AND id = ?",
1086            )
1087            .bind(workspace_id)
1088            .bind(task_id)
1089            .bind(note_id)
1090            .fetch_one(&mut *conn)
1091            .await?;
1092            if exists != 0 {
1093                bail!("error undo-state-changed task_id={task_id} field=notes");
1094            }
1095            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1096            crate::operations::route_recurrence_task_mutation(
1097                conn,
1098                &workspace,
1099                task_id,
1100                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1101                mutation_at,
1102            )
1103            .await?;
1104            let change_id = crate::change_log::append_change(
1105                conn,
1106                crate::change_log::ChangeEntity::Task,
1107                task_id,
1108                Some("notes"),
1109                crate::change_log::op_type::NOTE_ADD,
1110                crate::change_log::ChangePayload::workspace(&workspace)
1111                    .set("note_id", note_id)
1112                    .set("body", body)
1113                    .set("created_at", created_at),
1114            )
1115            .await?;
1116            sqlx::query(
1117                "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id)
1118                 VALUES (?, ?, ?, ?, ?, ?)",
1119            )
1120            .bind(workspace_id)
1121            .bind(note_id)
1122            .bind(task_id)
1123            .bind(body)
1124            .bind(created_at)
1125            .bind(change_id)
1126            .execute(&mut *conn)
1127            .await?;
1128            sqlx::query("UPDATE tasks SET queue_activity_at = ? WHERE workspace_id = ? AND id = ?")
1129                .bind(now())
1130                .bind(workspace_id)
1131                .bind(task_id)
1132                .execute(&mut *conn)
1133                .await?;
1134            Ok(CommandOutcome {
1135                task_id: Some(task_id.clone()),
1136                include_deleted: None,
1137                project_rename: None,
1138                label_rename: None,
1139            })
1140        }
1141        UndoCommand::DeleteCreatedNote {
1142            task_id,
1143            note_id,
1144            note_add_change_id,
1145        } => {
1146            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1147            crate::operations::route_recurrence_task_mutation(
1148                conn,
1149                &workspace,
1150                task_id,
1151                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Notes),
1152                mutation_at,
1153            )
1154            .await?;
1155            delete_created_note(conn, workspace_id, task_id, note_id, note_add_change_id).await?;
1156            Ok(CommandOutcome {
1157                task_id: Some(task_id.clone()),
1158                include_deleted: None,
1159                project_rename: None,
1160                label_rename: None,
1161            })
1162        }
1163        UndoCommand::DeleteCreatedProject {
1164            project_key,
1165            create_change_id,
1166            expected_name,
1167            expected_prefix,
1168        } => {
1169            delete_created_project(
1170                conn,
1171                workspace_id,
1172                project_key,
1173                create_change_id,
1174                expected_name,
1175                expected_prefix,
1176            )
1177            .await?;
1178            Ok(empty_command_outcome())
1179        }
1180        UndoCommand::SetProjectMetadata {
1181            project_id,
1182            before_key,
1183            before_name,
1184            before_prefix,
1185            after_key,
1186            after_name,
1187            after_prefix,
1188        } => {
1189            set_project_metadata_for_undo(
1190                conn,
1191                workspace_id,
1192                project_id,
1193                ProjectMetadata {
1194                    key: before_key,
1195                    name: before_name,
1196                    prefix: before_prefix,
1197                },
1198                ProjectMetadata {
1199                    key: after_key,
1200                    name: after_name,
1201                    prefix: after_prefix,
1202                },
1203            )
1204            .await?;
1205            Ok(CommandOutcome {
1206                task_id: None,
1207                include_deleted: None,
1208                project_rename: Some(ProjectRenameUndoOutcome {
1209                    before_key: before_key.clone(),
1210                    after_key: after_key.clone(),
1211                }),
1212                label_rename: None,
1213            })
1214        }
1215        UndoCommand::DeleteCreatedLabel {
1216            label,
1217            create_change_id,
1218        } => {
1219            delete_created_label(conn, workspace_id, label, create_change_id).await?;
1220            Ok(empty_command_outcome())
1221        }
1222        UndoCommand::SetLabelName { before, after } => {
1223            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1224            let after_exists = sqlx::query_scalar::<_, i64>(
1225                "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
1226            )
1227            .bind(workspace_id)
1228            .bind(after)
1229            .fetch_one(&mut *conn)
1230            .await?
1231                == 1;
1232            let before_exists = sqlx::query_scalar::<_, i64>(
1233                "SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?",
1234            )
1235            .bind(workspace_id)
1236            .bind(before)
1237            .fetch_one(&mut *conn)
1238            .await?
1239                > 0;
1240            if !after_exists || before_exists {
1241                bail!("error undo-state-changed label={after}");
1242            }
1243            crate::operations::set_label_name(conn, &workspace, after, before).await?;
1244            Ok(CommandOutcome {
1245                task_id: None,
1246                include_deleted: None,
1247                project_rename: None,
1248                label_rename: Some(LabelRenameUndoOutcome {
1249                    before: before.clone(),
1250                    after: after.clone(),
1251                }),
1252            })
1253        }
1254        UndoCommand::RestoreDeletedLabel {
1255            name,
1256            created_at,
1257            task_ids,
1258            series_ids,
1259        } => {
1260            restore_deleted_label(conn, workspace_id, name, created_at, task_ids, series_ids)
1261                .await?;
1262            Ok(empty_command_outcome())
1263        }
1264        UndoCommand::RestoreConflictResolution {
1265            task_id,
1266            field,
1267            before,
1268            after,
1269            conflict_id,
1270        } => {
1271            let task_field = TaskField::parse_or_unknown(field)?;
1272            let current =
1273                task_field_value_for_field(conn, workspace_id, task_id, task_field).await?;
1274            if current != *after {
1275                bail!("error undo-state-changed task_id={task_id} field={field}");
1276            }
1277            if task_field == TaskField::Deleted && before != after {
1278                collect_task_attachment_hashes(
1279                    conn,
1280                    workspace_id,
1281                    task_id,
1282                    affected_attachment_hashes,
1283                )
1284                .await?;
1285            }
1286            set_task_field_in_workspace(
1287                conn,
1288                workspace_id,
1289                task_id,
1290                task_field,
1291                before,
1292                mutation_at,
1293            )
1294            .await?;
1295            let restored = sqlx::query(
1296                "UPDATE conflicts SET resolved = 0 WHERE id = ? AND workspace_id = ? AND resolved = 1",
1297            )
1298            .bind(conflict_id)
1299            .bind(workspace_id)
1300            .execute(&mut *conn)
1301            .await?;
1302            ensure!(
1303                restored.rows_affected() == 1,
1304                "error undo-state-changed task_id={task_id} field={field}"
1305            );
1306            Ok(CommandOutcome {
1307                task_id: Some(task_id.clone()),
1308                include_deleted: None,
1309                project_rename: None,
1310                label_rename: None,
1311            })
1312        }
1313        UndoCommand::AddTaskDependency {
1314            task_id,
1315            depends_on_task_id,
1316        } => {
1317            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1318            crate::operations::route_recurrence_task_mutation(
1319                conn,
1320                &workspace,
1321                task_id,
1322                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1323                mutation_at,
1324            )
1325            .await?;
1326            crate::operations::route_recurrence_task_mutation(
1327                conn,
1328                &workspace,
1329                depends_on_task_id,
1330                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1331                mutation_at,
1332            )
1333            .await?;
1334            ensure!(
1335                dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
1336                "error undo-state-changed task_id={task_id} field=dependency"
1337            );
1338            remove_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
1339            Ok(CommandOutcome {
1340                task_id: Some(task_id.clone()),
1341                include_deleted: None,
1342                project_rename: None,
1343                label_rename: None,
1344            })
1345        }
1346        UndoCommand::RemoveTaskDependency {
1347            task_id,
1348            depends_on_task_id,
1349        } => {
1350            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1351            crate::operations::route_recurrence_task_mutation(
1352                conn,
1353                &workspace,
1354                task_id,
1355                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1356                mutation_at,
1357            )
1358            .await?;
1359            crate::operations::route_recurrence_task_mutation(
1360                conn,
1361                &workspace,
1362                depends_on_task_id,
1363                RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Dependencies),
1364                mutation_at,
1365            )
1366            .await?;
1367            ensure!(
1368                !dependency_edge_exists(conn, workspace_id, task_id, depends_on_task_id).await?,
1369                "error undo-state-changed task_id={task_id} field=dependency"
1370            );
1371            ensure!(
1372                !crate::operations::dependency_path_exists(
1373                    conn,
1374                    workspace_id,
1375                    depends_on_task_id,
1376                    task_id,
1377                )
1378                .await?,
1379                "error dependency-cycle task_id={task_id} depends_on_task_id={depends_on_task_id}"
1380            );
1381            add_dependency_for_undo(conn, workspace_id, task_id, depends_on_task_id).await?;
1382            Ok(CommandOutcome {
1383                task_id: Some(task_id.clone()),
1384                include_deleted: None,
1385                project_rename: None,
1386                label_rename: None,
1387            })
1388        }
1389        UndoCommand::SetTaskRelatedLink {
1390            task_id,
1391            related_task_id,
1392            forward_change_id,
1393            linked,
1394        } => {
1395            let (task_a_id, task_b_id) =
1396                crate::operations::canonical_related_pair(task_id, related_task_id)?;
1397            let current_change_id = sqlx::query_scalar::<_, String>(
1398                "SELECT last_change_id FROM task_related_links
1399                 WHERE workspace_id = ? AND task_a_id = ? AND task_b_id = ?",
1400            )
1401            .bind(workspace_id)
1402            .bind(task_a_id)
1403            .bind(task_b_id)
1404            .fetch_optional(&mut *conn)
1405            .await?;
1406            ensure!(
1407                current_change_id.as_deref() == Some(forward_change_id),
1408                "error undo-state-changed task_id={task_id} field=related"
1409            );
1410            let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1411            crate::operations::set_task_related_link_in_transaction(
1412                conn,
1413                &workspace,
1414                task_id,
1415                related_task_id,
1416                !linked,
1417            )
1418            .await?;
1419            Ok(CommandOutcome {
1420                task_id: Some(task_id.clone()),
1421                include_deleted: None,
1422                project_rename: None,
1423                label_rename: None,
1424            })
1425        }
1426        UndoCommand::AddEpicChild { epic_id, child_id } => {
1427            ensure!(
1428                epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
1429                "error undo-state-changed task_id={child_id} field=epic"
1430            );
1431            set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, false).await?;
1432            Ok(CommandOutcome {
1433                task_id: Some(child_id.clone()),
1434                include_deleted: None,
1435                project_rename: None,
1436                label_rename: None,
1437            })
1438        }
1439        UndoCommand::RemoveEpicChild { epic_id, child_id } => {
1440            ensure!(
1441                !epic_edge_exists(conn, workspace_id, epic_id, child_id).await?,
1442                "error undo-state-changed task_id={child_id} field=epic"
1443            );
1444            set_epic_edge_for_undo(conn, workspace_id, epic_id, child_id, true).await?;
1445            Ok(CommandOutcome {
1446                task_id: Some(child_id.clone()),
1447                include_deleted: None,
1448                project_rename: None,
1449                label_rename: None,
1450            })
1451        }
1452    }
1453}
1454
1455async fn project_id_exists(
1456    conn: &mut SqliteConnection,
1457    workspace_id: &WorkspaceId,
1458    project_id: &ProjectId,
1459) -> Result<bool> {
1460    Ok(sqlx::query_scalar::<_, i64>(
1461        "SELECT count(*) FROM projects
1462         WHERE workspace_id = ? AND id = ? AND deleted = 0",
1463    )
1464    .bind(workspace_id)
1465    .bind(project_id)
1466    .fetch_one(&mut *conn)
1467    .await?
1468        > 0)
1469}
1470
1471async fn set_task_field_in_workspace(
1472    conn: &mut SqliteConnection,
1473    workspace_id: &WorkspaceId,
1474    task_id: &crate::ids::TaskId,
1475    task_field: TaskField,
1476    value: &str,
1477    mutation_at: &str,
1478) -> Result<()> {
1479    let field = task_field.as_str();
1480    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1481    match crate::operations::route_recurrence_task_mutation(
1482        conn,
1483        &workspace,
1484        task_id,
1485        RecurrenceTaskMutation::Scalar {
1486            field: task_field,
1487            value,
1488        },
1489        mutation_at,
1490    )
1491    .await?
1492    {
1493        crate::operations::RecurrenceMutationOutcome::Proceed => {}
1494        crate::operations::RecurrenceMutationOutcome::NoChange
1495        | crate::operations::RecurrenceMutationOutcome::Handled => return Ok(()),
1496    }
1497    if conflict_exists(conn, workspace_id, task_id, field).await? {
1498        bail!(
1499            "error conflicted-field ref={} field={} hint=\"use conflict resolve\"",
1500            task_id,
1501            field
1502        );
1503    }
1504    let base = field_version(conn, task_id, field).await?;
1505    let workspace_key = workspace_key_for_id(conn, workspace_id).await?;
1506    let payload = if task_field.is_project() {
1507        let project = resolve_project_for_stored_value(conn, workspace_id, value).await?;
1508        apply_project_id_in_workspace(conn, workspace_id, task_id, &project.id).await?;
1509        TaskField::project_payload(workspace_id, &workspace_key, &project)
1510    } else {
1511        apply_field_value_in_workspace(conn, workspace_id, task_id, field, value).await?;
1512        task_field.scalar_payload(workspace_id, &workspace_key, value)?
1513    };
1514    let change_id = insert_change(
1515        conn,
1516        "task",
1517        task_id,
1518        Some(field),
1519        "set_field",
1520        payload,
1521        base.as_deref(),
1522    )
1523    .await?;
1524    set_field_version(conn, task_id, field, &change_id).await?;
1525    Ok(())
1526}
1527
1528fn label_sets_equal(left: &[String], right: &[String]) -> bool {
1529    let left: BTreeSet<_> = left.iter().collect();
1530    let right: BTreeSet<_> = right.iter().collect();
1531    left == right
1532}
1533
1534fn label_delta(current: &[String], target: &[String]) -> (Vec<String>, Vec<String>) {
1535    let current_set: BTreeSet<_> = current.iter().collect();
1536    let target_set: BTreeSet<_> = target.iter().collect();
1537    let add = target
1538        .iter()
1539        .filter(|label| !current_set.contains(label))
1540        .cloned()
1541        .collect();
1542    let remove = current
1543        .iter()
1544        .filter(|label| !target_set.contains(label))
1545        .cloned()
1546        .collect();
1547    (add, remove)
1548}
1549
1550async fn change_is_unsynced(conn: &mut SqliteConnection, change_id: &str) -> Result<bool> {
1551    let server_seq =
1552        sqlx::query_scalar::<_, Option<i64>>("SELECT server_seq FROM changes WHERE change_id = ?")
1553            .bind(change_id)
1554            .fetch_optional(&mut *conn)
1555            .await?;
1556    Ok(matches!(server_seq, Some(None)))
1557}
1558
1559async fn all_changes_unsynced(conn: &mut SqliteConnection, change_ids: &[String]) -> Result<bool> {
1560    for change_id in change_ids {
1561        if !change_is_unsynced(conn, change_id).await? {
1562            return Ok(false);
1563        }
1564    }
1565    Ok(true)
1566}
1567
1568async fn labels_match_create_change(
1569    conn: &mut SqliteConnection,
1570    change_id: &str,
1571    labels: &[String],
1572) -> Result<bool> {
1573    let payload: String = sqlx::query_scalar("SELECT payload FROM changes WHERE change_id = ?")
1574        .bind(change_id)
1575        .fetch_one(&mut *conn)
1576        .await?;
1577    let payload: serde_json::Value = serde_json::from_str(&payload)?;
1578    let payload_labels = payload
1579        .get("labels")
1580        .and_then(|labels| labels.as_array())
1581        .map(|labels| {
1582            labels
1583                .iter()
1584                .filter_map(|label| label.as_str().map(str::to_string))
1585                .collect::<Vec<_>>()
1586        })
1587        .unwrap_or_default();
1588    Ok(label_sets_equal(labels, &payload_labels))
1589}
1590
1591async fn collect_task_attachment_hashes(
1592    conn: &mut SqliteConnection,
1593    workspace_id: &WorkspaceId,
1594    task_id: &crate::ids::TaskId,
1595    affected_attachment_hashes: &mut BTreeSet<String>,
1596) -> Result<()> {
1597    let hashes: Vec<String> = sqlx::query_scalar(
1598        "SELECT DISTINCT sha256 FROM task_attachments
1599         WHERE workspace_id = ? AND task_id = ?",
1600    )
1601    .bind(workspace_id)
1602    .bind(task_id)
1603    .fetch_all(&mut *conn)
1604    .await?;
1605    affected_attachment_hashes.extend(hashes);
1606    Ok(())
1607}
1608
1609// Independently authored rows must retain their task endpoint when creation is undone.
1610async fn task_has_independent_creation_state(
1611    conn: &mut SqliteConnection,
1612    workspace_id: &WorkspaceId,
1613    task_id: &crate::ids::TaskId,
1614) -> Result<bool> {
1615    Ok(sqlx::query_scalar(
1616        "SELECT EXISTS(SELECT 1 FROM notes WHERE workspace_id = ?1 AND task_id = ?2)
1617         OR EXISTS(SELECT 1 FROM task_dependencies
1618                   WHERE workspace_id = ?1 AND (task_id = ?2 OR depends_on_task_id = ?2))
1619         OR EXISTS(SELECT 1 FROM task_epic_links
1620                   WHERE workspace_id = ?1 AND (epic_task_id = ?2 OR child_task_id = ?2))",
1621    )
1622    .bind(workspace_id)
1623    .bind(task_id)
1624    .fetch_one(&mut *conn)
1625    .await?)
1626}
1627
1628async fn hard_delete_created_task(
1629    conn: &mut SqliteConnection,
1630    workspace_id: &WorkspaceId,
1631    task_id: &crate::ids::TaskId,
1632    create_change_id: &str,
1633    attachment_change_ids: &[String],
1634    affected_attachment_hashes: &mut BTreeSet<String>,
1635) -> Result<()> {
1636    collect_task_attachment_hashes(conn, workspace_id, task_id, affected_attachment_hashes).await?;
1637    sqlx::query("DELETE FROM task_attachments WHERE workspace_id = ? AND task_id = ?")
1638        .bind(workspace_id)
1639        .bind(task_id)
1640        .execute(&mut *conn)
1641        .await?;
1642    sqlx::query("DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ?")
1643        .bind(workspace_id)
1644        .bind(task_id)
1645        .execute(&mut *conn)
1646        .await?;
1647    sqlx::query("DELETE FROM task_metadata WHERE workspace_id = ? AND task_id = ?")
1648        .bind(workspace_id)
1649        .bind(task_id)
1650        .execute(&mut *conn)
1651        .await?;
1652    sqlx::query("DELETE FROM field_versions WHERE entity_id = ?")
1653        .bind(task_id)
1654        .execute(&mut *conn)
1655        .await?;
1656    sqlx::query("DELETE FROM tasks WHERE workspace_id = ? AND id = ?")
1657        .bind(workspace_id)
1658        .bind(task_id)
1659        .execute(&mut *conn)
1660        .await?;
1661    for change_id in attachment_change_ids {
1662        sqlx::query("DELETE FROM changes WHERE change_id = ?")
1663            .bind(change_id)
1664            .execute(&mut *conn)
1665            .await?;
1666    }
1667    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1668        .bind(create_change_id)
1669        .execute(&mut *conn)
1670        .await?;
1671    Ok(())
1672}
1673
1674async fn delete_created_note(
1675    conn: &mut SqliteConnection,
1676    workspace_id: &WorkspaceId,
1677    task_id: &crate::ids::TaskId,
1678    note_id: &str,
1679    note_add_change_id: &str,
1680) -> Result<()> {
1681    let row = sqlx::query(
1682        "SELECT change_id FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?",
1683    )
1684    .bind(workspace_id)
1685    .bind(note_id)
1686    .bind(task_id)
1687    .fetch_optional(&mut *conn)
1688    .await?;
1689    let Some(row) = row else {
1690        bail!("error undo-state-changed task_id={task_id} field=note");
1691    };
1692    let stored_change_id: String = row.get("change_id");
1693    if stored_change_id != note_add_change_id {
1694        bail!("error undo-state-changed task_id={task_id} field=note");
1695    }
1696    if !change_is_unsynced(conn, note_add_change_id).await? {
1697        bail!("error undo-state-changed task_id={task_id} field=note");
1698    }
1699    sqlx::query("DELETE FROM notes WHERE workspace_id = ? AND id = ? AND task_id = ?")
1700        .bind(workspace_id)
1701        .bind(note_id)
1702        .bind(task_id)
1703        .execute(&mut *conn)
1704        .await?;
1705    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1706        .bind(note_add_change_id)
1707        .execute(&mut *conn)
1708        .await?;
1709    Ok(())
1710}
1711
1712async fn delete_created_project(
1713    conn: &mut SqliteConnection,
1714    workspace_id: &WorkspaceId,
1715    project_key: &str,
1716    create_change_id: &str,
1717    expected_name: &str,
1718    expected_prefix: &str,
1719) -> Result<()> {
1720    let row = sqlx::query(
1721        "SELECT id, name, prefix FROM projects WHERE workspace_id = ? AND key = ? AND deleted = 0",
1722    )
1723    .bind(workspace_id)
1724    .bind(project_key)
1725    .fetch_optional(&mut *conn)
1726    .await?;
1727    let Some(row) = row else {
1728        bail!("error undo-state-changed project_key={project_key}");
1729    };
1730    let project_id: ProjectId = row.get("id");
1731    let name: String = row.get("name");
1732    let prefix: String = row.get("prefix");
1733    if name != expected_name || prefix != expected_prefix {
1734        bail!("error undo-state-changed project_key={project_key}");
1735    }
1736    if !change_is_unsynced(conn, create_change_id).await? {
1737        bail!("error undo-state-changed project_key={project_key}");
1738    }
1739    let task_refs: i64 =
1740        sqlx::query_scalar("SELECT count(*) FROM tasks WHERE workspace_id = ? AND project_id = ?")
1741            .bind(workspace_id)
1742            .bind(&project_id)
1743            .fetch_one(&mut *conn)
1744            .await?;
1745    if task_refs > 0 {
1746        bail!("error undo-state-changed project_key={project_key}");
1747    }
1748    let path_refs: i64 = sqlx::query_scalar(
1749        "SELECT count(*) FROM project_paths WHERE workspace_id = ? AND project_id = ?",
1750    )
1751    .bind(workspace_id)
1752    .bind(&project_id)
1753    .fetch_one(&mut *conn)
1754    .await?;
1755    if path_refs > 0 {
1756        bail!("error undo-state-changed project_key={project_key}");
1757    }
1758    sqlx::query("DELETE FROM projects WHERE workspace_id = ? AND key = ?")
1759        .bind(workspace_id)
1760        .bind(project_key)
1761        .execute(&mut *conn)
1762        .await?;
1763    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1764        .bind(create_change_id)
1765        .execute(&mut *conn)
1766        .await?;
1767    Ok(())
1768}
1769
1770async fn set_project_metadata_for_undo(
1771    conn: &mut SqliteConnection,
1772    workspace_id: &WorkspaceId,
1773    project_id: &ProjectId,
1774    before: ProjectMetadata<'_>,
1775    after: ProjectMetadata<'_>,
1776) -> Result<()> {
1777    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1778    let row = sqlx::query(
1779        "SELECT key, name, prefix
1780         FROM projects
1781         WHERE workspace_id = ? AND id = ? AND deleted = 0",
1782    )
1783    .bind(workspace_id)
1784    .bind(project_id)
1785    .fetch_optional(&mut *conn)
1786    .await?;
1787    let Some(row) = row else {
1788        bail!("error undo-state-changed project_id={project_id}");
1789    };
1790    let key: String = row.get("key");
1791    let name: String = row.get("name");
1792    let prefix: String = row.get("prefix");
1793    if key != after.key || name != after.name || prefix != after.prefix {
1794        bail!("error undo-state-changed project_id={project_id}");
1795    }
1796    let key_refs: i64 = sqlx::query_scalar(
1797        "SELECT count(*) FROM projects
1798         WHERE workspace_id = ? AND key = ? AND id != ? AND deleted = 0",
1799    )
1800    .bind(workspace_id)
1801    .bind(before.key)
1802    .bind(project_id)
1803    .fetch_one(&mut *conn)
1804    .await?;
1805    if key_refs > 0 {
1806        bail!("error undo-state-changed project_id={project_id}");
1807    }
1808    let prefix_refs: i64 = sqlx::query_scalar(
1809        "SELECT count(*) FROM projects
1810         WHERE workspace_id = ? AND prefix = ? AND id != ? AND deleted = 0",
1811    )
1812    .bind(workspace_id)
1813    .bind(before.prefix)
1814    .bind(project_id)
1815    .fetch_one(&mut *conn)
1816    .await?;
1817    if prefix_refs > 0 {
1818        bail!("error undo-state-changed project_id={project_id}");
1819    }
1820    set_project_metadata(conn, &workspace, project_id, before, false).await?;
1821    insert_project_metadata_change(conn, &workspace, project_id, before, &now()).await?;
1822    Ok(())
1823}
1824
1825async fn delete_created_label(
1826    conn: &mut SqliteConnection,
1827    workspace_id: &WorkspaceId,
1828    label: &str,
1829    create_change_id: &str,
1830) -> Result<()> {
1831    let exists: i64 =
1832        sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1833            .bind(workspace_id)
1834            .bind(label)
1835            .fetch_one(&mut *conn)
1836            .await?;
1837    if exists == 0 || !change_is_unsynced(conn, create_change_id).await? {
1838        bail!("error undo-state-changed label={label}");
1839    }
1840    let task_refs: i64 =
1841        sqlx::query_scalar("SELECT count(*) FROM task_labels WHERE workspace_id = ? AND label = ?")
1842            .bind(workspace_id)
1843            .bind(label)
1844            .fetch_one(&mut *conn)
1845            .await?;
1846    let series_refs: i64 = sqlx::query_scalar(
1847        "SELECT count(*) FROM recurrence_series_labels WHERE workspace_id = ? AND label = ?",
1848    )
1849    .bind(workspace_id)
1850    .bind(label)
1851    .fetch_one(&mut *conn)
1852    .await?;
1853    if task_refs > 0 || series_refs > 0 {
1854        bail!("error undo-state-changed label={label}");
1855    }
1856    sqlx::query("DELETE FROM labels WHERE workspace_id = ? AND name = ?")
1857        .bind(workspace_id)
1858        .bind(label)
1859        .execute(&mut *conn)
1860        .await?;
1861    sqlx::query("DELETE FROM changes WHERE change_id = ?")
1862        .bind(create_change_id)
1863        .execute(&mut *conn)
1864        .await?;
1865    Ok(())
1866}
1867
1868async fn restore_deleted_label(
1869    conn: &mut SqliteConnection,
1870    workspace_id: &WorkspaceId,
1871    name: &str,
1872    created_at: &str,
1873    task_ids: &[crate::ids::TaskId],
1874    series_ids: &[crate::recurrence::RecurrenceSeriesId],
1875) -> Result<()> {
1876    let exists: i64 =
1877        sqlx::query_scalar("SELECT count(*) FROM labels WHERE workspace_id = ? AND name = ?")
1878            .bind(workspace_id)
1879            .bind(name)
1880            .fetch_one(&mut *conn)
1881            .await?;
1882    if exists > 0 {
1883        bail!("error undo-state-changed label={name}");
1884    }
1885    sqlx::query("INSERT INTO labels(workspace_id, name, created_at) VALUES (?, ?, ?)")
1886        .bind(workspace_id)
1887        .bind(name)
1888        .bind(created_at)
1889        .execute(&mut *conn)
1890        .await?;
1891    for task_id in task_ids {
1892        let inserted = sqlx::query(
1893            "INSERT INTO task_labels(workspace_id, task_id, label)
1894             SELECT ?, id, ? FROM tasks WHERE workspace_id = ? AND id = ?",
1895        )
1896        .bind(workspace_id)
1897        .bind(name)
1898        .bind(workspace_id)
1899        .bind(task_id)
1900        .execute(&mut *conn)
1901        .await?;
1902        if inserted.rows_affected() != 1 {
1903            bail!("error undo-state-changed label={name} task_id={task_id}");
1904        }
1905    }
1906    for series_id in series_ids {
1907        let inserted = sqlx::query(
1908            "INSERT INTO recurrence_series_labels(workspace_id, series_id, label)
1909             SELECT ?, id, ? FROM recurrence_series WHERE workspace_id = ? AND id = ?",
1910        )
1911        .bind(workspace_id)
1912        .bind(name)
1913        .bind(workspace_id)
1914        .bind(series_id)
1915        .execute(&mut *conn)
1916        .await?;
1917        if inserted.rows_affected() != 1 {
1918            bail!("error undo-state-changed label={name} series_id={series_id}");
1919        }
1920    }
1921    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
1922    append_change(
1923        conn,
1924        ChangeEntity::Label,
1925        name,
1926        None,
1927        op_type::LABEL_RESTORE,
1928        ChangePayload::workspace(&workspace)
1929            .set("name", name)
1930            .set("created_at", created_at)
1931            .set("task_ids", task_ids)
1932            .set("series_ids", series_ids)
1933            .set("restored_at", now()),
1934    )
1935    .await?;
1936    Ok(())
1937}
1938
1939async fn dependency_task_exists(
1940    conn: &mut SqliteConnection,
1941    workspace_id: &WorkspaceId,
1942    task_id: &crate::ids::TaskId,
1943) -> Result<bool> {
1944    Ok(
1945        sqlx::query_scalar::<_, i64>(
1946            "SELECT count(*) FROM tasks WHERE workspace_id = ? AND id = ?",
1947        )
1948        .bind(workspace_id)
1949        .bind(task_id)
1950        .fetch_one(&mut *conn)
1951        .await?
1952            > 0,
1953    )
1954}
1955
1956async fn dependency_edge_exists(
1957    conn: &mut SqliteConnection,
1958    workspace_id: &WorkspaceId,
1959    task_id: &crate::ids::TaskId,
1960    depends_on_task_id: &crate::ids::TaskId,
1961) -> Result<bool> {
1962    ensure!(
1963        dependency_task_exists(conn, workspace_id, task_id).await?
1964            && dependency_task_exists(conn, workspace_id, depends_on_task_id).await?,
1965        "error undo-state-changed task_id={task_id} field=dependency"
1966    );
1967    Ok(sqlx::query_scalar::<_, i64>(
1968        "SELECT count(*) FROM task_dependencies
1969         WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
1970    )
1971    .bind(workspace_id)
1972    .bind(task_id)
1973    .bind(depends_on_task_id)
1974    .fetch_one(&mut *conn)
1975    .await?
1976        > 0)
1977}
1978
1979async fn epic_edge_exists(
1980    conn: &mut SqliteConnection,
1981    workspace_id: &WorkspaceId,
1982    epic_id: &crate::ids::TaskId,
1983    child_id: &crate::ids::TaskId,
1984) -> Result<bool> {
1985    Ok(sqlx::query_scalar::<_, i64>(
1986        "SELECT count(*) FROM task_epic_links
1987         WHERE workspace_id = ? AND epic_task_id = ? AND child_task_id = ?",
1988    )
1989    .bind(workspace_id)
1990    .bind(epic_id)
1991    .bind(child_id)
1992    .fetch_one(&mut *conn)
1993    .await?
1994        > 0)
1995}
1996
1997async fn set_epic_edge_for_undo(
1998    conn: &mut SqliteConnection,
1999    workspace_id: &WorkspaceId,
2000    epic_id: &crate::ids::TaskId,
2001    child_id: &crate::ids::TaskId,
2002    linked: bool,
2003) -> Result<()> {
2004    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2005    let outcome = if linked {
2006        crate::operations::restore_task_to_epic_in_transaction(conn, &workspace, child_id, epic_id)
2007            .await?
2008    } else {
2009        crate::operations::remove_task_from_epic_in_transaction(conn, &workspace, child_id, epic_id)
2010            .await?
2011    };
2012    ensure!(
2013        outcome.changed,
2014        "error undo-state-changed task_id={child_id} field=epic"
2015    );
2016    Ok(())
2017}
2018
2019async fn add_dependency_for_undo(
2020    conn: &mut SqliteConnection,
2021    workspace_id: &WorkspaceId,
2022    task_id: &crate::ids::TaskId,
2023    depends_on_task_id: &crate::ids::TaskId,
2024) -> Result<()> {
2025    let created_at = now();
2026    sqlx::query(
2027        "INSERT OR IGNORE INTO task_dependencies(workspace_id, task_id, depends_on_task_id, created_at)
2028         VALUES (?, ?, ?, ?)",
2029    )
2030    .bind(workspace_id)
2031    .bind(task_id)
2032    .bind(depends_on_task_id)
2033    .bind(&created_at)
2034    .execute(&mut *conn)
2035    .await?;
2036    append_dependency_change(
2037        conn,
2038        workspace_id,
2039        task_id,
2040        depends_on_task_id,
2041        crate::change_log::op_type::DEPENDENCY_ADD,
2042    )
2043    .await
2044}
2045
2046async fn remove_dependency_for_undo(
2047    conn: &mut SqliteConnection,
2048    workspace_id: &WorkspaceId,
2049    task_id: &crate::ids::TaskId,
2050    depends_on_task_id: &crate::ids::TaskId,
2051) -> Result<()> {
2052    sqlx::query(
2053        "DELETE FROM task_dependencies
2054         WHERE workspace_id = ? AND task_id = ? AND depends_on_task_id = ?",
2055    )
2056    .bind(workspace_id)
2057    .bind(task_id)
2058    .bind(depends_on_task_id)
2059    .execute(&mut *conn)
2060    .await?;
2061    append_dependency_change(
2062        conn,
2063        workspace_id,
2064        task_id,
2065        depends_on_task_id,
2066        crate::change_log::op_type::DEPENDENCY_REMOVE,
2067    )
2068    .await
2069}
2070
2071async fn append_dependency_change(
2072    conn: &mut SqliteConnection,
2073    workspace_id: &WorkspaceId,
2074    task_id: &crate::ids::TaskId,
2075    depends_on_task_id: &crate::ids::TaskId,
2076    op_type: &'static str,
2077) -> Result<()> {
2078    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
2079    crate::change_log::append_change(
2080        conn,
2081        crate::change_log::ChangeEntity::Task,
2082        task_id,
2083        Some("dependencies"),
2084        op_type,
2085        crate::change_log::ChangePayload::workspace(&workspace)
2086            .set("depends_on_task_id", depends_on_task_id.to_string()),
2087    )
2088    .await?;
2089    Ok(())
2090}
2091
2092#[cfg(test)]
2093mod presentation_tests {
2094    use super::*;
2095
2096    fn set_field(task_id: &crate::ids::TaskId, field: &str) -> UndoCommand {
2097        UndoCommand::SetTaskField {
2098            task_id: task_id.clone(),
2099            field: field.to_string(),
2100            before: "before".to_string(),
2101            after: "after".to_string(),
2102            queue_activity_before: None,
2103            queue_activity_after: None,
2104        }
2105    }
2106
2107    #[test]
2108    fn classifier_derives_operations_and_unique_task_scope() {
2109        let first = crate::ids::TaskId::new();
2110        let second = crate::ids::TaskId::new();
2111        let cases = [
2112            (vec![set_field(&first, "priority")], "priority change", 1),
2113            (
2114                vec![
2115                    set_field(&first, "status"),
2116                    set_field(&first, "status"),
2117                    set_field(&second, "status"),
2118                ],
2119                "status change",
2120                2,
2121            ),
2122            (
2123                vec![UndoCommand::SetTaskMetadata {
2124                    task_id: first.clone(),
2125                    field_id: crate::ids::MetadataFieldId::new(),
2126                    before: None,
2127                    after: Some("value".to_string()),
2128                }],
2129                "metadata change",
2130                1,
2131            ),
2132        ];
2133
2134        for (commands, operation, task_count) in cases {
2135            let presentation = classify_undo_commands("entry".to_string(), &commands);
2136            assert_eq!(presentation.operation, operation);
2137            assert_eq!(presentation.task_ids.len(), task_count);
2138        }
2139    }
2140
2141    #[test]
2142    fn classifier_describes_related_link_changes_on_the_initiating_task() {
2143        let task_id = crate::ids::TaskId::new();
2144        let related_task_id = crate::ids::TaskId::new();
2145
2146        for (linked, operation) in [
2147            (true, "related link addition"),
2148            (false, "related link removal"),
2149        ] {
2150            let presentation = classify_undo_commands(
2151                "entry".to_string(),
2152                &[UndoCommand::SetTaskRelatedLink {
2153                    task_id: task_id.clone(),
2154                    related_task_id: related_task_id.clone(),
2155                    forward_change_id: "change".to_string(),
2156                    linked,
2157                }],
2158            );
2159
2160            assert_eq!(presentation.operation, operation);
2161            assert_eq!(presentation.task_ids, vec![task_id.clone()]);
2162        }
2163    }
2164
2165    #[test]
2166    fn classifier_uses_primary_intent_for_mixed_payloads() {
2167        let task_id = crate::ids::TaskId::new();
2168        let project_id = ProjectId::new();
2169        let creation = UndoCommand::DeleteCreatedTask {
2170            task_id: task_id.clone(),
2171            create_change_id: None,
2172            expected: TaskUndoSnapshot {
2173                title: "title".to_string(),
2174                description: String::new(),
2175                project_id,
2176                project_key: "project".to_string(),
2177                status: "inbox".to_string(),
2178                priority: "none".to_string(),
2179                queue_activity_at: String::new(),
2180                available_at: String::new(),
2181                due_on: String::new(),
2182                deleted: false,
2183                is_epic: false,
2184                labels: vec!["new".to_string()],
2185                metadata: BTreeMap::new(),
2186            },
2187            attachment_ids: Vec::new(),
2188            attachment_change_ids: Vec::new(),
2189        };
2190        let cleanup = UndoCommand::DeleteCreatedLabel {
2191            label: "new".to_string(),
2192            create_change_id: "change".to_string(),
2193        };
2194        let presentation =
2195            classify_undo_commands("entry".to_string(), &[creation, cleanup.clone()]);
2196        assert_eq!(presentation.operation, "task creation");
2197
2198        let label_change = UndoCommand::SetTaskLabels {
2199            task_id,
2200            before: Vec::new(),
2201            after: vec!["new".to_string()],
2202        };
2203        let presentation = classify_undo_commands("entry".to_string(), &[label_change, cleanup]);
2204        assert_eq!(presentation.operation, "label change");
2205
2206        let epic_id = crate::ids::TaskId::new();
2207        let child_id = crate::ids::TaskId::new();
2208        let presentation = classify_undo_commands(
2209            "entry".to_string(),
2210            &[
2211                set_field(&epic_id, "is_epic"),
2212                UndoCommand::AddEpicChild { epic_id, child_id },
2213            ],
2214        );
2215        assert_eq!(presentation.operation, "epic membership addition");
2216    }
2217}