Skip to main content

pinto/storage/
backend.rs

1//! Persistence backend selected in configuration.
2//!
3//! Build the concrete implementation selected by [`crate::config::StorageBackend`] and dispatch
4//! [`BacklogItemRepository`] and [`SprintRepository`] calls through one enum. The service layer
5//! therefore remains independent of the concrete backend.
6//!
7//! The traits use RPITIT (`impl Future`) and cannot be used as `dyn` traits here, so enum dispatch
8//! keeps resolution static and lightweight.
9
10use super::file_repository::FileRepository;
11use super::git_repository::GitRepository;
12use super::repository::{BacklogItemRepository, SprintRepository};
13#[cfg(feature = "sqlite")]
14use super::sqlite_repository::SqliteRepository;
15use crate::backlog::{BacklogItem, ItemId};
16use crate::config::StorageBackend;
17use crate::error::Result;
18use crate::sprint::{Sprint, SprintId};
19use std::path::PathBuf;
20
21/// Persistence backend selected in configuration.
22#[derive(Debug, Clone)]
23pub enum Backend {
24    /// Local file backend (the default).
25    File(FileRepository),
26    /// Git backend, which commits every change operation.
27    Git(GitRepository),
28    /// SQLite backend (optional feature `sqlite`), stored in one database file.
29    #[cfg(feature = "sqlite")]
30    Sqlite(SqliteRepository),
31}
32
33impl Backend {
34    /// Build from the board root (`.pinto/`) and the selected backend type.
35    ///
36    /// No I/O is performed during construction; Git repository preparation is delayed until the
37    /// first commit.
38    pub async fn open(root: impl Into<PathBuf>, backend: StorageBackend) -> Result<Self> {
39        let root = root.into();
40        match backend {
41            StorageBackend::File => Ok(Backend::File(FileRepository::new(root))),
42            StorageBackend::Git => Ok(Backend::Git(GitRepository::new(root))),
43            #[cfg(feature = "sqlite")]
44            StorageBackend::Sqlite => Ok(Backend::Sqlite(SqliteRepository::new(root))),
45        }
46    }
47
48    /// Build a backend for a write operation and snapshot pre-existing Git changes.
49    pub(crate) async fn open_for_write(
50        root: impl Into<PathBuf>,
51        backend: StorageBackend,
52    ) -> Result<Self> {
53        let root = root.into();
54        match backend {
55            StorageBackend::File => Ok(Backend::File(FileRepository::new(root))),
56            StorageBackend::Git => Ok(Backend::Git(GitRepository::new(root).prepare().await?)),
57            #[cfg(feature = "sqlite")]
58            StorageBackend::Sqlite => Ok(Backend::Sqlite(SqliteRepository::new(root))),
59        }
60    }
61
62    /// Commit board-level files for a Git-backed mutation. Other backends already persist their
63    /// changes transactionally and therefore have nothing to do here.
64    pub(crate) async fn commit(&self, message: &str) -> Result<()> {
65        match self {
66            Backend::File(_) => Ok(()),
67            Backend::Git(repository) => repository.commit(message).await,
68            #[cfg(feature = "sqlite")]
69            Backend::Sqlite(_) => Ok(()),
70        }
71    }
72}
73
74impl BacklogItemRepository for Backend {
75    async fn save(&self, item: &BacklogItem) -> Result<()> {
76        match self {
77            Backend::File(r) => BacklogItemRepository::save(r, item).await,
78            Backend::Git(r) => BacklogItemRepository::save(r, item).await,
79            #[cfg(feature = "sqlite")]
80            Backend::Sqlite(r) => BacklogItemRepository::save(r, item).await,
81        }
82    }
83
84    async fn load(&self, id: &ItemId) -> Result<BacklogItem> {
85        match self {
86            Backend::File(r) => BacklogItemRepository::load(r, id).await,
87            Backend::Git(r) => BacklogItemRepository::load(r, id).await,
88            #[cfg(feature = "sqlite")]
89            Backend::Sqlite(r) => BacklogItemRepository::load(r, id).await,
90        }
91    }
92
93    async fn list(&self) -> Result<Vec<BacklogItem>> {
94        match self {
95            Backend::File(r) => BacklogItemRepository::list(r).await,
96            Backend::Git(r) => BacklogItemRepository::list(r).await,
97            #[cfg(feature = "sqlite")]
98            Backend::Sqlite(r) => BacklogItemRepository::list(r).await,
99        }
100    }
101
102    async fn delete(&self, id: &ItemId) -> Result<()> {
103        match self {
104            Backend::File(r) => BacklogItemRepository::delete(r, id).await,
105            Backend::Git(r) => BacklogItemRepository::delete(r, id).await,
106            #[cfg(feature = "sqlite")]
107            Backend::Sqlite(r) => BacklogItemRepository::delete(r, id).await,
108        }
109    }
110
111    async fn archive(&self, id: &ItemId) -> Result<PathBuf> {
112        match self {
113            Backend::File(r) => r.archive(id).await,
114            Backend::Git(r) => r.archive(id).await,
115            #[cfg(feature = "sqlite")]
116            Backend::Sqlite(r) => r.archive(id).await,
117        }
118    }
119
120    async fn next_id(&self, prefix: &str) -> Result<ItemId> {
121        match self {
122            Backend::File(r) => r.next_id(prefix).await,
123            Backend::Git(r) => r.next_id(prefix).await,
124            #[cfg(feature = "sqlite")]
125            Backend::Sqlite(r) => r.next_id(prefix).await,
126        }
127    }
128}
129
130impl SprintRepository for Backend {
131    async fn save(&self, sprint: &Sprint) -> Result<()> {
132        match self {
133            Backend::File(r) => SprintRepository::save(r, sprint).await,
134            Backend::Git(r) => SprintRepository::save(r, sprint).await,
135            #[cfg(feature = "sqlite")]
136            Backend::Sqlite(r) => SprintRepository::save(r, sprint).await,
137        }
138    }
139
140    async fn load(&self, id: &SprintId) -> Result<Sprint> {
141        match self {
142            Backend::File(r) => SprintRepository::load(r, id).await,
143            Backend::Git(r) => SprintRepository::load(r, id).await,
144            #[cfg(feature = "sqlite")]
145            Backend::Sqlite(r) => SprintRepository::load(r, id).await,
146        }
147    }
148
149    async fn list(&self) -> Result<Vec<Sprint>> {
150        match self {
151            Backend::File(r) => SprintRepository::list(r).await,
152            Backend::Git(r) => SprintRepository::list(r).await,
153            #[cfg(feature = "sqlite")]
154            Backend::Sqlite(r) => SprintRepository::list(r).await,
155        }
156    }
157
158    async fn delete(&self, id: &SprintId) -> Result<()> {
159        match self {
160            Backend::File(r) => SprintRepository::delete(r, id).await,
161            Backend::Git(r) => SprintRepository::delete(r, id).await,
162            #[cfg(feature = "sqlite")]
163            Backend::Sqlite(r) => SprintRepository::delete(r, id).await,
164        }
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use crate::backlog::Status;
172    use crate::rank::Rank;
173    use chrono::{TimeZone, Utc};
174    use tempfile::TempDir;
175
176    /// You can build a file backend and save/retrieve back and forth across traits.
177    #[tokio::test]
178    async fn file_backend_dispatches_save_and_load() {
179        let dir = TempDir::new().expect("temp dir");
180        let backend = Backend::open(dir.path().join(".pinto"), StorageBackend::File)
181            .await
182            .expect("open file backend");
183
184        let item = BacklogItem::new(
185            ItemId::new("T", 1),
186            "Dispatch",
187            Status::new("todo"),
188            Rank::after(None),
189            Utc.timestamp_opt(1_000, 0).single().unwrap(),
190        )
191        .expect("valid item");
192
193        BacklogItemRepository::save(&backend, &item)
194            .await
195            .expect("save via backend");
196        let loaded = BacklogItemRepository::load(&backend, &item.id)
197            .await
198            .expect("load via backend");
199        assert_eq!(loaded, item);
200    }
201
202    /// You can build a sqlite backend, and you can save and retrieve back and forth over traits.
203    #[cfg(feature = "sqlite")]
204    #[tokio::test]
205    async fn sqlite_backend_dispatches_save_and_load() {
206        let dir = TempDir::new().expect("temp dir");
207        let backend = Backend::open(dir.path().join(".pinto"), StorageBackend::Sqlite)
208            .await
209            .expect("open sqlite backend");
210
211        let item = BacklogItem::new(
212            ItemId::new("T", 1),
213            "Dispatch",
214            Status::new("todo"),
215            Rank::after(None),
216            Utc.timestamp_opt(1_000, 0).single().unwrap(),
217        )
218        .expect("valid item");
219
220        BacklogItemRepository::save(&backend, &item)
221            .await
222            .expect("save via backend");
223        let loaded = BacklogItemRepository::load(&backend, &item.id)
224            .await
225            .expect("load via backend");
226        assert_eq!(loaded, item);
227    }
228}