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}