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}