Skip to main content

aven_core/
api.rs

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