Skip to main content

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}