Skip to main content

todoist_cache_rs/sync_manager/
mod.rs

1//! Sync manager for orchestrating synchronization between the Todoist API and local cache.
2//!
3//! The `SyncManager` handles:
4//! - Full sync when no cache exists or when explicitly requested
5//! - Incremental sync using stored sync tokens
6//! - Cache staleness detection (>5 minutes by default)
7//!
8//! # Example
9//!
10//! ```no_run
11//! use todoist_api_rs::client::TodoistClient;
12//! use todoist_cache_rs::{CacheStore, SyncManager};
13//!
14//! #[tokio::main]
15//! async fn main() -> Result<(), Box<dyn std::error::Error>> {
16//!     let client = TodoistClient::new("your-api-token")?;
17//!     let store = CacheStore::new()?;
18//!     let mut manager = SyncManager::new(client, store)?;
19//!
20//!     // Sync (full if no cache, incremental otherwise)
21//!     let cache = manager.sync().await?;
22//!     println!("Synced {} items", cache.items.len());
23//!
24//!     Ok(())
25//! }
26//! ```
27
28mod lookups;
29
30use chrono::{DateTime, Duration, Utc};
31use todoist_api_rs::client::TodoistClient;
32use todoist_api_rs::sync::{SyncCommand, SyncRequest, SyncResponse};
33
34use crate::{Cache, CacheStore, CacheStoreError};
35
36// Re-export lookup utilities for error formatting and tests
37#[cfg(test)]
38pub(crate) use lookups::find_similar_name;
39pub(crate) use lookups::format_not_found_error;
40
41/// Default staleness threshold in minutes.
42const DEFAULT_STALE_MINUTES: i64 = 5;
43
44/// Errors that can occur during sync operations.
45#[derive(Debug, thiserror::Error)]
46pub enum SyncError {
47    /// Cache storage error.
48    #[error("cache error: {0}")]
49    Cache(#[from] CacheStoreError),
50
51    /// API error.
52    #[error("API error: {0}")]
53    Api(#[from] todoist_api_rs::error::Error),
54
55    /// Resource not found in cache (even after sync).
56    #[error("{}", format_not_found_error(resource_type, identifier, suggestion.as_deref()))]
57    NotFound {
58        /// The type of resource that was not found (e.g., "project", "label").
59        resource_type: &'static str,
60        /// The name or ID that was searched for.
61        identifier: String,
62        /// Optional suggestion for similar resource names.
63        suggestion: Option<String>,
64    },
65
66    /// Sync token was rejected by the API.
67    ///
68    /// This indicates the cached sync token is no longer valid and the client
69    /// should perform a full sync to obtain a fresh token.
70    #[error("sync token invalid or expired, full sync required")]
71    SyncTokenInvalid,
72
73    /// Validation or lookup error for user-provided input.
74    #[error("{0}")]
75    Validation(String),
76}
77
78/// Result type for sync operations.
79pub type Result<T> = std::result::Result<T, SyncError>;
80
81/// Orchestrates synchronization between the Todoist API and local cache.
82///
83/// `SyncManager` provides methods for syncing data, checking cache staleness,
84/// and forcing full syncs when needed.
85///
86/// # Thread Safety
87///
88/// `SyncManager` is [`Send`] but **not** [`Sync`]. Most methods require `&mut self`
89/// because they modify the internal cache and persist changes to disk.
90///
91/// For multi-threaded usage, wrap in `Arc<Mutex<SyncManager>>` or
92/// `Arc<tokio::sync::Mutex<SyncManager>>`:
93///
94/// ```no_run
95/// use std::sync::Arc;
96/// use tokio::sync::Mutex;
97/// use todoist_api_rs::client::TodoistClient;
98/// use todoist_cache_rs::{CacheStore, SyncManager};
99///
100/// # async fn example() -> Result<(), Box<dyn std::error::Error>> {
101/// let client = TodoistClient::new("token")?;
102/// let store = CacheStore::new()?;
103/// let manager = Arc::new(Mutex::new(SyncManager::new(client, store)?));
104///
105/// // Lock before calling mutable methods
106/// let mut guard = manager.lock().await;
107/// guard.sync().await?;
108/// # Ok(())
109/// # }
110/// ```
111///
112/// In typical CLI usage, the manager is owned by a single async task and no
113/// synchronization is needed.
114pub struct SyncManager {
115    /// The Todoist API client.
116    client: TodoistClient,
117
118    /// The cache storage.
119    store: CacheStore,
120
121    /// The current in-memory cache.
122    cache: Cache,
123
124    /// Staleness threshold in minutes.
125    stale_minutes: i64,
126}
127
128impl SyncManager {
129    /// Creates a new `SyncManager` with the given client and store.
130    ///
131    /// The cache is loaded from disk if it exists, otherwise a new empty cache is created.
132    ///
133    /// # Arguments
134    ///
135    /// * `client` - The Todoist API client
136    /// * `store` - The cache store for persistence
137    ///
138    /// # Errors
139    ///
140    /// Returns an error if loading the cache from disk fails (excluding file not found).
141    pub fn new(client: TodoistClient, store: CacheStore) -> Result<Self> {
142        let cache = store.load_or_default()?;
143        Ok(Self {
144            client,
145            store,
146            cache,
147            stale_minutes: DEFAULT_STALE_MINUTES,
148        })
149    }
150
151    /// Creates a new `SyncManager` with a custom staleness threshold.
152    ///
153    /// # Arguments
154    ///
155    /// * `client` - The Todoist API client
156    /// * `store` - The cache store for persistence
157    /// * `stale_minutes` - Number of minutes after which the cache is considered stale
158    pub fn with_stale_threshold(
159        client: TodoistClient,
160        store: CacheStore,
161        stale_minutes: i64,
162    ) -> Result<Self> {
163        let cache = store.load_or_default()?;
164        Ok(Self {
165            client,
166            store,
167            cache,
168            stale_minutes,
169        })
170    }
171
172    /// Returns a reference to the current cache.
173    pub fn cache(&self) -> &Cache {
174        &self.cache
175    }
176
177    /// Returns a reference to the cache store.
178    pub fn store(&self) -> &CacheStore {
179        &self.store
180    }
181
182    /// Returns a reference to the Todoist client.
183    pub fn client(&self) -> &TodoistClient {
184        &self.client
185    }
186
187    /// Returns true if the cache is stale (older than the configured threshold).
188    ///
189    /// A cache is considered stale if:
190    /// - It has never been synced (`last_sync` is `None`)
191    /// - It was last synced more than `stale_minutes` ago
192    ///
193    /// # Arguments
194    ///
195    /// * `now` - The current time to compare against
196    pub fn is_stale(&self, now: DateTime<Utc>) -> bool {
197        match self.cache.last_sync {
198            None => true,
199            Some(last_sync) => {
200                let threshold = Duration::minutes(self.stale_minutes);
201                now.signed_duration_since(last_sync) > threshold
202            }
203        }
204    }
205
206    /// Returns true if a sync is needed.
207    ///
208    /// A sync is needed if:
209    /// - The cache requires a full sync (no sync token)
210    /// - The cache is stale
211    ///
212    /// # Arguments
213    ///
214    /// * `now` - The current time to compare against
215    pub fn needs_sync(&self, now: DateTime<Utc>) -> bool {
216        self.cache.needs_full_sync() || self.is_stale(now)
217    }
218
219    /// Performs a sync operation.
220    ///
221    /// This method automatically determines whether to perform a full or incremental sync:
222    /// - Full sync if the cache has never been synced (sync_token is "*")
223    /// - Incremental sync otherwise
224    ///
225    /// If an incremental sync fails due to an invalid sync token, this method
226    /// automatically falls back to a full sync with `sync_token='*'`.
227    ///
228    /// The cache is saved to disk asynchronously after a successful sync.
229    ///
230    /// # Returns
231    ///
232    /// A reference to the updated cache.
233    ///
234    /// # Errors
235    ///
236    /// Returns an error if the API request fails or if saving the cache fails.
237    pub async fn sync(&mut self) -> Result<&Cache> {
238        if self.cache.needs_full_sync() {
239            // Already need a full sync, just do it
240            let request = SyncRequest::full_sync();
241            let response = self.client.sync(request).await?;
242            self.cache.apply_sync_response(&response);
243            self.store.save_async(&self.cache).await?;
244            return Ok(&self.cache);
245        }
246
247        // Try incremental sync
248        let request = SyncRequest::incremental(&self.cache.sync_token);
249        match self.client.sync(request).await {
250            Ok(response) => {
251                self.cache.apply_sync_response(&response);
252                self.store.save_async(&self.cache).await?;
253                Ok(&self.cache)
254            }
255            Err(e) if e.is_invalid_sync_token() => {
256                // Sync token rejected - fall back to full sync
257                eprintln!("Warning: Sync token invalid, performing full sync to recover.");
258
259                // Reset sync token to force full sync
260                self.cache.sync_token = "*".to_string();
261
262                // Perform full sync
263                let request = SyncRequest::full_sync();
264                let response = self.client.sync(request).await?;
265                self.cache.apply_sync_response(&response);
266                self.store.save_async(&self.cache).await?;
267                Ok(&self.cache)
268            }
269            Err(e) => Err(e.into()),
270        }
271    }
272
273    /// Forces a full sync, ignoring the stored sync token.
274    ///
275    /// This replaces all cached data with fresh data from the server.
276    /// The cache is saved to disk asynchronously after a successful sync.
277    ///
278    /// # Returns
279    ///
280    /// A reference to the updated cache.
281    ///
282    /// # Errors
283    ///
284    /// Returns an error if the API request fails or if saving the cache fails.
285    pub async fn full_sync(&mut self) -> Result<&Cache> {
286        let request = SyncRequest::full_sync();
287        let response = self.client.sync(request).await?;
288        self.cache.apply_sync_response(&response);
289        self.store.save_async(&self.cache).await?;
290
291        Ok(&self.cache)
292    }
293
294    /// Reloads the cache from disk.
295    ///
296    /// This discards any in-memory changes and loads the cache from disk.
297    /// Useful if the cache file was modified externally.
298    ///
299    /// # Errors
300    ///
301    /// Returns an error if reading the cache from disk fails.
302    pub fn reload(&mut self) -> Result<&Cache> {
303        self.cache = self.store.load_or_default()?;
304        Ok(&self.cache)
305    }
306
307    /// Executes one or more commands via the Sync API.
308    ///
309    /// This method sends the commands to the Todoist API, applies the response
310    /// to the cache, and saves the cache to disk. It returns the full response
311    /// so callers can access `temp_id_mapping` to resolve temporary IDs to
312    /// real IDs, and `sync_status` to check per-command results.
313    ///
314    /// # Arguments
315    ///
316    /// * `commands` - A vector of `SyncCommand` objects to execute
317    ///
318    /// # Returns
319    ///
320    /// The `SyncResponse` from the API, containing:
321    /// - `sync_status`: Success/failure for each command (keyed by command UUID)
322    /// - `temp_id_mapping`: Maps temporary IDs to real IDs for created resources
323    /// - Updated resources affected by the commands
324    ///
325    /// # Errors
326    ///
327    /// Returns an error if the API request fails or if saving the cache fails.
328    ///
329    /// # Example
330    ///
331    /// ```no_run
332    /// use todoist_api_rs::client::TodoistClient;
333    /// use todoist_api_rs::sync::{SyncCommand, SyncCommandType};
334    /// use todoist_cache_rs::{CacheStore, SyncManager};
335    ///
336    /// #[tokio::main]
337    /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
338    ///     let client = TodoistClient::new("your-api-token")?;
339    ///     let store = CacheStore::new()?;
340    ///     let mut manager = SyncManager::new(client, store)?;
341    ///
342    ///     // Create a new task
343    ///     let temp_id = uuid::Uuid::new_v4().to_string();
344    ///     let cmd = SyncCommand::with_temp_id(
345    ///         SyncCommandType::ItemAdd,
346    ///         &temp_id,
347    ///         serde_json::json!({"content": "Buy milk", "project_id": "inbox"}),
348    ///     );
349    ///
350    ///     let response = manager.execute_commands(vec![cmd]).await?;
351    ///
352    ///     // Get the real ID from temp_id_mapping
353    ///     if let Some(real_id) = response.temp_id_mapping.get(&temp_id) {
354    ///         println!("Created task with ID: {}", real_id);
355    ///     }
356    ///
357    ///     Ok(())
358    /// }
359    /// ```
360    pub async fn execute_commands(&mut self, commands: Vec<SyncCommand>) -> Result<SyncResponse> {
361        // Execute command batches against the current sync token so mutation
362        // responses include incremental resource deltas (including delete tombstones).
363        // Without resource_types, the API only returns sync_status and temp_id_mapping.
364        let request = SyncRequest::incremental(self.cache.sync_token.clone())
365            .with_resource_types(vec!["all".to_string()])
366            .add_commands(commands);
367        let response = self.client.sync(request).await?;
368
369        // Apply the mutation response to update cache with affected resources
370        self.cache.apply_mutation_response(&response);
371
372        // Persist the updated cache asynchronously
373        self.store.save_async(&self.cache).await?;
374
375        Ok(response)
376    }
377}
378
379#[cfg(test)]
380mod tests;