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, 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}