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}