Skip to main content

aven_core/
api.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use anyhow::Error as InternalError;
5use chrono::{NaiveDate, NaiveTime, Weekday};
6
7use crate::choices::{TaskPriority, TaskSource, TaskStatus};
8use crate::db::Database;
9use crate::ids::{MetadataFieldId, ProjectId, TaskId, WorkspaceId};
10use crate::metadata::{MetadataFieldUsage, TaskMetadataInput, TaskMetadataValue};
11use crate::operations::{
12    CreateRecurrenceSeriesParams, RecurrenceSeriesDraft,
13    RecurrenceTemplateUpdate as InternalRecurrenceTemplateUpdate, TaskDraft,
14    TaskUpdate as InternalTaskUpdate, UpdateRecurrenceTemplateParams,
15};
16pub use crate::query::RecurrenceHistoryKind;
17use crate::query::{
18    MAX_RECURRENCE_HISTORY_LIMIT, RecurrenceCounts as InternalRecurrenceCounts,
19    RecurrenceHistoryEntry as InternalRecurrenceHistoryEntry,
20    RecurrenceSeriesDetail as InternalRecurrenceSeriesDetail,
21    RecurrenceSeriesSummary as InternalRecurrenceSeriesSummary, SortDirection, TaskFilters,
22    TaskListItem, TaskQueryMode, TaskSort,
23};
24pub use crate::recurrence::{
25    RecurrenceDuePolicy, RecurrenceFrequency, RecurrenceOutcome, RecurrenceProjectionState,
26    RecurrenceSeriesState,
27};
28use crate::recurrence::{
29    RecurrenceRule as InternalRecurrenceRule, RecurrenceSchedule, RecurrenceSeriesId, TimeZoneId,
30    WeekdaySet,
31};
32use crate::sync::SyncSession;
33use crate::task_fields::TaskField;
34use crate::types::{RecurrenceOccurrence, RecurrenceSeries, Task};
35use crate::workspaces::Workspace;
36
37#[derive(Clone)]
38pub struct Store {
39    database: Database,
40}
41
42impl Store {
43    pub async fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
44        let database = Database::open(path.as_ref())
45            .await
46            .map_err(Error::database_open)?;
47        Ok(Self { database })
48    }
49
50    pub fn initialize_storage(&self) -> Result<StorageLayout, Error> {
51        let root = crate::attachments::default_blob_dir(self.database.path());
52        let objects = root.join("objects").join("sha256");
53        let trash = root.join("trash");
54        let previews = root.join("cache").join("previews");
55        for directory in [&objects, &trash, &previews] {
56            std::fs::create_dir_all(directory)
57                .map_err(|error| Error::from_internal(error.into()))?;
58        }
59        Ok(StorageLayout {
60            root,
61            staging: objects.clone(),
62            objects,
63            trash,
64            previews,
65        })
66    }
67
68    pub async fn start_sync_session(
69        &self,
70        server: String,
71        auth_token: Option<String>,
72        page_budget: Option<usize>,
73    ) -> Result<SyncSession, Error> {
74        if !crate::sync::wire::sync_server_url_is_valid(&server) {
75            return Err(Error::new(
76                ErrorCode::Validation,
77                "invalid sync server URL".to_string(),
78            ));
79        }
80        SyncSession::start(self.database.clone(), server, auth_token, page_budget)
81            .await
82            .map_err(Error::from_internal)
83    }
84
85    pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, Error> {
86        self.database
87            .list_workspaces()
88            .await
89            .map(|workspaces| workspaces.into_iter().map(WorkspaceRecord::from).collect())
90            .map_err(Error::from_internal)
91    }
92
93    pub async fn resolve_workspace(&self, name_or_key: &str) -> Result<WorkspaceRecord, Error> {
94        self.database
95            .find_workspace(name_or_key)
96            .await
97            .map_err(Error::from_internal)?
98            .map(WorkspaceRecord::from)
99            .ok_or_else(|| {
100                Error::new(
101                    ErrorCode::NotFound,
102                    format!("workspace not found: {name_or_key}"),
103                )
104            })
105    }
106
107    pub async fn list_metadata_fields(
108        &self,
109        workspace_id: &WorkspaceId,
110    ) -> Result<Vec<MetadataFieldRecord>, Error> {
111        self.workspace(workspace_id).await?;
112        self.database
113            .list_metadata_fields(workspace_id)
114            .await
115            .map(|fields| fields.into_iter().map(Into::into).collect())
116            .map_err(Error::from_internal)
117    }
118
119    pub async fn rename_metadata_field(
120        &self,
121        workspace_id: &WorkspaceId,
122        key: &str,
123        new_key: &str,
124    ) -> Result<MetadataFieldRecord, Error> {
125        let workspace = self.workspace(workspace_id).await?;
126        let field = self
127            .database
128            .rename_metadata_field(&workspace, key, new_key)
129            .await
130            .map_err(Error::from_internal)?;
131        let fields = self.list_metadata_fields(workspace_id).await?;
132        fields
133            .into_iter()
134            .find(|candidate| candidate.id == field.id)
135            .ok_or_else(|| Error::new(ErrorCode::NotFound, "metadata field not found".to_string()))
136    }
137
138    pub async fn create_task(
139        &self,
140        workspace_id: &WorkspaceId,
141        input: CreateTask,
142    ) -> Result<TaskRecord, Error> {
143        validate_project(&input.project)?;
144        validate_optional_date("available_at", input.available_at.as_deref())?;
145        validate_optional_date("due_on", input.due_on.as_deref())?;
146        let workspace = self.workspace(workspace_id).await?;
147        let outcome = self
148            .database
149            .create_task(
150                &workspace,
151                TaskDraft {
152                    title: input.title,
153                    description: input.description,
154                    project: Some(input.project),
155                    status: input.status.as_str().to_string(),
156                    priority: input.priority.as_str().to_string(),
157                    source: TaskSource::Api,
158                    labels: Vec::new(),
159                    metadata: input
160                        .metadata
161                        .into_iter()
162                        .map(TaskMetadataInput::from)
163                        .collect(),
164                    available_at: input.available_at,
165                    due_on: input.due_on,
166                    is_epic: false,
167                },
168            )
169            .await
170            .map_err(Error::from_internal)?;
171        let metadata = self
172            .database
173            .task_metadata(workspace_id, &outcome.task.id)
174            .await
175            .map_err(Error::from_internal)?;
176        Ok(TaskRecord::with_metadata(outcome.task, metadata))
177    }
178
179    pub async fn update_task(
180        &self,
181        workspace_id: &WorkspaceId,
182        task_id: &TaskId,
183        input: UpdateTask,
184    ) -> Result<TaskUpdateResult, Error> {
185        if let Some(project) = input.project.as_deref() {
186            validate_project(project)?;
187        }
188        validate_date_update("available_at", &input.available_at)?;
189        validate_date_update("due_on", &input.due_on)?;
190        let workspace = self.workspace(workspace_id).await?;
191        self.fetch_task(workspace_id, task_id).await?;
192        let outcome = self
193            .database
194            .update_task(
195                &workspace,
196                task_id,
197                InternalTaskUpdate {
198                    title: input.title,
199                    description: input.description,
200                    project: input.project,
201                    status: input.status.map(|status| status.as_str().to_string()),
202                    priority: input.priority.map(|priority| priority.as_str().to_string()),
203                    available_at: input.available_at.into_internal(),
204                    due_on: input.due_on.into_internal(),
205                    set_metadata: input
206                        .set_metadata
207                        .into_iter()
208                        .map(TaskMetadataInput::from)
209                        .collect(),
210                    remove_metadata: input.remove_metadata,
211                    ..InternalTaskUpdate::default()
212                },
213            )
214            .await
215            .map_err(Error::from_internal)?;
216        let metadata = self
217            .database
218            .task_metadata(workspace_id, task_id)
219            .await
220            .map_err(Error::from_internal)?;
221        Ok(TaskUpdateResult {
222            task: TaskRecord::with_metadata(outcome.task, metadata),
223            changed: outcome.changed,
224        })
225    }
226
227    pub async fn list_tasks(&self, workspace_id: &WorkspaceId) -> Result<Vec<TaskRecord>, Error> {
228        self.workspace(workspace_id).await?;
229        let filters = TaskFilters {
230            exclude_epics: true,
231            ..TaskFilters::default()
232        };
233        self.database
234            .list_task_items(
235                workspace_id,
236                filters,
237                TaskQueryMode::Flat,
238                TaskSort::Created,
239                SortDirection::Asc,
240            )
241            .await
242            .map(|items| {
243                items
244                    .into_iter()
245                    .map(|item| TaskRecord::from(item.task))
246                    .collect()
247            })
248            .map_err(Error::from_internal)
249    }
250
251    pub async fn fetch_task(
252        &self,
253        workspace_id: &WorkspaceId,
254        task_id: &TaskId,
255    ) -> Result<TaskRecord, Error> {
256        let workspace = self.workspace(workspace_id).await?;
257        let mut connection = self
258            .database
259            .acquire_reader()
260            .await
261            .map_err(Error::from_internal)?;
262        let task = crate::refs::get_task_in_workspace(&mut connection, &workspace, task_id)
263            .await
264            .map_err(Error::from_internal)?;
265        drop(connection);
266        let metadata = self
267            .database
268            .task_metadata(workspace_id, task_id)
269            .await
270            .map_err(Error::from_internal)?;
271        Ok(TaskRecord::with_metadata(task, metadata))
272    }
273
274    pub async fn create_recurrence_series(
275        &self,
276        workspace_id: &WorkspaceId,
277        input: CreateRecurrenceSeries,
278    ) -> Result<RecurrenceCreateResult, Error> {
279        validate_project(&input.project)?;
280        let workspace = self.workspace(workspace_id).await?;
281        let schedule = input.schedule.into_internal()?;
282        let outcome = self
283            .database
284            .create_recurrence_series(
285                &workspace,
286                CreateRecurrenceSeriesParams::new(RecurrenceSeriesDraft {
287                    title: input.title,
288                    description: input.description,
289                    project: input.project,
290                    priority: input.priority.as_str().to_string(),
291                    initial_status: input.initial_status.as_str().to_string(),
292                    labels: input.labels,
293                    metadata: input.metadata.into_iter().map(Into::into).collect(),
294                    schedule,
295                }),
296            )
297            .await
298            .map_err(Error::from_internal)?;
299        let metadata = self
300            .database
301            .task_metadata(workspace_id, &outcome.task.id)
302            .await
303            .map_err(Error::from_internal)?;
304        Ok(RecurrenceCreateResult {
305            series: RecurrenceSeriesRecord::from(outcome.series),
306            series_ref: outcome.series_ref,
307            occurrence: RecurrenceOccurrenceRecord::from(outcome.occurrence),
308            task: TaskRecord::with_metadata(outcome.task, metadata),
309        })
310    }
311
312    pub async fn update_recurrence_template(
313        &self,
314        workspace_id: &WorkspaceId,
315        series_id: &RecurrenceSeriesId,
316        input: UpdateRecurrenceTemplate,
317    ) -> Result<RecurrenceTemplateUpdateResult, Error> {
318        if let Some(project) = input.project.as_deref() {
319            validate_project(project)?;
320        }
321        let workspace = self.workspace(workspace_id).await?;
322        self.database
323            .update_recurrence_template(
324                &workspace,
325                series_id,
326                UpdateRecurrenceTemplateParams::new(InternalRecurrenceTemplateUpdate {
327                    title: input.title,
328                    description: input.description,
329                    project: input.project,
330                    priority: input.priority.map(|value| value.as_str().to_string()),
331                    initial_status: input.initial_status.map(|value| value.as_str().to_string()),
332                    labels: input.labels,
333                    set_metadata: input.set_metadata.into_iter().map(Into::into).collect(),
334                    remove_metadata: input.remove_metadata,
335                    available_local_time: input.available_local_time.into_internal()?,
336                    due_policy: input.due_policy,
337                }),
338            )
339            .await
340            .map(|outcome| RecurrenceTemplateUpdateResult {
341                series: RecurrenceSeriesRecord::from(outcome.series),
342                changed: outcome.changed,
343            })
344            .map_err(Error::from_internal)
345    }
346
347    pub async fn resolve_recurrence_ref(
348        &self,
349        workspace_id: &WorkspaceId,
350        input: &str,
351    ) -> Result<RecurrenceRefResolution, Error> {
352        let workspace = self.workspace(workspace_id).await?;
353        let series = self
354            .database
355            .resolve_recurrence_ref(&workspace, input)
356            .await
357            .map_err(Error::from_internal)?;
358        let series_ref = self
359            .database
360            .recurrence_series_ref(workspace_id, &series.id)
361            .await
362            .map_err(Error::from_internal)?;
363        Ok(RecurrenceRefResolution {
364            series_id: series.id,
365            series_ref,
366        })
367    }
368
369    pub async fn list_recurrence_series(
370        &self,
371        workspace_id: &WorkspaceId,
372    ) -> Result<Vec<RecurrenceSeriesSummary>, Error> {
373        self.workspace(workspace_id).await?;
374        self.database
375            .list_recurrence_series(workspace_id)
376            .await
377            .map(|values| values.into_iter().map(Into::into).collect())
378            .map_err(Error::from_internal)
379    }
380
381    pub async fn show_recurrence_series(
382        &self,
383        workspace_id: &WorkspaceId,
384        input: &str,
385    ) -> Result<RecurrenceSeriesDetail, Error> {
386        let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
387        self.database
388            .recurrence_series_detail(workspace_id, &resolution.series_id)
389            .await
390            .map(Into::into)
391            .map_err(Error::from_internal)
392    }
393
394    pub async fn recurrence_history(
395        &self,
396        workspace_id: &WorkspaceId,
397        input: &str,
398        offset: usize,
399        limit: usize,
400    ) -> Result<RecurrenceHistoryPage, Error> {
401        if limit == 0 || limit > MAX_RECURRENCE_HISTORY_LIMIT {
402            return Err(Error::new(
403                ErrorCode::Validation,
404                format!(
405                    "recurrence history limit must be between 1 and {MAX_RECURRENCE_HISTORY_LIMIT}"
406                ),
407            ));
408        }
409        let resolution = self.resolve_recurrence_ref(workspace_id, input).await?;
410        self.database
411            .recurrence_history(workspace_id, &resolution.series_id, offset, limit)
412            .await
413            .map(|page| RecurrenceHistoryPage {
414                series_ref: page.series_ref,
415                items: page.items.into_iter().map(Into::into).collect(),
416                offset: page.offset,
417                limit: page.limit,
418                total: page.total,
419                has_more: page.has_more,
420            })
421            .map_err(Error::from_internal)
422    }
423
424    pub async fn resolve_recurrence_occurrence(
425        &self,
426        workspace_id: &WorkspaceId,
427        task_id: &TaskId,
428        outcome: RecurrenceOutcome,
429    ) -> Result<RecurrenceResolveResult, Error> {
430        let workspace = self.workspace(workspace_id).await?;
431        self.database
432            .resolve_recurrence_occurrence(&workspace, task_id, outcome)
433            .await
434            .map(|value| RecurrenceResolveResult {
435                series: value.series.into(),
436                occurrence: value.resolved.into(),
437                task: value.task.into(),
438                successor: value.successor.map(Into::into),
439            })
440            .map_err(Error::from_internal)
441    }
442
443    pub async fn complete_recurrence_occurrence(
444        &self,
445        workspace_id: &WorkspaceId,
446        task_id: &TaskId,
447    ) -> Result<RecurrenceResolveResult, Error> {
448        self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Completed)
449            .await
450    }
451
452    pub async fn skip_recurrence_occurrence(
453        &self,
454        workspace_id: &WorkspaceId,
455        task_id: &TaskId,
456    ) -> Result<RecurrenceResolveResult, Error> {
457        self.resolve_recurrence_occurrence(workspace_id, task_id, RecurrenceOutcome::Skipped)
458            .await
459    }
460
461    pub async fn pause_recurrence_series(
462        &self,
463        workspace_id: &WorkspaceId,
464        series_id: &RecurrenceSeriesId,
465    ) -> Result<RecurrenceStateResult, Error> {
466        let workspace = self.workspace(workspace_id).await?;
467        self.database
468            .pause_recurrence_series(&workspace, series_id)
469            .await
470            .map(Into::into)
471            .map_err(Error::from_internal)
472    }
473
474    pub async fn resume_recurrence_series(
475        &self,
476        workspace_id: &WorkspaceId,
477        series_id: &RecurrenceSeriesId,
478    ) -> Result<RecurrenceStateResult, Error> {
479        let workspace = self.workspace(workspace_id).await?;
480        let at = crate::ids::now_utc();
481        self.database
482            .resume_recurrence_series(&workspace, series_id, at)
483            .await
484            .map(Into::into)
485            .map_err(Error::from_internal)
486    }
487
488    pub async fn stop_recurrence_series(
489        &self,
490        workspace_id: &WorkspaceId,
491        series_id: &RecurrenceSeriesId,
492        skip_current: bool,
493    ) -> Result<RecurrenceStateResult, Error> {
494        let workspace = self.workspace(workspace_id).await?;
495        self.database
496            .stop_recurrence_series(&workspace, series_id, skip_current)
497            .await
498            .map(Into::into)
499            .map_err(Error::from_internal)
500    }
501
502    pub async fn recurrence_task_report(
503        &self,
504        workspace_id: &WorkspaceId,
505        expand_recurring: bool,
506    ) -> Result<Vec<TaskSummary>, Error> {
507        self.workspace(workspace_id).await?;
508        self.database
509            .list_task_items(
510                workspace_id,
511                TaskFilters {
512                    exclude_epics: true,
513                    expand_recurring,
514                    ..TaskFilters::default()
515                },
516                TaskQueryMode::Flat,
517                TaskSort::Created,
518                SortDirection::Asc,
519            )
520            .await
521            .map(|items| items.into_iter().map(TaskSummary::from).collect())
522            .map_err(Error::from_internal)
523    }
524
525    pub async fn list_conflicts(
526        &self,
527        workspace_id: &WorkspaceId,
528    ) -> Result<Vec<ConflictSummary>, Error> {
529        let workspace = self.workspace(workspace_id).await?;
530        self.database
531            .list_conflicts(&workspace, None, None)
532            .await
533            .map_err(Error::from_internal)?
534            .into_iter()
535            .filter(|conflict| !conflict.field.starts_with("metadata:"))
536            .map(|conflict| {
537                Ok(ConflictSummary {
538                    task_id: conflict.task_id,
539                    task_title: conflict.title,
540                    project_key: conflict.project_key,
541                    project_prefix: conflict.project_prefix,
542                    field: ConflictField::from_task_field(TaskField::parse_or_unknown(
543                        &conflict.field,
544                    )?),
545                })
546            })
547            .collect::<Result<Vec<_>, InternalError>>()
548            .map_err(Error::from_internal)
549    }
550
551    pub async fn inspect_conflicts(
552        &self,
553        workspace_id: &WorkspaceId,
554        task_id: &TaskId,
555    ) -> Result<Vec<Conflict>, Error> {
556        let workspace = self.workspace(workspace_id).await?;
557        let details = self
558            .database
559            .task_conflicts(&workspace, task_id, None)
560            .await
561            .map_err(Error::from_internal)?;
562        let mut conflicts = Vec::with_capacity(details.len());
563        for detail in details {
564            if detail.field.starts_with("metadata:") {
565                continue;
566            }
567            let field = TaskField::parse_or_unknown(&detail.field).map_err(Error::from_internal)?;
568            let local_value = self
569                .database
570                .conflict_display_value(workspace_id, field.as_str(), &detail.local_value)
571                .await
572                .map_err(Error::from_internal)?;
573            let remote_value = self
574                .database
575                .conflict_display_value(workspace_id, field.as_str(), &detail.remote_value)
576                .await
577                .map_err(Error::from_internal)?;
578            conflicts.push(Conflict {
579                task_id: task_id.clone(),
580                field: ConflictField::from_task_field(field),
581                local_value,
582                remote_value,
583            });
584        }
585        Ok(conflicts)
586    }
587
588    pub async fn resolve_conflict(
589        &self,
590        workspace_id: &WorkspaceId,
591        task_id: &TaskId,
592        field: ConflictField,
593        choice: ConflictChoice,
594    ) -> Result<TaskRecord, Error> {
595        let workspace = self.workspace(workspace_id).await?;
596        let field_name = field.as_str();
597        let choice = match choice {
598            ConflictChoice::Local => crate::operations::ConflictValueChoice::Local,
599            ConflictChoice::Remote => crate::operations::ConflictValueChoice::Remote,
600        };
601        let mut connection = self
602            .database
603            .acquire_writer()
604            .await
605            .map_err(Error::from_internal)?;
606        crate::operations::resolve_conflict_choice(
607            &mut connection,
608            &workspace,
609            task_id,
610            field_name,
611            choice,
612        )
613        .await
614        .map(|outcome| TaskRecord::from(outcome.task))
615        .map_err(Error::from_internal)
616    }
617
618    async fn workspace(&self, workspace_id: &WorkspaceId) -> Result<Workspace, Error> {
619        self.database
620            .workspace_for_id(workspace_id)
621            .await
622            .map_err(|error| {
623                let error = Error::from_internal(error);
624                if error.code == ErrorCode::NotFound {
625                    Error::new(
626                        ErrorCode::NotFound,
627                        format!("workspace not found: {workspace_id}"),
628                    )
629                } else {
630                    error
631                }
632            })
633    }
634}
635
636fn validate_project(project: &str) -> Result<(), Error> {
637    if crate::projects::normalize_key(project).is_empty() {
638        return Err(Error::new(
639            ErrorCode::Validation,
640            "project must contain at least one letter or number".to_string(),
641        ));
642    }
643    Ok(())
644}
645
646fn validate_optional_date(field: &str, value: Option<&str>) -> Result<(), Error> {
647    let Some(value) = value else {
648        return Ok(());
649    };
650    if value.is_empty() {
651        return Err(Error::new(
652            ErrorCode::Validation,
653            format!("{field} must be absent or contain a date"),
654        ));
655    }
656    let result = match field {
657        "available_at" => crate::time_validation::validate_available_at_value(value),
658        "due_on" => crate::time_validation::validate_due_on_value(value),
659        _ => unreachable!("consumer API validates only task dates"),
660    };
661    result.map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
662}
663
664fn validate_date_update(field: &str, update: &OptionalDateUpdate) -> Result<(), Error> {
665    match update {
666        OptionalDateUpdate::Unchanged | OptionalDateUpdate::Clear => Ok(()),
667        OptionalDateUpdate::Set(value) => validate_optional_date(field, Some(value)),
668    }
669}
670
671fn parse_date(field: &str, value: &str) -> Result<NaiveDate, Error> {
672    NaiveDate::parse_from_str(value, "%Y-%m-%d").map_err(|_| {
673        Error::new(
674            ErrorCode::Validation,
675            format!("{field} must use YYYY-MM-DD"),
676        )
677    })
678}
679
680fn parse_local_time(field: &str, value: &str) -> Result<NaiveTime, Error> {
681    NaiveTime::parse_from_str(value, "%H:%M")
682        .or_else(|_| NaiveTime::parse_from_str(value, "%H:%M:%S"))
683        .map_err(|_| {
684            Error::new(
685                ErrorCode::Validation,
686                format!("{field} must use HH:MM or HH:MM:SS"),
687            )
688        })
689}
690
691#[derive(Debug, Clone, PartialEq, Eq)]
692pub struct StorageLayout {
693    pub root: PathBuf,
694    pub objects: PathBuf,
695    pub staging: PathBuf,
696    pub trash: PathBuf,
697    pub previews: PathBuf,
698}
699
700#[derive(Debug, Clone, PartialEq, Eq)]
701pub struct WorkspaceRecord {
702    pub id: WorkspaceId,
703    pub key: String,
704    pub name: String,
705}
706
707impl From<Workspace> for WorkspaceRecord {
708    fn from(workspace: Workspace) -> Self {
709        Self {
710            id: workspace.id,
711            key: workspace.key,
712            name: workspace.name,
713        }
714    }
715}
716
717#[derive(Debug, Clone, PartialEq, Eq)]
718pub struct MetadataFieldRecord {
719    pub id: MetadataFieldId,
720    pub workspace_id: WorkspaceId,
721    pub key: String,
722    pub task_count: usize,
723    pub series_count: usize,
724}
725
726impl From<MetadataFieldUsage> for MetadataFieldRecord {
727    fn from(value: MetadataFieldUsage) -> Self {
728        Self {
729            id: value.field.id,
730            workspace_id: value.field.workspace_id,
731            key: value.field.key,
732            task_count: value.task_count,
733            series_count: value.series_count,
734        }
735    }
736}
737
738#[derive(Debug, Clone, PartialEq, Eq)]
739pub struct MetadataValueRecord {
740    pub field_id: MetadataFieldId,
741    pub key: String,
742    pub value: String,
743}
744
745impl From<TaskMetadataValue> for MetadataValueRecord {
746    fn from(value: TaskMetadataValue) -> Self {
747        Self {
748            field_id: value.field_id,
749            key: value.key,
750            value: value.value,
751        }
752    }
753}
754
755#[derive(Debug, Clone, PartialEq, Eq)]
756pub struct MetadataInput {
757    pub key: String,
758    pub value: String,
759}
760
761impl From<MetadataInput> for TaskMetadataInput {
762    fn from(input: MetadataInput) -> Self {
763        Self {
764            key: input.key,
765            value: input.value,
766        }
767    }
768}
769
770#[derive(Debug, Clone, PartialEq, Eq)]
771pub struct CreateTask {
772    pub title: String,
773    pub description: String,
774    pub project: String,
775    pub status: TaskStatus,
776    pub priority: TaskPriority,
777    pub metadata: Vec<MetadataInput>,
778    pub available_at: Option<String>,
779    pub due_on: Option<String>,
780}
781
782#[derive(Debug, Clone, PartialEq, Eq, Default)]
783pub struct UpdateTask {
784    pub title: Option<String>,
785    pub description: Option<String>,
786    pub project: Option<String>,
787    pub status: Option<TaskStatus>,
788    pub priority: Option<TaskPriority>,
789    pub set_metadata: Vec<MetadataInput>,
790    pub remove_metadata: Vec<String>,
791    pub available_at: OptionalDateUpdate,
792    pub due_on: OptionalDateUpdate,
793}
794
795#[derive(Debug, Clone, PartialEq, Eq, Default)]
796pub enum OptionalDateUpdate {
797    #[default]
798    Unchanged,
799    Set(String),
800    Clear,
801}
802
803impl OptionalDateUpdate {
804    fn into_internal(self) -> Option<Option<String>> {
805        match self {
806            Self::Unchanged => None,
807            Self::Set(value) => Some(Some(value)),
808            Self::Clear => Some(None),
809        }
810    }
811}
812
813#[derive(Debug, Clone, Copy, PartialEq, Eq)]
814pub enum RecurrenceWeekday {
815    Monday,
816    Tuesday,
817    Wednesday,
818    Thursday,
819    Friday,
820    Saturday,
821    Sunday,
822}
823
824impl From<RecurrenceWeekday> for Weekday {
825    fn from(value: RecurrenceWeekday) -> Self {
826        match value {
827            RecurrenceWeekday::Monday => Self::Mon,
828            RecurrenceWeekday::Tuesday => Self::Tue,
829            RecurrenceWeekday::Wednesday => Self::Wed,
830            RecurrenceWeekday::Thursday => Self::Thu,
831            RecurrenceWeekday::Friday => Self::Fri,
832            RecurrenceWeekday::Saturday => Self::Sat,
833            RecurrenceWeekday::Sunday => Self::Sun,
834        }
835    }
836}
837
838impl From<Weekday> for RecurrenceWeekday {
839    fn from(value: Weekday) -> Self {
840        match value {
841            Weekday::Mon => Self::Monday,
842            Weekday::Tue => Self::Tuesday,
843            Weekday::Wed => Self::Wednesday,
844            Weekday::Thu => Self::Thursday,
845            Weekday::Fri => Self::Friday,
846            Weekday::Sat => Self::Saturday,
847            Weekday::Sun => Self::Sunday,
848        }
849    }
850}
851
852#[derive(Debug, Clone, PartialEq, Eq)]
853pub struct RecurrenceRule {
854    pub frequency: RecurrenceFrequency,
855    pub interval: u32,
856    pub weekdays: Vec<RecurrenceWeekday>,
857}
858
859impl RecurrenceRule {
860    fn into_internal(self) -> Result<InternalRecurrenceRule, Error> {
861        InternalRecurrenceRule::new(
862            self.frequency,
863            self.interval,
864            WeekdaySet::from_weekdays(self.weekdays.into_iter().map(Into::into)),
865        )
866        .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
867    }
868}
869
870impl From<InternalRecurrenceRule> for RecurrenceRule {
871    fn from(value: InternalRecurrenceRule) -> Self {
872        Self {
873            frequency: value.frequency(),
874            interval: value.interval(),
875            weekdays: value.weekdays_set().iter().map(Into::into).collect(),
876        }
877    }
878}
879
880#[derive(Debug, Clone, PartialEq, Eq)]
881pub struct RecurrenceScheduleInput {
882    pub rule: RecurrenceRule,
883    pub timezone: String,
884    pub start_on: String,
885    pub available_local_time: Option<String>,
886    pub due_policy: RecurrenceDuePolicy,
887}
888
889impl RecurrenceScheduleInput {
890    fn into_internal(self) -> Result<RecurrenceSchedule, Error> {
891        let timezone = self
892            .timezone
893            .parse::<TimeZoneId>()
894            .map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))?;
895        let start_on = parse_date("start_on", &self.start_on)?;
896        let available_local_time = self
897            .available_local_time
898            .map(|value| parse_local_time("available_local_time", &value))
899            .transpose()?;
900        Ok(RecurrenceSchedule::new(
901            self.rule.into_internal()?,
902            timezone,
903            start_on,
904            available_local_time,
905            self.due_policy,
906        ))
907    }
908}
909
910#[derive(Debug, Clone, PartialEq, Eq)]
911pub struct CreateRecurrenceSeries {
912    pub title: String,
913    pub description: String,
914    pub project: String,
915    pub priority: TaskPriority,
916    pub initial_status: TaskStatus,
917    pub labels: Vec<String>,
918    pub metadata: Vec<MetadataInput>,
919    pub schedule: RecurrenceScheduleInput,
920}
921
922#[derive(Debug, Clone, PartialEq, Eq, Default)]
923pub enum OptionalLocalTimeUpdate {
924    #[default]
925    Unchanged,
926    Set(String),
927    Clear,
928}
929
930impl OptionalLocalTimeUpdate {
931    fn into_internal(self) -> Result<Option<Option<NaiveTime>>, Error> {
932        match self {
933            Self::Unchanged => Ok(None),
934            Self::Set(value) => Ok(Some(Some(parse_local_time(
935                "available_local_time",
936                &value,
937            )?))),
938            Self::Clear => Ok(Some(None)),
939        }
940    }
941}
942
943#[derive(Debug, Clone, PartialEq, Eq, Default)]
944pub struct UpdateRecurrenceTemplate {
945    pub title: Option<String>,
946    pub description: Option<String>,
947    pub project: Option<String>,
948    pub priority: Option<TaskPriority>,
949    pub initial_status: Option<TaskStatus>,
950    pub labels: Option<Vec<String>>,
951    pub set_metadata: Vec<MetadataInput>,
952    pub remove_metadata: Vec<String>,
953    pub available_local_time: OptionalLocalTimeUpdate,
954    pub due_policy: Option<RecurrenceDuePolicy>,
955}
956
957#[derive(Debug, Clone, PartialEq, Eq)]
958pub struct RecurrenceSeriesRecord {
959    pub workspace_id: WorkspaceId,
960    pub id: RecurrenceSeriesId,
961    pub title: String,
962    pub description: String,
963    pub project_id: ProjectId,
964    pub priority: TaskPriority,
965    pub initial_status: TaskStatus,
966    pub rule: RecurrenceRule,
967    pub timezone: String,
968    pub start_on: String,
969    pub available_local_time: Option<String>,
970    pub due_policy: RecurrenceDuePolicy,
971    pub state: RecurrenceSeriesState,
972    pub stopped_at: Option<String>,
973    pub created_at: String,
974    pub updated_at: String,
975}
976
977impl From<RecurrenceSeries> for RecurrenceSeriesRecord {
978    fn from(value: RecurrenceSeries) -> Self {
979        Self {
980            workspace_id: value.workspace_id,
981            id: value.id,
982            title: value.title,
983            description: value.description,
984            project_id: value.project_id,
985            priority: value.priority,
986            initial_status: value.initial_status,
987            rule: value.rule.into(),
988            timezone: value.timezone.to_string(),
989            start_on: value.start_on.to_string(),
990            available_local_time: value
991                .available_local_time
992                .map(|time| time.format("%H:%M:%S").to_string()),
993            due_policy: value.due_policy,
994            state: value.state,
995            stopped_at: value.stopped_at,
996            created_at: value.created_at,
997            updated_at: value.updated_at,
998        }
999    }
1000}
1001
1002#[derive(Debug, Clone, PartialEq, Eq)]
1003pub struct RecurrenceOccurrenceRecord {
1004    pub series_id: RecurrenceSeriesId,
1005    pub slot_on: String,
1006    pub task_id: Option<TaskId>,
1007    pub outcome: Option<RecurrenceOutcome>,
1008    pub resolved_at: Option<String>,
1009    pub projection_state: RecurrenceProjectionState,
1010    pub archived_at: Option<String>,
1011}
1012
1013impl From<RecurrenceOccurrence> for RecurrenceOccurrenceRecord {
1014    fn from(value: RecurrenceOccurrence) -> Self {
1015        Self {
1016            series_id: value.series_id,
1017            slot_on: value.slot_on.to_string(),
1018            task_id: value.task_id,
1019            outcome: value.outcome,
1020            resolved_at: value.resolved_at,
1021            projection_state: value.projection_state,
1022            archived_at: value.archived_at,
1023        }
1024    }
1025}
1026
1027#[derive(Debug, Clone, PartialEq, Eq)]
1028pub struct RecurrenceCreateResult {
1029    pub series: RecurrenceSeriesRecord,
1030    pub series_ref: String,
1031    pub occurrence: RecurrenceOccurrenceRecord,
1032    pub task: TaskRecord,
1033}
1034
1035#[derive(Debug, Clone, PartialEq, Eq)]
1036pub struct RecurrenceTemplateUpdateResult {
1037    pub series: RecurrenceSeriesRecord,
1038    pub changed: bool,
1039}
1040
1041#[derive(Debug, Clone, PartialEq, Eq)]
1042pub struct RecurrenceResolveResult {
1043    pub series: RecurrenceSeriesRecord,
1044    pub occurrence: RecurrenceOccurrenceRecord,
1045    pub task: TaskRecord,
1046    pub successor: Option<TaskRecord>,
1047}
1048
1049#[derive(Debug, Clone, PartialEq, Eq)]
1050pub struct RecurrenceStateResult {
1051    pub series: RecurrenceSeriesRecord,
1052    pub occurrence: Option<RecurrenceOccurrenceRecord>,
1053}
1054
1055impl From<crate::operations::RecurrenceStateOutcome> for RecurrenceStateResult {
1056    fn from(value: crate::operations::RecurrenceStateOutcome) -> Self {
1057        Self {
1058            series: value.series.into(),
1059            occurrence: value.occurrence.map(Into::into),
1060        }
1061    }
1062}
1063
1064#[derive(Debug, Clone, PartialEq, Eq)]
1065pub struct RecurrenceRefResolution {
1066    pub series_id: RecurrenceSeriesId,
1067    pub series_ref: String,
1068}
1069
1070#[derive(Debug, Clone, PartialEq, Eq, Default)]
1071pub struct RecurrenceCounts {
1072    pub completed: u64,
1073    pub skipped: u64,
1074    pub missed: u64,
1075    pub pause_intervals: u64,
1076    pub latest_slot_on: Option<String>,
1077    pub latest_outcome: Option<RecurrenceOutcome>,
1078}
1079
1080impl From<InternalRecurrenceCounts> for RecurrenceCounts {
1081    fn from(value: InternalRecurrenceCounts) -> Self {
1082        Self {
1083            completed: value.completed as u64,
1084            skipped: value.skipped as u64,
1085            missed: value.missed as u64,
1086            pause_intervals: value.pause_intervals as u64,
1087            latest_slot_on: value.latest_slot_on,
1088            latest_outcome: value.latest_outcome,
1089        }
1090    }
1091}
1092
1093#[derive(Debug, Clone, PartialEq, Eq)]
1094pub struct RecurrenceSeriesSummary {
1095    pub series: RecurrenceSeriesRecord,
1096    pub series_ref: String,
1097    pub rule_label: String,
1098    pub current_slot_on: Option<String>,
1099    pub current_task_ref: Option<String>,
1100    pub counts: RecurrenceCounts,
1101}
1102
1103impl From<InternalRecurrenceSeriesSummary> for RecurrenceSeriesSummary {
1104    fn from(value: InternalRecurrenceSeriesSummary) -> Self {
1105        Self {
1106            series: value.series.into(),
1107            series_ref: value.series_ref,
1108            rule_label: value.rule_label,
1109            current_slot_on: value.current_slot_on,
1110            current_task_ref: value.current_task_ref,
1111            counts: value.counts.into(),
1112        }
1113    }
1114}
1115
1116#[derive(Debug, Clone, PartialEq, Eq)]
1117pub struct RecurrenceSeriesConflict {
1118    pub field: String,
1119    pub variant_a: String,
1120    pub local_value: String,
1121    pub variant_b: String,
1122    pub remote_value: String,
1123}
1124
1125#[derive(Debug, Clone, PartialEq, Eq)]
1126pub struct RecurrenceSeriesDetail {
1127    pub series: RecurrenceSeriesRecord,
1128    pub labels: Vec<String>,
1129    pub metadata: Vec<MetadataValueRecord>,
1130    pub summary: RecurrenceSeriesSummary,
1131    pub current_occurrence: Option<RecurrenceOccurrenceRecord>,
1132    pub lifecycle_conflicts: Vec<RecurrenceSeriesConflict>,
1133}
1134
1135impl From<InternalRecurrenceSeriesDetail> for RecurrenceSeriesDetail {
1136    fn from(value: InternalRecurrenceSeriesDetail) -> Self {
1137        Self {
1138            series: value.series.into(),
1139            labels: value.labels,
1140            metadata: value.metadata.into_iter().map(Into::into).collect(),
1141            summary: value.summary.into(),
1142            current_occurrence: value.current_occurrence.map(Into::into),
1143            lifecycle_conflicts: value
1144                .lifecycle_conflicts
1145                .into_iter()
1146                .map(|conflict| RecurrenceSeriesConflict {
1147                    field: conflict.field,
1148                    variant_a: conflict.variant_a,
1149                    local_value: conflict.local_value,
1150                    variant_b: conflict.variant_b,
1151                    remote_value: conflict.remote_value,
1152                })
1153                .collect(),
1154        }
1155    }
1156}
1157
1158#[derive(Debug, Clone, PartialEq, Eq)]
1159pub struct RecurrenceHistoryRow {
1160    pub kind: RecurrenceHistoryKind,
1161    pub slot_on: Option<String>,
1162    pub interval_started_at: Option<String>,
1163    pub interval_ended_at: Option<String>,
1164    pub task_id: Option<TaskId>,
1165    pub task_ref: Option<String>,
1166    pub openable: bool,
1167    pub archived_projection: bool,
1168    pub resolved_at: Option<String>,
1169}
1170
1171impl From<InternalRecurrenceHistoryEntry> for RecurrenceHistoryRow {
1172    fn from(value: InternalRecurrenceHistoryEntry) -> Self {
1173        Self {
1174            kind: value.kind,
1175            slot_on: value.slot_on,
1176            interval_started_at: value.interval_started_at,
1177            interval_ended_at: value.interval_ended_at,
1178            task_id: value.task_id,
1179            task_ref: value.task_ref,
1180            openable: value.openable,
1181            archived_projection: value.archived_projection,
1182            resolved_at: value.resolved_at,
1183        }
1184    }
1185}
1186
1187#[derive(Debug, Clone, PartialEq, Eq)]
1188pub struct RecurrenceHistoryPage {
1189    pub series_ref: String,
1190    pub items: Vec<RecurrenceHistoryRow>,
1191    pub offset: usize,
1192    pub limit: usize,
1193    pub total: usize,
1194    pub has_more: bool,
1195}
1196
1197#[derive(Debug, Clone, PartialEq, Eq)]
1198pub struct TaskRecurrenceSummary {
1199    pub series_id: RecurrenceSeriesId,
1200    pub series_ref: String,
1201    pub slot_on: String,
1202    pub rule_label: String,
1203    pub timezone: String,
1204    pub lifecycle: RecurrenceSeriesState,
1205    pub outcome: Option<RecurrenceOutcome>,
1206    pub projection_state: RecurrenceProjectionState,
1207}
1208
1209#[derive(Debug, Clone, PartialEq, Eq)]
1210pub struct RecurrenceTaskGroup {
1211    pub series_id: RecurrenceSeriesId,
1212    pub series_ref: String,
1213    pub counts: RecurrenceCounts,
1214}
1215
1216#[derive(Debug, Clone, PartialEq, Eq)]
1217pub struct TaskSummary {
1218    pub task: TaskRecord,
1219    pub display_ref: String,
1220    pub recurrence: Option<TaskRecurrenceSummary>,
1221    pub recurrence_group: Option<RecurrenceTaskGroup>,
1222}
1223
1224impl From<TaskListItem> for TaskSummary {
1225    fn from(value: TaskListItem) -> Self {
1226        Self {
1227            task: value.task.into(),
1228            display_ref: value.display_ref,
1229            recurrence: value.recurrence.map(|summary| TaskRecurrenceSummary {
1230                series_id: summary.series_id,
1231                series_ref: summary.series_ref,
1232                slot_on: summary.slot_on,
1233                rule_label: summary.rule_label,
1234                timezone: summary.timezone,
1235                lifecycle: summary.lifecycle,
1236                outcome: summary.outcome,
1237                projection_state: summary.projection_state,
1238            }),
1239            recurrence_group: value.recurrence_group.map(|group| RecurrenceTaskGroup {
1240                series_id: group.series_id,
1241                series_ref: group.series_ref,
1242                counts: group.counts.into(),
1243            }),
1244        }
1245    }
1246}
1247
1248#[derive(Debug, Clone, PartialEq, Eq)]
1249pub struct TaskRecord {
1250    pub id: TaskId,
1251    pub workspace_id: WorkspaceId,
1252    pub title: String,
1253    pub description: String,
1254    pub project_id: ProjectId,
1255    pub project_key: String,
1256    pub project_prefix: String,
1257    pub status: TaskStatus,
1258    pub priority: TaskPriority,
1259    pub created_at: String,
1260    pub updated_at: String,
1261    pub available_at: Option<String>,
1262    pub due_on: Option<String>,
1263    pub metadata: Vec<MetadataValueRecord>,
1264}
1265
1266impl From<Task> for TaskRecord {
1267    fn from(task: Task) -> Self {
1268        Self {
1269            id: task.id,
1270            workspace_id: task.workspace_id,
1271            title: task.title,
1272            description: task.description,
1273            project_id: task.project_id,
1274            project_key: task.project_key,
1275            project_prefix: task.project_prefix,
1276            status: task.status,
1277            priority: task.priority,
1278            created_at: task.created_at,
1279            updated_at: task.updated_at,
1280            available_at: task.available_at,
1281            due_on: task.due_on,
1282            metadata: Vec::new(),
1283        }
1284    }
1285}
1286
1287impl TaskRecord {
1288    fn with_metadata(task: Task, metadata: Vec<TaskMetadataValue>) -> Self {
1289        let mut record = Self::from(task);
1290        record.metadata = metadata.into_iter().map(Into::into).collect();
1291        record
1292    }
1293}
1294
1295#[derive(Debug, Clone, PartialEq, Eq)]
1296pub struct TaskUpdateResult {
1297    pub task: TaskRecord,
1298    pub changed: bool,
1299}
1300
1301#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1302pub enum ConflictField {
1303    Title,
1304    Description,
1305    Project,
1306    Status,
1307    Priority,
1308    AvailableAt,
1309    DueOn,
1310    Deleted,
1311    IsEpic,
1312}
1313
1314impl ConflictField {
1315    pub const fn as_str(self) -> &'static str {
1316        match self {
1317            Self::Title => "title",
1318            Self::Description => "description",
1319            Self::Project => "project",
1320            Self::Status => "status",
1321            Self::Priority => "priority",
1322            Self::AvailableAt => "available_at",
1323            Self::DueOn => "due_on",
1324            Self::Deleted => "deleted",
1325            Self::IsEpic => "is_epic",
1326        }
1327    }
1328
1329    fn from_task_field(field: TaskField) -> Self {
1330        match field {
1331            TaskField::Title => Self::Title,
1332            TaskField::Description => Self::Description,
1333            TaskField::Project => Self::Project,
1334            TaskField::Status => Self::Status,
1335            TaskField::Priority => Self::Priority,
1336            TaskField::AvailableAt => Self::AvailableAt,
1337            TaskField::DueOn => Self::DueOn,
1338            TaskField::Deleted => Self::Deleted,
1339            TaskField::IsEpic => Self::IsEpic,
1340        }
1341    }
1342}
1343
1344#[derive(Debug, Clone, PartialEq, Eq)]
1345pub struct ConflictSummary {
1346    pub task_id: TaskId,
1347    pub task_title: String,
1348    pub project_key: String,
1349    pub project_prefix: String,
1350    pub field: ConflictField,
1351}
1352
1353#[derive(Debug, Clone, PartialEq, Eq)]
1354pub struct Conflict {
1355    pub task_id: TaskId,
1356    pub field: ConflictField,
1357    pub local_value: String,
1358    pub remote_value: String,
1359}
1360
1361#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1362pub enum ConflictChoice {
1363    Local,
1364    Remote,
1365}
1366
1367#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1368pub enum ErrorCode {
1369    Validation,
1370    NotFound,
1371    OpenConflict,
1372    GenerationConflict,
1373    Database,
1374    Internal,
1375}
1376
1377impl ErrorCode {
1378    pub const fn as_str(self) -> &'static str {
1379        match self {
1380            Self::Validation => "validation",
1381            Self::NotFound => "not_found",
1382            Self::OpenConflict => "open_conflict",
1383            Self::GenerationConflict => "generation_conflict",
1384            Self::Database => "database",
1385            Self::Internal => "internal",
1386        }
1387    }
1388}
1389
1390#[derive(Debug, Clone, PartialEq, Eq)]
1391pub struct Error {
1392    pub code: ErrorCode,
1393    pub message: String,
1394}
1395
1396impl Error {
1397    fn new(code: ErrorCode, message: String) -> Self {
1398        Self { code, message }
1399    }
1400
1401    fn database_open(error: InternalError) -> Self {
1402        Self::new(ErrorCode::Database, error.to_string())
1403    }
1404
1405    fn from_internal(error: InternalError) -> Self {
1406        let code = error
1407            .chain()
1408            .find_map(|cause| cause.downcast_ref::<crate::error::CoreError>())
1409            .map(|error| match error.kind() {
1410                crate::error::ErrorKind::Validation => ErrorCode::Validation,
1411                crate::error::ErrorKind::NotFound => ErrorCode::NotFound,
1412                crate::error::ErrorKind::OpenConflict => ErrorCode::OpenConflict,
1413                crate::error::ErrorKind::GenerationConflict => ErrorCode::GenerationConflict,
1414            })
1415            .unwrap_or_else(|| {
1416                if error
1417                    .chain()
1418                    .filter_map(|cause| cause.downcast_ref::<sqlx::Error>())
1419                    .any(|error| matches!(error, sqlx::Error::RowNotFound))
1420                {
1421                    ErrorCode::NotFound
1422                } else if error
1423                    .chain()
1424                    .any(|cause| cause.downcast_ref::<sqlx::Error>().is_some())
1425                {
1426                    ErrorCode::Database
1427                } else {
1428                    ErrorCode::Internal
1429                }
1430            });
1431        Self::new(code, error.to_string())
1432    }
1433}
1434
1435impl fmt::Display for Error {
1436    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
1437        write!(formatter, "{}: {}", self.code.as_str(), self.message)
1438    }
1439}
1440
1441impl std::error::Error for Error {}
1442
1443#[cfg(test)]
1444mod tests {
1445    use super::*;
1446
1447    #[test]
1448    fn error_codes_are_stable_and_internal_errors_are_distinct() {
1449        assert_eq!(ErrorCode::Validation.as_str(), "validation");
1450        assert_eq!(ErrorCode::NotFound.as_str(), "not_found");
1451        assert_eq!(ErrorCode::OpenConflict.as_str(), "open_conflict");
1452        assert_eq!(
1453            ErrorCode::GenerationConflict.as_str(),
1454            "generation_conflict"
1455        );
1456        assert_eq!(ErrorCode::Database.as_str(), "database");
1457        assert_eq!(ErrorCode::Internal.as_str(), "internal");
1458        assert_eq!(
1459            Error::from_internal(
1460                crate::error::CoreError::generation_conflict(
1461                    "error recurrence-generation-conflict slot=2026-08-01 field=task"
1462                )
1463                .into()
1464            )
1465            .code,
1466            ErrorCode::GenerationConflict
1467        );
1468        assert_eq!(
1469            Error::from_internal(
1470                crate::error::CoreError::not_found("error recurrence-series-not-found").into()
1471            )
1472            .code,
1473            ErrorCode::NotFound
1474        );
1475        assert_eq!(
1476            Error::from_internal(anyhow::anyhow!("unexpected invariant")).code,
1477            ErrorCode::Internal
1478        );
1479        assert_eq!(
1480            Error::from_internal(sqlx::Error::PoolClosed.into()).code,
1481            ErrorCode::Database
1482        );
1483    }
1484}