Skip to main content

git_paw/specs/
resolve.rs

1//! Spec name resolution for the `--specs NAME[,NAME...]` narrow flag.
2//!
3//! Maps user-supplied names against the discovered `SpecEntry` set returned
4//! by `scan_specs()` using a layered matching strategy:
5//!
6//! 1. **Exact match** on `SpecEntry.id`.
7//! 2. **Spec Kit feature-name match** — every `SpecEntry.id` whose feature
8//!    prefix equals the requested name.
9//! 3. **Spec Kit numeric prefix match** — digit-leading values match a unique
10//!    feature directory whose name starts with `<digits>` followed by a
11//!    non-digit boundary. Ambiguous matches are rejected.
12//!
13//! Resolution fails (no partial success) when any requested name is unknown
14//! or matches more than one feature ambiguously.
15
16use crate::error::PawError;
17use crate::specs::SpecEntry;
18
19/// Resolves the `--specs NAME[,NAME...]` values against the discovered set.
20///
21/// Returns the union of entries that match any of the supplied names. Order
22/// follows the discovered-set order; duplicates (same `SpecEntry.id`) are
23/// retained only on first appearance.
24///
25/// # Errors
26///
27/// Returns `PawError::SpecError` when:
28/// - Any requested name does not match an exact id, a feature, or an
29///   unambiguous numeric prefix.
30/// - A numeric prefix matches more than one feature.
31///
32/// The error message lists the unresolved or ambiguous names AND the
33/// discovered-set identifier list so the user can correct quickly.
34pub fn resolve_specs(entries: &[SpecEntry], names: &[String]) -> Result<Vec<SpecEntry>, PawError> {
35    let mut unknown: Vec<String> = Vec::new();
36    let mut ambiguous: Vec<(String, Vec<String>)> = Vec::new();
37    let mut selected_indices: Vec<usize> = Vec::new();
38
39    for name in names {
40        match match_name(entries, name) {
41            MatchResult::Indices(idxs) => {
42                for idx in idxs {
43                    if !selected_indices.contains(&idx) {
44                        selected_indices.push(idx);
45                    }
46                }
47            }
48            MatchResult::Unknown => unknown.push(name.clone()),
49            MatchResult::Ambiguous(features) => ambiguous.push((name.clone(), features)),
50        }
51    }
52
53    if let Some((prefix, candidates)) = ambiguous.first() {
54        return Err(PawError::SpecError(format!(
55            "spec name '{prefix}' is ambiguous; matches: {}\n  \
56             Run `git paw start --specs <full-name>` to disambiguate.",
57            candidates.join(", ")
58        )));
59    }
60
61    if !unknown.is_empty() {
62        let discovered: Vec<&str> = entries.iter().map(|e| e.id.as_str()).collect();
63        return Err(PawError::SpecError(format!(
64            "spec(s) not found: {}\n  \
65             Discovered specs: {}\n  \
66             Run `git paw start --specs` for an interactive picker.",
67            unknown.join(", "),
68            discovered.join(", ")
69        )));
70    }
71
72    Ok(selected_indices
73        .into_iter()
74        .map(|i| entries[i].clone())
75        .collect())
76}
77
78enum MatchResult {
79    Indices(Vec<usize>),
80    Unknown,
81    Ambiguous(Vec<String>),
82}
83
84fn match_name(entries: &[SpecEntry], name: &str) -> MatchResult {
85    if let Some(idx) = entries.iter().position(|e| e.id == name) {
86        return MatchResult::Indices(vec![idx]);
87    }
88
89    // Pure-numeric names are reserved for the prefix-match path; they must
90    // not collide with the feature-name match below (otherwise "003" would
91    // greedily match every "003-*" entry without surfacing ambiguity across
92    // multiple "003*-" features).
93    if !is_numeric_prefix(name) {
94        let feature_matches: Vec<usize> = entries
95            .iter()
96            .enumerate()
97            .filter(|(_, e)| is_feature_match(&e.id, name))
98            .map(|(i, _)| i)
99            .collect();
100        if !feature_matches.is_empty() {
101            return MatchResult::Indices(feature_matches);
102        }
103        return MatchResult::Unknown;
104    }
105
106    let features = collect_feature_ids_with_prefix(entries, name);
107    match features.len() {
108        0 => MatchResult::Unknown,
109        1 => {
110            let feature = &features[0];
111            let idxs: Vec<usize> = entries
112                .iter()
113                .enumerate()
114                .filter(|(_, e)| is_feature_match(&e.id, feature))
115                .map(|(i, _)| i)
116                .collect();
117            if idxs.is_empty() {
118                MatchResult::Unknown
119            } else {
120                MatchResult::Indices(idxs)
121            }
122        }
123        _ => MatchResult::Ambiguous(features),
124    }
125}
126
127/// True when `id` belongs to feature `feature`: either `id == feature`, or
128/// `id` is `feature` followed by `-<decomposition-suffix>`.
129fn is_feature_match(id: &str, feature: &str) -> bool {
130    if id == feature {
131        return true;
132    }
133    id.strip_prefix(feature)
134        .is_some_and(|rest| rest.starts_with('-'))
135}
136
137/// True when `name` is a non-empty digits-only string (e.g. `003`).
138fn is_numeric_prefix(name: &str) -> bool {
139    !name.is_empty() && name.chars().all(|c| c.is_ascii_digit())
140}
141
142/// Returns the deduplicated feature ids whose feature-id begins with
143/// `prefix` followed by a non-digit boundary (so `003` does not match
144/// `0034-…`).
145fn collect_feature_ids_with_prefix(entries: &[SpecEntry], prefix: &str) -> Vec<String> {
146    let mut out: Vec<String> = Vec::new();
147    for entry in entries {
148        let feature = feature_id_of(&entry.id);
149        let Some(rest) = feature.strip_prefix(prefix) else {
150            continue;
151        };
152        let bounded = rest.chars().next().is_none_or(|c| !c.is_ascii_digit());
153        if bounded && !out.contains(&feature) {
154            out.push(feature);
155        }
156    }
157    out
158}
159
160/// Extracts the feature id (the leading `<digits>-<slug>` portion) from a
161/// Spec Kit entry id. Falls back to the full id when the shape doesn't
162/// match the expected decomposition (`<feature>-T<digits>` or
163/// `<feature>-phase-<digits>`).
164///
165/// Examples:
166/// - `003-user-list-T009` → `003-user-list`
167/// - `003-user-list-phase-2` → `003-user-list`
168/// - `add-auth` → `add-auth`
169/// - `003a-experiment-T001` → `003a-experiment`
170fn feature_id_of(id: &str) -> String {
171    if let Some((before, after)) = id.rsplit_once("-phase-")
172        && !after.is_empty()
173        && after.chars().all(|c| c.is_ascii_digit())
174    {
175        return before.to_string();
176    }
177    if let Some((before, after)) = id.rsplit_once("-T")
178        && !after.is_empty()
179        && after.chars().all(|c| c.is_ascii_digit())
180    {
181        return before.to_string();
182    }
183    id.to_string()
184}
185
186#[cfg(test)]
187mod tests {
188    use super::*;
189
190    fn entry(id: &str) -> SpecEntry {
191        SpecEntry {
192            id: id.to_string(),
193            backend: crate::specs::SpecBackendKind::Markdown,
194            branch: format!("spec/{id}"),
195            cli: None,
196            prompt: String::new(),
197            owned_files: None,
198        }
199    }
200
201    #[test]
202    fn exact_match_returns_single_entry() {
203        let entries = vec![entry("add-auth"), entry("fix-session")];
204        let out = resolve_specs(&entries, &["add-auth".to_string()]).unwrap();
205        assert_eq!(out.len(), 1);
206        assert_eq!(out[0].id, "add-auth");
207    }
208
209    #[test]
210    fn exact_match_on_spec_kit_decomposed_id() {
211        let entries = vec![
212            entry("003-user-list-T009"),
213            entry("003-user-list-T010"),
214            entry("003-user-list-phase-2"),
215        ];
216        let out = resolve_specs(&entries, &["003-user-list-T009".to_string()]).unwrap();
217        assert_eq!(out.len(), 1);
218        assert_eq!(out[0].id, "003-user-list-T009");
219    }
220
221    #[test]
222    fn feature_name_expands_to_all_decomposed_entries() {
223        let entries = vec![
224            entry("003-user-list-T009"),
225            entry("003-user-list-T010"),
226            entry("003-user-list-phase-2"),
227            entry("004-error-handling-phase-1"),
228        ];
229        let out = resolve_specs(&entries, &["003-user-list".to_string()]).unwrap();
230        let ids: Vec<&str> = out.iter().map(|e| e.id.as_str()).collect();
231        assert_eq!(
232            ids,
233            vec![
234                "003-user-list-T009",
235                "003-user-list-T010",
236                "003-user-list-phase-2",
237            ]
238        );
239    }
240
241    #[test]
242    fn numeric_prefix_resolves_unambiguously() {
243        let entries = vec![
244            entry("003-user-list-T009"),
245            entry("003-user-list-T010"),
246            entry("003-user-list-phase-2"),
247        ];
248        let out = resolve_specs(&entries, &["003".to_string()]).unwrap();
249        assert_eq!(out.len(), 3);
250    }
251
252    #[test]
253    fn ambiguous_numeric_prefix_errors_with_candidates() {
254        let entries = vec![entry("003-user-list-T009"), entry("003a-experiment-T001")];
255        let err = resolve_specs(&entries, &["003".to_string()]).unwrap_err();
256        let msg = err.to_string();
257        assert!(msg.contains("ambiguous"), "got: {msg}");
258        assert!(msg.contains("003-user-list"), "got: {msg}");
259        assert!(msg.contains("003a-experiment"), "got: {msg}");
260    }
261
262    #[test]
263    fn numeric_prefix_with_no_features_errors_as_unknown() {
264        let entries = vec![entry("add-auth")];
265        let err = resolve_specs(&entries, &["003".to_string()]).unwrap_err();
266        let msg = err.to_string();
267        assert!(msg.contains("not found"), "got: {msg}");
268    }
269
270    #[test]
271    fn unknown_name_lists_candidates() {
272        let entries = vec![entry("add-auth"), entry("fix-session")];
273        let err = resolve_specs(&entries, &["no-such-spec".to_string()]).unwrap_err();
274        let msg = err.to_string();
275        assert!(msg.contains("not found"), "got: {msg}");
276        assert!(msg.contains("no-such-spec"), "got: {msg}");
277        assert!(msg.contains("add-auth"), "got: {msg}");
278        assert!(msg.contains("fix-session"), "got: {msg}");
279    }
280
281    #[test]
282    fn partial_failure_aborts_no_partial_result() {
283        let entries = vec![entry("add-auth"), entry("fix-session")];
284        let err = resolve_specs(
285            &entries,
286            &["add-auth".to_string(), "no-such-spec".to_string()],
287        )
288        .unwrap_err();
289        let msg = err.to_string();
290        assert!(msg.contains("no-such-spec"), "got: {msg}");
291    }
292
293    #[test]
294    fn multiple_names_resolved_independently() {
295        let entries = vec![
296            entry("add-auth"),
297            entry("fix-session"),
298            entry("add-logging"),
299        ];
300        let out = resolve_specs(
301            &entries,
302            &["add-auth".to_string(), "add-logging".to_string()],
303        )
304        .unwrap();
305        let ids: Vec<&str> = out.iter().map(|e| e.id.as_str()).collect();
306        assert_eq!(ids, vec!["add-auth", "add-logging"]);
307    }
308
309    #[test]
310    fn duplicate_names_are_deduplicated() {
311        let entries = vec![entry("add-auth"), entry("fix-session")];
312        let out =
313            resolve_specs(&entries, &["add-auth".to_string(), "add-auth".to_string()]).unwrap();
314        assert_eq!(out.len(), 1);
315    }
316
317    #[test]
318    fn feature_id_of_handles_t_task_suffix() {
319        assert_eq!(feature_id_of("003-user-list-T009"), "003-user-list");
320    }
321
322    #[test]
323    fn feature_id_of_handles_phase_suffix() {
324        assert_eq!(feature_id_of("003-user-list-phase-2"), "003-user-list");
325    }
326
327    #[test]
328    fn feature_id_of_handles_openspec_flat_id() {
329        assert_eq!(feature_id_of("add-auth"), "add-auth");
330    }
331
332    #[test]
333    fn feature_id_of_handles_alphanumeric_feature_directory() {
334        assert_eq!(feature_id_of("003a-experiment-T001"), "003a-experiment");
335    }
336}