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            // Both counts from the list with more ticks: the branch while it
116            // is ahead, the checkout once the branch is merged and left.
117            let (done, tasks) = branch_tasks(format, root, &f.name, &f.dir)
118                .filter(|b| b.0 > done)
119                .unwrap_or((done, tasks));
120            let artifacts = detail.as_ref().map_or_else(Vec::new, |d| {
121                [
122                    ("spec", d.artifacts.spec),
123                    ("plan", d.artifacts.plan),
124                    ("tasks", d.artifacts.tasks),
125                ]
126                .into_iter()
127                .filter(|(_, on)| *on)
128                .map(|(n, _)| n.to_owned())
129                .collect()
130            });
131            FeatureEntry {
132                number: i + 1,
133                name: f.name.clone(),
134                title: title(format, &root.join(&f.dir)),
135                dir: f.dir.clone(),
136                current: current.as_deref() == Some(f.name.as_str()),
137                done,
138                tasks,
139                artifacts,
140            }
141        })
142        .collect();
143    let reason = snap.selection.reason;
144    FeaturesReport {
145        unit: format.messages().unit.to_owned(),
146        current,
147        reason: serde_json::to_value(reason)
148            .ok()
149            .and_then(|v| v.as_str().map(str::to_owned))
150            .unwrap_or_default(),
151        reason_text: reason_text(reason, format),
152        action: action.to_owned(),
153        features,
154    }
155}
156
157/// The title its requirements document gives (`# Feature Specification:
158/// URL slugs` → `URL slugs`).
159fn title(format: &dyn Adapter, dir: &Path) -> Option<String> {
160    let text = std::fs::read_to_string(dir.join(format.requirements_file()?)).ok()?;
161    let h1 = text.lines().find_map(|l| l.strip_prefix("# "))?.trim();
162    let t = h1.split_once(": ").map_or(h1, |(_, t)| t).trim();
163    (!t.is_empty()).then(|| t.to_owned())
164}
165
166/// Ticked and all tasks on the feature's branch: speccoder ticks (and
167/// appends convergence tasks) there, so the checkout lags until the
168/// branch is merged. `None` without the branch.
169fn branch_tasks(
170    format: &dyn Adapter,
171    root: &Path,
172    name: &str,
173    dir: &str,
174) -> Option<(usize, usize)> {
175    let file = format!("{dir}/{}", format.messages().tasks_file);
176    let out = crate::git::Git::at(root)
177        .run(&["show", &format!("speccoder/{name}:{file}")])
178        .ok()?;
179    let (parsed, _) = format.parse_tasks(&file, &String::from_utf8_lossy(&out));
180    Some((
181        parsed.tasks.iter().filter(|t| t.done).count(),
182        parsed.tasks.len(),
183    ))
184}
185
186/// `speccoder feature`: every feature, the current one marked.
187#[must_use]
188pub fn list(path: &Path, feature: Option<&str>) -> CommandResult {
189    let (format, snap) = match open(path, feature) {
190        Ok(x) => x,
191        Err(r) => return *r,
192    };
193    let r = report(format, &snap, "listed");
194    let message = match (&r.current, r.features.len()) {
195        (_, 0) => format!(
196            "no {unit}s yet — `speccoder spec specify \"…\"` creates one",
197            unit = r.unit
198        ),
199        (Some(c), _) => format!("current {}: {c} ({})", r.unit, r.reason_text),
200        (None, n) => format!(
201            "{n} {}s and none current — `speccoder feature <name or number>` picks one",
202            r.unit
203        ),
204    };
205    CommandResult::new("feature", CommandStatus::Ok, &message)
206        .with_data(CommandData::Features(Box::new(r)))
207}
208
209/// `speccoder feature <query>`: makes the feature `query` names current.
210#[must_use]
211pub fn switch(path: &Path, query: &str) -> CommandResult {
212    let (format, snap) = match open(path, None) {
213        Ok(x) => x,
214        Err(r) => return *r,
215    };
216    let f = match resolve(&snap.features, query) {
217        Ok(f) => f.clone(),
218        Err(e) => {
219            return CommandResult::new("feature", CommandStatus::NotFound, &e)
220                .with_code("NOT-FOUND")
221                .with_fix(vec!["speccoder feature".to_owned()])
222                .with_data(CommandData::Features(Box::new(report(
223                    format, &snap, "listed",
224                ))));
225        }
226    };
227    let root = Path::new(&snap.root);
228    let pointer = match format.set_current(root, Some(&f.dir)) {
229        Ok(p) => p,
230        Err(e) => return CommandResult::new("feature", CommandStatus::UsageError, &e),
231    };
232    let after = format.open(root, None).unwrap_or(snap);
233    let r = report(format, &after, "switched");
234    let progress = r
235        .features
236        .iter()
237        .find(|e| e.name == f.name)
238        .filter(|e| e.tasks > 0)
239        .map(|e| format!(", {}/{} tasks done", e.done, e.tasks))
240        .unwrap_or_default();
241    // The variable outranks the pointer: say so rather than pretend.
242    let shadowed = format
243        .planning()
244        .current_env
245        .filter(|v| std::env::var(v).is_ok_and(|x| !x.is_empty()))
246        .map(|v| format!("; {v} is set in this shell and still wins until you unset it"))
247        .unwrap_or_default();
248    CommandResult::new(
249        "feature",
250        CommandStatus::Ok,
251        &format!(
252            "switched to {}{progress} (recorded in {pointer}{}){shadowed}",
253            f.name,
254            Some(format.messages().current_shared)
255                .filter(|s| !s.is_empty())
256                .map(|s| format!(": {s}"))
257                .unwrap_or_default()
258        ),
259    )
260    .with_data(CommandData::Features(Box::new(r)))
261}
262
263/// `speccoder feature --clear`.
264#[must_use]
265pub fn clear(path: &Path) -> CommandResult {
266    let (format, snap) = match open(path, None) {
267        Ok(x) => x,
268        Err(r) => return *r,
269    };
270    let root = Path::new(&snap.root);
271    let pointer = match format.set_current(root, None) {
272        Ok(p) => p,
273        Err(e) => return CommandResult::new("feature", CommandStatus::UsageError, &e),
274    };
275    let after = format.open(root, None).unwrap_or(snap);
276    let r = report(format, &after, "cleared");
277    let message = match &r.current {
278        Some(c) => format!(
279            "removed {pointer}; {c} is still current ({})",
280            r.reason_text
281        ),
282        None => format!("removed {pointer}; no {} is current", r.unit),
283    };
284    CommandResult::new("feature", CommandStatus::Ok, &message)
285        .with_data(CommandData::Features(Box::new(r)))
286}
287
288#[cfg(test)]
289mod tests {
290    use super::*;
291
292    fn refs(names: &[&str]) -> Vec<FeatureRef> {
293        names
294            .iter()
295            .map(|n| FeatureRef {
296                dir: format!("specs/{n}"),
297                name: (*n).to_owned(),
298                prefix: n.split('-').next().unwrap_or_default().to_owned(),
299            })
300            .collect()
301    }
302
303    #[test]
304    fn a_feature_is_named_by_name_number_or_a_unique_part() {
305        let f = refs(&["001-greet-function", "002-farewell", "010-greet-twice"]);
306        assert_eq!(
307            resolve(&f, "002-farewell").map(|f| f.name.as_str()),
308            Ok("002-farewell")
309        );
310        assert_eq!(
311            resolve(&f, "specs/002-farewell/").map(|f| f.name.as_str()),
312            Ok("002-farewell")
313        );
314        assert_eq!(
315            resolve(&f, "2").map(|f| f.name.as_str()),
316            Ok("002-farewell")
317        );
318        assert_eq!(
319            resolve(&f, "10").map(|f| f.name.as_str()),
320            Ok("010-greet-twice")
321        );
322        assert_eq!(
323            resolve(&f, "fare").map(|f| f.name.as_str()),
324            Ok("002-farewell")
325        );
326        let e = resolve(&f, "greet").unwrap_err();
327        assert!(
328            e.contains("matches 2: 001-greet-function, 010-greet-twice"),
329            "{e}"
330        );
331        let e = resolve(&f, "farwell").unwrap_err();
332        assert!(e.contains("did you mean 002-farewell?"), "{e}");
333        assert!(resolve(&f, "zzzzzzzz").unwrap_err().ends_with("`zzzzzzzz`"));
334    }
335
336    #[test]
337    fn the_closest_name_ignores_the_number() {
338        let f = refs(&["001-greet-function", "002-farewell"]);
339        assert_eq!(
340            closest(&f, "specs/003-greet-functon").map(|f| f.name.as_str()),
341            Some("001-greet-function")
342        );
343        assert!(closest(&f, "something-else-entirely").is_none());
344    }
345}