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        let changed_count = outcomes.iter().filter(|outcome| outcome.changed).count();
169        append_created_label_undo_commands(&mut undo_commands, &created_labels);
170        if let Some(summary) = undo.task_mutation_summary(changed_count) {
171            record_tui_undo(
172                &mut tx,
173                &workspace.id,
174                &summary,
175                UndoPayload {
176                    commands: undo_commands,
177                },
178            )
179            .await?;
180        }
181        tx.commit().await?;
182
183        for outcome in &outcomes {
184            info!(task_id = %outcome.task.id, changed = outcome.changed, "task updated");
185        }
186        Ok(outcomes)
187    }
188
189    pub async fn set_task_deleted(
190        &self,
191        workspace: &Workspace,
192        task_id: &TaskId,
193        deleted: bool,
194    ) -> Result<TaskOutcome> {
195        let outcome = self
196            .update_task(
197                workspace,
198                task_id,
199                TaskUpdate {
200                    deleted: Some(deleted),
201                    ..TaskUpdate::default()
202                },
203            )
204            .await?;
205        Ok(TaskOutcome {
206            task: outcome.task,
207            create_change_id: None,
208            attachment_change_ids: Vec::new(),
209            undo_snapshot: None,
210        })
211    }
212}
213
214#[cfg(any(test, feature = "test-support"))]
215pub(crate) async fn update_task(
216    conn: &mut SqliteConnection,
217    workspace: &Workspace,
218    task_id: &crate::ids::TaskId,
219    update: TaskUpdate,
220) -> Result<TaskUpdateOutcome> {
221    validate_task_update(&update)?;
222    let mut tx = begin_immediate(conn).await?;
223    let mut affected_attachment_hashes = BTreeSet::new();
224    let (changed, _) = apply_task_update(
225        &mut tx,
226        workspace,
227        task_id,
228        &update,
229        &mut affected_attachment_hashes,
230    )
231    .await?;
232    let affected_attachment_hashes = affected_attachment_hashes.into_iter().collect::<Vec<_>>();
233    crate::attachments::lifecycle::reconcile_liveness_for_hashes_in_transaction(
234        &mut tx,
235        &affected_attachment_hashes,
236        &crate::attachments::lifecycle::SystemClock,
237    )
238    .await?;
239    tx.commit().await?;
240    Ok(TaskUpdateOutcome {
241        task: get_task_in_workspace(conn, workspace, task_id).await?,
242        changed,
243    })
244}
245
246fn append_task_undo_commands(
247    task_id: &TaskId,
248    before_snapshot: &TaskUndoSnapshot,
249    after_snapshot: &TaskUndoSnapshot,
250    commands: &mut Vec<UndoCommand>,
251) {
252    let before = before_snapshot;
253    let after = after_snapshot;
254    let fields = [
255        ("title", before.title.as_str(), after.title.as_str()),
256        (
257            "description",
258            before.description.as_str(),
259            after.description.as_str(),
260        ),
261        (
262            "project",
263            before.project_id.as_str(),
264            after.project_id.as_str(),
265        ),
266        ("status", before.status.as_str(), after.status.as_str()),
267        (
268            "priority",
269            before.priority.as_str(),
270            after.priority.as_str(),
271        ),
272        (
273            "available_at",
274            before.available_at.as_str(),
275            after.available_at.as_str(),
276        ),
277        ("due_on", before.due_on.as_str(), after.due_on.as_str()),
278    ];
279    for (field, before, after) in fields {
280        if before != after {
281            commands.push(UndoCommand::SetTaskField {
282                task_id: task_id.clone(),
283                field: field.to_string(),
284                before: before.to_string(),
285                after: after.to_string(),
286                queue_activity_before: (before_snapshot.queue_activity_at
287                    != after_snapshot.queue_activity_at)
288                    .then(|| before_snapshot.queue_activity_at.clone()),
289                queue_activity_after: (before_snapshot.queue_activity_at
290                    != after_snapshot.queue_activity_at)
291                    .then(|| after_snapshot.queue_activity_at.clone()),
292            });
293        }
294    }
295    let before_deleted = if before.deleted { "1" } else { "0" };
296    let after_deleted = if after.deleted { "1" } else { "0" };
297    if before_deleted != after_deleted {
298        commands.push(UndoCommand::SetTaskField {
299            task_id: task_id.clone(),
300            field: "deleted".to_string(),
301            before: before_deleted.to_string(),
302            after: after_deleted.to_string(),
303            queue_activity_before: (before.queue_activity_at != after.queue_activity_at)
304                .then(|| before.queue_activity_at.clone()),
305            queue_activity_after: (before.queue_activity_at != after.queue_activity_at)
306                .then(|| after.queue_activity_at.clone()),
307        });
308    }
309    let before_is_epic = if before.is_epic { "1" } else { "0" };
310    let after_is_epic = if after.is_epic { "1" } else { "0" };
311    if before_is_epic != after_is_epic {
312        commands.push(UndoCommand::SetTaskField {
313            task_id: task_id.clone(),
314            field: "is_epic".to_string(),
315            before: before_is_epic.to_string(),
316            after: after_is_epic.to_string(),
317            queue_activity_before: None,
318            queue_activity_after: None,
319        });
320    }
321    if before.labels != after.labels {
322        commands.push(UndoCommand::SetTaskLabels {
323            task_id: task_id.clone(),
324            before: before.labels.clone(),
325            after: after.labels.clone(),
326        });
327    }
328    let metadata_fields = before
329        .metadata
330        .keys()
331        .chain(after.metadata.keys())
332        .collect::<BTreeSet<_>>();
333    for field_id in metadata_fields {
334        let before_value = before.metadata.get(field_id);
335        let after_value = after.metadata.get(field_id);
336        if before_value != after_value {
337            commands.push(UndoCommand::SetTaskMetadata {
338                task_id: task_id.clone(),
339                field_id: field_id
340                    .parse()
341                    .expect("task snapshots contain valid metadata field IDs"),
342                before: before_value.cloned(),
343                after: after_value.cloned(),
344            });
345        }
346    }
347}
348
349fn cycled_priority(priority: TaskPriority, reverse: bool) -> TaskPriority {
350    let index = TaskPriority::ALL
351        .iter()
352        .position(|candidate| *candidate == priority)
353        .unwrap_or(0);
354    let next = if reverse {
355        (index + TaskPriority::ALL.len() - 1) % TaskPriority::ALL.len()
356    } else {
357        (index + 1) % TaskPriority::ALL.len()
358    };
359    TaskPriority::ALL[next]
360}
361
362fn task_update_requires_snapshot(update: &TaskUpdate) -> bool {
363    update.cycle_priority.is_some() || update.label_selection.is_some()
364}
365
366fn materialize_task_update(update: &TaskUpdate, before: &TaskUndoSnapshot) -> Result<TaskUpdate> {
367    let mut update = update.clone();
368    if let Some(reverse) = update.cycle_priority.take() {
369        let priority = TaskPriority::parse(&before.priority)?;
370        update.priority = Some(cycled_priority(priority, reverse).to_string());
371    }
372    if let Some(selection) = update.label_selection.take() {
373        let selected = selection.selected.iter().cloned().collect::<BTreeSet<_>>();
374        let partial = selection.partial.iter().cloned().collect::<BTreeSet<_>>();
375        update.add_labels = selection
376            .selected
377            .into_iter()
378            .filter(|label| !before.labels.contains(label))
379            .collect();
380        update.remove_labels = before
381            .labels
382            .iter()
383            .filter(|label| !selected.contains(label.as_str()) && !partial.contains(label.as_str()))
384            .cloned()
385            .collect();
386    }
387    Ok(update)
388}
389
390pub(super) fn validate_task_update(update: &TaskUpdate) -> Result<()> {
391    crate::metadata::validate_metadata_update(&update.set_metadata, &update.remove_metadata)?;
392    if update.priority.is_some() && update.cycle_priority.is_some() {
393        bail!("error invalid-task-update priority and priority cycle are mutually exclusive");
394    }
395    if update.label_selection.is_some()
396        && (!update.add_labels.is_empty() || !update.remove_labels.is_empty())
397    {
398        bail!("error invalid-task-update label selection and label deltas are mutually exclusive");
399    }
400    if let Some(status) = update.status.as_deref() {
401        TaskStatus::parse(status)?;
402    }
403    if let Some(priority) = update.priority.as_deref() {
404        TaskPriority::parse(priority)?;
405    }
406    if let Some(Some(available_at)) = update.available_at.as_ref() {
407        crate::time_validation::validate_available_at_value(available_at)?;
408    }
409    if let Some(Some(due_on)) = update.due_on.as_ref() {
410        crate::time_validation::validate_due_on_value(due_on)?;
411    }
412    Ok(())
413}
414
415pub(super) async fn apply_task_update(
416    conn: &mut SqliteConnection,
417    workspace: &Workspace,
418    task_id: &crate::ids::TaskId,
419    update: &TaskUpdate,
420    affected_attachment_hashes: &mut BTreeSet<String>,
421) -> Result<(bool, Vec<CreatedLabel>)> {
422    let created_labels = if update.create_missing_labels {
423        resolve_or_create_labels_in_workspace(conn, workspace, &update.add_labels)
424            .await?
425            .created
426    } else {
427        Vec::new()
428    };
429    let promote_inbox = if update
430        .status
431        .as_deref()
432        .is_none_or(|status| status == TaskStatus::Inbox.as_str())
433        && update.priority.as_deref().is_some_and(|priority| {
434            TaskPriority::parse(priority).is_ok_and(TaskPriority::promotes_inbox_to_todo)
435        }) {
436        get_task_in_workspace(conn, workspace, task_id)
437            .await?
438            .status
439            == TaskStatus::Inbox
440    } else {
441        false
442    };
443    let mut changed = false;
444    if let Some(title) = update.title.as_deref() {
445        changed |= update_task_field(conn, workspace, task_id, "title", title).await?;
446    }
447    if let Some(description) = update.description.as_deref() {
448        changed |= update_task_field(conn, workspace, task_id, "description", description).await?;
449    }
450    if let Some(project) = update.project.as_deref() {
451        let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
452        changed |= set_task_project(conn, workspace, task_id, &project).await?;
453    }
454    if promote_inbox {
455        changed |= update_task_field(conn, workspace, task_id, "status", "todo").await?;
456    } else if let Some(status) = update.status.as_deref() {
457        changed |= update_task_field(conn, workspace, task_id, "status", status).await?;
458    }
459    if let Some(priority) = update.priority.as_deref() {
460        changed |= update_task_field(conn, workspace, task_id, "priority", priority).await?;
461    }
462    if let Some(available_at) = update.available_at.as_ref() {
463        changed |= update_task_field(
464            conn,
465            workspace,
466            task_id,
467            "available_at",
468            available_at.as_deref().unwrap_or(""),
469        )
470        .await?;
471    }
472    if let Some(due_on) = update.due_on.as_ref() {
473        changed |= update_task_field(
474            conn,
475            workspace,
476            task_id,
477            "due_on",
478            due_on.as_deref().unwrap_or(""),
479        )
480        .await?;
481    }
482    if update.deleted.is_some() {
483        let attachment_hashes: Vec<String> = sqlx::query_scalar(
484            "SELECT DISTINCT sha256 FROM task_attachments
485             WHERE workspace_id = ? AND task_id = ?",
486        )
487        .bind(&workspace.id)
488        .bind(task_id)
489        .fetch_all(&mut *conn)
490        .await?;
491        affected_attachment_hashes.extend(attachment_hashes);
492    }
493    if let Some(deleted) = update.deleted {
494        changed |= update_task_field(
495            conn,
496            workspace,
497            task_id,
498            "deleted",
499            if deleted { "1" } else { "0" },
500        )
501        .await?;
502    }
503    if let Some(is_epic) = update.is_epic {
504        if !is_epic {
505            let task = get_task_in_workspace(conn, workspace, task_id).await?;
506            if crate::operations::epics::task_has_epic_children(conn, &task.workspace_id, task_id)
507                .await?
508            {
509                bail!("error epic-has-children task_id={task_id}");
510            }
511        }
512        changed |= update_task_field(
513            conn,
514            workspace,
515            task_id,
516            "is_epic",
517            if is_epic { "1" } else { "0" },
518        )
519        .await?;
520    }
521    changed |= update_task_labels_in_workspace(
522        conn,
523        &workspace.id,
524        task_id,
525        &update.add_labels,
526        &update.remove_labels,
527    )
528    .await?;
529    for (id, key) in &update.require_metadata_fields {
530        crate::metadata::require_metadata_field(conn, &workspace.id, id, key).await?;
531    }
532    crate::metadata::validate_task_metadata_result(
533        conn,
534        &workspace.id,
535        task_id,
536        &update.set_metadata,
537        &update.remove_metadata,
538    )
539    .await?;
540    for input in &update.set_metadata {
541        changed |= crate::metadata::set_task_metadata(conn, workspace, task_id, input).await?;
542    }
543    for key in &update.remove_metadata {
544        changed |= crate::metadata::remove_task_metadata(conn, workspace, task_id, key).await?;
545    }
546    Ok((changed, created_labels))
547}
548
549pub(crate) async fn update_task_field(
550    conn: &mut SqliteConnection,
551    workspace: &Workspace,
552    task_id: &crate::ids::TaskId,
553    field: &str,
554    value: &str,
555) -> Result<bool> {
556    set_task_field(conn, workspace, task_id, field, value).await
557}
558
559pub async fn update_task_labels_in_workspace(
560    conn: &mut SqliteConnection,
561    workspace_id: &WorkspaceId,
562    task_id: &crate::ids::TaskId,
563    add_labels: &[String],
564    remove_labels: &[String],
565) -> Result<bool> {
566    let mutation_at = now();
567    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
568    crate::operations::route_recurrence_task_mutation(
569        conn,
570        &workspace,
571        task_id,
572        RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Labels),
573        &mutation_at,
574    )
575    .await?;
576    let mut changed = false;
577    for label in resolve_labels_in_workspace(conn, &workspace.id, add_labels).await? {
578        let rows_affected = sqlx::query(
579            "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
580        )
581        .bind(&workspace.id)
582        .bind(task_id)
583        .bind(&label)
584        .execute(&mut *conn)
585        .await?
586        .rows_affected();
587        if rows_affected > 0 {
588            append_change(
589                conn,
590                ChangeEntity::Task,
591                task_id,
592                Some("labels"),
593                op_type::LABEL_ADD,
594                ChangePayload::workspace(&workspace).set("label", label),
595            )
596            .await?;
597            changed = true;
598        }
599    }
600    for label in resolve_labels_in_workspace(conn, &workspace.id, remove_labels).await? {
601        let rows_affected = sqlx::query(
602            "DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ? AND label = ?",
603        )
604        .bind(&workspace.id)
605        .bind(task_id)
606        .bind(&label)
607        .execute(&mut *conn)
608        .await?
609        .rows_affected();
610        if rows_affected > 0 {
611            append_change(
612                conn,
613                ChangeEntity::Task,
614                task_id,
615                Some("labels"),
616                op_type::LABEL_REMOVE,
617                ChangePayload::workspace(&workspace).set("label", label),
618            )
619            .await?;
620            changed = true;
621        }
622    }
623    if changed {
624        info!(
625            task_id = %task_id,
626            added = add_labels.len(),
627            removed = remove_labels.len(),
628            "task labels changed"
629        );
630    }
631    Ok(changed)
632}