Skip to main content

aimcal_core/
aim.rs

1// SPDX-FileCopyrightText: 2025-2026 Zexin Yuan <aim@yzx9.xyz>
2//
3// SPDX-License-Identifier: Apache-2.0
4
5use std::collections::{HashMap, HashSet};
6use std::error::Error;
7use std::fmt;
8
9use jiff::Zoned;
10use tokio::fs;
11use uuid::Uuid;
12
13use crate::config::StoreDef;
14use crate::db::{Db, calendars::CalendarRecord};
15use crate::short_id::ShortIds;
16use crate::store::{CaldavStore, LocalStore, Store, SyncResult};
17use crate::{
18    Config, Event, EventConditions, EventDraft, EventPatch, Id, Kind, Pager, Todo, TodoConditions,
19    TodoDraft, TodoPatch, TodoSort,
20};
21
22/// Detailed information for a single calendar.
23#[derive(Debug, Clone, serde::Serialize)]
24pub struct CalendarDetails {
25    /// Unique calendar identifier.
26    pub id: String,
27    /// Display name.
28    pub name: String,
29    /// Store kind.
30    pub kind: String,
31    /// Lower numbers sort first.
32    pub priority: i32,
33    /// Whether the calendar is enabled.
34    pub enabled: bool,
35    /// Whether this calendar is used by default for new items.
36    pub is_default: bool,
37    /// Creation timestamp.
38    pub created_at: String,
39    /// Last update timestamp.
40    pub updated_at: String,
41    /// Store-specific configuration details, when available from config.
42    pub store: Option<CalendarStoreDetails>,
43}
44
45/// Store-specific details for a calendar.
46#[derive(Debug, Clone, serde::Serialize)]
47#[serde(tag = "kind", rename_all = "snake_case")]
48pub enum CalendarStoreDetails {
49    /// Local filesystem-backed calendar details.
50    Local {
51        /// Path to the local calendar directory, if configured.
52        calendar_path: Option<String>,
53    },
54    /// CalDAV-backed calendar details.
55    Caldav {
56        /// Base URL of the `CalDAV` server.
57        base_url: String,
58        /// Calendar home path on the server.
59        calendar_home: String,
60        /// Href of the calendar collection on the server.
61        calendar_href: String,
62        /// Authentication method kind.
63        auth_method: String,
64        /// Request timeout in seconds.
65        timeout_secs: u64,
66        /// User agent used for HTTP requests.
67        user_agent: String,
68    },
69}
70
71/// AIM calendar application core.
72pub struct Aim {
73    now: Zoned,
74    config: Config,
75    db: Db,
76    short_ids: ShortIds,
77    stores: HashMap<String, Box<dyn Store>>,
78    default_calendar: String,
79    startup_notices: Vec<String>,
80}
81
82struct InitializedStores {
83    stores: HashMap<String, Box<dyn Store>>,
84    default_calendar: String,
85    startup_notices: Vec<String>,
86}
87
88impl fmt::Debug for Aim {
89    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
90        f.debug_struct("Aim")
91            .field("now", &self.now)
92            .field("config", &self.config)
93            .field("db", &self.db)
94            .field("short_ids", &self.short_ids)
95            .field("stores", &self.stores.len())
96            .field("default_calendar", &self.default_calendar)
97            .field("startup_notices", &self.startup_notices)
98            .finish()
99    }
100}
101
102impl Aim {
103    fn calendar_store_details(
104        entry: &crate::CalendarEntry,
105        backend: &StoreDef,
106    ) -> CalendarStoreDetails {
107        match backend {
108            StoreDef::Local { .. } => CalendarStoreDetails::Local {
109                calendar_path: entry.calendar_path.clone(),
110            },
111            StoreDef::Caldav {
112                base_url,
113                calendar_home,
114                auth,
115                timeout_secs,
116                user_agent,
117            } => CalendarStoreDetails::Caldav {
118                base_url: base_url.clone(),
119                calendar_home: calendar_home.clone(),
120                calendar_href: entry.calendar_href.clone().unwrap_or_default(),
121                auth_method: match auth {
122                    crate::AuthMethod::None => "none".to_string(),
123                    crate::AuthMethod::Basic { .. } => "basic".to_string(),
124                    crate::AuthMethod::Bearer { .. } => "bearer".to_string(),
125                },
126                timeout_secs: *timeout_secs,
127                user_agent: user_agent.clone(),
128            },
129        }
130    }
131
132    /// Create a store from a store definition and calendar-specific fields.
133    fn create_store(
134        calendar_id: String,
135        entry: &crate::CalendarEntry,
136        store_def: &StoreDef,
137        db: &Db,
138        state_dir: Option<&std::path::Path>,
139    ) -> Result<Box<dyn Store>, Box<dyn Error>> {
140        match store_def {
141            StoreDef::Local { .. } => {
142                let calendar_path = entry.calendar_path.as_ref().map_or_else(
143                    || {
144                        state_dir.map_or_else(
145                            || std::path::PathBuf::from("calendar"),
146                            |p| p.join("calendar"),
147                        )
148                    },
149                    std::path::PathBuf::from,
150                );
151                Ok(Box::new(LocalStore::with_db(
152                    calendar_path,
153                    db.clone(),
154                    calendar_id,
155                )))
156            }
157            StoreDef::Caldav {
158                base_url,
159                calendar_home,
160                auth,
161                timeout_secs,
162                user_agent,
163            } => {
164                let calendar_href = entry.calendar_href.as_deref().ok_or_else(|| {
165                    format!(
166                        "Calendar '{calendar_id}' references caldav store but has no calendar_href"
167                    )
168                })?;
169                let caldav_config = aimcal_caldav::CalDavConfig {
170                    base_url: base_url.clone(),
171                    calendar_home: calendar_home.clone(),
172                    auth: auth.clone(),
173                    timeout_secs: *timeout_secs,
174                    user_agent: user_agent.clone(),
175                };
176                let backend = CaldavStore::new(
177                    caldav_config,
178                    calendar_href.to_string(),
179                    db.clone(),
180                    calendar_id,
181                )
182                .map_err(|e| format!("Failed to create CalDAV store: {e}"))?;
183                Ok(Box::new(backend))
184            }
185        }
186    }
187
188    /// Get a store by calendar ID.
189    fn get_store(&self, calendar_id: &str) -> Result<&dyn Store, Box<dyn Error>> {
190        self.stores
191            .get(calendar_id)
192            .map(Box::as_ref)
193            .ok_or_else(|| format!("Store not found for calendar: {calendar_id}").into())
194    }
195
196    /// Creates a new AIM instance with the given configuration.
197    ///
198    /// # Errors
199    /// If initialization fails.
200    pub async fn new(mut config: Config) -> Result<Self, Box<dyn Error>> {
201        let now = Zoned::now();
202
203        config.normalize()?;
204        prepare(&config).await?;
205
206        let db = initialize_db(&config).await?;
207        let short_ids = ShortIds::new(db.clone());
208
209        // Handle legacy vs multi-calendar format
210        let InitializedStores {
211            stores,
212            default_calendar,
213            startup_notices,
214        } = if config.is_legacy_format() {
215            Self::initialize_legacy_calendar(&config, &db).await?
216        } else {
217            Self::initialize_multi_calendars(&config, &db).await?
218        };
219
220        // Sync all stores with local cache
221        for (calendar_id, backend) in &stores {
222            backend.sync_cache().await.map_err(|e| {
223                format!("Failed to sync store cache for calendar '{calendar_id}': {e}")
224            })?;
225        }
226
227        Ok(Self {
228            now,
229            config,
230            db,
231            short_ids,
232            stores,
233            default_calendar,
234            startup_notices,
235        })
236    }
237
238    async fn initialize_legacy_calendar(
239        config: &Config,
240        db: &Db,
241    ) -> Result<InitializedStores, Box<dyn Error>> {
242        let default_calendar_id = "default".to_string();
243
244        // Legacy mode: create a local store using calendar_path or state_dir
245        let calendar_path = config
246            .calendar_path
247            .as_ref()
248            .map(|p| p.to_string_lossy().to_string());
249
250        let entry = crate::CalendarEntry {
251            id: default_calendar_id.clone(),
252            name: "Default".to_string(),
253            store: "local".to_string(),
254            calendar_href: None,
255            calendar_path,
256            priority: 0,
257            enabled: true,
258        };
259        let store_def = StoreDef::Local {
260            calendar_path: None,
261        };
262
263        let backend = Self::create_store(
264            default_calendar_id.clone(),
265            &entry,
266            &store_def,
267            db,
268            config.state_dir.as_deref(),
269        )?;
270
271        let calendar = CalendarRecord::new(
272            default_calendar_id.clone(),
273            "Default".to_string(),
274            "local".to_string(),
275            0,
276            true,
277        );
278        db.calendars.upsert(calendar).await?;
279
280        let mut stores = HashMap::new();
281        stores.insert(default_calendar_id.clone(), backend);
282
283        Ok(InitializedStores {
284            stores,
285            default_calendar: default_calendar_id,
286            startup_notices: Vec::new(),
287        })
288    }
289
290    async fn initialize_multi_calendars(
291        config: &Config,
292        db: &Db,
293    ) -> Result<InitializedStores, Box<dyn Error>> {
294        if config.calendars.is_empty() {
295            return Err("No calendars configured".into());
296        }
297
298        let existing = db.calendars.list().await?;
299        let configured_ids: HashSet<_> = config
300            .calendars
301            .iter()
302            .map(|calendar| &calendar.id)
303            .collect();
304        let mut auto_disabled = Vec::new();
305        for calendar in existing {
306            if configured_ids.contains(&calendar.id) || !calendar.enabled {
307                continue;
308            }
309
310            db.calendars.set_enabled(&calendar.id, false).await?;
311            auto_disabled.push(calendar.id);
312        }
313
314        let mut effective = Vec::with_capacity(config.calendars.len());
315        for calendar in &config.calendars {
316            let store_def = config.stores.get(&calendar.store).ok_or_else(|| {
317                format!(
318                    "Store '{}' not found for calendar '{}'",
319                    calendar.store, calendar.id
320                )
321            })?;
322            let calendar_kind = match store_def {
323                StoreDef::Local { .. } => "local",
324                StoreDef::Caldav { .. } => "caldav",
325            };
326            let record = CalendarRecord::new(
327                calendar.id.clone(),
328                calendar.name.clone(),
329                calendar_kind.to_string(),
330                calendar.priority,
331                calendar.enabled,
332            );
333            db.calendars.upsert(record).await?;
334            effective.push((calendar, calendar.enabled));
335        }
336
337        let mut stores = HashMap::new();
338        for (calendar, enabled) in &effective {
339            if !enabled {
340                continue;
341            }
342
343            let store_def = config
344                .stores
345                .get(&calendar.store)
346                .ok_or_else(|| format!("Store '{}' not found", calendar.store))?;
347
348            let backend = Self::create_store(
349                calendar.id.clone(),
350                calendar,
351                store_def,
352                db,
353                config.state_dir.as_deref(),
354            )?;
355            stores.insert(calendar.id.clone(), backend);
356        }
357
358        if stores.is_empty() {
359            return Err("No enabled calendars found in configuration".into());
360        }
361
362        let default_calendar = if stores.contains_key(&config.default_calendar) {
363            config.default_calendar.clone()
364        } else {
365            config
366                .calendars
367                .iter()
368                .filter(|calendar| stores.contains_key(&calendar.id))
369                .min_by_key(|calendar| calendar.priority)
370                .map(|calendar| calendar.id.clone())
371                .ok_or("No enabled calendars found in configuration")?
372        };
373
374        let startup_notices = if auto_disabled.is_empty() {
375            Vec::new()
376        } else {
377            vec![format!(
378                "Disabled calendar(s) not present in config: {}. Existing data was kept.",
379                auto_disabled.join(", ")
380            )]
381        };
382
383        Ok(InitializedStores {
384            stores,
385            default_calendar,
386            startup_notices,
387        })
388    }
389
390    /// The current time in the AIM instance.
391    #[must_use]
392    pub fn now(&self) -> Zoned {
393        self.now.clone()
394    }
395
396    /// Refresh the current time to now.
397    pub fn refresh_now(&mut self) {
398        self.now = Zoned::now();
399    }
400
401    /// Create a default event draft based on the AIM configuration.
402    #[must_use]
403    pub fn default_event_draft(&self) -> EventDraft {
404        EventDraft::default(&self.now)
405    }
406
407    /// Get a event by its id.
408    ///
409    /// # Errors
410    /// If the event is not found or database access fails.
411    pub async fn get_event(&self, id: &Id) -> Result<impl Event + 'static, Box<dyn Error>> {
412        let uid = self.short_ids.get_uid(id).await?;
413        match self.db.events.get(&uid).await {
414            Ok(Some(event)) => Ok(self.short_ids.event(event).await?),
415            Ok(None) => Err("Event not found".into()),
416            Err(e) => Err(e.into()),
417        }
418    }
419
420    /// Find the latest event matching the given summary.
421    ///
422    /// # Errors
423    /// If database access fails.
424    pub async fn find_latest_event_by_summary(
425        &self,
426        summary: &str,
427    ) -> Result<Option<impl Event + 'static>, Box<dyn Error>> {
428        let Some(event) = self.db.events.find_latest_by_summary(summary).await? else {
429            return Ok(None);
430        };
431        let event = self.short_ids.event(event).await?;
432        Ok(Some(event))
433    }
434
435    /// Add a new event from the given draft.
436    ///
437    /// # Errors
438    /// If the event is not found, database or backend access fails.
439    pub async fn new_event(
440        &self,
441        draft: EventDraft,
442    ) -> Result<impl Event + 'static, Box<dyn Error>> {
443        let uid = self.generate_uid(Kind::Event).await?;
444        let event = draft.resolve(&self.now).into_ics(&uid);
445
446        // Resolve calendar: use draft.calendar_id or fall back to default
447        let calendar_id = draft
448            .calendar_id
449            .as_deref()
450            .unwrap_or(&self.default_calendar);
451        let backend = self.get_store(calendar_id)?;
452
453        // Create event in store
454        let resource_id = backend
455            .create_event(&uid, &event)
456            .await
457            .map_err(|e| format!("Failed to create event in store: {e}"))?;
458
459        // Store in database with resource mapping
460        self.db.upsert_event(&uid, &event, calendar_id).await?;
461        self.db
462            .resources
463            .insert(&uid, calendar_id, &resource_id, None)
464            .await?;
465
466        let event = self.short_ids.event(event).await?;
467        Ok(event)
468    }
469
470    /// Upsert an event into the calendar.
471    ///
472    /// # Errors
473    /// If the event is not found, database or backend access fails.
474    pub async fn update_event(
475        &self,
476        id: &Id,
477        patch: EventPatch,
478    ) -> Result<impl Event + 'static, Box<dyn Error>> {
479        let uid = self.short_ids.get_uid(id).await?;
480        let Some(_event) = self.db.events.get(&uid).await? else {
481            return Err("Event not found".into());
482        };
483
484        // Get calendar_id from event record
485        let event_record = self.db.events.get(&uid).await?.ok_or("Event not found")?;
486        let backend = self.get_store(&event_record.calendar_id)?;
487        let calendar_id = backend.calendar_id();
488
489        // Update event through backend
490        let updated_event = backend
491            .update_event(&uid, &patch)
492            .await
493            .map_err(|e| format!("Failed to update event in store: {e}"))?;
494
495        // Update database
496        self.db
497            .upsert_event(&uid, &updated_event, calendar_id)
498            .await?;
499
500        let event_with_id = self.short_ids.event(updated_event).await?;
501        Ok(event_with_id)
502    }
503
504    /// Get the kind of the given id, which can be either an event or a todo.
505    ///
506    /// # Errors
507    /// If the id is not found or database access fails.
508    pub async fn get_kind(&self, id: &Id) -> Result<Kind, Box<dyn Error>> {
509        tracing::debug!(?id, "getting kind of id");
510        if let Some(data) = self.short_ids.get(id).await? {
511            return Ok(data.kind);
512        }
513
514        let uid = id.as_uid();
515
516        tracing::debug!(uid, "checking if id is an event");
517        if self.db.events.get(uid).await?.is_some() {
518            return Ok(Kind::Event);
519        }
520
521        tracing::debug!(uid, "checking if id is a todo");
522        if self.db.todos.get(uid).await?.is_some() {
523            return Ok(Kind::Todo);
524        }
525
526        Err("Id not found".into())
527    }
528
529    /// List events matching the given conditions.
530    ///
531    /// # Errors
532    /// If database access fails.
533    pub async fn list_events(
534        &self,
535        conds: &EventConditions,
536        pager: &Pager,
537    ) -> Result<Vec<impl Event + 'static>, Box<dyn Error>> {
538        let conds = conds.resolve(&self.now)?;
539        let events = self.db.events.list(&conds, pager).await?;
540        let events = self.short_ids.events(events).await?;
541        Ok(events)
542    }
543
544    /// Counts the number of events matching the given conditions.
545    ///
546    /// # Errors
547    /// If database access fails.
548    pub async fn count_events(&self, conds: &EventConditions) -> Result<i64, Box<dyn Error>> {
549        let conds = conds.resolve(&self.now)?;
550        Ok(self.db.events.count(&conds).await?)
551    }
552
553    /// Create a default todo draft based on the AIM configuration.
554    ///
555    /// # Errors
556    /// If date/time resolution fails.
557    pub fn default_todo_draft(&self) -> Result<TodoDraft, String> {
558        TodoDraft::default(&self.config, &self.now)
559    }
560
561    /// Find the latest todo matching the given summary.
562    ///
563    /// # Errors
564    /// If database access fails.
565    pub async fn find_latest_todo_by_summary(
566        &self,
567        summary: &str,
568    ) -> Result<Option<impl Todo + 'static>, Box<dyn Error>> {
569        let Some(todo) = self.db.todos.find_latest_by_summary(summary).await? else {
570            return Ok(None);
571        };
572        let todo = self.short_ids.todo(todo).await?;
573        Ok(Some(todo))
574    }
575
576    /// Add a new todo from the given draft.
577    ///
578    /// # Errors
579    /// If the todo is not found, database or backend access fails.
580    pub async fn new_todo(&self, draft: TodoDraft) -> Result<impl Todo + 'static, Box<dyn Error>> {
581        let uid = self.generate_uid(Kind::Todo).await?;
582        let todo = draft.resolve(&self.config, &self.now).into_ics(&uid);
583
584        // Resolve calendar: use draft.calendar_id or fall back to default
585        let calendar_id = draft
586            .calendar_id
587            .as_deref()
588            .unwrap_or(&self.default_calendar);
589        let backend = self.get_store(calendar_id)?;
590
591        // Create todo in store
592        let resource_id = backend
593            .create_todo(&uid, &todo)
594            .await
595            .map_err(|e| format!("Failed to create todo in store: {e}"))?;
596
597        // Store in database with resource mapping
598        self.db.upsert_todo(&uid, &todo, calendar_id).await?;
599        self.db
600            .resources
601            .insert(&uid, calendar_id, &resource_id, None)
602            .await?;
603
604        let todo_with_id = self.short_ids.todo(todo).await?;
605        Ok(todo_with_id)
606    }
607
608    /// Upsert a todo into the calendar.
609    ///
610    /// # Errors
611    /// If the todo is not found, database or backend access fails.
612    pub async fn update_todo(
613        &self,
614        id: &Id,
615        patch: TodoPatch,
616    ) -> Result<impl Todo + 'static, Box<dyn Error>> {
617        let uid = self.short_ids.get_uid(id).await?;
618        let Some(_todo) = self.db.todos.get(&uid).await? else {
619            return Err("Todo not found".into());
620        };
621
622        // Get calendar_id from todo record
623        let todo_record = self.db.todos.get(&uid).await?.ok_or("Todo not found")?;
624        let backend = self.get_store(&todo_record.calendar_id)?;
625        let calendar_id = backend.calendar_id();
626
627        // Update todo through backend
628        let updated_todo = backend
629            .update_todo(&uid, &patch)
630            .await
631            .map_err(|e| format!("Failed to update todo in store: {e}"))?;
632
633        // Update database
634        self.db
635            .upsert_todo(&uid, &updated_todo, calendar_id)
636            .await?;
637
638        let todo = self.short_ids.todo(updated_todo).await?;
639        Ok(todo)
640    }
641
642    /// Get a todo by its id.
643    ///
644    /// # Errors
645    /// If the todo is not found or database access fails.
646    pub async fn get_todo(&self, id: &Id) -> Result<impl Todo + 'static, Box<dyn Error>> {
647        let uid = self.short_ids.get_uid(id).await?;
648        match self.db.todos.get(&uid).await {
649            Ok(Some(todo)) => Ok(self.short_ids.todo(todo).await?),
650            Ok(None) => Err("Event not found".into()),
651            Err(e) => Err(e.into()),
652        }
653    }
654
655    /// List todos matching the given conditions, sorted and paginated.
656    ///
657    /// # Errors
658    /// If database access fails.
659    pub async fn list_todos(
660        &self,
661        conds: &TodoConditions,
662        sort: &[TodoSort],
663        pager: &Pager,
664    ) -> Result<Vec<impl Todo + 'static>, Box<dyn Error>> {
665        let conds = conds.resolve(&self.now)?;
666        let sort = TodoSort::resolve_vec(sort, &self.config);
667        let todos = self.db.todos.list(&conds, &sort, pager).await?;
668        let todos = self.short_ids.todos(todos).await?;
669        Ok(todos)
670    }
671
672    /// Counts the number of todos matching the given conditions.
673    ///
674    /// # Errors
675    /// If database access fails.
676    pub async fn count_todos(&self, conds: &TodoConditions) -> Result<i64, Box<dyn Error>> {
677        let conds = conds.resolve(&self.now)?;
678        Ok(self.db.todos.count(&conds).await?)
679    }
680
681    /// List known calendars ordered by priority.
682    ///
683    /// # Errors
684    /// If database access fails.
685    pub async fn list_calendars(&self) -> Result<Vec<CalendarRecord>, Box<dyn Error>> {
686        Ok(self.db.calendars.list().await?)
687    }
688
689    /// Get detailed information for a single calendar.
690    ///
691    /// # Errors
692    /// If the calendar is not found or database access fails.
693    pub async fn get_calendar_details(&self, id: &str) -> Result<CalendarDetails, Box<dyn Error>> {
694        let record = self
695            .db
696            .calendars
697            .get(id)
698            .await?
699            .ok_or_else(|| format!("Calendar not found: {id}"))?;
700
701        let backend = self
702            .config
703            .calendars
704            .iter()
705            .find(|calendar| calendar.id == record.id)
706            .and_then(|calendar| {
707                self.config
708                    .stores
709                    .get(&calendar.store)
710                    .map(|store_def| Self::calendar_store_details(calendar, store_def))
711            });
712
713        Ok(CalendarDetails {
714            id: record.id.clone(),
715            name: record.name.clone(),
716            kind: record.kind.clone(),
717            priority: record.priority,
718            enabled: record.enabled,
719            is_default: self.default_calendar == record.id,
720            created_at: record.created_at.clone(),
721            updated_at: record.updated_at.clone(),
722            store: backend,
723        })
724    }
725
726    /// Startup notices produced while reconciling config and database state.
727    #[must_use]
728    pub fn startup_notices(&self) -> &[String] {
729        &self.startup_notices
730    }
731
732    /// Flush the short IDs to remove all entries.
733    ///
734    /// # Errors
735    /// If database access fails.
736    pub async fn flush_short_ids(&self) -> Result<(), Box<dyn Error>> {
737        self.short_ids.flush().await
738    }
739
740    /// Synchronizes the store with the local cache.
741    ///
742    /// # Errors
743    /// If synchronization fails.
744    pub async fn sync(&self) -> Result<SyncResult, Box<dyn Error>> {
745        let mut created = 0;
746        let mut updated = 0;
747        let mut deleted = 0;
748
749        for (calendar_id, backend) in &self.stores {
750            match backend.sync_cache().await {
751                Ok(result) => {
752                    created += result.created;
753                    updated += result.updated;
754                    deleted += result.deleted;
755                }
756                Err(e) => {
757                    return Err(format!("Failed to sync calendar '{calendar_id}': {e}").into());
758                }
759            }
760        }
761
762        Ok(SyncResult {
763            created,
764            updated,
765            deleted,
766        })
767    }
768
769    /// Close the AIM instance, saving any changes to the database.
770    ///
771    /// # Errors
772    /// If closing the database fails.
773    pub async fn close(self) -> Result<(), Box<dyn Error>> {
774        self.db.close().await
775    }
776
777    async fn generate_uid(&self, kind: Kind) -> Result<String, Box<dyn Error>> {
778        for i in 0..16 {
779            let uid = Uuid::new_v4().to_string(); // TODO: better uid
780            tracing::debug!(
781                ?uid,
782                attempt = i + 1,
783                "generated uid, checking for uniqueness"
784            );
785
786            let exists = match kind {
787                Kind::Event => self.db.events.get(&uid).await?.is_some(),
788                Kind::Todo => self.db.todos.get(&uid).await?.is_some(),
789            };
790            if exists {
791                tracing::debug!(uid, ?kind, "uid already exists in db");
792                continue;
793            }
794
795            return Ok(uid);
796        }
797
798        tracing::warn!("failed to generate a unique uid after multiple attempts");
799        Err("Failed to generate a unique UID after multiple attempts".into())
800    }
801}
802
803async fn prepare(config: &Config) -> Result<(), Box<dyn Error>> {
804    if let Some(parent) = &config.state_dir {
805        tracing::debug!(path = %parent.display(), "ensuring state directory exists");
806        fs::create_dir_all(parent).await?;
807    }
808    Ok(())
809}
810
811async fn initialize_db(config: &Config) -> Result<Db, Box<dyn Error>> {
812    const NAME: &str = "aim.db";
813    let db = if let Some(parent) = &config.state_dir {
814        Db::open(Some(&parent.join(NAME))).await
815    } else {
816        Db::open(None).await
817    }
818    .map_err(|e| format!("Failed to initialize db: {e}"))?;
819
820    Ok(db)
821}