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
244fn 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) async fn apply_task_update(
414    conn: &mut SqliteConnection,
415    workspace: &Workspace,
416    task_id: &crate::ids::TaskId,
417    update: &TaskUpdate,
418    affected_attachment_hashes: &mut BTreeSet<String>,
419) -> Result<(bool, Vec<CreatedLabel>)> {
420    let created_labels = if update.create_missing_labels {
421        resolve_or_create_labels_in_workspace(conn, workspace, &update.add_labels)
422            .await?
423            .created
424    } else {
425        Vec::new()
426    };
427    let promote_inbox = if update
428        .status
429        .as_deref()
430        .is_none_or(|status| status == TaskStatus::Inbox.as_str())
431        && update.priority.as_deref().is_some_and(|priority| {
432            TaskPriority::parse(priority).is_ok_and(TaskPriority::promotes_inbox_to_todo)
433        }) {
434        get_task_in_workspace(conn, workspace, task_id)
435            .await?
436            .status
437            == TaskStatus::Inbox
438    } else {
439        false
440    };
441    let mut changed = false;
442    if let Some(title) = update.title.as_deref() {
443        changed |= update_task_field(conn, workspace, task_id, "title", title).await?;
444    }
445    if let Some(description) = update.description.as_deref() {
446        changed |= update_task_field(conn, workspace, task_id, "description", description).await?;
447    }
448    if let Some(project) = update.project.as_deref() {
449        let project = resolve_or_create_project_in_workspace(conn, &workspace.id, project).await?;
450        changed |= set_task_project(conn, workspace, task_id, &project).await?;
451    }
452    if promote_inbox {
453        changed |= update_task_field(conn, workspace, task_id, "status", "todo").await?;
454    } else if let Some(status) = update.status.as_deref() {
455        changed |= update_task_field(conn, workspace, task_id, "status", status).await?;
456    }
457    if let Some(priority) = update.priority.as_deref() {
458        changed |= update_task_field(conn, workspace, task_id, "priority", priority).await?;
459    }
460    if let Some(available_at) = update.available_at.as_ref() {
461        changed |= update_task_field(
462            conn,
463            workspace,
464            task_id,
465            "available_at",
466            available_at.as_deref().unwrap_or(""),
467        )
468        .await?;
469    }
470    if let Some(due_on) = update.due_on.as_ref() {
471        changed |= update_task_field(
472            conn,
473            workspace,
474            task_id,
475            "due_on",
476            due_on.as_deref().unwrap_or(""),
477        )
478        .await?;
479    }
480    if update.deleted.is_some() {
481        let attachment_hashes: Vec<String> = sqlx::query_scalar(
482            "SELECT DISTINCT sha256 FROM task_attachments
483             WHERE workspace_id = ? AND task_id = ?",
484        )
485        .bind(&workspace.id)
486        .bind(task_id)
487        .fetch_all(&mut *conn)
488        .await?;
489        affected_attachment_hashes.extend(attachment_hashes);
490    }
491    if let Some(deleted) = update.deleted {
492        changed |= update_task_field(
493            conn,
494            workspace,
495            task_id,
496            "deleted",
497            if deleted { "1" } else { "0" },
498        )
499        .await?;
500    }
501    if let Some(is_epic) = update.is_epic {
502        if !is_epic {
503            let task = get_task_in_workspace(conn, workspace, task_id).await?;
504            if crate::operations::epics::task_has_epic_children(conn, &task.workspace_id, task_id)
505                .await?
506            {
507                bail!("error epic-has-children task_id={task_id}");
508            }
509        }
510        changed |= update_task_field(
511            conn,
512            workspace,
513            task_id,
514            "is_epic",
515            if is_epic { "1" } else { "0" },
516        )
517        .await?;
518    }
519    changed |= update_task_labels_in_workspace(
520        conn,
521        &workspace.id,
522        task_id,
523        &update.add_labels,
524        &update.remove_labels,
525    )
526    .await?;
527    for (id, key) in &update.require_metadata_fields {
528        crate::metadata::require_metadata_field(conn, &workspace.id, id, key).await?;
529    }
530    crate::metadata::validate_task_metadata_result(
531        conn,
532        &workspace.id,
533        task_id,
534        &update.set_metadata,
535        &update.remove_metadata,
536    )
537    .await?;
538    for input in &update.set_metadata {
539        changed |= crate::metadata::set_task_metadata(conn, workspace, task_id, input).await?;
540    }
541    for key in &update.remove_metadata {
542        changed |= crate::metadata::remove_task_metadata(conn, workspace, task_id, key).await?;
543    }
544    Ok((changed, created_labels))
545}
546
547pub(crate) async fn update_task_field(
548    conn: &mut SqliteConnection,
549    workspace: &Workspace,
550    task_id: &crate::ids::TaskId,
551    field: &str,
552    value: &str,
553) -> Result<bool> {
554    set_task_field(conn, workspace, task_id, field, value).await
555}
556
557pub async fn update_task_labels_in_workspace(
558    conn: &mut SqliteConnection,
559    workspace_id: &WorkspaceId,
560    task_id: &crate::ids::TaskId,
561    add_labels: &[String],
562    remove_labels: &[String],
563) -> Result<bool> {
564    let mutation_at = now();
565    let workspace = crate::workspaces::workspace_for_id(conn, workspace_id).await?;
566    crate::operations::route_recurrence_task_mutation(
567        conn,
568        &workspace,
569        task_id,
570        RecurrenceTaskMutation::Structural(RecurrenceStructuralMutation::Labels),
571        &mutation_at,
572    )
573    .await?;
574    let mut changed = false;
575    for label in resolve_labels_in_workspace(conn, &workspace.id, add_labels).await? {
576        let rows_affected = sqlx::query(
577            "INSERT OR IGNORE INTO task_labels(workspace_id, task_id, label) VALUES (?, ?, ?)",
578        )
579        .bind(&workspace.id)
580        .bind(task_id)
581        .bind(&label)
582        .execute(&mut *conn)
583        .await?
584        .rows_affected();
585        if rows_affected > 0 {
586            append_change(
587                conn,
588                ChangeEntity::Task,
589                task_id,
590                Some("labels"),
591                op_type::LABEL_ADD,
592                ChangePayload::workspace(&workspace).set("label", label),
593            )
594            .await?;
595            changed = true;
596        }
597    }
598    for label in resolve_labels_in_workspace(conn, &workspace.id, remove_labels).await? {
599        let rows_affected = sqlx::query(
600            "DELETE FROM task_labels WHERE workspace_id = ? AND task_id = ? AND label = ?",
601        )
602        .bind(&workspace.id)
603        .bind(task_id)
604        .bind(&label)
605        .execute(&mut *conn)
606        .await?
607        .rows_affected();
608        if rows_affected > 0 {
609            append_change(
610                conn,
611                ChangeEntity::Task,
612                task_id,
613                Some("labels"),
614                op_type::LABEL_REMOVE,
615                ChangePayload::workspace(&workspace).set("label", label),
616            )
617            .await?;
618            changed = true;
619        }
620    }
621    if changed {
622        info!(
623            task_id = %task_id,
624            added = add_labels.len(),
625            removed = remove_labels.len(),
626            "task labels changed"
627        );
628    }
629    Ok(changed)
630}