use chrono::{DateTime, Duration, Utc};
use strsim::levenshtein;
use todoist_api_rs::client::TodoistClient;
use todoist_api_rs::sync::{SyncCommand, SyncRequest, SyncResponse};
use crate::{Cache, CacheStore, CacheStoreError};
const DEFAULT_STALE_MINUTES: i64 = 5;
const MAX_SUGGESTION_DISTANCE: usize = 3;
fn format_not_found_error(
resource_type: &str,
identifier: &str,
suggestion: Option<&str>,
) -> String {
let base = format!(
"{} '{}' not found. Try running 'td sync' to refresh your cache.",
resource_type, identifier
);
match suggestion {
Some(s) => format!("{} Did you mean '{}'?", base, s),
None => base,
}
}
fn find_similar_name<'a>(query: &str, candidates: impl Iterator<Item = &'a str>) -> Option<String> {
let query_lower = query.to_lowercase();
let (best_match, best_distance) = candidates
.filter(|name| !name.is_empty())
.map(|name| {
let distance = levenshtein(&query_lower, &name.to_lowercase());
(name.to_string(), distance)
})
.min_by_key(|(_, d)| *d)?;
if best_distance > 0 && best_distance <= MAX_SUGGESTION_DISTANCE {
Some(best_match)
} else {
None
}
}
#[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,
}
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(&self.cache)?;
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(&self.cache)?;
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(&self.cache)?;
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(&self.cache)?;
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::with_commands(commands).with_resource_types(vec!["all".to_string()]);
let response = self.client.sync(request).await?;
self.cache.apply_mutation_response(&response);
self.store.save(&self.cache)?;
Ok(response)
}
pub async fn resolve_project(
&mut self,
name_or_id: &str,
) -> Result<&todoist_api_rs::sync::Project> {
if self.find_project_in_cache(name_or_id).is_some() {
return Ok(self.find_project_in_cache(name_or_id).unwrap());
}
self.sync().await?;
self.find_project_in_cache(name_or_id).ok_or_else(|| {
let suggestion = find_similar_name(
name_or_id,
self.cache
.projects
.iter()
.filter(|p| !p.is_deleted)
.map(|p| p.name.as_str()),
);
SyncError::NotFound {
resource_type: "Project",
identifier: name_or_id.to_string(),
suggestion,
}
})
}
fn find_project_in_cache(&self, name_or_id: &str) -> Option<&todoist_api_rs::sync::Project> {
let name_lower = name_or_id.to_lowercase();
self.cache
.projects
.iter()
.find(|p| !p.is_deleted && (p.name.to_lowercase() == name_lower || p.id == name_or_id))
}
pub async fn resolve_section(
&mut self,
name_or_id: &str,
project_id: Option<&str>,
) -> Result<&todoist_api_rs::sync::Section> {
if self.find_section_in_cache(name_or_id, project_id).is_some() {
return Ok(self.find_section_in_cache(name_or_id, project_id).unwrap());
}
self.sync().await?;
self.find_section_in_cache(name_or_id, project_id)
.ok_or_else(|| {
let suggestion = find_similar_name(
name_or_id,
self.cache
.sections
.iter()
.filter(|s| {
!s.is_deleted && project_id.is_none_or(|pid| s.project_id == pid)
})
.map(|s| s.name.as_str()),
);
SyncError::NotFound {
resource_type: "Section",
identifier: name_or_id.to_string(),
suggestion,
}
})
}
fn find_section_in_cache(
&self,
name_or_id: &str,
project_id: Option<&str>,
) -> Option<&todoist_api_rs::sync::Section> {
let name_lower = name_or_id.to_lowercase();
self.cache.sections.iter().find(|s| {
if s.is_deleted {
return false;
}
if s.id == name_or_id {
return true;
}
if s.name.to_lowercase() == name_lower {
return project_id.is_none_or(|pid| s.project_id == pid);
}
false
})
}
pub async fn resolve_label(
&mut self,
name_or_id: &str,
) -> Result<&todoist_api_rs::sync::Label> {
if self.find_label_in_cache(name_or_id).is_some() {
return Ok(self.find_label_in_cache(name_or_id).unwrap());
}
self.sync().await?;
self.find_label_in_cache(name_or_id).ok_or_else(|| {
let suggestion = find_similar_name(
name_or_id,
self.cache
.labels
.iter()
.filter(|l| !l.is_deleted)
.map(|l| l.name.as_str()),
);
SyncError::NotFound {
resource_type: "Label",
identifier: name_or_id.to_string(),
suggestion,
}
})
}
fn find_label_in_cache(&self, name_or_id: &str) -> Option<&todoist_api_rs::sync::Label> {
let name_lower = name_or_id.to_lowercase();
self.cache
.labels
.iter()
.find(|l| !l.is_deleted && (l.name.to_lowercase() == name_lower || l.id == name_or_id))
}
pub async fn resolve_item(&mut self, id: &str) -> Result<&todoist_api_rs::sync::Item> {
if self.find_item_in_cache(id).is_some() {
return Ok(self.find_item_in_cache(id).unwrap());
}
self.sync().await?;
self.find_item_in_cache(id)
.ok_or_else(|| SyncError::NotFound {
resource_type: "Item",
identifier: id.to_string(),
suggestion: None, })
}
fn find_item_in_cache(&self, id: &str) -> Option<&todoist_api_rs::sync::Item> {
self.cache
.items
.iter()
.find(|i| !i.is_deleted && i.id == id)
}
pub async fn resolve_item_by_prefix(
&mut self,
id_or_prefix: &str,
require_checked: Option<bool>,
) -> Result<&todoist_api_rs::sync::Item> {
match self.find_item_by_prefix_in_cache(id_or_prefix, require_checked) {
ItemLookupResult::Found(_) => {
if let ItemLookupResult::Found(item) =
self.find_item_by_prefix_in_cache(id_or_prefix, require_checked)
{
return Ok(item);
}
unreachable!()
}
ItemLookupResult::Ambiguous(msg) => {
return Err(SyncError::NotFound {
resource_type: "Item",
identifier: msg,
suggestion: None,
});
}
ItemLookupResult::NotFound => {
}
}
self.sync().await?;
match self.find_item_by_prefix_in_cache(id_or_prefix, require_checked) {
ItemLookupResult::Found(item) => Ok(item),
ItemLookupResult::Ambiguous(msg) => Err(SyncError::NotFound {
resource_type: "Item",
identifier: msg,
suggestion: None,
}),
ItemLookupResult::NotFound => Err(SyncError::NotFound {
resource_type: "Item",
identifier: id_or_prefix.to_string(),
suggestion: None, }),
}
}
fn find_item_by_prefix_in_cache(
&self,
id_or_prefix: &str,
require_checked: Option<bool>,
) -> ItemLookupResult<'_> {
if let Some(item) = self.cache.items.iter().find(|i| {
!i.is_deleted
&& i.id == id_or_prefix
&& require_checked.is_none_or(|checked| i.checked == checked)
}) {
return ItemLookupResult::Found(item);
}
let matches: Vec<&todoist_api_rs::sync::Item> = self
.cache
.items
.iter()
.filter(|i| {
!i.is_deleted
&& i.id.starts_with(id_or_prefix)
&& require_checked.is_none_or(|checked| i.checked == checked)
})
.collect();
match matches.len() {
0 => ItemLookupResult::NotFound,
1 => ItemLookupResult::Found(matches[0]),
_ => {
let mut msg = format!(
"Ambiguous task ID \"{}\"\n\nMultiple tasks match this prefix:",
id_or_prefix
);
for item in matches.iter().take(5) {
let prefix = &item.id[..6.min(item.id.len())];
msg.push_str(&format!("\n {} {}", prefix, item.content));
}
if matches.len() > 5 {
msg.push_str(&format!("\n ... and {} more", matches.len() - 5));
}
msg.push_str("\n\nPlease use a longer prefix.");
ItemLookupResult::Ambiguous(msg)
}
}
}
}
enum ItemLookupResult<'a> {
Found(&'a todoist_api_rs::sync::Item),
Ambiguous(String),
NotFound,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_is_stale_when_never_synced() {
let cache = Cache::new();
assert!(cache.last_sync.is_none());
let last_sync: Option<DateTime<Utc>> = None;
let now = Utc::now();
let threshold = Duration::minutes(5);
let is_stale = match last_sync {
None => true,
Some(ls) => now.signed_duration_since(ls) > threshold,
};
assert!(is_stale);
}
#[test]
fn test_is_stale_when_recently_synced() {
let now = Utc::now();
let last_sync = Some(now - Duration::minutes(2)); let threshold = Duration::minutes(5);
let is_stale = match last_sync {
None => true,
Some(ls) => now.signed_duration_since(ls) > threshold,
};
assert!(!is_stale);
}
#[test]
fn test_is_stale_when_old_sync() {
let now = Utc::now();
let last_sync = Some(now - Duration::minutes(10)); let threshold = Duration::minutes(5);
let is_stale = match last_sync {
None => true,
Some(ls) => now.signed_duration_since(ls) > threshold,
};
assert!(is_stale);
}
#[test]
fn test_is_stale_at_threshold_boundary() {
let now = Utc::now();
let last_sync = Some(now - Duration::minutes(5));
let threshold = Duration::minutes(5);
let is_stale = match last_sync {
None => true,
Some(ls) => now.signed_duration_since(ls) > threshold,
};
assert!(!is_stale);
}
#[test]
fn test_is_stale_just_over_threshold() {
let now = Utc::now();
let last_sync = Some(now - Duration::minutes(5) - Duration::seconds(1));
let threshold = Duration::minutes(5);
let is_stale = match last_sync {
None => true,
Some(ls) => now.signed_duration_since(ls) > threshold,
};
assert!(is_stale);
}
#[test]
fn test_needs_sync_when_full_sync_needed() {
let cache = Cache::new();
assert!(cache.needs_full_sync());
}
#[test]
fn test_needs_sync_when_stale() {
let mut cache = Cache::new();
cache.sync_token = "some_token".to_string(); cache.last_sync = Some(Utc::now() - Duration::minutes(10));
assert!(!cache.needs_full_sync());
}
#[test]
fn test_needs_sync_when_fresh() {
let mut cache = Cache::new();
cache.sync_token = "some_token".to_string(); cache.last_sync = Some(Utc::now());
assert!(!cache.needs_full_sync());
}
#[test]
fn test_find_similar_name_exact_match_returns_none() {
let candidates = vec!["Work", "Personal", "Shopping"];
let result = find_similar_name("Work", candidates.iter().map(|s| *s));
assert!(result.is_none());
}
#[test]
fn test_find_similar_name_case_insensitive_exact_match_returns_none() {
let candidates = vec!["Work", "Personal", "Shopping"];
let result = find_similar_name("work", candidates.iter().map(|s| *s));
assert!(result.is_none());
}
#[test]
fn test_find_similar_name_single_typo() {
let candidates = vec!["Work", "Personal", "Shopping"];
let result = find_similar_name("Wrok", candidates.iter().map(|s| *s));
assert_eq!(result, Some("Work".to_string()));
}
#[test]
fn test_find_similar_name_missing_letter() {
let candidates = vec!["Inbox", "Personal", "Shopping"];
let result = find_similar_name("inbx", candidates.iter().map(|s| *s));
assert_eq!(result, Some("Inbox".to_string()));
}
#[test]
fn test_find_similar_name_extra_letter() {
let candidates = vec!["Work", "Personal", "Shopping"];
let result = find_similar_name("Workk", candidates.iter().map(|s| *s));
assert_eq!(result, Some("Work".to_string()));
}
#[test]
fn test_find_similar_name_too_different() {
let candidates = vec!["Work", "Personal", "Shopping"];
let result = find_similar_name("Completely Different", candidates.iter().map(|s| *s));
assert!(result.is_none());
}
#[test]
fn test_find_similar_name_empty_candidates() {
let candidates: Vec<&str> = vec![];
let result = find_similar_name("Work", candidates.iter().map(|s| *s));
assert!(result.is_none());
}
#[test]
fn test_find_similar_name_best_match_selected() {
let candidates = vec!["Workshop", "Work", "Working"];
let result = find_similar_name("Wok", candidates.iter().map(|s| *s));
assert_eq!(result, Some("Work".to_string()));
}
#[test]
fn test_format_not_found_error_without_suggestion() {
let msg = format_not_found_error("Project", "inbox", None);
assert_eq!(
msg,
"Project 'inbox' not found. Try running 'td sync' to refresh your cache."
);
}
#[test]
fn test_format_not_found_error_with_suggestion() {
let msg = format_not_found_error("Project", "inbox", Some("Inbox"));
assert_eq!(
msg,
"Project 'inbox' not found. Try running 'td sync' to refresh your cache. Did you mean 'Inbox'?"
);
}
#[test]
fn test_format_not_found_error_label_with_suggestion() {
let msg = format_not_found_error("Label", "urgnt", Some("urgent"));
assert_eq!(
msg,
"Label 'urgnt' not found. Try running 'td sync' to refresh your cache. Did you mean 'urgent'?"
);
}
}