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(
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#[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#[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 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#[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}