Skip to main content

backend/
lib.rs

1use chrono::{DateTime, Utc};
2
3use serde::{Deserialize, Serialize};
4
5#[cfg(feature = "json")]
6mod json;
7#[cfg(feature = "json")]
8pub use json::JsonDataProvide;
9
10#[cfg(feature = "sqlite")]
11mod sqlite;
12#[cfg(feature = "sqlite")]
13pub use sqlite::SqliteDataProvide;
14
15pub const TRANSFER_DATA_VERSION: u16 = 100;
16
17#[derive(Debug, thiserror::Error)]
18pub enum ModifyEntryError {
19    #[error("{0}")]
20    ValidationError(String),
21    #[error("{0}")]
22    DataError(#[from] anyhow::Error),
23}
24
25// The warning can be suppressed since this will be used with the code base of this app only
26#[allow(async_fn_in_trait)]
27pub trait DataProvider {
28    async fn load_all_entries(&self) -> anyhow::Result<Vec<Entry>>;
29    async fn add_entry(&self, entry: EntryDraft) -> Result<Entry, ModifyEntryError>;
30    /// Restores an entry with its existing id. Implementations must not overwrite another entry.
31    async fn restore_entry(&self, entry: Entry) -> Result<Entry, ModifyEntryError>;
32    async fn remove_entry(&self, entry_id: u32) -> anyhow::Result<()>;
33    async fn update_entry(&self, entry: Entry) -> Result<Entry, ModifyEntryError>;
34    async fn get_export_object(&self, entries_ids: &[u32]) -> anyhow::Result<EntriesDTO>;
35    async fn import_entries(&self, entries_dto: EntriesDTO) -> anyhow::Result<()> {
36        debug_assert_eq!(
37            TRANSFER_DATA_VERSION, entries_dto.version,
38            "Version mismatches check if there is a need to do a converting to the data"
39        );
40
41        for entry_draft in entries_dto.entries {
42            self.add_entry(entry_draft).await?;
43        }
44
45        Ok(())
46    }
47    /// Assigns priority to all entries that don't have a priority assigned to
48    async fn assign_priority_to_entries(&self, priority: u32) -> anyhow::Result<()>;
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52pub struct Entry {
53    pub id: u32,
54    pub date: DateTime<Utc>,
55    pub title: String,
56    pub content: String,
57    #[serde(default)]
58    pub tags: Vec<String>,
59    #[serde(default)]
60    pub priority: Option<u32>,
61}
62
63impl Entry {
64    #[allow(dead_code)]
65    pub fn new(
66        id: u32,
67        date: DateTime<Utc>,
68        title: String,
69        content: String,
70        tags: Vec<String>,
71        priority: Option<u32>,
72    ) -> Self {
73        Self {
74            id,
75            date,
76            title,
77            content,
78            tags,
79            priority,
80        }
81    }
82
83    pub fn from_draft(id: u32, draft: EntryDraft) -> Self {
84        Self {
85            id,
86            date: draft.date,
87            title: draft.title,
88            content: draft.content,
89            tags: draft.tags,
90            priority: draft.priority,
91        }
92    }
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
96pub struct EntryDraft {
97    pub date: DateTime<Utc>,
98    pub title: String,
99    pub content: String,
100    pub tags: Vec<String>,
101    pub priority: Option<u32>,
102}
103
104impl EntryDraft {
105    pub fn new(
106        date: DateTime<Utc>,
107        title: String,
108        tags: Vec<String>,
109        priority: Option<u32>,
110    ) -> Self {
111        let content = String::new();
112        Self {
113            date,
114            title,
115            content,
116            tags,
117            priority,
118        }
119    }
120
121    #[must_use]
122    pub fn with_content(mut self, content: String) -> Self {
123        self.content = content;
124        self
125    }
126
127    pub fn from_entry(entry: Entry) -> Self {
128        Self {
129            date: entry.date,
130            title: entry.title,
131            content: entry.content,
132            tags: entry.tags,
133            priority: entry.priority,
134        }
135    }
136}
137
138/// Entries data transfer object
139#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
140pub struct EntriesDTO {
141    pub version: u16,
142    pub entries: Vec<EntryDraft>,
143}
144
145impl EntriesDTO {
146    pub fn new(entries: Vec<EntryDraft>) -> Self {
147        Self {
148            version: TRANSFER_DATA_VERSION,
149            entries,
150        }
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use std::sync::Mutex;
157
158    use chrono::TimeZone;
159
160    use super::*;
161
162    fn sample_draft() -> EntryDraft {
163        EntryDraft {
164            date: Utc.with_ymd_and_hms(2024, 1, 2, 3, 4, 5).unwrap(),
165            title: String::from("Draft"),
166            content: String::from("Body"),
167            tags: vec![String::from("one"), String::from("two")],
168            priority: Some(3),
169        }
170    }
171
172    struct ImportStubProvider {
173        added_entries: Mutex<Vec<EntryDraft>>,
174        fail_on_call: Option<usize>,
175    }
176
177    impl ImportStubProvider {
178        fn new(fail_on_call: Option<usize>) -> Self {
179            Self {
180                added_entries: Mutex::new(Vec::new()),
181                fail_on_call,
182            }
183        }
184    }
185
186    impl DataProvider for ImportStubProvider {
187        async fn load_all_entries(&self) -> anyhow::Result<Vec<Entry>> {
188            unreachable!("not used in these tests");
189        }
190
191        async fn add_entry(&self, entry: EntryDraft) -> Result<Entry, ModifyEntryError> {
192            let mut added_entries = self.added_entries.lock().unwrap();
193            let call_idx = added_entries.len();
194            added_entries.push(entry.clone());
195
196            if self.fail_on_call == Some(call_idx) {
197                return Err(ModifyEntryError::ValidationError(format!(
198                    "fail on {call_idx}"
199                )));
200            }
201
202            Ok(Entry::from_draft(call_idx as u32, entry))
203        }
204
205        async fn restore_entry(&self, _entry: Entry) -> Result<Entry, ModifyEntryError> {
206            unreachable!("not used in these tests");
207        }
208
209        async fn remove_entry(&self, _entry_id: u32) -> anyhow::Result<()> {
210            unreachable!("not used in these tests");
211        }
212
213        async fn update_entry(&self, _entry: Entry) -> Result<Entry, ModifyEntryError> {
214            unreachable!("not used in these tests");
215        }
216
217        async fn get_export_object(&self, _entries_ids: &[u32]) -> anyhow::Result<EntriesDTO> {
218            unreachable!("not used in these tests");
219        }
220
221        async fn assign_priority_to_entries(&self, _priority: u32) -> anyhow::Result<()> {
222            unreachable!("not used in these tests");
223        }
224    }
225
226    #[test]
227    fn draft_to_entry() {
228        let draft = sample_draft();
229
230        let entry = Entry::from_draft(7, draft.clone());
231
232        assert_eq!(entry.id, 7);
233        assert_eq!(entry.date, draft.date);
234        assert_eq!(entry.title, draft.title);
235        assert_eq!(entry.content, draft.content);
236        assert_eq!(entry.tags, draft.tags);
237        assert_eq!(entry.priority, draft.priority);
238    }
239
240    #[test]
241    fn with_content_replaces_only_body() {
242        let draft = sample_draft();
243
244        let updated = draft.clone().with_content(String::from("Updated"));
245
246        assert_eq!(updated.content, "Updated");
247        assert_eq!(updated.date, draft.date);
248        assert_eq!(updated.title, draft.title);
249        assert_eq!(updated.tags, draft.tags);
250        assert_eq!(updated.priority, draft.priority);
251    }
252
253    #[test]
254    fn from_entry_drops_id_only() {
255        let entry = Entry::new(
256            11,
257            Utc.with_ymd_and_hms(2023, 11, 12, 13, 14, 15).unwrap(),
258            String::from("Title"),
259            String::from("Content"),
260            vec![String::from("tag")],
261            Some(2),
262        );
263
264        let draft = EntryDraft::from_entry(entry.clone());
265
266        assert_eq!(draft.date, entry.date);
267        assert_eq!(draft.title, entry.title);
268        assert_eq!(draft.content, entry.content);
269        assert_eq!(draft.tags, entry.tags);
270        assert_eq!(draft.priority, entry.priority);
271    }
272
273    #[test]
274    fn dto_sets_version() {
275        let dto = EntriesDTO::new(vec![sample_draft()]);
276
277        assert_eq!(dto.version, TRANSFER_DATA_VERSION);
278        assert_eq!(dto.entries, vec![sample_draft()]);
279    }
280
281    #[tokio::test]
282    async fn import_entries_keeps_order() {
283        let provider = ImportStubProvider::new(None);
284        let entries = vec![
285            sample_draft(),
286            EntryDraft::new(
287                Utc.with_ymd_and_hms(2025, 6, 7, 8, 9, 10).unwrap(),
288                String::from("Second"),
289                vec![String::from("x")],
290                None,
291            ),
292        ];
293
294        provider
295            .import_entries(EntriesDTO::new(entries.clone()))
296            .await
297            .unwrap();
298
299        let added_entries = provider.added_entries.lock().unwrap().clone();
300        assert_eq!(added_entries, entries);
301    }
302
303    #[tokio::test]
304    async fn import_entries_stops_on_error() {
305        let provider = ImportStubProvider::new(Some(1));
306        let entries = vec![
307            sample_draft(),
308            EntryDraft::new(
309                Utc.with_ymd_and_hms(2025, 1, 1, 0, 0, 0).unwrap(),
310                String::from("Second"),
311                vec![],
312                None,
313            ),
314            EntryDraft::new(
315                Utc.with_ymd_and_hms(2025, 1, 2, 0, 0, 0).unwrap(),
316                String::from("Third"),
317                vec![],
318                None,
319            ),
320        ];
321
322        let err = provider
323            .import_entries(EntriesDTO::new(entries.clone()))
324            .await
325            .unwrap_err();
326
327        assert_eq!(err.to_string(), "fail on 1");
328
329        // The stub records the draft before failing, so the third entry proves import stopped.
330        let added_entries = provider.added_entries.lock().unwrap().clone();
331        assert_eq!(added_entries, entries[..2].to_vec());
332    }
333}