speccoder_speckit/
features.rs1use speccoder_core::snapshot::{Diagnostic, FeatureRef, Selection, SelectionReason, Severity};
4
5pub const SPECS: &str = "specs";
7
8#[must_use]
11pub fn prefix(name: &str) -> Option<&str> {
12 let digits = name.bytes().take_while(u8::is_ascii_digit).count();
13 if digits == 0 {
14 return None;
15 }
16 let rest = &name[digits..];
17 if digits == 8 {
19 let time = rest.strip_prefix('-').unwrap_or("");
20 let t = time.bytes().take_while(u8::is_ascii_digit).count();
21 if t == 6 && time[t..].starts_with('-') {
22 return Some(&name[..digits + 1 + t]);
23 }
24 }
25 rest.starts_with('-').then(|| &name[..digits])
26}
27
28fn key(f: &FeatureRef) -> (u128, String) {
31 let digits: String = f.prefix.chars().filter(char::is_ascii_digit).collect();
32 (digits.parse().unwrap_or(u128::MAX), f.name.clone())
33}
34
35pub fn collect(names: &[String], diags: &mut Vec<Diagnostic>) -> Vec<FeatureRef> {
38 let mut features: Vec<FeatureRef> = names
39 .iter()
40 .filter_map(|name| match prefix(name) {
41 Some(p) => Some(FeatureRef {
42 dir: format!("{SPECS}/{name}"),
43 name: name.clone(),
44 prefix: p.to_owned(),
45 }),
46 None => {
47 diags.push(
48 Diagnostic::new(
49 Severity::Info,
50 "discover.not-a-feature",
51 format!("{SPECS}/{name} has no numeric prefix; ignored"),
52 )
53 .at(format!("{SPECS}/{name}")),
54 );
55 None
56 }
57 })
58 .collect();
59 features.sort_by_key(key);
60 features
61}
62
63fn matches(f: &FeatureRef, arg: &str) -> bool {
65 if f.name == arg || f.prefix == arg {
66 return true;
67 }
68 match (arg.parse::<u128>(), f.prefix.parse::<u128>()) {
69 (Ok(a), Ok(b)) => a == b,
70 _ => false,
71 }
72}
73
74pub fn select(
77 features: &[FeatureRef],
78 argument: Option<&str>,
79 environment: Option<&str>,
80 recorded: Option<&str>,
81 diags: &mut Vec<Diagnostic>,
82) -> Selection {
83 let available = || {
84 features
85 .iter()
86 .map(|f| f.name.as_str())
87 .collect::<Vec<_>>()
88 .join(", ")
89 };
90 let none = Selection {
91 reason: SelectionReason::None,
92 feature: None,
93 };
94
95 if let Some(arg) = argument {
96 return match features.iter().find(|f| matches(f, arg)) {
97 Some(f) => Selection {
98 reason: SelectionReason::Argument,
99 feature: Some(f.name.clone()),
100 },
101 None => {
102 diags.push(
103 Diagnostic::new(
104 Severity::Error,
105 "select.not-found",
106 format!("no feature matches `{arg}`"),
107 )
108 .hint(format!(
109 "available: {}; `speccoder feature` lists them",
110 available()
111 )),
112 );
113 none
114 }
115 };
116 }
117
118 let pointers = [
120 (
121 environment,
122 SelectionReason::Environment,
123 "SPECIFY_FEATURE_DIRECTORY",
124 ),
125 (recorded, SelectionReason::Recorded, ".specify/feature.json"),
126 ];
127 for (value, reason, source) in pointers {
128 let Some(value) = value else { continue };
129 let dir = value.trim_start_matches("./").trim_end_matches('/');
130 return match features.iter().find(|f| f.dir == dir) {
131 Some(f) => Selection {
132 reason,
133 feature: Some(f.name.clone()),
134 },
135 None => {
136 let mut d = Diagnostic::new(
137 Severity::Error,
138 "select.not-found",
139 format!("{source} names `{value}`, which is not a feature directory inside the project"),
140 )
141 .hint(match speccoder_core::snapshot::closest(features, dir) {
142 Some(c) => format!("did you mean {}? `speccoder feature {}` makes it current", c.name, c.name),
143 None => format!("`speccoder feature <name>` picks another (available: {})", available()),
144 });
145 if reason == SelectionReason::Recorded {
146 d = d.at(source);
147 }
148 diags.push(d);
149 none
150 }
151 };
152 }
153
154 match features {
155 [] => {
156 diags.push(
157 Diagnostic::new(
158 Severity::Warning,
159 "select.no-features",
160 "the project has no features yet",
161 )
162 .hint("speccoder spec specify \"…\" creates one (/speckit-specify in your editor)"),
163 );
164 none
165 }
166 [only] => Selection {
167 reason: SelectionReason::OnlyFeature,
168 feature: Some(only.name.clone()),
169 },
170 _ => {
171 diags.push(
172 Diagnostic::new(
173 Severity::Error,
174 "select.ambiguous",
175 "several features and none recorded as current",
176 )
177 .hint(format!(
178 "`speccoder feature <name or number>` picks one (available: {})",
179 available()
180 )),
181 );
182 none
183 }
184 }
185}
186
187#[cfg(test)]
188mod tests {
189 use super::*;
190
191 #[test]
192 fn recognises_sequential_and_timestamp_prefixes() {
193 assert_eq!(prefix("002-speckit-inspect"), Some("002"));
194 assert_eq!(prefix("20260319-143022-user-auth"), Some("20260319-143022"));
195 assert_eq!(prefix("12345678-auth"), Some("12345678"));
196 assert_eq!(prefix("notes"), None);
197 assert_eq!(prefix("001"), None);
198 }
199
200 #[test]
201 fn orders_numerically_not_lexically() {
202 let names = ["010-c", "002-b", "001-a", "notes"].map(String::from);
203 let mut diags = Vec::new();
204 let order: Vec<_> = collect(&names, &mut diags)
205 .into_iter()
206 .map(|f| f.name)
207 .collect();
208 assert_eq!(order, ["001-a", "002-b", "010-c"]);
209 assert_eq!(diags[0].code, "discover.not-a-feature");
210 }
211}