1use std::collections::BTreeMap;
9use std::path::{Path, PathBuf};
10
11use serde::{Deserialize, Serialize};
12use speccoder_core::{
13 CommandData, CommandResult, CommandStatus, OpenQuestion, RequirementDrift, ReviewItem,
14 ReviewReport, ReviewTotals, SpecVersion,
15};
16
17use crate::tools::paths::REVIEW_FILE;
18
19#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21struct Decision {
22 verdict: String,
24 line: String,
26 sha256: String,
28 by: String,
30 via: String,
32 at: u64,
34 #[serde(default, skip_serializing_if = "Option::is_none")]
35 comment: Option<String>,
36}
37
38#[derive(Debug, Default, Serialize, Deserialize)]
40struct Record {
41 #[serde(default)]
42 requirements: BTreeMap<String, Decision>,
43}
44
45fn digest(line: &str) -> String {
46 format!(
47 "sha256:{}",
48 crate::integrity::sha256_hex(line.trim_end().as_bytes())
49 )
50}
51
52fn wording(line: &str) -> String {
54 let t = line.trim_start().trim_start_matches("- ");
55 match t.strip_prefix("**").and_then(|r| r.split_once("**")) {
56 Some((_, rest)) => rest.trim_start_matches(':').trim().to_owned(),
57 None => t.trim().to_owned(),
58 }
59}
60
61struct Unit {
63 feature: String,
64 root: PathBuf,
65 dir: String,
66 format: &'static dyn crate::format::Adapter,
67}
68
69impl Unit {
70 fn file(&self) -> PathBuf {
71 self.root.join(&self.dir).join(REVIEW_FILE)
72 }
73 fn rel(&self) -> String {
74 format!("{}/{REVIEW_FILE}", self.dir.trim_end_matches('/'))
75 }
76 fn requirements(&self) -> Vec<(String, String)> {
78 let Some(file) = self.format.requirements_file() else {
79 return Vec::new();
80 };
81 let text =
82 std::fs::read_to_string(self.root.join(&self.dir).join(file)).unwrap_or_default();
83 self.format.requirements_in(&text)
84 }
85 fn record(&self) -> Record {
86 std::fs::read_to_string(self.file())
87 .ok()
88 .and_then(|t| serde_json::from_str(&t).ok())
89 .unwrap_or_default()
90 }
91}
92
93fn unit(command: &str, path: &Path, feature: Option<&str>) -> Result<Unit, Box<CommandResult>> {
94 let inspected = crate::inspect(path, feature);
95 let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
96 return Err(Box::new(CommandResult::carried(command, &inspected)));
97 };
98 let Some(f) = snapshot.feature.as_ref() else {
99 return Err(Box::new(CommandResult::new(
100 command,
101 CommandStatus::SelectionNeeded,
102 inspected.message(),
103 )));
104 };
105 Ok(Unit {
106 feature: f.feature.name.clone(),
107 root: PathBuf::from(&snapshot.root),
108 dir: f.feature.dir.clone(),
109 format: crate::format::of(snapshot),
110 })
111}
112
113fn items(requirements: &[(String, String)], record: &Record) -> Vec<ReviewItem> {
115 requirements
116 .iter()
117 .map(|(id, line)| {
118 let d = record.requirements.get(id);
119 let current = d.is_some_and(|d| d.sha256 == digest(line));
120 let state = match d {
121 None => "open",
122 Some(_) if !current => "lapsed",
123 Some(d) if d.verdict == "approved" => "approved",
124 Some(_) => "changes_requested",
125 };
126 ReviewItem {
127 id: id.clone(),
128 text: wording(line),
129 state: state.to_owned(),
130 by: d.map(|d| d.by.clone()),
131 at: d.map(|d| d.at),
132 comment: d.and_then(|d| d.comment.clone()),
133 decided_on: d.filter(|_| !current).map(|d| wording(&d.line)),
134 }
135 })
136 .collect()
137}
138
139fn totals(items: &[ReviewItem], questions: usize) -> ReviewTotals {
140 let n = |s: &str| items.iter().filter(|i| i.state == s).count();
141 ReviewTotals {
142 approved: n("approved"),
143 changes_requested: n("changes_requested"),
144 open: n("open"),
145 lapsed: n("lapsed"),
146 questions,
147 }
148}
149
150const MARKER: &str = "[NEEDS CLARIFICATION";
152
153fn documents(u: &Unit) -> Vec<(String, String)> {
156 let dir = u.root.join(&u.dir);
157 let mut names: Vec<String> = std::fs::read_dir(&dir)
158 .into_iter()
159 .flatten()
160 .flatten()
161 .filter(|e| e.path().is_file())
162 .map(|e| e.file_name().to_string_lossy().into_owned())
163 .filter(|n| n.ends_with(".md"))
164 .collect();
165 let first = u.format.requirements_file().unwrap_or_default();
166 names.sort_by_key(|n| (n != first, n.clone()));
167 names
168 .into_iter()
169 .filter_map(|n| {
170 let text = std::fs::read_to_string(dir.join(&n)).ok()?;
171 Some((format!("{}/{n}", u.dir.trim_end_matches('/')), text))
172 })
173 .collect()
174}
175
176fn markers(line: &str) -> Vec<(String, String)> {
179 let mut out = Vec::new();
180 let mut rest = line;
181 while let Some(at) = rest.find(MARKER) {
182 let tail = &rest[at..];
183 let Some(end) = tail.find(']') else { break };
184 let raw = &tail[..=end];
185 let question = raw[MARKER.len()..raw.len() - 1]
186 .trim_start_matches(':')
187 .trim()
188 .to_owned();
189 out.push((raw.to_owned(), question));
190 rest = &tail[end + 1..];
191 }
192 out
193}
194
195fn questions(u: &Unit) -> Vec<(OpenQuestion, String)> {
197 let mut out = Vec::new();
198 for (file, text) in documents(u) {
199 let mut section: Option<String> = None;
200 for (i, line) in text.lines().enumerate() {
201 if let Some(h) = line
202 .strip_prefix("## ")
203 .or_else(|| line.strip_prefix("### "))
204 {
205 section = Some(h.trim().to_owned());
206 }
207 let near = u
208 .format
209 .requirements_in(line)
210 .first()
211 .map(|(id, _)| id.clone())
212 .or_else(|| section.clone());
213 for (raw, text) in markers(line) {
214 out.push((
215 OpenQuestion {
216 id: format!("Q{}", out.len() + 1),
217 text,
218 file: file.clone(),
219 line: i + 1,
220 near: near.clone(),
221 },
222 raw,
223 ));
224 }
225 }
226 }
227 out
228}
229
230fn requirements_at(u: &Unit, rev: &str) -> Option<Vec<(String, String)>> {
232 let file = u.format.requirements_file()?;
233 let rel = format!("{}/{file}", u.dir.trim_end_matches('/'));
234 let text = crate::git::Git::at(&u.root)
235 .run(&["show", &format!("{rev}:./{rel}")])
236 .ok()?;
237 Some(u.format.requirements_in(&String::from_utf8_lossy(&text)))
238}
239
240fn compare(before: &[(String, String)], after: &[(String, String)]) -> Vec<RequirementDrift> {
242 let mut ids: Vec<&String> = after.iter().map(|(id, _)| id).collect();
243 for (id, _) in before {
244 if !ids.contains(&id) {
245 ids.push(id);
246 }
247 }
248 let line = |v: &[(String, String)], id: &str| {
249 v.iter()
250 .find(|(i, _)| i == id)
251 .map(|(_, l)| l.trim_end().to_owned())
252 };
253 ids.into_iter()
254 .filter_map(|id| {
255 let (then, now) = (line(before, id), line(after, id));
256 (then != now).then(|| RequirementDrift {
257 id: id.clone(),
258 then,
259 now,
260 })
261 })
262 .collect()
263}
264
265fn history(u: &Unit) -> Vec<SpecVersion> {
267 let Some(file) = u.format.requirements_file() else {
268 return Vec::new();
269 };
270 let rel = format!("{}/{file}", u.dir.trim_end_matches('/'));
271 let git = crate::git::Git::at(&u.root);
272 let log = git
273 .run(&[
274 "log",
275 "-n",
276 "12",
277 "--format=%H%x1f%as%x1f%an%x1f%s",
278 "--",
279 &rel,
280 ])
281 .map(|b| String::from_utf8_lossy(&b).into_owned())
282 .unwrap_or_default();
283 log.lines()
284 .filter_map(|l| {
285 let mut f = l.split('\u{1f}');
286 let (commit, date, author, subject) = (f.next()?, f.next()?, f.next()?, f.next()?);
287 let now = requirements_at(u, commit).unwrap_or_default();
288 let before = requirements_at(u, &format!("{commit}^")).unwrap_or_default();
289 let changes = compare(&before, &now);
290 let pick = |want: fn(&RequirementDrift) -> bool| {
291 changes
292 .iter()
293 .filter(|c| want(c))
294 .map(|c| c.id.clone())
295 .collect::<Vec<_>>()
296 };
297 Some(SpecVersion {
298 commit: commit.to_owned(),
299 date: date.to_owned(),
300 author: author.to_owned(),
301 subject: subject.to_owned(),
302 added: pick(|c| c.then.is_none()),
303 reworded: pick(|c| c.then.is_some() && c.now.is_some()),
304 removed: pick(|c| c.now.is_none()),
305 })
306 })
307 .collect()
308}
309
310#[must_use]
314pub fn totals_of(
315 root: &Path,
316 format: &'static dyn crate::format::Adapter,
317 feature: &str,
318 dir: &str,
319) -> Option<ReviewTotals> {
320 let u = Unit {
321 feature: feature.to_owned(),
322 root: root.to_path_buf(),
323 dir: dir.to_owned(),
324 format,
325 };
326 let requirements = u.requirements();
327 (!requirements.is_empty())
328 .then(|| totals(&items(&requirements, &u.record()), questions(&u).len()))
329}
330
331fn report_of(u: &Unit, full: bool) -> ReviewReport {
334 let items = items(&u.requirements(), &u.record());
335 let skills = crate::skills::discover_with(&u.root, u.format.planning());
336 let installed = |name: &str| {
337 let full = format!("{}{name}", u.format.planning().prefix);
338 skills.iter().any(|s| s.name == full && s.refused.is_none())
339 };
340 let open: Vec<OpenQuestion> = questions(u).into_iter().map(|(q, _)| q).collect();
341 let totals = totals(&items, open.len());
342 let revision = (totals.changes_requested > 0).then(|| revision_request(&items));
343 let changes = if full {
344 requirements_at(u, "HEAD")
345 .map(|then| compare(&then, &u.requirements()))
346 .unwrap_or_default()
347 } else {
348 Vec::new()
349 };
350 ReviewReport {
351 feature: u.feature.clone(),
352 file: u.rel(),
353 totals,
354 revision,
355 questions: open,
356 changes,
357 history: if full { history(u) } else { Vec::new() },
358 requirements: items,
359 revise_with: u
360 .format
361 .planning()
362 .revises
363 .filter(|s| installed(s))
364 .map(str::to_owned),
365 }
366}
367
368fn message(r: &ReviewReport) -> String {
369 let t = r.totals;
370 let n = r.requirements.len();
371 if n == 0 {
372 return format!("{}'s spec names no requirement IDs to review", r.feature);
373 }
374 if t.complete() {
375 return format!("{n}/{n} requirements approved");
376 }
377 let plural =
378 |n: usize, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
379 let parts: Vec<String> = [
380 (
381 t.questions,
382 plural(t.questions, "open question", "open questions"),
383 ),
384 (t.open, format!("{} to review", t.open)),
385 (
386 t.changes_requested,
387 format!("{} with changes requested", t.changes_requested),
388 ),
389 (
390 t.lapsed,
391 format!("{} changed since they were decided", t.lapsed),
392 ),
393 ]
394 .into_iter()
395 .filter(|(k, _)| *k > 0)
396 .map(|(_, w)| w)
397 .collect();
398 format!(
399 "{}/{n} requirements approved; {}",
400 t.approved,
401 parts.join(", ")
402 )
403}
404
405#[must_use]
408pub fn summary(path: &Path, feature: Option<&str>) -> Option<ReviewReport> {
409 unit("review", path, feature)
410 .ok()
411 .map(|u| report_of(&u, false))
412}
413
414#[must_use]
416pub fn report(path: &Path, feature: Option<&str>) -> CommandResult {
417 let u = match unit("review", path, feature) {
418 Ok(u) => u,
419 Err(r) => return *r,
420 };
421 let r = report_of(&u, true);
422 CommandResult::new("review", CommandStatus::Ok, &message(&r))
423 .with_data(CommandData::Review(Box::new(r)))
424}
425
426#[must_use]
430pub fn decide(
431 path: &Path,
432 feature: Option<&str>,
433 ids: &[String],
434 verdict: &str,
435 comment: Option<&str>,
436 via: &str,
437) -> CommandResult {
438 let u = match unit("review", path, feature) {
439 Ok(u) => u,
440 Err(r) => return *r,
441 };
442 let comment = comment.map(str::trim).filter(|c| !c.is_empty());
443 if verdict == "request" && comment.is_none() {
444 return CommandResult::new(
445 "review",
446 CommandStatus::UsageError,
447 "a change request needs a comment: what should the requirement say?",
448 )
449 .with_code("USAGE");
450 }
451 let requirements = u.requirements();
452 let mut record = u.record();
453 let before = items(&requirements, &record);
454 let asks = |line: &str| line.contains(MARKER);
457 let undecided: Vec<&str> = requirements
458 .iter()
459 .filter(|(_, l)| asks(l))
460 .map(|(id, _)| id.as_str())
461 .collect();
462 if verdict == "approve" && !ids.is_empty() {
463 let blocked: Vec<&str> = ids
464 .iter()
465 .map(String::as_str)
466 .filter(|id| undecided.contains(id))
467 .collect();
468 if !blocked.is_empty() {
469 return CommandResult::new(
470 "review",
471 CommandStatus::UsageError,
472 &format!(
473 "{} still {} an open question: answer it first (`speccoder review answer …`), or reword it (`speccoder review edit {} \"…\" --approve`)",
474 blocked.join(", "),
475 if blocked.len() == 1 { "asks" } else { "ask" },
476 blocked[0]
477 ),
478 )
479 .with_code("USAGE");
480 }
481 }
482 let chosen: Vec<&(String, String)> = if ids.is_empty() {
483 requirements
484 .iter()
485 .filter(|(id, l)| {
486 before.iter().any(|i| i.id == *id && i.state != "approved")
487 && !(verdict == "approve" && asks(l))
488 })
489 .collect()
490 } else {
491 let unknown: Vec<&String> = ids
492 .iter()
493 .filter(|id| !requirements.iter().any(|(r, _)| r == *id))
494 .collect();
495 if !unknown.is_empty() {
496 return CommandResult::new(
497 "review",
498 CommandStatus::NotFound,
499 &format!(
500 "the spec has no {}; it names {}",
501 unknown
502 .iter()
503 .map(|s| s.as_str())
504 .collect::<Vec<_>>()
505 .join(", "),
506 requirements
507 .iter()
508 .map(|(r, _)| r.as_str())
509 .collect::<Vec<_>>()
510 .join(", ")
511 ),
512 )
513 .with_code("NOT-FOUND")
514 .with_fix(vec!["speccoder review".to_owned()]);
515 }
516 requirements
517 .iter()
518 .filter(|(id, _)| ids.contains(id))
519 .collect()
520 };
521 let who = crate::approvals::Who::local_in(&u.root, via);
522 let now = std::time::SystemTime::now()
523 .duration_since(std::time::UNIX_EPOCH)
524 .map_or(0, |d| d.as_secs());
525 for (id, line) in &chosen {
526 match verdict {
527 "clear" => {
528 record.requirements.remove(id);
529 }
530 "approve" | "request" => {
531 record.requirements.insert(
532 id.clone(),
533 Decision {
534 verdict: if verdict == "approve" {
535 "approved"
536 } else {
537 "changes_requested"
538 }
539 .to_owned(),
540 line: line.trim_end().to_owned(),
541 sha256: digest(line),
542 by: who.name.clone(),
543 via: who.via.clone(),
544 at: now,
545 comment: comment.map(str::to_owned),
546 },
547 );
548 }
549 other => {
550 return CommandResult::new(
551 "review",
552 CommandStatus::UsageError,
553 &format!("`{other}` is not a review verdict: approve, request or clear"),
554 )
555 .with_code("USAGE");
556 }
557 }
558 }
559 record
561 .requirements
562 .retain(|id, _| requirements.iter().any(|(r, _)| r == id));
563 let text = serde_json::to_string_pretty(&record).unwrap_or_default() + "\n";
564 if let Err(e) = std::fs::write(u.file(), text) {
565 return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", u.rel()));
566 }
567 let r = report_of(&u, true);
568 let word = match verdict {
569 "approve" => "approved",
570 "request" => "changes requested on",
571 _ => "cleared",
572 };
573 let skipped = if verdict == "approve" && ids.is_empty() && !undecided.is_empty() {
574 format!(
575 "; not {} — {} still {} an open question",
576 undecided.join(", "),
577 if undecided.len() == 1 { "it" } else { "they" },
578 if undecided.len() == 1 { "asks" } else { "ask" }
579 )
580 } else {
581 String::new()
582 };
583 let message = if chosen.is_empty() {
584 format!("nothing to decide{skipped}: {}", message(&r))
585 } else {
586 format!(
587 "{word} {}{skipped}; {} — commit {} to share it",
588 chosen
589 .iter()
590 .map(|(id, _)| id.as_str())
591 .collect::<Vec<_>>()
592 .join(", "),
593 message(&r),
594 r.file
595 )
596 };
597 CommandResult::new("review", CommandStatus::Ok, &message)
598 .with_data(CommandData::Review(Box::new(r)))
599}
600
601#[must_use]
604pub fn answer(path: &Path, feature: Option<&str>, id: &str, text: &str) -> CommandResult {
605 let u = match unit("review", path, feature) {
606 Ok(u) => u,
607 Err(r) => return *r,
608 };
609 let text = text.trim();
610 if text.is_empty() {
611 return CommandResult::new("review", CommandStatus::UsageError, "an answer needs words")
612 .with_code("USAGE");
613 }
614 let open = questions(&u);
615 let Some((q, raw)) = open.iter().find(|(q, _)| q.id.eq_ignore_ascii_case(id)) else {
616 return CommandResult::new(
617 "review",
618 CommandStatus::NotFound,
619 &format!(
620 "no open question {id}; open: {}",
621 if open.is_empty() {
622 "none".to_owned()
623 } else {
624 open.iter()
625 .map(|(q, _)| q.id.as_str())
626 .collect::<Vec<_>>()
627 .join(", ")
628 }
629 ),
630 )
631 .with_code("NOT-FOUND");
632 };
633 let file = u.root.join(&q.file);
634 let Ok(body) = std::fs::read_to_string(&file) else {
635 return CommandResult::new(
636 "review",
637 CommandStatus::Error,
638 &format!("{} cannot be read", q.file),
639 );
640 };
641 let mut lines: Vec<String> = body.lines().map(str::to_owned).collect();
642 let Some(line) = lines
643 .get_mut(q.line - 1)
644 .filter(|l| l.contains(raw.as_str()))
645 else {
646 return CommandResult::new(
647 "review",
648 CommandStatus::Error,
649 &format!("{} changed under the question; look again", q.file),
650 );
651 };
652 *line = line.replacen(raw.as_str(), text, 1);
653 let mut out = lines.join("\n");
654 if body.ends_with('\n') {
655 out.push('\n');
656 }
657 if let Err(e) = std::fs::write(&file, out) {
658 return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", q.file));
659 }
660 let r = report_of(&u, true);
661 CommandResult::ok(
662 "review",
663 &format!("answered {} in {} — {}", q.id, q.file, message(&r)),
664 )
665 .with_data(CommandData::Review(Box::new(r)))
666}
667
668#[must_use]
672pub fn edit(
673 path: &Path,
674 feature: Option<&str>,
675 id: &str,
676 wording: &str,
677 approve: bool,
678 via: &str,
679) -> CommandResult {
680 let u = match unit("review", path, feature) {
681 Ok(u) => u,
682 Err(r) => return *r,
683 };
684 let wording = wording.trim();
685 if wording.is_empty() || wording.contains('\n') {
686 return CommandResult::new(
687 "review",
688 CommandStatus::UsageError,
689 "a requirement's wording is one line of words",
690 )
691 .with_code("USAGE");
692 }
693 let Some(spec) = u.format.requirements_file() else {
694 return CommandResult::new(
695 "review",
696 CommandStatus::UsageError,
697 "this format has no requirement identifiers",
698 )
699 .with_code("USAGE");
700 };
701 let Some((_, old)) = u.requirements().into_iter().find(|(r, _)| r == id) else {
702 return CommandResult::new(
703 "review",
704 CommandStatus::NotFound,
705 &format!("the spec has no {id}"),
706 )
707 .with_code("NOT-FOUND");
708 };
709 let key = format!("**{id}**");
711 let Some(at) = old.find(&key) else {
712 return CommandResult::new(
713 "review",
714 CommandStatus::Error,
715 &format!("{id}'s line has no `{key}`"),
716 );
717 };
718 let new = format!("{}{key}: {wording}", &old[..at]);
719 let file = u.root.join(&u.dir).join(spec);
720 let body = std::fs::read_to_string(&file).unwrap_or_default();
721 if !body.lines().any(|l| l.trim_end() == old.trim_end()) {
722 return CommandResult::new(
723 "review",
724 CommandStatus::Error,
725 &format!("{spec} changed under {id}; look again"),
726 );
727 }
728 let mut done = false;
729 let mut out: Vec<String> = Vec::new();
730 for l in body.lines() {
731 if !done && l.trim_end() == old.trim_end() {
732 out.push(new.clone());
733 done = true;
734 } else {
735 out.push(l.to_owned());
736 }
737 }
738 let mut text = out.join("\n");
739 if body.ends_with('\n') {
740 text.push('\n');
741 }
742 if let Err(e) = std::fs::write(&file, text) {
743 return CommandResult::new("review", CommandStatus::Error, &format!("{spec}: {e}"));
744 }
745 if approve {
746 let r = decide(path, feature, &[id.to_owned()], "approve", None, via);
747 let status = r.status();
748 let message = if status == CommandStatus::Ok {
749 format!("reworded {id} and approved it; {}", r.message())
750 } else {
751 format!("reworded {id}; not approved: {}", r.message())
752 };
753 return r.reworded(status, &message);
754 }
755 let r = report_of(&u, true);
756 CommandResult::ok("review", &format!("reworded {id}; {}", message(&r)))
757 .with_data(CommandData::Review(Box::new(r)))
758}
759
760#[must_use]
763fn revision_request(items: &[ReviewItem]) -> String {
764 let lines: Vec<String> = items
765 .iter()
766 .filter(|i| i.state == "changes_requested")
767 .map(|i| {
768 format!(
769 "- {} (\"{}\"): {}",
770 i.id,
771 i.text,
772 i.comment.as_deref().unwrap_or_default()
773 )
774 })
775 .collect();
776 format!(
777 "Revise the spec per the reviewer's change requests; keep every requirement ID, reword only what is asked:\n{}",
778 lines.join("\n")
779 )
780}
781
782#[derive(Debug)]
785pub struct Snapshot {
786 root: PathBuf,
787 files: Vec<(PathBuf, Option<Vec<u8>>)>,
788}
789
790fn records(root: &Path) -> Vec<(PathBuf, Option<Vec<u8>>)> {
792 let format = crate::format::at(root);
793 let Some(snap) = format.inspect(root, None) else {
794 return Vec::new();
795 };
796 snap.features
797 .iter()
798 .map(|f| {
799 let file = Path::new(&snap.root).join(&f.dir).join(REVIEW_FILE);
800 let bytes = std::fs::read(&file).ok();
801 (file, bytes)
802 })
803 .collect()
804}
805
806#[must_use]
808pub fn snapshot(root: &Path) -> Snapshot {
809 Snapshot {
810 root: root.to_path_buf(),
811 files: records(root),
812 }
813}
814
815pub fn restore(before: &Snapshot) -> Vec<String> {
818 let mut restored = Vec::new();
819 let now = records(&before.root);
820 for (file, bytes) in &now {
821 let was = before
822 .files
823 .iter()
824 .find(|(f, _)| f == file)
825 .and_then(|(_, b)| b.clone());
826 if *bytes == was {
827 continue;
828 }
829 let ok = match &was {
830 Some(b) => std::fs::write(file, b).is_ok(),
831 None => std::fs::remove_file(file).is_ok(),
832 };
833 if ok {
834 restored.push(file.display().to_string());
835 }
836 }
837 restored
838}
839
840#[cfg(test)]
841mod tests {
842 use super::*;
843
844 #[test]
845 fn the_wording_is_the_line_without_its_markup() {
846 assert_eq!(
847 wording("- **FR-001**: Users MUST log in."),
848 "Users MUST log in."
849 );
850 assert_eq!(digest("a "), digest("a"));
851 }
852}