todoist_cache_rs/sync_manager/lookups.rs
1//! Resource lookup functionality for SyncManager.
2//!
3//! This module provides lookup methods for finding projects, sections, labels,
4//! and items in the cache with auto-sync fallback and fuzzy matching suggestions.
5
6use strsim::levenshtein;
7use todoist_api_rs::sync::{Collaborator, Item, Label, Project, Section};
8
9use crate::{SyncError, SyncManager, SyncResult};
10
11/// Maximum Levenshtein distance to consider a name as a suggestion.
12const MAX_SUGGESTION_DISTANCE: usize = 3;
13
14/// Formats the "not found" error message, optionally including a suggestion.
15pub(crate) fn format_not_found_error(
16 resource_type: &str,
17 identifier: &str,
18 suggestion: Option<&str>,
19) -> String {
20 let base = format!(
21 "{} '{}' not found. Try running 'td sync' to refresh your cache.",
22 resource_type, identifier
23 );
24 match suggestion {
25 Some(s) => format!("{} Did you mean '{}'?", base, s),
26 None => base,
27 }
28}
29
30/// Finds the best matching name from a list of candidates using Levenshtein distance.
31///
32/// Returns the best match if its edit distance is within the threshold,
33/// otherwise returns `None`.
34pub(crate) fn find_similar_name<'a>(
35 query: &str,
36 candidates: impl Iterator<Item = &'a str>,
37) -> Option<String> {
38 let query_lower = query.to_lowercase();
39
40 let (best_match, best_distance) = candidates
41 .filter(|name| !name.is_empty())
42 .map(|name| {
43 let distance = levenshtein(&query_lower, &name.to_lowercase());
44 (name.to_string(), distance)
45 })
46 .min_by_key(|(_, d)| *d)?;
47
48 // Only suggest if the distance is within threshold and not an exact match
49 if best_distance > 0 && best_distance <= MAX_SUGGESTION_DISTANCE {
50 Some(best_match)
51 } else {
52 None
53 }
54}
55
56/// Result of an item lookup by prefix.
57pub(crate) enum ItemLookupResult<'a> {
58 /// Found exactly one matching item.
59 Found(&'a Item),
60 /// Multiple items match the prefix (contains error message).
61 Ambiguous(String),
62 /// No matching item found.
63 NotFound,
64}
65
66/// Internal status for cache lookups (used to avoid borrow checker issues).
67enum CacheLookupStatus {
68 Found,
69 Ambiguous(String),
70 NotFound,
71}
72
73impl SyncManager {
74 // ==================== Smart Lookup Methods ====================
75
76 /// Resolves a project by name or ID, with auto-sync fallback.
77 ///
78 /// This method first attempts to find the project in the cache. If not found,
79 /// it performs a sync and retries the lookup. This provides a seamless experience
80 /// where users can reference recently-created projects without manual syncing.
81 ///
82 /// # Arguments
83 ///
84 /// * `name_or_id` - The project name (case-insensitive) or ID to search for
85 ///
86 /// # Returns
87 ///
88 /// A reference to the matching `Project` from the cache.
89 ///
90 /// # Errors
91 ///
92 /// Returns `SyncError::NotFound` if the project cannot be found even after syncing.
93 /// Returns `SyncError::Api` if the sync operation fails.
94 ///
95 /// # Example
96 ///
97 /// ```no_run
98 /// use todoist_api_rs::client::TodoistClient;
99 /// use todoist_cache_rs::{CacheStore, SyncManager};
100 ///
101 /// #[tokio::main]
102 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
103 /// let client = TodoistClient::new("your-api-token")?;
104 /// let store = CacheStore::new()?;
105 /// let mut manager = SyncManager::new(client, store)?;
106 ///
107 /// // Find by name (case-insensitive)
108 /// let project = manager.resolve_project("work").await?;
109 /// println!("Found project: {} ({})", project.name, project.id);
110 ///
111 /// // Find by ID
112 /// let project = manager.resolve_project("12345678").await?;
113 /// println!("Found project: {}", project.name);
114 ///
115 /// Ok(())
116 /// }
117 /// ```
118 pub async fn resolve_project(&mut self, name_or_id: &str) -> SyncResult<&Project> {
119 // Try cache first - if found, we can return early after sync check
120 let found_in_cache = self.find_project_in_cache(name_or_id).is_some();
121
122 if !found_in_cache {
123 // Not found - sync and retry
124 self.sync().await?;
125 }
126
127 // Return from cache (either was there or now present after sync)
128 self.find_project_in_cache(name_or_id).ok_or_else(|| {
129 // Find similar project names for suggestion
130 let suggestion = find_similar_name(
131 name_or_id,
132 self.cache()
133 .projects
134 .iter()
135 .filter(|p| !p.is_deleted)
136 .map(|p| p.name.as_str()),
137 );
138 SyncError::NotFound {
139 resource_type: "Project",
140 identifier: name_or_id.to_string(),
141 suggestion,
142 }
143 })
144 }
145
146 /// Helper to find a project in the cache by name or ID.
147 ///
148 /// Searches for non-deleted projects where either:
149 /// - The name matches (case-insensitive)
150 /// - The ID matches exactly
151 fn find_project_in_cache(&self, name_or_id: &str) -> Option<&Project> {
152 let name_lower = name_or_id.to_lowercase();
153 self.cache()
154 .projects
155 .iter()
156 .find(|p| !p.is_deleted && (p.name.to_lowercase() == name_lower || p.id == name_or_id))
157 }
158
159 /// Returns whether a project is shared with active collaborators other than the owner.
160 pub fn is_shared_project(&self, project_id: &str) -> bool {
161 let owner_id = self.cache().user.as_ref().map(|user| user.id.as_str());
162
163 self.cache().collaborator_states.iter().any(|state| {
164 state.project_id == project_id
165 && state.state.eq_ignore_ascii_case("active")
166 && owner_id != Some(state.user_id.as_str())
167 })
168 }
169
170 /// Resolves a collaborator in a project by name or email.
171 ///
172 /// Matching order:
173 /// 1. Exact full name (case-insensitive)
174 /// 2. Exact email (case-insensitive)
175 /// 3. Prefix/substring match on full name or email (case-insensitive)
176 pub fn resolve_collaborator(&self, query: &str, project_id: &str) -> SyncResult<&Collaborator> {
177 let query = query.trim();
178 let query_lower = query.to_lowercase();
179 let project_name = self
180 .cache()
181 .projects
182 .iter()
183 .find(|project| !project.is_deleted && project.id == project_id)
184 .map(|project| project.name.as_str())
185 .unwrap_or(project_id);
186
187 // Handle "me" as a special value — resolve to the current user.
188 if query_lower == "me" {
189 if let Some(user) = &self.cache().user {
190 let user_id = &user.id;
191 // Verify the current user is an active collaborator on this project
192 let is_active = self.cache().collaborator_states.iter().any(|state| {
193 state.project_id == project_id
194 && state.user_id == *user_id
195 && state.state.eq_ignore_ascii_case("active")
196 });
197 if is_active {
198 if let Some(collaborator) =
199 self.cache().collaborators.iter().find(|c| c.id == *user_id)
200 {
201 return Ok(collaborator);
202 }
203 }
204 }
205 return Err(SyncError::Validation(format!(
206 "No collaborator matching '{}' in project '{}'",
207 query, project_name
208 )));
209 }
210
211 let mut project_collaborators: Vec<&Collaborator> = Vec::new();
212 for state in &self.cache().collaborator_states {
213 if state.project_id != project_id || !state.state.eq_ignore_ascii_case("active") {
214 continue;
215 }
216
217 if let Some(collaborator) = self
218 .cache()
219 .collaborators
220 .iter()
221 .find(|collaborator| collaborator.id == state.user_id)
222 {
223 let already_added = project_collaborators
224 .iter()
225 .any(|existing| existing.id == collaborator.id);
226 if !already_added {
227 project_collaborators.push(collaborator);
228 }
229 }
230 }
231
232 if let Some(collaborator) = Self::single_or_ambiguous(
233 query,
234 project_collaborators
235 .iter()
236 .copied()
237 .filter(|c| {
238 c.full_name
239 .as_deref()
240 .is_some_and(|n| n.eq_ignore_ascii_case(query))
241 })
242 .collect(),
243 )? {
244 return Ok(collaborator);
245 }
246
247 if let Some(collaborator) = Self::single_or_ambiguous(
248 query,
249 project_collaborators
250 .iter()
251 .copied()
252 .filter(|c| {
253 c.email
254 .as_deref()
255 .is_some_and(|e| e.eq_ignore_ascii_case(query))
256 })
257 .collect(),
258 )? {
259 return Ok(collaborator);
260 }
261
262 let partial_matches: Vec<&Collaborator> = project_collaborators
263 .iter()
264 .copied()
265 .filter(|collaborator| {
266 collaborator
267 .full_name
268 .as_deref()
269 .is_some_and(|name| name.to_lowercase().contains(&query_lower))
270 || collaborator
271 .email
272 .as_deref()
273 .is_some_and(|email| email.to_lowercase().contains(&query_lower))
274 })
275 .collect();
276
277 match Self::single_or_ambiguous(query, partial_matches)? {
278 Some(collaborator) => Ok(collaborator),
279 None => Err(SyncError::Validation(format!(
280 "No collaborator matching '{}' in project '{}'",
281 query, project_name
282 ))),
283 }
284 }
285
286 fn single_or_ambiguous<'a>(
287 query: &str,
288 matches: Vec<&'a Collaborator>,
289 ) -> SyncResult<Option<&'a Collaborator>> {
290 match matches.len() {
291 0 => Ok(None),
292 1 => Ok(matches.into_iter().next()),
293 _ => {
294 let mut names = matches
295 .iter()
296 .map(|collaborator| {
297 collaborator
298 .full_name
299 .as_deref()
300 .or(collaborator.email.as_deref())
301 .unwrap_or(collaborator.id.as_str())
302 .to_string()
303 })
304 .collect::<Vec<_>>();
305 names.sort_unstable();
306 names.dedup();
307
308 Err(SyncError::Validation(format!(
309 "Multiple collaborators match '{}': {}. Please be more specific.",
310 query,
311 names.join(", ")
312 )))
313 }
314 }
315 }
316
317 /// Resolves a section by name or ID, with auto-sync fallback.
318 ///
319 /// This method first attempts to find the section in the cache. If not found,
320 /// it performs a sync and retries the lookup.
321 ///
322 /// # Arguments
323 ///
324 /// * `name_or_id` - The section name (case-insensitive) or ID to search for
325 /// * `project_id` - Optional project ID to scope the search. If provided, only
326 /// sections in that project are considered for name matching.
327 ///
328 /// # Returns
329 ///
330 /// A reference to the matching `Section` from the cache.
331 ///
332 /// # Errors
333 ///
334 /// Returns `SyncError::NotFound` if the section cannot be found even after syncing.
335 /// Returns `SyncError::Api` if the sync operation fails.
336 ///
337 /// # Example
338 ///
339 /// ```no_run
340 /// use todoist_api_rs::client::TodoistClient;
341 /// use todoist_cache_rs::{CacheStore, SyncManager};
342 ///
343 /// #[tokio::main]
344 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
345 /// let client = TodoistClient::new("your-api-token")?;
346 /// let store = CacheStore::new()?;
347 /// let mut manager = SyncManager::new(client, store)?;
348 ///
349 /// // Find by name within a specific project
350 /// let section = manager.resolve_section("To Do", Some("12345678")).await?;
351 /// println!("Found section: {} ({})", section.name, section.id);
352 ///
353 /// // Find by ID (project_id is ignored for ID lookups)
354 /// let section = manager.resolve_section("87654321", None).await?;
355 /// println!("Found section: {}", section.name);
356 ///
357 /// Ok(())
358 /// }
359 /// ```
360 pub async fn resolve_section(
361 &mut self,
362 name_or_id: &str,
363 project_id: Option<&str>,
364 ) -> SyncResult<&Section> {
365 // Try cache first - if found, we can return early after sync check
366 let found_in_cache = self.find_section_in_cache(name_or_id, project_id).is_some();
367
368 if !found_in_cache {
369 // Not found - sync and retry
370 self.sync().await?;
371 }
372
373 // Return from cache (either was there or now present after sync)
374 self.find_section_in_cache(name_or_id, project_id)
375 .ok_or_else(|| {
376 // Find similar section names for suggestion (within same project if specified)
377 let suggestion = find_similar_name(
378 name_or_id,
379 self.cache()
380 .sections
381 .iter()
382 .filter(|s| {
383 !s.is_deleted && project_id.is_none_or(|pid| s.project_id == pid)
384 })
385 .map(|s| s.name.as_str()),
386 );
387 SyncError::NotFound {
388 resource_type: "Section",
389 identifier: name_or_id.to_string(),
390 suggestion,
391 }
392 })
393 }
394
395 /// Helper to find a section in the cache by name or ID.
396 ///
397 /// Searches for non-deleted sections where either:
398 /// - The ID matches exactly (ignores project_id filter)
399 /// - The name matches (case-insensitive) and optionally within the specified project
400 fn find_section_in_cache(
401 &self,
402 name_or_id: &str,
403 project_id: Option<&str>,
404 ) -> Option<&Section> {
405 let name_lower = name_or_id.to_lowercase();
406 self.cache().sections.iter().find(|s| {
407 if s.is_deleted {
408 return false;
409 }
410 // ID match takes precedence (ignores project filter)
411 if s.id == name_or_id {
412 return true;
413 }
414 // Name match with optional project filter
415 if s.name.to_lowercase() == name_lower {
416 return project_id.is_none_or(|pid| s.project_id == pid);
417 }
418 false
419 })
420 }
421
422 /// Resolves a label by name or ID, with auto-sync fallback.
423 ///
424 /// This method first attempts to find the label in the cache. If not found,
425 /// it performs a sync and retries the lookup.
426 ///
427 /// # Arguments
428 ///
429 /// * `name_or_id` - The label name (case-insensitive) or ID to search for
430 ///
431 /// # Returns
432 ///
433 /// A reference to the matching `Label` from the cache.
434 ///
435 /// # Errors
436 ///
437 /// Returns `SyncError::NotFound` if the label cannot be found even after syncing.
438 /// Returns `SyncError::Api` if the sync operation fails.
439 ///
440 /// # Example
441 ///
442 /// ```no_run
443 /// use todoist_api_rs::client::TodoistClient;
444 /// use todoist_cache_rs::{CacheStore, SyncManager};
445 ///
446 /// #[tokio::main]
447 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
448 /// let client = TodoistClient::new("your-api-token")?;
449 /// let store = CacheStore::new()?;
450 /// let mut manager = SyncManager::new(client, store)?;
451 ///
452 /// // Find by name (case-insensitive)
453 /// let label = manager.resolve_label("urgent").await?;
454 /// println!("Found label: {} ({})", label.name, label.id);
455 ///
456 /// // Find by ID
457 /// let label = manager.resolve_label("12345678").await?;
458 /// println!("Found label: {}", label.name);
459 ///
460 /// Ok(())
461 /// }
462 /// ```
463 pub async fn resolve_label(&mut self, name_or_id: &str) -> SyncResult<&Label> {
464 // Try cache first - if found, we can return early after sync check
465 let found_in_cache = self.find_label_in_cache(name_or_id).is_some();
466
467 if !found_in_cache {
468 // Not found - sync and retry
469 self.sync().await?;
470 }
471
472 // Return from cache (either was there or now present after sync)
473 self.find_label_in_cache(name_or_id).ok_or_else(|| {
474 // Find similar label names for suggestion
475 let suggestion = find_similar_name(
476 name_or_id,
477 self.cache()
478 .labels
479 .iter()
480 .filter(|l| !l.is_deleted)
481 .map(|l| l.name.as_str()),
482 );
483 SyncError::NotFound {
484 resource_type: "Label",
485 identifier: name_or_id.to_string(),
486 suggestion,
487 }
488 })
489 }
490
491 /// Helper to find a label in the cache by name or ID.
492 ///
493 /// Searches for non-deleted labels where either:
494 /// - The name matches (case-insensitive)
495 /// - The ID matches exactly
496 fn find_label_in_cache(&self, name_or_id: &str) -> Option<&Label> {
497 let name_lower = name_or_id.to_lowercase();
498 self.cache()
499 .labels
500 .iter()
501 .find(|l| !l.is_deleted && (l.name.to_lowercase() == name_lower || l.id == name_or_id))
502 }
503
504 /// Resolves an item (task) by ID, with auto-sync fallback.
505 ///
506 /// This method first attempts to find the item in the cache. If not found,
507 /// it performs a sync and retries the lookup.
508 ///
509 /// Note: Unlike projects, sections, and labels, items can only be looked up
510 /// by ID since task content is not guaranteed to be unique.
511 ///
512 /// # Arguments
513 ///
514 /// * `id` - The item ID to search for
515 ///
516 /// # Returns
517 ///
518 /// A reference to the matching `Item` from the cache.
519 ///
520 /// # Errors
521 ///
522 /// Returns `SyncError::NotFound` if the item cannot be found even after syncing.
523 /// Returns `SyncError::Api` if the sync operation fails.
524 ///
525 /// # Example
526 ///
527 /// ```no_run
528 /// use todoist_api_rs::client::TodoistClient;
529 /// use todoist_cache_rs::{CacheStore, SyncManager};
530 ///
531 /// #[tokio::main]
532 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
533 /// let client = TodoistClient::new("your-api-token")?;
534 /// let store = CacheStore::new()?;
535 /// let mut manager = SyncManager::new(client, store)?;
536 ///
537 /// // Find by ID
538 /// let item = manager.resolve_item("12345678").await?;
539 /// println!("Found item: {} ({})", item.content, item.id);
540 ///
541 /// Ok(())
542 /// }
543 /// ```
544 pub async fn resolve_item(&mut self, id: &str) -> SyncResult<&Item> {
545 // Try cache first - if found, we can return early after sync check
546 let found_in_cache = self.find_item_in_cache(id).is_some();
547
548 if !found_in_cache {
549 // Not found - sync and retry
550 self.sync().await?;
551 }
552
553 // Return from cache (either was there or now present after sync)
554 self.find_item_in_cache(id)
555 .ok_or_else(|| SyncError::NotFound {
556 resource_type: "Item",
557 identifier: id.to_string(),
558 suggestion: None, // Items are looked up by ID, no name suggestions
559 })
560 }
561
562 /// Helper to find an item in the cache by ID.
563 ///
564 /// Searches for non-deleted items where the ID matches exactly.
565 fn find_item_in_cache(&self, id: &str) -> Option<&Item> {
566 self.cache()
567 .items
568 .iter()
569 .find(|i| !i.is_deleted && i.id == id)
570 }
571
572 /// Resolves an item (task) by ID or unique prefix, with auto-sync fallback.
573 ///
574 /// This method first attempts to find the item in the cache by exact ID match
575 /// or unique prefix. If not found, it performs a sync and retries the lookup.
576 ///
577 /// # Arguments
578 ///
579 /// * `id_or_prefix` - The item ID or unique prefix to search for
580 /// * `require_checked` - If `Some(true)`, only match completed items.
581 /// If `Some(false)`, only match uncompleted items.
582 /// If `None`, match any item regardless of completion status.
583 ///
584 /// # Returns
585 ///
586 /// A reference to the matching `Item` from the cache.
587 ///
588 /// # Errors
589 ///
590 /// Returns `SyncError::NotFound` if the item cannot be found even after syncing.
591 /// Returns `SyncError::Api` if the sync operation fails.
592 ///
593 /// # Example
594 ///
595 /// ```no_run
596 /// use todoist_api_rs::client::TodoistClient;
597 /// use todoist_cache_rs::{CacheStore, SyncManager};
598 ///
599 /// #[tokio::main]
600 /// async fn main() -> Result<(), Box<dyn std::error::Error>> {
601 /// let client = TodoistClient::new("your-api-token")?;
602 /// let store = CacheStore::new()?;
603 /// let mut manager = SyncManager::new(client, store)?;
604 ///
605 /// // Find uncompleted task by ID prefix
606 /// let item = manager.resolve_item_by_prefix("abc123", Some(false)).await?;
607 /// println!("Found item: {} ({})", item.content, item.id);
608 ///
609 /// // Find any task by prefix (completed or not)
610 /// let item = manager.resolve_item_by_prefix("def456", None).await?;
611 /// println!("Found item: {}", item.content);
612 ///
613 /// Ok(())
614 /// }
615 /// ```
616 pub async fn resolve_item_by_prefix(
617 &mut self,
618 id_or_prefix: &str,
619 require_checked: Option<bool>,
620 ) -> SyncResult<&Item> {
621 // Check cache status first (without borrowing the result)
622 let cache_status = match self.find_item_by_prefix_in_cache(id_or_prefix, require_checked) {
623 ItemLookupResult::Found(_) => CacheLookupStatus::Found,
624 ItemLookupResult::Ambiguous(msg) => CacheLookupStatus::Ambiguous(msg),
625 ItemLookupResult::NotFound => CacheLookupStatus::NotFound,
626 };
627
628 // Handle ambiguous case early (no sync needed)
629 if let CacheLookupStatus::Ambiguous(msg) = cache_status {
630 return Err(SyncError::NotFound {
631 resource_type: "Item",
632 identifier: msg,
633 suggestion: None,
634 });
635 }
636
637 // If not found, sync first
638 if matches!(cache_status, CacheLookupStatus::NotFound) {
639 self.sync().await?;
640 }
641
642 // Now return from cache
643 match self.find_item_by_prefix_in_cache(id_or_prefix, require_checked) {
644 ItemLookupResult::Found(item) => Ok(item),
645 ItemLookupResult::Ambiguous(msg) => Err(SyncError::NotFound {
646 resource_type: "Item",
647 identifier: msg,
648 suggestion: None,
649 }),
650 ItemLookupResult::NotFound => Err(SyncError::NotFound {
651 resource_type: "Item",
652 identifier: id_or_prefix.to_string(),
653 suggestion: None, // Items are looked up by ID, no name suggestions
654 }),
655 }
656 }
657
658 /// Helper to find an item in the cache by ID or unique prefix.
659 ///
660 /// Returns the found item, an ambiguity error message, or not found.
661 fn find_item_by_prefix_in_cache(
662 &self,
663 id_or_prefix: &str,
664 require_checked: Option<bool>,
665 ) -> ItemLookupResult<'_> {
666 // First try exact match
667 if let Some(item) = self.cache().items.iter().find(|i| {
668 !i.is_deleted
669 && i.id == id_or_prefix
670 && require_checked.is_none_or(|checked| i.checked == checked)
671 }) {
672 return ItemLookupResult::Found(item);
673 }
674
675 // Try prefix match
676 let matches: Vec<&Item> = self
677 .cache()
678 .items
679 .iter()
680 .filter(|i| {
681 !i.is_deleted
682 && i.id.starts_with(id_or_prefix)
683 && require_checked.is_none_or(|checked| i.checked == checked)
684 })
685 .collect();
686
687 match matches.len() {
688 0 => ItemLookupResult::NotFound,
689 1 => ItemLookupResult::Found(matches[0]),
690 _ => {
691 // Ambiguous prefix - provide helpful error message
692 let mut msg = format!(
693 "Ambiguous task ID \"{}\"\n\nMultiple tasks match this prefix:",
694 id_or_prefix
695 );
696 for item in matches.iter().take(5) {
697 let prefix = &item.id[..6.min(item.id.len())];
698 msg.push_str(&format!("\n {} {}", prefix, item.content));
699 }
700 if matches.len() > 5 {
701 msg.push_str(&format!("\n ... and {} more", matches.len() - 5));
702 }
703 msg.push_str("\n\nPlease use a longer prefix.");
704 ItemLookupResult::Ambiguous(msg)
705 }
706 }
707 }
708}