Skip to main content

speccoder_speckit/
features.rs

1//! Feature directories: prefixes, ordering, selection.
2
3use speccoder_core::snapshot::{Diagnostic, FeatureRef, Selection, SelectionReason, Severity};
4
5/// The specs directory.
6pub const SPECS: &str = "specs";
7
8/// Splits `NNN-name` or `YYYYMMDD-HHMMSS-name` into its prefix, or `None`
9/// when the directory has no numeric prefix.
10#[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    // Timestamp numbering: 8 digits, '-', 6 digits.
18    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
28/// Sort key: the prefix's digits as a number (the timestamp's two halves
29/// concatenated), then the full name.
30fn 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
35/// Builds and orders the feature list from directory names under `specs/`,
36/// reporting directories without a numeric prefix.
37pub 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
63/// Whether `arg` names `f`: its full name, its prefix, or the same number.
64fn 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
74/// Selects a feature: argument > `SPECIFY_FEATURE_DIRECTORY` > recorded
75/// `feature_directory` > the only feature.
76pub 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    // Spec Kit's own order (`common.sh`): SPECIFY_FEATURE_DIRECTORY, then feature.json.
119    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}