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