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