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