todoist-cache-rs 0.2.0

Local cache for Todoist data
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
//! Local cache for Todoist data.
//!
//! This crate provides a local cache that mirrors the Sync API response structure,
//! enabling efficient incremental updates and offline read access.
//!
//! # Storage
//!
//! The cache is stored on disk using XDG-compliant paths via [`CacheStore`]:
//! - Unix: `~/.cache/td/cache.json`
//! - macOS: `~/Library/Caches/td/cache.json`
//! - Windows: `C:\Users\<User>\AppData\Local\td\cache\cache.json`
//!
//! # Example
//!
//! ```no_run
//! use todoist_cache_rs::{Cache, CacheStore};
//!
//! // Create a store with the default XDG path
//! let store = CacheStore::new()?;
//!
//! // Load existing cache or create a new one
//! let mut cache = store.load_or_default()?;
//!
//! // Modify the cache...
//! cache.sync_token = "new_token".to_string();
//!
//! // Save changes to disk
//! store.save(&cache)?;
//! # Ok::<(), todoist_cache_rs::CacheStoreError>(())
//! ```

pub mod filter;
mod merge;
mod store;
mod sync_manager;

pub use store::{CacheStore, CacheStoreError, Result as CacheStoreResult};
pub use sync_manager::{Result as SyncResult, SyncError, SyncManager};

use std::collections::HashMap;

use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use todoist_api_rs::sync::{
    Collaborator, CollaboratorState, Filter, Item, Label, Note, Project, ProjectNote, Reminder,
    Section, User,
};

/// Indexes for O(1) cache lookups.
///
/// These indexes are rebuilt after every sync operation and when loading
/// the cache from disk. They map IDs and lowercase names to indices in
/// the corresponding vectors, enabling fast lookups without linear searches.
#[derive(Debug, Default, Clone, PartialEq)]
pub struct CacheIndexes {
    /// Project ID -> index in projects vec.
    pub projects_by_id: HashMap<String, usize>,
    /// Lowercase project name -> index in projects vec.
    pub projects_by_name: HashMap<String, usize>,
    /// Section ID -> index in sections vec.
    pub sections_by_id: HashMap<String, usize>,
    /// Lowercase section name -> list of (project_id, index in sections vec).
    /// Multiple sections can have the same name across different projects.
    pub sections_by_name: HashMap<String, Vec<(String, usize)>>,
    /// Label ID -> index in labels vec.
    pub labels_by_id: HashMap<String, usize>,
    /// Lowercase label name -> index in labels vec.
    pub labels_by_name: HashMap<String, usize>,
    /// Item ID -> index in items vec.
    pub items_by_id: HashMap<String, usize>,
    /// Collaborator user ID -> index in collaborators vec.
    pub collaborators_by_id: HashMap<String, usize>,
    /// Project ID -> list of collaborator user IDs for that project.
    pub collaborators_by_project: HashMap<String, Vec<String>>,
}

/// Local cache for Todoist data.
///
/// The cache structure mirrors the Sync API response for easy updates from sync operations.
/// It stores all relevant resources and metadata about the last sync.
///
/// # Thread Safety
///
/// `Cache` is [`Send`] and [`Sync`], but it has no internal synchronization.
/// Concurrent reads are safe, but concurrent writes or read-modify-write
/// patterns require external synchronization.
///
/// For multi-threaded access, wrap in `Arc<RwLock<Cache>>`:
///
/// ```
/// use std::sync::{Arc, RwLock};
/// use todoist_cache_rs::Cache;
///
/// let cache = Arc::new(RwLock::new(Cache::new()));
///
/// // Read access
/// let items_count = cache.read().unwrap().items.len();
///
/// // Write access
/// cache.write().unwrap().sync_token = "new_token".to_string();
/// ```
///
/// In typical CLI usage, the cache is owned by a single-threaded runtime
/// and external synchronization is not needed.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Cache {
    /// The sync token for incremental syncs.
    /// Use "*" for a full sync or the stored token for incremental updates.
    pub sync_token: String,

    /// UTC timestamp when the last full sync was performed.
    /// This is set when a full sync completes successfully.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub full_sync_date_utc: Option<DateTime<Utc>>,

    /// UTC timestamp of the last successful sync (full or incremental).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub last_sync: Option<DateTime<Utc>>,

    /// Cached tasks (called "items" in the Sync API).
    #[serde(default)]
    pub items: Vec<Item>,

    /// Cached projects.
    #[serde(default)]
    pub projects: Vec<Project>,

    /// Cached personal labels.
    #[serde(default)]
    pub labels: Vec<Label>,

    /// Cached sections.
    #[serde(default)]
    pub sections: Vec<Section>,

    /// Cached task comments (called "notes" in the Sync API).
    #[serde(default)]
    pub notes: Vec<Note>,

    /// Cached project comments.
    #[serde(default)]
    pub project_notes: Vec<ProjectNote>,

    /// Cached reminders.
    #[serde(default)]
    pub reminders: Vec<Reminder>,

    /// Cached saved filters.
    #[serde(default)]
    pub filters: Vec<Filter>,

    /// Cached collaborators for shared projects.
    #[serde(default)]
    pub collaborators: Vec<Collaborator>,

    /// Cached collaborator membership states by project.
    #[serde(default)]
    pub collaborator_states: Vec<CollaboratorState>,

    /// Cached user information.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub user: Option<User>,

    /// Indexes for fast lookups (rebuilt on sync, not serialized).
    #[serde(skip)]
    indexes: CacheIndexes,
}

