use crate::error::PawError;
use crate::specs::SpecEntry;
pub fn resolve_specs(entries: &[SpecEntry], names: &[String]) -> Result<Vec<SpecEntry>, PawError> {
let mut unknown: Vec<String> = Vec::new();
let mut ambiguous: Vec<(String, Vec<String>)> = Vec::new();
let mut selected_indices: Vec<usize> = Vec::new();
for name in names {
match match_name(entries, name) {
MatchResult::Indices(idxs) => {
for idx in idxs {
if !selected_indices.contains(&idx) {
selected_indices.push(idx);
}
}
}
MatchResult::Unknown => unknown.push(name.clone()),
MatchResult::Ambiguous(features) => ambiguous.push((name.clone(), features)),
}
}
if let Some((prefix, candidates)) = ambiguous.first() {
return Err(PawError::SpecError(format!(
"spec name '{prefix}' is ambiguous; matches: {}\n \
Run `git paw start --specs <full-name>` to disambiguate.",
candidates.join(", ")
)));
}
if !unknown.is_empty() {
let discovered: Vec<&str> = entries.iter().map(|e| e.id.as_str()).collect();
return Err(PawError::SpecError(format!(
"spec(s) not found: {}\n \
Discovered specs: {}\n \
Run `git paw start --specs` for an interactive picker.",
unknown.join(", "),
discovered.join(", ")
)));
}
Ok(selected_indices
.into_iter()
.map(|i| entries[i].clone())
.collect())
}
enum MatchResult {
Indices(Vec<usize>),
Unknown,
Ambiguous(Vec<String>),
}
fn match_name(entries: &[SpecEntry], name: &str) -> MatchResult {
if let Some(idx) = entries.iter().position(|e| e.id == name) {
return MatchResult::Indices(vec![idx]);
}
if !is_numeric_prefix(name) {
let feature_matches: Vec<usize> = entries
.iter()
.enumerate()
.filter(|(_, e)| is_feature_match(&e.id, name))
.map(|(i, _)| i)
.collect();
if !feature_matches.is_empty() {
return MatchResult::Indices(feature_matches);
}
return MatchResult::Unknown;
}
let features = collect_feature_ids_with_prefix(entries, name);
match features.len() {
0 => MatchResult::Unknown,
1 => {
let feature = &features[0];
let idxs: Vec<usize> = entries
.iter()
.enumerate()
.filter(|(_, e)| is_feature_match(&e.id, feature))
.map(|(i, _)| i)
.collect();
if idxs.is_empty() {
MatchResult::Unknown
} else {
MatchResult::Indices(idxs)
}
}
_ => MatchResult::Ambiguous(features),
}
}
fn is_feature_match(id: &str, feature: &str) -> bool {
if id == feature {
return true;
}
id.strip_prefix(feature)
.is_some_and(|rest| rest.starts_with('-'))
}
fn is_numeric_prefix(name: &str) -> bool {
!name.is_empty() && name.chars().all(|c| c.is_ascii_digit())
}
fn collect_feature_ids_with_prefix(entries: &[SpecEntry], prefix: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for entry in entries {
let feature = feature_id_of(&entry.id);
let Some(rest) = feature.strip_prefix(prefix) else {
continue;
};
let bounded = rest.chars().next().is_none_or(|c| !c.is_ascii_digit());
if bounded && !out.contains(&feature) {
out.push(feature);
}
}
out
}
fn feature_id_of(id: &str) -> String {
if let Some((before, after)) = id.rsplit_once("-phase-")
&& !after.is_empty()
&& after.chars().all(|c| c.is_ascii_digit())
{
return before.to_string();
}
if let Some((before, after)) = id.rsplit_once("-T")
&& !after.is_empty()
&& after.chars().all(|c| c.is_ascii_digit())
{
return before.to_string();
}
id.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
fn entry(id: &str) -> SpecEntry {
SpecEntry {
id: id.to_string(),
backend: crate::specs::SpecBackendKind::Markdown,
branch: format!("spec/{id}"),
cli: None,
prompt: String::new(),
owned_files: None,
}
}
#[test]
fn exact_match_returns_single_entry() {
let entries = vec![entry("add-auth"), entry("fix-session")];
let out = resolve_specs(&entries, &["add-auth".to_string()]).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].id, "add-auth");
}
#[test]
fn exact_match_on_spec_kit_decomposed_id() {
let entries = vec![
entry("003-user-list-T009"),
entry("003-user-list-T010"),
entry("003-user-list-phase-2"),
];
let out = resolve_specs(&entries, &["003-user-list-T009".to_string()]).unwrap();
assert_eq!(out.len(), 1);
assert_eq!(out[0].id, "003-user-list-T009");
}
#[test]
fn feature_name_expands_to_all_decomposed_entries() {
let entries = vec![
entry("003-user-list-T009"),
entry("003-user-list-T010"),
entry("003-user-list-phase-2"),
entry("004-error-handling-phase-1"),
];
let out = resolve_specs(&entries, &["003-user-list".to_string()]).unwrap();
let ids: Vec<&str> = out.iter().map(|e| e.id.as_str()).collect();
assert_eq!(
ids,
vec![
"003-user-list-T009",
"003-user-list-T010",
"003-user-list-phase-2",
]
);
}
#[test]
fn numeric_prefix_resolves_unambiguously() {
let entries = vec![
entry("003-user-list-T009"),
entry("003-user-list-T010"),
entry("003-user-list-phase-2"),
];
let out = resolve_specs(&entries, &["003".to_string()]).unwrap();
assert_eq!(out.len(), 3);
}
#[test]
fn ambiguous_numeric_prefix_errors_with_candidates() {
let entries = vec![entry("003-user-list-T009"), entry("003a-experiment-T001")];
let err = resolve_specs(&entries, &["003".to_string()]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("ambiguous"), "got: {msg}");
assert!(msg.contains("003-user-list"), "got: {msg}");
assert!(msg.contains("003a-experiment"), "got: {msg}");
}
#[test]
fn numeric_prefix_with_no_features_errors_as_unknown() {
let entries = vec![entry("add-auth")];
let err = resolve_specs(&entries, &["003".to_string()]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not found"), "got: {msg}");
}
#[test]
fn unknown_name_lists_candidates() {
let entries = vec![entry("add-auth"), entry("fix-session")];
let err = resolve_specs(&entries, &["no-such-spec".to_string()]).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("not found"), "got: {msg}");
assert!(msg.contains("no-such-spec"), "got: {msg}");
assert!(msg.contains("add-auth"), "got: {msg}");
assert!(msg.contains("fix-session"), "got: {msg}");
}
#[test]
fn partial_failure_aborts_no_partial_result() {
let entries = vec![entry("add-auth"), entry("fix-session")];
let err = resolve_specs(
&entries,
&["add-auth".to_string(), "no-such-spec".to_string()],
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("no-such-spec"), "got: {msg}");
}
#[test]
fn multiple_names_resolved_independently() {
let entries = vec![
entry("add-auth"),
entry("fix-session"),
entry("add-logging"),
];
let out = resolve_specs(
&entries,
&["add-auth".to_string(), "add-logging".to_string()],
)
.unwrap();
let ids: Vec<&str> = out.iter().map(|e| e.id.as_str()).collect();
assert_eq!(ids, vec!["add-auth", "add-logging"]);
}
#[test]
fn duplicate_names_are_deduplicated() {
let entries = vec![entry("add-auth"), entry("fix-session")];
let out =
resolve_specs(&entries, &["add-auth".to_string(), "add-auth".to_string()]).unwrap();
assert_eq!(out.len(), 1);
}
#[test]
fn feature_id_of_handles_t_task_suffix() {
assert_eq!(feature_id_of("003-user-list-T009"), "003-user-list");
}
#[test]
fn feature_id_of_handles_phase_suffix() {
assert_eq!(feature_id_of("003-user-list-phase-2"), "003-user-list");
}
#[test]
fn feature_id_of_handles_openspec_flat_id() {
assert_eq!(feature_id_of("add-auth"), "add-auth");
}
#[test]
fn feature_id_of_handles_alphanumeric_feature_directory() {
assert_eq!(feature_id_of("003a-experiment-T001"), "003a-experiment");
}
}