Skip to main content

todoapp_core/
short_id.rs

1//! Git/jj-style id abbreviation: pure prefix logic shared by every adapter
2//! that lets a human type or display an [`Id`] (TUI column, future CLI
3//! subcommands) so both resolve prefixes the same way.
4
5use std::collections::HashMap;
6
7use crate::model::Id;
8
9/// Shortest prefix of each id that's unique among `ids` — the length is
10/// picked so the prefix differs from both lexical neighbours once sorted.
11pub fn shortest_unique_prefixes(ids: &[Id]) -> HashMap<Id, String> {
12    let mut sorted: Vec<&Id> = ids.iter().collect();
13    sorted.sort();
14    sorted
15        .iter()
16        .enumerate()
17        .map(|(i, id)| {
18            let len = [i.checked_sub(1), (i + 1 < sorted.len()).then_some(i + 1)]
19                .into_iter()
20                .flatten()
21                .map(|j| common_prefix_len(id.as_str(), sorted[j].as_str()) + 1)
22                .max()
23                .unwrap_or(1)
24                .min(id.as_str().len());
25            ((*id).clone(), id.as_str()[..len].to_string())
26        })
27        .collect()
28}
29
30fn common_prefix_len(a: &str, b: &str) -> usize {
31    a.bytes().zip(b.bytes()).take_while(|(x, y)| x == y).count()
32}
33
34/// Result of resolving a user-typed id or prefix against the full id set.
35pub enum ResolvedId {
36    Found(Id),
37    NotFound,
38    Ambiguous(Vec<Id>),
39}
40
41/// Resolve a typed id/prefix (e.g. from a CLI arg or TUI lookup) the way
42/// git/jj do: an exact full-id match always wins, even if it's also a prefix
43/// of other ids; otherwise the prefix must match exactly one id.
44/// Case-insensitive: ids are generated lowercase, but older stores may still
45/// hold uppercase ULIDs, and typing case shouldn't matter either way.
46pub fn resolve_id_prefix(ids: &[Id], typed: &str) -> ResolvedId {
47    let typed = typed.to_lowercase();
48    if let Some(id) = ids
49        .iter()
50        .find(|id| id.as_str().eq_ignore_ascii_case(&typed))
51    {
52        return ResolvedId::Found(id.clone());
53    }
54    let matches: Vec<Id> = ids
55        .iter()
56        .filter(|id| id.as_str().to_lowercase().starts_with(&typed))
57        .cloned()
58        .collect();
59    match matches.len() {
60        0 => ResolvedId::NotFound,
61        1 => ResolvedId::Found(matches.into_iter().next().unwrap_or_else(Id::root)),
62        _ => ResolvedId::Ambiguous(matches),
63    }
64}
65
66#[cfg(test)]
67mod tests {
68    use super::*;
69
70    #[test]
71    fn shortest_unique_prefixes_distinguishes_shared_start() {
72        let ids = vec![Id::new("abc123"), Id::new("abc456"), Id::new("xyz789")];
73        let short = shortest_unique_prefixes(&ids);
74        assert_eq!(short[&Id::new("abc123")], "abc1");
75        assert_eq!(short[&Id::new("abc456")], "abc4");
76        assert_eq!(short[&Id::new("xyz789")], "x");
77    }
78
79    #[test]
80    fn resolve_prefix_exact_match_wins_over_being_a_prefix() {
81        let ids = vec![Id::new("abc"), Id::new("abcdef")];
82        assert!(matches!(
83            resolve_id_prefix(&ids, "abc"),
84            ResolvedId::Found(id) if id == Id::new("abc")
85        ));
86    }
87
88    #[test]
89    fn resolve_prefix_ambiguous_when_multiple_match() {
90        let ids = vec![Id::new("abc123"), Id::new("abc456")];
91        assert!(matches!(
92            resolve_id_prefix(&ids, "abc"),
93            ResolvedId::Ambiguous(m) if m.len() == 2
94        ));
95    }
96
97    #[test]
98    fn resolve_prefix_not_found() {
99        let ids = vec![Id::new("abc123")];
100        assert!(matches!(
101            resolve_id_prefix(&ids, "zzz"),
102            ResolvedId::NotFound
103        ));
104    }
105
106    #[test]
107    fn resolve_prefix_is_case_insensitive() {
108        // Ids are generated lowercase, but older stores may still hold
109        // uppercase ULIDs — typing case must not matter either way.
110        let ids = vec![Id::new("ABC123")];
111        assert!(matches!(
112            resolve_id_prefix(&ids, "abc"),
113            ResolvedId::Found(id) if id == Id::new("ABC123")
114        ));
115        assert!(matches!(
116            resolve_id_prefix(&ids, "ABC123"),
117            ResolvedId::Found(id) if id == Id::new("ABC123")
118        ));
119    }
120}