Skip to main content

aven_core/
undo.rs

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