Skip to main content

aimcal_core/db/
calendars.rs

1// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use jiff::Zoned;
6use sqlx::SqlitePool;
7
8#[derive(Debug, Clone)]
9pub struct Calendars {
10    pool: SqlitePool,
11}
12
13impl Calendars {
14    pub fn new(pool: SqlitePool) -> Self {
15        Self { pool }
16    }
17
18    pub async fn upsert(&self, calendar: CalendarRecord) -> Result<(), sqlx::Error> {
19        const SQL: &str = "\
20INSERT INTO calendars (id, name, kind, priority, enabled, created_at, updated_at)
21VALUES (?, ?, ?, ?, ?, ?, ?)
22ON CONFLICT(id) DO UPDATE SET
23    name = excluded.name,
24    kind = excluded.kind,
25    priority = excluded.priority,
26    enabled = excluded.enabled,
27    updated_at = excluded.updated_at;
28";
29
30        sqlx::query(SQL)
31            .bind(&calendar.id)
32            .bind(&calendar.name)
33            .bind(&calendar.kind)
34            .bind(calendar.priority)
35            .bind(calendar.enabled)
36            .bind(&calendar.created_at)
37            .bind(&calendar.updated_at)
38            .execute(&self.pool)
39            .await?;
40
41        Ok(())
42    }
43
44    pub async fn get(&self, id: &str) -> Result<Option<CalendarRecord>, sqlx::Error> {
45        const SQL: &str = "\
46SELECT id, name, kind, priority, enabled, created_at, updated_at
47FROM calendars
48WHERE id = ?;
49";
50
51        sqlx::query_as(SQL)
52            .bind(id)
53            .fetch_optional(&self.pool)
54            .await
55    }
56
57    pub async fn list(&self) -> Result<Vec<CalendarRecord>, sqlx::Error> {
58        const SQL: &str = "\
59SELECT id, name, kind, priority, enabled, created_at, updated_at
60FROM calendars
61ORDER BY priority ASC;
62";
63
64        sqlx::query_as(SQL).fetch_all(&self.pool).await
65    }
66
67    pub async fn list_enabled(&self) -> Result<Vec<CalendarRecord>, sqlx::Error> {
68        const SQL: &str = "\
69SELECT id, name, kind, priority, enabled, created_at, updated_at
70FROM calendars
71WHERE enabled = 1
72ORDER BY priority ASC;
73";
74
75        sqlx::query_as(SQL).fetch_all(&self.pool).await
76    }
77
78    pub async fn set_enabled(&self, id: &str, enabled: bool) -> Result<(), sqlx::Error> {
79        const SQL: &str = "\
80UPDATE calendars
81SET enabled = ?, updated_at = ?
82WHERE id = ?;
83";
84
85        let now = Zoned::now().strftime("%Y-%m-%dT%H:%M:%S%.f%:z").to_string();
86
87        sqlx::query(SQL)
88            .bind(enabled)
89            .bind(&now)
90            .bind(id)
91            .execute(&self.pool)
92            .await?;
93
94        Ok(())
95    }
96
97    pub async fn delete(&self, id: &str) -> Result<(), sqlx::Error> {
98        const SQL: &str = "DELETE FROM calendars WHERE id = ?;";
99
100        sqlx::query(SQL).bind(id).execute(&self.pool).await?;
101
102        Ok(())
103    }
104}
105
106/// Calendar record stored in database.
107#[derive(Debug, Clone, sqlx::FromRow)]
108pub struct CalendarRecord {
109    /// Unique calendar identifier.
110    pub id: String,
111    /// Display name.
112    pub name: String,
113    /// Backend kind.
114    pub kind: String,
115    /// Lower numbers sort first.
116    pub priority: i32,
117    /// Whether the calendar is enabled for queries and backend initialization.
118    pub enabled: bool,
119    /// Creation timestamp.
120    pub created_at: String,
121    /// Last update timestamp.
122    pub updated_at: String,
123}
124
125impl CalendarRecord {
126    /// Creates a new calendar record with the given parameters.
127    #[must_use]
128    pub fn new(id: String, name: String, kind: String, priority: i32, enabled: bool) -> Self {
129        let now = Zoned::now().strftime("%Y-%m-%dT%H:%M:%S%.f%:z").to_string();
130        Self {
131            id,
132            name,
133            kind,
134            priority,
135            enabled,
136            created_at: now.clone(),
137            updated_at: now,
138        }
139    }
140}
141
142#[cfg(test)]
143mod tests {
144    use super::*;
145
146    async fn setup_test_db() -> crate::db::Db {
147        crate::db::Db::open(None)
148            .await
149            .expect("Failed to create test database")
150    }
151
152    #[tokio::test]
153    async fn calendars_insert_inserts_new_calendar() {
154        let db = setup_test_db().await;
155
156        let calendar = CalendarRecord::new(
157            "test-calendar".to_string(),
158            "Test Calendar".to_string(),
159            "local".to_string(),
160            0,
161            true,
162        );
163
164        db.calendars
165            .upsert(calendar)
166            .await
167            .expect("Failed to insert calendar");
168
169        let retrieved = db
170            .calendars
171            .get("test-calendar")
172            .await
173            .expect("Failed to get calendar")
174            .expect("Calendar not found");
175
176        assert_eq!(retrieved.id, "test-calendar");
177        assert_eq!(retrieved.name, "Test Calendar");
178        assert_eq!(retrieved.kind, "local");
179        assert_eq!(retrieved.priority, 0);
180        assert!(retrieved.enabled);
181    }
182
183    #[tokio::test]
184    async fn calendars_upsert_updates_existing_calendar() {
185        let db = setup_test_db().await;
186
187        let calendar = CalendarRecord::new(
188            "test-calendar".to_string(),
189            "Original Name".to_string(),
190            "local".to_string(),
191            0,
192            true,
193        );
194        db.calendars.upsert(calendar).await.unwrap();
195
196        let updated = CalendarRecord::new(
197            "test-calendar".to_string(),
198            "Updated Name".to_string(),
199            "caldav".to_string(),
200            5,
201            false,
202        );
203        db.calendars.upsert(updated).await.unwrap();
204
205        let retrieved = db.calendars.get("test-calendar").await.unwrap().unwrap();
206        assert_eq!(retrieved.name, "Updated Name");
207        assert_eq!(retrieved.kind, "caldav");
208        assert_eq!(retrieved.priority, 5);
209        assert!(!retrieved.enabled);
210    }
211
212    #[tokio::test]
213    async fn calendars_list_returns_all_calendars() {
214        let db = setup_test_db().await;
215
216        // Default calendar exists from migration
217        let calendars = db.calendars.list().await.unwrap();
218        assert!(!calendars.is_empty());
219
220        // Add more calendars
221        for i in 1..=3 {
222            let calendar = CalendarRecord::new(
223                format!("calendar-{i}"),
224                format!("Calendar {i}"),
225                "local".to_string(),
226                i,
227                true,
228            );
229            db.calendars.upsert(calendar).await.unwrap();
230        }
231
232        let calendars = db.calendars.list().await.unwrap();
233        assert!(calendars.len() >= 4); // default + 3 new
234    }
235
236    #[tokio::test]
237    async fn calendars_list_enabled_returns_only_enabled() {
238        let db = setup_test_db().await;
239
240        // Add enabled and disabled calendars
241        let enabled = CalendarRecord::new(
242            "enabled-cal".to_string(),
243            "Enabled".to_string(),
244            "local".to_string(),
245            0,
246            true,
247        );
248        db.calendars.upsert(enabled).await.unwrap();
249
250        let disabled = CalendarRecord::new(
251            "disabled-cal".to_string(),
252            "Disabled".to_string(),
253            "local".to_string(),
254            1,
255            false,
256        );
257        db.calendars.upsert(disabled).await.unwrap();
258
259        let enabled_calendars = db.calendars.list_enabled().await.unwrap();
260        let ids: Vec<&str> = enabled_calendars.iter().map(|c| c.id.as_str()).collect();
261        assert!(ids.contains(&"enabled-cal"));
262        assert!(!ids.contains(&"disabled-cal"));
263    }
264
265    #[tokio::test]
266    async fn calendars_set_enabled_toggles_enabled_flag() {
267        let db = setup_test_db().await;
268
269        let calendar = CalendarRecord::new(
270            "test-cal".to_string(),
271            "Test".to_string(),
272            "local".to_string(),
273            0,
274            true,
275        );
276        db.calendars.upsert(calendar).await.unwrap();
277
278        db.calendars.set_enabled("test-cal", false).await.unwrap();
279
280        let retrieved = db.calendars.get("test-cal").await.unwrap().unwrap();
281        assert!(!retrieved.enabled);
282
283        db.calendars.set_enabled("test-cal", true).await.unwrap();
284
285        let retrieved = db.calendars.get("test-cal").await.unwrap().unwrap();
286        assert!(retrieved.enabled);
287    }
288
289    #[tokio::test]
290    async fn calendars_delete_removes_calendar() {
291        let db = setup_test_db().await;
292
293        let calendar = CalendarRecord::new(
294            "to-delete".to_string(),
295            "To Delete".to_string(),
296            "local".to_string(),
297            0,
298            true,
299        );
300        db.calendars.upsert(calendar).await.unwrap();
301
302        db.calendars.delete("to-delete").await.unwrap();
303
304        let retrieved = db.calendars.get("to-delete").await.unwrap();
305        assert!(retrieved.is_none());
306    }
307
308    #[tokio::test]
309    async fn calendars_list_orders_by_priority() {
310        let db = setup_test_db().await;
311
312        // Clear default calendar first for clean test
313        db.calendars.delete("default").await.ok();
314
315        // Add calendars with different priorities
316        for (priority, name) in [(2, "Second"), (0, "First"), (1, "Middle")] {
317            let calendar = CalendarRecord::new(
318                name.to_lowercase(),
319                name.to_string(),
320                "local".to_string(),
321                priority,
322                true,
323            );
324            db.calendars.upsert(calendar).await.unwrap();
325        }
326
327        let calendars = db.calendars.list().await.unwrap();
328        let names: Vec<&str> = calendars.iter().map(|c| c.name.as_str()).collect();
329        assert_eq!(names, vec!["First", "Middle", "Second"]);
330    }
331}