Skip to main content

speccoder_runtime/
current.rs

1//! The current feature: which unit a command works on when none is named,
2//! shown and switched by `speccoder feature`.
3//!
4//! speccoder keeps no pointer of its own: the current feature is the
5//! format's (Spec Kit: `.specify/feature.json`, which Spec Kit's scripts,
6//! `/speckit-*` in an editor and speccoder all read), so every tool agrees
7//! on it. `--feature` and the format's variable still override it for one
8//! command.
9
10use std::path::Path;
11
12use speccoder_core::snapshot::{FeatureRef, ProjectSnapshot, SelectionReason, closest};
13use speccoder_core::{CommandData, CommandResult, CommandStatus, FeatureEntry, FeaturesReport};
14
15use crate::format::Adapter;
16
17/// How the current feature was chosen, in words.
18#[must_use]
19pub fn reason_text(reason: SelectionReason, format: &dyn Adapter) -> String {
20    let env = format
21        .planning()
22        .current_env
23        .unwrap_or("the format's variable");
24    let unit = format.messages().unit;
25    match reason {
26        SelectionReason::Argument => "named with --feature".to_owned(),
27        SelectionReason::Environment => format!("from {env}"),
28        SelectionReason::Recorded => format
29            .current_pointer()
30            .map_or_else(|| "recorded".to_owned(), |p| format!("recorded in {p}")),
31        SelectionReason::OnlyFeature => format!("the only {unit}"),
32        SelectionReason::None => format!("no current {unit}"),
33    }
34}
35
36/// The feature `query` names: its exact name or directory; a number — the
37/// numeric prefix (`2` is `002-…`), else the position in the list; or a
38/// part of the name that only one feature has.
39pub fn resolve<'f>(features: &'f [FeatureRef], query: &str) -> Result<&'f FeatureRef, String> {
40    let q = query.trim().trim_end_matches('/');
41    if let Some(f) = features
42        .iter()
43        .find(|f| f.name == q || f.dir == q || f.dir.ends_with(&format!("/{q}")))
44    {
45        return Ok(f);
46    }
47    if !q.is_empty() && q.chars().all(|c| c.is_ascii_digit()) {
48        let n: u64 = q.parse().unwrap_or(0);
49        if let Some(f) = features
50            .iter()
51            .find(|f| f.prefix.parse::<u64>().ok() == Some(n))
52        {
53            return Ok(f);
54        }
55        if let Some(f) = usize::try_from(n)
56            .ok()
57            .and_then(|n| n.checked_sub(1))
58            .and_then(|i| features.get(i))
59        {
60            return Ok(f);
61        }
62    }
63    let lower = q.to_lowercase();
64    let found: Vec<&FeatureRef> = features
65        .iter()
66        .filter(|f| f.name.to_lowercase().contains(&lower))
67        .collect();
68    match found.as_slice() {
69        [one] => Ok(one),
70        [] => Err(match closest(features, q) {
71            Some(c) => format!("no feature matches `{q}`; did you mean {}?", c.name),
72            None => format!("no feature matches `{q}`"),
73        }),
74        many => Err(format!(
75            "`{q}` matches {}: {}",
76            many.len(),
77            many.iter()
78                .map(|f| f.name.as_str())
79                .collect::<Vec<_>>()
80                .join(", ")
81        )),
82    }
83}
84
85fn open(
86    path: &Path,
87    feature: Option<&str>,
88) -> Result<(&'static dyn Adapter, ProjectSnapshot), Box<CommandResult>> {
89    let format = crate::format::at(path);
90    match format.open(path, feature) {
91        Some(s) => Ok((format, s)),
92        None => Err(Box::new(CommandResult::new(
93            "feature",
94            CommandStatus::NotAProject,
95            &(format.messages().not_a_project)(path),
96        ))),
97    }
98}
99
100fn report(format: &dyn Adapter, snap: &ProjectSnapshot, action: &str) -> FeaturesReport {
101    let root = Path::new(&snap.root);
102    let current = snap.feature.as_ref().map(|f| f.feature.name.clone());
103    let features = snap
104        .features
105        .iter()
106        .enumerate()
107        .map(|(i, f)| {
108            let detail = format.inspect(root, Some(&f.name)).and_then(|s| s.feature);
109            let (done, tasks) = detail
110                .as_ref()
111                .and_then(|d| d.tasks.as_ref())
112                .map_or((0, 0), |t| {
113                    (t.tasks.iter().filter(|t| t.done).count(), t.tasks.len())
114                });
115            let artifacts = detail.as_ref().map_or_else(Vec::new, |d| {
116                [
117                    ("spec", d.artifacts.spec),
118                    ("plan", d.artifacts.plan),
119                    ("tasks", d.artifacts.tasks),
120                ]
121                .into_iter()
122                .filter(|(_, on)| *on)
123                .map(|(n, _)| n.to_owned())
124                .collect()
125            });
126            FeatureEntry {
127                number: i + 1,
128                name: f.name.clone(),
129                title: title(format, &root.join(&f.dir)),
130                dir: f.dir.clone(),
131                current: current.as_deref() == Some(f.name.as_str()),
132                done: done.max(branch_ticks(format, root, &f.name, &f.dir)),
133                tasks,
134                artifacts,
135            }
136        })
137        .collect();
138    let reason = snap.selection.reason;
139    FeaturesReport {
140        unit: format.messages().unit.to_owned(),
141        current,
142        reason: serde_json::to_value(reason)
143            .ok()
144            .and_then(|v| v.as_str().map(str::to_owned))
145            .unwrap_or_default(),
146        reason_text: reason_text(reason, format),
147        action: action.to_owned(),
148        features,
149    }
150}
151
152/// The title its requirements document gives (`# Feature Specification:
153/// URL slugs` → `URL slugs`).
154fn title(format: &dyn Adapter, dir: &Path) -> Option<String> {
155    let text = std::fs::read_to_string(dir.join(format.requirements_file()?)).ok()?;
156    let h1 = text.lines().find_map(|l| l.strip_prefix("# "))?.trim();
157    let t = h1.split_once(": ").map_or(h1, |(_, t)| t).trim();
158    (!t.is_empty()).then(|| t.to_owned())
159}
160
161/// Ticked tasks on the feature's branch: speccoder ticks there, so the
162/// checkout lags until the branch is merged.
163fn branch_ticks(format: &dyn Adapter, root: &Path, name: &str, dir: &str) -> usize {
164    let file = format!("{dir}/{}", format.messages().tasks_file);
165    crate::git::Git::at(root)
166        .run(&["show", &format!("speccoder/{name}:{file}")])
167        .map_or(0, |out| {
168            let (parsed, _) = format.parse_tasks(&file, &String::from_utf8_lossy(&out));
169            parsed.tasks.iter().filter(|t| t.done).count()
170        })
171}
172
173/// `speccoder feature`: every feature, the current one marked.
174#[must_use]
175pub fn list(path: &Path, feature: Option<&str>) -> CommandResult {
176    let (format, snap) = match open(path, feature) {
177        Ok(x) => x,
178        Err(r) => return *r,
179    };
180    let r = report(format, &snap, "listed");
181    let message = match (&r.current, r.features.len()) {
182        (_, 0) => format!(
183            "no {unit}s yet — `speccoder spec specify \"…\"` creates one",
184            unit = r.unit
185        ),
186        (Some(c), _) => format!("current {}: {c} ({})", r.unit, r.reason_text),
187        (None, n) => format!(
188            "{n} {}s and none current — `speccoder feature <name or number>` picks one",
189            r.unit
190        ),
191    };
192    CommandResult::new("feature", CommandStatus::Ok, &message)
193        .with_data(CommandData::Features(Box::new(r)))
194}
195
196/// `speccoder feature <query>`: makes the feature `query` names current.
197#[must_use]
198pub fn switch(path: &Path, query: &str) -> CommandResult {
199    let (format, snap) = match open(path, None) {
200        Ok(x) => x,
201        Err(r) => return *r,
202    };
203    let f = match resolve(&snap.features, query) {
204        Ok(f) => f.clone(),
205        Err(e) => {
206            return CommandResult::new("feature", CommandStatus::NotFound, &e)
207                .with_code("NOT-FOUND")
208                .with_fix(vec!["speccoder feature".to_owned()])
209                .with_data(CommandData::Features(Box::new(report(
210                    format, &snap, "listed",
211                ))));
212        }
213    };
214    let root = Path::new(&snap.root);
215    let pointer = match format.set_current(root, Some(&f.dir)) {
216        Ok(p) => p,
217        Err(e) => return CommandResult::new("feature", CommandStatus::UsageError, &e),
218    };
219    let after = format.open(root, None).unwrap_or(snap);
220    let r = report(format, &after, "switched");
221    let progress = r
222        .features
223        .iter()
224        .find(|e| e.name == f.name)
225        .filter(|e| e.tasks > 0)
226        .map(|e| format!(", {}/{} tasks done", e.done, e.tasks))
227        .unwrap_or_default();
228    // The variable outranks the pointer: say so rather than pretend.
229    let shadowed = format
230        .planning()
231        .current_env
232        .filter(|v| std::env::var(v).is_ok_and(|x| !x.is_empty()))
233        .map(|v| format!("; {v} is set in this shell and still wins until you unset it"))
234        .unwrap_or_default();
235    CommandResult::new(
236        "feature",
237        CommandStatus::Ok,
238        &format!(
239            "switched to {}{progress} (recorded in {pointer}{}){shadowed}",
240            f.name,
241            Some(format.messages().current_shared)
242                .filter(|s| !s.is_empty())
243                .map(|s| format!(": {s}"))
244                .unwrap_or_default()
245        ),
246    )
247    .with_data(CommandData::Features(Box::new(r)))
248}
249
250/// `speccoder feature --clear`.
251#[must_use]
252pub fn clear(path: &Path) -> CommandResult {
253    let (format, snap) = match open(path, None) {
254        Ok(x) => x,
255        Err(r) => return *r,
256    };
257    let root = Path::new(&snap.root);
258    let pointer = match format.set_current(root, None) {
259        Ok(p) => p,
260        Err(e) => return CommandResult::new("feature", CommandStatus::UsageError, &e),
261    };
262    let after = format.open(root, None).unwrap_or(snap);
263    let r = report(format, &after, "cleared");
264    let message = match &r.current {
265        Some(c) => format!(
266            "removed {pointer}; {c} is still current ({})",
267            r.reason_text
268        ),
269        None => format!("removed {pointer}; no {} is current", r.unit),
270    };
271    CommandResult::new("feature", CommandStatus::Ok, &message)
272        .with_data(CommandData::Features(Box::new(r)))
273}
274
275#[cfg(test)]
276mod tests {
277    use super::*;
278
279    fn refs(names: &[&str]) -> Vec<FeatureRef> {
280        names
281            .iter()
282            .map(|n| FeatureRef {
283                dir: format!("specs/{n}"),
284                name: (*n).to_owned(),
285                prefix: n.split('-').next().unwrap_or_default().to_owned(),
286            })
287            .collect()
288    }
289
290    #[test]
291    fn a_feature_is_named_by_name_number_or_a_unique_part() {
292        let f = refs(&["001-greet-function", "002-farewell", "010-greet-twice"]);
293        assert_eq!(
294            resolve(&f, "002-farewell").map(|f| f.name.as_str()),
295            Ok("002-farewell")
296        );
297        assert_eq!(
298            resolve(&f, "specs/002-farewell/").map(|f| f.name.as_str()),
299            Ok("002-farewell")
300        );
301        assert_eq!(
302            resolve(&f, "2").map(|f| f.name.as_str()),
303            Ok("002-farewell")
304        );
305        assert_eq!(
306            resolve(&f, "10").map(|f| f.name.as_str()),
307            Ok("010-greet-twice")
308        );
309        assert_eq!(
310            resolve(&f, "fare").map(|f| f.name.as_str()),
311            Ok("002-farewell")
312        );
313        let e = resolve(&f, "greet").unwrap_err();
314        assert!(
315            e.contains("matches 2: 001-greet-function, 010-greet-twice"),
316            "{e}"
317        );
318        let e = resolve(&f, "farwell").unwrap_err();
319        assert!(e.contains("did you mean 002-farewell?"), "{e}");
320        assert!(resolve(&f, "zzzzzzzz").unwrap_err().ends_with("`zzzzzzzz`"));
321    }
322
323    #[test]
324    fn the_closest_name_ignores_the_number() {
325        let f = refs(&["001-greet-function", "002-farewell"]);
326        assert_eq!(
327            closest(&f, "specs/003-greet-functon").map(|f| f.name.as_str()),
328            Some("001-greet-function")
329        );
330        assert!(closest(&f, "something-else-entirely").is_none());
331    }
332}