use speccoder_core::snapshot::{Diagnostic, FeatureRef, Selection, SelectionReason, Severity};
pub const SPECS: &str = "specs";
#[must_use]
pub fn prefix(name: &str) -> Option<&str> {
let digits = name.bytes().take_while(u8::is_ascii_digit).count();
if digits == 0 {
return None;
}
let rest = &name[digits..];
if digits == 8 {
let time = rest.strip_prefix('-').unwrap_or("");
let t = time.bytes().take_while(u8::is_ascii_digit).count();
if t == 6 && time[t..].starts_with('-') {
return Some(&name[..digits + 1 + t]);
}
}
rest.starts_with('-').then(|| &name[..digits])
}
fn key(f: &FeatureRef) -> (u128, String) {
let digits: String = f.prefix.chars().filter(char::is_ascii_digit).collect();
(digits.parse().unwrap_or(u128::MAX), f.name.clone())
}
pub fn collect(names: &[String], diags: &mut Vec<Diagnostic>) -> Vec<FeatureRef> {
let mut features: Vec<FeatureRef> = names
.iter()
.filter_map(|name| match prefix(name) {
Some(p) => Some(FeatureRef {
dir: format!("{SPECS}/{name}"),
name: name.clone(),
prefix: p.to_owned(),
}),
None => {
diags.push(
Diagnostic::new(
Severity::Info,
"discover.not-a-feature",
format!("{SPECS}/{name} has no numeric prefix; ignored"),
)
.at(format!("{SPECS}/{name}")),
);
None
}
})
.collect();
features.sort_by_key(key);
features
}
fn matches(f: &FeatureRef, arg: &str) -> bool {
if f.name == arg || f.prefix == arg {
return true;
}
match (arg.parse::<u128>(), f.prefix.parse::<u128>()) {
(Ok(a), Ok(b)) => a == b,
_ => false,
}
}
pub fn select(
features: &[FeatureRef],
argument: Option<&str>,
environment: Option<&str>,
recorded: Option<&str>,
diags: &mut Vec<Diagnostic>,
) -> Selection {
let available = || {
features
.iter()
.map(|f| f.name.as_str())
.collect::<Vec<_>>()
.join(", ")
};
let none = Selection {
reason: SelectionReason::None,
feature: None,
};
if let Some(arg) = argument {
return match features.iter().find(|f| matches(f, arg)) {
Some(f) => Selection {
reason: SelectionReason::Argument,
feature: Some(f.name.clone()),
},
None => {
diags.push(
Diagnostic::new(
Severity::Error,
"select.not-found",
format!("no feature matches `{arg}`"),
)
.hint(format!(
"available: {}; `speccoder feature` lists them",
available()
)),
);
none
}
};
}
let pointers = [
(
environment,
SelectionReason::Environment,
"SPECIFY_FEATURE_DIRECTORY",
),
(recorded, SelectionReason::Recorded, ".specify/feature.json"),
];
for (value, reason, source) in pointers {
let Some(value) = value else { continue };
let dir = value.trim_start_matches("./").trim_end_matches('/');
return match features.iter().find(|f| f.dir == dir) {
Some(f) => Selection {
reason,
feature: Some(f.name.clone()),
},
None => {
let mut d = Diagnostic::new(
Severity::Error,
"select.not-found",
format!("{source} names `{value}`, which is not a feature directory inside the project"),
)
.hint(match speccoder_core::snapshot::closest(features, dir) {
Some(c) => format!("did you mean {}? `speccoder feature {}` makes it current", c.name, c.name),
None => format!("`speccoder feature <name>` picks another (available: {})", available()),
});
if reason == SelectionReason::Recorded {
d = d.at(source);
}
diags.push(d);
none
}
};
}
match features {
[] => {
diags.push(
Diagnostic::new(
Severity::Warning,
"select.no-features",
"the project has no features yet",
)
.hint("speccoder spec specify \"…\" creates one (/speckit-specify in your editor)"),
);
none
}
[only] => Selection {
reason: SelectionReason::OnlyFeature,
feature: Some(only.name.clone()),
},
_ => {
diags.push(
Diagnostic::new(
Severity::Error,
"select.ambiguous",
"several features and none recorded as current",
)
.hint(format!(
"`speccoder feature <name or number>` picks one (available: {})",
available()
)),
);
none
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_sequential_and_timestamp_prefixes() {
assert_eq!(prefix("002-speckit-inspect"), Some("002"));
assert_eq!(prefix("20260319-143022-user-auth"), Some("20260319-143022"));
assert_eq!(prefix("12345678-auth"), Some("12345678"));
assert_eq!(prefix("notes"), None);
assert_eq!(prefix("001"), None);
}
#[test]
fn orders_numerically_not_lexically() {
let names = ["010-c", "002-b", "001-a", "notes"].map(String::from);
let mut diags = Vec::new();
let order: Vec<_> = collect(&names, &mut diags)
.into_iter()
.map(|f| f.name)
.collect();
assert_eq!(order, ["001-a", "002-b", "010-c"]);
assert_eq!(diags[0].code, "discover.not-a-feature");
}
}