Skip to main content

aven_core/operations/tasks/
mutation.rs

1use super::creation::append_created_label_undo_commands;
2use super::{TaskMutationOutcome, TaskMutationReport, TaskOutcome, TaskUpdate, TaskUpdateOutcome};
3use crate::change_log::{ChangeEntity, ChangePayload, append_change, op_type};
4use crate::choices::{TaskPriority, TaskStatus};
5use crate::db::{Database, begin_immediate};
6use crate::ids::{TaskId, WorkspaceId, now};
7use crate::labels::{
8    CreatedLabel, resolve_labels_in_workspace, resolve_or_create_labels_in_workspace,
9};
10use crate::mutation::{set_task_field, set_task_project};
11use crate::operations::{RecurrenceStructuralMutation, RecurrenceTaskMutation};
12use crate::projects::resolve_or_create_project_in_workspace;
13use crate::refs::get_task_in_workspace;
14use crate::types::Task;
15use crate::undo::{
16    TaskUndoSnapshot, UndoCommand, UndoContext, UndoPayload, record_tui_undo, task_snapshot,
17};
18use crate::workspaces::Workspace;
19use anyhow::{Result, bail};
20use sqlx::SqliteConnection;
21use std::collections::BTreeSet;
22use tracing::info;
23
24#[derive(Clone, Copy, PartialEq, Eq)]
25enum TaskMutationDetail {
26    Report,
27    Compact,
28}
29
30struct TaskMutationExecutionOutcome {
31    task: Task,
32    before: Option<TaskUndoSnapshot>,
33    after: Option<TaskUndoSnapshot>,
34    changed: bool,
35}
36
37impl Database {
38    pub async fn update_task(
39        &self,
40        workspace: &Workspace,
41        task_id: &TaskId,
42        update: TaskUpdate,
43    ) -> Result<TaskUpdateOutcome> {
44        let outcomes = self
45            .mutate_tasks_owned(
46                workspace,
47                vec![(task_id.clone(), update)],
48                UndoContext::None,
49                TaskMutationDetail::Compact,
50            )
51            .await?;
52        let outcome = outcomes.into_iter().next().unwrap();
53        Ok(TaskUpdateOutcome {
54            task: outcome.task,
55            changed: outcome.changed,
56        })
57    }
58
59    pub async fn update_tasks(
60        &self,
61        workspace: &Workspace,
62        updates: Vec<(TaskId, TaskUpdate)>,
63    ) -> Result<Vec<TaskUpdateOutcome>> {
64        Ok(self
65            .mutate_tasks_owned(
66                workspace,
67                updates,
68                UndoContext::None,
69                TaskMutationDetail::Compact,
70            )
71            .await?
72            .into_iter()
73            .map(|outcome| TaskUpdateOutcome {
74                task: outcome.task,
75                changed: outcome.changed,
76            })
77            .collect())
78    }
79
80    pub async fn mutate_tasks(
81        &self,
82        workspace: &Workspace,
83        updates: Vec<(TaskId, TaskUpdate)>,
84        undo: UndoContext,
85    ) -> Result<TaskMutationReport> {
86        let outcomes = self
87            .mutate_tasks_owned(workspace, updates, undo, TaskMutationDetail::Report)
88            .await?;
89        Ok(TaskMutationReport {
90            outcomes: outcomes
91                .into_iter()
92                .map(|outcome| TaskMutationOutcome {
93                    task: outcome.task,
94                    before: outcome.before.expect("report mutation before snapshot"),
95                    after: outcome.after.expect("report mutation after snapshot"),
96                    changed: outcome.changed,
97                })
98                .collect(),
99        })
100    }
101
102    async fn mutate_tasks_owned(
103        &self,
104        workspace: &Workspace,
105        updates: Vec<(TaskId, TaskUpdate)>,
106        undo: UndoContext,
107        detail: TaskMutationDetail,
108    ) -> Result<Vec<TaskMutationExecutionOutcome>> {
109        for (_, update) in &updates {
110            validate_task_update(update)?;
111        }
112        if updates.is_empty() {
113            return Ok(Vec::new());
114        }
115        if detail == TaskMutationDetail::Compact && !matches!(&undo, UndoContext::None) {
116            bail!("compact task mutations cannot record undo");
117        }
118
119        let mut conn = self.acquire_writer().await?;
120        let mut tx = begin_immediate(&mut conn).await?;
121        let mut outcomes = Vec::with_capacity(updates.len());
122        let mut affected_attachment_hashes = BTreeSet::new();
123        let mut undo_commands = Vec::new();
124        let mut created_labels = Vec::new();
125        for (task_id, update) in &updates {
126            let before =
127                if detail == TaskMutationDetail::Report || task_update_requires_snapshot(update) {
128                    Some(task_snapshot(&mut tx, &workspace.id, task_id).await?)
129                } else {
130                    None
131                };
132            let update = match before.as_ref() {
133                Some(before) => materialize_task_update(update, before)?,
134                None => update.clone(),
135            };
136            let (changed, task_created_labels) = apply_task_update(
137                &mut tx,
138                workspace,
139                task_id,
140                &update,
141                &mut affected_attachment_hashes,
142            )
143            .await?;
144            created_labels.extend(task_created_labels);
145            let task = get_task_in_workspace(&mut tx, workspace, task_id).await?;
146            let after = if detail == TaskMutationDetail::Report {
147                Some(task_snapshot(&mut tx, &workspace.id, task_id).await?)
148            } else {
149                None
150            };
151            if let (Some(before), Some(after)) = (&before, &after) {
152                append_task_undo_commands(task_id, before, after, &mut undo_commands);
153            }
154            outcomes.push(TaskMutationExecutionOutcome {
155                task,
156                changed,
157                before,
158                after,
159            });
160        }
161        let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
162        crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
163            &mut tx,
164            &affected_attachment_hashes,
165            &crate::attachments::lifecycle::SystemClock,
166        )
167        .await?;
168        append_created_label_undo_commands(&mut undo_commands, &created_labels);
169        if matches!(undo, UndoContext::Tui) {
170            record_tui_undo(
171                &mut tx,
172                &workspace.id,
173                UndoPayload {
174                    commands: undo_commands,
175                },
176            )
177            .await?;
178        }
179        tx.commit().await?;
180
181        for outcome in &outcomes {
182            info!(task_id = %outcome.task.id, changed = outcome.changed, "task updated");
183        }
184        Ok(outcomes)
185    }
186
187    pub async fn set_task_deleted(
188        &self,
189        workspace: &Workspace,
190        task_id: &TaskId,
191        deleted: bool,
192    ) -> Result<TaskOutcome> {
193        let outcome = self
194            .update_task(
195                workspace,
196                task_id,
197                TaskUpdate {
198                    deleted: Some(deleted),
199                    ..TaskUpdate::default()
200                },
201            )
202            .await?;
203        Ok(TaskOutcome {
204            task: outcome.task,
205            create_change_id: None,
206            attachment_change_ids: Vec::new(),
207            undo_snapshot: None,
208        })
209    }
210}
211
212#[cfg(any(test, feature = "test-support"))]
213pub(crate) async fn update_task(
214    conn: &mut SqliteConnection,
215    workspace: &Workspace,
216    task_id: &crate::ids::TaskId,
217    update: TaskUpdate,
218) -> Result<TaskUpdateOutcome> {
219    validate_task_update(&update)?;
220    let mut tx = begin_immediate(conn).await?;
221    let mut affected_attachment_hashes = BTreeSet::new();
222    let (changed, _) = apply_task_update(
223        &mut tx,
224        workspace,
225        task_id,
226        &update,
227        &mut affected_attachment_hashes,
228    )
229    .await?;
230    let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
231    crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
232        &mut tx,
233        &affected_attachment_hashes,
234        &crate::attachments::lifecycle::SystemClock,
235    )
236    .await?;
237    tx.commit().await?;
238    Ok(TaskUpdateOutcome {
239        task: get_task_in_workspace(conn, workspace, task_id).await?,
240        changed,
241    })
242}
243
244pub(super) fn append_task_undo_commands(
245    task_id: &TaskId,
246    before_snapshot: &TaskUndoSnapshot,
247    after_snapshot: &TaskUndoSnapshot,
248    commands: &mut Vec<UndoCommand>,
249) {
250    let before = before_snapshot;
251    let after = after_snapshot;
252    let fields = [
253        ("title", before.title.as_str(), after.title.as_str()),
254        (
255            "description",
256            before.description.as_str(),
257            after.description.as_str(),
258        ),
259        (
260            "project",
261            before.project_id.as_str(),
262            after.project_id.as_str(),
263        ),
264        ("status", before.status.as_str(), after.status.as_str()),
265        (
266            "priority",
267            before.priority.as_str(),
268            after.priority.as_str(),
269        ),
270        (
271            "available_at",
272            before.available_at.as_str(),
273            after.available_at.as_str(),
274        ),
275        ("due_on", before.due_on.as_str(), after.due_on.as_str()),
276    ];
277    for (field, before, after) in fields {
278        if before != after {
279            commands.push(UndoCommand::SetTaskField {
280                task_id: task_id.clone(),
281                field: field.to_string(),
282                before: before.to_string(),
283                after: after.to_string(),
284                queue_activity_before: (before_snapshot.queue_activity_at
285                    != after_snapshot.queue_activity_at)
286                    .then(|| before_snapshot.queue_activity_at.clone()),
287                queue_activity_after: (before_snapshot.queue_activity_at
288                    != after_snapshot.queue_activity_at)
289                    .then(|| after_snapshot.queue_activity_at.clone()),
290            });
291        }
292    }
293    let before_deleted = if before.deleted { "1" } else { "0" };
294    let after_deleted = if after.deleted { "1" } else { "0" };
295    if before_deleted != after_deleted {
296        commands.push(UndoCommand::SetTaskField {
297            task_id: task_id.clone(),
298            field: "deleted".to_string(),
299            before: before_deleted.to_string(),
300            after: after_deleted.to_string(),
301            queue_activity_before: (before.queue_activity_at != after.queue_activity_at)
302                .then(|| before.queue_activity_at.clone()),
303            queue_activity_after: (before.queue_activity_at != after.queue_activity_at)
304                .then(|| after.queue_activity_at.clone()),
305        });
306    }
307    let before_is_epic = if before.is_epic { "1" } else { "0" };
308    let after_is_epic = if after.is_epic { "1" } else { "0" };
309    if before_is_epic != after_is_epic {
310        commands.push(UndoCommand::SetTaskField {
311            task_id: task_id.clone(),
312            field: "is_epic".to_string(),
313            before: before_is_epic.to_string(),
314            after: after_is_epic.to_string(),
315            queue_activity_before: None,
316            queue_activity_after: None,
317        });
318    }
319    if before.labels != after.labels {
320        commands.push(UndoCommand::SetTaskLabels {
321            task_id: task_id.clone(),
322            before: before.labels.clone(),
323            after: after.labels.clone(),
324        });
325    }
326    let metadata_fields = before
327        .metadata
328        .keys()
329        .chain(after.metadata.keys())
330        .collect::<BTreeSet<_>>();
331    for field_id in metadata_fields {
332        let before_value = before.metadata.get(field_id);
333        let after_value = after.metadata.get(field_id);
334        if before_value != after_value {
335            commands.push(UndoCommand::SetTaskMetadata {
336                task_id: task_id.clone(),
337                field_id: field_id
338                    .parse()
339                    .expect("task snapshots contain valid metadata field IDs"),
340                before: before_value.cloned(),
341                after: after_value.cloned(),
342            });
343        }
344    }
345}
346
347fn cycled_priority(priority: TaskPriority, reverse: bool) -> TaskPriority {
348    let index = TaskPriority::ALL
349        .iter()
350        .position(|candidate| *candidate == priority)
351        .unwrap_or(0);
352    let next = if reverse {
353        (index + TaskPriority::ALL.len() - 1) % TaskPriority::ALL.len()
354    } else {
355        (index + 1) % TaskPriority::ALL.len()
356    };
357    TaskPriority::ALL[next]
358}
359
360fn task_update_requires_snapshot(update: &TaskUpdate) -> bool {
361    update.cycle_priority.is_some() || update.label_selection.is_some()
362}
363
364fn materialize_task_update(update: &TaskUpdate, before: &TaskUndoSnapshot) -> Result<TaskUpdate> {
365    let mut update = update.clone();
366    if let Some(reverse) = update.cycle_priority.take() {
367        let priority = TaskPriority::parse(&before.priority)?;
368        update.priority = Some(cycled_priority(priority, reverse).to_string());
369    }
370    if let Some(selection) = update.label_selection.take() {
371        let selected = selection.selected.iter().cloned().collect::<BTreeSet<_>>();
372        let partial = selection.partial.iter().cloned().collect::<BTreeSet<_>>();
373        update.add_labels = selection
374            .selected
375            .into_iter()
376            .filter(|label| !before.labels.contains(label))
377            .collect();
378        update.remove_labels = before
379            .labels
380            .iter()
381            .filter(|label| !selected.contains(label.as_str()) && !partial.contains(label.as_str()))
382            .cloned()
383            .collect();
384    }
385    Ok(update)
386}
387
388pub(super) fn validate_task_update(update: &TaskUpdate) -> Result<()> {
389    crate::metadata::validate_metadata_update(&update.set_metadata, &update.remove_metadata)?;
390    if update.priority.is_some() && update.cycle_priority.is_some() {
391        bail!("error invalid-task-update priority and priority cycle are mutually exclusive");
392    }
393    if update.label_selection.is_some()
394        && (!update.add_labels.is_empty() || !update.remove_labels.is_empty())
395    {
396        bail!("error invalid-task-update label selection and label deltas are mutually exclusive");
397    }
398    if let Some(status) = update.status.as_deref() {
399        TaskStatus::parse(status)?;
400    }
401    if let Some(priority) = update.priority.as_deref() {
402        TaskPriority::parse(priority)?;
403    }
404    if let Some(Some(available_at)) = update.available_at.as_ref() {
405        crate::time_validation::validate_available_at_value(available_at)?;
406    }
407    if let Some(Some(due_on)) = update.due_on.as_ref() {
408        crate::time_validation::validate_due_on_value(due_on)?;
409    }
410    Ok(())
411}
412
413pub(super) fn update_requests_inbox_promotion(update: &TaskUpdate) -> bool {
414    update
415        .status
416        .as_deref()
417        .is_none_or(|status| status == TaskStatus::Inbox.as_str())
418        && update.priority.as_deref().is_some_and(|priority| {
419            TaskPriority::parse(priority).is_ok_and(TaskPriority::promotes_inbox_to_todo)
420        })
421}
422
423pub(super) async fn apply_task_update(
424    conn: &mut SqliteConnection,
425    workspace: &Workspace,
426    task_id: &crate::ids::TaskId,
427    update: &TaskUpdate,
428    affected_attachment_hashes: &mut BTreeSet<String>,
429) -> Result<(bool, Vec<CreatedLabel>)> {
430    let created_labels = if update.create_missing_labels {
431        resolve_or_create_labels_in_workspace(conn, workspace, &update.add_labels)
432            .await?
433            .created
434    } else {
435        Vec::new()
436    };
437    let promote_inbox = if update_requests_inbox_promotion(update) {
438        get_task_in_workspace(conn, workspace, task_id)
439            .await?
440            .status
441            == TaskStatus::Inbox
442    } else {
443        false
444    };
445    let mut changed = false;
446    if let Some(title) = update.title.as_deref() {
447        changed |= update_task_field(conn, workspace, task_id, "title", title).await?;
448    }
449    if let Some(description) = update.description.as_deref() {
450        changed |= update_task_field(conn, workspace, task_id, "description", description).await?;
451    }
452    if let Some(project) = update.project.as_deref() {
453        let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
454        changed |= set_task_project(conn, workspace, task_id, &project).await?;
455    }
456    if promote_inbox {
457        changed |= update_task_field(conn, workspace, task_id, "status", "todo").await?;
458    } else if let Some(status) = update.status.as_deref() {
459        changed |= update_task_field(conn, workspace, task_id, "status", status).await?;
460    }
461    if let Some(priority) = update.priority.as_deref() {
462        changed |= update_task_field(conn, workspace, task_id, "priority", priority).await?;
463    }
464    if let Some(available_at) = update.available_at.as_ref() {
465        changed |= update_task_field(
466            conn,
467            workspace,
468            task_id,
469            "available_at",
470            available_at.as_deref().unwrap_or(""),
471        )
472        .await?;
473    }
474    if let Some(due_on) = update.due_on.as_ref() {
475        changed |= update_task_field(
476            conn,
477            workspace,
478            task_id,
479            "due_on",
480            due_on.as_deref().unwrap_or(""),
481        )
482        .await?;
483    }
484    if update.deleted.is_some() {
485        let attachment_hashes: Vec<String> = sqlx::query_scalar(
486            "SELECT DISTINCT sha256 FROM task_attachments
487             WHERE workspace_id = ? AND task_id = ?",
488        )
489        .bind(&workspace.id)
490        .bind(task_id)
491        .fetch_all(&mut *conn)
492        .await?;
493        affected_attachment_hashes.extend(attachment_hashes);
494    }
495    if let Some(deleted) = update.deleted {
496        changed |= update_task_field(
497            conn,
498            workspace,
499            task_id,
500            "deleted",
501            if deleted { "1" } else { "0" },
502        )
503        .await?;
504    }
505    if let Some(is_epic) = update.is_epic {
506        if !is_epic {
507            let task = get_task_in_workspace(conn, workspace, task_id).await?;
508            if crate::operations::epics::task_has_epic_children(conn, &task.workspace_id, task_id)
509                .await?
510            {
511                bail!("error epic-has-children task_id={task_id}");
512            }
513        }
514        changed |= update_task_field(
515            conn,
516            workspace,
517            task_id,
518            "is_epic",
519            if is_epic { "1" } else { "0" },
520        )
521        .await?;
522    }
523    changed |= update_task_labels_in_workspace(
524        conn,
525        &workspace.id,
526        task_id,
527        &update.add_labels,
528        &update.remove_labels,
529    )
530    .await?;
531    for (id, key) in &update.require_metadata_fields {
532        crate::metadata::require_metadata_field(conn, &workspace.id, id, key).await?;
533    }
534    crate::metadata::validate_task_metadata_result(
535        conn,
536        &workspace.id,
537        task_id,
538        &update.set_metadata,
539        &update.remove_metadata,
540    )
541    .await?;
542    for input in &update.set_metadata {
543        changed |= crate::metadata::set_task_metadata(conn, workspace, task_id, input).await?;
544    }
545    for key in &update.remove_metadata {
546        changed |= crate::metadata::remove_task_metadata(conn, workspace, task_id, key).await?;
547    }
548    Ok((changed, created_labels))
549}
550
551pub(crate) async fn update_task_field(
552    conn: &mut SqliteConnection,
553    workspace: &Workspace,
554    task_id: &crate::ids::TaskId,
555    field: &str,
556    value: &str,
557) -> Result<bool> {
558    set_task_field(conn, workspace, task_id, field, value).await
559}
560
561pub async fn update_task_labels_in_workspace(
562    conn: &mut SqliteConnection,
563    workspace_id: &WorkspaceId,
564    task_id: &crate::ids::TaskId,
565    add_labels: &[String],
566    remove_labels: &[String],
567) -> Result<bool> {
568    let mutation_at = now();
569    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
570    crate::operations::route_recurrence_task_mutation(
571        conn,
572        &workspace,
573        task_id,
574        RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Labels),
575        &mutation_at,
576    )
577    .await?;
578    let mut changed = false;
579    for label in resolve_labels_in_workspace(conn, &workspace.id, add_labels).await? {
580        let rows_affected = sqlx::query(
581            "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
582        )
583        .bind(&workspace.id)
584        .bind(task_id)
585        .bind(&label)
586        .execute(&mut *conn)
587        .await?
588        .rows_affected();
589        if rows_affected > 0 {
590            append_change(
591                conn,
592                ChangeEntity::Task,
593                task_id,
594                Some("labels"),
595                op_type::LABEL_ADD,
596                ChangePayload::workspace(&workspace).set("label", label),
597            )
598            .await?;
599            changed = true;
600        }
601    }
602    for label in resolve_labels_in_workspace(conn, &workspace.id, remove_labels).await? {
603        let rows_affected = sqlx::query(
604            "DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ? AND label = ?",
605        )
606        .bind(&workspace.id)
607        .bind(task_id)
608        .bind(&label)
609        .execute(&mut *conn)
610        .await?
611        .rows_affected();
612        if rows_affected > 0 {
613            append_change(
614                conn,
615                ChangeEntity::Task,
616                task_id,
617                Some("labels"),
618                op_type::LABEL_REMOVE,
619                ChangePayload::workspace(&workspace).set("label", label),
620            )
621            .await?;
622            changed = true;
623        }
624    }
625    if changed {
626        info!(
627            task_id = %task_id,
628            added = add_labels.len(),
629            removed = remove_labels.len(),
630            "task labels changed"
631        );
632    }
633    Ok(changed)
634}