1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use anyhow::Error as InternalError;
5
6use crate::choices::{TaskPriority, TaskSource, TaskStatus};
7use crate::db::Database;
8use crate::ids::{ProjectId, TaskId, WorkspaceId};
9use crate::operations::{TaskDraft, TaskUpdate as InternalTaskUpdate};
10use crate::query::{SortDirection, TaskFilters, TaskQueryMode, TaskSort};
11use crate::sync::SyncSession;
12use crate::task_fields::TaskField;
13use crate::types::Task;
14use crate::workspaces::Workspace;
15
16#[derive(Clone)]
17pub struct Store {
18 database: Database,
19}
20
21impl Store {
22 pub async fn open(path: impl AsRef<Path>) -> Result<Self, Error> {
23 let database = Database::open(path.as_ref())
24 .await
25 .map_err(Error::database_open)?;
26 Ok(Self { database })
27 }
28
29 pub fn initialize_storage(&self) -> Result<StorageLayout, Error> {
30 let root = crate::attachments::default_blob_dir(self.database.path());
31 let objects = root.join("objects").join("sha256");
32 let trash = root.join("trash");
33 let previews = root.join("cache").join("previews");
34 for directory in [&objects, &trash, &previews] {
35 std::fs::create_dir_all(directory)
36 .map_err(|error| Error::from_internal(error.into()))?;
37 }
38 Ok(StorageLayout {
39 root,
40 staging: objects.clone(),
41 objects,
42 trash,
43 previews,
44 })
45 }
46
47 pub async fn start_sync_session(
48 &self,
49 server: String,
50 auth_token: Option<String>,
51 page_budget: Option<usize>,
52 ) -> Result<SyncSession, Error> {
53 if !crate::sync::wire::sync_server_url_is_valid(&server) {
54 return Err(Error::new(
55 ErrorCode::Validation,
56 "invalid sync server URL".to_string(),
57 ));
58 }
59 SyncSession::start(self.database.clone(), server, auth_token, page_budget)
60 .await
61 .map_err(Error::from_internal)
62 }
63
64 pub async fn list_workspaces(&self) -> Result<Vec<WorkspaceRecord>, Error> {
65 self.database
66 .list_workspaces()
67 .await
68 .map(|workspaces| workspaces.into_iter().map(WorkspaceRecord::from).collect())
69 .map_err(Error::from_internal)
70 }
71
72 pub async fn resolve_workspace(&self, name_or_key: &str) -> Result<WorkspaceRecord, Error> {
73 self.database
74 .find_workspace(name_or_key)
75 .await
76 .map_err(Error::from_internal)?
77 .map(WorkspaceRecord::from)
78 .ok_or_else(|| {
79 Error::new(
80 ErrorCode::NotFound,
81 format!("workspace not found: {name_or_key}"),
82 )
83 })
84 }
85
86 pub async fn create_task(
87 &self,
88 workspace_id: &WorkspaceId,
89 input: CreateTask,
90 ) -> Result<TaskRecord, Error> {
91 validate_project(&input.project)?;
92 validate_optional_date("available_at", input.available_at.as_deref())?;
93 validate_optional_date("due_on", input.due_on.as_deref())?;
94 let workspace = self.workspace(workspace_id).await?;
95 self.database
96 .create_task(
97 &workspace,
98 TaskDraft {
99 title: input.title,
100 description: input.description,
101 project: Some(input.project),
102 status: input.status.as_str().to_string(),
103 priority: input.priority.as_str().to_string(),
104 source: TaskSource::Api,
105 labels: Vec::new(),
106 available_at: input.available_at,
107 due_on: input.due_on,
108 is_epic: false,
109 },
110 )
111 .await
112 .map(|outcome| TaskRecord::from(outcome.task))
113 .map_err(Error::from_internal)
114 }
115
116 pub async fn update_task(
117 &self,
118 workspace_id: &WorkspaceId,
119 task_id: &TaskId,
120 input: UpdateTask,
121 ) -> Result<TaskUpdateResult, Error> {
122 if let Some(project) = input.project.as_deref() {
123 validate_project(project)?;
124 }
125 validate_date_update("available_at", &input.available_at)?;
126 validate_date_update("due_on", &input.due_on)?;
127 let workspace = self.workspace(workspace_id).await?;
128 self.fetch_task(workspace_id, task_id).await?;
129 self.database
130 .update_task(
131 &workspace,
132 task_id,
133 InternalTaskUpdate {
134 title: input.title,
135 description: input.description,
136 project: input.project,
137 status: input.status.map(|status| status.as_str().to_string()),
138 priority: input.priority.map(|priority| priority.as_str().to_string()),
139 available_at: input.available_at.into_internal(),
140 due_on: input.due_on.into_internal(),
141 ..InternalTaskUpdate::default()
142 },
143 )
144 .await
145 .map(|outcome| TaskUpdateResult {
146 task: TaskRecord::from(outcome.task),
147 changed: outcome.changed,
148 })
149 .map_err(Error::from_internal)
150 }
151
152 pub async fn list_tasks(&self, workspace_id: &WorkspaceId) -> Result<Vec<TaskRecord>, Error> {
153 self.workspace(workspace_id).await?;
154 let filters = TaskFilters {
155 exclude_epics: true,
156 ..TaskFilters::default()
157 };
158 self.database
159 .list_task_items(
160 workspace_id,
161 filters,
162 TaskQueryMode::Flat,
163 TaskSort::Created,
164 SortDirection::Asc,
165 )
166 .await
167 .map(|items| {
168 items
169 .into_iter()
170 .map(|item| TaskRecord::from(item.task))
171 .collect()
172 })
173 .map_err(Error::from_internal)
174 }
175
176 pub async fn fetch_task(
177 &self,
178 workspace_id: &WorkspaceId,
179 task_id: &TaskId,
180 ) -> Result<TaskRecord, Error> {
181 let workspace = self.workspace(workspace_id).await?;
182 let mut connection = self
183 .database
184 .acquire()
185 .await
186 .map_err(Error::from_internal)?;
187 crate::refs::get_task_in_workspace(&mut connection, &workspace, task_id)
188 .await
189 .map(TaskRecord::from)
190 .map_err(Error::from_internal)
191 }
192
193 pub async fn list_conflicts(
194 &self,
195 workspace_id: &WorkspaceId,
196 ) -> Result<Vec<ConflictSummary>, Error> {
197 let workspace = self.workspace(workspace_id).await?;
198 self.database
199 .list_conflicts(&workspace, None, None)
200 .await
201 .map_err(Error::from_internal)?
202 .into_iter()
203 .map(|conflict| {
204 Ok(ConflictSummary {
205 task_id: conflict.task_id,
206 task_title: conflict.title,
207 project_key: conflict.project_key,
208 project_prefix: conflict.project_prefix,
209 field: ConflictField::from_task_field(TaskField::parse_or_unknown(
210 &conflict.field,
211 )?),
212 })
213 })
214 .collect::<Result<Vec<_>, InternalError>>()
215 .map_err(Error::from_internal)
216 }
217
218 pub async fn inspect_conflicts(
219 &self,
220 workspace_id: &WorkspaceId,
221 task_id: &TaskId,
222 ) -> Result<Vec<Conflict>, Error> {
223 let workspace = self.workspace(workspace_id).await?;
224 let details = self
225 .database
226 .task_conflicts(&workspace, task_id, None)
227 .await
228 .map_err(Error::from_internal)?;
229 let mut conflicts = Vec::with_capacity(details.len());
230 for detail in details {
231 let field = TaskField::parse_or_unknown(&detail.field).map_err(Error::from_internal)?;
232 let local_value = self
233 .database
234 .conflict_display_value(workspace_id, field.as_str(), &detail.local_value)
235 .await
236 .map_err(Error::from_internal)?;
237 let remote_value = self
238 .database
239 .conflict_display_value(workspace_id, field.as_str(), &detail.remote_value)
240 .await
241 .map_err(Error::from_internal)?;
242 conflicts.push(Conflict {
243 task_id: task_id.clone(),
244 field: ConflictField::from_task_field(field),
245 local_value,
246 remote_value,
247 });
248 }
249 Ok(conflicts)
250 }
251
252 pub async fn resolve_conflict(
253 &self,
254 workspace_id: &WorkspaceId,
255 task_id: &TaskId,
256 field: ConflictField,
257 choice: ConflictChoice,
258 ) -> Result<TaskRecord, Error> {
259 let workspace = self.workspace(workspace_id).await?;
260 let field_name = field.as_str();
261 let choice = match choice {
262 ConflictChoice::Local => crate::operations::ConflictValueChoice::Local,
263 ConflictChoice::Remote => crate::operations::ConflictValueChoice::Remote,
264 };
265 let mut connection = self
266 .database
267 .acquire()
268 .await
269 .map_err(Error::from_internal)?;
270 crate::operations::resolve_conflict_choice(
271 &mut connection,
272 &workspace,
273 task_id,
274 field_name,
275 choice,
276 )
277 .await
278 .map(|outcome| TaskRecord::from(outcome.task))
279 .map_err(Error::from_internal)
280 }
281
282 async fn workspace(&self, workspace_id: &WorkspaceId) -> Result<Workspace, Error> {
283 self.database
284 .list_workspaces()
285 .await
286 .map_err(Error::from_internal)?
287 .into_iter()
288 .find(|workspace| workspace.id == *workspace_id)
289 .ok_or_else(|| {
290 Error::new(
291 ErrorCode::NotFound,
292 format!("workspace not found: {workspace_id}"),
293 )
294 })
295 }
296}
297
298fn validate_project(project: &str) -> Result<(), Error> {
299 if crate::projects::normalize_key(project).is_empty() {
300 return Err(Error::new(
301 ErrorCode::Validation,
302 "project must contain at least one letter or number".to_string(),
303 ));
304 }
305 Ok(())
306}
307
308fn validate_optional_date(field: &str, value: Option<&str>) -> Result<(), Error> {
309 let Some(value) = value else {
310 return Ok(());
311 };
312 if value.is_empty() {
313 return Err(Error::new(
314 ErrorCode::Validation,
315 format!("{field} must be absent or contain a date"),
316 ));
317 }
318 let result = match field {
319 "available_at" => crate::time_validation::validate_available_at_value(value),
320 "due_on" => crate::time_validation::validate_due_on_value(value),
321 _ => unreachable!("consumer API validates only task dates"),
322 };
323 result.map_err(|error| Error::new(ErrorCode::Validation, error.to_string()))
324}
325
326fn validate_date_update(field: &str, update: &OptionalDateUpdate) -> Result<(), Error> {
327 match update {
328 OptionalDateUpdate::Unchanged | OptionalDateUpdate::Clear => Ok(()),
329 OptionalDateUpdate::Set(value) => validate_optional_date(field, Some(value)),
330 }
331}
332
333#[derive(Debug, Clone, PartialEq, Eq)]
334pub struct StorageLayout {
335 pub root: PathBuf,
336 pub objects: PathBuf,
337 pub staging: PathBuf,
338 pub trash: PathBuf,
339 pub previews: PathBuf,
340}
341
342#[derive(Debug, Clone, PartialEq, Eq)]
343pub struct WorkspaceRecord {
344 pub id: WorkspaceId,
345 pub key: String,
346 pub name: String,
347}
348
349impl From<Workspace> for WorkspaceRecord {
350 fn from(workspace: Workspace) -> Self {
351 Self {
352 id: workspace.id,
353 key: workspace.key,
354 name: workspace.name,
355 }
356 }
357}
358
359#[derive(Debug, Clone, PartialEq, Eq)]
360pub struct CreateTask {
361 pub title: String,
362 pub description: String,
363 pub project: String,
364 pub status: TaskStatus,
365 pub priority: TaskPriority,
366 pub available_at: Option<String>,
367 pub due_on: Option<String>,
368}
369
370#[derive(Debug, Clone, PartialEq, Eq, Default)]
371pub struct UpdateTask {
372 pub title: Option<String>,
373 pub description: Option<String>,
374 pub project: Option<String>,
375 pub status: Option<TaskStatus>,
376 pub priority: Option<TaskPriority>,
377 pub available_at: OptionalDateUpdate,
378 pub due_on: OptionalDateUpdate,
379}
380
381#[derive(Debug, Clone, PartialEq, Eq, Default)]
382pub enum OptionalDateUpdate {
383 #[default]
384 Unchanged,
385 Set(String),
386 Clear,
387}
388
389impl OptionalDateUpdate {
390 fn into_internal(self) -> Option<Option<String>> {
391 match self {
392 Self::Unchanged => None,
393 Self::Set(value) => Some(Some(value)),
394 Self::Clear => Some(None),
395 }
396 }
397}
398
399#[derive(Debug, Clone, PartialEq, Eq)]
400pub struct TaskRecord {
401 pub id: TaskId,
402 pub workspace_id: WorkspaceId,
403 pub title: String,
404 pub description: String,
405 pub project_id: ProjectId,
406 pub project_key: String,
407 pub project_prefix: String,
408 pub status: TaskStatus,
409 pub priority: TaskPriority,
410 pub created_at: String,
411 pub updated_at: String,
412 pub available_at: Option<String>,
413 pub due_on: Option<String>,
414}
415
416impl From<Task> for TaskRecord {
417 fn from(task: Task) -> Self {
418 Self {
419 id: task.id,
420 workspace_id: task.workspace_id,
421 title: task.title,
422 description: task.description,
423 project_id: task.project_id,
424 project_key: task.project_key,
425 project_prefix: task.project_prefix,
426 status: task.status,
427 priority: task.priority,
428 created_at: task.created_at,
429 updated_at: task.updated_at,
430 available_at: task.available_at,
431 due_on: task.due_on,
432 }
433 }
434}
435
436#[derive(Debug, Clone, PartialEq, Eq)]
437pub struct TaskUpdateResult {
438 pub task: TaskRecord,
439 pub changed: bool,
440}
441
442#[derive(Debug, Clone, Copy, PartialEq, Eq)]
443pub enum ConflictField {
444 Title,
445 Description,
446 Project,
447 Status,
448 Priority,
449 AvailableAt,
450 DueOn,
451 Deleted,
452 IsEpic,
453}
454
455impl ConflictField {
456 pub const fn as_str(self) -> &'static str {
457 match self {
458 Self::Title => "title",
459 Self::Description => "description",
460 Self::Project => "project",
461 Self::Status => "status",
462 Self::Priority => "priority",
463 Self::AvailableAt => "available_at",
464 Self::DueOn => "due_on",
465 Self::Deleted => "deleted",
466 Self::IsEpic => "is_epic",
467 }
468 }
469
470 fn from_task_field(field: TaskField) -> Self {
471 match field {
472 TaskField::Title => Self::Title,
473 TaskField::Description => Self::Description,
474 TaskField::Project => Self::Project,
475 TaskField::Status => Self::Status,
476 TaskField::Priority => Self::Priority,
477 TaskField::AvailableAt => Self::AvailableAt,
478 TaskField::DueOn => Self::DueOn,
479 TaskField::Deleted => Self::Deleted,
480 TaskField::IsEpic => Self::IsEpic,
481 }
482 }
483}
484
485#[derive(Debug, Clone, PartialEq, Eq)]
486pub struct ConflictSummary {
487 pub task_id: TaskId,
488 pub task_title: String,
489 pub project_key: String,
490 pub project_prefix: String,
491 pub field: ConflictField,
492}
493
494#[derive(Debug, Clone, PartialEq, Eq)]
495pub struct Conflict {
496 pub task_id: TaskId,
497 pub field: ConflictField,
498 pub local_value: String,
499 pub remote_value: String,
500}
501
502#[derive(Debug, Clone, Copy, PartialEq, Eq)]
503pub enum ConflictChoice {
504 Local,
505 Remote,
506}
507
508#[derive(Debug, Clone, Copy, PartialEq, Eq)]
509pub enum ErrorCode {
510 Validation,
511 NotFound,
512 OpenConflict,
513 Database,
514 Internal,
515}
516
517impl ErrorCode {
518 pub const fn as_str(self) -> &'static str {
519 match self {
520 Self::Validation => "validation",
521 Self::NotFound => "not_found",
522 Self::OpenConflict => "open_conflict",
523 Self::Database => "database",
524 Self::Internal => "internal",
525 }
526 }
527}
528
529#[derive(Debug, Clone, PartialEq, Eq)]
530pub struct Error {
531 pub code: ErrorCode,
532 pub message: String,
533}
534
535impl Error {
536 fn new(code: ErrorCode, message: String) -> Self {
537 Self { code, message }
538 }
539
540 fn database_open(error: InternalError) -> Self {
541 Self::new(ErrorCode::Database, error.to_string())
542 }
543
544 fn from_internal(error: InternalError) -> Self {
545 let code = if error.chain().any(|cause| {
546 cause
547 .downcast_ref::<crate::mutation::OpenConflictError>()
548 .is_some()
549 }) {
550 ErrorCode::OpenConflict
551 } else if error.chain().any(|cause| {
552 cause
553 .downcast_ref::<crate::operations::ConflictNotFoundError>()
554 .is_some()
555 }) || error
556 .chain()
557 .filter_map(|cause| cause.downcast_ref::<sqlx::Error>())
558 .any(|error| matches!(error, sqlx::Error::RowNotFound))
559 {
560 ErrorCode::NotFound
561 } else if error
562 .chain()
563 .any(|cause| cause.downcast_ref::<sqlx::Error>().is_some())
564 {
565 ErrorCode::Database
566 } else {
567 ErrorCode::Internal
568 };
569 Self::new(code, error.to_string())
570 }
571}
572
573impl fmt::Display for Error {
574 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
575 write!(formatter, "{}: {}", self.code.as_str(), self.message)
576 }
577}
578
579impl std::error::Error for Error {}
580
581#[cfg(test)]
582mod tests {
583 use super::*;
584
585 #[test]
586 fn error_codes_are_stable_and_internal_errors_are_distinct() {
587 assert_eq!(ErrorCode::Validation.as_str(), "validation");
588 assert_eq!(ErrorCode::NotFound.as_str(), "not_found");
589 assert_eq!(ErrorCode::OpenConflict.as_str(), "open_conflict");
590 assert_eq!(ErrorCode::Database.as_str(), "database");
591 assert_eq!(ErrorCode::Internal.as_str(), "internal");
592 assert_eq!(
593 Error::from_internal(anyhow::anyhow!("unexpected invariant")).code,
594 ErrorCode::Internal
595 );
596 assert_eq!(
597 Error::from_internal(sqlx::Error::PoolClosed.into()).code,
598 ErrorCode::Database
599 );
600 }
601}