Skip to main content

ta_changeset/
draft_resolver.rs

1//! Draft ID resolution — the single authoritative function for turning any
2//! user-supplied ID string into a concrete [`DraftPackage`].
3//!
4//! # Resolution order
5//!
6//! 1. Exact UUID (`cbda7f5f-4a19-4752-bea4-802af93fc020`)
7//! 2. Shortref/seq (`6ebf85ab/1`) — goal 8-char prefix + draft sequence number
8//! 3. Legacy display_id (`cbda7f5f-1`)
9//! 4. UUID prefix — unambiguous prefix of ≥4 chars (error if ambiguous)
10//! 5. 8-char all-hex — resolves to the latest draft for that goal shortref
11//! 6. Tag match
12//!
13//! All draft subcommands route through [`resolve_draft`] so that every ID
14//! format surfaced in `ta draft list` is accepted as input by every command.
15
16use crate::draft_package::DraftPackage;
17use uuid::Uuid;
18
19/// Error returned when a draft ID cannot be resolved.
20#[derive(Debug, Clone)]
21pub enum DraftResolveError {
22    /// Nothing matched the provided ID.
23    ///
24    /// `hint` lists candidate short IDs and titles to help the user.
25    NotFound { input: String, hint: String },
26    /// The provided prefix matches more than one draft.
27    ///
28    /// `candidates` is a list of `"<short_id>  <title>"` strings.
29    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
56/// Resolve a user-supplied draft ID to the matching [`DraftPackage`].
57///
58/// Accepts:
59/// - Full UUID
60/// - Shortref/seq (`6ebf85ab/1`)
61/// - Legacy display_id prefix (e.g. `cbda7f5f-1`)
62/// - UUID prefix (≥4 chars, unambiguous)
63/// - 8-char all-hex goal shortref (resolves to the latest draft for that goal)
64/// - Tag match
65///
66/// Returns a reference into `packages`, or a [`DraftResolveError`].
67pub 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    // ── 1. Exact UUID ──────────────────────────────────────────────────────
77    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    // ── 2. Shortref/seq (`<8hex>/<N>`) ────────────────────────────────────
85    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                        // Should not happen (seq is unique per goal), but handle gracefully.
99                        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        // `/` present but doesn't look like shortref/seq — fall through to other matchers.
114    }
115
116    // ── 3. Legacy display_id match (`cbda7f5f-1` or prefix thereof) ───────
117    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    // ── 4. UUID prefix match ───────────────────────────────────────────────
140    // Require ≥4 chars to avoid accidental broad matches.
141    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    // ── 5. 8-char all-hex: latest draft for that goal shortref ────────────
162    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    // ── 6. Tag match ──────────────────────────────────────────────────────
177    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
202/// Return the canonical display ID for a draft — the string that `resolve_draft`
203/// will accept back as input.
204///
205/// Prefers `<goal_shortref>/<draft_seq>` (shortest and most human-friendly),
206/// then falls back to `display_id`, then to the first 8 chars of the UUID.
207pub 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); // seq=0 means not set
304        pkg.display_id = Some("aabbccdd-01".to_string());
305        assert_eq!(draft_canonical_id(&pkg), "aabbccdd-01");
306    }
307}