Skip to main content

aven_core/
api.rs

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}