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 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
45 oracle: BTreeMap<String, crate::oracle::OracleEntry>,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 approval: Option<speccoder_core::oracle::ReviewApproval>,
49}
50
51fn digest(line: &str) -> String {
52 format!(
53 "sha256:{}",
54 crate::integrity::sha256_hex(line.trim_end().as_bytes())
55 )
56}
57
58fn wording(line: &str) -> String {
60 let t = line.trim_start().trim_start_matches("- ");
61 match t.strip_prefix("**").and_then(|r| r.split_once("**")) {
62 Some((_, rest)) => rest.trim_start_matches(':').trim().to_owned(),
63 None => t.trim().to_owned(),
64 }
65}
66
67struct Unit {
69 feature: String,
70 root: PathBuf,
71 dir: String,
72 format: &'static dyn crate::format::Adapter,
73 snapshot: Option<Box<speccoder_core::snapshot::ProjectSnapshot>>,
76 state: Option<PathBuf>,
78}
79
80impl Unit {
81 fn file(&self) -> PathBuf {
82 self.root.join(&self.dir).join(REVIEW_FILE)
83 }
84 fn rel(&self) -> String {
85 format!("{}/{REVIEW_FILE}", self.dir.trim_end_matches('/'))
86 }
87 fn requirements(&self) -> Vec<(String, String)> {
89 let Some(file) = self.format.requirements_file() else {
90 return Vec::new();
91 };
92 let text =
93 std::fs::read_to_string(self.root.join(&self.dir).join(file)).unwrap_or_default();
94 self.format.requirements_in(&text)
95 }
96 fn record(&self) -> Result<Record, String> {
99 read_record(&self.file())
100 }
101 fn oracle_requirements(&self) -> Vec<crate::format::OracleRequirement> {
103 self.snapshot
104 .as_ref()
105 .map(|s| self.format.oracle_requirements(s))
106 .unwrap_or_default()
107 .into_iter()
108 .filter(|r| !r.scenarios.is_empty())
109 .collect()
110 }
111 fn rung(&self) -> speccoder_core::ladder::Rung {
113 crate::oracle::rung(self.format, &self.root, &self.feature)
114 }
115}
116
117fn unit(command: &str, path: &Path, feature: Option<&str>) -> Result<Unit, Box<CommandResult>> {
118 unit_in(command, path, feature, None)
119}
120
121fn unit_in(
122 command: &str,
123 path: &Path,
124 feature: Option<&str>,
125 state: Option<&Path>,
126) -> Result<Unit, Box<CommandResult>> {
127 let inspected = crate::inspect(path, feature);
128 let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
129 return Err(Box::new(CommandResult::carried(command, &inspected)));
130 };
131 let Some(f) = snapshot.feature.as_ref() else {
132 return Err(Box::new(CommandResult::new(
133 command,
134 CommandStatus::SelectionNeeded,
135 inspected.message(),
136 )));
137 };
138 let root = PathBuf::from(&snapshot.root);
139 let state = state
140 .map(Path::to_path_buf)
141 .or_else(crate::worktree::state_dir)
142 .map(|s| crate::worktree::project_state_dir(&s, &root));
143 Ok(Unit {
144 feature: f.feature.name.clone(),
145 root,
146 dir: f.feature.dir.clone(),
147 format: crate::format::of(snapshot),
148 snapshot: Some(snapshot.clone()),
149 state,
150 })
151}
152
153fn items(requirements: &[(String, String)], record: &Record) -> Vec<ReviewItem> {
155 requirements
156 .iter()
157 .map(|(id, line)| {
158 let d = record.requirements.get(id);
159 let current = d.is_some_and(|d| d.sha256 == digest(line));
160 let state = match d {
161 None => "open",
162 Some(_) if !current => "lapsed",
163 Some(d) if d.verdict == "approved" => "approved",
164 Some(_) => "changes_requested",
165 };
166 ReviewItem {
167 id: id.clone(),
168 text: wording(line),
169 state: state.to_owned(),
170 by: d.map(|d| d.by.clone()),
171 at: d.map(|d| d.at),
172 comment: d.and_then(|d| d.comment.clone()),
173 decided_on: d.filter(|_| !current).map(|d| wording(&d.line)),
174 }
175 })
176 .collect()
177}
178
179fn totals(items: &[ReviewItem], questions: usize) -> ReviewTotals {
180 let n = |s: &str| items.iter().filter(|i| i.state == s).count();
181 ReviewTotals {
182 approved: n("approved"),
183 changes_requested: n("changes_requested"),
184 open: n("open"),
185 lapsed: n("lapsed"),
186 questions,
187 ambiguous: 0,
188 }
189}
190
191const MARKER: &str = "[NEEDS CLARIFICATION";
193
194fn documents(u: &Unit) -> Vec<(String, String)> {
197 let dir = u.root.join(&u.dir);
198 let mut names: Vec<String> = std::fs::read_dir(&dir)
199 .into_iter()
200 .flatten()
201 .flatten()
202 .filter(|e| e.path().is_file())
203 .map(|e| e.file_name().to_string_lossy().into_owned())
204 .filter(|n| n.ends_with(".md"))
205 .collect();
206 let first = u.format.requirements_file().unwrap_or_default();
207 names.sort_by_key(|n| (n != first, n.clone()));
208 names
209 .into_iter()
210 .filter_map(|n| {
211 let text = std::fs::read_to_string(dir.join(&n)).ok()?;
212 Some((format!("{}/{n}", u.dir.trim_end_matches('/')), text))
213 })
214 .collect()
215}
216
217fn markers(line: &str) -> Vec<(String, String)> {
220 let mut out = Vec::new();
221 let mut rest = line;
222 while let Some(at) = rest.find(MARKER) {
223 let tail = &rest[at..];
224 let Some(end) = tail.find(']') else { break };
225 let raw = &tail[..=end];
226 let question = raw[MARKER.len()..raw.len() - 1]
227 .trim_start_matches(':')
228 .trim()
229 .to_owned();
230 out.push((raw.to_owned(), question));
231 rest = &tail[end + 1..];
232 }
233 out
234}
235
236fn questions(u: &Unit) -> Vec<(OpenQuestion, String)> {
238 let mut out = Vec::new();
239 for (file, text) in documents(u) {
240 let mut section: Option<String> = None;
241 for (i, line) in text.lines().enumerate() {
242 if let Some(h) = line
243 .strip_prefix("## ")
244 .or_else(|| line.strip_prefix("### "))
245 {
246 section = Some(h.trim().to_owned());
247 }
248 let near = u
249 .format
250 .requirements_in(line)
251 .first()
252 .map(|(id, _)| id.clone())
253 .or_else(|| section.clone());
254 for (raw, text) in markers(line) {
255 out.push((
256 OpenQuestion {
257 id: format!("Q{}", out.len() + 1),
258 text,
259 file: file.clone(),
260 line: i + 1,
261 near: near.clone(),
262 },
263 raw,
264 ));
265 }
266 }
267 }
268 out
269}
270
271fn requirements_at(u: &Unit, rev: &str) -> Option<Vec<(String, String)>> {
273 let file = u.format.requirements_file()?;
274 let rel = format!("{}/{file}", u.dir.trim_end_matches('/'));
275 let text = crate::git::Git::at(&u.root)
276 .run(&["show", &format!("{rev}:./{rel}")])
277 .ok()?;
278 Some(u.format.requirements_in(&String::from_utf8_lossy(&text)))
279}
280
281fn changes_since_head(u: &Unit) -> Vec<speccoder_core::ReviewChange> {
284 let Some(file) = u.format.requirements_file() else {
285 return Vec::new();
286 };
287 let Some(then) = requirements_at(u, "HEAD") else {
288 return Vec::new();
289 };
290 let path = format!("{}/{file}", u.dir.trim_end_matches('/'));
291 let old_text = crate::git::Git::at(&u.root)
292 .run(&["show", &format!("HEAD:./{path}")])
293 .map(|b| String::from_utf8_lossy(&b).into_owned())
294 .unwrap_or_default();
295 let new_text = std::fs::read_to_string(u.root.join(&path)).unwrap_or_default();
296 let line_of = |text: &str, line: &Option<String>| {
297 let line = line.as_deref()?.trim_end();
298 text.lines()
299 .position(|l| l.trim_end() == line)
300 .and_then(|i| u32::try_from(i + 1).ok())
301 };
302 compare(&then, &u.requirements())
303 .into_iter()
304 .map(|d| speccoder_core::ReviewChange {
305 then_line: line_of(&old_text, &d.then),
306 now_line: line_of(&new_text, &d.now),
307 path: path.clone(),
308 id: d.id,
309 then: d.then,
310 now: d.now,
311 })
312 .collect()
313}
314
315fn compare(before: &[(String, String)], after: &[(String, String)]) -> Vec<RequirementDrift> {
317 let mut ids: Vec<&String> = after.iter().map(|(id, _)| id).collect();
318 for (id, _) in before {
319 if !ids.contains(&id) {
320 ids.push(id);
321 }
322 }
323 let line = |v: &[(String, String)], id: &str| {
324 v.iter()
325 .find(|(i, _)| i == id)
326 .map(|(_, l)| l.trim_end().to_owned())
327 };
328 ids.into_iter()
329 .filter_map(|id| {
330 let (then, now) = (line(before, id), line(after, id));
331 (then != now).then(|| RequirementDrift {
332 id: id.clone(),
333 then,
334 now,
335 })
336 })
337 .collect()
338}
339
340fn history(u: &Unit) -> Vec<SpecVersion> {
342 let Some(file) = u.format.requirements_file() else {
343 return Vec::new();
344 };
345 let rel = format!("{}/{file}", u.dir.trim_end_matches('/'));
346 let git = crate::git::Git::at(&u.root);
347 let log = git
348 .run(&[
349 "log",
350 "-n",
351 "12",
352 "--format=%H%x1f%as%x1f%an%x1f%s",
353 "--",
354 &rel,
355 ])
356 .map(|b| String::from_utf8_lossy(&b).into_owned())
357 .unwrap_or_default();
358 log.lines()
359 .filter_map(|l| {
360 let mut f = l.split('\u{1f}');
361 let (commit, date, author, subject) = (f.next()?, f.next()?, f.next()?, f.next()?);
362 let now = requirements_at(u, commit).unwrap_or_default();
363 let before = requirements_at(u, &format!("{commit}^")).unwrap_or_default();
364 let changes = compare(&before, &now);
365 let pick = |want: fn(&RequirementDrift) -> bool| {
366 changes
367 .iter()
368 .filter(|c| want(c))
369 .map(|c| c.id.clone())
370 .collect::<Vec<_>>()
371 };
372 Some(SpecVersion {
373 commit: commit.to_owned(),
374 date: date.to_owned(),
375 author: author.to_owned(),
376 subject: subject.to_owned(),
377 added: pick(|c| c.then.is_none()),
378 reworded: pick(|c| c.then.is_some() && c.now.is_some()),
379 removed: pick(|c| c.now.is_none()),
380 })
381 })
382 .collect()
383}
384
385#[must_use]
389pub fn totals_of(
390 root: &Path,
391 format: &'static dyn crate::format::Adapter,
392 feature: &str,
393 dir: &str,
394) -> Option<ReviewTotals> {
395 let u = Unit {
396 feature: feature.to_owned(),
397 root: root.to_path_buf(),
398 dir: dir.to_owned(),
399 format,
400 snapshot: None,
401 state: None,
402 };
403 let requirements = u.requirements();
404 (!requirements.is_empty()).then(|| {
406 totals(
407 &items(&requirements, &u.record().unwrap_or_default()),
408 questions(&u).len(),
409 )
410 })
411}
412
413fn report_of(u: &Unit, full: bool) -> ReviewReport {
416 let (record, error) = match u.record() {
419 Ok(r) => (r, None),
420 Err(e) => (Record::default(), Some(e)),
421 };
422 let items = items(&u.requirements(), &record);
423 let skills = crate::skills::discover_with(&u.root, u.format.planning());
424 let installed = |name: &str| {
425 let full = format!("{}{name}", u.format.planning().prefix);
426 skills.iter().any(|s| s.name == full && s.refused.is_none())
427 };
428 let open: Vec<OpenQuestion> = questions(u).into_iter().map(|(q, _)| q).collect();
429 let totals = totals(&items, open.len());
430 let revision = (totals.changes_requested > 0).then(|| revision_request(&items));
431 let changes = if full {
432 changes_since_head(u)
433 } else {
434 Vec::new()
435 };
436 let rung = u.rung();
437 let (oracle, tests_notice) = if rung == speccoder_core::ladder::Rung::Feature {
438 let cfg = crate::oracle::ViewConfig::at(&u.root);
439 let views = u
440 .oracle_requirements()
441 .iter()
442 .map(|r| match &u.state {
443 Some(state) => {
444 crate::oracle::view(state, &u.feature, r, record.oracle.get(&r.key), cfg)
445 }
446 None => crate::oracle::view(
447 Path::new("/nonexistent-speccoder-state"),
448 &u.feature,
449 r,
450 record.oracle.get(&r.key),
451 cfg,
452 ),
453 })
454 .collect::<Vec<_>>();
455 (views, None)
456 } else {
457 (
458 Vec::new(),
459 Some("Tests for this unit are written and confirmed by its own pipeline".to_owned()),
460 )
461 };
462 let bug_tests = if rung == speccoder_core::ladder::Rung::Bug {
463 u.state
464 .as_deref()
465 .map(|plane| confirmed_reproductions(plane, &u.feature))
466 .unwrap_or_default()
467 } else {
468 Vec::new()
469 };
470 let mut totals = totals;
471 totals.ambiguous = oracle
472 .iter()
473 .filter(|v| v.open_ambiguities().next().is_some())
474 .count();
475 let sheet = None;
477 ReviewReport {
478 feature: u.feature.clone(),
479 file: u.rel(),
480 totals,
481 revision,
482 questions: open,
483 changes,
484 history: if full { history(u) } else { Vec::new() },
485 requirements: items,
486 revise_with: u
487 .format
488 .planning()
489 .revises
490 .filter(|s| installed(s))
491 .map(str::to_owned),
492 oracle,
493 tests_notice,
494 bug_tests,
495 approval: record.approval,
496 sheet,
497 error,
498 }
499}
500
501fn confirmed_reproductions(plane: &Path, unit: &str) -> Vec<speccoder_core::oracle::BugTests> {
504 let mut bugs: Vec<speccoder_core::bug::BugReport> = crate::bug::state::all(plane)
505 .into_iter()
506 .filter(|b| b.unit == unit && b.confirmation.is_some())
507 .collect();
508 bugs.sort_by(|a, b| a.task.cmp(&b.task));
509 bugs.into_iter()
510 .filter_map(|b| {
511 let paths: Vec<String> = b.reproductions.iter().map(|r| r.file.clone()).collect();
512 let kept = crate::bug::state::repro(plane, &b.unit, &b.task, &paths);
513 let reproductions = b
514 .reproductions
515 .iter()
516 .map(|r| speccoder_core::oracle::BugTest {
517 statement: r.statement.clone(),
518 file: r.file.clone(),
519 test: r.test.clone(),
520 red_class: r
521 .baseline
522 .last()
523 .map(|c| crate::pipeline::class_word(*c))
524 .unwrap_or_default(),
525 content: kept
526 .iter()
527 .find(|(p, _)| *p == r.file)
528 .filter(|(_, c)| {
529 format!("sha256:{}", crate::integrity::sha256_hex(c.as_bytes()))
530 == r.digest
531 })
532 .map(|(_, c)| c.clone()),
533 })
534 .collect();
535 Some(speccoder_core::oracle::BugTests {
536 task: b.task.clone(),
537 title: b.title.clone(),
538 confirmation: b.confirmation.clone()?,
539 reproductions,
540 })
541 })
542 .collect()
543}
544
545fn run_active(u: &Unit) -> bool {
547 crate::worktree::state_dir()
548 .and_then(|s| crate::progress::read_live(&u.root, &s))
549 .is_some_and(|(live, running)| running && live.finished.is_none())
550}
551
552fn autopilot_blocked(root: &Path) -> Option<String> {
554 let text = crate::verify::find_config(root)
555 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
556 .unwrap_or_default();
557 let mode = crate::sandbox::parse(&text)
558 .map(|c| c.mode)
559 .unwrap_or(crate::sandbox::Mode::Auto);
560 if mode == crate::sandbox::Mode::Off {
561 return Some("autopilot needs the OS sandbox ([sandbox] mode = \"off\")".to_owned());
562 }
563 crate::sandbox::backend()
564 .err()
565 .map(|e| format!("autopilot needs the OS sandbox ({e})"))
566}
567
568fn sandbox_line(root: &Path) -> Option<String> {
570 let text = crate::verify::find_config(root)
571 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
572 .unwrap_or_default();
573 let config = crate::sandbox::parse(&text).ok()?;
574 let backend = crate::sandbox::backend().ok()?;
575 let network = match config.network {
576 crate::sandbox::Network::Off => "network off",
577 crate::sandbox::Network::Loopback => "loopback only",
578 crate::sandbox::Network::On => "network on",
579 };
580 let backend = match backend {
581 crate::sandbox::Backend::Seatbelt => "Seatbelt",
582 crate::sandbox::Backend::Landlock(_) => "Landlock+seccomp",
583 };
584 Some(format!("Sandbox: {backend}, {network}"))
585}
586
587fn sheet(
590 u: &Unit,
591 items: &[ReviewItem],
592 questions: &[OpenQuestion],
593 oracle: &[speccoder_core::oracle::OracleView],
594) -> speccoder_core::oracle::ApproveSheet {
595 let mut blocked = Vec::new();
596 if !questions.is_empty() {
597 blocked.push(format!(
598 "{} open question(s): {}",
599 questions.len(),
600 questions
601 .iter()
602 .map(|q| q.near.clone().unwrap_or_else(|| q.id.clone()))
603 .collect::<Vec<_>>()
604 .join(", ")
605 ));
606 }
607 for v in oracle {
608 for a in v.open_ambiguities() {
609 blocked.push(format!(
610 "{}: two readings of {} disagree — choose one, or reword",
611 v.req, a.scenario
612 ));
613 }
614 }
615 if run_active(u) {
616 blocked.push("a run is active".to_owned());
617 }
618 let sha256 = match (&u.state, u.record()) {
621 (Some(plane), Ok(record)) => {
622 match crate::oracle::freezable(plane, &u.feature, &u.oracle_requirements()) {
623 Ok(f) => Some(would_approve(&u.requirements(), &record, &f)),
624 Err(t) => {
625 blocked.push(format!("a test draft cannot be read: {}", t.0));
626 None
627 }
628 }
629 }
630 (_, Err(e)) => {
631 blocked.push(e);
632 None
633 }
634 (None, Ok(_)) => None,
635 };
636 speccoder_core::oracle::ApproveSheet {
637 sha256,
638 approves: items
639 .iter()
640 .filter(|i| matches!(i.state.as_str(), "open" | "lapsed"))
641 .map(|i| i.id.clone())
642 .collect(),
643 blocked,
644 autopilot_blocked: autopilot_blocked(&u.root),
645 sandbox: sandbox_line(&u.root),
646 grants: u
647 .state
648 .as_ref()
649 .map(|s| crate::approvals::offered_grants(s, &u.feature))
650 .unwrap_or_default(),
651 }
652}
653
654pub fn oracle_entries(
660 root: &Path,
661 dir: &str,
662) -> Result<(BTreeMap<String, crate::oracle::OracleEntry>, Option<String>), String> {
663 let record = read_record(&root.join(dir).join(REVIEW_FILE))?;
664 Ok((record.oracle, record.approval.map(|a| a.sha256)))
665}
666
667fn read_record(file: &Path) -> Result<Record, String> {
670 match std::fs::read_to_string(file) {
671 Ok(text) => serde_json::from_str(&text)
672 .map_err(|e| format!("{} is not a valid review record: {e}", file.display())),
673 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Record::default()),
674 Err(e) => Err(format!("{}: {e}", file.display())),
675 }
676}
677
678fn unreadable(e: &str) -> CommandResult {
680 CommandResult::new("review", CommandStatus::NeedsHuman, e).with_code("REVIEW-UNREADABLE")
681}
682
683fn message(r: &ReviewReport) -> String {
684 let t = r.totals;
685 let n = r.requirements.len();
686 if n == 0 {
687 return format!("{}'s spec names no requirement IDs to review", r.feature);
688 }
689 if t.complete() {
690 return format!("{n}/{n} requirements approved");
691 }
692 let plural =
693 |n: usize, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
694 let parts: Vec<String> = [
695 (
696 t.questions,
697 plural(t.questions, "open question", "open questions"),
698 ),
699 (t.open, format!("{} to review", t.open)),
700 (
701 t.changes_requested,
702 format!("{} with changes requested", t.changes_requested),
703 ),
704 (
705 t.lapsed,
706 format!("{} changed since they were decided", t.lapsed),
707 ),
708 ]
709 .into_iter()
710 .filter(|(k, _)| *k > 0)
711 .map(|(_, w)| w)
712 .collect();
713 format!(
714 "{}/{n} requirements approved; {}",
715 t.approved,
716 parts.join(", ")
717 )
718}
719
720#[must_use]
723pub fn summary(path: &Path, feature: Option<&str>) -> Option<ReviewReport> {
724 unit("review", path, feature)
725 .ok()
726 .map(|u| report_of(&u, false))
727}
728
729#[must_use]
731pub fn report(path: &Path, feature: Option<&str>) -> CommandResult {
732 report_in(path, feature, None)
733}
734
735#[must_use]
737pub fn report_in(path: &Path, feature: Option<&str>, state: Option<&Path>) -> CommandResult {
738 let u = match unit_in("review", path, feature, state) {
739 Ok(u) => u,
740 Err(r) => return *r,
741 };
742 let mut r = report_of(&u, true);
743 r.sheet = Some(sheet(&u, &r.requirements, &r.questions, &r.oracle));
744 CommandResult::new("review", CommandStatus::Ok, &message(&r))
745 .with_data(CommandData::Review(Box::new(r)))
746}
747
748#[must_use]
752pub fn decide(
753 path: &Path,
754 feature: Option<&str>,
755 ids: &[String],
756 verdict: &str,
757 comment: Option<&str>,
758 via: speccoder_core::actions::Via,
759) -> CommandResult {
760 decide_in(path, feature, ids, verdict, comment, via, None)
761}
762
763#[must_use]
765pub fn decide_in(
766 path: &Path,
767 feature: Option<&str>,
768 ids: &[String],
769 verdict: &str,
770 comment: Option<&str>,
771 via: speccoder_core::actions::Via,
772 state: Option<&Path>,
773) -> CommandResult {
774 let u = match unit_in("review", path, feature, state) {
775 Ok(u) => u,
776 Err(r) => return *r,
777 };
778 let comment = comment.map(str::trim).filter(|c| !c.is_empty());
779 if verdict == "request" && comment.is_none() {
780 return CommandResult::new(
781 "review",
782 CommandStatus::UsageError,
783 "a change request needs a comment: what should the requirement say?",
784 )
785 .with_code("USAGE");
786 }
787 let requirements = u.requirements();
788 let mut record = match u.record() {
789 Ok(r) => r,
790 Err(e) => return unreadable(&e),
791 };
792 let before = items(&requirements, &record);
793 let asks = |line: &str| line.contains(MARKER);
796 let undecided: Vec<&str> = requirements
797 .iter()
798 .filter(|(_, l)| asks(l))
799 .map(|(id, _)| id.as_str())
800 .collect();
801 if verdict == "approve" && !ids.is_empty() {
802 let blocked: Vec<&str> = ids
803 .iter()
804 .map(String::as_str)
805 .filter(|id| undecided.contains(id))
806 .collect();
807 if !blocked.is_empty() {
808 return CommandResult::new(
809 "review",
810 CommandStatus::UsageError,
811 &format!(
812 "{} still {} an open question: answer it first (`speccoder review answer …`), or reword it (`speccoder review edit {} \"…\" --approve`)",
813 blocked.join(", "),
814 if blocked.len() == 1 { "asks" } else { "ask" },
815 blocked[0]
816 ),
817 )
818 .with_code("USAGE");
819 }
820 }
821 let chosen: Vec<&(String, String)> = if ids.is_empty() {
822 requirements
823 .iter()
824 .filter(|(id, l)| {
825 before.iter().any(|i| i.id == *id && i.state != "approved")
826 && !(verdict == "approve" && asks(l))
827 })
828 .collect()
829 } else {
830 let unknown: Vec<&String> = ids
831 .iter()
832 .filter(|id| !requirements.iter().any(|(r, _)| r == *id))
833 .collect();
834 if !unknown.is_empty() {
835 return CommandResult::new(
836 "review",
837 CommandStatus::NotFound,
838 &format!(
839 "the spec has no {}; it names {}",
840 unknown
841 .iter()
842 .map(|s| s.as_str())
843 .collect::<Vec<_>>()
844 .join(", "),
845 requirements
846 .iter()
847 .map(|(r, _)| r.as_str())
848 .collect::<Vec<_>>()
849 .join(", ")
850 ),
851 )
852 .with_code("NOT-FOUND")
853 .with_fix(vec!["speccoder review".to_owned()]);
854 }
855 requirements
856 .iter()
857 .filter(|(id, _)| ids.contains(id))
858 .collect()
859 };
860 let who = crate::approvals::Who::local_in(&u.root, via);
861 let now = std::time::SystemTime::now()
862 .duration_since(std::time::UNIX_EPOCH)
863 .map_or(0, |d| d.as_secs());
864 for (id, line) in &chosen {
865 match verdict {
866 "clear" => {
867 record.requirements.remove(id);
868 }
869 "approve" | "request" => {
870 record.requirements.insert(
871 id.clone(),
872 Decision {
873 verdict: if verdict == "approve" {
874 "approved"
875 } else {
876 "changes_requested"
877 }
878 .to_owned(),
879 line: line.trim_end().to_owned(),
880 sha256: digest(line),
881 by: who.name.clone(),
882 via: who.via.clone(),
883 at: now,
884 comment: comment.map(str::to_owned),
885 },
886 );
887 }
888 other => {
889 return CommandResult::new(
890 "review",
891 CommandStatus::UsageError,
892 &format!("`{other}` is not a review verdict: approve, request or clear"),
893 )
894 .with_code("USAGE");
895 }
896 }
897 }
898 record
900 .requirements
901 .retain(|id, _| requirements.iter().any(|(r, _)| r == id));
902 let named = u.oracle_requirements();
903 if u.snapshot.is_some() {
904 record
905 .oracle
906 .retain(|key, _| named.iter().any(|r| r.key == *key));
907 }
908 let text = serde_json::to_string_pretty(&record).unwrap_or_default() + "\n";
909 if let Err(e) = std::fs::write(u.file(), text) {
910 return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", u.rel()));
911 }
912 let r = report_of(&u, true);
913 let word = match verdict {
914 "approve" => "approved",
915 "request" => "changes requested on",
916 _ => "cleared",
917 };
918 let skipped = if verdict == "approve" && ids.is_empty() && !undecided.is_empty() {
919 format!(
920 "; not {} — {} still {} an open question",
921 undecided.join(", "),
922 if undecided.len() == 1 { "it" } else { "they" },
923 if undecided.len() == 1 { "asks" } else { "ask" }
924 )
925 } else {
926 String::new()
927 };
928 let ambiguous: Vec<String> = r
929 .oracle
930 .iter()
931 .filter(|v| v.open_ambiguities().next().is_some())
932 .map(|v| v.req.clone())
933 .collect();
934 let skipped = if verdict == "approve" && !ambiguous.is_empty() {
935 format!(
936 "{skipped}; {} {} ambiguous — Approve and Run stays blocked",
937 ambiguous.join(", "),
938 if ambiguous.len() == 1 { "is" } else { "are" }
939 )
940 } else {
941 skipped
942 };
943 let message = if chosen.is_empty() {
944 format!("nothing to decide{skipped}: {}", message(&r))
945 } else {
946 format!(
947 "{word} {}{skipped}; {} — commit {} to share it",
948 chosen
949 .iter()
950 .map(|(id, _)| id.as_str())
951 .collect::<Vec<_>>()
952 .join(", "),
953 message(&r),
954 r.file
955 )
956 };
957 CommandResult::new("review", CommandStatus::Ok, &message)
958 .with_data(CommandData::Review(Box::new(r)))
959}
960
961fn would_approve(
966 requirements: &[(String, String)],
967 record: &Record,
968 freezable: &[crate::oracle::Freezable],
969) -> String {
970 let before = items(requirements, record);
971 let approved: Vec<(String, String)> = requirements
972 .iter()
973 .filter_map(|(id, line)| {
974 let state = before.iter().find(|i| i.id == *id)?.state.as_str();
975 match state {
976 "open" | "lapsed" => Some((id.clone(), digest(line))),
977 _ => record
978 .requirements
979 .get(id)
980 .filter(|d| d.verdict == "approved")
981 .map(|d| (id.clone(), d.sha256.clone())),
982 }
983 })
984 .collect();
985 approval_digest(
986 approved.iter().map(|(a, b)| (a.as_str(), b.as_str())),
987 freezable
988 .iter()
989 .map(|f| (f.req.key.as_str(), f.manifest.as_str())),
990 )
991}
992
993#[must_use]
997pub fn approval_digest<'a>(
998 decisions: impl IntoIterator<Item = (&'a str, &'a str)>,
999 oracle: impl IntoIterator<Item = (&'a str, &'a str)>,
1000) -> String {
1001 let mut lines: Vec<String> = decisions
1002 .into_iter()
1003 .map(|(id, sha)| format!("{id}={sha}"))
1004 .collect();
1005 lines.sort();
1006 let mut frozen: Vec<String> = oracle
1007 .into_iter()
1008 .map(|(req, sha)| format!("{req}={sha}"))
1009 .collect();
1010 frozen.sort();
1011 format!(
1012 "sha256:{}",
1013 crate::integrity::sha256_hex(
1014 format!("{}\n{}", lines.join("\n"), frozen.join("\n")).as_bytes()
1015 )
1016 )
1017}
1018
1019#[must_use]
1026#[allow(clippy::too_many_arguments)]
1027pub fn approve_and_run(
1028 path: &Path,
1029 feature: Option<&str>,
1030 mode: speccoder_core::oracle::RunMode,
1031 grants: &[String],
1032 sheet: Option<&str>,
1033 via: speccoder_core::actions::Via,
1034 state: Option<&Path>,
1035 provider: Option<std::sync::Arc<dyn agentplane::model::ModelProvider>>,
1036 options: &crate::RunOptions,
1037) -> CommandResult {
1038 use speccoder_core::oracle::RunMode;
1039 let u = match unit_in("review", path, feature, state) {
1040 Ok(u) => u,
1041 Err(r) => return *r,
1042 };
1043 let Some(plane) = u.state.clone() else {
1044 return CommandResult::new(
1045 "review",
1046 CommandStatus::ConfigError,
1047 "no state directory (set SPECCODER_STATE_DIR or HOME)",
1048 )
1049 .with_code("NO-STATE-DIR");
1050 };
1051 let refuse =
1052 |status, code: &str, m: String| CommandResult::new("review", status, &m).with_code(code);
1053 if run_active(&u) {
1054 return refuse(
1055 CommandStatus::GitPrecondition,
1056 "RUN-ACTIVE",
1057 format!(
1058 "a run is active for {}: approve and run when it ends",
1059 u.feature
1060 ),
1061 );
1062 }
1063 let lock = match crate::oracle::UnitLock::take(&plane, &u.feature) {
1065 Ok(l) => l,
1066 Err(e) => return refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", e),
1067 };
1068 let open = questions(&u);
1069 if !open.is_empty() {
1070 return refuse(
1071 CommandStatus::NeedsHuman,
1072 "REVIEW-PENDING",
1073 format!(
1074 "{} open question(s) first: {} — answer (`speccoder review answer Q1 \"…\"`) or reword",
1075 open.len(),
1076 open.iter()
1077 .map(|(q, _)| q.id.clone())
1078 .collect::<Vec<_>>()
1079 .join(", ")
1080 ),
1081 );
1082 }
1083 let named = u.oracle_requirements();
1084 let ambiguous = match crate::oracle::open_ambiguities(&plane, &u.feature, &named) {
1085 Ok(a) => a,
1086 Err(t) => return refuse(CommandStatus::NeedsHuman, "ORACLE-TAMPERED", t.0),
1087 };
1088 if !ambiguous.is_empty() {
1089 return refuse(
1090 CommandStatus::NeedsHuman,
1091 "REVIEW-AMBIGUOUS",
1092 format!(
1093 "Approve and Run waits: {} read two ways — choose a reading (`speccoder review tests {} --reading A|B {}`) or reword",
1094 ambiguous
1095 .iter()
1096 .map(|(r, s)| format!("{r}/{s}"))
1097 .collect::<Vec<_>>()
1098 .join(", "),
1099 ambiguous[0].0,
1100 ambiguous[0].1
1101 ),
1102 );
1103 }
1104 let mut objects = Vec::new();
1107 if mode == RunMode::Autopilot {
1108 if let Some(why) = autopilot_blocked(&u.root) {
1109 return refuse(CommandStatus::ConfigError, "SANDBOX-UNAVAILABLE", why).with_fix(vec![
1110 "speccoder review approve --run ask".to_owned(),
1111 "→ or enable the OS sandbox ([sandbox] mode = \"auto\" on macOS or Linux 6.2+)"
1112 .to_owned(),
1113 ]);
1114 }
1115 let offered = crate::approvals::offered_objects(&plane, &u.feature);
1116 for id in grants {
1117 match offered.iter().find(|(g, _)| g.id == *id) {
1118 Some((_, o)) => objects.push(o.clone()),
1119 None => {
1120 return refuse(
1121 CommandStatus::UsageError,
1122 "USAGE",
1123 format!(
1124 "{id} is not a grant offered for {}; offered: {}",
1125 u.feature,
1126 if offered.is_empty() {
1127 "none".to_owned()
1128 } else {
1129 offered
1130 .iter()
1131 .map(|(g, _)| format!("{} ({})", g.id, g.object))
1132 .collect::<Vec<_>>()
1133 .join(", ")
1134 }
1135 ),
1136 );
1137 }
1138 }
1139 }
1140 } else if !grants.is_empty() {
1141 return refuse(
1142 CommandStatus::UsageError,
1143 "USAGE",
1144 "--grant is for an autopilot run (--run autopilot)".to_owned(),
1145 );
1146 }
1147 let freezable = match crate::oracle::freezable(&plane, &u.feature, &named) {
1148 Ok(f) => f,
1149 Err(t) => {
1150 return refuse(CommandStatus::NeedsHuman, "ORACLE-TAMPERED", t.0);
1151 }
1152 };
1153 let requirements = u.requirements();
1155 let mut record = match u.record() {
1156 Ok(r) => r,
1157 Err(e) => return unreadable(&e),
1158 };
1159 let before = items(&requirements, &record);
1160 let who = crate::approvals::Who::local_in(&u.root, via);
1161 let at = std::time::SystemTime::now()
1162 .duration_since(std::time::UNIX_EPOCH)
1163 .map_or(0, |d| d.as_secs());
1164 let mut approved_now = Vec::new();
1165 for (id, line) in &requirements {
1166 if before
1167 .iter()
1168 .any(|i| i.id == *id && matches!(i.state.as_str(), "open" | "lapsed"))
1169 {
1170 record.requirements.insert(
1171 id.clone(),
1172 Decision {
1173 verdict: "approved".to_owned(),
1174 line: line.trim_end().to_owned(),
1175 sha256: digest(line),
1176 by: who.name.clone(),
1177 via: who.via.clone(),
1178 at,
1179 comment: None,
1180 },
1181 );
1182 approved_now.push(id.clone());
1183 }
1184 }
1185 record
1186 .requirements
1187 .retain(|id, _| requirements.iter().any(|(r, _)| r == id));
1188 let approved: Vec<(String, String)> = record
1189 .requirements
1190 .iter()
1191 .filter(|(_, d)| d.verdict == "approved")
1192 .map(|(id, d)| (id.clone(), d.sha256.clone()))
1193 .collect();
1194 let sha = approval_digest(
1195 approved.iter().map(|(a, b)| (a.as_str(), b.as_str())),
1196 freezable
1197 .iter()
1198 .map(|f| (f.req.key.as_str(), f.manifest.as_str())),
1199 );
1200 if sheet.is_none() && via != speccoder_core::actions::Via::Terminal {
1203 return refuse(
1204 CommandStatus::UsageError,
1205 "USAGE",
1206 "Approve and Run names what its sheet showed (the sheet's sha256): open the sheet again".to_owned(),
1207 );
1208 }
1209 if let Some(shown) = sheet
1210 && shown != sha
1211 {
1212 return refuse(
1213 CommandStatus::NeedsHuman,
1214 "REVIEW-CHANGED",
1215 "what Approve and Run would approve and freeze changed since the sheet showed it (another tab, a finished Write Tests): nothing was approved — look at the sheet again".to_owned(),
1216 );
1217 }
1218 record.oracle.clear();
1219 let mut frozen = Vec::new();
1220 for f in &freezable {
1221 let decided = speccoder_core::acceptance::Decided {
1222 by: who.name.clone(),
1223 via,
1224 at,
1225 };
1226 match crate::oracle::freeze(&plane, &u.feature, f, &sha, decided) {
1227 Ok(rec) => {
1228 record.oracle.insert(
1229 f.req.key.clone(),
1230 crate::oracle::OracleEntry {
1231 requirement_sha256: f.draft.requirement.sha256.clone(),
1232 manifest_sha256: f.manifest.clone(),
1233 held_out_sha256: rec
1234 .held_out_sealed
1235 .as_ref()
1236 .map(|h| format!("sha256:{}", h.manifest_sha256)),
1237 by: who.name.clone(),
1238 via,
1239 at,
1240 },
1241 );
1242 frozen.push((f, rec));
1243 }
1244 Err(e) => return refuse(CommandStatus::NeedsHuman, "ORACLE-TAMPERED", e),
1245 }
1246 }
1247 record.approval = Some(speccoder_core::oracle::ReviewApproval {
1248 sha256: sha.clone(),
1249 mode,
1250 by: who.name.clone(),
1251 via,
1252 at,
1253 });
1254 let text = serde_json::to_string_pretty(&record).unwrap_or_default() + "\n";
1255 if let Err(e) = std::fs::write(u.file(), text) {
1256 return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", u.rel()));
1257 }
1258 let rel = {
1260 let (top_prefix, rel) = (
1261 crate::git::Git::at(&u.root)
1262 .line(&["rev-parse", "--show-prefix"])
1263 .unwrap_or_default(),
1264 u.rel(),
1265 );
1266 format!("{top_prefix}{rel}")
1267 };
1268 let committed = crate::hosting::commit_exactly(
1269 &u.root,
1270 &[rel],
1271 &format!("review: approve {} and freeze its tests", u.feature),
1272 );
1273 if committed.status() != CommandStatus::Ok {
1274 return CommandResult::carried("review", &committed);
1275 }
1276 for (f, rec) in &frozen {
1277 if let Some(p) = &options.progress {
1278 p.event(&crate::progress::Event::Oracle {
1279 step: crate::progress::OracleEvent::Frozen {
1280 unit: u.feature.clone(),
1281 requirement: f.req.key.clone(),
1282 manifest_sha256: f.manifest.clone(),
1283 approval_sha256: sha.clone(),
1284 visible_count: rec.visible.len(),
1285 held_out_count: rec.held_out_sealed.as_ref().map_or(0, |h| h.count),
1286 by: who.name.clone(),
1287 via: via.as_str().to_owned(),
1288 },
1289 });
1290 }
1291 }
1292 let said = format!(
1293 "approved {} decision line(s), froze tests for {} requirement(s){}; committed {}",
1294 approved_now.len(),
1295 frozen.len(),
1296 if mode == RunMode::Autopilot {
1297 format!(", autopilot with {} grant(s)", objects.len())
1298 } else {
1299 String::new()
1300 },
1301 u.rel()
1302 );
1303 drop(lock);
1304 let mut run_options = options.clone();
1305 run_options.mode = mode;
1306 if mode == RunMode::Autopilot {
1307 run_options.approver = None;
1309 run_options.run_grants = std::sync::Arc::new(std::sync::Mutex::new(objects));
1310 }
1311 let ran = match (state, provider) {
1312 (Some(state), Some(provider)) => crate::pipeline::run_feature_with(
1313 path,
1314 Some(&u.feature),
1315 state,
1316 &provider,
1317 None,
1318 &run_options,
1319 ),
1320 _ => crate::run_feature(path, Some(&u.feature), &run_options, false),
1321 };
1322 let status = ran.status();
1323 let message = format!("{said} — {}", ran.message());
1324 ran.reworded(status, &message)
1325}
1326
1327#[must_use]
1330pub fn answer(path: &Path, feature: Option<&str>, id: &str, text: &str) -> CommandResult {
1331 let u = match unit("review", path, feature) {
1332 Ok(u) => u,
1333 Err(r) => return *r,
1334 };
1335 let text = text.trim();
1336 if text.is_empty() {
1337 return CommandResult::new("review", CommandStatus::UsageError, "an answer needs words")
1338 .with_code("USAGE");
1339 }
1340 let open = questions(&u);
1341 let Some((q, raw)) = open.iter().find(|(q, _)| q.id.eq_ignore_ascii_case(id)) else {
1342 return CommandResult::new(
1343 "review",
1344 CommandStatus::NotFound,
1345 &format!(
1346 "no open question {id}; open: {}",
1347 if open.is_empty() {
1348 "none".to_owned()
1349 } else {
1350 open.iter()
1351 .map(|(q, _)| q.id.as_str())
1352 .collect::<Vec<_>>()
1353 .join(", ")
1354 }
1355 ),
1356 )
1357 .with_code("NOT-FOUND");
1358 };
1359 let file = u.root.join(&q.file);
1360 let Ok(body) = std::fs::read_to_string(&file) else {
1361 return CommandResult::new(
1362 "review",
1363 CommandStatus::Error,
1364 &format!("{} cannot be read", q.file),
1365 );
1366 };
1367 let mut lines: Vec<String> = body.lines().map(str::to_owned).collect();
1368 let Some(line) = lines
1369 .get_mut(q.line - 1)
1370 .filter(|l| l.contains(raw.as_str()))
1371 else {
1372 return CommandResult::new(
1373 "review",
1374 CommandStatus::Error,
1375 &format!("{} changed under the question; look again", q.file),
1376 );
1377 };
1378 *line = line.replacen(raw.as_str(), text, 1);
1379 let mut out = lines.join("\n");
1380 if body.ends_with('\n') {
1381 out.push('\n');
1382 }
1383 if let Err(e) = std::fs::write(&file, out) {
1384 return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", q.file));
1385 }
1386 let r = report_of(&u, true);
1387 CommandResult::ok(
1388 "review",
1389 &format!("answered {} in {} — {}", q.id, q.file, message(&r)),
1390 )
1391 .with_data(CommandData::Review(Box::new(r)))
1392}
1393
1394#[must_use]
1398pub fn edit(
1399 path: &Path,
1400 feature: Option<&str>,
1401 id: &str,
1402 wording: &str,
1403 approve: bool,
1404 via: speccoder_core::actions::Via,
1405) -> CommandResult {
1406 let u = match unit("review", path, feature) {
1407 Ok(u) => u,
1408 Err(r) => return *r,
1409 };
1410 let wording = wording.trim();
1411 if wording.is_empty() || wording.contains('\n') {
1412 return CommandResult::new(
1413 "review",
1414 CommandStatus::UsageError,
1415 "a requirement's wording is one line of words",
1416 )
1417 .with_code("USAGE");
1418 }
1419 let Some(spec) = u.format.requirements_file() else {
1420 return CommandResult::new(
1421 "review",
1422 CommandStatus::UsageError,
1423 "this format has no requirement identifiers",
1424 )
1425 .with_code("USAGE");
1426 };
1427 let Some((_, old)) = u.requirements().into_iter().find(|(r, _)| r == id) else {
1428 return CommandResult::new(
1429 "review",
1430 CommandStatus::NotFound,
1431 &format!("the spec has no {id}"),
1432 )
1433 .with_code("NOT-FOUND");
1434 };
1435 let key = format!("**{id}**");
1437 let Some(at) = old.find(&key) else {
1438 return CommandResult::new(
1439 "review",
1440 CommandStatus::Error,
1441 &format!("{id}'s line has no `{key}`"),
1442 );
1443 };
1444 let new = format!("{}{key}: {wording}", &old[..at]);
1445 let file = u.root.join(&u.dir).join(spec);
1446 let body = std::fs::read_to_string(&file).unwrap_or_default();
1447 if !body.lines().any(|l| l.trim_end() == old.trim_end()) {
1448 return CommandResult::new(
1449 "review",
1450 CommandStatus::Error,
1451 &format!("{spec} changed under {id}; look again"),
1452 );
1453 }
1454 let mut done = false;
1455 let mut out: Vec<String> = Vec::new();
1456 for l in body.lines() {
1457 if !done && l.trim_end() == old.trim_end() {
1458 out.push(new.clone());
1459 done = true;
1460 } else {
1461 out.push(l.to_owned());
1462 }
1463 }
1464 let mut text = out.join("\n");
1465 if body.ends_with('\n') {
1466 text.push('\n');
1467 }
1468 if let Err(e) = std::fs::write(&file, text) {
1469 return CommandResult::new("review", CommandStatus::Error, &format!("{spec}: {e}"));
1470 }
1471 if approve {
1472 let r = decide(path, feature, &[id.to_owned()], "approve", None, via);
1473 let status = r.status();
1474 let message = if status == CommandStatus::Ok {
1475 format!("reworded {id} and approved it; {}", r.message())
1476 } else {
1477 format!("reworded {id}; not approved: {}", r.message())
1478 };
1479 return r.reworded(status, &message);
1480 }
1481 let r = report_of(&u, true);
1482 CommandResult::ok("review", &format!("reworded {id}; {}", message(&r)))
1483 .with_data(CommandData::Review(Box::new(r)))
1484}
1485
1486#[must_use]
1489fn revision_request(items: &[ReviewItem]) -> String {
1490 let lines: Vec<String> = items
1491 .iter()
1492 .filter(|i| i.state == "changes_requested")
1493 .map(|i| {
1494 format!(
1495 "- {} (\"{}\"): {}",
1496 i.id,
1497 i.text,
1498 i.comment.as_deref().unwrap_or_default()
1499 )
1500 })
1501 .collect();
1502 format!(
1503 "Revise the spec per the reviewer's change requests; keep every requirement ID, reword only what is asked:\n{}",
1504 lines.join("\n")
1505 )
1506}
1507
1508#[derive(Debug)]
1511pub struct Snapshot {
1512 root: PathBuf,
1513 files: Vec<(PathBuf, Option<Vec<u8>>)>,
1514}
1515
1516fn records(root: &Path) -> Vec<(PathBuf, Option<Vec<u8>>)> {
1518 let format = crate::format::at(root);
1519 let Some(snap) = format.inspect(root, None) else {
1520 return Vec::new();
1521 };
1522 snap.features
1523 .iter()
1524 .map(|f| {
1525 let file = Path::new(&snap.root).join(&f.dir).join(REVIEW_FILE);
1526 let bytes = std::fs::read(&file).ok();
1527 (file, bytes)
1528 })
1529 .collect()
1530}
1531
1532#[must_use]
1534pub fn snapshot(root: &Path) -> Snapshot {
1535 Snapshot {
1536 root: root.to_path_buf(),
1537 files: records(root),
1538 }
1539}
1540
1541pub fn restore(before: &Snapshot) -> Vec<String> {
1544 let mut restored = Vec::new();
1545 let now = records(&before.root);
1546 for (file, bytes) in &now {
1547 let was = before
1548 .files
1549 .iter()
1550 .find(|(f, _)| f == file)
1551 .and_then(|(_, b)| b.clone());
1552 if *bytes == was {
1553 continue;
1554 }
1555 let ok = match &was {
1556 Some(b) => std::fs::write(file, b).is_ok(),
1557 None => std::fs::remove_file(file).is_ok(),
1558 };
1559 if ok {
1560 restored.push(file.display().to_string());
1561 }
1562 }
1563 restored
1564}
1565
1566#[cfg(test)]
1567mod tests {
1568 use super::*;
1569
1570 #[test]
1571 fn the_wording_is_the_line_without_its_markup() {
1572 assert_eq!(
1573 wording("- **FR-001**: Users MUST log in."),
1574 "Users MUST log in."
1575 );
1576 assert_eq!(digest("a "), digest("a"));
1577 }
1578}