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