Skip to main content

aven_core/
api.rs

1mod epics;
2pub use epics::{EpicProgress, EpicSummary};
3
4use std::fmt;
5use std::path::{Path, PathBuf};
6
7use anyhow::Error as InternalError;
8use chrono::{Days, LocalResult, NaiveDate, NaiveTime, TimeZone, Utc, Weekday};
9
10pub use crate::attachments::AttachmentBytesState as AttachmentAvailability;
11use crate::choices::{TaskPriority, TaskSource, TaskStatus};
12use crate::db::Database;
13use crate::ids::{MetadataFieldId, ProjectId, TaskId, WorkspaceId};
14use crate::metadata::{MetadataFieldUsage, TaskMetadataInput, TaskMetadataValue};
15use crate::operations::{
16    ConsumerTaskMutation as InternalConsumerTaskMutation, CreateRecurrenceSeriesParams,
17    RecurrenceSeriesDraft, RecurrenceTemplateUpdate as InternalRecurrenceTemplateUpdate,
18    TaskCreationOptions, TaskDraft, TaskUpdate as InternalTaskUpdate,
19    UpdateRecurrenceTemplateParams,
20};
21use crate::query::{
22    MAX_RECURRENCE_HISTORY_LIMIT, RecurrenceCounts as InternalRecurrenceCounts,
23    RecurrenceHistoryEntry as InternalRecurrenceHistoryEntry,
24    RecurrenceSeriesDetail as InternalRecurrenceSeriesDetail,
25    RecurrenceSeriesSummary as InternalRecurrenceSeriesSummary, SortDirection,
26    TaskAvailabilityFilter, TaskFilters, TaskListItem, TaskQueryMode, TaskSearchQuery, TaskSort,
27};
28pub use crate::query::{RecurrenceHistoryKind, SearchMatchedField};
29pub use crate::queue::{QueueBand, QueueDateKind, QueueReason};
30pub use crate::recurrence::{
31    RecurrenceDuePolicy, RecurrenceFrequency, RecurrenceOutcome, RecurrenceProjectionState,
32    RecurrenceSeriesState,
33};
34use crate::recurrence::{
35    RecurrenceRule as InternalRecurrenceRule, RecurrenceSchedule, RecurrenceSeriesId, TimeZoneId,
36    WeekdaySet,
37};
38use crate::task_fields::TaskField;
39use crate::types::{Project, RecurrenceOccurrence, RecurrenceSeries, Task};
40use crate::undo::TaskUndoSnapshot;
41use crate::workspaces::Workspace;
42
43#[derive(Clone)]
44pub struct Store {
45    database: Database,
46    #[cfg(test)]
47    fail_queue_reads: std::sync::Arc<std::sync::atomic::AtomicBool>,
48}
49
50impl Store {
51    pub async fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
52        let database = Database::open(path.as_ref())
53            .await
54            .map_err(Error::database_open)?;
55        Ok(Self {
56            database,
57            #[cfg(test)]
58            fail_queue_reads: Default::default(),
59        })
60    }
61
62    /// See [`Database::close`].
63    pub async fn close(&self) {
64        self.database.close().await;
65    }
66
67    pub fn initialize_storage(&self) -> Result<StorageLayout, Error> {
68        let root = crate::attachments::default_blob_dir(self.database.path());
69        let objects = root.join("objects").join("sha256");
70        let trash = root.join("trash");
71        let previews = root.join("cache").join("previews");
72        for directory in [&objects, &trash, &previews] {
73            std::fs::create_dir_all(directory)
74                .map_err(|error| Error::from_internal(error.into()))?;
75        }
76        Ok(StorageLayout {
77            root,
78            staging: objects.clone(),
79            objects,
80            trash,
81            previews,
82        })
83    }
84
85    pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, Error> {
86        self.database
87            .list_workspaces()
88            .await
89            .map(|workspaces| workspaces.into_iter().map(WorkspaceRecord::from).collect())
90            .map_err(Error::from_internal)
91    }
92
93    pub async fn queue_state(&self) -> Result<QueueState, Error> {
94        let selected = self
95            .database
96            .restore_queue_workspace()
97            .await
98            .map_err(Error::from_internal)?;
99        self.queue_state_for(selected).await
100    }
101
102    pub async fn select_queue_workspace(
103        &self,
104        workspace_id: &WorkspaceId,
105    ) -> Result<QueueState, Error> {
106        let selected = self
107            .database
108            .select_queue_workspace(workspace_id)
109            .await
110            .map_err(Error::from_internal)?;
111        self.queue_state_for(selected).await
112    }
113
114    async fn queue_state_for(&self, selected: Workspace) -> Result<QueueState, Error> {
115        #[cfg(test)]
116        if self
117            .fail_queue_reads
118            .load(std::sync::atomic::Ordering::SeqCst)
119        {
120            return Err(Error::from_internal(sqlx::Error::PoolClosed.into()));
121        }
122        let workspaces = self
123            .database
124            .list_workspace_open_summaries()
125            .await
126            .map_err(Error::from_internal)?
127            .into_iter()
128            .map(|summary| WorkspaceQueueSummary {
129                workspace: summary.workspace.into(),
130                open_task_count: summary.open_task_count,
131            })
132            .collect();
133        let projects = self
134            .database
135            .list_projects(&selected.id, None)
136            .await
137            .map_err(Error::from_internal)?
138            .into_iter()
139            .map(ProjectRecord::from)
140            .collect();
141        let labels = self
142            .database
143            .list_labels(&selected.id, None)
144            .await
145            .map_err(Error::from_internal)?;
146        let queue = self.queue_report(&selected.id).await?;
147        Ok(QueueState {
148            selected_workspace: selected.into(),
149            workspaces,
150            projects,
151            labels,
152            queue,
153        })
154    }
155
156    pub async fn project_tasks(
157        &self,
158        workspace_id: &WorkspaceId,
159        project_id: &ProjectId,
160    ) -> Result<Vec<TaskListRow>, Error> {
161        self.workspace(workspace_id).await?;
162        let project = self
163            .database
164            .find_project_by_id(workspace_id, project_id)
165            .await
166            .map_err(Error::from_internal)?
167            .ok_or_else(|| Error::new(ErrorCode::NotFound, "project not found".to_string()))?;
168        self.database
169            .list_task_summary_items(
170                workspace_id,
171                TaskFilters {
172                    project: Some(project.key),
173                    ..TaskFilters::default()
174                },
175                TaskQueryMode::Flat,
176                TaskSort::Updated,
177                SortDirection::Desc,
178                None,
179            )
180            .await
181            .map(|items| items.into_iter().map(TaskListRow::from).collect())
182            .map_err(Error::from_internal)
183    }
184
185    pub async fn search_tasks(
186        &self,
187        workspace_id: &WorkspaceId,
188        text: &str,
189    ) -> Result<Vec<TaskSearchResult>, Error> {
190        self.workspace(workspace_id).await?;
191        let text = text.trim();
192        if text.is_empty() {
193            return Ok(Vec::new());
194        }
195        if text.chars().count() > 256 {
196            return Err(Error::new(
197                ErrorCode::Validation,
198                "search query is too long".to_string(),
199            ));
200        }
201        self.database
202            .search_task_items(
203                workspace_id,
204                TaskSearchQuery {
205                    text: text.to_string(),
206                    project: None,
207                    metadata: Vec::new(),
208                    has_metadata: Vec::new(),
209                    missing_metadata: Vec::new(),
210                    include_deleted: false,
211                    limit: 100,
212                },
213            )
214            .await
215            .map(|results| {
216                results
217                    .into_iter()
218                    .map(|result| TaskSearchResult {
219                        task: TaskListRow::from(result.item),
220                        matched_field: result.matched_field,
221                        snippet: result.snippet,
222                    })
223                    .collect()
224            })
225            .map_err(Error::from_internal)
226    }
227
228    pub async fn task_detail(
229        &self,
230        workspace_id: &WorkspaceId,
231        task_id: &TaskId,
232    ) -> Result<TaskDetail, Error> {
233        let workspace = self.workspace(workspace_id).await?;
234        let mut connection = self
235            .database
236            .acquire_reader()
237            .await
238            .map_err(Error::from_internal)?;
239        let task = crate::refs::get_task_in_workspace(&mut connection, &workspace, task_id)
240            .await
241            .map_err(Error::from_internal)?;
242        drop(connection);
243        let mut detail = self
244            .database
245            .task_detail(&task)
246            .await
247            .map_err(Error::from_internal)?;
248        let blob_dir = crate::attachments::default_blob_dir(self.database.path());
249        let present = detail
250            .item
251            .attachments
252            .iter()
253            .enumerate()
254            .filter(|(_, attachment)| {
255                attachment.bytes_state == crate::attachments::AttachmentBytesState::Present
256            })
257            .map(|(index, attachment)| (index, attachment.sha256.clone()))
258            .collect::<Vec<_>>();
259        let missing = crate::attachments::run_blocking(move || {
260            let mut missing = Vec::new();
261            for (index, sha256) in present {
262                if crate::operations::local_object_missing(&blob_dir, &sha256)? {
263                    missing.push(index);
264                }
265            }
266            Ok(missing)
267        })
268        .await
269        .map_err(Error::from_internal)?;
270        for index in missing {
271            let attachment = &mut detail.item.attachments[index];
272            attachment.bytes_state = crate::attachments::AttachmentBytesState::Unavailable;
273            attachment.has_blob = false;
274        }
275        TaskDetail::from_detail(detail, workspace)
276    }
277
278    /// Returns an owned, bounded snapshot without exposing object storage paths.
279    pub async fn attachment_bytes(
280        &self,
281        workspace_id: &WorkspaceId,
282        task_id: &TaskId,
283        attachment_id: &str,
284    ) -> Result<AttachmentRead, Error> {
285        use crate::attachments::{MAX_BLOB_BYTES, default_blob_dir, object_path, sha256_hex};
286        use std::io::Read;
287
288        crate::attachments::validate_attachment_id(attachment_id).map_err(Error::from_internal)?;
289        let workspace = self.workspace(workspace_id).await?;
290        let lease = match self
291            .database
292            .acquire_live_attachment_read_lease(&workspace, attachment_id)
293            .await
294        {
295            Ok(lease) => lease,
296            Err(error) => {
297                return match error.downcast_ref::<crate::operations::AttachmentReadFailure>() {
298                    Some(crate::operations::AttachmentReadFailure::Invalidated) => {
299                        Ok(AttachmentRead::Invalidated)
300                    }
301                    Some(crate::operations::AttachmentReadFailure::Unavailable) => {
302                        Ok(AttachmentRead::Unavailable)
303                    }
304                    None => Err(Error::from_internal(error)),
305                };
306            }
307        };
308        let result = if lease.task_id != task_id.as_str() {
309            Ok(AttachmentRead::Invalidated)
310        } else {
311            let path = object_path(&default_blob_dir(self.database.path()), &lease.sha256);
312            match path {
313                Err(error) => Err(error),
314                Ok(path) => {
315                    let expected_size = lease.byte_size;
316                    let expected_hash = lease.sha256.clone();
317                    crate::attachments::run_preview(move || {
318                        let file = match std::fs::File::open(path) {
319                            Ok(file) => file,
320                            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
321                                return Ok(AttachmentRead::Missing);
322                            }
323                            Err(error) => return Err(error.into()),
324                        };
325                        let mut bytes = Vec::new();
326                        file.take(MAX_BLOB_BYTES as u64 + 1)
327                            .read_to_end(&mut bytes)?;
328                        if bytes.len() > MAX_BLOB_BYTES
329                            || bytes.len() as i64 != expected_size
330                            || sha256_hex(&bytes) != expected_hash
331                        {
332                            return Ok(AttachmentRead::Corrupt);
333                        }
334                        Ok(AttachmentRead::Bytes { bytes })
335                    })
336                    .await
337                }
338            }
339        };
340        self.database
341            .release_attachment_lease(&lease.lease_id)
342            .await
343            .map_err(Error::from_internal)?;
344        result.map_err(Error::from_internal)
345    }
346
347    pub async fn capture_queue_task(
348        &self,
349        workspace_id: &WorkspaceId,
350        input: TaskCapture,
351    ) -> Result<TaskCaptureResult, Error> {
352        let title = input.title.trim();
353        if title.is_empty() {
354            return Err(Error::new(
355                ErrorCode::Validation,
356                "task title is required".to_string(),
357            ));
358        }
359        validate_optional_date("due_on", input.due_on.as_deref())?;
360        let workspace = self.workspace(workspace_id).await?;
361        let project = input
362            .project
363            .ok_or_else(|| Error::new(ErrorCode::Validation, "project is required".to_string()))?;
364        let outcome = self
365            .database
366            .create_task_with_options(
367                &workspace,
368                TaskDraft {
369                    title: title.to_string(),
370                    description: input.description,
371                    project: Some(project),
372                    status: input.status.as_str().to_string(),
373                    priority: input.priority.as_str().to_string(),
374                    source: input.source,
375                    labels: input.labels,
376                    metadata: Vec::new(),
377                    available_at: None,
378                    due_on: input.due_on,
379                    is_epic: false,
380                },
381                TaskCreationOptions::for_consumer_capture(),
382            )
383            .await
384            .map_err(Error::from_internal)?;
385        let expected = outcome.undo_snapshot.ok_or_else(|| {
386            Error::new(
387                ErrorCode::Internal,
388                "capture undo state is unavailable".to_string(),
389            )
390        })?;
391        let undo_token = serde_json::to_string(&CaptureUndoToken {
392            workspace_id: workspace_id.clone(),
393            task_id: outcome.task.id.clone(),
394            expected,
395        })
396        .map_err(|error| Error::from_internal(error.into()))?;
397        let state = self.queue_state_for(workspace).await.ok();
398        let display_ref = state
399            .as_ref()
400            .and_then(|state| {
401                state
402                    .queue
403                    .tasks
404                    .iter()
405                    .find(|task| task.id == outcome.task.id)
406            })
407            .map(|task| task.display_ref.clone())
408            .unwrap_or_else(|| outcome.task.id.to_string());
409        Ok(TaskCaptureResult {
410            task_id: outcome.task.id,
411            display_ref,
412            undo_token,
413            state,
414        })
415    }
416
417    pub async fn undo_queue_capture(&self, undo_token: &str) -> Result<Option<QueueState>, Error> {
418        let token: CaptureUndoToken = serde_json::from_str(undo_token).map_err(|_| {
419            Error::new(
420                ErrorCode::Validation,
421                "invalid capture undo token".to_string(),
422            )
423        })?;
424        let workspace = self.workspace(&token.workspace_id).await?;
425        self.database
426            .undo_queue_capture(&workspace, &token.task_id, &token.expected)
427            .await
428            .map_err(Error::from_internal)?;
429        Ok(self.queue_state_for(workspace).await.ok())
430    }
431
432    /// Returns the committed status and its conditional Undo receipt without a read-model refresh.
433    pub async fn update_detail_status(
434        &self,
435        workspace_id: &WorkspaceId,
436        task_id: &TaskId,
437        status: TaskStatus,
438    ) -> Result<DetailStatusReceipt, Error> {
439        let workspace = self.workspace(workspace_id).await?;
440        let mutation = InternalConsumerTaskMutation::DetailStatus {
441            status: status.as_str().to_string(),
442            expected_version: None,
443        };
444        let outcome = self
445            .database
446            .mutate_consumer_task(&workspace, task_id, &mutation)
447            .await
448            .map_err(Error::from_internal)?;
449        let status = TaskStatus::parse(&outcome.after.status)
450            .expect("persisted task status is validated by core mutations");
451        let mutation = InternalConsumerTaskMutation::DetailStatus {
452            status: status.as_str().to_string(),
453            expected_version: outcome.field_version,
454        };
455        let undo_token = outcome.changed.then(|| {
456            serde_json::to_string(&MutationUndoToken {
457                workspace_id: workspace_id.clone(),
458                task_id: task_id.clone(),
459                mutation,
460                before: outcome.before,
461                expected: outcome.after,
462            })
463            .expect("Undo snapshots contain only JSON-serializable values")
464        });
465        Ok(DetailStatusReceipt { status, undo_token })
466    }
467
468    pub async fn undo_detail_status(&self, undo_token: &str) -> Result<(), Error> {
469        let token: MutationUndoToken = serde_json::from_str(undo_token).map_err(|_| {
470            Error::new(
471                ErrorCode::Validation,
472                "invalid detail status undo token".to_string(),
473            )
474        })?;
475        if !matches!(
476            token.mutation,
477            InternalConsumerTaskMutation::DetailStatus { .. }
478        ) {
479            return Err(Error::new(
480                ErrorCode::Validation,
481                "invalid detail status undo token".to_string(),
482            ));
483        }
484        self.undo_consumer_mutation(token).await
485    }
486
487    /// Sets the synchronized soft-deletion field and returns a conditional Undo receipt.
488    pub async fn set_task_deleted(
489        &self,
490        workspace_id: &WorkspaceId,
491        task_id: &TaskId,
492        deleted: bool,
493    ) -> Result<TaskDeletionReceipt, Error> {
494        let workspace = self.workspace(workspace_id).await?;
495        let mutation = InternalConsumerTaskMutation::DeletedState {
496            deleted,
497            expected_version: None,
498        };
499        let outcome = self
500            .database
501            .mutate_consumer_task(&workspace, task_id, &mutation)
502            .await
503            .map_err(Error::from_internal)?;
504        let deleted = outcome.after.deleted;
505        let mutation = InternalConsumerTaskMutation::DeletedState {
506            deleted,
507            expected_version: outcome.field_version,
508        };
509        let undo_token = outcome.changed.then(|| {
510            serde_json::to_string(&MutationUndoToken {
511                workspace_id: workspace_id.clone(),
512                task_id: task_id.clone(),
513                mutation,
514                before: outcome.before,
515                expected: outcome.after,
516            })
517            .expect("Undo snapshots contain only JSON-serializable values")
518        });
519        Ok(TaskDeletionReceipt {
520            deleted,
521            undo_token,
522        })
523    }
524
525    pub async fn undo_task_deletion(&self, undo_token: &str) -> Result<(), Error> {
526        let token: MutationUndoToken = serde_json::from_str(undo_token).map_err(|_| {
527            Error::new(
528                ErrorCode::Validation,
529                "invalid task deletion undo token".to_string(),
530            )
531        })?;
532        if !matches!(
533            token.mutation,
534            InternalConsumerTaskMutation::DeletedState { .. }
535        ) {
536            return Err(Error::new(
537                ErrorCode::Validation,
538                "invalid task deletion undo token".to_string(),
539            ));
540        }
541        self.undo_consumer_mutation(token).await
542    }
543
544    async fn undo_consumer_mutation(&self, token: MutationUndoToken) -> Result<(), Error> {
545        let workspace = self.workspace(&token.workspace_id).await?;
546        self.database
547            .undo_consumer_task_mutation(
548                &workspace,
549                &token.task_id,
550                &token.mutation,
551                &token.before,
552                &token.expected,
553            )
554            .await
555            .map_err(Error::from_internal)?;
556        Ok(())
557    }
558
559    pub async fn mutate_queue_task(
560        &self,
561        workspace_id: &WorkspaceId,
562        task_id: &TaskId,
563        input: QueueMutation,
564    ) -> Result<QueueMutationResult, Error> {
565        let workspace = self.workspace(workspace_id).await?;
566        let mutation = input.into_internal()?;
567        let outcome = self
568            .database
569            .mutate_consumer_task(&workspace, task_id, &mutation)
570            .await
571            .map_err(Error::from_internal)?;
572        if !outcome.changed {
573            return Err(Error::new(
574                ErrorCode::GenerationConflict,
575                "quick action did not change the task".to_string(),
576            ));
577        }
578        let undo_token = serde_json::to_string(&MutationUndoToken {
579            workspace_id: workspace_id.clone(),
580            task_id: task_id.clone(),
581            mutation,
582            before: outcome.before,
583            expected: outcome.after,
584        })
585        .map_err(|error| Error::from_internal(error.into()))?;
586        Ok(QueueMutationResult {
587            task_id: task_id.clone(),
588            undo_token,
589            state: self.queue_state_for(workspace).await.ok(),
590        })
591    }
592
593    pub async fn undo_queue_mutation(&self, undo_token: &str) -> Result<Option<QueueState>, Error> {
594        let token: MutationUndoToken = serde_json::from_str(undo_token).map_err(|_| {
595            Error::new(
596                ErrorCode::Validation,
597                "invalid quick action undo token".to_string(),
598            )
599        })?;
600        let workspace = self.workspace(&token.workspace_id).await?;
601        self.database
602            .undo_consumer_task_mutation(
603                &workspace,
604                &token.task_id,
605                &token.mutation,
606                &token.before,
607                &token.expected,
608            )
609            .await
610            .map_err(Error::from_internal)?;
611        Ok(self.queue_state_for(workspace).await.ok())
612    }
613
614    pub async fn resolve_workspace(&self, name_or_key: &str) -> Result<WorkspaceRecord, Error> {
615        self.database
616            .find_workspace(name_or_key)
617            .await
618            .map_err(Error::from_internal)?
619            .map(WorkspaceRecord::from)
620            .ok_or_else(|| {
621                Error::new(
622                    ErrorCode::NotFound,
623                    format!("workspace not found: {name_or_key}"),
624                )
625            })
626    }
627
628    pub async fn list_metadata_fields(
629        &self,
630        workspace_id: &WorkspaceId,
631    ) -> Result<Vec<MetadataFieldRecord>, Error> {
632        self.workspace(workspace_id).await?;
633        self.database
634            .list_metadata_fields(workspace_id)
635            .await
636            .map(|fields| fields.into_iter().map(Into::into).collect())
637            .map_err(Error::from_internal)
638    }
639
640    pub async fn rename_metadata_field(
641        &self,
642        workspace_id: &WorkspaceId,
643        key: &str,
644        new_key: &str,
645    ) -> Result<MetadataFieldRecord, Error> {
646        let workspace = self.workspace(workspace_id).await?;
647        let field = self
648            .database
649            .rename_metadata_field(&workspace, key, new_key)
650            .await
651            .map_err(Error::from_internal)?;
652        let fields = self.list_metadata_fields(workspace_id).await?;
653        fields
654            .into_iter()
655            .find(|candidate| candidate.id == field.id)
656            .ok_or_else(|| Error::new(ErrorCode::NotFound, "metadata field not found".to_string()))
657    }
658
659    pub async fn create_task(
660        &self,
661        workspace_id: &WorkspaceId,
662        input: CreateTask,
663    ) -> Result<TaskRecord, Error> {
664        validate_project(&input.project)?;
665        validate_optional_date("available_at", input.available_at.as_deref())?;
666        validate_optional_date("due_on", input.due_on.as_deref())?;
667        let workspace = self.workspace(workspace_id).await?;
668        let outcome = self
669            .database
670            .create_task(
671                &workspace,
672                TaskDraft {
673                    title: input.title,
674                    description: input.description,
675                    project: Some(input.project),
676                    status: input.status.as_str().to_string(),
677                    priority: input.priority.as_str().to_string(),
678                    source: TaskSource::Api,
679                    labels: Vec::new(),
680                    metadata: input
681                        .metadata
682                        .into_iter()
683                        .map(TaskMetadataInput::from)
684                        .collect(),
685                    available_at: input.available_at,
686                    due_on: input.due_on,
687                    is_epic: false,
688                },
689            )
690            .await
691            .map_err(Error::from_internal)?;
692        let metadata = self
693            .database
694            .task_metadata(workspace_id, &outcome.task.id)
695            .await
696            .map_err(Error::from_internal)?;
697        Ok(TaskRecord::with_metadata(outcome.task, metadata))
698    }
699
700    pub async fn task_edit_context(
701        &self,
702        workspace_id: &WorkspaceId,
703        task_id: &TaskId,
704    ) -> Result<TaskEditContext, Error> {
705        let detail = self.task_detail(workspace_id, task_id).await?;
706        let projects = self
707            .database
708            .list_projects(workspace_id, None)
709            .await
710            .map_err(Error::from_internal)?
711            .into_iter()
712            .map(ProjectRecord::from)
713            .collect();
714        let labels = self
715            .database
716            .list_labels(workspace_id, None)
717            .await
718            .map_err(Error::from_internal)?;
719        Ok(TaskEditContext {
720            detail,
721            projects,
722            labels,
723        })
724    }
725
726    pub async fn edit_task(
727        &self,
728        workspace_id: &WorkspaceId,
729        task_id: &TaskId,
730        input: TaskEdit,
731    ) -> Result<bool, Error> {
732        let title = input.title.map(|title| title.trim().to_string());
733        if let Some(title) = title.as_deref() {
734            if title.is_empty() {
735                return Err(Error::new(
736                    ErrorCode::Validation,
737                    "title must not be empty".into(),
738                ));
739            }
740            if title.chars().any(|character| {
741                matches!(
742                    character,
743                    '\n' | '\r' | '\u{000B}' | '\u{000C}' | '\u{0085}' | '\u{2028}' | '\u{2029}'
744                )
745            }) {
746                return Err(Error::new(
747                    ErrorCode::Validation,
748                    "title must be a single line".into(),
749                ));
750            }
751        }
752        validate_date_update("available_at", &input.available_at)?;
753        validate_date_update("due_on", &input.due_on)?;
754        let workspace = self.workspace(workspace_id).await?;
755        self.database
756            .edit_consumer_task(
757                &workspace,
758                task_id,
759                input.project_id.as_ref(),
760                InternalTaskUpdate {
761                    title,
762                    description: input.description,
763                    status: input.status.map(|value| value.as_str().to_string()),
764                    priority: input.priority.map(|value| value.as_str().to_string()),
765                    available_at: input.available_at.into_internal(),
766                    due_on: input.due_on.into_internal(),
767                    add_labels: input.add_labels,
768                    remove_labels: input.remove_labels,
769                    ..InternalTaskUpdate::default()
770                },
771            )
772            .await
773            .map_err(Error::from_internal)
774    }
775
776    pub async fn update_task(
777        &self,
778        workspace_id: &WorkspaceId,
779        task_id: &TaskId,
780        input: UpdateTask,
781    ) -> Result<TaskUpdateResult, Error> {
782        if let Some(project) = input.project.as_deref() {
783            validate_project(project)?;
784        }
785        validate_date_update("available_at", &input.available_at)?;
786        validate_date_update("due_on", &input.due_on)?;
787        let workspace = self.workspace(workspace_id).await?;
788        self.fetch_task(workspace_id, task_id).await?;
789        let outcome = self
790            .database
791            .update_task(
792                &workspace,
793                task_id,
794                InternalTaskUpdate {
795                    title: input.title,
796                    description: input.description,
797                    project: input.project,
798                    status: input.status.map(|status| status.as_str().to_string()),
799                    priority: input.priority.map(|priority| priority.as_str().to_string()),
800                    available_at: input.available_at.into_internal(),
801                    due_on: input.due_on.into_internal(),
802                    set_metadata: input
803                        .set_metadata
804                        .into_iter()
805                        .map(TaskMetadataInput::from)
806                        .collect(),
807                    remove_metadata: input.remove_metadata,
808                    ..InternalTaskUpdate::default()
809                },
810            )
811            .await
812            .map_err(Error::from_internal)?;
813        let metadata = self
814            .database
815            .task_metadata(workspace_id, task_id)
816            .await
817            .map_err(Error::from_internal)?;
818        let related = self
819            .database
820            .task_related_links(workspace_id, task_id)
821            .await
822            .map_err(Error::from_internal)?;
823        Ok(TaskUpdateResult {
824            task: TaskRecord::with_metadata_and_related(outcome.task, metadata, related),
825            changed: outcome.changed,
826        })
827    }
828
829    pub async fn list_tasks(&self, workspace_id: &WorkspaceId) -> Result<Vec<TaskRecord>, Error> {
830        self.workspace(workspace_id).await?;
831        let filters = TaskFilters {
832            exclude_epics: true,
833            ..TaskFilters::default()
834        };
835        self.database
836            .list_base_tasks(workspace_id, filters, TaskSort::Created, SortDirection::Asc)
837            .await
838            .map(|tasks| tasks.into_iter().map(TaskRecord::from).collect())
839            .map_err(Error::from_internal)
840    }
841
842    pub async fn queue_report(&self, workspace_id: &WorkspaceId) -> Result<QueueReport, Error> {
843        self.workspace(workspace_id).await?;
844        let tasks = self
845            .database
846            .list_task_summary_items(
847                workspace_id,
848                TaskFilters {
849                    hide_done: true,
850                    availability: TaskAvailabilityFilter::Available,
851                    ..TaskFilters::default()
852                },
853                TaskQueryMode::RankedQueue,
854                TaskSort::Created,
855                SortDirection::Asc,
856                None,
857            )
858            .await
859            .map_err(Error::from_internal)?
860            .into_iter()
861            .map(QueueTaskSummary::from)
862            .collect();
863        let unresolved_conflict_count = self
864            .database
865            .unresolved_task_conflict_count_in_workspace(workspace_id)
866            .await
867            .map_err(Error::from_internal)?
868            .clamp(0, i64::from(u32::MAX)) as u32;
869        Ok(QueueReport {
870            tasks,
871            unresolved_conflict_count,
872        })
873    }
874
875    pub async fn fetch_task(
876        &self,
877        workspace_id: &WorkspaceId,
878        task_id: &TaskId,
879    ) -> Result<TaskRecord, Error> {
880        let workspace = self.workspace(workspace_id).await?;
881        let mut connection = self
882            .database
883            .acquire_reader()
884            .await
885            .map_err(Error::from_internal)?;
886        let task = crate::refs::get_task_in_workspace(&mut connection, &workspace, task_id)
887            .await
888            .map_err(Error::from_internal)?;
889        drop(connection);
890        let metadata = self
891            .database
892            .task_metadata(workspace_id, task_id)
893            .await
894            .map_err(Error::from_internal)?;
895        let related = self
896            .database
897            .task_related_links(workspace_id, task_id)
898            .await
899            .map_err(Error::from_internal)?;
900        Ok(TaskRecord::with_metadata_and_related(
901            task, metadata, related,
902        ))
903    }
904
905    pub async fn add_related_task(
906        &self,
907        workspace_id: &WorkspaceId,
908        task_id: &TaskId,
909        related_task_id: &TaskId,
910    ) -> Result<RelatedMutationResult, Error> {
911        let workspace = self.workspace(workspace_id).await?;
912        self.database
913            .add_task_related_link(&workspace, task_id, related_task_id)
914            .await
915            .map(|outcome| RelatedMutationResult {
916                changed: outcome.changed,
917            })
918            .map_err(Error::from_internal)
919    }
920
921    pub async fn remove_related_task(
922        &self,
923        workspace_id: &WorkspaceId,
924        task_id: &TaskId,
925        related_task_id: &TaskId,
926    ) -> Result<RelatedMutationResult, Error> {
927        let workspace = self.workspace(workspace_id).await?;
928        self.database
929            .remove_task_related_link(&workspace, task_id, related_task_id)
930            .await
931            .map(|outcome| RelatedMutationResult {
932                changed: outcome.changed,
933            })
934            .map_err(Error::from_internal)
935    }
936
937    pub async fn create_recurrence_series(
938        &self,
939        workspace_id: &WorkspaceId,
940        input: CreateRecurrenceSeries,
941    ) -> Result<RecurrenceCreateResult, Error> {
942        validate_project(&input.project)?;
943        let workspace = self.workspace(workspace_id).await?;
944        let schedule = input.schedule.into_internal()?;
945        let outcome = self
946            .database
947            .create_recurrence_series(
948                &workspace,
949                CreateRecurrenceSeriesParams::new(RecurrenceSeriesDraft {
950                    title: input.title,
951                    description: input.description,
952                    project: input.project,
953                    priority: input.priority.as_str().to_string(),
954                    initial_status: input.initial_status.as_str().to_string(),
955                    labels: input.labels,
956                    metadata: input.metadata.into_iter().map(Into::into).collect(),
957                    schedule,
958                }),
959            )
960            .await
961            .map_err(Error::from_internal)?;
962        let metadata = self
963            .database
964            .task_metadata(workspace_id, &outcome.task.id)
965            .await
966            .map_err(Error::from_internal)?;
967        Ok(RecurrenceCreateResult {
968            series: RecurrenceSeriesRecord::from(outcome.series),
969            series_ref: outcome.series_ref,
970            occurrence: RecurrenceOccurrenceRecord::from(outcome.occurrence),
971            task: TaskRecord::with_metadata(outcome.task, metadata),
972        })
973    }
974
975    pub async fn update_recurrence_template(
976        &self,
977        workspace_id: &WorkspaceId,
978        series_id: &RecurrenceSeriesId,
979        input: UpdateRecurrenceTemplate,
980    ) -> Result<RecurrenceTemplateUpdateResult, Error> {
981        if let Some(project) = input.project.as_deref() {
982            validate_project(project)?;
983        }
984        let workspace = self.workspace(workspace_id).await?;
985        self.database
986            .update_recurrence_template(
987                &workspace,
988                series_id,
989                UpdateRecurrenceTemplateParams::new(InternalRecurrenceTemplateUpdate {
990                    title: input.title,
991                    description: input.description,
992                    project: input.project,
993                    priority: input.priority.map(|value| value.as_str().to_string()),
994                    initial_status: input.initial_status.map(|value| value.as_str().to_string()),
995                    labels: input.labels,
996                    set_metadata: input.set_metadata.into_iter().map(Into::into).collect(),
997                    remove_metadata: input.remove_metadata,
998                    available_local_time: input.available_local_time.into_internal()?,
999                    due_policy: input.due_policy,
1000                }),
1001            )
1002            .await
1003            .map(|outcome| RecurrenceTemplateUpdateResult {
1004                series: RecurrenceSeriesRecord::from(outcome.series),
1005                changed: outcome.changed,
1006            })
1007            .map_err(Error::from_internal)
1008    }
1009
1010    pub async fn resolve_recurrence_ref(
1011        &self,
1012        workspace_id: &WorkspaceId,
1013        input: &str,
1014    ) -> Result<RecurrenceRefResolution, Error> {
1015        let workspace = self.workspace(workspace_id).await?;
1016        let series = self
1017            .database
1018            .resolve_recurrence_ref(&workspace, input)
1019            .await
1020            .map_err(Error::from_internal)?;
1021        let series_ref = self
1022            .database
1023            .recurrence_series_ref(workspace_id, &series.id)
1024            .await
1025            .map_err(Error::from_internal)?;
1026        Ok(RecurrenceRefResolution {
1027            series_id: series.id,
1028            series_ref,
1029        })
1030    }
1031
1032    pub async fn list_recurrence_series(
1033        &self,
1034        workspace_id: &WorkspaceId,
1035    ) -> Result<Vec<RecurrenceSeriesSummary>, Error> {
1036        self.workspace(workspace_id).await?;
1037        self.database
1038            .list_recurrence_series(workspace_id)
1039            .await
1040            .map(|values| values.into_iter().map(Into::into).collect())
1041            .map_err(Error::from_internal)
1042    }
1043
1044    pub async fn show_recurrence_series(
1045        &self,
1046        workspace_id: &WorkspaceId,
1047        input: &str,
1048    ) -> Result<RecurrenceSeriesDetail, Error> {
1049        let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
1050        self.database
1051            .recurrence_series_detail(workspace_id, &resolution.series_id)
1052            .await
1053            .map(Into::into)
1054            .map_err(Error::from_internal)
1055    }
1056
1057    pub async fn recurrence_history(
1058        &self,
1059        workspace_id: &WorkspaceId,
1060        input: &str,
1061        offset: usize,
1062        limit: usize,
1063    ) -> Result<RecurrenceHistoryPage, Error> {
1064        if limit == 0 || limit > MAX_RECURRENCE_HISTORY_LIMIT {
1065            return Err(Error::new(
1066                ErrorCode::Validation,
1067                format!(
1068                    "recurrence history limit must be between 1 and {MAX_RECURRENCE_HISTORY_LIMIT}"
1069                ),
1070            ));
1071        }
1072        let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
1073        self.database
1074            .recurrence_history(workspace_id, &resolution.series_id, offset, limit)
1075            .await
1076            .map(|page| RecurrenceHistoryPage {
1077                series_ref: page.series_ref,
1078                items: page.items.into_iter().map(Into::into).collect(),
1079                offset: page.offset,
1080                limit: page.limit,
1081                total: page.total,
1082                has_more: page.has_more,
1083            })
1084            .map_err(Error::from_internal)
1085    }
1086
1087    pub async fn resolve_recurrence_occurrence(
1088        &self,
1089        workspace_id: &WorkspaceId,
1090        task_id: &TaskId,
1091        outcome: RecurrenceOutcome,
1092    ) -> Result<RecurrenceResolveResult, Error> {
1093        let workspace = self.workspace(workspace_id).await?;
1094        self.database
1095            .resolve_recurrence_occurrence(&workspace, task_id, outcome)
1096            .await
1097            .map(|value| RecurrenceResolveResult {
1098                series: value.series.into(),
1099                occurrence: value.resolved.into(),
1100                task: value.task.into(),
1101                successor: value.successor.map(Into::into),
1102            })
1103            .map_err(Error::from_internal)
1104    }
1105
1106    pub async fn complete_recurrence_occurrence(
1107        &self,
1108        workspace_id: &WorkspaceId,
1109        task_id: &TaskId,
1110    ) -> Result<RecurrenceResolveResult, Error> {
1111        self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Completed)
1112            .await
1113    }
1114
1115    pub async fn skip_recurrence_occurrence(
1116        &self,
1117        workspace_id: &WorkspaceId,
1118        task_id: &TaskId,
1119    ) -> Result<RecurrenceResolveResult, Error> {
1120        self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Skipped)
1121            .await
1122    }
1123
1124    pub async fn pause_recurrence_series(
1125        &self,
1126        workspace_id: &WorkspaceId,
1127        series_id: &RecurrenceSeriesId,
1128    ) -> Result<RecurrenceStateResult, Error> {
1129        let workspace = self.workspace(workspace_id).await?;
1130        self.database
1131            .pause_recurrence_series(&workspace, series_id)
1132            .await
1133            .map(Into::into)
1134            .map_err(Error::from_internal)
1135    }
1136
1137    pub async fn resume_recurrence_series(
1138        &self,
1139        workspace_id: &WorkspaceId,
1140        series_id: &RecurrenceSeriesId,
1141    ) -> Result<RecurrenceStateResult, Error> {
1142        let workspace = self.workspace(workspace_id).await?;
1143        let at = crate::ids::now_utc();
1144        self.database
1145            .resume_recurrence_series(&workspace, series_id, at)
1146            .await
1147            .map(Into::into)
1148            .map_err(Error::from_internal)
1149    }
1150
1151    pub async fn stop_recurrence_series(
1152        &self,
1153        workspace_id: &WorkspaceId,
1154        series_id: &RecurrenceSeriesId,
1155        skip_current: bool,
1156    ) -> Result<RecurrenceStateResult, Error> {
1157        let workspace = self.workspace(workspace_id).await?;
1158        self.database
1159            .stop_recurrence_series(&workspace, series_id, skip_current)
1160            .await
1161            .map(Into::into)
1162            .map_err(Error::from_internal)
1163    }
1164
1165    pub async fn recurrence_task_report(
1166        &self,
1167        workspace_id: &WorkspaceId,
1168        expand_recurring: bool,
1169    ) -> Result<Vec<TaskSummary>, Error> {
1170        self.workspace(workspace_id).await?;
1171        self.database
1172            .list_task_items(
1173                workspace_id,
1174                TaskFilters {
1175                    exclude_epics: true,
1176                    expand_recurring,
1177                    ..TaskFilters::default()
1178                },
1179                TaskQueryMode::Flat,
1180                TaskSort::Created,
1181                SortDirection::Asc,
1182            )
1183            .await
1184            .map(|items| items.into_iter().map(TaskSummary::from).collect())
1185            .map_err(Error::from_internal)
1186    }
1187
1188    pub async fn list_conflicts(
1189        &self,
1190        workspace_id: &WorkspaceId,
1191    ) -> Result<Vec<ConflictSummary>, Error> {
1192        let workspace = self.workspace(workspace_id).await?;
1193        self.database
1194            .list_conflicts(&workspace, None, None)
1195            .await
1196            .map_err(Error::from_internal)?
1197            .into_iter()
1198            // Summaries identify tasks with typed task fields; series template
1199            // conflicts use their own fields and resolution path.
1200            .filter(|conflict| {
1201                !conflict.recurrence_series && !conflict.field.starts_with("metadata:")
1202            })
1203            .map(|conflict| {
1204                Ok(ConflictSummary {
1205                    task_id: conflict.task_id,
1206                    task_title: conflict.title,
1207                    project_key: conflict.project_key,
1208                    project_prefix: conflict.project_prefix,
1209                    field: ConflictField::from_task_field(TaskField::parse_or_unknown(
1210                        &conflict.field,
1211                    )?),
1212                })
1213            })
1214            .collect::<Result<Vec<_>, InternalError>>()
1215            .map_err(Error::from_internal)
1216    }
1217
1218    pub async fn inspect_conflicts(
1219        &self,
1220        workspace_id: &WorkspaceId,
1221        task_id: &TaskId,
1222    ) -> Result<Vec<Conflict>, Error> {
1223        let workspace = self.workspace(workspace_id).await?;
1224        let details = self
1225            .database
1226            .task_conflicts(&workspace, task_id, None)
1227            .await
1228            .map_err(Error::from_internal)?;
1229        let mut connection = self
1230            .database
1231            .acquire_reader()
1232            .await
1233            .map_err(Error::from_internal)?;
1234        let mut conflicts = Vec::with_capacity(details.len());
1235        for detail in details {
1236            if detail.field.starts_with("metadata:") {
1237                continue;
1238            }
1239            let field = TaskField::parse_or_unknown(&detail.field).map_err(Error::from_internal)?;
1240            let local_value = crate::query::conflict_display_value(
1241                &mut connection,
1242                workspace_id,
1243                field.as_str(),
1244                &detail.local_value,
1245            )
1246            .await
1247            .map_err(Error::from_internal)?;
1248            let remote_value = crate::query::conflict_display_value(
1249                &mut connection,
1250                workspace_id,
1251                field.as_str(),
1252                &detail.remote_value,
1253            )
1254            .await
1255            .map_err(Error::from_internal)?;
1256            conflicts.push(Conflict {
1257                task_id: task_id.clone(),
1258                field: ConflictField::from_task_field(field),
1259                local_value: bounded_conflict_display_value(local_value),
1260                remote_value: bounded_conflict_display_value(remote_value),
1261                variant_a: detail.variant_a,
1262                variant_b: detail.variant_b,
1263            });
1264        }
1265        Ok(conflicts)
1266    }
1267
1268    pub async fn resolve_conflict(
1269        &self,
1270        workspace_id: &WorkspaceId,
1271        task_id: &TaskId,
1272        field: ConflictField,
1273        variant_a: String,
1274        variant_b: String,
1275        resolution: ConflictResolution,
1276    ) -> Result<TaskRecord, Error> {
1277        let workspace = self.workspace(workspace_id).await?;
1278        let field_name = field.as_str();
1279        let resolution = match &resolution {
1280            ConflictResolution::Local => crate::operations::ConflictResolutionValue::Local,
1281            ConflictResolution::Remote => crate::operations::ConflictResolutionValue::Remote,
1282            ConflictResolution::Explicit(value) => {
1283                crate::operations::ConflictResolutionValue::Explicit(value)
1284            }
1285        };
1286        let mut connection = self
1287            .database
1288            .acquire_writer()
1289            .await
1290            .map_err(Error::from_internal)?;
1291        crate::operations::resolve_conflict_transaction(
1292            &mut connection,
1293            &workspace,
1294            task_id,
1295            field_name,
1296            crate::operations::ExpectedConflictIdentity {
1297                variant_a: &variant_a,
1298                variant_b: &variant_b,
1299            },
1300            resolution,
1301        )
1302        .await
1303        .map(|outcome| TaskRecord::from(outcome.task))
1304        .map_err(Error::from_internal)
1305    }
1306
1307    async fn workspace(&self, workspace_id: &WorkspaceId) -> Result<Workspace, Error> {
1308        self.database
1309            .workspace_for_id(workspace_id)
1310            .await
1311            .map_err(|error| {
1312                let error = Error::from_internal(error);
1313                if error.code == ErrorCode::NotFound {
1314                    Error::new(
1315                        ErrorCode::NotFound,
1316                        format!("workspace not found: {workspace_id}"),
1317                    )
1318                } else {
1319                    error
1320                }
1321            })
1322    }
1323}
1324
1325fn validate_project(project: &str) -> Result<(), Error> {
1326    if crate::projects::normalize_key(project).is_empty() {
1327        return Err(Error::new(
1328            ErrorCode::Validation,
1329            "project must contain at least one letter or number".to_string(),
1330        ));
1331    }
1332    Ok(())
1333}
1334
1335fn validate_optional_date(field: &str, value: Option<&str>) -> Result<(), Error> {
1336    let Some(value) = value else {
1337        return Ok(());
1338    };
1339    if value.is_empty() {
1340        return Err(Error::new(
1341            ErrorCode::Validation,
1342            format!("{field} must be absent or contain a date"),
1343        ));
1344    }
1345    let result = match field {
1346        "available_at" => crate::time_validation::validate_available_at_value(value),
1347        "due_on" => crate::time_validation::validate_due_on_value(value),
1348        _ => unreachable!("consumer API validates only task dates"),
1349    };
1350    result.map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
1351}
1352
1353fn validate_date_update(field: &str, update: &OptionalDateUpdate) -> Result<(), Error> {
1354    match update {
1355        OptionalDateUpdate::Unchanged | OptionalDateUpdate::Clear => Ok(()),
1356        OptionalDateUpdate::Set(value) => validate_optional_date(field, Some(value)),
1357    }
1358}
1359
1360fn parse_date(field: &str, value: &str) -> Result<NaiveDate, Error> {
1361    NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| {
1362        Error::new(
1363            ErrorCode::Validation,
1364            format!("{field} must use YYYY-MM-DD"),
1365        )
1366    })
1367}
1368
1369fn parse_local_time(field: &str, value: &str) -> Result<NaiveTime, Error> {
1370    NaiveTime::parse_from_str(value, "%H:%M")
1371        .or_else(|_| NaiveTime::parse_from_str(value, "%H:%M:%S"))
1372        .map_err(|_| {
1373            Error::new(
1374                ErrorCode::Validation,
1375                format!("{field} must use HH:MM or HH:MM:SS"),
1376            )
1377        })
1378}
1379
1380#[derive(Debug, Clone, PartialEq, Eq)]
1381pub struct StorageLayout {
1382    pub root: PathBuf,
1383    pub objects: PathBuf,
1384    pub staging: PathBuf,
1385    pub trash: PathBuf,
1386    pub previews: PathBuf,
1387}
1388
1389#[derive(Debug, Clone, PartialEq, Eq)]
1390pub struct WorkspaceRecord {
1391    pub id: WorkspaceId,
1392    pub key: String,
1393    pub name: String,
1394}
1395
1396impl From<Workspace> for WorkspaceRecord {
1397    fn from(workspace: Workspace) -> Self {
1398        Self {
1399            id: workspace.id,
1400            key: workspace.key,
1401            name: workspace.name,
1402        }
1403    }
1404}
1405
1406#[derive(Debug, Clone, PartialEq, Eq)]
1407pub struct WorkspaceQueueSummary {
1408    pub workspace: WorkspaceRecord,
1409    pub open_task_count: u32,
1410}
1411
1412#[derive(Debug, Clone, PartialEq, Eq)]
1413pub struct ProjectRecord {
1414    pub id: ProjectId,
1415    pub key: String,
1416    pub name: String,
1417}
1418
1419impl From<Project> for ProjectRecord {
1420    fn from(project: Project) -> Self {
1421        Self {
1422            id: project.id,
1423            key: project.key,
1424            name: project.name,
1425        }
1426    }
1427}
1428
1429#[derive(Debug, Clone, PartialEq, Eq)]
1430pub struct QueueState {
1431    pub selected_workspace: WorkspaceRecord,
1432    pub workspaces: Vec<WorkspaceQueueSummary>,
1433    pub projects: Vec<ProjectRecord>,
1434    pub labels: Vec<String>,
1435    pub queue: QueueReport,
1436}
1437
1438#[derive(Debug, Clone, PartialEq, Eq)]
1439pub struct TaskListRow {
1440    pub id: TaskId,
1441    pub title: String,
1442    pub display_ref: String,
1443    pub project_key: String,
1444    pub status: TaskStatus,
1445    pub priority: TaskPriority,
1446    pub due_on: Option<String>,
1447    pub is_epic: bool,
1448}
1449
1450impl From<TaskListItem> for TaskListRow {
1451    fn from(item: TaskListItem) -> Self {
1452        Self {
1453            id: item.task.id,
1454            title: item.task.title,
1455            display_ref: item.display_ref,
1456            project_key: item.task.project_key,
1457            status: item.task.status,
1458            priority: item.task.priority,
1459            due_on: item.task.due_on,
1460            is_epic: item.task.is_epic,
1461        }
1462    }
1463}
1464
1465#[derive(Debug, Clone, PartialEq, Eq)]
1466pub struct TaskSearchResult {
1467    pub task: TaskListRow,
1468    pub matched_field: SearchMatchedField,
1469    pub snippet: Option<String>,
1470}
1471
1472pub use crate::query::TaskNote;
1473
1474#[derive(Debug, Clone, PartialEq, Eq)]
1475pub struct TaskLink {
1476    pub task_id: TaskId,
1477    pub display_ref: String,
1478    pub title: String,
1479    pub project_key: Option<String>,
1480    pub status: TaskStatus,
1481    pub deleted: bool,
1482    pub unresolved: bool,
1483}
1484
1485#[derive(Debug, Clone, PartialEq, Eq)]
1486pub enum AttachmentRead {
1487    Bytes { bytes: Vec<u8> },
1488    Unavailable,
1489    Missing,
1490    Corrupt,
1491    Invalidated,
1492}
1493
1494#[derive(Debug, Clone, PartialEq, Eq)]
1495pub struct TaskAttachment {
1496    pub attachment_id: String,
1497    pub media_type: String,
1498    pub byte_size: i64,
1499    pub filename: Option<String>,
1500    pub alt_text: Option<String>,
1501    pub width: Option<i64>,
1502    pub height: Option<i64>,
1503    pub availability: AttachmentAvailability,
1504    pub has_blob: bool,
1505}
1506
1507#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1508pub enum TaskActivityKind {
1509    Created,
1510    Title,
1511    Description,
1512    Status,
1513    Priority,
1514    Project,
1515    Deletion,
1516    Availability,
1517    DueDate,
1518    Epic,
1519    Attachment,
1520    Metadata,
1521    Label,
1522    Note,
1523    Blocker,
1524    Related,
1525    Conflict,
1526    Other,
1527}
1528
1529#[derive(Debug, Clone, PartialEq, Eq)]
1530pub struct TaskActivity {
1531    pub change_id: String,
1532    pub created_at: String,
1533    pub kind: TaskActivityKind,
1534    pub summary: String,
1535    /// RFC 3339 UTC instant a task was deferred to, for availability changes
1536    /// and availability conflict resolutions that set one. Clients format it
1537    /// for display.
1538    pub available_at: Option<String>,
1539    pub anchors_queue_idle: bool,
1540}
1541
1542#[derive(Debug, Clone, PartialEq, Eq)]
1543pub struct TaskDetail {
1544    pub id: TaskId,
1545    pub workspace_id: WorkspaceId,
1546    pub workspace_key: String,
1547    pub workspace_name: String,
1548    pub title: String,
1549    pub description: String,
1550    pub display_ref: String,
1551    pub project_key: String,
1552    pub project_name: String,
1553    pub status: TaskStatus,
1554    pub priority: TaskPriority,
1555    pub created_at: String,
1556    pub updated_at: String,
1557    pub available_at: Option<String>,
1558    pub due_on: Option<String>,
1559    pub deleted: bool,
1560    pub is_epic: bool,
1561    pub labels: Vec<String>,
1562    pub notes: Vec<TaskNote>,
1563    pub activity: Vec<TaskActivity>,
1564    pub blocked_by: Vec<TaskLink>,
1565    pub blocks: Vec<TaskLink>,
1566    pub related: Vec<TaskLink>,
1567    pub epic_parent: Option<TaskLink>,
1568    pub epic_children: Vec<TaskLink>,
1569    pub epic_done_count: u32,
1570    pub epic_total_count: u32,
1571    pub epic_progress: Option<EpicProgress>,
1572    pub attachments: Vec<TaskAttachment>,
1573    pub unresolved_conflict_count: u32,
1574    pub unresolved_conflict_fields: Vec<ConflictField>,
1575}
1576
1577impl TaskDetail {
1578    fn from_detail(detail: crate::query::TaskDetail, workspace: Workspace) -> Result<Self, Error> {
1579        let item = detail.item;
1580        let anchor = item.queue_idle_activity_index();
1581        let activity = item
1582            .activity
1583            .iter()
1584            .enumerate()
1585            .map(|(index, action)| TaskActivity {
1586                change_id: action.change_id.clone(),
1587                created_at: action.created_at.clone(),
1588                kind: match action.verb.as_str() {
1589                    "create" => TaskActivityKind::Created,
1590                    "title" => TaskActivityKind::Title,
1591                    "details" => TaskActivityKind::Description,
1592                    "status" => TaskActivityKind::Status,
1593                    "priority" => TaskActivityKind::Priority,
1594                    "project" => TaskActivityKind::Project,
1595                    "delete" => TaskActivityKind::Deletion,
1596                    "availability" => TaskActivityKind::Availability,
1597                    "due date" => TaskActivityKind::DueDate,
1598                    "epic" => TaskActivityKind::Epic,
1599                    "attachment" => TaskActivityKind::Attachment,
1600                    "metadata" => TaskActivityKind::Metadata,
1601                    "label" => TaskActivityKind::Label,
1602                    "note" => TaskActivityKind::Note,
1603                    "blocker" => TaskActivityKind::Blocker,
1604                    "related link" => TaskActivityKind::Related,
1605                    "conflict" => TaskActivityKind::Conflict,
1606                    _ => TaskActivityKind::Other,
1607                },
1608                summary: action.task_activity_summary(&item.task.title),
1609                available_at: action.task_activity_available_at(),
1610                anchors_queue_idle: anchor == Some(index),
1611            })
1612            .collect();
1613        let task = item.task;
1614        let blocked_by = detail
1615            .dependencies
1616            .depends_on
1617            .into_iter()
1618            .map(task_link_from_dependency)
1619            .collect();
1620        let blocks = detail
1621            .dependencies
1622            .blocks
1623            .into_iter()
1624            .map(task_link_from_dependency)
1625            .collect();
1626        let related = detail
1627            .related
1628            .into_iter()
1629            .map(|link| TaskLink {
1630                task_id: link.task_id,
1631                display_ref: link.display_ref,
1632                title: link.title,
1633                project_key: None,
1634                status: link.status,
1635                deleted: link.deleted,
1636                unresolved: false,
1637            })
1638            .collect();
1639        let epic_parent = item.epic_parent.map(task_link_from_enriched).transpose()?;
1640        let epic_children = item
1641            .epic_children
1642            .into_iter()
1643            .map(task_link_from_enriched)
1644            .collect::<Result<Vec<_>, _>>()?;
1645        let epic_progress = item.epic_rollup.clone().map(EpicProgress::from);
1646        let (epic_done_count, epic_total_count) = item
1647            .epic_rollup
1648            .map(|rollup| {
1649                (
1650                    rollup.done.min(u32::MAX as usize) as u32,
1651                    rollup.total.min(u32::MAX as usize) as u32,
1652                )
1653            })
1654            .unwrap_or_default();
1655        let attachments = item
1656            .attachments
1657            .into_iter()
1658            .map(|attachment| TaskAttachment {
1659                attachment_id: attachment.attachment_id,
1660                media_type: attachment.media_type,
1661                byte_size: attachment.byte_size,
1662                filename: attachment.filename,
1663                alt_text: attachment.alt_text,
1664                width: attachment.width,
1665                height: attachment.height,
1666                availability: attachment.bytes_state,
1667                has_blob: attachment.has_blob,
1668            })
1669            .collect();
1670        Ok(Self {
1671            id: task.id,
1672            workspace_id: task.workspace_id,
1673            workspace_key: workspace.key,
1674            workspace_name: workspace.name,
1675            title: task.title,
1676            description: task.description,
1677            display_ref: item.display_ref,
1678            project_key: task.project_key,
1679            project_name: detail.project_name,
1680            status: task.status,
1681            priority: task.priority,
1682            created_at: task.created_at,
1683            updated_at: task.updated_at,
1684            available_at: task.available_at,
1685            due_on: task.due_on,
1686            deleted: task.deleted,
1687            is_epic: task.is_epic,
1688            labels: item.labels,
1689            notes: detail.notes,
1690            activity,
1691            blocked_by,
1692            blocks,
1693            related,
1694            epic_parent,
1695            epic_children,
1696            epic_done_count,
1697            epic_total_count,
1698            epic_progress,
1699            attachments,
1700            unresolved_conflict_count: detail.conflicts.len().min(u32::MAX as usize) as u32,
1701            unresolved_conflict_fields: detail
1702                .conflicts
1703                .into_iter()
1704                .filter(|conflict| !conflict.field.starts_with("metadata:"))
1705                .map(|conflict| {
1706                    TaskField::parse_or_unknown(&conflict.field)
1707                        .map(ConflictField::from_task_field)
1708                        .map_err(Error::from_internal)
1709                })
1710                .collect::<Result<Vec<_>, _>>()?,
1711        })
1712    }
1713}
1714
1715fn task_link_from_dependency(link: crate::query::TaskDependencyItem) -> TaskLink {
1716    TaskLink {
1717        task_id: link.task.id,
1718        display_ref: link.display_ref,
1719        title: link.task.title,
1720        project_key: Some(link.task.project_key),
1721        status: link.task.status,
1722        deleted: link.task.deleted,
1723        unresolved: link.unresolved,
1724    }
1725}
1726
1727fn task_link_from_enriched(link: crate::query::TaskDependencyLink) -> Result<TaskLink, Error> {
1728    let status =
1729        TaskStatus::parse(&link.status).map_err(|error| Error::from_internal(error.into()))?;
1730    Ok(TaskLink {
1731        task_id: link.task_id,
1732        display_ref: link.display_ref,
1733        title: link.title,
1734        project_key: None,
1735        status,
1736        deleted: false,
1737        unresolved: link.unresolved,
1738    })
1739}
1740
1741#[derive(Debug, Clone, PartialEq, Eq)]
1742pub struct TaskCapture {
1743    pub title: String,
1744    pub description: String,
1745    pub project: Option<String>,
1746    pub status: TaskStatus,
1747    pub priority: TaskPriority,
1748    pub source: TaskSource,
1749    pub due_on: Option<String>,
1750    pub labels: Vec<String>,
1751}
1752
1753#[derive(Debug, Clone, PartialEq, Eq)]
1754pub struct TaskCaptureResult {
1755    pub task_id: TaskId,
1756    pub display_ref: String,
1757    pub undo_token: String,
1758    /// Optional refreshed projection. Absence does not change the committed receipt.
1759    pub state: Option<QueueState>,
1760}
1761
1762#[derive(serde::Serialize, serde::Deserialize)]
1763struct CaptureUndoToken {
1764    workspace_id: WorkspaceId,
1765    task_id: TaskId,
1766    expected: TaskUndoSnapshot,
1767}
1768
1769#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1770pub enum QueueMutationKind {
1771    Start,
1772    Done,
1773    Snooze,
1774    SetPriority,
1775}
1776
1777#[derive(Debug, Clone, PartialEq, Eq)]
1778pub struct QueueMutation {
1779    pub kind: QueueMutationKind,
1780    pub priority: Option<TaskPriority>,
1781    pub local_date: Option<String>,
1782    pub time_zone: Option<String>,
1783}
1784
1785impl QueueMutation {
1786    fn into_internal(self) -> Result<InternalConsumerTaskMutation, Error> {
1787        match self.kind {
1788            QueueMutationKind::Start => Ok(InternalConsumerTaskMutation::Start),
1789            QueueMutationKind::Done => Ok(InternalConsumerTaskMutation::Done),
1790            QueueMutationKind::SetPriority => self
1791                .priority
1792                .map(|priority| InternalConsumerTaskMutation::SetPriority {
1793                    priority: priority.as_str().to_string(),
1794                })
1795                .ok_or_else(|| {
1796                    Error::new(
1797                        ErrorCode::Validation,
1798                        "priority is required for this quick action".to_string(),
1799                    )
1800                }),
1801            QueueMutationKind::Snooze => {
1802                let local_date = self.local_date.ok_or_else(|| {
1803                    Error::new(
1804                        ErrorCode::Validation,
1805                        "local date is required for snooze".to_string(),
1806                    )
1807                })?;
1808                let time_zone = self.time_zone.ok_or_else(|| {
1809                    Error::new(
1810                        ErrorCode::Validation,
1811                        "time zone is required for snooze".to_string(),
1812                    )
1813                })?;
1814                Ok(InternalConsumerTaskMutation::Snooze {
1815                    available_at: tomorrow_start(&local_date, &time_zone)?,
1816                })
1817            }
1818        }
1819    }
1820}
1821
1822#[derive(Debug, Clone, PartialEq, Eq)]
1823pub struct QueueMutationResult {
1824    pub task_id: TaskId,
1825    pub undo_token: String,
1826    /// Optional refreshed projection. Absence does not change the committed receipt.
1827    pub state: Option<QueueState>,
1828}
1829
1830#[derive(Debug, Clone, PartialEq, Eq)]
1831pub struct DetailStatusReceipt {
1832    pub status: TaskStatus,
1833    pub undo_token: Option<String>,
1834}
1835
1836#[derive(Debug, Clone, PartialEq, Eq)]
1837pub struct TaskDeletionReceipt {
1838    pub deleted: bool,
1839    pub undo_token: Option<String>,
1840}
1841
1842#[derive(serde::Serialize, serde::Deserialize)]
1843struct MutationUndoToken {
1844    workspace_id: WorkspaceId,
1845    task_id: TaskId,
1846    mutation: InternalConsumerTaskMutation,
1847    before: TaskUndoSnapshot,
1848    expected: TaskUndoSnapshot,
1849}
1850
1851fn tomorrow_start(local_date: &str, time_zone: &str) -> Result<String, Error> {
1852    let date = NaiveDate::parse_from_str(local_date, "%Y-%m-%d")
1853        .map_err(|_| Error::new(ErrorCode::Validation, "invalid local date".to_string()))?
1854        .checked_add_days(Days::new(1))
1855        .ok_or_else(|| Error::new(ErrorCode::Validation, "invalid local date".to_string()))?;
1856    let zone = time_zone
1857        .parse::<chrono_tz::Tz>()
1858        .map_err(|_| Error::new(ErrorCode::Validation, "invalid IANA time zone".to_string()))?;
1859    let mut local = date.and_time(NaiveTime::MIN);
1860    let resolved = loop {
1861        match zone.from_local_datetime(&local) {
1862            LocalResult::Single(value) => break value,
1863            LocalResult::Ambiguous(first, second) => break first.min(second),
1864            LocalResult::None => {
1865                local = local
1866                    .checked_add_signed(chrono::Duration::minutes(1))
1867                    .ok_or_else(|| {
1868                        Error::new(ErrorCode::Validation, "invalid local date".to_string())
1869                    })?;
1870                if local.date() != date {
1871                    return Err(Error::new(
1872                        ErrorCode::Validation,
1873                        "time zone has no valid instant on the next local day".to_string(),
1874                    ));
1875                }
1876            }
1877        }
1878    };
1879    Ok(resolved
1880        .with_timezone(&Utc)
1881        .format("%Y-%m-%dT%H:%M:%SZ")
1882        .to_string())
1883}
1884
1885#[derive(Debug, Clone, PartialEq, Eq)]
1886pub struct MetadataFieldRecord {
1887    pub id: MetadataFieldId,
1888    pub workspace_id: WorkspaceId,
1889    pub key: String,
1890    pub task_count: usize,
1891    pub series_count: usize,
1892}
1893
1894impl From<MetadataFieldUsage> for MetadataFieldRecord {
1895    fn from(value: MetadataFieldUsage) -> Self {
1896        Self {
1897            id: value.field.id,
1898            workspace_id: value.field.workspace_id,
1899            key: value.field.key,
1900            task_count: value.task_count,
1901            series_count: value.series_count,
1902        }
1903    }
1904}
1905
1906#[derive(Debug, Clone, PartialEq, Eq)]
1907pub struct MetadataValueRecord {
1908    pub field_id: MetadataFieldId,
1909    pub key: String,
1910    pub value: String,
1911}
1912
1913impl From<TaskMetadataValue> for MetadataValueRecord {
1914    fn from(value: TaskMetadataValue) -> Self {
1915        Self {
1916            field_id: value.field_id,
1917            key: value.key,
1918            value: value.value,
1919        }
1920    }
1921}
1922
1923#[derive(Debug, Clone, PartialEq, Eq)]
1924pub struct MetadataInput {
1925    pub key: String,
1926    pub value: String,
1927}
1928
1929impl From<MetadataInput> for TaskMetadataInput {
1930    fn from(input: MetadataInput) -> Self {
1931        Self {
1932            expected_field_id: None,
1933            key: input.key,
1934            value: input.value,
1935        }
1936    }
1937}
1938
1939#[derive(Debug, Clone, PartialEq, Eq)]
1940pub struct CreateTask {
1941    pub title: String,
1942    pub description: String,
1943    pub project: String,
1944    pub status: TaskStatus,
1945    pub priority: TaskPriority,
1946    pub metadata: Vec<MetadataInput>,
1947    pub available_at: Option<String>,
1948    pub due_on: Option<String>,
1949}
1950
1951#[derive(Debug, Clone, PartialEq, Eq)]
1952pub struct TaskEditContext {
1953    pub detail: TaskDetail,
1954    pub projects: Vec<ProjectRecord>,
1955    pub labels: Vec<String>,
1956}
1957
1958#[derive(Debug, Clone, PartialEq, Eq, Default)]
1959pub struct TaskEdit {
1960    pub title: Option<String>,
1961    pub description: Option<String>,
1962    pub project_id: Option<ProjectId>,
1963    pub status: Option<TaskStatus>,
1964    pub priority: Option<TaskPriority>,
1965    pub add_labels: Vec<String>,
1966    pub remove_labels: Vec<String>,
1967    pub available_at: OptionalDateUpdate,
1968    pub due_on: OptionalDateUpdate,
1969}
1970
1971#[derive(Debug, Clone, PartialEq, Eq, Default)]
1972pub struct UpdateTask {
1973    pub title: Option<String>,
1974    pub description: Option<String>,
1975    pub project: Option<String>,
1976    pub status: Option<TaskStatus>,
1977    pub priority: Option<TaskPriority>,
1978    pub set_metadata: Vec<MetadataInput>,
1979    pub remove_metadata: Vec<String>,
1980    pub available_at: OptionalDateUpdate,
1981    pub due_on: OptionalDateUpdate,
1982}
1983
1984#[derive(Debug, Clone, PartialEq, Eq, Default)]
1985pub enum OptionalDateUpdate {
1986    #[default]
1987    Unchanged,
1988    Set(String),
1989    Clear,
1990}
1991
1992impl OptionalDateUpdate {
1993    fn into_internal(self) -> Option<Option<String>> {
1994        match self {
1995            Self::Unchanged => None,
1996            Self::Set(value) => Some(Some(value)),
1997            Self::Clear => Some(None),
1998        }
1999    }
2000}
2001
2002#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2003pub enum RecurrenceWeekday {
2004    Monday,
2005    Tuesday,
2006    Wednesday,
2007    Thursday,
2008    Friday,
2009    Saturday,
2010    Sunday,
2011}
2012
2013impl From<RecurrenceWeekday> for Weekday {
2014    fn from(value: RecurrenceWeekday) -> Self {
2015        match value {
2016            RecurrenceWeekday::Monday => Self::Mon,
2017            RecurrenceWeekday::Tuesday => Self::Tue,
2018            RecurrenceWeekday::Wednesday => Self::Wed,
2019            RecurrenceWeekday::Thursday => Self::Thu,
2020            RecurrenceWeekday::Friday => Self::Fri,
2021            RecurrenceWeekday::Saturday => Self::Sat,
2022            RecurrenceWeekday::Sunday => Self::Sun,
2023        }
2024    }
2025}
2026
2027impl From<Weekday> for RecurrenceWeekday {
2028    fn from(value: Weekday) -> Self {
2029        match value {
2030            Weekday::Mon => Self::Monday,
2031            Weekday::Tue => Self::Tuesday,
2032            Weekday::Wed => Self::Wednesday,
2033            Weekday::Thu => Self::Thursday,
2034            Weekday::Fri => Self::Friday,
2035            Weekday::Sat => Self::Saturday,
2036            Weekday::Sun => Self::Sunday,
2037        }
2038    }
2039}
2040
2041#[derive(Debug, Clone, PartialEq, Eq)]
2042pub struct RecurrenceRule {
2043    pub frequency: RecurrenceFrequency,
2044    pub interval: u32,
2045    pub weekdays: Vec<RecurrenceWeekday>,
2046}
2047
2048impl RecurrenceRule {
2049    fn into_internal(self) -> Result<InternalRecurrenceRule, Error> {
2050        InternalRecurrenceRule::new(
2051            self.frequency,
2052            self.interval,
2053            WeekdaySet::from_weekdays(self.weekdays.into_iter().map(Into::into)),
2054        )
2055        .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
2056    }
2057}
2058
2059impl From<InternalRecurrenceRule> for RecurrenceRule {
2060    fn from(value: InternalRecurrenceRule) -> Self {
2061        Self {
2062            frequency: value.frequency(),
2063            interval: value.interval(),
2064            weekdays: value.weekdays_set().iter().map(Into::into).collect(),
2065        }
2066    }
2067}
2068
2069#[derive(Debug, Clone, PartialEq, Eq)]
2070pub struct RecurrenceScheduleInput {
2071    pub rule: RecurrenceRule,
2072    pub timezone: String,
2073    pub start_on: String,
2074    pub available_local_time: Option<String>,
2075    pub due_policy: RecurrenceDuePolicy,
2076}
2077
2078impl RecurrenceScheduleInput {
2079    fn into_internal(self) -> Result<RecurrenceSchedule, Error> {
2080        let timezone = self
2081            .timezone
2082            .parse::<TimeZoneId>()
2083            .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))?;
2084        let start_on = parse_date("start_on", &self.start_on)?;
2085        let available_local_time = self
2086            .available_local_time
2087            .map(|value| parse_local_time("available_local_time", &value))
2088            .transpose()?;
2089        Ok(RecurrenceSchedule::new(
2090            self.rule.into_internal()?,
2091            timezone,
2092            start_on,
2093            available_local_time,
2094            self.due_policy,
2095        ))
2096    }
2097}
2098
2099#[derive(Debug, Clone, PartialEq, Eq)]
2100pub struct CreateRecurrenceSeries {
2101    pub title: String,
2102    pub description: String,
2103    pub project: String,
2104    pub priority: TaskPriority,
2105    pub initial_status: TaskStatus,
2106    pub labels: Vec<String>,
2107    pub metadata: Vec<MetadataInput>,
2108    pub schedule: RecurrenceScheduleInput,
2109}
2110
2111#[derive(Debug, Clone, PartialEq, Eq, Default)]
2112pub enum OptionalLocalTimeUpdate {
2113    #[default]
2114    Unchanged,
2115    Set(String),
2116    Clear,
2117}
2118
2119impl OptionalLocalTimeUpdate {
2120    fn into_internal(self) -> Result<Option<Option<NaiveTime>>, Error> {
2121        match self {
2122            Self::Unchanged => Ok(None),
2123            Self::Set(value) => Ok(Some(Some(parse_local_time(
2124                "available_local_time",
2125                &value,
2126            )?))),
2127            Self::Clear => Ok(Some(None)),
2128        }
2129    }
2130}
2131
2132#[derive(Debug, Clone, PartialEq, Eq, Default)]
2133pub struct UpdateRecurrenceTemplate {
2134    pub title: Option<String>,
2135    pub description: Option<String>,
2136    pub project: Option<String>,
2137    pub priority: Option<TaskPriority>,
2138    pub initial_status: Option<TaskStatus>,
2139    pub labels: Option<Vec<String>>,
2140    pub set_metadata: Vec<MetadataInput>,
2141    pub remove_metadata: Vec<String>,
2142    pub available_local_time: OptionalLocalTimeUpdate,
2143    pub due_policy: Option<RecurrenceDuePolicy>,
2144}
2145
2146#[derive(Debug, Clone, PartialEq, Eq)]
2147pub struct RecurrenceSeriesRecord {
2148    pub workspace_id: WorkspaceId,
2149    pub id: RecurrenceSeriesId,
2150    pub title: String,
2151    pub description: String,
2152    pub project_id: ProjectId,
2153    pub priority: TaskPriority,
2154    pub initial_status: TaskStatus,
2155    pub rule: RecurrenceRule,
2156    pub timezone: String,
2157    pub start_on: String,
2158    pub available_local_time: Option<String>,
2159    pub due_policy: RecurrenceDuePolicy,
2160    pub state: RecurrenceSeriesState,
2161    pub stopped_at: Option<String>,
2162    pub created_at: String,
2163    pub updated_at: String,
2164}
2165
2166impl From<RecurrenceSeries> for RecurrenceSeriesRecord {
2167    fn from(value: RecurrenceSeries) -> Self {
2168        Self {
2169            workspace_id: value.workspace_id,
2170            id: value.id,
2171            title: value.title,
2172            description: value.description,
2173            project_id: value.project_id,
2174            priority: value.priority,
2175            initial_status: value.initial_status,
2176            rule: value.rule.into(),
2177            timezone: value.timezone.to_string(),
2178            start_on: value.start_on.to_string(),
2179            available_local_time: value
2180                .available_local_time
2181                .map(|time| time.format("%H:%M:%S").to_string()),
2182            due_policy: value.due_policy,
2183            state: value.state,
2184            stopped_at: value.stopped_at,
2185            created_at: value.created_at,
2186            updated_at: value.updated_at,
2187        }
2188    }
2189}
2190
2191#[derive(Debug, Clone, PartialEq, Eq)]
2192pub struct RecurrenceOccurrenceRecord {
2193    pub series_id: RecurrenceSeriesId,
2194    pub slot_on: String,
2195    pub task_id: Option<TaskId>,
2196    pub outcome: Option<RecurrenceOutcome>,
2197    pub resolved_at: Option<String>,
2198    pub projection_state: RecurrenceProjectionState,
2199    pub archived_at: Option<String>,
2200}
2201
2202impl From<RecurrenceOccurrence> for RecurrenceOccurrenceRecord {
2203    fn from(value: RecurrenceOccurrence) -> Self {
2204        Self {
2205            series_id: value.series_id,
2206            slot_on: value.slot_on.to_string(),
2207            task_id: value.task_id,
2208            outcome: value.outcome,
2209            resolved_at: value.resolved_at,
2210            projection_state: value.projection_state,
2211            archived_at: value.archived_at,
2212        }
2213    }
2214}
2215
2216#[derive(Debug, Clone, PartialEq, Eq)]
2217pub struct RecurrenceCreateResult {
2218    pub series: RecurrenceSeriesRecord,
2219    pub series_ref: String,
2220    pub occurrence: RecurrenceOccurrenceRecord,
2221    pub task: TaskRecord,
2222}
2223
2224#[derive(Debug, Clone, PartialEq, Eq)]
2225pub struct RecurrenceTemplateUpdateResult {
2226    pub series: RecurrenceSeriesRecord,
2227    pub changed: bool,
2228}
2229
2230#[derive(Debug, Clone, PartialEq, Eq)]
2231pub struct RecurrenceResolveResult {
2232    pub series: RecurrenceSeriesRecord,
2233    pub occurrence: RecurrenceOccurrenceRecord,
2234    pub task: TaskRecord,
2235    pub successor: Option<TaskRecord>,
2236}
2237
2238#[derive(Debug, Clone, PartialEq, Eq)]
2239pub struct RecurrenceStateResult {
2240    pub series: RecurrenceSeriesRecord,
2241    pub occurrence: Option<RecurrenceOccurrenceRecord>,
2242}
2243
2244impl From<crate::operations::RecurrenceStateOutcome> for RecurrenceStateResult {
2245    fn from(value: crate::operations::RecurrenceStateOutcome) -> Self {
2246        Self {
2247            series: value.series.into(),
2248            occurrence: value.occurrence.map(Into::into),
2249        }
2250    }
2251}
2252
2253#[derive(Debug, Clone, PartialEq, Eq)]
2254pub struct RecurrenceRefResolution {
2255    pub series_id: RecurrenceSeriesId,
2256    pub series_ref: String,
2257}
2258
2259#[derive(Debug, Clone, PartialEq, Eq, Default)]
2260pub struct RecurrenceCounts {
2261    pub completed: u64,
2262    pub skipped: u64,
2263    pub missed: u64,
2264    pub pause_intervals: u64,
2265    pub latest_slot_on: Option<String>,
2266    pub latest_outcome: Option<RecurrenceOutcome>,
2267}
2268
2269impl From<InternalRecurrenceCounts> for RecurrenceCounts {
2270    fn from(value: InternalRecurrenceCounts) -> Self {
2271        Self {
2272            completed: value.completed as u64,
2273            skipped: value.skipped as u64,
2274            missed: value.missed as u64,
2275            pause_intervals: value.pause_intervals as u64,
2276            latest_slot_on: value.latest_slot_on,
2277            latest_outcome: value.latest_outcome,
2278        }
2279    }
2280}
2281
2282#[derive(Debug, Clone, PartialEq, Eq)]
2283pub struct RecurrenceSeriesSummary {
2284    pub series: RecurrenceSeriesRecord,
2285    pub series_ref: String,
2286    pub rule_label: String,
2287    pub current_slot_on: Option<String>,
2288    pub current_task_ref: Option<String>,
2289    pub counts: RecurrenceCounts,
2290}
2291
2292impl From<InternalRecurrenceSeriesSummary> for RecurrenceSeriesSummary {
2293    fn from(value: InternalRecurrenceSeriesSummary) -> Self {
2294        Self {
2295            series: value.series.into(),
2296            series_ref: value.series_ref,
2297            rule_label: value.rule_label,
2298            current_slot_on: value.current_slot_on,
2299            current_task_ref: value.current_task_ref,
2300            counts: value.counts.into(),
2301        }
2302    }
2303}
2304
2305#[derive(Debug, Clone, PartialEq, Eq)]
2306pub struct RecurrenceSeriesConflict {
2307    pub field: String,
2308    pub variant_a: String,
2309    pub local_value: String,
2310    pub variant_b: String,
2311    pub remote_value: String,
2312}
2313
2314#[derive(Debug, Clone, PartialEq, Eq)]
2315pub struct RecurrenceSeriesDetail {
2316    pub series: RecurrenceSeriesRecord,
2317    pub labels: Vec<String>,
2318    pub metadata: Vec<MetadataValueRecord>,
2319    pub summary: RecurrenceSeriesSummary,
2320    pub current_occurrence: Option<RecurrenceOccurrenceRecord>,
2321    pub lifecycle_conflicts: Vec<RecurrenceSeriesConflict>,
2322}
2323
2324impl From<InternalRecurrenceSeriesDetail> for RecurrenceSeriesDetail {
2325    fn from(value: InternalRecurrenceSeriesDetail) -> Self {
2326        Self {
2327            series: value.series.into(),
2328            labels: value.labels,
2329            metadata: value.metadata.into_iter().map(Into::into).collect(),
2330            summary: value.summary.into(),
2331            current_occurrence: value.current_occurrence.map(Into::into),
2332            lifecycle_conflicts: value
2333                .lifecycle_conflicts
2334                .into_iter()
2335                .map(|conflict| RecurrenceSeriesConflict {
2336                    field: conflict.field,
2337                    variant_a: conflict.variant_a,
2338                    local_value: conflict.local_value,
2339                    variant_b: conflict.variant_b,
2340                    remote_value: conflict.remote_value,
2341                })
2342                .collect(),
2343        }
2344    }
2345}
2346
2347#[derive(Debug, Clone, PartialEq, Eq)]
2348pub struct RecurrenceHistoryRow {
2349    pub kind: RecurrenceHistoryKind,
2350    pub slot_on: Option<String>,
2351    pub interval_started_at: Option<String>,
2352    pub interval_ended_at: Option<String>,
2353    pub task_id: Option<TaskId>,
2354    pub task_ref: Option<String>,
2355    pub openable: bool,
2356    pub archived_projection: bool,
2357    pub resolved_at: Option<String>,
2358}
2359
2360impl From<InternalRecurrenceHistoryEntry> for RecurrenceHistoryRow {
2361    fn from(value: InternalRecurrenceHistoryEntry) -> Self {
2362        Self {
2363            kind: value.kind,
2364            slot_on: value.slot_on,
2365            interval_started_at: value.interval_started_at,
2366            interval_ended_at: value.interval_ended_at,
2367            task_id: value.task_id,
2368            task_ref: value.task_ref,
2369            openable: value.openable,
2370            archived_projection: value.archived_projection,
2371            resolved_at: value.resolved_at,
2372        }
2373    }
2374}
2375
2376#[derive(Debug, Clone, PartialEq, Eq)]
2377pub struct RecurrenceHistoryPage {
2378    pub series_ref: String,
2379    pub items: Vec<RecurrenceHistoryRow>,
2380    pub offset: usize,
2381    pub limit: usize,
2382    pub total: usize,
2383    pub has_more: bool,
2384}
2385
2386#[derive(Debug, Clone, PartialEq, Eq)]
2387pub struct TaskRecurrenceSummary {
2388    pub series_id: RecurrenceSeriesId,
2389    pub series_ref: String,
2390    pub slot_on: String,
2391    pub rule_label: String,
2392    pub timezone: String,
2393    pub lifecycle: RecurrenceSeriesState,
2394    pub outcome: Option<RecurrenceOutcome>,
2395    pub projection_state: RecurrenceProjectionState,
2396}
2397
2398#[derive(Debug, Clone, PartialEq, Eq)]
2399pub struct RecurrenceTaskGroup {
2400    pub series_id: RecurrenceSeriesId,
2401    pub series_ref: String,
2402    pub counts: RecurrenceCounts,
2403}
2404
2405#[derive(Debug, Clone, PartialEq, Eq)]
2406pub enum QueueDate {
2407    Due { on: String, kind: QueueDateKind },
2408    Deferred { at: String },
2409}
2410
2411#[derive(Debug, Clone, PartialEq, Eq)]
2412pub struct QueueLabelSummary {
2413    pub first: String,
2414    pub remaining_count: u32,
2415}
2416
2417#[derive(Debug, Clone, PartialEq, Eq)]
2418pub struct QueueTaskSummary {
2419    pub id: TaskId,
2420    pub title: String,
2421    pub display_ref: String,
2422    pub project_key: String,
2423    pub status: TaskStatus,
2424    pub priority: TaskPriority,
2425    pub due_on: Option<String>,
2426    pub band: QueueBand,
2427    pub reason: Option<QueueReason>,
2428    pub date: Option<QueueDate>,
2429    pub label: Option<QueueLabelSummary>,
2430    pub is_epic: bool,
2431    pub live_attachment_count: u32,
2432    pub has_conflict: bool,
2433    pub unresolved_blocker_count: i64,
2434}
2435
2436impl From<TaskListItem> for QueueTaskSummary {
2437    fn from(value: TaskListItem) -> Self {
2438        let date = if value.queue.has_deferred_date {
2439            value
2440                .task
2441                .available_at
2442                .clone()
2443                .map(|at| QueueDate::Deferred { at })
2444        } else {
2445            value.queue.date_kind.and_then(|kind| {
2446                value
2447                    .task
2448                    .due_on
2449                    .clone()
2450                    .map(|on| QueueDate::Due { on, kind })
2451            })
2452        };
2453        let label = value
2454            .labels
2455            .first()
2456            .cloned()
2457            .map(|first| QueueLabelSummary {
2458                first,
2459                remaining_count: value.labels.len().saturating_sub(1).min(u32::MAX as usize) as u32,
2460            });
2461        Self {
2462            id: value.task.id,
2463            title: value.task.title,
2464            display_ref: value.display_ref,
2465            project_key: value.task.project_key,
2466            status: value.task.status,
2467            priority: value.task.priority,
2468            due_on: value.task.due_on,
2469            band: value.queue.band,
2470            reason: value.queue.reason,
2471            date,
2472            label,
2473            is_epic: value.task.is_epic,
2474            live_attachment_count: value.live_attachment_count,
2475            has_conflict: value.has_conflict,
2476            unresolved_blocker_count: value.unresolved_blocker_count,
2477        }
2478    }
2479}
2480
2481#[derive(Debug, Clone, PartialEq, Eq)]
2482pub struct QueueReport {
2483    pub tasks: Vec<QueueTaskSummary>,
2484    pub unresolved_conflict_count: u32,
2485}
2486
2487#[derive(Debug, Clone, PartialEq, Eq)]
2488pub struct TaskSummary {
2489    pub task: TaskRecord,
2490    pub display_ref: String,
2491    pub recurrence: Option<TaskRecurrenceSummary>,
2492    pub recurrence_group: Option<RecurrenceTaskGroup>,
2493}
2494
2495impl From<TaskListItem> for TaskSummary {
2496    fn from(value: TaskListItem) -> Self {
2497        Self {
2498            task: value.task.into(),
2499            display_ref: value.display_ref,
2500            recurrence: value.recurrence.map(|summary| TaskRecurrenceSummary {
2501                series_id: summary.series_id,
2502                series_ref: summary.series_ref,
2503                slot_on: summary.slot_on,
2504                rule_label: summary.rule_label,
2505                timezone: summary.timezone,
2506                lifecycle: summary.lifecycle,
2507                outcome: summary.outcome,
2508                projection_state: summary.projection_state,
2509            }),
2510            recurrence_group: value.recurrence_group.map(|group| RecurrenceTaskGroup {
2511                series_id: group.series_id,
2512                series_ref: group.series_ref,
2513                counts: group.counts.into(),
2514            }),
2515        }
2516    }
2517}
2518
2519#[derive(Debug, Clone, PartialEq, Eq)]
2520pub struct RelatedTaskRecord {
2521    pub task_id: TaskId,
2522    pub display_ref: String,
2523    pub title: String,
2524    pub status: TaskStatus,
2525    pub priority: TaskPriority,
2526    pub deleted: bool,
2527    pub linked_at: String,
2528}
2529
2530impl From<crate::query::TaskRelatedLink> for RelatedTaskRecord {
2531    fn from(value: crate::query::TaskRelatedLink) -> Self {
2532        Self {
2533            task_id: value.task_id,
2534            display_ref: value.display_ref,
2535            title: value.title,
2536            status: value.status,
2537            priority: value.priority,
2538            deleted: value.deleted,
2539            linked_at: value.linked_at,
2540        }
2541    }
2542}
2543
2544#[derive(Debug, Clone, PartialEq, Eq)]
2545pub struct RelatedMutationResult {
2546    pub changed: bool,
2547}
2548
2549#[derive(Debug, Clone, PartialEq, Eq)]
2550pub struct TaskRecord {
2551    pub id: TaskId,
2552    pub workspace_id: WorkspaceId,
2553    pub title: String,
2554    pub description: String,
2555    pub project_id: ProjectId,
2556    pub project_key: String,
2557    pub project_prefix: String,
2558    pub status: TaskStatus,
2559    pub priority: TaskPriority,
2560    pub created_at: String,
2561    pub updated_at: String,
2562    pub available_at: Option<String>,
2563    pub due_on: Option<String>,
2564    pub metadata: Vec<MetadataValueRecord>,
2565    pub related: Vec<RelatedTaskRecord>,
2566}
2567
2568impl From<Task> for TaskRecord {
2569    fn from(task: Task) -> Self {
2570        Self {
2571            id: task.id,
2572            workspace_id: task.workspace_id,
2573            title: task.title,
2574            description: task.description,
2575            project_id: task.project_id,
2576            project_key: task.project_key,
2577            project_prefix: task.project_prefix,
2578            status: task.status,
2579            priority: task.priority,
2580            created_at: task.created_at,
2581            updated_at: task.updated_at,
2582            available_at: task.available_at,
2583            due_on: task.due_on,
2584            metadata: Vec::new(),
2585            related: Vec::new(),
2586        }
2587    }
2588}
2589
2590impl TaskRecord {
2591    fn with_metadata(task: Task, metadata: Vec<TaskMetadataValue>) -> Self {
2592        Self::with_metadata_and_related(task, metadata, Vec::new())
2593    }
2594
2595    fn with_metadata_and_related(
2596        task: Task,
2597        metadata: Vec<TaskMetadataValue>,
2598        related: Vec<crate::query::TaskRelatedLink>,
2599    ) -> Self {
2600        let mut record = Self::from(task);
2601        record.metadata = metadata.into_iter().map(Into::into).collect();
2602        record.related = related.into_iter().map(Into::into).collect();
2603        record
2604    }
2605}
2606
2607#[derive(Debug, Clone, PartialEq, Eq)]
2608pub struct TaskUpdateResult {
2609    pub task: TaskRecord,
2610    pub changed: bool,
2611}
2612
2613#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2614pub enum ConflictField {
2615    Title,
2616    Description,
2617    Project,
2618    Status,
2619    Priority,
2620    AvailableAt,
2621    DueOn,
2622    Deleted,
2623    IsEpic,
2624}
2625
2626impl ConflictField {
2627    pub const fn as_str(self) -> &'static str {
2628        match self {
2629            Self::Title => "title",
2630            Self::Description => "description",
2631            Self::Project => "project",
2632            Self::Status => "status",
2633            Self::Priority => "priority",
2634            Self::AvailableAt => "available_at",
2635            Self::DueOn => "due_on",
2636            Self::Deleted => "deleted",
2637            Self::IsEpic => "is_epic",
2638        }
2639    }
2640
2641    fn from_task_field(field: TaskField) -> Self {
2642        match field {
2643            TaskField::Title => Self::Title,
2644            TaskField::Description => Self::Description,
2645            TaskField::Project => Self::Project,
2646            TaskField::Status => Self::Status,
2647            TaskField::Priority => Self::Priority,
2648            TaskField::AvailableAt => Self::AvailableAt,
2649            TaskField::DueOn => Self::DueOn,
2650            TaskField::Deleted => Self::Deleted,
2651            TaskField::IsEpic => Self::IsEpic,
2652        }
2653    }
2654}
2655
2656#[derive(Debug, Clone, PartialEq, Eq)]
2657pub struct ConflictSummary {
2658    pub task_id: TaskId,
2659    pub task_title: String,
2660    pub project_key: String,
2661    pub project_prefix: String,
2662    pub field: ConflictField,
2663}
2664
2665#[derive(Debug, Clone, PartialEq, Eq)]
2666pub struct Conflict {
2667    pub task_id: TaskId,
2668    pub field: ConflictField,
2669    pub local_value: String,
2670    pub remote_value: String,
2671    pub variant_a: String,
2672    pub variant_b: String,
2673}
2674
2675#[derive(Debug, Clone, PartialEq, Eq)]
2676pub enum ConflictResolution {
2677    Local,
2678    Remote,
2679    Explicit(String),
2680}
2681
2682fn bounded_conflict_display_value(value: String) -> String {
2683    const MAX_CHARACTERS: usize = 512;
2684    let mut characters = value.chars();
2685    let preview: String = characters.by_ref().take(MAX_CHARACTERS).collect();
2686    if characters.next().is_some() {
2687        format!("{preview}…")
2688    } else {
2689        preview
2690    }
2691}
2692
2693#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2694pub enum ErrorCode {
2695    SyncCompatibility,
2696    Validation,
2697    NotFound,
2698    OpenConflict,
2699    GenerationConflict,
2700    Database,
2701    Internal,
2702}
2703
2704impl ErrorCode {
2705    pub const fn as_str(self) -> &'static str {
2706        match self {
2707            Self::SyncCompatibility => "sync_compatibility",
2708            Self::Validation => "validation",
2709            Self::NotFound => "not_found",
2710            Self::OpenConflict => "open_conflict",
2711            Self::GenerationConflict => "generation_conflict",
2712            Self::Database => "database",
2713            Self::Internal => "internal",
2714        }
2715    }
2716}
2717
2718#[derive(Debug, Clone, PartialEq, Eq)]
2719pub struct Error {
2720    pub code: ErrorCode,
2721    pub message: String,
2722}
2723
2724impl Error {
2725    fn new(code: ErrorCode, message: String) -> Self {
2726        Self { code, message }
2727    }
2728
2729    fn database_open(error: InternalError) -> Self {
2730        Self::new(ErrorCode::Database, error.to_string())
2731    }
2732
2733    fn from_internal(error: InternalError) -> Self {
2734        if error
2735            .downcast_ref::<crate::sync::protocol::SyncCompatibilityError>()
2736            .is_some()
2737            || error
2738                .downcast_ref::<crate::sync::protocol::SharedOperationCompatibilityError>()
2739                .is_some()
2740        {
2741            return Self::new(ErrorCode::SyncCompatibility, error.to_string());
2742        }
2743        let code = error
2744            .chain()
2745            .find_map(|cause| cause.downcast_ref::<crate::error::CoreError>())
2746            .map(|error| match error.kind() {
2747                crate::error::ErrorKind::Validation => ErrorCode::Validation,
2748                crate::error::ErrorKind::NotFound => ErrorCode::NotFound,
2749                crate::error::ErrorKind::OpenConflict => ErrorCode::OpenConflict,
2750                crate::error::ErrorKind::GenerationConflict => ErrorCode::GenerationConflict,
2751            })
2752            .unwrap_or_else(|| {
2753                if error
2754                    .chain()
2755                    .filter_map(|cause| cause.downcast_ref::<sqlx::Error>())
2756                    .any(|error| matches!(error, sqlx::Error::RowNotFound))
2757                {
2758                    ErrorCode::NotFound
2759                } else if error
2760                    .chain()
2761                    .any(|cause| cause.downcast_ref::<sqlx::Error>().is_some())
2762                {
2763                    ErrorCode::Database
2764                } else {
2765                    ErrorCode::Internal
2766                }
2767            });
2768        Self::new(code, error.to_string())
2769    }
2770}
2771
2772impl fmt::Display for Error {
2773    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2774        write!(formatter, "{}: {}", self.code.as_str(), self.message)
2775    }
2776}
2777
2778impl std::error::Error for Error {}
2779
2780#[cfg(test)]
2781mod tests {
2782    use super::*;
2783
2784    #[tokio::test]
2785    async fn queue_capture_receipt_survives_post_commit_read_failure() {
2786        let directory = tempfile::tempdir().unwrap();
2787        let store = Store::open(directory.path().join("capture.sqlite"))
2788            .await
2789            .unwrap();
2790        let workspace = store.resolve_workspace("default").await.unwrap();
2791        store
2792            .create_task(
2793                &workspace.id,
2794                CreateTask {
2795                    title: "Seed".into(),
2796                    description: String::new(),
2797                    project: "ios".into(),
2798                    status: TaskStatus::Done,
2799                    priority: TaskPriority::None,
2800                    metadata: Vec::new(),
2801                    available_at: None,
2802                    due_on: None,
2803                },
2804            )
2805            .await
2806            .unwrap();
2807        store
2808            .fail_queue_reads
2809            .store(true, std::sync::atomic::Ordering::SeqCst);
2810        let result = store
2811            .capture_queue_task(
2812                &workspace.id,
2813                TaskCapture {
2814                    title: "Captured".into(),
2815                    description: String::new(),
2816                    project: Some("ios".into()),
2817                    status: TaskStatus::Inbox,
2818                    priority: TaskPriority::None,
2819                    source: TaskSource::Ios,
2820                    due_on: None,
2821                    labels: Vec::new(),
2822                },
2823            )
2824            .await;
2825        let tasks = store.list_tasks(&workspace.id).await.unwrap();
2826        assert_eq!(
2827            tasks.iter().filter(|task| task.title == "Captured").count(),
2828            1
2829        );
2830        assert!(
2831            result.is_ok(),
2832            "committed capture lost its receipt: {result:?}"
2833        );
2834        let captured = result.unwrap();
2835        assert!(captured.state.is_none());
2836        assert_eq!(captured.display_ref, captured.task_id.to_string());
2837        assert!(!captured.undo_token.is_empty());
2838        let mutation_target = store
2839            .capture_queue_task(
2840                &workspace.id,
2841                TaskCapture {
2842                    title: "Mutation target".into(),
2843                    description: String::new(),
2844                    project: Some("ios".into()),
2845                    status: TaskStatus::Inbox,
2846                    priority: TaskPriority::None,
2847                    source: TaskSource::Ios,
2848                    due_on: None,
2849                    labels: Vec::new(),
2850                },
2851            )
2852            .await
2853            .unwrap();
2854        // Mutations can invalidate capture undo even after their values are restored.
2855        let changed = store
2856            .mutate_queue_task(
2857                &workspace.id,
2858                &mutation_target.task_id,
2859                QueueMutation {
2860                    kind: QueueMutationKind::SetPriority,
2861                    priority: Some(TaskPriority::High),
2862                    local_date: None,
2863                    time_zone: None,
2864                },
2865            )
2866            .await
2867            .unwrap();
2868        assert_eq!(changed.task_id, mutation_target.task_id);
2869        assert!(changed.state.is_none());
2870        assert_eq!(
2871            store
2872                .task_detail(&workspace.id, &mutation_target.task_id)
2873                .await
2874                .unwrap()
2875                .priority,
2876            TaskPriority::High
2877        );
2878        assert!(
2879            store
2880                .undo_queue_mutation(&changed.undo_token)
2881                .await
2882                .unwrap()
2883                .is_none()
2884        );
2885        assert_eq!(
2886            store
2887                .task_detail(&workspace.id, &mutation_target.task_id)
2888                .await
2889                .unwrap()
2890                .priority,
2891            TaskPriority::None
2892        );
2893        assert!(
2894            store
2895                .undo_queue_capture(&captured.undo_token)
2896                .await
2897                .unwrap()
2898                .is_none()
2899        );
2900        store
2901            .fail_queue_reads
2902            .store(false, std::sync::atomic::Ordering::SeqCst);
2903        let state = store.queue_state().await.unwrap();
2904        assert!(
2905            state
2906                .queue
2907                .tasks
2908                .iter()
2909                .all(|task| task.id != captured.task_id)
2910        );
2911    }
2912
2913    #[test]
2914    fn error_codes_are_stable_and_internal_errors_are_distinct() {
2915        assert_eq!(ErrorCode::Validation.as_str(), "validation");
2916        assert_eq!(ErrorCode::NotFound.as_str(), "not_found");
2917        assert_eq!(ErrorCode::OpenConflict.as_str(), "open_conflict");
2918        assert_eq!(
2919            ErrorCode::GenerationConflict.as_str(),
2920            "generation_conflict"
2921        );
2922        assert_eq!(ErrorCode::Database.as_str(), "database");
2923        assert_eq!(ErrorCode::Internal.as_str(), "internal");
2924        assert_eq!(
2925            Error::from_internal(
2926                crate::error::CoreError::generation_conflict(
2927                    "error recurrence-generation-conflict slot=2026-08-01 field=task"
2928                )
2929                .into()
2930            )
2931            .code,
2932            ErrorCode::GenerationConflict
2933        );
2934        assert_eq!(
2935            Error::from_internal(
2936                crate::error::CoreError::not_found("error recurrence-series-not-found").into()
2937            )
2938            .code,
2939            ErrorCode::NotFound
2940        );
2941        assert_eq!(
2942            Error::from_internal(anyhow::anyhow!("unexpected invariant")).code,
2943            ErrorCode::Internal
2944        );
2945        assert_eq!(
2946            Error::from_internal(sqlx::Error::PoolClosed.into()).code,
2947            ErrorCode::Database
2948        );
2949    }
2950}