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::{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    /// Save a batch of items efficiently on the file backend while preserving regular per-item
35    /// repository semantics for the other backends.
36    pub(crate) async fn save_item_batch(&self, items: &[BacklogItem]) -> Result<()> {
37        match self {
38            Backend::File(repository) => repository.save_batch(items).await,
39            Backend::Git(repository) => {
40                for item in items {
41                    BacklogItemRepository::save(repository, item).await?;
42                }
43                Ok(())
44            }
45            #[cfg(feature = "sqlite")]
46            Backend::Sqlite(repository) => {
47                for item in items {
48                    BacklogItemRepository::save(repository, item).await?;
49                }
50                Ok(())
51            }
52        }
53    }
54
55    /// Build from the board root (`.pinto/`) and the selected backend type.
56    ///
57    /// No I/O is performed during construction; Git repository preparation is delayed until the
58    /// first commit.
59    pub async fn open(root: impl Into<PathBuf>, backend: StorageBackend) -> Result<Self> {
60        let root = root.into();
61        match backend {
62            StorageBackend::File => Ok(Backend::File(FileRepository::new(root))),
63            StorageBackend::Git => Ok(Backend::Git(GitRepository::new(root))),
64            #[cfg(feature = "sqlite")]
65            StorageBackend::Sqlite => Ok(Backend::Sqlite(SqliteRepository::new(root))),
66        }
67    }
68
69    /// Build a backend for a write operation and snapshot pre-existing Git changes.
70    pub(crate) async fn open_for_write(
71        root: impl Into<PathBuf>,
72        backend: StorageBackend,
73    ) -> Result<Self> {
74        let root = root.into();
75        match backend {
76            StorageBackend::File => Ok(Backend::File(FileRepository::new(root))),
77            StorageBackend::Git => Ok(Backend::Git(GitRepository::new(root).prepare().await?)),
78            #[cfg(feature = "sqlite")]
79            StorageBackend::Sqlite => Ok(Backend::Sqlite(SqliteRepository::new(root))),
80        }
81    }
82
83    /// Commit board-level files for a Git-backed mutation. Other backends already persist their
84    /// changes transactionally and therefore have nothing to do here.
85    pub(crate) async fn commit(&self, message: &str) -> Result<()> {
86        match self {
87            Backend::File(_) => Ok(()),
88            Backend::Git(repository) => repository.commit(message).await,
89            #[cfg(feature = "sqlite")]
90            Backend::Sqlite(_) => Ok(()),
91        }
92    }
93
94    /// Undo the most recent completed board mutation and return its subject.
95    ///
96    /// Only the Git backend records history, so it delegates to [`GitRepository::undo_last`]. The
97    /// historyless backends fail with [`Error::UndoUnsupported`], which names the backend and the
98    /// recovery options.
99    pub(crate) async fn undo(&self) -> Result<String> {
100        match self {
101            Backend::File(_) => Err(Error::UndoUnsupported {
102                backend: "file".to_string(),
103            }),
104            Backend::Git(repository) => repository.undo_last().await,
105            #[cfg(feature = "sqlite")]
106            Backend::Sqlite(_) => Err(Error::UndoUnsupported {
107                backend: "sqlite".to_string(),
108            }),
109        }
110    }
111}
112
113impl BacklogItemRepository for Backend {
114    async fn save(&self, item: &BacklogItem) -> Result<()> {
115        match self {
116            Backend::File(r) => BacklogItemRepository::save(r, item).await,
117            Backend::Git(r) => BacklogItemRepository::save(r, item).await,
118            #[cfg(feature = "sqlite")]
119            Backend::Sqlite(r) => BacklogItemRepository::save(r, item).await,
120        }
121    }
122
123    async fn load(&self, id: &ItemId) -> Result<BacklogItem> {
124        match self {
125            Backend::File(r) => BacklogItemRepository::load(r, id).await,
126            Backend::Git(r) => BacklogItemRepository::load(r, id).await,
127            #[cfg(feature = "sqlite")]
128            Backend::Sqlite(r) => BacklogItemRepository::load(r, id).await,
129        }
130    }
131
132    async fn list(&self) -> Result<Vec<BacklogItem>> {
133        match self {
134            Backend::File(r) => BacklogItemRepository::list(r).await,
135            Backend::Git(r) => BacklogItemRepository::list(r).await,
136            #[cfg(feature = "sqlite")]
137            Backend::Sqlite(r) => BacklogItemRepository::list(r).await,
138        }
139    }
140
141    async fn list_archived(&self) -> Result<Vec<BacklogItem>> {
142        match self {
143            Backend::File(r) => BacklogItemRepository::list_archived(r).await,
144            Backend::Git(r) => BacklogItemRepository::list_archived(r).await,
145            #[cfg(feature = "sqlite")]
146            Backend::Sqlite(r) => BacklogItemRepository::list_archived(r).await,
147        }
148    }
149
150    async fn load_archived(&self, id: &ItemId) -> Result<BacklogItem> {
151        match self {
152            Backend::File(r) => BacklogItemRepository::load_archived(r, id).await,
153            Backend::Git(r) => BacklogItemRepository::load_archived(r, id).await,
154            #[cfg(feature = "sqlite")]
155            Backend::Sqlite(r) => BacklogItemRepository::load_archived(r, id).await,
156        }
157    }
158
159    async fn delete(&self, id: &ItemId) -> Result<()> {
160        match self {
161            Backend::File(r) => BacklogItemRepository::delete(r, id).await,
162            Backend::Git(r) => BacklogItemRepository::delete(r, id).await,
163            #[cfg(feature = "sqlite")]
164            Backend::Sqlite(r) => BacklogItemRepository::delete(r, id).await,
165        }
166    }
167
168    async fn archive(&self, id: &ItemId) -> Result<PathBuf> {
169        match self {
170            Backend::File(r) => r.archive(id).await,
171            Backend::Git(r) => r.archive(id).await,
172            #[cfg(feature = "sqlite")]
173            Backend::Sqlite(r) => r.archive(id).await,
174        }
175    }
176
177    async fn restore(&self, id: &ItemId) -> Result<()> {
178        match self {
179            Backend::File(r) => BacklogItemRepository::restore(r, id).await,
180            Backend::Git(r) => BacklogItemRepository::restore(r, id).await,
181            #[cfg(feature = "sqlite")]
182            Backend::Sqlite(r) => BacklogItemRepository::restore(r, id).await,
183        }
184    }
185
186    async fn next_id(&self, prefix: &str) -> Result<ItemId> {
187        match self {
188            Backend::File(r) => r.next_id(prefix).await,
189            Backend::Git(r) => r.next_id(prefix).await,
190            #[cfg(feature = "sqlite")]
191            Backend::Sqlite(r) => r.next_id(prefix).await,
192        }
193    }
194}
195
196impl SprintRepository for Backend {
197    async fn save(&self, sprint: &Sprint) -> Result<()> {
198        match self {
199            Backend::File(r) => SprintRepository::save(r, sprint).await,
200            Backend::Git(r) => SprintRepository::save(r, sprint).await,
201            #[cfg(feature = "sqlite")]
202            Backend::Sqlite(r) => SprintRepository::save(r, sprint).await,
203        }
204    }
205
206    async fn load(&self, id: &SprintId) -> Result<Sprint> {
207        match self {
208            Backend::File(r) => SprintRepository::load(r, id).await,
209            Backend::Git(r) => SprintRepository::load(r, id).await,
210            #[cfg(feature = "sqlite")]
211            Backend::Sqlite(r) => SprintRepository::load(r, id).await,
212        }
213    }
214
215    async fn list(&self) -> Result<Vec<Sprint>> {
216        match self {
217            Backend::File(r) => SprintRepository::list(r).await,
218            Backend::Git(r) => SprintRepository::list(r).await,
219            #[cfg(feature = "sqlite")]
220            Backend::Sqlite(r) => SprintRepository::list(r).await,
221        }
222    }
223
224    async fn delete(&self, id: &SprintId) -> Result<()> {
225        match self {
226            Backend::File(r) => SprintRepository::delete(r, id).await,
227            Backend::Git(r) => SprintRepository::delete(r, id).await,
228            #[cfg(feature = "sqlite")]
229            Backend::Sqlite(r) => SprintRepository::delete(r, id).await,
230        }
231    }
232}
233
234#[cfg(test)]
235mod tests {
236    use super::*;
237    use crate::backlog::Status;
238    use crate::rank::Rank;
239    use chrono::{TimeZone, Utc};
240    use tempfile::TempDir;
241
242    /// You can build a file backend and save/retrieve back and forth across traits.
243    #[tokio::test]
244    async fn file_backend_dispatches_save_and_load() {
245        let dir = TempDir::new().expect("temp dir");
246        let backend = Backend::open(dir.path().join(".pinto"), StorageBackend::File)
247            .await
248            .expect("open file backend");
249
250        let item = BacklogItem::new(
251            ItemId::new("T", 1),
252            "Dispatch",
253            Status::new("todo"),
254            Rank::after(None),
255            Utc.timestamp_opt(1_000, 0).single().unwrap(),
256        )
257        .expect("valid item");
258
259        BacklogItemRepository::save(&backend, &item)
260            .await
261            .expect("save via backend");
262        let loaded = BacklogItemRepository::load(&backend, &item.id)
263            .await
264            .expect("load via backend");
265        assert_eq!(loaded, item);
266    }
267
268    /// You can build a sqlite backend, and you can save and retrieve back and forth over traits.
269    #[cfg(feature = "sqlite")]
270    #[tokio::test]
271    async fn sqlite_backend_dispatches_save_and_load() {
272        let dir = TempDir::new().expect("temp dir");
273        let backend = Backend::open(dir.path().join(".pinto"), StorageBackend::Sqlite)
274            .await
275            .expect("open sqlite backend");
276
277        let item = BacklogItem::new(
278            ItemId::new("T", 1),
279            "Dispatch",
280            Status::new("todo"),
281            Rank::after(None),
282            Utc.timestamp_opt(1_000, 0).single().unwrap(),
283        )
284        .expect("valid item");
285
286        BacklogItemRepository::save(&backend, &item)
287            .await
288            .expect("save via backend");
289        let loaded = BacklogItemRepository::load(&backend, &item.id)
290            .await
291            .expect("load via backend");
292        assert_eq!(loaded, item);
293    }
294
295    #[tokio::test]
296    async fn save_item_batch_dispatches_to_git_and_sqlite_backends() {
297        let item = BacklogItem::new(
298            ItemId::new("T", 1),
299            "Batch",
300            Status::new("todo"),
301            Rank::after(None),
302            Utc.timestamp_opt(1_000, 0).single().unwrap(),
303        )
304        .expect("valid item");
305
306        let git_dir = TempDir::new().expect("git temp dir");
307        let git = Backend::Git(GitRepository::new(git_dir.path().join(".pinto")));
308        git.save_item_batch(std::slice::from_ref(&item))
309            .await
310            .expect("git batch save");
311        assert_eq!(
312            BacklogItemRepository::list(&git).await.unwrap(),
313            vec![item.clone()]
314        );
315
316        #[cfg(feature = "sqlite")]
317        {
318            let sqlite_dir = TempDir::new().expect("sqlite temp dir");
319            let sqlite = Backend::Sqlite(SqliteRepository::new(sqlite_dir.path().join(".pinto")));
320            sqlite
321                .save_item_batch(std::slice::from_ref(&item))
322                .await
323                .expect("sqlite batch save");
324            assert_eq!(
325                BacklogItemRepository::list(&sqlite).await.unwrap(),
326                vec![item]
327            );
328        }
329    }
330
331    #[tokio::test]
332    async fn historyless_backends_report_undo_unsupported() {
333        let file_dir = TempDir::new().expect("file temp dir");
334        assert!(matches!(
335            Backend::File(FileRepository::new(file_dir.path().join(".pinto")))
336                .undo()
337                .await,
338            Err(Error::UndoUnsupported { backend }) if backend == "file"
339        ));
340
341        #[cfg(feature = "sqlite")]
342        {
343            let sqlite_dir = TempDir::new().expect("sqlite temp dir");
344            assert!(matches!(
345                Backend::Sqlite(SqliteRepository::new(sqlite_dir.path().join(".pinto")))
346                    .undo()
347                    .await,
348                Err(Error::UndoUnsupported { backend }) if backend == "sqlite"
349            ));
350        }
351    }
352}