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;