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;