impl Default for Cache {
    fn default() -> Self {
        Self::new()
    }
}

impl Cache {
    /// Creates a new empty cache with sync_token set to "*" for initial full sync.
    pub fn new() -> Self {
        Self {
            sync_token: "*".to_string(),
            full_sync_date_utc: None,
            last_sync: None,
            items: Vec::new(),
            projects: Vec::new(),
            labels: Vec::new(),
            sections: Vec::new(),
            notes: Vec::new(),
            project_notes: Vec::new(),
            reminders: Vec::new(),
            filters: Vec::new(),
            collaborators: Vec::new(),
            collaborator_states: Vec::new(),
            user: None,
            indexes: CacheIndexes::default(),
        }
    }

    /// Creates a new cache with provided data and rebuilds indexes.
    ///
    /// This is primarily useful for testing. The indexes are automatically
    /// rebuilt after construction.
    #[allow(clippy::too_many_arguments)]
    pub fn with_data(
        sync_token: String,
        full_sync_date_utc: Option<DateTime<Utc>>,
        last_sync: Option<DateTime<Utc>>,
        items: Vec<Item>,
        projects: Vec<Project>,
        labels: Vec<Label>,
        sections: Vec<Section>,
        notes: Vec<Note>,
        project_notes: Vec<ProjectNote>,
        reminders: Vec<Reminder>,
        filters: Vec<Filter>,
        user: Option<User>,
    ) -> Self {
        let mut cache = Self {
            sync_token,
            full_sync_date_utc,
            last_sync,
            items,
            projects,
            labels,
            sections,
            notes,
            project_notes,
            reminders,
            filters,
            collaborators: Vec::new(),
            collaborator_states: Vec::new(),
            user,
            indexes: CacheIndexes::default(),
        };
        cache.rebuild_indexes();
        cache
    }

    /// Rebuilds all lookup indexes from current cache data.
    ///
    /// This is called automatically after applying sync responses and should
    /// be called after loading the cache from disk.
    pub fn rebuild_indexes(&mut self) {
        let mut indexes = CacheIndexes::default();

        // Pre-allocate capacity for better performance
        indexes.projects_by_id.reserve(self.projects.len());
        indexes.projects_by_name.reserve(self.projects.len());
        indexes.sections_by_id.reserve(self.sections.len());
        indexes.sections_by_name.reserve(self.sections.len());
        indexes.labels_by_id.reserve(self.labels.len());
        indexes.labels_by_name.reserve(self.labels.len());
        indexes.items_by_id.reserve(self.items.len());
        indexes
            .collaborators_by_id
            .reserve(self.collaborators.len());
        indexes
            .collaborators_by_project
            .reserve(self.collaborator_states.len());

        // Index projects
        for (i, project) in self.projects.iter().enumerate() {
            if !project.is_deleted {
                indexes.projects_by_id.insert(project.id.clone(), i);
                indexes
                    .projects_by_name
                    .insert(project.name.to_lowercase(), i);
            }
        }

        // Index sections
        for (i, section) in self.sections.iter().enumerate() {
            if !section.is_deleted {
                indexes.sections_by_id.insert(section.id.clone(), i);
                indexes
                    .sections_by_name
                    .entry(section.name.to_lowercase())
                    .or_default()
                    .push((section.project_id.clone(), i));
            }
        }

        // Index labels
        for (i, label) in self.labels.iter().enumerate() {
            if !label.is_deleted {
                indexes.labels_by_id.insert(label.id.clone(), i);
                indexes.labels_by_name.insert(label.name.to_lowercase(), i);
            }
        }

        // Index items
        for (i, item) in self.items.iter().enumerate() {
            if !item.is_deleted {
                indexes.items_by_id.insert(item.id.clone(), i);
            }
        }

        // Index collaborators
        for (i, collaborator) in self.collaborators.iter().enumerate() {
            indexes
                .collaborators_by_id
                .insert(collaborator.id.clone(), i);
        }

        // Index collaborator states by project (excluding deleted states)
        for collaborator_state in &self.collaborator_states {
            if collaborator_state.state != "deleted" {
                indexes
                    .collaborators_by_project
                    .entry(collaborator_state.project_id.clone())
                    .or_default()
                    .push(collaborator_state.user_id.clone());
            }
        }

        self.indexes = indexes;
    }

