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