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