Skip to main content

speccoder_runtime/
review.rs

1//! `speccoder review`: the person's review of the spec, one
2//! requirement at a time. A decision — approved, or changes requested with a
3//! comment — is bound to the exact line it was made on, and names who made
4//! it; when the line changes, the decision lapses. It is recorded in
5//! `review.json` beside the spec, to commit and share like the spec itself.
6//! The model never writes it ([`crate::tools::paths::REVIEW_FILE`]).
7
8use 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/// One decision as recorded.
20#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
21struct Decision {
22    /// `approved` or `changes_requested`.
23    verdict: String,
24    /// The line decided on.
25    line: String,
26    /// `sha256:<hex>` of the line, trailing space aside.
27    sha256: String,
28    /// Who (git's `user.name`, else the OS user).
29    by: String,
30    /// `cli`, `web`, `editor <name>`.
31    via: String,
32    /// Unix seconds.
33    at: u64,
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    comment: Option<String>,
36}
37
38/// The file.
39#[derive(Debug, Default, Serialize, Deserialize)]
40struct Record {
41    #[serde(default)]
42    requirements: BTreeMap<String, Decision>,
43    /// The frozen oracle, by requirement (`US1`): digests only.
44    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
45    oracle: BTreeMap<String, crate::oracle::OracleEntry>,
46    /// The approval that froze it.
47    #[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
58/// `- **FR-001**: Users MUST …` → `Users MUST …`.
59fn 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
67/// What a review is about: the feature, its directory, the project root.
68struct Unit {
69    feature: String,
70    root: PathBuf,
71    dir: String,
72    format: &'static dyn crate::format::Adapter,
73    /// The project as inspected (the requirements with scenarios come
74    /// from it); `None` for the run's gate, which reads decision lines only.
75    snapshot: Option<Box<speccoder_core::snapshot::ProjectSnapshot>>,
76    /// The project's state directory, when there is one.
77    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    /// The requirements as the spec in the checkout has them now.
88    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    /// The recorded review; none written yet is the empty record, one
97    /// that cannot be read is an `Err` (never read as empty).
98    fn record(&self) -> Result<Record, String> {
99        read_record(&self.file())
100    }
101    /// The requirements with scenarios a review writes tests for.
102    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    /// The unit's rung (a quick change's, a bug's, a feature's).
112    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
153/// Each requirement with its state against the record.
154fn 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
191/// The marker a skill leaves where it could not decide.
192const MARKER: &str = "[NEEDS CLARIFICATION";
193
194/// The unit's Markdown documents, the requirements file first, then by
195/// name: (project-relative path, text).
196fn 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
217/// Every `[NEEDS CLARIFICATION: …]` in `text`: (the marker as written, the
218/// question), in order.
219fn 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
236/// The questions the unit's documents leave open, with their markers.
237fn 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
271/// The requirements as `rev` (`HEAD`, a commit) has the requirements file.
272fn 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
281/// The requirements changed in the checkout since the spec's last commit,
282/// each with its line number on both sides.
283fn 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
315/// What changed from `before` to `after`, requirement by requirement.
316fn 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
340/// The spec's last commits and what each changed in its requirements.
341fn 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/// The review state of the feature in `dir` (project-relative) under
386/// `root`; `None` when the format has no requirement identifiers or the
387/// spec names none.
388#[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    // A record that cannot be read approves nothing: every line counts open.
405    (!requirements.is_empty()).then(|| {
406        totals(
407            &items(&requirements, &u.record().unwrap_or_default()),
408            questions(&u).len(),
409        )
410    })
411}
412
413/// The review; `full` adds what changed since the last commit and the
414/// history (git reads, for the review view — not for the inbox).
415fn report_of(u: &Unit, full: bool) -> ReviewReport {
416    // Shown as it is: a record that cannot be read approves and freezes
417    // nothing, and the report says why.
418    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    // The approve sheet is the view's (`report`), never a decision's result.
477    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
503/// The unit's readiness: its requirements, the scenarios over its
504/// stories (or the format's requirements), the open questions, and the
505/// stories or requirements no scenario covers — each counted from the
506/// files through the format adapter.
507fn 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/// The readiness of `feature` (default: the current one) now; `None` when
533/// there is no such unit.
534#[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
541/// The confirmed reproductions of the bugs recorded in bug unit `unit`,
542/// each with the content kept when it was confirmed (digest checked).
543fn 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
585/// Whether a feature run holds this project now.
586fn 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
592/// Why autopilot cannot run here: it needs the OS sandbox enforced.
593fn 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
608/// The sandbox line of the approve sheet.
609fn 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
623/// What *Approve and Run* would approve, what blocks it, and what an
624/// autopilot run may hold.
625fn 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    // What the approval would record: the click sends it back, and a
655    // change in between refuses it (REVIEW-CHANGED).
656    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
690/// The frozen oracle entries and the approval digest `review.json` in
691/// `dir` (project-relative, under `root`) holds.
692///
693/// # Errors
694/// The record exists but cannot be read: never read as "nothing frozen".
695pub 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
703/// `review.json` at `file`: absent is the empty record; present and not
704/// valid is an error naming it.
705fn 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
714/// The refusal for a review record that cannot be read.
715fn 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/// The review without its git reads (changes, history): what the inbox
757/// needs, per feature, on every refresh.
758#[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/// `speccoder review`: every requirement and its review.
766#[must_use]
767pub fn report(path: &Path, feature: Option<&str>) -> CommandResult {
768    report_in(path, feature, None)
769}
770
771/// [`report`] with the state directory given (`None`: the environment's).
772#[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/// `speccoder review approve|request|clear [ID…]`: records the person's
785/// decision on the requirements named (none: every one not approved on its
786/// current wording) and writes `review.json`. `request` needs a comment.
787#[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/// [`decide`] with the state directory given (`None`: the environment's).
800#[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    // A wording that still asks a question is not one to sign: approving
830    // it is refused; approving "the rest" passes it by and says so.
831    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    // Decisions on requirements the spec no longer names go with them.
935    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
997/// The approval digest *Approve and Run* records when it runs now: every
998/// decision line approved after it (each open or lapsed one approved on
999/// its wording), and the requirements it freezes — what the sheet shows,
1000/// so the click binds to it.
1001fn 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/// The approval digest (research R6): SHA-256 over the approved decision
1030/// lines (`ID=line digest`, sorted) and the frozen oracle entries
1031/// (`REQ=manifest digest`, sorted).
1032#[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/// `speccoder review approve --run ask|autopilot [--grant …]` — *Approve
1056/// and Run*: approves every decision line that is open or lapsed, freezes
1057/// each requirement's red tests (one pre-frozen set each, `review.json`'s
1058/// `oracle` and `approval`), commits `review.json` and starts the
1059/// feature's run. Refused while a question is open, a requirement is read
1060/// two ways, or a run is active.
1061#[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    // Nothing to run: refused before anything is approved or frozen — a
1100    // unit without a task is never done.
1101    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    // Nothing writes the unit's tests while they are frozen.
1121    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    // Autopilot: an enforced sandbox, and only grants offered for this
1162    // feature, by their grant id.
1163    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    // The decision lines: every one open or lapsed is approved now.
1211    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    // A click binds to what its sheet showed; a terminal command acts on
1258    // what it reads now.
1259    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    // The run reads the committed spec: the approval is committed with it.
1323    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        // Nobody is asked: the ticked grants are the run's only grants.
1372        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/// `speccoder review answer Q1 "…"`: writes the person's answer where the
1392/// question was — the marker replaced by the answer, nothing else touched.
1393#[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/// `speccoder review edit FR-003 "…" [--approve]`: the person rewords a
1459/// requirement — its line keeps its identifier, its decision lapses (or is
1460/// approved on the new words with `approve`).
1461#[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    // `- **FR-003**: …` keeps everything up to the identifier and its colon.
1500    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/// The arguments for the revising skill: each change request with its
1551/// requirement, so the spec is revised to say what the person asked.
1552#[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/// The review records under `root`'s units, as they are before a hosted
1573/// skill runs: it must not change them, nor leave one in a unit it creates.
1574#[derive(Debug)]
1575pub struct Snapshot {
1576    root: PathBuf,
1577    files: Vec<(PathBuf, Option<Vec<u8>>)>,
1578}
1579
1580/// Every unit's review record under `root` (absent ones as `None`).
1581fn 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/// The records as they are now.
1597#[must_use]
1598pub fn snapshot(root: &Path) -> Snapshot {
1599    Snapshot {
1600        root: root.to_path_buf(),
1601        files: records(root),
1602    }
1603}
1604
1605/// Puts back every record that changed since `before`, and removes one in
1606/// a unit that appeared since; the paths restored.
1607pub 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}