1use 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#[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
36pub 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 (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
157fn 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
166fn 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
180pub(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#[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#[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 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#[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}