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. `None` without the branch.
167fn branch_tasks(
168    format: &dyn Adapter,
169    root: &Path,
170    name: &str,
171    dir: &str,
172) -> Option<(usize, usize)> {
173    let parsed = branch_task_list(format, root, name, dir)?;
174    Some((
175        parsed.tasks.iter().filter(|t| t.done).count(),
176        parsed.tasks.len(),
177    ))
178}
179
180/// The task list as committed on the feature's branch: speccoder ticks
181/// (and appends convergence tasks) there, so the checkout lags until the
182/// branch is merged. `None` without the branch.
183pub(crate) fn branch_task_list(
184    format: &dyn Adapter,
185    root: &Path,
186    name: &str,
187    dir: &str,
188) -> Option<speccoder_core::snapshot::ParsedTasks> {
189    let file = format!("{dir}/{}", format.messages().tasks_file);
190    let out = crate::git::Git::at(root)
191        .run(&["show", &format!("speccoder/{name}:{file}")])
192        .ok()?;
193    Some(format.parse_tasks(&file, &String::from_utf8_lossy(&out)).0)
194}
195
196/// `speccoder feature`: every feature, the current one marked.
197#[must_use]
198pub fn list(path: &Path, feature: Option<&str>) -> CommandResult {
199    let (format, snap) = match open(path, feature) {
200        Ok(x) => x,
201        Err(r) => return *r,
202    };
203    let r = report(format, &snap, "listed");
204    let message = match (&r.current, r.features.len()) {
205        (_, 0) => format!(
206            "no {unit}s yet — `speccoder spec specify \"…\"` creates one",
207            unit = r.unit
208        ),
209        (Some(c), _) => format!("current {}: {c} ({})", r.unit, r.reason_text),
210        (None, n) => format!(
211            "{n} {}s and none current — `speccoder feature <name or number>` picks one",
212            r.unit
213        ),
214    };
215    CommandResult::new("feature", CommandStatus::Ok, &message)
216        .with_data(CommandData::Features(Box::new(r)))
217}
218
219/// `speccoder feature <query>`: makes the feature `query` names current.
220#[must_use]
221pub fn switch(path: &Path, query: &str) -> CommandResult {
222    let (format, snap) = match open(path, None) {
223        Ok(x) => x,
224        Err(r) => return *r,
225    };
226    let f = match resolve(&snap.features, query) {
227        Ok(f) => f.clone(),
228        Err(e) => {
229            return CommandResult::new("feature", CommandStatus::NotFound, &e)
230                .with_code("NOT-FOUND")
231                .with_fix(vec!["speccoder feature".to_owned()])
232                .with_data(CommandData::Features(Box::new(report(
233                    format, &snap, "listed",
234                ))));
235        }
236    };
237    let root = Path::new(&snap.root);
238    let pointer = match format.set_current(root, Some(&f.dir)) {
239        Ok(p) => p,
240        Err(e) => return CommandResult::new("feature", CommandStatus::UsageError, &e),
241    };
242    let after = format.open(root, None).unwrap_or(snap);
243    let r = report(format, &after, "switched");
244    let progress = r
245        .features
246        .iter()
247        .find(|e| e.name == f.name)
248        .filter(|e| e.tasks > 0)
249        .map(|e| format!(", {}/{} tasks done", e.done, e.tasks))
250        .unwrap_or_default();
251    // The variable outranks the pointer: say so rather than pretend.
252    let shadowed = format
253        .planning()
254        .current_env
255        .filter(|v| std::env::var(v).is_ok_and(|x| !x.is_empty()))
256        .map(|v| format!("; {v} is set in this shell and still wins until you unset it"))
257        .unwrap_or_default();
258    CommandResult::new(
259        "feature",
260        CommandStatus::Ok,
261        &format!(
262            "switched to {}{progress} (recorded in {pointer}{}){shadowed}",
263            f.name,
264            Some(format.messages().current_shared)
265                .filter(|s| !s.is_empty())
266                .map(|s| format!(": {s}"))
267                .unwrap_or_default()
268        ),
269    )
270    .with_data(CommandData::Features(Box::new(r)))
271}
272
273/// `speccoder feature --clear`.
274#[must_use]
275pub fn clear(path: &Path) -> CommandResult {
276    let (format, snap) = match open(path, None) {
277        Ok(x) => x,
278        Err(r) => return *r,
279    };
280    let root = Path::new(&snap.root);
281    let pointer = match format.set_current(root, None) {
282        Ok(p) => p,
283        Err(e) => return CommandResult::new("feature", CommandStatus::UsageError, &e),
284    };
285    let after = format.open(root, None).unwrap_or(snap);
286    let r = report(format, &after, "cleared");
287    let message = match &r.current {
288        Some(c) => format!(
289            "removed {pointer}; {c} is still current ({})",
290            r.reason_text
291        ),
292        None => format!("removed {pointer}; no {} is current", r.unit),
293    };
294    CommandResult::new("feature", CommandStatus::Ok, &message)
295        .with_data(CommandData::Features(Box::new(r)))
296}
297
298#[cfg(test)]
299mod tests {
300    use super::*;
301
302    fn refs(names: &[&str]) -> Vec<FeatureRef> {
303        names
304            .iter()
305            .map(|n| FeatureRef {
306                dir: format!("specs/{n}"),
307                name: (*n).to_owned(),
308                prefix: n.split('-').next().unwrap_or_default().to_owned(),
309            })
310            .collect()
311    }
312
313    #[test]
314    fn a_feature_is_named_by_name_number_or_a_unique_part() {
315        let f = refs(&["001-greet-function", "002-farewell", "010-greet-twice"]);
316        assert_eq!(
317            resolve(&f, "002-farewell").map(|f| f.name.as_str()),
318            Ok("002-farewell")
319        );
320        assert_eq!(
321            resolve(&f, "specs/002-farewell/").map(|f| f.name.as_str()),
322            Ok("002-farewell")
323        );
324        assert_eq!(
325            resolve(&f, "2").map(|f| f.name.as_str()),
326            Ok("002-farewell")
327        );
328        assert_eq!(
329            resolve(&f, "10").map(|f| f.name.as_str()),
330            Ok("010-greet-twice")
331        );
332        assert_eq!(
333            resolve(&f, "fare").map(|f| f.name.as_str()),
334            Ok("002-farewell")
335        );
336        let e = resolve(&f, "greet").unwrap_err();
337        assert!(
338            e.contains("matches 2: 001-greet-function, 010-greet-twice"),
339            "{e}"
340        );
341        let e = resolve(&f, "farwell").unwrap_err();
342        assert!(e.contains("did you mean 002-farewell?"), "{e}");
343        assert!(resolve(&f, "zzzzzzzz").unwrap_err().ends_with("`zzzzzzzz`"));
344    }
345
346    #[test]
347    fn the_closest_name_ignores_the_number() {
348        let f = refs(&["001-greet-function", "002-farewell"]);
349        assert_eq!(
350            closest(&f, "specs/003-greet-functon").map(|f| f.name.as_str()),
351            Some("001-greet-function")
352        );
353        assert!(closest(&f, "something-else-entirely").is_none());
354    }
355}