mod lookups;
use chrono::{DateTime, Duration, Utc};
use todoist_api_rs::client::TodoistClient;
use todoist_api_rs::sync::{SyncCommand, SyncRequest, SyncResponse};
use crate::{Cache, CacheStore, CacheStoreError};
#[cfg(test)]
pub(crate) use lookups::find_similar_name;
pub(crate) use lookups::format_not_found_error;
const DEFAULT_STALE_MINUTES: i64 = 5;
#[derive(Debug, thiserror::Error)]
pub enum SyncError {
#[error("cache error: {0}")]
Cache(#[from] CacheStoreError),
#[error("API error: {0}")]
Api(#[from] todoist_api_rs::error::Error),
#[error("{}", format_not_found_error(resource_type, identifier, suggestion.as_deref()))]
NotFound {
resource_type: &'static str,
identifier: String,
suggestion: Option<String>,
},
#[error("sync token invalid or expired, full sync required")]
SyncTokenInvalid,
#[error("{0}")]
Validation(String),
}
pub type Result<T> = std::result::Result<T, SyncError>;
pub struct SyncManager {
client: TodoistClient,
store: CacheStore,
cache: Cache,
stale_minutes: i64,
}
impl SyncManager {
pub fn new(client: TodoistClient, store: CacheStore) -> Result<Self> {
let cache = store.load_or_default()?;
Ok(Self {
client,
store,
cache,
stale_minutes: DEFAULT_STALE_MINUTES,
})
}
pub fn with_stale_threshold(
client: TodoistClient,
store: CacheStore,
stale_minutes: i64,
) -> Result<Self> {
let cache = store.load_or_default()?;
Ok(Self {
client,
store,
cache,
stale_minutes,
})
}
pub fn cache(&self) -> &Cache {
&self.cache
}
pub fn store(&self) -> &CacheStore {
&self.store
}
pub fn client(&self) -> &TodoistClient {
&self.client
}
pub fn is_stale(&self, now: DateTime<Utc>) -> bool {
match self.cache.last_sync {
None => true,
Some(last_sync) => {
let threshold = Duration::minutes(self.stale_minutes);
now.signed_duration_since(last_sync) > threshold
}
}
}
pub fn needs_sync(&self, now: DateTime<Utc>) -> bool {
self.cache.needs_full_sync() || self.is_stale(now)
}
pub async fn sync(&mut self) -> Result<&Cache> {
if self.cache.needs_full_sync() {
let request = SyncRequest::full_sync();
let response = self.client.sync(request).await?;
self.cache.apply_sync_response(&response);
self.store.save_async(&self.cache).await?;
return Ok(&self.cache);
}
let request = SyncRequest::incremental(&self.cache.sync_token);
match self.client.sync(request).await {
Ok(response) => {
self.cache.apply_sync_response(&response);
self.store.save_async(&self.cache).await?;
Ok(&self.cache)
}
Err(e) if e.is_invalid_sync_token() => {
eprintln!("Warning: Sync token invalid, performing full sync to recover.");
self.cache.sync_token = "*".to_string();
let request = SyncRequest::full_sync();
let response = self.client.sync(request).await?;
self.cache.apply_sync_response(&response);
self.store.save_async(&self.cache).await?;
Ok(&self.cache)
}
Err(e) => Err(e.into()),
}
}
pub async fn full_sync(&mut self) -> Result<&Cache> {
let request = SyncRequest::full_sync();
let response = self.client.sync(request).await?;
self.cache.apply_sync_response(&response);
self.store.save_async(&self.cache).await?;
Ok(&self.cache)
}
pub fn reload(&mut self) -> Result<&Cache> {
self.cache = self.store.load_or_default()?;
Ok(&self.cache)
}
pub async fn execute_commands(&mut self, commands: Vec<SyncCommand>) -> Result<SyncResponse> {
let request = SyncRequest::incremental(self.cache.sync_token.clone())
.with_resource_types(vec!["all".to_string()])
.add_commands(commands);
let response = self.client.sync(request).await?;
self.cache.apply_mutation_response(&response);
self.store.save_async(&self.cache).await?;
Ok(response)
}
}
#[cfg(test)]
mod tests;