Skip to main content

wt/
query.rs

1//! Query resolution (spec §7): match a query against a set of worktrees in a
2//! defined precedence order, reporting a unique match, ambiguity, or no match.
3
4use crate::model::Worktree;
5
6/// The outcome of resolving a query against a worktree set. Indices refer into
7/// the slice passed to [`resolve`].
8#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum Resolved {
10    /// Exactly one worktree matched (its index).
11    One(usize),
12    /// Several worktrees matched (their indices); the query is ambiguous.
13    Ambiguous(Vec<usize>),
14    /// No worktree matched.
15    NotFound,
16}
17
18/// The directory name of a worktree (the last path component).
19fn dir_name(worktree: &Worktree) -> Option<String> {
20    worktree
21        .path
22        .file_name()
23        .map(|n| n.to_string_lossy().into_owned())
24}
25
26/// Resolves `query` against `worktrees` (spec §7): exact branch, then exact
27/// slug, then exact directory name; then an unambiguous prefix across all three.
28/// The first tier with any match wins; if that tier has more than one match the
29/// query is ambiguous.
30pub fn resolve(worktrees: &[Worktree], query: &str) -> Resolved {
31    let exact = |pick: &dyn Fn(&Worktree) -> Option<String>| -> Vec<usize> {
32        worktrees
33            .iter()
34            .enumerate()
35            .filter(|(_, w)| pick(w).as_deref() == Some(query))
36            .map(|(i, _)| i)
37            .collect()
38    };
39
40    for matches in [
41        exact(&|w| w.branch.clone()),
42        exact(&|w| w.slug.clone()),
43        exact(&dir_name),
44    ] {
45        if !matches.is_empty() {
46            return decide(matches);
47        }
48    }
49
50    // Tier 2: unambiguous prefix across branch / slug / directory name.
51    let starts = |value: Option<String>| value.as_deref().is_some_and(|v| v.starts_with(query));
52    let prefix: Vec<usize> = worktrees
53        .iter()
54        .enumerate()
55        .filter(|(_, w)| starts(w.branch.clone()) || starts(w.slug.clone()) || starts(dir_name(w)))
56        .map(|(i, _)| i)
57        .collect();
58    if prefix.is_empty() {
59        Resolved::NotFound
60    } else {
61        decide(prefix)
62    }
63}
64
65/// Maps a non-empty match list to [`Resolved::One`] or [`Resolved::Ambiguous`].
66fn decide(matches: Vec<usize>) -> Resolved {
67    if matches.len() == 1 {
68        Resolved::One(matches[0])
69    } else {
70        Resolved::Ambiguous(matches)
71    }
72}
73
74#[cfg(test)]
75mod tests {
76    use super::*;
77    use std::path::PathBuf;
78
79    fn wt(path: &str, branch: Option<&str>, slug: Option<&str>) -> Worktree {
80        let mut w = Worktree::new(PathBuf::from(path));
81        w.branch = branch.map(str::to_string);
82        w.slug = slug.map(str::to_string);
83        w
84    }
85
86    fn set() -> Vec<Worktree> {
87        vec![
88            wt("/r/main", Some("main"), Some("main")),
89            wt(
90                "/r/feature-login",
91                Some("feature/login"),
92                Some("feature-login"),
93            ),
94            wt(
95                "/r/feature-logout",
96                Some("feature/logout"),
97                Some("feature-logout"),
98            ),
99            wt("/r/detached", None, None),
100        ]
101    }
102
103    #[test]
104    fn exact_branch_wins() {
105        assert_eq!(resolve(&set(), "feature/login"), Resolved::One(1));
106        assert_eq!(resolve(&set(), "main"), Resolved::One(0));
107    }
108
109    #[test]
110    fn exact_slug_matches() {
111        assert_eq!(resolve(&set(), "feature-logout"), Resolved::One(2));
112    }
113
114    #[test]
115    fn exact_dir_name_matches() {
116        assert_eq!(resolve(&set(), "detached"), Resolved::One(3));
117    }
118
119    #[test]
120    fn unambiguous_prefix_matches() {
121        // "feature/log" is a prefix of both feature/login and feature/logout.
122        assert_eq!(resolve(&set(), "feature/login"), Resolved::One(1));
123        // "feature-login" exact-slug beats prefix.
124        assert_eq!(resolve(&set(), "feature-login"), Resolved::One(1));
125        // A prefix that hits only one.
126        assert_eq!(resolve(&set(), "feature/logi"), Resolved::One(1));
127    }
128
129    #[test]
130    fn ambiguous_prefix_lists_candidates() {
131        match resolve(&set(), "feature/log") {
132            Resolved::Ambiguous(ix) => assert_eq!(ix, vec![1, 2]),
133            other => panic!("expected ambiguous, got {other:?}"),
134        }
135        // The slug prefix "feature-log" is also ambiguous.
136        match resolve(&set(), "feature-log") {
137            Resolved::Ambiguous(ix) => assert_eq!(ix, vec![1, 2]),
138            other => panic!("expected ambiguous, got {other:?}"),
139        }
140    }
141
142    #[test]
143    fn no_match_is_not_found() {
144        assert_eq!(resolve(&set(), "nonexistent"), Resolved::NotFound);
145    }
146
147    #[test]
148    fn exact_tier_does_not_fall_through_to_prefix() {
149        // Two worktrees share an exact slug -> ambiguous at the exact tier,
150        // never reaching prefix matching.
151        let worktrees = vec![
152            wt("/r/a", Some("topic-a"), Some("dup")),
153            wt("/r/b", Some("topic-b"), Some("dup")),
154        ];
155        match resolve(&worktrees, "dup") {
156            Resolved::Ambiguous(ix) => assert_eq!(ix, vec![0, 1]),
157            other => panic!("expected ambiguous, got {other:?}"),
158        }
159    }
160
161    #[test]
162    fn prefix_matches_on_slug_alone() {
163        // A worktree whose slug — but neither its branch nor its directory name —
164        // prefix-matches the query must still resolve. The three prefix checks
165        // are independent alternatives, not a conjunction.
166        let worktrees = vec![
167            wt("/r/main", Some("main"), Some("main")),
168            wt("/r/alpha", Some("alpha"), Some("zztop")),
169        ];
170        assert_eq!(resolve(&worktrees, "zz"), Resolved::One(1));
171    }
172}