ta_changeset/
draft_resolver.rs1use crate::draft_package::DraftPackage;
17use uuid::Uuid;
18
19#[derive(Debug, Clone)]
21pub enum DraftResolveError {
22 NotFound { input: String, hint: String },
26 Ambiguous {
30 input: String,
31 candidates: Vec<String>,
32 },
33}
34
35impl std::fmt::Display for DraftResolveError {
36 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
37 match self {
38 DraftResolveError::NotFound { input, hint } => {
39 write!(f, "No draft matching \"{}\". {}", input, hint)
40 }
41 DraftResolveError::Ambiguous { input, candidates } => {
42 write!(
43 f,
44 "Ambiguous ID \"{}\" matches {} drafts:\n {}\nSpecify more characters.",
45 input,
46 candidates.len(),
47 candidates.join("\n ")
48 )
49 }
50 }
51 }
52}
53
54impl std::error::Error for DraftResolveError {}
55
56pub fn resolve_draft<'a>(
68 packages: &'a [DraftPackage],
69 id: &str,
70) -> Result<&'a DraftPackage, DraftResolveError> {
71 let not_found = |hint: &str| DraftResolveError::NotFound {
72 input: id.to_string(),
73 hint: hint.to_string(),
74 };
75
76 if let Ok(uuid) = Uuid::parse_str(id) {
78 return packages
79 .iter()
80 .find(|p| p.package_id == uuid)
81 .ok_or_else(|| not_found("Run `ta draft list` to see available drafts."));
82 }
83
84 if let Some((shortref_part, seq_part)) = id.split_once('/') {
86 if shortref_part.len() == 8 && shortref_part.chars().all(|c| c.is_ascii_hexdigit()) {
87 if let Ok(seq) = seq_part.parse::<u32>() {
88 let matched: Vec<&DraftPackage> = packages
89 .iter()
90 .filter(|p| {
91 p.goal_shortref.as_deref() == Some(shortref_part) && p.draft_seq == seq
92 })
93 .collect();
94 return match matched.len() {
95 0 => Err(not_found("Run `ta draft list` to see available drafts.")),
96 1 => Ok(matched[0]),
97 _ => {
98 let candidates: Vec<String> = matched
100 .iter()
101 .map(|p| {
102 format!("{} {}", &p.package_id.to_string()[..8], p.goal.title)
103 })
104 .collect();
105 Err(DraftResolveError::Ambiguous {
106 input: id.to_string(),
107 candidates,
108 })
109 }
110 };
111 }
112 }
113 }
115
116 let display_matches: Vec<&DraftPackage> = packages
118 .iter()
119 .filter(|p| {
120 p.display_id
121 .as_deref()
122 .is_some_and(|did| did == id || did.starts_with(id))
123 })
124 .collect();
125 if display_matches.len() == 1 {
126 return Ok(display_matches[0]);
127 }
128 if display_matches.len() > 1 {
129 let candidates: Vec<String> = display_matches
130 .iter()
131 .map(|p| format!("{} {}", &p.package_id.to_string()[..8], p.goal.title))
132 .collect();
133 return Err(DraftResolveError::Ambiguous {
134 input: id.to_string(),
135 candidates,
136 });
137 }
138
139 if id.len() >= 4 && id.chars().all(|c| c.is_ascii_hexdigit() || c == '-') && !id.contains('/') {
142 let prefix_matches: Vec<&DraftPackage> = packages
143 .iter()
144 .filter(|p| p.package_id.to_string().starts_with(id))
145 .collect();
146 if prefix_matches.len() == 1 {
147 return Ok(prefix_matches[0]);
148 }
149 if prefix_matches.len() > 1 {
150 let candidates: Vec<String> = prefix_matches
151 .iter()
152 .map(|p| format!("{} {}", &p.package_id.to_string()[..8], p.goal.title))
153 .collect();
154 return Err(DraftResolveError::Ambiguous {
155 input: id.to_string(),
156 candidates,
157 });
158 }
159 }
160
161 if id.len() == 8 && id.chars().all(|c| c.is_ascii_hexdigit()) {
163 let shortref_matches: Vec<&DraftPackage> = packages
164 .iter()
165 .filter(|p| p.goal_shortref.as_deref() == Some(id))
166 .collect();
167 if !shortref_matches.is_empty() {
168 let latest = shortref_matches
169 .iter()
170 .max_by_key(|p| p.created_at)
171 .unwrap();
172 return Ok(latest);
173 }
174 }
175
176 let tag_matches: Vec<&DraftPackage> = packages
178 .iter()
179 .filter(|p| {
180 p.tag
181 .as_deref()
182 .is_some_and(|t| t == id || t.starts_with(id))
183 })
184 .collect();
185 if tag_matches.len() == 1 {
186 return Ok(tag_matches[0]);
187 }
188 if tag_matches.len() > 1 {
189 let candidates: Vec<String> = tag_matches
190 .iter()
191 .map(|p| format!("{} {}", &p.package_id.to_string()[..8], p.goal.title))
192 .collect();
193 return Err(DraftResolveError::Ambiguous {
194 input: id.to_string(),
195 candidates,
196 });
197 }
198
199 Err(not_found("Run `ta draft list` to see available drafts."))
200}
201
202pub fn draft_canonical_id(pkg: &DraftPackage) -> String {
208 if let (Some(shortref), seq) = (&pkg.goal_shortref, pkg.draft_seq) {
209 if seq > 0 {
210 return format!("{}/{}", shortref, seq);
211 }
212 }
213 pkg.display_id
214 .as_deref()
215 .unwrap_or(&pkg.package_id.to_string()[..8])
216 .to_string()
217}
218
219#[cfg(test)]
220mod tests {
221 use super::*;
222 use crate::draft_package::make_test_pkg;
223
224 #[test]
225 fn resolve_by_full_uuid() {
226 let pkg = make_test_pkg("aabbccdd", 1);
227 let id = pkg.package_id.to_string();
228 let packages = vec![pkg];
229 let result = resolve_draft(&packages, &id);
230 assert!(result.is_ok());
231 assert_eq!(result.unwrap().package_id.to_string(), id);
232 }
233
234 #[test]
235 fn resolve_by_shortref_seq() {
236 let pkg = make_test_pkg("aabbccdd", 1);
237 let packages = vec![pkg];
238 let result = resolve_draft(&packages, "aabbccdd/1");
239 assert!(result.is_ok());
240 let found = result.unwrap();
241 assert_eq!(found.goal_shortref.as_deref(), Some("aabbccdd"));
242 assert_eq!(found.draft_seq, 1);
243 }
244
245 #[test]
246 fn resolve_by_shortref_seq_second_draft() {
247 let pkg1 = make_test_pkg("aabbccdd", 1);
248 let mut pkg2 = make_test_pkg("aabbccdd", 2);
249 pkg2.created_at = chrono::Utc::now() + chrono::Duration::seconds(5);
250 let packages = vec![pkg1, pkg2];
251 let result = resolve_draft(&packages, "aabbccdd/2");
252 assert!(result.is_ok());
253 assert_eq!(result.unwrap().draft_seq, 2);
254 }
255
256 #[test]
257 fn resolve_by_8char_shortref_returns_latest() {
258 let pkg1 = make_test_pkg("aabbccdd", 1);
259 let mut pkg2 = make_test_pkg("aabbccdd", 2);
260 pkg2.created_at = chrono::Utc::now() + chrono::Duration::seconds(5);
261 let packages = vec![pkg1, pkg2];
262 let result = resolve_draft(&packages, "aabbccdd");
263 assert!(result.is_ok());
264 assert_eq!(result.unwrap().draft_seq, 2);
265 }
266
267 #[test]
268 fn resolve_by_uuid_prefix() {
269 let pkg = make_test_pkg("aabbccdd", 1);
270 let prefix = pkg.package_id.to_string()[..8].to_string();
271 let packages = vec![pkg];
272 let result = resolve_draft(&packages, &prefix);
273 assert!(result.is_ok());
274 }
275
276 #[test]
277 fn resolve_ambiguous_tag_errors() {
278 let mut pkg1 = make_test_pkg("11223344", 1);
279 pkg1.tag = Some("my-tag".to_string());
280 let mut pkg2 = make_test_pkg("55667788", 1);
281 pkg2.tag = Some("my-tag".to_string());
282 let packages = vec![pkg1, pkg2];
283 let result = resolve_draft(&packages, "my-tag");
284 assert!(matches!(result, Err(DraftResolveError::Ambiguous { .. })));
285 }
286
287 #[test]
288 fn resolve_unknown_id_returns_not_found() {
289 let pkg = make_test_pkg("aabbccdd", 1);
290 let packages = vec![pkg];
291 let result = resolve_draft(&packages, "ffffffff/99");
292 assert!(matches!(result, Err(DraftResolveError::NotFound { .. })));
293 }
294
295 #[test]
296 fn draft_canonical_id_prefers_shortref_seq() {
297 let pkg = make_test_pkg("aabbccdd", 3);
298 assert_eq!(draft_canonical_id(&pkg), "aabbccdd/3");
299 }
300
301 #[test]
302 fn draft_canonical_id_falls_back_to_display_id() {
303 let mut pkg = make_test_pkg("aabbccdd", 0); pkg.display_id = Some("aabbccdd-01".to_string());
305 assert_eq!(draft_canonical_id(&pkg), "aabbccdd-01");
306 }
307}