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: input.status.as_str().to_string(),
422                    priority: input.priority.as_str().to_string(),
423                    source: TaskSource::Ios,
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 status: TaskStatus,
1740    pub priority: TaskPriority,
1741    pub due_on: Option<String>,
1742    pub labels: Vec<String>,
1743}
1744
1745#[derive(Debug, Clone, PartialEq, Eq)]
1746pub struct IosTaskCaptureResult {
1747    pub task_id: TaskId,
1748    pub display_ref: String,
1749    pub undo_token: String,
1750    /// Optional refreshed projection. Absence does not change the committed receipt.
1751    pub state: Option<IosQueueState>,
1752}
1753
1754#[derive(serde::Serialize, serde::Deserialize)]
1755struct IosCaptureUndoToken {
1756    workspace_id: WorkspaceId,
1757    task_id: TaskId,
1758    expected: TaskUndoSnapshot,
1759}
1760
1761#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1762pub enum IosQueueMutationKind {
1763    Start,
1764    Done,
1765    Snooze,
1766    SetPriority,
1767}
1768
1769#[derive(Debug, Clone, PartialEq, Eq)]
1770pub struct IosQueueMutation {
1771    pub kind: IosQueueMutationKind,
1772    pub priority: Option<TaskPriority>,
1773    pub local_date: Option<String>,
1774    pub time_zone: Option<String>,
1775}
1776
1777impl IosQueueMutation {
1778    fn into_internal(self) -> Result<InternalIosTaskMutation, Error> {
1779        match self.kind {
1780            IosQueueMutationKind::Start => Ok(InternalIosTaskMutation::Start),
1781            IosQueueMutationKind::Done => Ok(InternalIosTaskMutation::Done),
1782            IosQueueMutationKind::SetPriority => self
1783                .priority
1784                .map(|priority| InternalIosTaskMutation::SetPriority {
1785                    priority: priority.as_str().to_string(),
1786                })
1787                .ok_or_else(|| {
1788                    Error::new(
1789                        ErrorCode::Validation,
1790                        "priority is required for this quick action".to_string(),
1791                    )
1792                }),
1793            IosQueueMutationKind::Snooze => {
1794                let local_date = self.local_date.ok_or_else(|| {
1795                    Error::new(
1796                        ErrorCode::Validation,
1797                        "local date is required for snooze".to_string(),
1798                    )
1799                })?;
1800                let time_zone = self.time_zone.ok_or_else(|| {
1801                    Error::new(
1802                        ErrorCode::Validation,
1803                        "time zone is required for snooze".to_string(),
1804                    )
1805                })?;
1806                Ok(InternalIosTaskMutation::Snooze {
1807                    available_at: tomorrow_start(&local_date, &time_zone)?,
1808                })
1809            }
1810        }
1811    }
1812}
1813
1814#[derive(Debug, Clone, PartialEq, Eq)]
1815pub struct IosQueueMutationResult {
1816    pub task_id: TaskId,
1817    pub undo_token: String,
1818    /// Optional refreshed projection. Absence does not change the committed receipt.
1819    pub state: Option<IosQueueState>,
1820}
1821
1822#[derive(Debug, Clone, PartialEq, Eq)]
1823pub struct IosDetailStatusReceipt {
1824    pub status: TaskStatus,
1825    pub undo_token: Option<String>,
1826}
1827
1828#[derive(serde::Serialize, serde::Deserialize)]
1829struct IosMutationUndoToken {
1830    workspace_id: WorkspaceId,
1831    task_id: TaskId,
1832    mutation: InternalIosTaskMutation,
1833    before: TaskUndoSnapshot,
1834    expected: TaskUndoSnapshot,
1835}
1836
1837fn tomorrow_start(local_date: &str, time_zone: &str) -> Result<String, Error> {
1838    let date = NaiveDate::parse_from_str(local_date, "%Y-%m-%d")
1839        .map_err(|_| Error::new(ErrorCode::Validation, "invalid local date".to_string()))?
1840        .checked_add_days(Days::new(1))
1841        .ok_or_else(|| Error::new(ErrorCode::Validation, "invalid local date".to_string()))?;
1842    let zone = time_zone
1843        .parse::<chrono_tz::Tz>()
1844        .map_err(|_| Error::new(ErrorCode::Validation, "invalid IANA time zone".to_string()))?;
1845    let mut local = date.and_time(NaiveTime::MIN);
1846    let resolved = loop {
1847        match zone.from_local_datetime(&local) {
1848            LocalResult::Single(value) => break value,
1849            LocalResult::Ambiguous(first, second) => break first.min(second),
1850            LocalResult::None => {
1851                local = local
1852                    .checked_add_signed(chrono::Duration::minutes(1))
1853                    .ok_or_else(|| {
1854                        Error::new(ErrorCode::Validation, "invalid local date".to_string())
1855                    })?;
1856                if local.date() != date {
1857                    return Err(Error::new(
1858                        ErrorCode::Validation,
1859                        "time zone has no valid instant on the next local day".to_string(),
1860                    ));
1861                }
1862            }
1863        }
1864    };
1865    Ok(resolved
1866        .with_timezone(&Utc)
1867        .format("%Y-%m-%dT%H:%M:%SZ")
1868        .to_string())
1869}
1870
1871#[derive(Debug, Clone, PartialEq, Eq)]
1872pub struct MetadataFieldRecord {
1873    pub id: MetadataFieldId,
1874    pub workspace_id: WorkspaceId,
1875    pub key: String,
1876    pub task_count: usize,
1877    pub series_count: usize,
1878}
1879
1880impl From<MetadataFieldUsage> for MetadataFieldRecord {
1881    fn from(value: MetadataFieldUsage) -> Self {
1882        Self {
1883            id: value.field.id,
1884            workspace_id: value.field.workspace_id,
1885            key: value.field.key,
1886            task_count: value.task_count,
1887            series_count: value.series_count,
1888        }
1889    }
1890}
1891
1892#[derive(Debug, Clone, PartialEq, Eq)]
1893pub struct MetadataValueRecord {
1894    pub field_id: MetadataFieldId,
1895    pub key: String,
1896    pub value: String,
1897}
1898
1899impl From<TaskMetadataValue> for MetadataValueRecord {
1900    fn from(value: TaskMetadataValue) -> Self {
1901        Self {
1902            field_id: value.field_id,
1903            key: value.key,
1904            value: value.value,
1905        }
1906    }
1907}
1908
1909#[derive(Debug, Clone, PartialEq, Eq)]
1910pub struct MetadataInput {
1911    pub key: String,
1912    pub value: String,
1913}
1914
1915impl From<MetadataInput> for TaskMetadataInput {
1916    fn from(input: MetadataInput) -> Self {
1917        Self {
1918            expected_field_id: None,
1919            key: input.key,
1920            value: input.value,
1921        }
1922    }
1923}
1924
1925#[derive(Debug, Clone, PartialEq, Eq)]
1926pub struct CreateTask {
1927    pub title: String,
1928    pub description: String,
1929    pub project: String,
1930    pub status: TaskStatus,
1931    pub priority: TaskPriority,
1932    pub metadata: Vec<MetadataInput>,
1933    pub available_at: Option<String>,
1934    pub due_on: Option<String>,
1935}
1936
1937#[derive(Debug, Clone, PartialEq, Eq)]
1938pub struct IosTaskEditContext {
1939    pub detail: IosTaskDetail,
1940    pub projects: Vec<ProjectRecord>,
1941    pub labels: Vec<String>,
1942}
1943
1944#[derive(Debug, Clone, PartialEq, Eq, Default)]
1945pub struct IosTaskEdit {
1946    pub title: Option<String>,
1947    pub description: Option<String>,
1948    pub project_id: Option<ProjectId>,
1949    pub status: Option<TaskStatus>,
1950    pub priority: Option<TaskPriority>,
1951    pub add_labels: Vec<String>,
1952    pub remove_labels: Vec<String>,
1953    pub available_at: OptionalDateUpdate,
1954    pub due_on: OptionalDateUpdate,
1955}
1956
1957#[derive(Debug, Clone, PartialEq, Eq, Default)]
1958pub struct UpdateTask {
1959    pub title: Option<String>,
1960    pub description: Option<String>,
1961    pub project: Option<String>,
1962    pub status: Option<TaskStatus>,
1963    pub priority: Option<TaskPriority>,
1964    pub set_metadata: Vec<MetadataInput>,
1965    pub remove_metadata: Vec<String>,
1966    pub available_at: OptionalDateUpdate,
1967    pub due_on: OptionalDateUpdate,
1968}
1969
1970#[derive(Debug, Clone, PartialEq, Eq, Default)]
1971pub enum OptionalDateUpdate {
1972    #[default]
1973    Unchanged,
1974    Set(String),
1975    Clear,
1976}
1977
1978impl OptionalDateUpdate {
1979    fn into_internal(self) -> Option<Option<String>> {
1980        match self {
1981            Self::Unchanged => None,
1982            Self::Set(value) => Some(Some(value)),
1983            Self::Clear => Some(None),
1984        }
1985    }
1986}
1987
1988#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1989pub enum RecurrenceWeekday {
1990    Monday,
1991    Tuesday,
1992    Wednesday,
1993    Thursday,
1994    Friday,
1995    Saturday,
1996    Sunday,
1997}
1998
1999impl From<RecurrenceWeekday> for Weekday {
2000    fn from(value: RecurrenceWeekday) -> Self {
2001        match value {
2002            RecurrenceWeekday::Monday => Self::Mon,
2003            RecurrenceWeekday::Tuesday => Self::Tue,
2004            RecurrenceWeekday::Wednesday => Self::Wed,
2005            RecurrenceWeekday::Thursday => Self::Thu,
2006            RecurrenceWeekday::Friday => Self::Fri,
2007            RecurrenceWeekday::Saturday => Self::Sat,
2008            RecurrenceWeekday::Sunday => Self::Sun,
2009        }
2010    }
2011}
2012
2013impl From<Weekday> for RecurrenceWeekday {
2014    fn from(value: Weekday) -> Self {
2015        match value {
2016            Weekday::Mon => Self::Monday,
2017            Weekday::Tue => Self::Tuesday,
2018            Weekday::Wed => Self::Wednesday,
2019            Weekday::Thu => Self::Thursday,
2020            Weekday::Fri => Self::Friday,
2021            Weekday::Sat => Self::Saturday,
2022            Weekday::Sun => Self::Sunday,
2023        }
2024    }
2025}
2026
2027#[derive(Debug, Clone, PartialEq, Eq)]
2028pub struct RecurrenceRule {
2029    pub frequency: RecurrenceFrequency,
2030    pub interval: u32,
2031    pub weekdays: Vec<RecurrenceWeekday>,
2032}
2033
2034impl RecurrenceRule {
2035    fn into_internal(self) -> Result<InternalRecurrenceRule, Error> {
2036        InternalRecurrenceRule::new(
2037            self.frequency,
2038            self.interval,
2039            WeekdaySet::from_weekdays(self.weekdays.into_iter().map(Into::into)),
2040        )
2041        .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
2042    }
2043}
2044
2045impl From<InternalRecurrenceRule> for RecurrenceRule {
2046    fn from(value: InternalRecurrenceRule) -> Self {
2047        Self {
2048            frequency: value.frequency(),
2049            interval: value.interval(),
2050            weekdays: value.weekdays_set().iter().map(Into::into).collect(),
2051        }
2052    }
2053}
2054
2055#[derive(Debug, Clone, PartialEq, Eq)]
2056pub struct RecurrenceScheduleInput {
2057    pub rule: RecurrenceRule,
2058    pub timezone: String,
2059    pub start_on: String,
2060    pub available_local_time: Option<String>,
2061    pub due_policy: RecurrenceDuePolicy,
2062}
2063
2064impl RecurrenceScheduleInput {
2065    fn into_internal(self) -> Result<RecurrenceSchedule, Error> {
2066        let timezone = self
2067            .timezone
2068            .parse::<TimeZoneId>()
2069            .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))?;
2070        let start_on = parse_date("start_on", &self.start_on)?;
2071        let available_local_time = self
2072            .available_local_time
2073            .map(|value| parse_local_time("available_local_time", &value))
2074            .transpose()?;
2075        Ok(RecurrenceSchedule::new(
2076            self.rule.into_internal()?,
2077            timezone,
2078            start_on,
2079            available_local_time,
2080            self.due_policy,
2081        ))
2082    }
2083}
2084
2085#[derive(Debug, Clone, PartialEq, Eq)]
2086pub struct CreateRecurrenceSeries {
2087    pub title: String,
2088    pub description: String,
2089    pub project: String,
2090    pub priority: TaskPriority,
2091    pub initial_status: TaskStatus,
2092    pub labels: Vec<String>,
2093    pub metadata: Vec<MetadataInput>,
2094    pub schedule: RecurrenceScheduleInput,
2095}
2096
2097#[derive(Debug, Clone, PartialEq, Eq, Default)]
2098pub enum OptionalLocalTimeUpdate {
2099    #[default]
2100    Unchanged,
2101    Set(String),
2102    Clear,
2103}
2104
2105impl OptionalLocalTimeUpdate {
2106    fn into_internal(self) -> Result<Option<Option<NaiveTime>>, Error> {
2107        match self {
2108            Self::Unchanged => Ok(None),
2109            Self::Set(value) => Ok(Some(Some(parse_local_time(
2110                "available_local_time",
2111                &value,
2112            )?))),
2113            Self::Clear => Ok(Some(None)),
2114        }
2115    }
2116}
2117
2118#[derive(Debug, Clone, PartialEq, Eq, Default)]
2119pub struct UpdateRecurrenceTemplate {
2120    pub title: Option<String>,
2121    pub description: Option<String>,
2122    pub project: Option<String>,
2123    pub priority: Option<TaskPriority>,
2124    pub initial_status: Option<TaskStatus>,
2125    pub labels: Option<Vec<String>>,
2126    pub set_metadata: Vec<MetadataInput>,
2127    pub remove_metadata: Vec<String>,
2128    pub available_local_time: OptionalLocalTimeUpdate,
2129    pub due_policy: Option<RecurrenceDuePolicy>,
2130}
2131
2132#[derive(Debug, Clone, PartialEq, Eq)]
2133pub struct RecurrenceSeriesRecord {
2134    pub workspace_id: WorkspaceId,
2135    pub id: RecurrenceSeriesId,
2136    pub title: String,
2137    pub description: String,
2138    pub project_id: ProjectId,
2139    pub priority: TaskPriority,
2140    pub initial_status: TaskStatus,
2141    pub rule: RecurrenceRule,
2142    pub timezone: String,
2143    pub start_on: String,
2144    pub available_local_time: Option<String>,
2145    pub due_policy: RecurrenceDuePolicy,
2146    pub state: RecurrenceSeriesState,
2147    pub stopped_at: Option<String>,
2148    pub created_at: String,
2149    pub updated_at: String,
2150}
2151
2152impl From<RecurrenceSeries> for RecurrenceSeriesRecord {
2153    fn from(value: RecurrenceSeries) -> Self {
2154        Self {
2155            workspace_id: value.workspace_id,
2156            id: value.id,
2157            title: value.title,
2158            description: value.description,
2159            project_id: value.project_id,
2160            priority: value.priority,
2161            initial_status: value.initial_status,
2162            rule: value.rule.into(),
2163            timezone: value.timezone.to_string(),
2164            start_on: value.start_on.to_string(),
2165            available_local_time: value
2166                .available_local_time
2167                .map(|time| time.format("%H:%M:%S").to_string()),
2168            due_policy: value.due_policy,
2169            state: value.state,
2170            stopped_at: value.stopped_at,
2171            created_at: value.created_at,
2172            updated_at: value.updated_at,
2173        }
2174    }
2175}
2176
2177#[derive(Debug, Clone, PartialEq, Eq)]
2178pub struct RecurrenceOccurrenceRecord {
2179    pub series_id: RecurrenceSeriesId,
2180    pub slot_on: String,
2181    pub task_id: Option<TaskId>,
2182    pub outcome: Option<RecurrenceOutcome>,
2183    pub resolved_at: Option<String>,
2184    pub projection_state: RecurrenceProjectionState,
2185    pub archived_at: Option<String>,
2186}
2187
2188impl From<RecurrenceOccurrence> for RecurrenceOccurrenceRecord {
2189    fn from(value: RecurrenceOccurrence) -> Self {
2190        Self {
2191            series_id: value.series_id,
2192            slot_on: value.slot_on.to_string(),
2193            task_id: value.task_id,
2194            outcome: value.outcome,
2195            resolved_at: value.resolved_at,
2196            projection_state: value.projection_state,
2197            archived_at: value.archived_at,
2198        }
2199    }
2200}
2201
2202#[derive(Debug, Clone, PartialEq, Eq)]
2203pub struct RecurrenceCreateResult {
2204    pub series: RecurrenceSeriesRecord,
2205    pub series_ref: String,
2206    pub occurrence: RecurrenceOccurrenceRecord,
2207    pub task: TaskRecord,
2208}
2209
2210#[derive(Debug, Clone, PartialEq, Eq)]
2211pub struct RecurrenceTemplateUpdateResult {
2212    pub series: RecurrenceSeriesRecord,
2213    pub changed: bool,
2214}
2215
2216#[derive(Debug, Clone, PartialEq, Eq)]
2217pub struct RecurrenceResolveResult {
2218    pub series: RecurrenceSeriesRecord,
2219    pub occurrence: RecurrenceOccurrenceRecord,
2220    pub task: TaskRecord,
2221    pub successor: Option<TaskRecord>,
2222}
2223
2224#[derive(Debug, Clone, PartialEq, Eq)]
2225pub struct RecurrenceStateResult {
2226    pub series: RecurrenceSeriesRecord,
2227    pub occurrence: Option<RecurrenceOccurrenceRecord>,
2228}
2229
2230impl From<crate::operations::RecurrenceStateOutcome> for RecurrenceStateResult {
2231    fn from(value: crate::operations::RecurrenceStateOutcome) -> Self {
2232        Self {
2233            series: value.series.into(),
2234            occurrence: value.occurrence.map(Into::into),
2235        }
2236    }
2237}
2238
2239#[derive(Debug, Clone, PartialEq, Eq)]
2240pub struct RecurrenceRefResolution {
2241    pub series_id: RecurrenceSeriesId,
2242    pub series_ref: String,
2243}
2244
2245#[derive(Debug, Clone, PartialEq, Eq, Default)]
2246pub struct RecurrenceCounts {
2247    pub completed: u64,
2248    pub skipped: u64,
2249    pub missed: u64,
2250    pub pause_intervals: u64,
2251    pub latest_slot_on: Option<String>,
2252    pub latest_outcome: Option<RecurrenceOutcome>,
2253}
2254
2255impl From<InternalRecurrenceCounts> for RecurrenceCounts {
2256    fn from(value: InternalRecurrenceCounts) -> Self {
2257        Self {
2258            completed: value.completed as u64,
2259            skipped: value.skipped as u64,
2260            missed: value.missed as u64,
2261            pause_intervals: value.pause_intervals as u64,
2262            latest_slot_on: value.latest_slot_on,
2263            latest_outcome: value.latest_outcome,
2264        }
2265    }
2266}
2267
2268#[derive(Debug, Clone, PartialEq, Eq)]
2269pub struct RecurrenceSeriesSummary {
2270    pub series: RecurrenceSeriesRecord,
2271    pub series_ref: String,
2272    pub rule_label: String,
2273    pub current_slot_on: Option<String>,
2274    pub current_task_ref: Option<String>,
2275    pub counts: RecurrenceCounts,
2276}
2277
2278impl From<InternalRecurrenceSeriesSummary> for RecurrenceSeriesSummary {
2279    fn from(value: InternalRecurrenceSeriesSummary) -> Self {
2280        Self {
2281            series: value.series.into(),
2282            series_ref: value.series_ref,
2283            rule_label: value.rule_label,
2284            current_slot_on: value.current_slot_on,
2285            current_task_ref: value.current_task_ref,
2286            counts: value.counts.into(),
2287        }
2288    }
2289}
2290
2291#[derive(Debug, Clone, PartialEq, Eq)]
2292pub struct RecurrenceSeriesConflict {
2293    pub field: String,
2294    pub variant_a: String,
2295    pub local_value: String,
2296    pub variant_b: String,
2297    pub remote_value: String,
2298}
2299
2300#[derive(Debug, Clone, PartialEq, Eq)]
2301pub struct RecurrenceSeriesDetail {
2302    pub series: RecurrenceSeriesRecord,
2303    pub labels: Vec<String>,
2304    pub metadata: Vec<MetadataValueRecord>,
2305    pub summary: RecurrenceSeriesSummary,
2306    pub current_occurrence: Option<RecurrenceOccurrenceRecord>,
2307    pub lifecycle_conflicts: Vec<RecurrenceSeriesConflict>,
2308}
2309
2310impl From<InternalRecurrenceSeriesDetail> for RecurrenceSeriesDetail {
2311    fn from(value: InternalRecurrenceSeriesDetail) -> Self {
2312        Self {
2313            series: value.series.into(),
2314            labels: value.labels,
2315            metadata: value.metadata.into_iter().map(Into::into).collect(),
2316            summary: value.summary.into(),
2317            current_occurrence: value.current_occurrence.map(Into::into),
2318            lifecycle_conflicts: value
2319                .lifecycle_conflicts
2320                .into_iter()
2321                .map(|conflict| RecurrenceSeriesConflict {
2322                    field: conflict.field,
2323                    variant_a: conflict.variant_a,
2324                    local_value: conflict.local_value,
2325                    variant_b: conflict.variant_b,
2326                    remote_value: conflict.remote_value,
2327                })
2328                .collect(),
2329        }
2330    }
2331}
2332
2333#[derive(Debug, Clone, PartialEq, Eq)]
2334pub struct RecurrenceHistoryRow {
2335    pub kind: RecurrenceHistoryKind,
2336    pub slot_on: Option<String>,
2337    pub interval_started_at: Option<String>,
2338    pub interval_ended_at: Option<String>,
2339    pub task_id: Option<TaskId>,
2340    pub task_ref: Option<String>,
2341    pub openable: bool,
2342    pub archived_projection: bool,
2343    pub resolved_at: Option<String>,
2344}
2345
2346impl From<InternalRecurrenceHistoryEntry> for RecurrenceHistoryRow {
2347    fn from(value: InternalRecurrenceHistoryEntry) -> Self {
2348        Self {
2349            kind: value.kind,
2350            slot_on: value.slot_on,
2351            interval_started_at: value.interval_started_at,
2352            interval_ended_at: value.interval_ended_at,
2353            task_id: value.task_id,
2354            task_ref: value.task_ref,
2355            openable: value.openable,
2356            archived_projection: value.archived_projection,
2357            resolved_at: value.resolved_at,
2358        }
2359    }
2360}
2361
2362#[derive(Debug, Clone, PartialEq, Eq)]
2363pub struct RecurrenceHistoryPage {
2364    pub series_ref: String,
2365    pub items: Vec<RecurrenceHistoryRow>,
2366    pub offset: usize,
2367    pub limit: usize,
2368    pub total: usize,
2369    pub has_more: bool,
2370}
2371
2372#[derive(Debug, Clone, PartialEq, Eq)]
2373pub struct TaskRecurrenceSummary {
2374    pub series_id: RecurrenceSeriesId,
2375    pub series_ref: String,
2376    pub slot_on: String,
2377    pub rule_label: String,
2378    pub timezone: String,
2379    pub lifecycle: RecurrenceSeriesState,
2380    pub outcome: Option<RecurrenceOutcome>,
2381    pub projection_state: RecurrenceProjectionState,
2382}
2383
2384#[derive(Debug, Clone, PartialEq, Eq)]
2385pub struct RecurrenceTaskGroup {
2386    pub series_id: RecurrenceSeriesId,
2387    pub series_ref: String,
2388    pub counts: RecurrenceCounts,
2389}
2390
2391#[derive(Debug, Clone, PartialEq, Eq)]
2392pub enum QueueDate {
2393    Due { on: String, kind: QueueDateKind },
2394    Deferred { at: String },
2395}
2396
2397#[derive(Debug, Clone, PartialEq, Eq)]
2398pub struct QueueLabelSummary {
2399    pub first: String,
2400    pub remaining_count: u32,
2401}
2402
2403#[derive(Debug, Clone, PartialEq, Eq)]
2404pub struct QueueTaskSummary {
2405    pub id: TaskId,
2406    pub title: String,
2407    pub display_ref: String,
2408    pub project_key: String,
2409    pub status: TaskStatus,
2410    pub priority: TaskPriority,
2411    pub due_on: Option<String>,
2412    pub band: QueueBand,
2413    pub reason: Option<QueueReason>,
2414    pub date: Option<QueueDate>,
2415    pub label: Option<QueueLabelSummary>,
2416    pub is_epic: bool,
2417    pub live_attachment_count: u32,
2418    pub has_conflict: bool,
2419    pub unresolved_blocker_count: i64,
2420}
2421
2422impl From<TaskListItem> for QueueTaskSummary {
2423    fn from(value: TaskListItem) -> Self {
2424        let date = if value.queue.has_deferred_date {
2425            value
2426                .task
2427                .available_at
2428                .clone()
2429                .map(|at| QueueDate::Deferred { at })
2430        } else {
2431            value.queue.date_kind.and_then(|kind| {
2432                value
2433                    .task
2434                    .due_on
2435                    .clone()
2436                    .map(|on| QueueDate::Due { on, kind })
2437            })
2438        };
2439        let label = value
2440            .labels
2441            .first()
2442            .cloned()
2443            .map(|first| QueueLabelSummary {
2444                first,
2445                remaining_count: value.labels.len().saturating_sub(1).min(u32::MAX as usize) as u32,
2446            });
2447        Self {
2448            id: value.task.id,
2449            title: value.task.title,
2450            display_ref: value.display_ref,
2451            project_key: value.task.project_key,
2452            status: value.task.status,
2453            priority: value.task.priority,
2454            due_on: value.task.due_on,
2455            band: value.queue.band,
2456            reason: value.queue.reason,
2457            date,
2458            label,
2459            is_epic: value.task.is_epic,
2460            live_attachment_count: value.live_attachment_count,
2461            has_conflict: value.has_conflict,
2462            unresolved_blocker_count: value.unresolved_blocker_count,
2463        }
2464    }
2465}
2466
2467#[derive(Debug, Clone, PartialEq, Eq)]
2468pub struct QueueReport {
2469    pub tasks: Vec<QueueTaskSummary>,
2470    pub unresolved_conflict_count: u32,
2471    pub last_success_at: Option<String>,
2472}
2473
2474#[derive(Debug, Clone, PartialEq, Eq)]
2475pub struct TaskSummary {
2476    pub task: TaskRecord,
2477    pub display_ref: String,
2478    pub recurrence: Option<TaskRecurrenceSummary>,
2479    pub recurrence_group: Option<RecurrenceTaskGroup>,
2480}
2481
2482impl From<TaskListItem> for TaskSummary {
2483    fn from(value: TaskListItem) -> Self {
2484        Self {
2485            task: value.task.into(),
2486            display_ref: value.display_ref,
2487            recurrence: value.recurrence.map(|summary| TaskRecurrenceSummary {
2488                series_id: summary.series_id,
2489                series_ref: summary.series_ref,
2490                slot_on: summary.slot_on,
2491                rule_label: summary.rule_label,
2492                timezone: summary.timezone,
2493                lifecycle: summary.lifecycle,
2494                outcome: summary.outcome,
2495                projection_state: summary.projection_state,
2496            }),
2497            recurrence_group: value.recurrence_group.map(|group| RecurrenceTaskGroup {
2498                series_id: group.series_id,
2499                series_ref: group.series_ref,
2500                counts: group.counts.into(),
2501            }),
2502        }
2503    }
2504}
2505
2506#[derive(Debug, Clone, PartialEq, Eq)]
2507pub struct RelatedTaskRecord {
2508    pub task_id: TaskId,
2509    pub display_ref: String,
2510    pub title: String,
2511    pub status: TaskStatus,
2512    pub priority: TaskPriority,
2513    pub deleted: bool,
2514    pub linked_at: String,
2515}
2516
2517impl From<crate::query::TaskRelatedLink> for RelatedTaskRecord {
2518    fn from(value: crate::query::TaskRelatedLink) -> Self {
2519        Self {
2520            task_id: value.task_id,
2521            display_ref: value.display_ref,
2522            title: value.title,
2523            status: value.status,
2524            priority: value.priority,
2525            deleted: value.deleted,
2526            linked_at: value.linked_at,
2527        }
2528    }
2529}
2530
2531#[derive(Debug, Clone, PartialEq, Eq)]
2532pub struct RelatedMutationResult {
2533    pub changed: bool,
2534}
2535
2536#[derive(Debug, Clone, PartialEq, Eq)]
2537pub struct TaskRecord {
2538    pub id: TaskId,
2539    pub workspace_id: WorkspaceId,
2540    pub title: String,
2541    pub description: String,
2542    pub project_id: ProjectId,
2543    pub project_key: String,
2544    pub project_prefix: String,
2545    pub status: TaskStatus,
2546    pub priority: TaskPriority,
2547    pub created_at: String,
2548    pub updated_at: String,
2549    pub available_at: Option<String>,
2550    pub due_on: Option<String>,
2551    pub metadata: Vec<MetadataValueRecord>,
2552    pub related: Vec<RelatedTaskRecord>,
2553}
2554
2555impl From<Task> for TaskRecord {
2556    fn from(task: Task) -> Self {
2557        Self {
2558            id: task.id,
2559            workspace_id: task.workspace_id,
2560            title: task.title,
2561            description: task.description,
2562            project_id: task.project_id,
2563            project_key: task.project_key,
2564            project_prefix: task.project_prefix,
2565            status: task.status,
2566            priority: task.priority,
2567            created_at: task.created_at,
2568            updated_at: task.updated_at,
2569            available_at: task.available_at,
2570            due_on: task.due_on,
2571            metadata: Vec::new(),
2572            related: Vec::new(),
2573        }
2574    }
2575}
2576
2577impl TaskRecord {
2578    fn with_metadata(task: Task, metadata: Vec<TaskMetadataValue>) -> Self {
2579        Self::with_metadata_and_related(task, metadata, Vec::new())
2580    }
2581
2582    fn with_metadata_and_related(
2583        task: Task,
2584        metadata: Vec<TaskMetadataValue>,
2585        related: Vec<crate::query::TaskRelatedLink>,
2586    ) -> Self {
2587        let mut record = Self::from(task);
2588        record.metadata = metadata.into_iter().map(Into::into).collect();
2589        record.related = related.into_iter().map(Into::into).collect();
2590        record
2591    }
2592}
2593
2594#[derive(Debug, Clone, PartialEq, Eq)]
2595pub struct TaskUpdateResult {
2596    pub task: TaskRecord,
2597    pub changed: bool,
2598}
2599
2600#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2601pub enum ConflictField {
2602    Title,
2603    Description,
2604    Project,
2605    Status,
2606    Priority,
2607    AvailableAt,
2608    DueOn,
2609    Deleted,
2610    IsEpic,
2611}
2612
2613impl ConflictField {
2614    pub const fn as_str(self) -> &'static str {
2615        match self {
2616            Self::Title => "title",
2617            Self::Description => "description",
2618            Self::Project => "project",
2619            Self::Status => "status",
2620            Self::Priority => "priority",
2621            Self::AvailableAt => "available_at",
2622            Self::DueOn => "due_on",
2623            Self::Deleted => "deleted",
2624            Self::IsEpic => "is_epic",
2625        }
2626    }
2627
2628    fn from_task_field(field: TaskField) -> Self {
2629        match field {
2630            TaskField::Title => Self::Title,
2631            TaskField::Description => Self::Description,
2632            TaskField::Project => Self::Project,
2633            TaskField::Status => Self::Status,
2634            TaskField::Priority => Self::Priority,
2635            TaskField::AvailableAt => Self::AvailableAt,
2636            TaskField::DueOn => Self::DueOn,
2637            TaskField::Deleted => Self::Deleted,
2638            TaskField::IsEpic => Self::IsEpic,
2639        }
2640    }
2641}
2642
2643#[derive(Debug, Clone, PartialEq, Eq)]
2644pub struct ConflictSummary {
2645    pub task_id: TaskId,
2646    pub task_title: String,
2647    pub project_key: String,
2648    pub project_prefix: String,
2649    pub field: ConflictField,
2650}
2651
2652#[derive(Debug, Clone, PartialEq, Eq)]
2653pub struct Conflict {
2654    pub task_id: TaskId,
2655    pub field: ConflictField,
2656    pub local_value: String,
2657    pub remote_value: String,
2658    pub variant_a: String,
2659    pub variant_b: String,
2660}
2661
2662#[derive(Debug, Clone, PartialEq, Eq)]
2663pub enum ConflictResolution {
2664    Local,
2665    Remote,
2666    Explicit(String),
2667}
2668
2669fn bounded_conflict_display_value(value: String) -> String {
2670    const MAX_CHARACTERS: usize = 512;
2671    let mut characters = value.chars();
2672    let preview: String = characters.by_ref().take(MAX_CHARACTERS).collect();
2673    if characters.next().is_some() {
2674        format!("{preview}…")
2675    } else {
2676        preview
2677    }
2678}
2679
2680#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2681pub enum ErrorCode {
2682    Validation,
2683    NotFound,
2684    OpenConflict,
2685    GenerationConflict,
2686    Database,
2687    Internal,
2688}
2689
2690impl ErrorCode {
2691    pub const fn as_str(self) -> &'static str {
2692        match self {
2693            Self::Validation => "validation",
2694            Self::NotFound => "not_found",
2695            Self::OpenConflict => "open_conflict",
2696            Self::GenerationConflict => "generation_conflict",
2697            Self::Database => "database",
2698            Self::Internal => "internal",
2699        }
2700    }
2701}
2702
2703#[derive(Debug, Clone, PartialEq, Eq)]
2704pub struct Error {
2705    pub code: ErrorCode,
2706    pub message: String,
2707}
2708
2709impl Error {
2710    fn new(code: ErrorCode, message: String) -> Self {
2711        Self { code, message }
2712    }
2713
2714    fn database_open(error: InternalError) -> Self {
2715        Self::new(ErrorCode::Database, error.to_string())
2716    }
2717
2718    fn from_internal(error: InternalError) -> Self {
2719        let code = error
2720            .chain()
2721            .find_map(|cause| cause.downcast_ref::<crate::error::CoreError>())
2722            .map(|error| match error.kind() {
2723                crate::error::ErrorKind::Validation => ErrorCode::Validation,
2724                crate::error::ErrorKind::NotFound => ErrorCode::NotFound,
2725                crate::error::ErrorKind::OpenConflict => ErrorCode::OpenConflict,
2726                crate::error::ErrorKind::GenerationConflict => ErrorCode::GenerationConflict,
2727            })
2728            .unwrap_or_else(|| {
2729                if error
2730                    .chain()
2731                    .filter_map(|cause| cause.downcast_ref::<sqlx::Error>())
2732                    .any(|error| matches!(error, sqlx::Error::RowNotFound))
2733                {
2734                    ErrorCode::NotFound
2735                } else if error
2736                    .chain()
2737                    .any(|cause| cause.downcast_ref::<sqlx::Error>().is_some())
2738                {
2739                    ErrorCode::Database
2740                } else {
2741                    ErrorCode::Internal
2742                }
2743            });
2744        Self::new(code, error.to_string())
2745    }
2746}
2747
2748impl fmt::Display for Error {
2749    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
2750        write!(formatter, "{}: {}", self.code.as_str(), self.message)
2751    }
2752}
2753
2754impl std::error::Error for Error {}
2755
2756#[cfg(test)]
2757mod tests {
2758    use super::*;
2759
2760    #[tokio::test]
2761    async fn ios_capture_receipt_survives_post_commit_read_failure() {
2762        let directory = tempfile::tempdir().unwrap();
2763        let store = Store::open(directory.path().join("capture.sqlite"))
2764            .await
2765            .unwrap();
2766        let workspace = store.resolve_workspace("default").await.unwrap();
2767        store
2768            .create_task(
2769                &workspace.id,
2770                CreateTask {
2771                    title: "Seed".into(),
2772                    description: String::new(),
2773                    project: "ios".into(),
2774                    status: TaskStatus::Done,
2775                    priority: TaskPriority::None,
2776                    metadata: Vec::new(),
2777                    available_at: None,
2778                    due_on: None,
2779                },
2780            )
2781            .await
2782            .unwrap();
2783        store
2784            .fail_queue_reads
2785            .store(true, std::sync::atomic::Ordering::SeqCst);
2786        let result = store
2787            .capture_ios_queue_task(
2788                &workspace.id,
2789                IosTaskCapture {
2790                    title: "Captured".into(),
2791                    description: String::new(),
2792                    project: Some("ios".into()),
2793                    status: TaskStatus::Inbox,
2794                    priority: TaskPriority::None,
2795                    due_on: None,
2796                    labels: Vec::new(),
2797                },
2798            )
2799            .await;
2800        let tasks = store.list_tasks(&workspace.id).await.unwrap();
2801        assert_eq!(
2802            tasks.iter().filter(|task| task.title == "Captured").count(),
2803            1
2804        );
2805        assert!(
2806            result.is_ok(),
2807            "committed capture lost its receipt: {result:?}"
2808        );
2809        let captured = result.unwrap();
2810        assert!(captured.state.is_none());
2811        assert_eq!(captured.display_ref, captured.task_id.to_string());
2812        assert!(!captured.undo_token.is_empty());
2813        let mutation_target = store
2814            .capture_ios_queue_task(
2815                &workspace.id,
2816                IosTaskCapture {
2817                    title: "Mutation target".into(),
2818                    description: String::new(),
2819                    project: Some("ios".into()),
2820                    status: TaskStatus::Inbox,
2821                    priority: TaskPriority::None,
2822                    due_on: None,
2823                    labels: Vec::new(),
2824                },
2825            )
2826            .await
2827            .unwrap();
2828        // Mutations can invalidate capture undo even after their values are restored.
2829        let changed = store
2830            .mutate_ios_queue_task(
2831                &workspace.id,
2832                &mutation_target.task_id,
2833                IosQueueMutation {
2834                    kind: IosQueueMutationKind::SetPriority,
2835                    priority: Some(TaskPriority::High),
2836                    local_date: None,
2837                    time_zone: None,
2838                },
2839            )
2840            .await
2841            .unwrap();
2842        assert_eq!(changed.task_id, mutation_target.task_id);
2843        assert!(changed.state.is_none());
2844        assert_eq!(
2845            store
2846                .ios_task_detail(&workspace.id, &mutation_target.task_id)
2847                .await
2848                .unwrap()
2849                .priority,
2850            TaskPriority::High
2851        );
2852        assert!(
2853            store
2854                .undo_ios_queue_mutation(&changed.undo_token)
2855                .await
2856                .unwrap()
2857                .is_none()
2858        );
2859        assert_eq!(
2860            store
2861                .ios_task_detail(&workspace.id, &mutation_target.task_id)
2862                .await
2863                .unwrap()
2864                .priority,
2865            TaskPriority::None
2866        );
2867        assert!(
2868            store
2869                .undo_ios_queue_capture(&captured.undo_token)
2870                .await
2871                .unwrap()
2872                .is_none()
2873        );
2874        store
2875            .fail_queue_reads
2876            .store(false, std::sync::atomic::Ordering::SeqCst);
2877        let state = store.ios_queue_state().await.unwrap();
2878        assert!(
2879            state
2880                .queue
2881                .tasks
2882                .iter()
2883                .all(|task| task.id != captured.task_id)
2884        );
2885    }
2886
2887    #[test]
2888    fn error_codes_are_stable_and_internal_errors_are_distinct() {
2889        assert_eq!(ErrorCode::Validation.as_str(), "validation");
2890        assert_eq!(ErrorCode::NotFound.as_str(), "not_found");
2891        assert_eq!(ErrorCode::OpenConflict.as_str(), "open_conflict");
2892        assert_eq!(
2893            ErrorCode::GenerationConflict.as_str(),
2894            "generation_conflict"
2895        );
2896        assert_eq!(ErrorCode::Database.as_str(), "database");
2897        assert_eq!(ErrorCode::Internal.as_str(), "internal");
2898        assert_eq!(
2899            Error::from_internal(
2900                crate::error::CoreError::generation_conflict(
2901                    "error recurrence-generation-conflict slot=2026-08-01 field=task"
2902                )
2903                .into()
2904            )
2905            .code,
2906            ErrorCode::GenerationConflict
2907        );
2908        assert_eq!(
2909            Error::from_internal(
2910                crate::error::CoreError::not_found("error recurrence-series-not-found").into()
2911            )
2912            .code,
2913            ErrorCode::NotFound
2914        );
2915        assert_eq!(
2916            Error::from_internal(anyhow::anyhow!("unexpected invariant")).code,
2917            ErrorCode::Internal
2918        );
2919        assert_eq!(
2920            Error::from_internal(sqlx::Error::PoolClosed.into()).code,
2921            ErrorCode::Database
2922        );
2923    }
2924}