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}
44
45fn digest(line: &str) -> String {
46    format!(
47        "sha256:{}",
48        crate::integrity::sha256_hex(line.trim_end().as_bytes())
49    )
50}
51
52/// `- **FR-001**: Users MUST …` → `Users MUST …`.
53fn wording(line: &str) -> String {
54    let t = line.trim_start().trim_start_matches("- ");
55    match t.strip_prefix("**").and_then(|r| r.split_once("**")) {
56        Some((_, rest)) => rest.trim_start_matches(':').trim().to_owned(),
57        None => t.trim().to_owned(),
58    }
59}
60
61/// What a review is about: the feature, its directory, the project root.
62struct Unit {
63    feature: String,
64    root: PathBuf,
65    dir: String,
66    format: &'static dyn crate::format::Adapter,
67}
68
69impl Unit {
70    fn file(&self) -> PathBuf {
71        self.root.join(&self.dir).join(REVIEW_FILE)
72    }
73    fn rel(&self) -> String {
74        format!("{}/{REVIEW_FILE}", self.dir.trim_end_matches('/'))
75    }
76    /// The requirements as the spec in the checkout has them now.
77    fn requirements(&self) -> Vec<(String, String)> {
78        let Some(file) = self.format.requirements_file() else {
79            return Vec::new();
80        };
81        let text =
82            std::fs::read_to_string(self.root.join(&self.dir).join(file)).unwrap_or_default();
83        self.format.requirements_in(&text)
84    }
85    fn record(&self) -> Record {
86        std::fs::read_to_string(self.file())
87            .ok()
88            .and_then(|t| serde_json::from_str(&t).ok())
89            .unwrap_or_default()
90    }
91}
92
93fn unit(command: &str, path: &Path, feature: Option<&str>) -> Result<Unit, Box<CommandResult>> {
94    let inspected = crate::inspect(path, feature);
95    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
96        return Err(Box::new(CommandResult::carried(command, &inspected)));
97    };
98    let Some(f) = snapshot.feature.as_ref() else {
99        return Err(Box::new(CommandResult::new(
100            command,
101            CommandStatus::SelectionNeeded,
102            inspected.message(),
103        )));
104    };
105    Ok(Unit {
106        feature: f.feature.name.clone(),
107        root: PathBuf::from(&snapshot.root),
108        dir: f.feature.dir.clone(),
109        format: crate::format::of(snapshot),
110    })
111}
112
113/// Each requirement with its state against the record.
114fn items(requirements: &[(String, String)], record: &Record) -> Vec<ReviewItem> {
115    requirements
116        .iter()
117        .map(|(id, line)| {
118            let d = record.requirements.get(id);
119            let current = d.is_some_and(|d| d.sha256 == digest(line));
120            let state = match d {
121                None => "open",
122                Some(_) if !current => "lapsed",
123                Some(d) if d.verdict == "approved" => "approved",
124                Some(_) => "changes_requested",
125            };
126            ReviewItem {
127                id: id.clone(),
128                text: wording(line),
129                state: state.to_owned(),
130                by: d.map(|d| d.by.clone()),
131                at: d.map(|d| d.at),
132                comment: d.and_then(|d| d.comment.clone()),
133                decided_on: d.filter(|_| !current).map(|d| wording(&d.line)),
134            }
135        })
136        .collect()
137}
138
139fn totals(items: &[ReviewItem], questions: usize) -> ReviewTotals {
140    let n = |s: &str| items.iter().filter(|i| i.state == s).count();
141    ReviewTotals {
142        approved: n("approved"),
143        changes_requested: n("changes_requested"),
144        open: n("open"),
145        lapsed: n("lapsed"),
146        questions,
147    }
148}
149
150/// The marker a skill leaves where it could not decide.
151const MARKER: &str = "[NEEDS CLARIFICATION";
152
153/// The unit's Markdown documents, the requirements file first, then by
154/// name: (project-relative path, text).
155fn documents(u: &Unit) -> Vec<(String, String)> {
156    let dir = u.root.join(&u.dir);
157    let mut names: Vec<String> = std::fs::read_dir(&dir)
158        .into_iter()
159        .flatten()
160        .flatten()
161        .filter(|e| e.path().is_file())
162        .map(|e| e.file_name().to_string_lossy().into_owned())
163        .filter(|n| n.ends_with(".md"))
164        .collect();
165    let first = u.format.requirements_file().unwrap_or_default();
166    names.sort_by_key(|n| (n != first, n.clone()));
167    names
168        .into_iter()
169        .filter_map(|n| {
170            let text = std::fs::read_to_string(dir.join(&n)).ok()?;
171            Some((format!("{}/{n}", u.dir.trim_end_matches('/')), text))
172        })
173        .collect()
174}
175
176/// Every `[NEEDS CLARIFICATION: …]` in `text`: (the marker as written, the
177/// question), in order.
178fn markers(line: &str) -> Vec<(String, String)> {
179    let mut out = Vec::new();
180    let mut rest = line;
181    while let Some(at) = rest.find(MARKER) {
182        let tail = &rest[at..];
183        let Some(end) = tail.find(']') else { break };
184        let raw = &tail[..=end];
185        let question = raw[MARKER.len()..raw.len() - 1]
186            .trim_start_matches(':')
187            .trim()
188            .to_owned();
189        out.push((raw.to_owned(), question));
190        rest = &tail[end + 1..];
191    }
192    out
193}
194
195/// The questions the unit's documents leave open, with their markers.
196fn questions(u: &Unit) -> Vec<(OpenQuestion, String)> {
197    let mut out = Vec::new();
198    for (file, text) in documents(u) {
199        let mut section: Option<String> = None;
200        for (i, line) in text.lines().enumerate() {
201            if let Some(h) = line
202                .strip_prefix("## ")
203                .or_else(|| line.strip_prefix("### "))
204            {
205                section = Some(h.trim().to_owned());
206            }
207            let near = u
208                .format
209                .requirements_in(line)
210                .first()
211                .map(|(id, _)| id.clone())
212                .or_else(|| section.clone());
213            for (raw, text) in markers(line) {
214                out.push((
215                    OpenQuestion {
216                        id: format!("Q{}", out.len() + 1),
217                        text,
218                        file: file.clone(),
219                        line: i + 1,
220                        near: near.clone(),
221                    },
222                    raw,
223                ));
224            }
225        }
226    }
227    out
228}
229
230/// The requirements as `rev` (`HEAD`, a commit) has the requirements file.
231fn requirements_at(u: &Unit, rev: &str) -> Option<Vec<(String, String)>> {
232    let file = u.format.requirements_file()?;
233    let rel = format!("{}/{file}", u.dir.trim_end_matches('/'));
234    let text = crate::git::Git::at(&u.root)
235        .run(&["show", &format!("{rev}:./{rel}")])
236        .ok()?;
237    Some(u.format.requirements_in(&String::from_utf8_lossy(&text)))
238}
239
240/// What changed from `before` to `after`, requirement by requirement.
241fn compare(before: &[(String, String)], after: &[(String, String)]) -> Vec<RequirementDrift> {
242    let mut ids: Vec<&String> = after.iter().map(|(id, _)| id).collect();
243    for (id, _) in before {
244        if !ids.contains(&id) {
245            ids.push(id);
246        }
247    }
248    let line = |v: &[(String, String)], id: &str| {
249        v.iter()
250            .find(|(i, _)| i == id)
251            .map(|(_, l)| l.trim_end().to_owned())
252    };
253    ids.into_iter()
254        .filter_map(|id| {
255            let (then, now) = (line(before, id), line(after, id));
256            (then != now).then(|| RequirementDrift {
257                id: id.clone(),
258                then,
259                now,
260            })
261        })
262        .collect()
263}
264
265/// The spec's last commits and what each changed in its requirements.
266fn history(u: &Unit) -> Vec<SpecVersion> {
267    let Some(file) = u.format.requirements_file() else {
268        return Vec::new();
269    };
270    let rel = format!("{}/{file}", u.dir.trim_end_matches('/'));
271    let git = crate::git::Git::at(&u.root);
272    let log = git
273        .run(&[
274            "log",
275            "-n",
276            "12",
277            "--format=%H%x1f%as%x1f%an%x1f%s",
278            "--",
279            &rel,
280        ])
281        .map(|b| String::from_utf8_lossy(&b).into_owned())
282        .unwrap_or_default();
283    log.lines()
284        .filter_map(|l| {
285            let mut f = l.split('\u{1f}');
286            let (commit, date, author, subject) = (f.next()?, f.next()?, f.next()?, f.next()?);
287            let now = requirements_at(u, commit).unwrap_or_default();
288            let before = requirements_at(u, &format!("{commit}^")).unwrap_or_default();
289            let changes = compare(&before, &now);
290            let pick = |want: fn(&RequirementDrift) -> bool| {
291                changes
292                    .iter()
293                    .filter(|c| want(c))
294                    .map(|c| c.id.clone())
295                    .collect::<Vec<_>>()
296            };
297            Some(SpecVersion {
298                commit: commit.to_owned(),
299                date: date.to_owned(),
300                author: author.to_owned(),
301                subject: subject.to_owned(),
302                added: pick(|c| c.then.is_none()),
303                reworded: pick(|c| c.then.is_some() && c.now.is_some()),
304                removed: pick(|c| c.now.is_none()),
305            })
306        })
307        .collect()
308}
309
310/// The review state of the feature in `dir` (project-relative) under
311/// `root`; `None` when the format has no requirement identifiers or the
312/// spec names none.
313#[must_use]
314pub fn totals_of(
315    root: &Path,
316    format: &'static dyn crate::format::Adapter,
317    feature: &str,
318    dir: &str,
319) -> Option<ReviewTotals> {
320    let u = Unit {
321        feature: feature.to_owned(),
322        root: root.to_path_buf(),
323        dir: dir.to_owned(),
324        format,
325    };
326    let requirements = u.requirements();
327    (!requirements.is_empty())
328        .then(|| totals(&items(&requirements, &u.record()), questions(&u).len()))
329}
330
331/// The review; `full` adds what changed since the last commit and the
332/// history (git reads, for the review view — not for the inbox).
333fn report_of(u: &Unit, full: bool) -> ReviewReport {
334    let items = items(&u.requirements(), &u.record());
335    let skills = crate::skills::discover_with(&u.root, u.format.planning());
336    let installed = |name: &str| {
337        let full = format!("{}{name}", u.format.planning().prefix);
338        skills.iter().any(|s| s.name == full && s.refused.is_none())
339    };
340    let open: Vec<OpenQuestion> = questions(u).into_iter().map(|(q, _)| q).collect();
341    let totals = totals(&items, open.len());
342    let revision = (totals.changes_requested > 0).then(|| revision_request(&items));
343    let changes = if full {
344        requirements_at(u, "HEAD")
345            .map(|then| compare(&then, &u.requirements()))
346            .unwrap_or_default()
347    } else {
348        Vec::new()
349    };
350    ReviewReport {
351        feature: u.feature.clone(),
352        file: u.rel(),
353        totals,
354        revision,
355        questions: open,
356        changes,
357        history: if full { history(u) } else { Vec::new() },
358        requirements: items,
359        revise_with: u
360            .format
361            .planning()
362            .revises
363            .filter(|s| installed(s))
364            .map(str::to_owned),
365    }
366}
367
368fn message(r: &ReviewReport) -> String {
369    let t = r.totals;
370    let n = r.requirements.len();
371    if n == 0 {
372        return format!("{}'s spec names no requirement IDs to review", r.feature);
373    }
374    if t.complete() {
375        return format!("{n}/{n} requirements approved");
376    }
377    let plural =
378        |n: usize, one: &str, many: &str| format!("{n} {}", if n == 1 { one } else { many });
379    let parts: Vec<String> = [
380        (
381            t.questions,
382            plural(t.questions, "open question", "open questions"),
383        ),
384        (t.open, format!("{} to review", t.open)),
385        (
386            t.changes_requested,
387            format!("{} with changes requested", t.changes_requested),
388        ),
389        (
390            t.lapsed,
391            format!("{} changed since they were decided", t.lapsed),
392        ),
393    ]
394    .into_iter()
395    .filter(|(k, _)| *k > 0)
396    .map(|(_, w)| w)
397    .collect();
398    format!(
399        "{}/{n} requirements approved; {}",
400        t.approved,
401        parts.join(", ")
402    )
403}
404
405/// The review without its git reads (changes, history): what the inbox
406/// needs, per feature, on every refresh.
407#[must_use]
408pub fn summary(path: &Path, feature: Option<&str>) -> Option<ReviewReport> {
409    unit("review", path, feature)
410        .ok()
411        .map(|u| report_of(&u, false))
412}
413
414/// `speccoder review`: every requirement and its review.
415#[must_use]
416pub fn report(path: &Path, feature: Option<&str>) -> CommandResult {
417    let u = match unit("review", path, feature) {
418        Ok(u) => u,
419        Err(r) => return *r,
420    };
421    let r = report_of(&u, true);
422    CommandResult::new("review", CommandStatus::Ok, &message(&r))
423        .with_data(CommandData::Review(Box::new(r)))
424}
425
426/// `speccoder review approve|request|clear [ID…]`: records the person's
427/// decision on the requirements named (none: every one not approved on its
428/// current wording) and writes `review.json`. `request` needs a comment.
429#[must_use]
430pub fn decide(
431    path: &Path,
432    feature: Option<&str>,
433    ids: &[String],
434    verdict: &str,
435    comment: Option<&str>,
436    via: &str,
437) -> CommandResult {
438    let u = match unit("review", path, feature) {
439        Ok(u) => u,
440        Err(r) => return *r,
441    };
442    let comment = comment.map(str::trim).filter(|c| !c.is_empty());
443    if verdict == "request" && comment.is_none() {
444        return CommandResult::new(
445            "review",
446            CommandStatus::UsageError,
447            "a change request needs a comment: what should the requirement say?",
448        )
449        .with_code("USAGE");
450    }
451    let requirements = u.requirements();
452    let mut record = u.record();
453    let before = items(&requirements, &record);
454    // A wording that still asks a question is not one to sign: approving
455    // it is refused; approving "the rest" passes it by and says so.
456    let asks = |line: &str| line.contains(MARKER);
457    let undecided: Vec<&str> = requirements
458        .iter()
459        .filter(|(_, l)| asks(l))
460        .map(|(id, _)| id.as_str())
461        .collect();
462    if verdict == "approve" && !ids.is_empty() {
463        let blocked: Vec<&str> = ids
464            .iter()
465            .map(String::as_str)
466            .filter(|id| undecided.contains(id))
467            .collect();
468        if !blocked.is_empty() {
469            return CommandResult::new(
470                "review",
471                CommandStatus::UsageError,
472                &format!(
473                    "{} still {} an open question: answer it first (`speccoder review answer …`), or reword it (`speccoder review edit {} \"…\" --approve`)",
474                    blocked.join(", "),
475                    if blocked.len() == 1 { "asks" } else { "ask" },
476                    blocked[0]
477                ),
478            )
479            .with_code("USAGE");
480        }
481    }
482    let chosen: Vec<&(String, String)> = if ids.is_empty() {
483        requirements
484            .iter()
485            .filter(|(id, l)| {
486                before.iter().any(|i| i.id == *id && i.state != "approved")
487                    && !(verdict == "approve" && asks(l))
488            })
489            .collect()
490    } else {
491        let unknown: Vec<&String> = ids
492            .iter()
493            .filter(|id| !requirements.iter().any(|(r, _)| r == *id))
494            .collect();
495        if !unknown.is_empty() {
496            return CommandResult::new(
497                "review",
498                CommandStatus::NotFound,
499                &format!(
500                    "the spec has no {}; it names {}",
501                    unknown
502                        .iter()
503                        .map(|s| s.as_str())
504                        .collect::<Vec<_>>()
505                        .join(", "),
506                    requirements
507                        .iter()
508                        .map(|(r, _)| r.as_str())
509                        .collect::<Vec<_>>()
510                        .join(", ")
511                ),
512            )
513            .with_code("NOT-FOUND")
514            .with_fix(vec!["speccoder review".to_owned()]);
515        }
516        requirements
517            .iter()
518            .filter(|(id, _)| ids.contains(id))
519            .collect()
520    };
521    let who = crate::approvals::Who::local_in(&u.root, via);
522    let now = std::time::SystemTime::now()
523        .duration_since(std::time::UNIX_EPOCH)
524        .map_or(0, |d| d.as_secs());
525    for (id, line) in &chosen {
526        match verdict {
527            "clear" => {
528                record.requirements.remove(id);
529            }
530            "approve" | "request" => {
531                record.requirements.insert(
532                    id.clone(),
533                    Decision {
534                        verdict: if verdict == "approve" {
535                            "approved"
536                        } else {
537                            "changes_requested"
538                        }
539                        .to_owned(),
540                        line: line.trim_end().to_owned(),
541                        sha256: digest(line),
542                        by: who.name.clone(),
543                        via: who.via.clone(),
544                        at: now,
545                        comment: comment.map(str::to_owned),
546                    },
547                );
548            }
549            other => {
550                return CommandResult::new(
551                    "review",
552                    CommandStatus::UsageError,
553                    &format!("`{other}` is not a review verdict: approve, request or clear"),
554                )
555                .with_code("USAGE");
556            }
557        }
558    }
559    // Decisions on requirements the spec no longer names go with them.
560    record
561        .requirements
562        .retain(|id, _| requirements.iter().any(|(r, _)| r == id));
563    let text = serde_json::to_string_pretty(&record).unwrap_or_default() + "\n";
564    if let Err(e) = std::fs::write(u.file(), text) {
565        return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", u.rel()));
566    }
567    let r = report_of(&u, true);
568    let word = match verdict {
569        "approve" => "approved",
570        "request" => "changes requested on",
571        _ => "cleared",
572    };
573    let skipped = if verdict == "approve" && ids.is_empty() && !undecided.is_empty() {
574        format!(
575            "; not {} — {} still {} an open question",
576            undecided.join(", "),
577            if undecided.len() == 1 { "it" } else { "they" },
578            if undecided.len() == 1 { "asks" } else { "ask" }
579        )
580    } else {
581        String::new()
582    };
583    let message = if chosen.is_empty() {
584        format!("nothing to decide{skipped}: {}", message(&r))
585    } else {
586        format!(
587            "{word} {}{skipped}; {} — commit {} to share it",
588            chosen
589                .iter()
590                .map(|(id, _)| id.as_str())
591                .collect::<Vec<_>>()
592                .join(", "),
593            message(&r),
594            r.file
595        )
596    };
597    CommandResult::new("review", CommandStatus::Ok, &message)
598        .with_data(CommandData::Review(Box::new(r)))
599}
600
601/// `speccoder review answer Q1 "…"`: writes the person's answer where the
602/// question was — the marker replaced by the answer, nothing else touched.
603#[must_use]
604pub fn answer(path: &Path, feature: Option<&str>, id: &str, text: &str) -> CommandResult {
605    let u = match unit("review", path, feature) {
606        Ok(u) => u,
607        Err(r) => return *r,
608    };
609    let text = text.trim();
610    if text.is_empty() {
611        return CommandResult::new("review", CommandStatus::UsageError, "an answer needs words")
612            .with_code("USAGE");
613    }
614    let open = questions(&u);
615    let Some((q, raw)) = open.iter().find(|(q, _)| q.id.eq_ignore_ascii_case(id)) else {
616        return CommandResult::new(
617            "review",
618            CommandStatus::NotFound,
619            &format!(
620                "no open question {id}; open: {}",
621                if open.is_empty() {
622                    "none".to_owned()
623                } else {
624                    open.iter()
625                        .map(|(q, _)| q.id.as_str())
626                        .collect::<Vec<_>>()
627                        .join(", ")
628                }
629            ),
630        )
631        .with_code("NOT-FOUND");
632    };
633    let file = u.root.join(&q.file);
634    let Ok(body) = std::fs::read_to_string(&file) else {
635        return CommandResult::new(
636            "review",
637            CommandStatus::Error,
638            &format!("{} cannot be read", q.file),
639        );
640    };
641    let mut lines: Vec<String> = body.lines().map(str::to_owned).collect();
642    let Some(line) = lines
643        .get_mut(q.line - 1)
644        .filter(|l| l.contains(raw.as_str()))
645    else {
646        return CommandResult::new(
647            "review",
648            CommandStatus::Error,
649            &format!("{} changed under the question; look again", q.file),
650        );
651    };
652    *line = line.replacen(raw.as_str(), text, 1);
653    let mut out = lines.join("\n");
654    if body.ends_with('\n') {
655        out.push('\n');
656    }
657    if let Err(e) = std::fs::write(&file, out) {
658        return CommandResult::new("review", CommandStatus::Error, &format!("{}: {e}", q.file));
659    }
660    let r = report_of(&u, true);
661    CommandResult::ok(
662        "review",
663        &format!("answered {} in {} — {}", q.id, q.file, message(&r)),
664    )
665    .with_data(CommandData::Review(Box::new(r)))
666}
667
668/// `speccoder review edit FR-003 "…" [--approve]`: the person rewords a
669/// requirement — its line keeps its identifier, its decision lapses (or is
670/// approved on the new words with `approve`).
671#[must_use]
672pub fn edit(
673    path: &Path,
674    feature: Option<&str>,
675    id: &str,
676    wording: &str,
677    approve: bool,
678    via: &str,
679) -> CommandResult {
680    let u = match unit("review", path, feature) {
681        Ok(u) => u,
682        Err(r) => return *r,
683    };
684    let wording = wording.trim();
685    if wording.is_empty() || wording.contains('\n') {
686        return CommandResult::new(
687            "review",
688            CommandStatus::UsageError,
689            "a requirement's wording is one line of words",
690        )
691        .with_code("USAGE");
692    }
693    let Some(spec) = u.format.requirements_file() else {
694        return CommandResult::new(
695            "review",
696            CommandStatus::UsageError,
697            "this format has no requirement identifiers",
698        )
699        .with_code("USAGE");
700    };
701    let Some((_, old)) = u.requirements().into_iter().find(|(r, _)| r == id) else {
702        return CommandResult::new(
703            "review",
704            CommandStatus::NotFound,
705            &format!("the spec has no {id}"),
706        )
707        .with_code("NOT-FOUND");
708    };
709    // `- **FR-003**: …` keeps everything up to the identifier and its colon.
710    let key = format!("**{id}**");
711    let Some(at) = old.find(&key) else {
712        return CommandResult::new(
713            "review",
714            CommandStatus::Error,
715            &format!("{id}'s line has no `{key}`"),
716        );
717    };
718    let new = format!("{}{key}: {wording}", &old[..at]);
719    let file = u.root.join(&u.dir).join(spec);
720    let body = std::fs::read_to_string(&file).unwrap_or_default();
721    if !body.lines().any(|l| l.trim_end() == old.trim_end()) {
722        return CommandResult::new(
723            "review",
724            CommandStatus::Error,
725            &format!("{spec} changed under {id}; look again"),
726        );
727    }
728    let mut done = false;
729    let mut out: Vec<String> = Vec::new();
730    for l in body.lines() {
731        if !done && l.trim_end() == old.trim_end() {
732            out.push(new.clone());
733            done = true;
734        } else {
735            out.push(l.to_owned());
736        }
737    }
738    let mut text = out.join("\n");
739    if body.ends_with('\n') {
740        text.push('\n');
741    }
742    if let Err(e) = std::fs::write(&file, text) {
743        return CommandResult::new("review", CommandStatus::Error, &format!("{spec}: {e}"));
744    }
745    if approve {
746        let r = decide(path, feature, &[id.to_owned()], "approve", None, via);
747        let status = r.status();
748        let message = if status == CommandStatus::Ok {
749            format!("reworded {id} and approved it; {}", r.message())
750        } else {
751            format!("reworded {id}; not approved: {}", r.message())
752        };
753        return r.reworded(status, &message);
754    }
755    let r = report_of(&u, true);
756    CommandResult::ok("review", &format!("reworded {id}; {}", message(&r)))
757        .with_data(CommandData::Review(Box::new(r)))
758}
759
760/// The arguments for the revising skill: each change request with its
761/// requirement, so the spec is revised to say what the person asked.
762#[must_use]
763fn revision_request(items: &[ReviewItem]) -> String {
764    let lines: Vec<String> = items
765        .iter()
766        .filter(|i| i.state == "changes_requested")
767        .map(|i| {
768            format!(
769                "- {} (\"{}\"): {}",
770                i.id,
771                i.text,
772                i.comment.as_deref().unwrap_or_default()
773            )
774        })
775        .collect();
776    format!(
777        "Revise the spec per the reviewer's change requests; keep every requirement ID, reword only what is asked:\n{}",
778        lines.join("\n")
779    )
780}
781
782/// The review records under `root`'s units, as they are before a hosted
783/// skill runs: it must not change them, nor leave one in a unit it creates.
784#[derive(Debug)]
785pub struct Snapshot {
786    root: PathBuf,
787    files: Vec<(PathBuf, Option<Vec<u8>>)>,
788}
789
790/// Every unit's review record under `root` (absent ones as `None`).
791fn records(root: &Path) -> Vec<(PathBuf, Option<Vec<u8>>)> {
792    let format = crate::format::at(root);
793    let Some(snap) = format.inspect(root, None) else {
794        return Vec::new();
795    };
796    snap.features
797        .iter()
798        .map(|f| {
799            let file = Path::new(&snap.root).join(&f.dir).join(REVIEW_FILE);
800            let bytes = std::fs::read(&file).ok();
801            (file, bytes)
802        })
803        .collect()
804}
805
806/// The records as they are now.
807#[must_use]
808pub fn snapshot(root: &Path) -> Snapshot {
809    Snapshot {
810        root: root.to_path_buf(),
811        files: records(root),
812    }
813}
814
815/// Puts back every record that changed since `before`, and removes one in
816/// a unit that appeared since; the paths restored.
817pub fn restore(before: &Snapshot) -> Vec<String> {
818    let mut restored = Vec::new();
819    let now = records(&before.root);
820    for (file, bytes) in &now {
821        let was = before
822            .files
823            .iter()
824            .find(|(f, _)| f == file)
825            .and_then(|(_, b)| b.clone());
826        if *bytes == was {
827            continue;
828        }
829        let ok = match &was {
830            Some(b) => std::fs::write(file, b).is_ok(),
831            None => std::fs::remove_file(file).is_ok(),
832        };
833        if ok {
834            restored.push(file.display().to_string());
835        }
836    }
837    restored
838}
839
840#[cfg(test)]
841mod tests {
842    use super::*;
843
844    #[test]
845    fn the_wording_is_the_line_without_its_markup() {
846        assert_eq!(
847            wording("- **FR-001**: Users MUST log in."),
848            "Users MUST log in."
849        );
850        assert_eq!(digest("a  "), digest("a"));
851    }
852}