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 let backend = match backend {
621 crate::sandbox::Backend::Seatbelt => "Seatbelt",
622 crate::sandbox::Backend::Landlock(_) => "Landlock+seccomp",
623 };
624 Some(format!("Sandbox: {backend}, {network}"))
625}
626
627fn sheet(
630 u: &Unit,
631 items: &[ReviewItem],
632 questions: &[OpenQuestion],
633 oracle: &[speccoder_core::oracle::OracleView],
634) -> speccoder_core::oracle::ApproveSheet {
635 let mut blocked = Vec::new();
636 if !questions.is_empty() {
637 blocked.push(format!(
638 "{} open question(s): {}",
639 questions.len(),
640 questions
641 .iter()
642 .map(|q| q.near.clone().unwrap_or_else(|| q.id.clone()))
643 .collect::<Vec<_>>()
644 .join(", ")
645 ));
646 }
647 for v in oracle {
648 for a in v.open_ambiguities() {
649 blocked.push(format!(
650 "{}: two readings of {} disagree — choose one, or reword",
651 v.req, a.scenario
652 ));
653 }
654 }
655 if run_active(u) {
656 blocked.push("a run is active".to_owned());
657 }
658 let sha256 = match (&u.state, u.record()) {
661 (Some(plane), Ok(record)) => {
662 match crate::oracle::freezable(plane, &u.feature, &u.oracle_requirements()) {
663 Ok(f) => Some(would_approve(&u.requirements(), &record, &f)),
664 Err(t) => {
665 blocked.push(format!("a test draft cannot be read: {}", t.0));
666 None
667 }
668 }
669 }
670 (_, Err(e)) => {
671 blocked.push(e);
672 None
673 }
674 (None, Ok(_)) => None,
675 };
676 speccoder_core::oracle::ApproveSheet {
677 sha256,
678 approves: items
679 .iter()
680 .filter(|i| matches!(i.state.as_str(), "open" | "lapsed"))
681 .map(|i| i.id.clone())
682 .collect(),
683 blocked,
684 autopilot_blocked: autopilot_blocked(&u.root),
685 sandbox: sandbox_line(&u.root),
686 grants: u
687 .state
688 .as_ref()
689 .map(|s| crate::approvals::offered_grants(s, &u.feature))
690 .unwrap_or_default(),
691 }
692}
693
694pub fn oracle_entries(
700 root: &Path,
701 dir: &str,
702) -> Result<(BTreeMap<String, crate::oracle::OracleEntry>, Option<String>), String> {
703 let record = read_record(&root.join(dir).join(REVIEW_FILE))?;
704 Ok((record.oracle, record.approval.map(|a| a.sha256)))
705}
706
707fn read_record(file: &Path) -> Result<Record, String> {
710 match std::fs::read_to_string(file) {
711 Ok(text) => serde_json::from_str(&text)
712 .map_err(|e| format!("{} is not a valid review record: {e}", file.display())),
713 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Record::default()),
714 Err(e) => Err(format!("{}: {e}", file.display())),
715 }
716}
717
718fn unreadable(e: &str) -> CommandResult {
720 CommandResult::new("review", CommandStatus::NeedsHuman, e).with_code("REVIEW-UNREADABLE")
721}
722
723fn message(r: &ReviewReport) -> String {
724 let t = r.totals;
725 let n = r.requirements.len();
726 if n == 0 {
727 return format!("{}'s spec names no requirement IDs to review", r.feature);
728 }
729 if t.complete() {
730 return format!("{n}/{n} requirements approved");
731 }
732 let plural =
733 |n: usize, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
734 let parts: Vec<String> = [
735 (
736 t.questions,
737 plural(t.questions, "open question", "open questions"),
738 ),
739 (t.open, format!("{} to review", t.open)),
740 (
741 t.changes_requested,
742 format!("{} with changes requested", t.changes_requested),
743 ),
744 (
745 t.lapsed,
746 format!("{} changed since they were decided", t.lapsed),
747 ),
748 ]
749 .into_iter()
750 .filter(|(k, _)| *k > 0)
751 .map(|(_, w)| w)
752 .collect();
753 format!(
754 "{}/{n} requirements approved; {}",
755 t.approved,
756 parts.join(", ")
757 )
758}
759
760#[must_use]
763pub fn summary(path: &Path, feature: Option<&str>) -> Option<ReviewReport> {
764 unit("review", path, feature)
765 .ok()
766 .map(|u| report_of(&u, false))
767}
768
769#[must_use]
771pub fn report(path: &Path, feature: Option<&str>) -> CommandResult {
772 report_in(path, feature, None)
773}
774
775#[must_use]
777pub fn report_in(path: &Path, feature: Option<&str>, state: Option<&Path>) -> CommandResult {
778 let u = match unit_in("review", path, feature, state) {
779 Ok(u) => u,
780 Err(r) => return *r,
781 };
782 let mut r = report_of(&u, true);
783 r.sheet = Some(sheet(&u, &r.requirements, &r.questions, &r.oracle));
784 CommandResult::new("review", CommandStatus::Ok, &message(&r))
785 .with_data(CommandData::Review(Box::new(r)))
786}
787
788#[must_use]
792pub fn decide(
793 path: &Path,
794 feature: Option<&str>,
795 ids: &[String],
796 verdict: &str,
797 comment: Option<&str>,
798 via: speccoder_core::actions::Via,
799) -> CommandResult {
800 decide_in(path, feature, ids, verdict, comment, via, None)
801}
802
803#[must_use]
805pub fn decide_in(
806 path: &Path,
807 feature: Option<&str>,
808 ids: &[String],
809 verdict: &str,
810 comment: Option<&str>,
811 via: speccoder_core::actions::Via,
812 state: Option<&Path>,
813) -> CommandResult {
814 let u = match unit_in("review", path, feature, state) {
815 Ok(u) => u,
816 Err(r) => return *r,
817 };
818 let comment = comment.map(str::trim).filter(|c| !c.is_empty());
819 if verdict == "request" && comment.is_none() {
820 return CommandResult::new(
821 "review",
822 CommandStatus::UsageError,
823 "a change request needs a comment: what should the requirement say?",
824 )
825 .with_code("USAGE");
826 }
827 let requirements = u.requirements();
828 let mut record = match u.record() {
829 Ok(r) => r,
830 Err(e) => return unreadable(&e),
831 };
832 let before = items(&requirements, &record);
833 let asks = |line: &str| line.contains(MARKER);
836 let undecided: Vec<&str> = requirements
837 .iter()
838 .filter(|(_, l)| asks(l))
839 .map(|(id, _)| id.as_str())
840 .collect();
841 if verdict == "approve" && !ids.is_empty() {
842 let blocked: Vec<&str> = ids
843 .iter()
844 .map(String::as_str)
845 .filter(|id| undecided.contains(id))
846 .collect();
847 if !blocked.is_empty() {
848 return CommandResult::new(
849 "review",
850 CommandStatus::UsageError,
851 &format!(
852 "{} still {} an open question: answer it first (`speccoder review answer …`), or reword it (`speccoder review edit {} \"…\" --approve`)",
853 blocked.join(", "),
854 if blocked.len() == 1 { "asks" } else { "ask" },
855 blocked[0]
856 ),
857 )
858 .with_code("USAGE");
859 }
860 }
861 let chosen: Vec<&(String, String)> = if ids.is_empty() {
862 requirements
863 .iter()
864 .filter(|(id, l)| {
865 before.iter().any(|i| i.id == *id && i.state != "approved")
866 && !(verdict == "approve" && asks(l))
867 })
868 .collect()
869 } else {
870 let unknown: Vec<&String> = ids
871 .iter()
872 .filter(|id| !requirements.iter().any(|(r, _)| r == *id))
873 .collect();
874 if !unknown.is_empty() {
875 return CommandResult::new(
876 "review",
877 CommandStatus::NotFound,
878 &format!(
879 "the spec has no {}; it names {}",
880 unknown
881 .iter()
882 .map(|s| s.as_str())
883 .collect::<Vec<_>>()
884 .join(", "),
885 requirements
886 .iter()
887 .map(|(r, _)| r.as_str())
888 .collect::<Vec<_>>()
889 .join(", ")
890 ),
891 )
892 .with_code("NOT-FOUND")
893 .with_fix(vec!["speccoder review".to_owned()]);
894 }
895 requirements
896 .iter()
897 .filter(|(id, _)| ids.contains(id))
898 .collect()
899 };
900 let who = crate::approvals::Who::local_in(&u.root, via);
901 let now = std::time::SystemTime::now()
902 .duration_since(std::time::UNIX_EPOCH)
903 .map_or(0, |d| d.as_secs());
904 for (id, line) in &chosen {
905 match verdict {
906 "clear" => {
907 record.requirements.remove(id);
908 }
909 "approve" | "request" => {
910 record.requirements.insert(
911 id.clone(),
912 Decision {
913 verdict: if verdict == "approve" {
914 "approved"
915 } else {
916 "changes_requested"
917 }
918 .to_owned(),
919 line: line.trim_end().to_owned(),
920 sha256: digest(line),
921 by: who.name.clone(),
922 via: who.via.clone(),
923 at: now,
924 comment: comment.map(str::to_owned),
925 },
926 );
927 }
928 other => {
929 return CommandResult::new(
930 "review",
931 CommandStatus::UsageError,
932 &format!("`{other}` is not a review verdict: approve, request or clear"),
933 )
934 .with_code("USAGE");
935 }
936 }
937 }
938 record
940 .requirements
941 .retain(|id, _| requirements.iter().any(|(r, _)| r == id));
942 let named = u.oracle_requirements();
943 if u.snapshot.is_some() {
944 record
945 .oracle
946 .retain(|key, _| named.iter().any(|r| r.key == *key));
947 }
948 let text = serde_json::to_string_pretty(&record).unwrap_or_default() + "\n";
949 if let Err(e) = std::fs::write(u.file(), text) {
950 return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", u.rel()));
951 }
952 let r = report_of(&u, true);
953 let word = match verdict {
954 "approve" => "approved",
955 "request" => "changes requested on",
956 _ => "cleared",
957 };
958 let skipped = if verdict == "approve" && ids.is_empty() && !undecided.is_empty() {
959 format!(
960 "; not {} — {} still {} an open question",
961 undecided.join(", "),
962 if undecided.len() == 1 { "it" } else { "they" },
963 if undecided.len() == 1 { "asks" } else { "ask" }
964 )
965 } else {
966 String::new()
967 };
968 let ambiguous: Vec<String> = r
969 .oracle
970 .iter()
971 .filter(|v| v.open_ambiguities().next().is_some())
972 .map(|v| v.req.clone())
973 .collect();
974 let skipped = if verdict == "approve" && !ambiguous.is_empty() {
975 format!(
976 "{skipped}; {} {} ambiguous — Approve and Run stays blocked",
977 ambiguous.join(", "),
978 if ambiguous.len() == 1 { "is" } else { "are" }
979 )
980 } else {
981 skipped
982 };
983 let message = if chosen.is_empty() {
984 format!("nothing to decide{skipped}: {}", message(&r))
985 } else {
986 format!(
987 "{word} {}{skipped}; {} — commit {} to share it",
988 chosen
989 .iter()
990 .map(|(id, _)| id.as_str())
991 .collect::<Vec<_>>()
992 .join(", "),
993 message(&r),
994 r.file
995 )
996 };
997 CommandResult::new("review", CommandStatus::Ok, &message)
998 .with_data(CommandData::Review(Box::new(r)))
999}
1000
1001fn would_approve(
1006 requirements: &[(String, String)],
1007 record: &Record,
1008 freezable: &[crate::oracle::Freezable],
1009) -> String {
1010 let before = items(requirements, record);
1011 let approved: Vec<(String, String)> = requirements
1012 .iter()
1013 .filter_map(|(id, line)| {
1014 let state = before.iter().find(|i| i.id == *id)?.state.as_str();
1015 match state {
1016 "open" | "lapsed" => Some((id.clone(), digest(line))),
1017 _ => record
1018 .requirements
1019 .get(id)
1020 .filter(|d| d.verdict == "approved")
1021 .map(|d| (id.clone(), d.sha256.clone())),
1022 }
1023 })
1024 .collect();
1025 approval_digest(
1026 approved.iter().map(|(a, b)| (a.as_str(), b.as_str())),
1027 freezable
1028 .iter()
1029 .map(|f| (f.req.key.as_str(), f.manifest.as_str())),
1030 )
1031}
1032
1033#[must_use]
1037pub fn approval_digest<'a>(
1038 decisions: impl IntoIterator<Item = (&'a str, &'a str)>,
1039 oracle: impl IntoIterator<Item = (&'a str, &'a str)>,
1040) -> String {
1041 let mut lines: Vec<String> = decisions
1042 .into_iter()
1043 .map(|(id, sha)| format!("{id}={sha}"))
1044 .collect();
1045 lines.sort();
1046 let mut frozen: Vec<String> = oracle
1047 .into_iter()
1048 .map(|(req, sha)| format!("{req}={sha}"))
1049 .collect();
1050 frozen.sort();
1051 format!(
1052 "sha256:{}",
1053 crate::integrity::sha256_hex(
1054 format!("{}\n{}", lines.join("\n"), frozen.join("\n")).as_bytes()
1055 )
1056 )
1057}
1058
1059#[must_use]
1066#[allow(clippy::too_many_arguments)]
1067pub fn approve_and_run(
1068 path: &Path,
1069 feature: Option<&str>,
1070 mode: speccoder_core::oracle::RunMode,
1071 grants: &[String],
1072 sheet: Option<&str>,
1073 via: speccoder_core::actions::Via,
1074 state: Option<&Path>,
1075 provider: Option<std::sync::Arc<dyn agentplane::model::ModelProvider>>,
1076 options: &crate::RunOptions,
1077) -> CommandResult {
1078 use speccoder_core::oracle::RunMode;
1079 let u = match unit_in("review", path, feature, state) {
1080 Ok(u) => u,
1081 Err(r) => return *r,
1082 };
1083 let Some(plane) = u.state.clone() else {
1084 return CommandResult::new(
1085 "review",
1086 CommandStatus::ConfigError,
1087 "no state directory (set SPECCODER_STATE_DIR or HOME)",
1088 )
1089 .with_code("NO-STATE-DIR");
1090 };
1091 let refuse =
1092 |status, code: &str, m: String| CommandResult::new("review", status, &m).with_code(code);
1093 if run_active(&u) {
1094 return refuse(
1095 CommandStatus::GitPrecondition,
1096 "RUN-ACTIVE",
1097 format!(
1098 "a run is active for {}: approve and run when it ends",
1099 u.feature
1100 ),
1101 );
1102 }
1103 if let Some(stop) = u
1106 .snapshot
1107 .as_ref()
1108 .and_then(|s| s.feature.as_ref())
1109 .and_then(|f| crate::pipeline::feature::no_tasks(u.format, f))
1110 {
1111 let mut fix = vec![format!(
1112 "speccoder review approve --feature {} (approve the spec without a run)",
1113 u.feature
1114 )];
1115 fix.extend(stop.fix);
1116 return refuse(
1117 CommandStatus::TaskNotReady,
1118 "NO-TASKS",
1119 format!("{}; nothing was approved", stop.message),
1120 )
1121 .with_fix(fix)
1122 .with_next(stop.next);
1123 }
1124 let lock = match crate::oracle::UnitLock::take(&plane, &u.feature) {
1126 Ok(l) => l,
1127 Err(e) => return refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", e),
1128 };
1129 let open = questions(&u);
1130 if !open.is_empty() {
1131 return refuse(
1132 CommandStatus::NeedsHuman,
1133 "REVIEW-PENDING",
1134 format!(
1135 "{} open question(s) first: {} — answer (`speccoder review answer Q1 \"…\"`) or reword",
1136 open.len(),
1137 open.iter()
1138 .map(|(q, _)| q.id.clone())
1139 .collect::<Vec<_>>()
1140 .join(", ")
1141 ),
1142 );
1143 }
1144 let named = u.oracle_requirements();
1145 let ambiguous = match crate::oracle::open_ambiguities(&plane, &u.feature, &named) {
1146 Ok(a) => a,
1147 Err(t) => return refuse(CommandStatus::NeedsHuman, "ORACLE-TAMPERED", t.0),
1148 };
1149 if !ambiguous.is_empty() {
1150 return refuse(
1151 CommandStatus::NeedsHuman,
1152 "REVIEW-AMBIGUOUS",
1153 format!(
1154 "Approve and Run waits: {} read two ways — choose a reading (`speccoder review tests {} --reading A|B {}`) or reword",
1155 ambiguous
1156 .iter()
1157 .map(|(r, s)| format!("{r}/{s}"))
1158 .collect::<Vec<_>>()
1159 .join(", "),
1160 ambiguous[0].0,
1161 ambiguous[0].1
1162 ),
1163 );
1164 }
1165 let mut objects = Vec::new();
1168 if mode == RunMode::Autopilot {
1169 if let Some(why) = autopilot_blocked(&u.root) {
1170 return refuse(CommandStatus::ConfigError, "SANDBOX-UNAVAILABLE", why).with_fix(vec![
1171 "speccoder review approve --run ask".to_owned(),
1172 "→ or enable the OS sandbox ([sandbox] mode = \"auto\" on macOS or Linux 6.2+)"
1173 .to_owned(),
1174 ]);
1175 }
1176 let offered = crate::approvals::offered_objects(&plane, &u.feature);
1177 for id in grants {
1178 match offered.iter().find(|(g, _)| g.id == *id) {
1179 Some((_, o)) => objects.push(o.clone()),
1180 None => {
1181 return refuse(
1182 CommandStatus::UsageError,
1183 "USAGE",
1184 format!(
1185 "{id} is not a grant offered for {}; offered: {}",
1186 u.feature,
1187 if offered.is_empty() {
1188 "none".to_owned()
1189 } else {
1190 offered
1191 .iter()
1192 .map(|(g, _)| format!("{} ({})", g.id, g.object))
1193 .collect::<Vec<_>>()
1194 .join(", ")
1195 }
1196 ),
1197 );
1198 }
1199 }
1200 }
1201 } else if !grants.is_empty() {
1202 return refuse(
1203 CommandStatus::UsageError,
1204 "USAGE",
1205 "--grant is for an autopilot run (--run autopilot)".to_owned(),
1206 );
1207 }
1208 let freezable = match crate::oracle::freezable(&plane, &u.feature, &named) {
1209 Ok(f) => f,
1210 Err(t) => {
1211 return refuse(CommandStatus::NeedsHuman, "ORACLE-TAMPERED", t.0);
1212 }
1213 };
1214 let requirements = u.requirements();
1216 let mut record = match u.record() {
1217 Ok(r) => r,
1218 Err(e) => return unreadable(&e),
1219 };
1220 let before = items(&requirements, &record);
1221 let who = crate::approvals::Who::local_in(&u.root, via);
1222 let at = std::time::SystemTime::now()
1223 .duration_since(std::time::UNIX_EPOCH)
1224 .map_or(0, |d| d.as_secs());
1225 let mut approved_now = Vec::new();
1226 for (id, line) in &requirements {
1227 if before
1228 .iter()
1229 .any(|i| i.id == *id && matches!(i.state.as_str(), "open" | "lapsed"))
1230 {
1231 record.requirements.insert(
1232 id.clone(),
1233 Decision {
1234 verdict: "approved".to_owned(),
1235 line: line.trim_end().to_owned(),
1236 sha256: digest(line),
1237 by: who.name.clone(),
1238 via: who.via.clone(),
1239 at,
1240 comment: None,
1241 },
1242 );
1243 approved_now.push(id.clone());
1244 }
1245 }
1246 record
1247 .requirements
1248 .retain(|id, _| requirements.iter().any(|(r, _)| r == id));
1249 let approved: Vec<(String, String)> = record
1250 .requirements
1251 .iter()
1252 .filter(|(_, d)| d.verdict == "approved")
1253 .map(|(id, d)| (id.clone(), d.sha256.clone()))
1254 .collect();
1255 let sha = approval_digest(
1256 approved.iter().map(|(a, b)| (a.as_str(), b.as_str())),
1257 freezable
1258 .iter()
1259 .map(|f| (f.req.key.as_str(), f.manifest.as_str())),
1260 );
1261 if sheet.is_none() && via != speccoder_core::actions::Via::Terminal {
1264 return refuse(
1265 CommandStatus::UsageError,
1266 "USAGE",
1267 "Approve and Run names what its sheet showed (the sheet's sha256): open the sheet again".to_owned(),
1268 );
1269 }
1270 if let Some(shown) = sheet
1271 && shown != sha
1272 {
1273 return refuse(
1274 CommandStatus::NeedsHuman,
1275 "REVIEW-CHANGED",
1276 "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(),
1277 );
1278 }
1279 record.oracle.clear();
1280 let mut frozen = Vec::new();
1281 for f in &freezable {
1282 let decided = speccoder_core::acceptance::Decided {
1283 by: who.name.clone(),
1284 via,
1285 at,
1286 };
1287 match crate::oracle::freeze(&plane, &u.feature, f, &sha, decided) {
1288 Ok(rec) => {
1289 record.oracle.insert(
1290 f.req.key.clone(),
1291 crate::oracle::OracleEntry {
1292 requirement_sha256: f.draft.requirement.sha256.clone(),
1293 manifest_sha256: f.manifest.clone(),
1294 held_out_sha256: rec
1295 .held_out_sealed
1296 .as_ref()
1297 .map(|h| format!("sha256:{}", h.manifest_sha256)),
1298 by: who.name.clone(),
1299 via,
1300 at,
1301 },
1302 );
1303 frozen.push((f, rec));
1304 }
1305 Err(e) => return refuse(CommandStatus::NeedsHuman, "ORACLE-TAMPERED", e),
1306 }
1307 }
1308 record.approval = Some(speccoder_core::oracle::ReviewApproval {
1309 sha256: sha.clone(),
1310 mode,
1311 by: who.name.clone(),
1312 via,
1313 at,
1314 });
1315 let text = serde_json::to_string_pretty(&record).unwrap_or_default() + "\n";
1316 if let Err(e) = std::fs::write(u.file(), text) {
1317 return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", u.rel()));
1318 }
1319 let rel = {
1321 let (top_prefix, rel) = (
1322 crate::git::Git::at(&u.root)
1323 .line(&["rev-parse", "--show-prefix"])
1324 .unwrap_or_default(),
1325 u.rel(),
1326 );
1327 format!("{top_prefix}{rel}")
1328 };
1329 let committed = crate::hosting::commit_exactly(
1330 &u.root,
1331 &[rel],
1332 &format!("review: approve {} and freeze its tests", u.feature),
1333 );
1334 if committed.status() != CommandStatus::Ok {
1335 return CommandResult::carried("review", &committed);
1336 }
1337 for (f, rec) in &frozen {
1338 if let Some(p) = &options.progress {
1339 p.event(&crate::progress::Event::Oracle {
1340 step: crate::progress::OracleEvent::Frozen {
1341 unit: u.feature.clone(),
1342 requirement: f.req.key.clone(),
1343 manifest_sha256: f.manifest.clone(),
1344 approval_sha256: sha.clone(),
1345 visible_count: rec.visible.len(),
1346 held_out_count: rec.held_out_sealed.as_ref().map_or(0, |h| h.count),
1347 by: who.name.clone(),
1348 via: via.as_str().to_owned(),
1349 },
1350 });
1351 }
1352 }
1353 let said = format!(
1354 "approved {} decision line(s), froze tests for {} requirement(s){}; committed {}",
1355 approved_now.len(),
1356 frozen.len(),
1357 if mode == RunMode::Autopilot {
1358 format!(", autopilot with {} grant(s)", objects.len())
1359 } else {
1360 String::new()
1361 },
1362 u.rel()
1363 );
1364 drop(lock);
1365 let mut run_options = options.clone();
1366 run_options.mode = mode;
1367 if mode == RunMode::Autopilot {
1368 run_options.approver = None;
1370 run_options.run_grants = std::sync::Arc::new(std::sync::Mutex::new(objects));
1371 }
1372 let ran = match (state, provider) {
1373 (Some(state), Some(provider)) => crate::pipeline::run_feature_with(
1374 path,
1375 Some(&u.feature),
1376 state,
1377 &provider,
1378 None,
1379 &run_options,
1380 ),
1381 _ => crate::run_feature(path, Some(&u.feature), &run_options, false),
1382 };
1383 let status = ran.status();
1384 let message = format!("{said} — {}", ran.message());
1385 ran.reworded(status, &message)
1386}
1387
1388#[must_use]
1391pub fn answer(path: &Path, feature: Option<&str>, id: &str, text: &str) -> CommandResult {
1392 let u = match unit("review", path, feature) {
1393 Ok(u) => u,
1394 Err(r) => return *r,
1395 };
1396 let text = text.trim();
1397 if text.is_empty() {
1398 return CommandResult::new("review", CommandStatus::UsageError, "an answer needs words")
1399 .with_code("USAGE");
1400 }
1401 let open = questions(&u);
1402 let Some((q, raw)) = open.iter().find(|(q, _)| q.id.eq_ignore_ascii_case(id)) else {
1403 return CommandResult::new(
1404 "review",
1405 CommandStatus::NotFound,
1406 &format!(
1407 "no open question {id}; open: {}",
1408 if open.is_empty() {
1409 "none".to_owned()
1410 } else {
1411 open.iter()
1412 .map(|(q, _)| q.id.as_str())
1413 .collect::<Vec<_>>()
1414 .join(", ")
1415 }
1416 ),
1417 )
1418 .with_code("NOT-FOUND");
1419 };
1420 let file = u.root.join(&q.file);
1421 let Ok(body) = std::fs::read_to_string(&file) else {
1422 return CommandResult::new(
1423 "review",
1424 CommandStatus::Error,
1425 &format!("{} cannot be read", q.file),
1426 );
1427 };
1428 let mut lines: Vec<String> = body.lines().map(str::to_owned).collect();
1429 let Some(line) = lines
1430 .get_mut(q.line - 1)
1431 .filter(|l| l.contains(raw.as_str()))
1432 else {
1433 return CommandResult::new(
1434 "review",
1435 CommandStatus::Error,
1436 &format!("{} changed under the question; look again", q.file),
1437 );
1438 };
1439 *line = line.replacen(raw.as_str(), text, 1);
1440 let mut out = lines.join("\n");
1441 if body.ends_with('\n') {
1442 out.push('\n');
1443 }
1444 if let Err(e) = std::fs::write(&file, out) {
1445 return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", q.file));
1446 }
1447 let r = report_of(&u, true);
1448 CommandResult::ok(
1449 "review",
1450 &format!("answered {} in {} — {}", q.id, q.file, message(&r)),
1451 )
1452 .with_data(CommandData::Review(Box::new(r)))
1453}
1454
1455#[must_use]
1459pub fn edit(
1460 path: &Path,
1461 feature: Option<&str>,
1462 id: &str,
1463 wording: &str,
1464 approve: bool,
1465 via: speccoder_core::actions::Via,
1466) -> CommandResult {
1467 let u = match unit("review", path, feature) {
1468 Ok(u) => u,
1469 Err(r) => return *r,
1470 };
1471 let wording = wording.trim();
1472 if wording.is_empty() || wording.contains('\n') {
1473 return CommandResult::new(
1474 "review",
1475 CommandStatus::UsageError,
1476 "a requirement's wording is one line of words",
1477 )
1478 .with_code("USAGE");
1479 }
1480 let Some(spec) = u.format.requirements_file() else {
1481 return CommandResult::new(
1482 "review",
1483 CommandStatus::UsageError,
1484 "this format has no requirement identifiers",
1485 )
1486 .with_code("USAGE");
1487 };
1488 let Some((_, old)) = u.requirements().into_iter().find(|(r, _)| r == id) else {
1489 return CommandResult::new(
1490 "review",
1491 CommandStatus::NotFound,
1492 &format!("the spec has no {id}"),
1493 )
1494 .with_code("NOT-FOUND");
1495 };
1496 let key = format!("**{id}**");
1498 let Some(at) = old.find(&key) else {
1499 return CommandResult::new(
1500 "review",
1501 CommandStatus::Error,
1502 &format!("{id}'s line has no `{key}`"),
1503 );
1504 };
1505 let new = format!("{}{key}: {wording}", &old[..at]);
1506 let file = u.root.join(&u.dir).join(spec);
1507 let body = std::fs::read_to_string(&file).unwrap_or_default();
1508 if !body.lines().any(|l| l.trim_end() == old.trim_end()) {
1509 return CommandResult::new(
1510 "review",
1511 CommandStatus::Error,
1512 &format!("{spec} changed under {id}; look again"),
1513 );
1514 }
1515 let mut done = false;
1516 let mut out: Vec<String> = Vec::new();
1517 for l in body.lines() {
1518 if !done && l.trim_end() == old.trim_end() {
1519 out.push(new.clone());
1520 done = true;
1521 } else {
1522 out.push(l.to_owned());
1523 }
1524 }
1525 let mut text = out.join("\n");
1526 if body.ends_with('\n') {
1527 text.push('\n');
1528 }
1529 if let Err(e) = std::fs::write(&file, text) {
1530 return CommandResult::new("review", CommandStatus::Error, &format!("{spec}: {e}"));
1531 }
1532 if approve {
1533 let r = decide(path, feature, &[id.to_owned()], "approve", None, via);
1534 let status = r.status();
1535 let message = if status == CommandStatus::Ok {
1536 format!("reworded {id} and approved it; {}", r.message())
1537 } else {
1538 format!("reworded {id}; not approved: {}", r.message())
1539 };
1540 return r.reworded(status, &message);
1541 }
1542 let r = report_of(&u, true);
1543 CommandResult::ok("review", &format!("reworded {id}; {}", message(&r)))
1544 .with_data(CommandData::Review(Box::new(r)))
1545}
1546
1547#[must_use]
1550fn revision_request(items: &[ReviewItem]) -> String {
1551 let lines: Vec<String> = items
1552 .iter()
1553 .filter(|i| i.state == "changes_requested")
1554 .map(|i| {
1555 format!(
1556 "- {} (\"{}\"): {}",
1557 i.id,
1558 i.text,
1559 i.comment.as_deref().unwrap_or_default()
1560 )
1561 })
1562 .collect();
1563 format!(
1564 "Revise the spec per the reviewer's change requests; keep every requirement ID, reword only what is asked:\n{}",
1565 lines.join("\n")
1566 )
1567}
1568
1569#[derive(Debug)]
1572pub struct Snapshot {
1573 root: PathBuf,
1574 files: Vec<(PathBuf, Option<Vec<u8>>)>,
1575}
1576
1577fn records(root: &Path) -> Vec<(PathBuf, Option<Vec<u8>>)> {
1579 let format = crate::format::at(root);
1580 let Some(snap) = format.inspect(root, None) else {
1581 return Vec::new();
1582 };
1583 snap.features
1584 .iter()
1585 .map(|f| {
1586 let file = Path::new(&snap.root).join(&f.dir).join(REVIEW_FILE);
1587 let bytes = std::fs::read(&file).ok();
1588 (file, bytes)
1589 })
1590 .collect()
1591}
1592
1593#[must_use]
1595pub fn snapshot(root: &Path) -> Snapshot {
1596 Snapshot {
1597 root: root.to_path_buf(),
1598 files: records(root),
1599 }
1600}
1601
1602pub fn restore(before: &Snapshot) -> Vec<String> {
1605 let mut restored = Vec::new();
1606 let now = records(&before.root);
1607 for (file, bytes) in &now {
1608 let was = before
1609 .files
1610 .iter()
1611 .find(|(f, _)| f == file)
1612 .and_then(|(_, b)| b.clone());
1613 if *bytes == was {
1614 continue;
1615 }
1616 let ok = match &was {
1617 Some(b) => std::fs::write(file, b).is_ok(),
1618 None => std::fs::remove_file(file).is_ok(),
1619 };
1620 if ok {
1621 restored.push(file.display().to_string());
1622 }
1623 }
1624 restored
1625}
1626
1627#[cfg(test)]
1628mod tests {
1629 use super::*;
1630
1631 #[test]
1632 fn the_wording_is_the_line_without_its_markup() {
1633 assert_eq!(
1634 wording("- **FR-001**: Users MUST log in."),
1635 "Users MUST log in."
1636 );
1637 assert_eq!(digest("a "), digest("a"));
1638 }
1639}