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 readiness = readiness_of(u, open.len());
430 let totals = totals(&items, open.len());
431 let revision = (totals.changes_requested > 0).then(|| revision_request(&items));
432 let changes = if full {
433 changes_since_head(u)
434 } else {
435 Vec::new()
436 };
437 let rung = u.rung();
438 let (oracle, tests_notice) = if rung == speccoder_core::ladder::Rung::Feature {
439 let cfg = crate::oracle::ViewConfig::at(&u.root);
440 let views = u
441 .oracle_requirements()
442 .iter()
443 .map(|r| match &u.state {
444 Some(state) => {
445 crate::oracle::view(state, &u.feature, r, record.oracle.get(&r.key), cfg)
446 }
447 None => crate::oracle::view(
448 Path::new("/nonexistent-speccoder-state"),
449 &u.feature,
450 r,
451 record.oracle.get(&r.key),
452 cfg,
453 ),
454 })
455 .collect::<Vec<_>>();
456 (views, None)
457 } else {
458 (
459 Vec::new(),
460 Some("Tests for this unit are written and confirmed by its own pipeline".to_owned()),
461 )
462 };
463 let bug_tests = if rung == speccoder_core::ladder::Rung::Bug {
464 u.state
465 .as_deref()
466 .map(|plane| confirmed_reproductions(plane, &u.feature))
467 .unwrap_or_default()
468 } else {
469 Vec::new()
470 };
471 let mut totals = totals;
472 totals.ambiguous = oracle
473 .iter()
474 .filter(|v| v.open_ambiguities().next().is_some())
475 .count();
476 let sheet = None;
478 ReviewReport {
479 feature: u.feature.clone(),
480 file: u.rel(),
481 totals,
482 revision,
483 questions: open,
484 changes,
485 history: if full { history(u) } else { Vec::new() },
486 requirements: items,
487 revise_with: u
488 .format
489 .planning()
490 .revises
491 .filter(|s| installed(s))
492 .map(str::to_owned),
493 oracle,
494 tests_notice,
495 bug_tests,
496 approval: record.approval,
497 sheet,
498 error,
499 readiness: Some(readiness),
500 }
501}
502
503fn readiness_of(u: &Unit, open_questions: usize) -> speccoder_core::Readiness {
508 let all = u
509 .snapshot
510 .as_ref()
511 .map(|s| u.format.oracle_requirements(s))
512 .unwrap_or_default();
513 let identifiers = u.format.requirements_file().is_some();
514 let count = |n: usize| u32::try_from(n).unwrap_or(u32::MAX);
515 speccoder_core::Readiness {
516 requirements: if identifiers {
517 count(u.requirements().len())
518 } else {
519 count(all.len())
520 },
521 scenarios: count(all.iter().map(|r| r.scenarios.len()).sum()),
522 open_questions: count(open_questions),
523 untestable: all
524 .iter()
525 .filter(|r| r.scenarios.is_empty())
526 .map(|r| r.key.clone())
527 .collect(),
528 identifiers,
529 }
530}
531
532#[must_use]
535pub fn readiness(path: &Path, feature: Option<&str>) -> Option<speccoder_core::Readiness> {
536 let u = unit("review", path, feature).ok()?;
537 let open = questions(&u).len();
538 Some(readiness_of(&u, open))
539}
540
541fn confirmed_reproductions(plane: &Path, unit: &str) -> Vec<speccoder_core::oracle::BugTests> {
544 let mut bugs: Vec<speccoder_core::bug::BugReport> = crate::bug::state::all(plane)
545 .into_iter()
546 .filter(|b| b.unit == unit && b.confirmation.is_some())
547 .collect();
548 bugs.sort_by(|a, b| a.task.cmp(&b.task));
549 bugs.into_iter()
550 .filter_map(|b| {
551 let paths: Vec<String> = b.reproductions.iter().map(|r| r.file.clone()).collect();
552 let kept = crate::bug::state::repro(plane, &b.unit, &b.task, &paths);
553 let reproductions = b
554 .reproductions
555 .iter()
556 .map(|r| speccoder_core::oracle::BugTest {
557 statement: r.statement.clone(),
558 file: r.file.clone(),
559 test: r.test.clone(),
560 red_class: r
561 .baseline
562 .last()
563 .map(|c| crate::pipeline::class_word(*c))
564 .unwrap_or_default(),
565 content: kept
566 .iter()
567 .find(|(p, _)| *p == r.file)
568 .filter(|(_, c)| {
569 format!("sha256:{}", crate::integrity::sha256_hex(c.as_bytes()))
570 == r.digest
571 })
572 .map(|(_, c)| c.clone()),
573 })
574 .collect();
575 Some(speccoder_core::oracle::BugTests {
576 task: b.task.clone(),
577 title: b.title.clone(),
578 confirmation: b.confirmation.clone()?,
579 reproductions,
580 })
581 })
582 .collect()
583}
584
585fn run_active(u: &Unit) -> bool {
587 crate::worktree::state_dir()
588 .and_then(|s| crate::progress::read_live(&u.root, &s))
589 .is_some_and(|(live, running)| running && live.finished.is_none())
590}
591
592fn autopilot_blocked(root: &Path) -> Option<String> {
594 let text = crate::verify::find_config(root)
595 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
596 .unwrap_or_default();
597 let mode = crate::sandbox::parse(&text)
598 .map(|c| c.mode)
599 .unwrap_or(crate::sandbox::Mode::Auto);
600 if mode == crate::sandbox::Mode::Off {
601 return Some("autopilot needs the OS sandbox ([sandbox] mode = \"off\")".to_owned());
602 }
603 crate::sandbox::backend()
604 .err()
605 .map(|e| format!("autopilot needs the OS sandbox ({e})"))
606}
607
608fn sandbox_line(root: &Path) -> Option<String> {
610 let text = crate::verify::find_config(root)
611 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
612 .unwrap_or_default();
613 let config = crate::sandbox::parse(&text).ok()?;
614 let backend = crate::sandbox::backend().ok()?;
615 let network = match config.network {
616 crate::sandbox::Network::Off => "network off",
617 crate::sandbox::Network::Loopback => "loopback only",
618 crate::sandbox::Network::On => "network on",
619 };
620 Some(format!("Sandbox: {}, {network}", backend.mechanism()))
621}
622
623fn sheet(
626 u: &Unit,
627 items: &[ReviewItem],
628 questions: &[OpenQuestion],
629 oracle: &[speccoder_core::oracle::OracleView],
630) -> speccoder_core::oracle::ApproveSheet {
631 let mut blocked = Vec::new();
632 if !questions.is_empty() {
633 blocked.push(format!(
634 "{} open question(s): {}",
635 questions.len(),
636 questions
637 .iter()
638 .map(|q| q.near.clone().unwrap_or_else(|| q.id.clone()))
639 .collect::<Vec<_>>()
640 .join(", ")
641 ));
642 }
643 for v in oracle {
644 for a in v.open_ambiguities() {
645 blocked.push(format!(
646 "{}: two readings of {} disagree — choose one, or reword",
647 v.req, a.scenario
648 ));
649 }
650 }
651 if run_active(u) {
652 blocked.push("a run is active".to_owned());
653 }
654 let sha256 = match (&u.state, u.record()) {
657 (Some(plane), Ok(record)) => {
658 match crate::oracle::freezable(plane, &u.feature, &u.oracle_requirements()) {
659 Ok(f) => Some(would_approve(&u.requirements(), &record, &f)),
660 Err(t) => {
661 blocked.push(format!("a test draft cannot be read: {}", t.0));
662 None
663 }
664 }
665 }
666 (_, Err(e)) => {
667 blocked.push(e);
668 None
669 }
670 (None, Ok(_)) => None,
671 };
672 speccoder_core::oracle::ApproveSheet {
673 sha256,
674 approves: items
675 .iter()
676 .filter(|i| matches!(i.state.as_str(), "open" | "lapsed"))
677 .map(|i| i.id.clone())
678 .collect(),
679 blocked,
680 autopilot_blocked: autopilot_blocked(&u.root),
681 sandbox: sandbox_line(&u.root),
682 grants: u
683 .state
684 .as_ref()
685 .map(|s| crate::approvals::offered_grants(s, &u.feature))
686 .unwrap_or_default(),
687 }
688}
689
690pub fn oracle_entries(
696 root: &Path,
697 dir: &str,
698) -> Result<(BTreeMap<String, crate::oracle::OracleEntry>, Option<String>), String> {
699 let record = read_record(&root.join(dir).join(REVIEW_FILE))?;
700 Ok((record.oracle, record.approval.map(|a| a.sha256)))
701}
702
703fn read_record(file: &Path) -> Result<Record, String> {
706 match std::fs::read_to_string(file) {
707 Ok(text) => serde_json::from_str(&text)
708 .map_err(|e| format!("{} is not a valid review record: {e}", file.display())),
709 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Record::default()),
710 Err(e) => Err(format!("{}: {e}", file.display())),
711 }
712}
713
714fn unreadable(e: &str) -> CommandResult {
716 CommandResult::new("review", CommandStatus::NeedsHuman, e).with_code("REVIEW-UNREADABLE")
717}
718
719fn message(r: &ReviewReport) -> String {
720 let t = r.totals;
721 let n = r.requirements.len();
722 if n == 0 {
723 return format!("{}'s spec names no requirement IDs to review", r.feature);
724 }
725 if t.complete() {
726 return format!("{n}/{n} requirements approved");
727 }
728 let plural =
729 |n: usize, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
730 let parts: Vec<String> = [
731 (
732 t.questions,
733 plural(t.questions, "open question", "open questions"),
734 ),
735 (t.open, format!("{} to review", t.open)),
736 (
737 t.changes_requested,
738 format!("{} with changes requested", t.changes_requested),
739 ),
740 (
741 t.lapsed,
742 format!("{} changed since they were decided", t.lapsed),
743 ),
744 ]
745 .into_iter()
746 .filter(|(k, _)| *k > 0)
747 .map(|(_, w)| w)
748 .collect();
749 format!(
750 "{}/{n} requirements approved; {}",
751 t.approved,
752 parts.join(", ")
753 )
754}
755
756#[must_use]
759pub fn summary(path: &Path, feature: Option<&str>) -> Option<ReviewReport> {
760 unit("review", path, feature)
761 .ok()
762 .map(|u| report_of(&u, false))
763}
764
765#[must_use]
767pub fn report(path: &Path, feature: Option<&str>) -> CommandResult {
768 report_in(path, feature, None)
769}
770
771#[must_use]
773pub fn report_in(path: &Path, feature: Option<&str>, state: Option<&Path>) -> CommandResult {
774 let u = match unit_in("review", path, feature, state) {
775 Ok(u) => u,
776 Err(r) => return *r,
777 };
778 let mut r = report_of(&u, true);
779 r.sheet = Some(sheet(&u, &r.requirements, &r.questions, &r.oracle));
780 CommandResult::new("review", CommandStatus::Ok, &message(&r))
781 .with_data(CommandData::Review(Box::new(r)))
782}
783
784#[must_use]
788pub fn decide(
789 path: &Path,
790 feature: Option<&str>,
791 ids: &[String],
792 verdict: &str,
793 comment: Option<&str>,
794 via: speccoder_core::actions::Via,
795) -> CommandResult {
796 decide_in(path, feature, ids, verdict, comment, via, None)
797}
798
799#[must_use]
801pub fn decide_in(
802 path: &Path,
803 feature: Option<&str>,
804 ids: &[String],
805 verdict: &str,
806 comment: Option<&str>,
807 via: speccoder_core::actions::Via,
808 state: Option<&Path>,
809) -> CommandResult {
810 let u = match unit_in("review", path, feature, state) {
811 Ok(u) => u,
812 Err(r) => return *r,
813 };
814 let comment = comment.map(str::trim).filter(|c| !c.is_empty());
815 if verdict == "request" && comment.is_none() {
816 return CommandResult::new(
817 "review",
818 CommandStatus::UsageError,
819 "a change request needs a comment: what should the requirement say?",
820 )
821 .with_code("USAGE");
822 }
823 let requirements = u.requirements();
824 let mut record = match u.record() {
825 Ok(r) => r,
826 Err(e) => return unreadable(&e),
827 };
828 let before = items(&requirements, &record);
829 let asks = |line: &str| line.contains(MARKER);
832 let undecided: Vec<&str> = requirements
833 .iter()
834 .filter(|(_, l)| asks(l))
835 .map(|(id, _)| id.as_str())
836 .collect();
837 if verdict == "approve" && !ids.is_empty() {
838 let blocked: Vec<&str> = ids
839 .iter()
840 .map(String::as_str)
841 .filter(|id| undecided.contains(id))
842 .collect();
843 if !blocked.is_empty() {
844 return CommandResult::new(
845 "review",
846 CommandStatus::UsageError,
847 &format!(
848 "{} still {} an open question: answer it first (`speccoder review answer …`), or reword it (`speccoder review edit {} \"…\" --approve`)",
849 blocked.join(", "),
850 if blocked.len() == 1 { "asks" } else { "ask" },
851 blocked[0]
852 ),
853 )
854 .with_code("USAGE");
855 }
856 }
857 let chosen: Vec<&(String, String)> = if ids.is_empty() {
858 requirements
859 .iter()
860 .filter(|(id, l)| {
861 before.iter().any(|i| i.id == *id && i.state != "approved")
862 && !(verdict == "approve" && asks(l))
863 })
864 .collect()
865 } else {
866 let unknown: Vec<&String> = ids
867 .iter()
868 .filter(|id| !requirements.iter().any(|(r, _)| r == *id))
869 .collect();
870 if !unknown.is_empty() {
871 return CommandResult::new(
872 "review",
873 CommandStatus::NotFound,
874 &format!(
875 "the spec has no {}; it names {}",
876 unknown
877 .iter()
878 .map(|s| s.as_str())
879 .collect::<Vec<_>>()
880 .join(", "),
881 requirements
882 .iter()
883 .map(|(r, _)| r.as_str())
884 .collect::<Vec<_>>()
885 .join(", ")
886 ),
887 )
888 .with_code("NOT-FOUND")
889 .with_fix(vec!["speccoder review".to_owned()]);
890 }
891 requirements
892 .iter()
893 .filter(|(id, _)| ids.contains(id))
894 .collect()
895 };
896 let who = crate::approvals::Who::local_in(&u.root, via);
897 let now = std::time::SystemTime::now()
898 .duration_since(std::time::UNIX_EPOCH)
899 .map_or(0, |d| d.as_secs());
900 for (id, line) in &chosen {
901 match verdict {
902 "clear" => {
903 record.requirements.remove(id);
904 }
905 "approve" | "request" => {
906 record.requirements.insert(
907 id.clone(),
908 Decision {
909 verdict: if verdict == "approve" {
910 "approved"
911 } else {
912 "changes_requested"
913 }
914 .to_owned(),
915 line: line.trim_end().to_owned(),
916 sha256: digest(line),
917 by: who.name.clone(),
918 via: who.via.clone(),
919 at: now,
920 comment: comment.map(str::to_owned),
921 },
922 );
923 }
924 other => {
925 return CommandResult::new(
926 "review",
927 CommandStatus::UsageError,
928 &format!("`{other}` is not a review verdict: approve, request or clear"),
929 )
930 .with_code("USAGE");
931 }
932 }
933 }
934 record
936 .requirements
937 .retain(|id, _| requirements.iter().any(|(r, _)| r == id));
938 let named = u.oracle_requirements();
939 if u.snapshot.is_some() {
940 record
941 .oracle
942 .retain(|key, _| named.iter().any(|r| r.key == *key));
943 }
944 let text = serde_json::to_string_pretty(&record).unwrap_or_default() + "\n";
945 if let Err(e) = std::fs::write(u.file(), text) {
946 return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", u.rel()));
947 }
948 let r = report_of(&u, true);
949 let word = match verdict {
950 "approve" => "approved",
951 "request" => "changes requested on",
952 _ => "cleared",
953 };
954 let skipped = if verdict == "approve" && ids.is_empty() && !undecided.is_empty() {
955 format!(
956 "; not {} — {} still {} an open question",
957 undecided.join(", "),
958 if undecided.len() == 1 { "it" } else { "they" },
959 if undecided.len() == 1 { "asks" } else { "ask" }
960 )
961 } else {
962 String::new()
963 };
964 let ambiguous: Vec<String> = r
965 .oracle
966 .iter()
967 .filter(|v| v.open_ambiguities().next().is_some())
968 .map(|v| v.req.clone())
969 .collect();
970 let skipped = if verdict == "approve" && !ambiguous.is_empty() {
971 format!(
972 "{skipped}; {} {} ambiguous — Approve and Run stays blocked",
973 ambiguous.join(", "),
974 if ambiguous.len() == 1 { "is" } else { "are" }
975 )
976 } else {
977 skipped
978 };
979 let message = if chosen.is_empty() {
980 format!("nothing to decide{skipped}: {}", message(&r))
981 } else {
982 format!(
983 "{word} {}{skipped}; {} — commit {} to share it",
984 chosen
985 .iter()
986 .map(|(id, _)| id.as_str())
987 .collect::<Vec<_>>()
988 .join(", "),
989 message(&r),
990 r.file
991 )
992 };
993 CommandResult::new("review", CommandStatus::Ok, &message)
994 .with_data(CommandData::Review(Box::new(r)))
995}
996
997fn would_approve(
1002 requirements: &[(String, String)],
1003 record: &Record,
1004 freezable: &[crate::oracle::Freezable],
1005) -> String {
1006 let before = items(requirements, record);
1007 let approved: Vec<(String, String)> = requirements
1008 .iter()
1009 .filter_map(|(id, line)| {
1010 let state = before.iter().find(|i| i.id == *id)?.state.as_str();
1011 match state {
1012 "open" | "lapsed" => Some((id.clone(), digest(line))),
1013 _ => record
1014 .requirements
1015 .get(id)
1016 .filter(|d| d.verdict == "approved")
1017 .map(|d| (id.clone(), d.sha256.clone())),
1018 }
1019 })
1020 .collect();
1021 approval_digest(
1022 approved.iter().map(|(a, b)| (a.as_str(), b.as_str())),
1023 freezable
1024 .iter()
1025 .map(|f| (f.req.key.as_str(), f.manifest.as_str())),
1026 )
1027}
1028
1029#[must_use]
1033pub fn approval_digest<'a>(
1034 decisions: impl IntoIterator<Item = (&'a str, &'a str)>,
1035 oracle: impl IntoIterator<Item = (&'a str, &'a str)>,
1036) -> String {
1037 let mut lines: Vec<String> = decisions
1038 .into_iter()
1039 .map(|(id, sha)| format!("{id}={sha}"))
1040 .collect();
1041 lines.sort();
1042 let mut frozen: Vec<String> = oracle
1043 .into_iter()
1044 .map(|(req, sha)| format!("{req}={sha}"))
1045 .collect();
1046 frozen.sort();
1047 format!(
1048 "sha256:{}",
1049 crate::integrity::sha256_hex(
1050 format!("{}\n{}", lines.join("\n"), frozen.join("\n")).as_bytes()
1051 )
1052 )
1053}
1054
1055#[must_use]
1062#[allow(clippy::too_many_arguments)]
1063pub fn approve_and_run(
1064 path: &Path,
1065 feature: Option<&str>,
1066 mode: speccoder_core::oracle::RunMode,
1067 grants: &[String],
1068 sheet: Option<&str>,
1069 via: speccoder_core::actions::Via,
1070 state: Option<&Path>,
1071 provider: Option<std::sync::Arc<dyn agentplane::model::ModelProvider>>,
1072 options: &crate::RunOptions,
1073) -> CommandResult {
1074 use speccoder_core::oracle::RunMode;
1075 let u = match unit_in("review", path, feature, state) {
1076 Ok(u) => u,
1077 Err(r) => return *r,
1078 };
1079 let Some(plane) = u.state.clone() else {
1080 return CommandResult::new(
1081 "review",
1082 CommandStatus::ConfigError,
1083 "no state directory (set SPECCODER_STATE_DIR or HOME)",
1084 )
1085 .with_code("NO-STATE-DIR");
1086 };
1087 let refuse =
1088 |status, code: &str, m: String| CommandResult::new("review", status, &m).with_code(code);
1089 if run_active(&u) {
1090 return refuse(
1091 CommandStatus::GitPrecondition,
1092 "RUN-ACTIVE",
1093 format!(
1094 "a run is active for {}: approve and run when it ends",
1095 u.feature
1096 ),
1097 );
1098 }
1099 if let Some(stop) = u
1102 .snapshot
1103 .as_ref()
1104 .and_then(|s| s.feature.as_ref())
1105 .and_then(|f| crate::pipeline::feature::no_tasks(u.format, f))
1106 {
1107 let mut fix = vec![format!(
1108 "speccoder review approve --feature {} (approve the spec without a run)",
1109 u.feature
1110 )];
1111 fix.extend(stop.fix);
1112 return refuse(
1113 CommandStatus::TaskNotReady,
1114 "NO-TASKS",
1115 format!("{}; nothing was approved", stop.message),
1116 )
1117 .with_fix(fix)
1118 .with_next(stop.next);
1119 }
1120 let lock = match crate::oracle::UnitLock::take(&plane, &u.feature) {
1122 Ok(l) => l,
1123 Err(e) => return refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", e),
1124 };
1125 let open = questions(&u);
1126 if !open.is_empty() {
1127 return refuse(
1128 CommandStatus::NeedsHuman,
1129 "REVIEW-PENDING",
1130 format!(
1131 "{} open question(s) first: {} — answer (`speccoder review answer Q1 \"…\"`) or reword",
1132 open.len(),
1133 open.iter()
1134 .map(|(q, _)| q.id.clone())
1135 .collect::<Vec<_>>()
1136 .join(", ")
1137 ),
1138 );
1139 }
1140 let named = u.oracle_requirements();
1141 let ambiguous = match crate::oracle::open_ambiguities(&plane, &u.feature, &named) {
1142 Ok(a) => a,
1143 Err(t) => return refuse(CommandStatus::NeedsHuman, "ORACLE-TAMPERED", t.0),
1144 };
1145 if !ambiguous.is_empty() {
1146 return refuse(
1147 CommandStatus::NeedsHuman,
1148 "REVIEW-AMBIGUOUS",
1149 format!(
1150 "Approve and Run waits: {} read two ways — choose a reading (`speccoder review tests {} --reading A|B {}`) or reword",
1151 ambiguous
1152 .iter()
1153 .map(|(r, s)| format!("{r}/{s}"))
1154 .collect::<Vec<_>>()
1155 .join(", "),
1156 ambiguous[0].0,
1157 ambiguous[0].1
1158 ),
1159 );
1160 }
1161 let mut objects = Vec::new();
1164 if mode == RunMode::Autopilot {
1165 if let Some(why) = autopilot_blocked(&u.root) {
1166 return refuse(CommandStatus::ConfigError, "SANDBOX-UNAVAILABLE", why).with_fix(vec![
1167 "speccoder review approve --run ask".to_owned(),
1168 "→ or enable the OS sandbox ([sandbox] mode = \"auto\" on macOS or Linux 6.2+)"
1169 .to_owned(),
1170 ]);
1171 }
1172 let offered = crate::approvals::offered_objects(&plane, &u.feature);
1173 for id in grants {
1174 match offered.iter().find(|(g, _)| g.id == *id) {
1175 Some((_, o)) => objects.push(o.clone()),
1176 None => {
1177 return refuse(
1178 CommandStatus::UsageError,
1179 "USAGE",
1180 format!(
1181 "{id} is not a grant offered for {}; offered: {}",
1182 u.feature,
1183 if offered.is_empty() {
1184 "none".to_owned()
1185 } else {
1186 offered
1187 .iter()
1188 .map(|(g, _)| format!("{} ({})", g.id, g.object))
1189 .collect::<Vec<_>>()
1190 .join(", ")
1191 }
1192 ),
1193 );
1194 }
1195 }
1196 }
1197 } else if !grants.is_empty() {
1198 return refuse(
1199 CommandStatus::UsageError,
1200 "USAGE",
1201 "--grant is for an autopilot run (--run autopilot)".to_owned(),
1202 );
1203 }
1204 let freezable = match crate::oracle::freezable(&plane, &u.feature, &named) {
1205 Ok(f) => f,
1206 Err(t) => {
1207 return refuse(CommandStatus::NeedsHuman, "ORACLE-TAMPERED", t.0);
1208 }
1209 };
1210 let requirements = u.requirements();
1212 let mut record = match u.record() {
1213 Ok(r) => r,
1214 Err(e) => return unreadable(&e),
1215 };
1216 let before = items(&requirements, &record);
1217 let who = crate::approvals::Who::local_in(&u.root, via);
1218 let at = std::time::SystemTime::now()
1219 .duration_since(std::time::UNIX_EPOCH)
1220 .map_or(0, |d| d.as_secs());
1221 let mut approved_now = Vec::new();
1222 for (id, line) in &requirements {
1223 if before
1224 .iter()
1225 .any(|i| i.id == *id && matches!(i.state.as_str(), "open" | "lapsed"))
1226 {
1227 record.requirements.insert(
1228 id.clone(),
1229 Decision {
1230 verdict: "approved".to_owned(),
1231 line: line.trim_end().to_owned(),
1232 sha256: digest(line),
1233 by: who.name.clone(),
1234 via: who.via.clone(),
1235 at,
1236 comment: None,
1237 },
1238 );
1239 approved_now.push(id.clone());
1240 }
1241 }
1242 record
1243 .requirements
1244 .retain(|id, _| requirements.iter().any(|(r, _)| r == id));
1245 let approved: Vec<(String, String)> = record
1246 .requirements
1247 .iter()
1248 .filter(|(_, d)| d.verdict == "approved")
1249 .map(|(id, d)| (id.clone(), d.sha256.clone()))
1250 .collect();
1251 let sha = approval_digest(
1252 approved.iter().map(|(a, b)| (a.as_str(), b.as_str())),
1253 freezable
1254 .iter()
1255 .map(|f| (f.req.key.as_str(), f.manifest.as_str())),
1256 );
1257 if sheet.is_none() && via != speccoder_core::actions::Via::Terminal {
1260 return refuse(
1261 CommandStatus::UsageError,
1262 "USAGE",
1263 "Approve and Run names what its sheet showed (the sheet's sha256): open the sheet again".to_owned(),
1264 );
1265 }
1266 if let Some(shown) = sheet
1267 && shown != sha
1268 {
1269 return refuse(
1270 CommandStatus::NeedsHuman,
1271 "REVIEW-CHANGED",
1272 "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(),
1273 );
1274 }
1275 record.oracle.clear();
1276 let mut frozen = Vec::new();
1277 for f in &freezable {
1278 let decided = speccoder_core::acceptance::Decided {
1279 by: who.name.clone(),
1280 via,
1281 at,
1282 };
1283 match crate::oracle::freeze(
1284 &plane,
1285 &u.feature,
1286 f,
1287 &sha,
1288 decided,
1289 speccoder_core::acceptance::Producer::Review,
1290 ) {
1291 Ok(rec) => {
1292 record.oracle.insert(
1293 f.req.key.clone(),
1294 crate::oracle::OracleEntry {
1295 requirement_sha256: f.draft.requirement.sha256.clone(),
1296 manifest_sha256: f.manifest.clone(),
1297 held_out_sha256: rec
1298 .held_out_sealed
1299 .as_ref()
1300 .map(|h| format!("sha256:{}", h.manifest_sha256)),
1301 by: who.name.clone(),
1302 via,
1303 at,
1304 },
1305 );
1306 frozen.push((f, rec));
1307 }
1308 Err(e) => return refuse(CommandStatus::NeedsHuman, "ORACLE-TAMPERED", e),
1309 }
1310 }
1311 record.approval = Some(speccoder_core::oracle::ReviewApproval {
1312 sha256: sha.clone(),
1313 mode,
1314 by: who.name.clone(),
1315 via,
1316 at,
1317 });
1318 let text = serde_json::to_string_pretty(&record).unwrap_or_default() + "\n";
1319 if let Err(e) = std::fs::write(u.file(), text) {
1320 return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", u.rel()));
1321 }
1322 let rel = {
1324 let (top_prefix, rel) = (
1325 crate::git::Git::at(&u.root)
1326 .line(&["rev-parse", "--show-prefix"])
1327 .unwrap_or_default(),
1328 u.rel(),
1329 );
1330 format!("{top_prefix}{rel}")
1331 };
1332 let committed = crate::hosting::commit_exactly(
1333 &u.root,
1334 &[rel],
1335 &format!("review: approve {} and freeze its tests", u.feature),
1336 );
1337 if committed.status() != CommandStatus::Ok {
1338 return CommandResult::carried("review", &committed);
1339 }
1340 for (f, rec) in &frozen {
1341 if let Some(p) = &options.progress {
1342 p.event(&crate::progress::Event::Oracle {
1343 step: crate::progress::OracleEvent::Frozen {
1344 unit: u.feature.clone(),
1345 requirement: f.req.key.clone(),
1346 manifest_sha256: f.manifest.clone(),
1347 approval_sha256: sha.clone(),
1348 visible_count: rec.visible.len(),
1349 held_out_count: rec.held_out_sealed.as_ref().map_or(0, |h| h.count),
1350 by: who.name.clone(),
1351 via: via.as_str().to_owned(),
1352 },
1353 });
1354 }
1355 }
1356 let said = format!(
1357 "approved {} decision line(s), froze tests for {} requirement(s){}; committed {}",
1358 approved_now.len(),
1359 frozen.len(),
1360 if mode == RunMode::Autopilot {
1361 format!(", autopilot with {} grant(s)", objects.len())
1362 } else {
1363 String::new()
1364 },
1365 u.rel()
1366 );
1367 drop(lock);
1368 let mut run_options = options.clone();
1369 run_options.mode = mode;
1370 if mode == RunMode::Autopilot {
1371 run_options.approver = None;
1373 run_options.run_grants = std::sync::Arc::new(std::sync::Mutex::new(objects));
1374 }
1375 let ran = match (state, provider) {
1376 (Some(state), Some(provider)) => crate::pipeline::run_feature_with(
1377 path,
1378 Some(&u.feature),
1379 state,
1380 &provider,
1381 None,
1382 &run_options,
1383 ),
1384 _ => crate::run_feature(path, Some(&u.feature), &run_options, false),
1385 };
1386 let status = ran.status();
1387 let message = format!("{said} — {}", ran.message());
1388 ran.reworded(status, &message)
1389}
1390
1391#[must_use]
1394pub fn answer(path: &Path, feature: Option<&str>, id: &str, text: &str) -> CommandResult {
1395 let u = match unit("review", path, feature) {
1396 Ok(u) => u,
1397 Err(r) => return *r,
1398 };
1399 let text = text.trim();
1400 if text.is_empty() {
1401 return CommandResult::new("review", CommandStatus::UsageError, "an answer needs words")
1402 .with_code("USAGE");
1403 }
1404 let open = questions(&u);
1405 let Some((q, raw)) = open.iter().find(|(q, _)| q.id.eq_ignore_ascii_case(id)) else {
1406 return CommandResult::new(
1407 "review",
1408 CommandStatus::NotFound,
1409 &format!(
1410 "no open question {id}; open: {}",
1411 if open.is_empty() {
1412 "none".to_owned()
1413 } else {
1414 open.iter()
1415 .map(|(q, _)| q.id.as_str())
1416 .collect::<Vec<_>>()
1417 .join(", ")
1418 }
1419 ),
1420 )
1421 .with_code("NOT-FOUND");
1422 };
1423 let file = u.root.join(&q.file);
1424 let Ok(body) = std::fs::read_to_string(&file) else {
1425 return CommandResult::new(
1426 "review",
1427 CommandStatus::Error,
1428 &format!("{} cannot be read", q.file),
1429 );
1430 };
1431 let mut lines: Vec<String> = body.lines().map(str::to_owned).collect();
1432 let Some(line) = lines
1433 .get_mut(q.line - 1)
1434 .filter(|l| l.contains(raw.as_str()))
1435 else {
1436 return CommandResult::new(
1437 "review",
1438 CommandStatus::Error,
1439 &format!("{} changed under the question; look again", q.file),
1440 );
1441 };
1442 *line = line.replacen(raw.as_str(), text, 1);
1443 let mut out = lines.join("\n");
1444 if body.ends_with('\n') {
1445 out.push('\n');
1446 }
1447 if let Err(e) = std::fs::write(&file, out) {
1448 return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", q.file));
1449 }
1450 let r = report_of(&u, true);
1451 CommandResult::ok(
1452 "review",
1453 &format!("answered {} in {} — {}", q.id, q.file, message(&r)),
1454 )
1455 .with_data(CommandData::Review(Box::new(r)))
1456}
1457
1458#[must_use]
1462pub fn edit(
1463 path: &Path,
1464 feature: Option<&str>,
1465 id: &str,
1466 wording: &str,
1467 approve: bool,
1468 via: speccoder_core::actions::Via,
1469) -> CommandResult {
1470 let u = match unit("review", path, feature) {
1471 Ok(u) => u,
1472 Err(r) => return *r,
1473 };
1474 let wording = wording.trim();
1475 if wording.is_empty() || wording.contains('\n') {
1476 return CommandResult::new(
1477 "review",
1478 CommandStatus::UsageError,
1479 "a requirement's wording is one line of words",
1480 )
1481 .with_code("USAGE");
1482 }
1483 let Some(spec) = u.format.requirements_file() else {
1484 return CommandResult::new(
1485 "review",
1486 CommandStatus::UsageError,
1487 "this format has no requirement identifiers",
1488 )
1489 .with_code("USAGE");
1490 };
1491 let Some((_, old)) = u.requirements().into_iter().find(|(r, _)| r == id) else {
1492 return CommandResult::new(
1493 "review",
1494 CommandStatus::NotFound,
1495 &format!("the spec has no {id}"),
1496 )
1497 .with_code("NOT-FOUND");
1498 };
1499 let key = format!("**{id}**");
1501 let Some(at) = old.find(&key) else {
1502 return CommandResult::new(
1503 "review",
1504 CommandStatus::Error,
1505 &format!("{id}'s line has no `{key}`"),
1506 );
1507 };
1508 let new = format!("{}{key}: {wording}", &old[..at]);
1509 let file = u.root.join(&u.dir).join(spec);
1510 let body = std::fs::read_to_string(&file).unwrap_or_default();
1511 if !body.lines().any(|l| l.trim_end() == old.trim_end()) {
1512 return CommandResult::new(
1513 "review",
1514 CommandStatus::Error,
1515 &format!("{spec} changed under {id}; look again"),
1516 );
1517 }
1518 let mut done = false;
1519 let mut out: Vec<String> = Vec::new();
1520 for l in body.lines() {
1521 if !done && l.trim_end() == old.trim_end() {
1522 out.push(new.clone());
1523 done = true;
1524 } else {
1525 out.push(l.to_owned());
1526 }
1527 }
1528 let mut text = out.join("\n");
1529 if body.ends_with('\n') {
1530 text.push('\n');
1531 }
1532 if let Err(e) = std::fs::write(&file, text) {
1533 return CommandResult::new("review", CommandStatus::Error, &format!("{spec}: {e}"));
1534 }
1535 if approve {
1536 let r = decide(path, feature, &[id.to_owned()], "approve", None, via);
1537 let status = r.status();
1538 let message = if status == CommandStatus::Ok {
1539 format!("reworded {id} and approved it; {}", r.message())
1540 } else {
1541 format!("reworded {id}; not approved: {}", r.message())
1542 };
1543 return r.reworded(status, &message);
1544 }
1545 let r = report_of(&u, true);
1546 CommandResult::ok("review", &format!("reworded {id}; {}", message(&r)))
1547 .with_data(CommandData::Review(Box::new(r)))
1548}
1549
1550#[must_use]
1553fn revision_request(items: &[ReviewItem]) -> String {
1554 let lines: Vec<String> = items
1555 .iter()
1556 .filter(|i| i.state == "changes_requested")
1557 .map(|i| {
1558 format!(
1559 "- {} (\"{}\"): {}",
1560 i.id,
1561 i.text,
1562 i.comment.as_deref().unwrap_or_default()
1563 )
1564 })
1565 .collect();
1566 format!(
1567 "Revise the spec per the reviewer's change requests; keep every requirement ID, reword only what is asked:\n{}",
1568 lines.join("\n")
1569 )
1570}
1571
1572#[derive(Debug)]
1575pub struct Snapshot {
1576 root: PathBuf,
1577 files: Vec<(PathBuf, Option<Vec<u8>>)>,
1578}
1579
1580fn records(root: &Path) -> Vec<(PathBuf, Option<Vec<u8>>)> {
1582 let format = crate::format::at(root);
1583 let Some(snap) = format.inspect(root, None) else {
1584 return Vec::new();
1585 };
1586 snap.features
1587 .iter()
1588 .map(|f| {
1589 let file = Path::new(&snap.root).join(&f.dir).join(REVIEW_FILE);
1590 let bytes = std::fs::read(&file).ok();
1591 (file, bytes)
1592 })
1593 .collect()
1594}
1595
1596#[must_use]
1598pub fn snapshot(root: &Path) -> Snapshot {
1599 Snapshot {
1600 root: root.to_path_buf(),
1601 files: records(root),
1602 }
1603}
1604
1605pub fn restore(before: &Snapshot) -> Vec<String> {
1608 let mut restored = Vec::new();
1609 let now = records(&before.root);
1610 for (file, bytes) in &now {
1611 let was = before
1612 .files
1613 .iter()
1614 .find(|(f, _)| f == file)
1615 .and_then(|(_, b)| b.clone());
1616 if *bytes == was {
1617 continue;
1618 }
1619 let ok = match &was {
1620 Some(b) => std::fs::write(file, b).is_ok(),
1621 None => std::fs::remove_file(file).is_ok(),
1622 };
1623 if ok {
1624 restored.push(file.display().to_string());
1625 }
1626 }
1627 restored
1628}
1629
1630#[cfg(test)]
1631mod tests {
1632 use super::*;
1633
1634 #[test]
1635 fn the_wording_is_the_line_without_its_markup() {
1636 assert_eq!(
1637 wording("- **FR-001**: Users MUST log in."),
1638 "Users MUST log in."
1639 );
1640 assert_eq!(digest("a "), digest("a"));
1641 }
1642}