Skip to main content

todoist_cache_rs/
lib.rs

1//! Local cache for Todoist data.
2//!
3//! This crate provides a local cache that mirrors the Sync API response structure,
4//! enabling efficient incremental updates and offline read access.
5//!
6//! # Storage
7//!
8//! The cache is stored on disk using XDG-compliant paths via [`CacheStore`]:
9//! - Unix: `~/.cache/td/cache.json`
10//! - macOS: `~/Library/Caches/td/cache.json`
11//! - Windows: `C:\Users\<User>\AppData\Local\td\cache\cache.json`
12//!
13//! # Example
14//!
15//! ```no_run
16//! use todoist_cache_rs::{Cache, CacheStore};
17//!
18//! // Create a store with the default XDG path
19//! let store = CacheStore::new()?;
20//!
21//! // Load existing cache or create a new one
22//! let mut cache = store.load_or_default()?;
23//!
24//! // Modify the cache...
25//! cache.sync_token = "new_token".to_string();
26//!
27//! // Save changes to disk
28//! store.save(&cache)?;
29//! # Ok::<(), todoist_cache_rs::CacheStoreError>(())
30//! ```
31
32pub mod filter;
33mod merge;
34mod store;
35mod sync_manager;
36
37pub use store::{CacheStore, CacheStoreError, Result as CacheStoreResult};
38pub use sync_manager::{Result as SyncResult, SyncError, SyncManager};
39
40use std::collections::HashMap;
41
42use chrono::{DateTime, Utc};
43use serde::{Deserialize, Serialize};
44use todoist_api_rs::sync::{
45    Collaborator, CollaboratorState, Filter, Item, Label, Note, Project, ProjectNote, Reminder,
46    Section, User,
47};
48
49/// Indexes for O(1) cache lookups.
50///
51/// These indexes are rebuilt after every sync operation and when loading
52/// the cache from disk. They map IDs and lowercase names to indices in
53/// the corresponding vectors, enabling fast lookups without linear searches.
54#[derive(Debug, Default, Clone, PartialEq)]
55pub struct CacheIndexes {
56    /// Project ID -> index in projects vec.
57    pub projects_by_id: HashMap<String, usize>,
58    /// Lowercase project name -> index in projects vec.
59    pub projects_by_name: HashMap<String, usize>,
60    /// Section ID -> index in sections vec.
61    pub sections_by_id: HashMap<String, usize>,
62    /// Lowercase section name -> list of (project_id, index in sections vec).
63    /// Multiple sections can have the same name across different projects.
64    pub sections_by_name: HashMap<String, Vec<(String, usize)>>,
65    /// Label ID -> index in labels vec.
66    pub labels_by_id: HashMap<String, usize>,
67    /// Lowercase label name -> index in labels vec.
68    pub labels_by_name: HashMap<String, usize>,
69    /// Item ID -> index in items vec.
70    pub items_by_id: HashMap<String, usize>,
71    /// Collaborator user ID -> index in collaborators vec.
72    pub collaborators_by_id: HashMap<String, usize>,
73    /// Project ID -> list of collaborator user IDs for that project.
74    pub collaborators_by_project: HashMap<String, Vec<String>>,
75}
76
77/// Local cache for Todoist data.
78///
79/// The cache structure mirrors the Sync API response for easy updates from sync operations.
80/// It stores all relevant resources and metadata about the last sync.
81///
82/// # Thread Safety
83///
84/// `Cache` is [`Send`] and [`Sync`], but it has no internal synchronization.
85/// Concurrent reads are safe, but concurrent writes or read-modify-write
86/// patterns require external synchronization.
87///
88/// For multi-threaded access, wrap in `Arc<RwLock<Cache>>`:
89///
90/// ```
91/// use std::sync::{Arc, RwLock};
92/// use todoist_cache_rs::Cache;
93///
94/// let cache = Arc::new(RwLock::new(Cache::new()));
95///
96/// // Read access
97/// let items_count = cache.read().unwrap().items.len();
98///
99/// // Write access
100/// cache.write().unwrap().sync_token = "new_token".to_string();
101/// ```
102///
103/// In typical CLI usage, the cache is owned by a single-threaded runtime
104/// and external synchronization is not needed.
105#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
106pub struct Cache {
107    /// The sync token for incremental syncs.
108    /// Use "*" for a full sync or the stored token for incremental updates.
109    pub sync_token: String,
110
111    /// UTC timestamp when the last full sync was performed.
112    /// This is set when a full sync completes successfully.
113    #[serde(default, skip_serializing_if = "Option::is_none")]
114    pub full_sync_date_utc: Option<DateTime<Utc>>,
115
116    /// UTC timestamp of the last successful sync (full or incremental).
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub last_sync: Option<DateTime<Utc>>,
119
120    /// Cached tasks (called "items" in the Sync API).
121    #[serde(default)]
122    pub items: Vec<Item>,
123
124    /// Cached projects.
125    #[serde(default)]
126    pub projects: Vec<Project>,
127
128    /// Cached personal labels.
129    #[serde(default)]
130    pub labels: Vec<Label>,
131
132    /// Cached sections.
133    #[serde(default)]
134    pub sections: Vec<Section>,
135
136    /// Cached task comments (called "notes" in the Sync API).
137    #[serde(default)]
138    pub notes: Vec<Note>,
139
140    /// Cached project comments.
141    #[serde(default)]
142    pub project_notes: Vec<ProjectNote>,
143
144    /// Cached reminders.
145    #[serde(default)]
146    pub reminders: Vec<Reminder>,
147
148    /// Cached saved filters.
149    #[serde(default)]
150    pub filters: Vec<Filter>,
151
152    /// Cached collaborators for shared projects.
153    #[serde(default)]
154    pub collaborators: Vec<Collaborator>,
155
156    /// Cached collaborator membership states by project.
157    #[serde(default)]
158    pub collaborator_states: Vec<CollaboratorState>,
159
160    /// Cached user information.
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub user: Option<User>,
163
164    /// Indexes for fast lookups (rebuilt on sync, not serialized).
165    #[serde(skip)]
166    indexes: CacheIndexes,
167}
168
169impl Default for Cache {
170    fn default() -> Self {
171        Self::new()
172    }
173}
174
175impl Cache {
176    /// Creates a new empty cache with sync_token set to "*" for initial full sync.
177    pub fn new() -> Self {
178        Self {
179            sync_token: "*".to_string(),
180            full_sync_date_utc: None,
181            last_sync: None,
182            items: Vec::new(),
183            projects: Vec::new(),
184            labels: Vec::new(),
185            sections: Vec::new(),
186            notes: Vec::new(),
187            project_notes: Vec::new(),
188            reminders: Vec::new(),
189            filters: Vec::new(),
190            collaborators: Vec::new(),
191            collaborator_states: Vec::new(),
192            user: None,
193            indexes: CacheIndexes::default(),
194        }
195    }
196
197    /// Creates a new cache with provided data and rebuilds indexes.
198    ///
199    /// This is primarily useful for testing. The indexes are automatically
200    /// rebuilt after construction.
201    #[allow(clippy::too_many_arguments)]
202    pub fn with_data(
203        sync_token: String,
204        full_sync_date_utc: Option<DateTime<Utc>>,
205        last_sync: Option<DateTime<Utc>>,
206        items: Vec<Item>,
207        projects: Vec<Project>,
208        labels: Vec<Label>,
209        sections: Vec<Section>,
210        notes: Vec<Note>,
211        project_notes: Vec<ProjectNote>,
212        reminders: Vec<Reminder>,
213        filters: Vec<Filter>,
214        user: Option<User>,
215    ) -> Self {
216        let mut cache = Self {
217            sync_token,
218            full_sync_date_utc,
219            last_sync,
220            items,
221            projects,
222            labels,
223            sections,
224            notes,
225            project_notes,
226            reminders,
227            filters,
228            collaborators: Vec::new(),
229            collaborator_states: Vec::new(),
230            user,
231            indexes: CacheIndexes::default(),
232        };
233        cache.rebuild_indexes();
234        cache
235    }
236
237    /// Rebuilds all lookup indexes from current cache data.
238    ///
239    /// This is called automatically after applying sync responses and should
240    /// be called after loading the cache from disk.
241    pub fn rebuild_indexes(&mut self) {
242        let mut indexes = CacheIndexes::default();
243
244        // Pre-allocate capacity for better performance
245        indexes.projects_by_id.reserve(self.projects.len());
246        indexes.projects_by_name.reserve(self.projects.len());
247        indexes.sections_by_id.reserve(self.sections.len());
248        indexes.sections_by_name.reserve(self.sections.len());
249        indexes.labels_by_id.reserve(self.labels.len());
250        indexes.labels_by_name.reserve(self.labels.len());
251        indexes.items_by_id.reserve(self.items.len());
252        indexes
253            .collaborators_by_id
254            .reserve(self.collaborators.len());
255        indexes
256            .collaborators_by_project
257            .reserve(self.collaborator_states.len());
258
259        // Index projects
260        for (i, project) in self.projects.iter().enumerate() {
261            if !project.is_deleted {
262                indexes.projects_by_id.insert(project.id.clone(), i);
263                indexes
264                    .projects_by_name
265                    .insert(project.name.to_lowercase(), i);
266            }
267        }
268
269        // Index sections
270        for (i, section) in self.sections.iter().enumerate() {
271            if !section.is_deleted {
272                indexes.sections_by_id.insert(section.id.clone(), i);
273                indexes
274                    .sections_by_name
275                    .entry(section.name.to_lowercase())
276                    .or_default()
277                    .push((section.project_id.clone(), i));
278            }
279        }
280
281        // Index labels
282        for (i, label) in self.labels.iter().enumerate() {
283            if !label.is_deleted {
284                indexes.labels_by_id.insert(label.id.clone(), i);
285                indexes.labels_by_name.insert(label.name.to_lowercase(), i);
286            }
287        }
288
289        // Index items
290        for (i, item) in self.items.iter().enumerate() {
291            if !item.is_deleted {
292                indexes.items_by_id.insert(item.id.clone(), i);
293            }
294        }
295
296        // Index collaborators
297        for (i, collaborator) in self.collaborators.iter().enumerate() {
298            indexes
299                .collaborators_by_id
300                .insert(collaborator.id.clone(), i);
301        }
302
303        // Index collaborator states by project (excluding deleted states)
304        for collaborator_state in &self.collaborator_states {
305            if collaborator_state.state != "deleted" {
306                indexes
307                    .collaborators_by_project
308                    .entry(collaborator_state.project_id.clone())
309                    .or_default()
310                    .push(collaborator_state.user_id.clone());
311            }
312        }
313
314        self.indexes = indexes;
315    }
316
317    /// Find a project by ID or name (case-insensitive). O(1) lookup.
318    pub fn find_project(&self, name_or_id: &str) -> Option<&Project> {
319        // Try ID first (exact match)
320        if let Some(&idx) = self.indexes.projects_by_id.get(name_or_id) {
321            return self.projects.get(idx);
322        }
323
324        // Try lowercase name
325        let name_lower = name_or_id.to_lowercase();
326        if let Some(&idx) = self.indexes.projects_by_name.get(&name_lower) {
327            return self.projects.get(idx);
328        }
329
330        None
331    }
332
333    /// Find a section by ID or name (case-insensitive) within a project. O(1) lookup.
334    ///
335    /// If `project_id` is provided, returns the section only if it belongs to that project.
336    /// If `project_id` is `None` and there's exactly one match, returns it.
337    pub fn find_section(&self, name_or_id: &str, project_id: Option<&str>) -> Option<&Section> {
338        // Try ID first (exact match)
339        if let Some(&idx) = self.indexes.sections_by_id.get(name_or_id) {
340            let section = self.sections.get(idx)?;
341            // If project_id is specified, verify it matches
342            if project_id.is_none() || project_id == Some(section.project_id.as_str()) {
343                return Some(section);
344            }
345        }
346
347        // Try name (may have multiple matches across projects)
348        let name_lower = name_or_id.to_lowercase();
349        if let Some(matches) = self.indexes.sections_by_name.get(&name_lower) {
350            // If project specified, filter by it
351            if let Some(proj_id) = project_id {
352                for (section_proj_id, idx) in matches {
353                    if section_proj_id == proj_id {
354                        return self.sections.get(*idx);
355                    }
356                }
357            } else if matches.len() == 1 {
358                // Unambiguous single match
359                return self.sections.get(matches[0].1);
360            }
361        }
362
363        None
364    }
365
366    /// Find a label by ID or name (case-insensitive). O(1) lookup.
367    pub fn find_label(&self, name_or_id: &str) -> Option<&Label> {
368        // Try ID first (exact match)
369        if let Some(&idx) = self.indexes.labels_by_id.get(name_or_id) {
370            return self.labels.get(idx);
371        }
372
373        // Try lowercase name
374        let name_lower = name_or_id.to_lowercase();
375        if let Some(&idx) = self.indexes.labels_by_name.get(&name_lower) {
376            return self.labels.get(idx);
377        }
378
379        None
380    }
381
382    /// Find an item by ID. O(1) lookup.
383    pub fn find_item(&self, id: &str) -> Option<&Item> {
384        if let Some(&idx) = self.indexes.items_by_id.get(id) {
385            return self.items.get(idx);
386        }
387        None
388    }
389
390    /// Returns true if the cache has never been synced (sync_token is "*").
391    pub fn is_empty(&self) -> bool {
392        self.sync_token == "*"
393    }
394
395    /// Returns true if the cache requires a full sync.
396    /// This is true when the sync_token is "*".
397    pub fn needs_full_sync(&self) -> bool {
398        self.sync_token == "*"
399    }
400
401    /// Applies a sync response to the cache, merging in changes.
402    ///
403    /// This method handles both full and incremental sync responses:
404    /// - Updates the sync token and timestamps
405    /// - For full sync: replaces all resources with the response data
406    /// - For incremental sync: merges changes (add/update/delete by ID)
407    ///
408    /// Resources with `is_deleted: true` are removed from the cache.
409    ///
410    /// # Arguments
411    ///
412    /// * `response` - The sync response from the Todoist API
413    pub fn apply_sync_response(&mut self, response: &todoist_api_rs::sync::SyncResponse) {
414        merge::apply_sync_response(self, response);
415    }
416
417    /// Applies a mutation response to the cache.
418    ///
419    /// This method is similar to `apply_sync_response()` but is specifically
420    /// designed for write operation (mutation) responses. It:
421    /// - Updates the sync_token from the response
422    /// - Updates the last_sync timestamp
423    /// - Merges any resources returned in the response (add/update/delete by ID)
424    ///
425    /// Unlike full sync responses, mutation responses always use incremental
426    /// merge logic since they only contain affected resources.
427    ///
428    /// Note: The `temp_id_mapping` from the response should be used by the caller
429    /// to resolve temporary IDs before calling this method, or the caller can
430    /// use the returned response's `temp_id_mapping` to look up real IDs.
431    ///
432    /// # Arguments
433    ///
434    /// * `response` - The sync response from a mutation (write) operation
435    pub fn apply_mutation_response(&mut self, response: &todoist_api_rs::sync::SyncResponse) {
436        merge::apply_mutation_response(self, response);
437    }
438}
439
440#[cfg(test)]
441#[path = "cache_tests.rs"]
442mod tests;