Skip to main content

aven_core/
api.rs

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