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 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
152fn 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
161fn 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#[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#[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 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#[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}