    /// Find a project by ID or name (case-insensitive). O(1) lookup.
    pub fn find_project(&self, name_or_id: &str) -> Option<&Project> {
        // Try ID first (exact match)
        if let Some(&idx) = self.indexes.projects_by_id.get(name_or_id) {
            return self.projects.get(idx);
        }

        // Try lowercase name
        let name_lower = name_or_id.to_lowercase();
        if let Some(&idx) = self.indexes.projects_by_name.get(&name_lower) {
            return self.projects.get(idx);
        }

        None
    }

    /// Find a section by ID or name (case-insensitive) within a project. O(1) lookup.
    ///
    /// If `project_id` is provided, returns the section only if it belongs to that project.
    /// If `project_id` is `None` and there's exactly one match, returns it.
    pub fn find_section(&self, name_or_id: &str, project_id: Option<&str>) -> Option<&Section> {
        // Try ID first (exact match)
        if let Some(&idx) = self.indexes.sections_by_id.get(name_or_id) {
            let section = self.sections.get(idx)?;
            // If project_id is specified, verify it matches
            if project_id.is_none() || project_id == Some(section.project_id.as_str()) {
                return Some(section);
            }
        }

        // Try name (may have multiple matches across projects)
        let name_lower = name_or_id.to_lowercase();
        if let Some(matches) = self.indexes.sections_by_name.get(&name_lower) {
            // If project specified, filter by it
            if let Some(proj_id) = project_id {
                for (section_proj_id, idx) in matches {
                    if section_proj_id == proj_id {
                        return self.sections.get(*idx);
                    }
                }
            } else if matches.len() == 1 {
                // Unambiguous single match
                return self.sections.get(matches[0].1);
            }
        }

        None
    }

    /// Find a label by ID or name (case-insensitive). O(1) lookup.
    pub fn find_label(&self, name_or_id: &str) -> Option<&Label> {
        // Try ID first (exact match)
        if let Some(&idx) = self.indexes.labels_by_id.get(name_or_id) {
            return self.labels.get(idx);
        }

        // Try lowercase name
        let name_lower = name_or_id.to_lowercase();
        if let Some(&idx) = self.indexes.labels_by_name.get(&name_lower) {
            return self.labels.get(idx);
        }

        None
    }

    /// Find an item by ID. O(1) lookup.
    pub fn find_item(&self, id: &str) -> Option<&Item> {
        if let Some(&idx) = self.indexes.items_by_id.get(id) {
            return self.items.get(idx);
        }
        None
    }

    /// Returns true if the cache has never been synced (sync_token is "*").
    pub fn is_empty(&self) -> bool {
        self.sync_token == "*"
    }

    /// Returns true if the cache requires a full sync.
    /// This is true when the sync_token is "*".
    pub fn needs_full_sync(&self) -> bool {
        self.sync_token == "*"
    }

    /// Applies a sync response to the cache, merging in changes.
    ///
    /// This method handles both full and incremental sync responses:
    /// - Updates the sync token and timestamps
    /// - For full sync: replaces all resources with the response data
    /// - For incremental sync: merges changes (add/update/delete by ID)
    ///
    /// Resources with `is_deleted: true` are removed from the cache.
    ///
    /// # Arguments
    ///
    /// * `response` - The sync response from the Todoist API
    pub fn apply_sync_response(&mut self, response: &todoist_api_rs::sync::SyncResponse) {
        merge::apply_sync_response(self, response);
    }

    /// Applies a mutation response to the cache.
    ///
    /// This method is similar to `apply_sync_response()` but is specifically
    /// designed for write operation (mutation) responses. It:
    /// - Updates the sync_token from the response
    /// - Updates the last_sync timestamp
    /// - Merges any resources returned in the response (add/update/delete by ID)
    ///
    /// Unlike full sync responses, mutation responses always use incremental
    /// merge logic since they only contain affected resources.
    ///
    /// Note: The `temp_id_mapping` from the response should be used by the caller
    /// to resolve temporary IDs before calling this method, or the caller can
    /// use the returned response's `temp_id_mapping` to look up real IDs.
    ///
    /// # Arguments
    ///
    /// * `response` - The sync response from a mutation (write) operation
    pub fn apply_mutation_response(&mut self, response: &todoist_api_rs::sync::SyncResponse) {
        merge::apply_mutation_response(self, response);
    }
}

#[cfg(test)]
#[path = "cache_tests.rs"]
mod tests;