1use crate::error::PawError;
17use crate::specs::SpecEntry;
18
19pub 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 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
127fn 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
137fn is_numeric_prefix(name: &str) -> bool {
139 !name.is_empty() && name.chars().all(|c| c.is_ascii_digit())
140}
141
142fn 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
160fn 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}