1use crate::model::Worktree;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
9pub enum Resolved {
10 One(usize),
12 Ambiguous(Vec<usize>),
14 NotFound,
16}
17
18fn dir_name(worktree: &Worktree) -> Option<String> {
20 worktree
21 .path
22 .file_name()
23 .map(|n| n.to_string_lossy().into_owned())
24}
25
26pub 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 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
65fn 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 assert_eq!(resolve(&set(), "feature/login"), Resolved::One(1));
123 assert_eq!(resolve(&set(), "feature-login"), Resolved::One(1));
125 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 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 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 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}