Skip to main content

speccoder_runtime/
comments.rs

1//! Anchored review comments: the person comments on a
2//! passage of a feature document, a line of the spec's changes, a line of a
3//! shown visible test or a finding; the drafts collect in a tray in the
4//! state directory (`<state>/comments/<unit>.json`, never browser storage,
5//! never the worktree) and go out together as one revision turn of the
6//! format's revising skill, scoped to the sections they touch. Before the
7//! turn the feature's documents are snapshot; after it the change is shown
8//! per section, sections no comment touched flagged; *Undo Revision*
9//! restores the snapshot byte for byte, on the person's command only
10//! (constitution 2.3.0, principle I). Code-view line comments become one
11//! nudge for a task's next attempt.
12//!
13//! Nothing here reads a held-out store: an anchor outside the feature's
14//! documents, its changes, its shown tests and its findings is refused.
15
16use std::collections::BTreeSet;
17use std::path::{Path, PathBuf};
18use std::sync::Arc;
19
20use agentplane::model::ModelProvider;
21use serde::{Deserialize, Serialize};
22use speccoder_core::actions::{CommentOp, Via};
23use speccoder_core::oracle::{
24    CodeComment, CommentAnchor, CommentDraft, CommentsView, DraftState, RevisionView,
25};
26use speccoder_core::{CommandData, CommandResult, CommandStatus};
27
28use crate::integrity::sha256_hex;
29use crate::worktree;
30
31/// The store's directory under a project's state directory.
32pub const DIR: &str = "comments";
33
34/// How far a quote may move from its anchored line and still be found.
35const DRIFT: usize = 20;
36
37/// A file's digest before or after a turn.
38#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct FileDigest {
40    /// Project-relative path.
41    pub path: String,
42    /// `sha256:<hex>`, or `absent`.
43    pub sha256: String,
44}
45
46/// One revision turn as stored.
47#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Turn {
49    /// `t1`, `t2`, …
50    pub id: String,
51    /// The drafts it was sent with.
52    pub comments: Vec<String>,
53    /// The sections they touch.
54    pub sections: Vec<String>,
55    /// The skill.
56    pub skill: String,
57    /// The documents before the turn.
58    pub before: Vec<FileDigest>,
59    /// After it.
60    pub after: Vec<FileDigest>,
61    /// Sections it changed.
62    pub changed: Vec<String>,
63    /// Of those, the ones no comment touched.
64    pub outside: Vec<String>,
65    /// `pending`, `kept` or `undone`.
66    pub outcome: String,
67    /// Requirements asked again from test comments.
68    #[serde(default)]
69    pub asked_again: Vec<String>,
70    /// Documents changed while the turn ran, not (only) by the skill.
71    #[serde(default)]
72    pub beside: Vec<String>,
73}
74
75/// The tray of one unit.
76#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Tray {
78    /// The next draft's number.
79    #[serde(default)]
80    pub next: u32,
81    /// Every draft (sent ones too, until their turn is kept).
82    #[serde(default)]
83    pub drafts: Vec<CommentDraft>,
84    /// The turns, oldest first.
85    #[serde(default)]
86    pub turns: Vec<Turn>,
87}
88
89fn file(project_state: &Path, unit: &str) -> PathBuf {
90    project_state.join(DIR).join(format!("{unit}.json"))
91}
92
93fn snapshot_dir(project_state: &Path, unit: &str, turn: &str) -> PathBuf {
94    project_state.join(DIR).join(unit).join(turn)
95}
96
97/// The tray of `unit` (empty when none was written).
98///
99/// # Errors
100/// The tray exists but cannot be read: never read as empty — the next
101/// write would drop every draft and the pending turn's record.
102pub fn load(project_state: &Path, unit: &str) -> Result<Tray, String> {
103    let f = file(project_state, unit);
104    match std::fs::read_to_string(&f) {
105        Ok(t) => serde_json::from_str(&t)
106            .map_err(|e| format!("the comment tray {} cannot be read: {e}", f.display())),
107        Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(Tray::default()),
108        Err(e) => Err(format!("{}: {e}", f.display())),
109    }
110}
111
112/// The refusal for a tray that cannot be read.
113fn unreadable(e: &str) -> CommandResult {
114    refuse(CommandStatus::NeedsHuman, "REVIEW-UNREADABLE", e)
115}
116
117fn save(project_state: &Path, unit: &str, tray: &Tray) -> Result<(), String> {
118    let f = file(project_state, unit);
119    if let Some(parent) = f.parent() {
120        std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
121    }
122    let tmp = f.with_extension("json.tmp");
123    let text = serde_json::to_string_pretty(tray).map_err(|e| e.to_string())?;
124    std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
125    std::fs::rename(&tmp, &f).map_err(|e| format!("{}: {e}", f.display()))
126}
127
128/// One writer at a time: a send that finds the lock waits for nobody, it
129/// is refused (a second browser's send of the same drafts).
130struct Lock(PathBuf);
131
132impl Lock {
133    fn take(project_state: &Path, unit: &str) -> Result<Self, String> {
134        let path = project_state.join(DIR).join(format!("{unit}.lock"));
135        if let Some(parent) = path.parent() {
136            std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
137        }
138        // A lock older than an hour is a crashed writer's.
139        if let Ok(meta) = std::fs::metadata(&path)
140            && meta
141                .modified()
142                .ok()
143                .and_then(|m| m.elapsed().ok())
144                .is_some_and(|age| age.as_secs() > 3600)
145        {
146            let _ = std::fs::remove_file(&path);
147        }
148        std::fs::OpenOptions::new()
149            .write(true)
150            .create_new(true)
151            .open(&path)
152            .map(|_| Self(path))
153            .map_err(|_| "the comments are being sent from elsewhere right now".to_owned())
154    }
155
156    /// [`Lock::take`], waiting a few seconds for a short writer (an add)
157    /// to finish: a turn's record is never written without the lock.
158    fn wait(project_state: &Path, unit: &str) -> Result<Self, String> {
159        let mut last = String::new();
160        for _ in 0..100 {
161            match Self::take(project_state, unit) {
162                Ok(l) => return Ok(l),
163                Err(e) => last = e,
164            }
165            std::thread::sleep(std::time::Duration::from_millis(50));
166        }
167        Err(last)
168    }
169}
170
171impl Drop for Lock {
172    fn drop(&mut self) {
173        let _ = std::fs::remove_file(&self.0);
174    }
175}
176
177fn now() -> u64 {
178    std::time::SystemTime::now()
179        .duration_since(std::time::UNIX_EPOCH)
180        .map_or(0, |d| d.as_secs())
181}
182
183fn digest(bytes: &[u8]) -> String {
184    format!("sha256:{}", sha256_hex(bytes))
185}
186
187/// What a comment action works on.
188struct Unit {
189    root: PathBuf,
190    feature: String,
191    dir: String,
192    format: &'static dyn crate::format::Adapter,
193    snapshot: speccoder_core::snapshot::ProjectSnapshot,
194    plane: PathBuf,
195}
196
197fn refuse(status: CommandStatus, code: &str, message: &str) -> CommandResult {
198    CommandResult::new("review", status, message).with_code(code)
199}
200
201fn unit(
202    path: &Path,
203    feature: Option<&str>,
204    state: Option<&Path>,
205) -> Result<Unit, Box<CommandResult>> {
206    let inspected = crate::inspect(path, feature);
207    let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
208        return Err(Box::new(CommandResult::carried("review", &inspected)));
209    };
210    let Some(f) = snapshot.feature.as_ref() else {
211        return Err(Box::new(CommandResult::new(
212            "review",
213            CommandStatus::SelectionNeeded,
214            inspected.message(),
215        )));
216    };
217    let root = PathBuf::from(&snapshot.root);
218    let Some(state) = state.map(Path::to_path_buf).or_else(worktree::state_dir) else {
219        return Err(Box::new(refuse(
220            CommandStatus::ConfigError,
221            "NO-STATE-DIR",
222            "no state directory (set SPECCODER_STATE_DIR or HOME)",
223        )));
224    };
225    Ok(Unit {
226        plane: worktree::project_state_dir(&state, &root),
227        feature: f.feature.name.clone(),
228        dir: f.feature.dir.clone(),
229        format: crate::format::of(snapshot),
230        snapshot: (**snapshot).clone(),
231        root,
232    })
233}
234
235/// The unit's documents: every Markdown file under its directory,
236/// project-relative, by path.
237fn documents(u: &Unit) -> Vec<String> {
238    let base = u.root.join(&u.dir);
239    let mut out = Vec::new();
240    let mut stack = vec![base];
241    while let Some(d) = stack.pop() {
242        for e in std::fs::read_dir(&d).into_iter().flatten().flatten() {
243            let p = e.path();
244            if e.file_type().is_ok_and(|t| t.is_symlink()) {
245                continue;
246            }
247            if p.is_dir() {
248                stack.push(p);
249            } else if p.extension().is_some_and(|x| x == "md")
250                && let Ok(rel) = p.strip_prefix(&u.root)
251            {
252                out.push(rel.to_string_lossy().replace('\\', "/"));
253            }
254        }
255    }
256    out.sort();
257    out
258}
259
260/// The heading path of `line` (1-based) in Markdown `text`:
261/// `Requirements › Functional Requirements`; empty before any heading.
262#[must_use]
263pub fn section_at(text: &str, line: usize) -> String {
264    let mut path: Vec<(usize, String)> = Vec::new();
265    for (i, l) in text.lines().enumerate() {
266        if i + 1 > line {
267            break;
268        }
269        let hashes = l.bytes().take_while(|b| *b == b'#').count();
270        if hashes == 0 || !l[hashes..].starts_with(' ') {
271            continue;
272        }
273        path.retain(|(level, _)| *level < hashes);
274        path.push((hashes, l[hashes..].trim().to_owned()));
275    }
276    path.into_iter()
277        .map(|(_, h)| h)
278        .collect::<Vec<_>>()
279        .join(" › ")
280}
281
282/// Each section of Markdown `text` by its heading path, with its body.
283#[must_use]
284pub fn sections(text: &str) -> Vec<(String, String)> {
285    let mut out: Vec<(String, String)> = Vec::new();
286    let mut path: Vec<(usize, String)> = Vec::new();
287    let mut current = String::new();
288    let mut body = String::new();
289    for l in text.lines() {
290        let hashes = l.bytes().take_while(|b| *b == b'#').count();
291        if hashes > 0 && l[hashes..].starts_with(' ') {
292            out.push((current.clone(), std::mem::take(&mut body)));
293            path.retain(|(level, _)| *level < hashes);
294            path.push((hashes, l[hashes..].trim().to_owned()));
295            current = path
296                .iter()
297                .map(|(_, h)| h.as_str())
298                .collect::<Vec<_>>()
299                .join(" › ");
300            continue;
301        }
302        body.push_str(l);
303        body.push('\n');
304    }
305    out.push((current, body));
306    out.retain(|(h, b)| !(h.is_empty() && b.trim().is_empty()));
307    out
308}
309
310fn normal(s: &str) -> String {
311    s.split_whitespace().collect::<Vec<_>>().join(" ")
312}
313
314/// Whether `quote` is in `text` within [`DRIFT`] lines of `start..=end`.
315fn found(text: &str, start: usize, end: usize, quote: &str) -> bool {
316    let lines: Vec<&str> = text.lines().collect();
317    let from = start.saturating_sub(DRIFT + 1);
318    let to = (end + DRIFT).min(lines.len());
319    if from >= to {
320        return false;
321    }
322    normal(&lines[from..to].join("\n")).contains(&normal(quote))
323}
324
325fn read(u: &Unit, rel: &str) -> Option<String> {
326    std::fs::read_to_string(u.root.join(rel)).ok()
327}
328
329fn at_head(u: &Unit, rel: &str) -> Option<String> {
330    let git = crate::git::Git::at(&u.root);
331    let top_prefix = git
332        .line(&["rev-parse", "--show-prefix"])
333        .unwrap_or_default();
334    git.run(&["show", &format!("HEAD:{top_prefix}{rel}")])
335        .ok()
336        .map(|b| String::from_utf8_lossy(&b).into_owned())
337}
338
339/// A shown visible test of the unit: its content (from the oracle draft).
340fn shown_test(u: &Unit, req: &str, path: &str) -> Option<String> {
341    let (_, contents) = crate::oracle::load(&u.plane, &u.feature, req)
342        .ok()
343        .flatten()?;
344    contents
345        .into_iter()
346        .find(|(p, _)| p == path)
347        .map(|(_, c)| c)
348}
349
350/// Checks a new anchor against what the Review shows, and fills its
351/// digest and section. `Err`: why it may not be anchored there.
352fn check(u: &Unit, anchor: CommentAnchor) -> Result<CommentAnchor, String> {
353    let docs = documents(u);
354    let doc = |p: &str| -> Result<(), String> {
355        if docs.iter().any(|d| d == p) {
356            Ok(())
357        } else {
358            Err(format!(
359                "{p} is not one of {}'s documents: a comment is anchored to the feature's documents, its changes, its shown tests or its findings",
360                u.feature
361            ))
362        }
363    };
364    match anchor {
365        CommentAnchor::Passage {
366            path,
367            start_line,
368            end_line,
369            quote,
370            ..
371        } => {
372            doc(&path)?;
373            if start_line == 0 || end_line < start_line || quote.trim().is_empty() {
374                return Err(
375                    "a passage needs its lines (from ≤ to, from 1) and its words".to_owned(),
376                );
377            }
378            let text = read(u, &path).unwrap_or_default();
379            if !found(&text, start_line, end_line, &quote) {
380                return Err(format!(
381                    "\"{}\" is not at {path}:{start_line}-{end_line}",
382                    quote.chars().take(60).collect::<String>()
383                ));
384            }
385            Ok(CommentAnchor::Passage {
386                section: section_at(&text, start_line),
387                quote_sha256: digest(normal(&quote).as_bytes()),
388                path,
389                start_line,
390                end_line,
391                quote,
392            })
393        }
394        CommentAnchor::DiffLine {
395            path,
396            side,
397            line,
398            quote,
399        } => {
400            doc(&path)?;
401            let text = match side.as_str() {
402                "new" => read(u, &path),
403                "old" => at_head(u, &path),
404                other => return Err(format!("a diff line's side is old or new, not {other}")),
405            }
406            .unwrap_or_default();
407            if line == 0 || !found(&text, line, line, &quote) {
408                return Err(format!("\"{quote}\" is not at {path}:{line} ({side})"));
409            }
410            Ok(CommentAnchor::DiffLine {
411                path,
412                side,
413                line,
414                quote,
415            })
416        }
417        CommentAnchor::TestLine {
418            req,
419            path,
420            line,
421            quote,
422        } => {
423            let Some(content) = shown_test(u, &req, &path) else {
424                return Err(format!("{req} shows no test {path}"));
425            };
426            if line == 0 || !found(&content, line, line, &quote) {
427                return Err(format!("\"{quote}\" is not at {path}:{line}"));
428            }
429            Ok(CommentAnchor::TestLine {
430                req,
431                path,
432                line,
433                quote,
434            })
435        }
436        CommentAnchor::Finding { source, id, text } => {
437            if id.trim().is_empty() || text.trim().is_empty() {
438                return Err("a finding needs its id and its words".to_owned());
439            }
440            let shown = match source.as_str() {
441                // The review's own: a decision line or an open question.
442                "review" => crate::review::summary(&u.root, Some(&u.feature)).is_some_and(|r| {
443                    r.requirements.iter().any(|i| i.id == id)
444                        || r.questions.iter().any(|q| q.id == id)
445                }),
446                // An analysis the format wrote into the unit's documents.
447                "analyze" => docs
448                    .iter()
449                    .filter_map(|d| read(u, d))
450                    .any(|t| t.contains(&id)),
451                other => {
452                    return Err(format!(
453                        "findings of {other} are not shown in the Review (analyze and review findings are)"
454                    ));
455                }
456            };
457            if !shown {
458                return Err(format!("the Review shows no {source} finding {id}"));
459            }
460            Ok(CommentAnchor::Finding { source, id, text })
461        }
462    }
463}
464
465/// Whether a stored draft's quote is still where it was anchored.
466fn current(u: &Unit, anchor: &CommentAnchor) -> bool {
467    match anchor {
468        CommentAnchor::Passage {
469            path,
470            start_line,
471            end_line,
472            quote,
473            ..
474        } => read(u, path).is_some_and(|t| found(&t, *start_line, *end_line, quote)),
475        CommentAnchor::DiffLine {
476            path,
477            side,
478            line,
479            quote,
480        } => {
481            let text = if side == "old" {
482                at_head(u, path)
483            } else {
484                read(u, path)
485            };
486            text.is_some_and(|t| found(&t, *line, *line, quote))
487        }
488        CommentAnchor::TestLine {
489            req,
490            path,
491            line,
492            quote,
493        } => shown_test(u, req, path).is_some_and(|t| found(&t, *line, *line, quote)),
494        CommentAnchor::Finding { .. } => true,
495    }
496}
497
498/// Each section a pending turn changed, before (its snapshot) and after
499/// (the document now).
500fn section_diffs(u: &Unit, t: &Turn) -> Vec<speccoder_core::oracle::SectionDiff> {
501    const CUT: usize = 4000;
502    let cut = |s: &str| s.chars().take(CUT).collect::<String>();
503    if t.outcome != "pending" || t.after.is_empty() {
504        return Vec::new();
505    }
506    let snap = snapshot_dir(&u.plane, &u.feature, &t.id);
507    let mut out = Vec::new();
508    let paths: BTreeSet<&str> = t.after.iter().map(|f| f.path.as_str()).collect();
509    for path in paths {
510        if !document_path(u, path) {
511            continue;
512        }
513        let before = std::fs::read_to_string(snap.join(path)).unwrap_or_default();
514        let after = read(u, path).unwrap_or_default();
515        if before == after {
516            continue;
517        }
518        let (a, b) = (sections(&before), sections(&after));
519        for key in changed_sections(path, &before, &after) {
520            let heading = key
521                .strip_prefix(&format!("{path}: "))
522                .unwrap_or(&key)
523                .to_owned();
524            let heading = if heading == "(before the first heading)" {
525                String::new()
526            } else {
527                heading
528            };
529            let body = |v: &[(String, String)]| {
530                v.iter()
531                    .find(|(h, _)| *h == heading)
532                    .map(|(_, t)| cut(t.trim_end()))
533                    .unwrap_or_default()
534            };
535            out.push(speccoder_core::oracle::SectionDiff {
536                path: path.to_owned(),
537                outside: t.outside.contains(&key),
538                before: body(&a),
539                after: body(&b),
540                heading,
541            });
542        }
543    }
544    out
545}
546
547fn turn_view(u: &Unit, t: &Turn) -> RevisionView {
548    let files: Vec<String> = t
549        .before
550        .iter()
551        .chain(&t.after)
552        .filter(|f| {
553            let a = t
554                .before
555                .iter()
556                .find(|b| b.path == f.path)
557                .map(|b| &b.sha256);
558            let b = t.after.iter().find(|b| b.path == f.path).map(|b| &b.sha256);
559            !t.after.is_empty() && a != b
560        })
561        .map(|f| f.path.clone())
562        .collect::<BTreeSet<_>>()
563        .into_iter()
564        .collect();
565    RevisionView {
566        id: t.id.clone(),
567        comments: t.comments.clone(),
568        sections: t.sections.clone(),
569        skill: t.skill.clone(),
570        changed: t.changed.clone(),
571        outside: t.outside.clone(),
572        outcome: t.outcome.clone(),
573        files,
574        asked_again: t.asked_again.clone(),
575        beside: t.beside.clone(),
576        diffs: section_diffs(u, t),
577    }
578}
579
580fn view_of(u: &Unit, tray: &Tray) -> CommentsView {
581    CommentsView {
582        feature: u.feature.clone(),
583        drafts: tray
584            .drafts
585            .iter()
586            .filter(|d| d.state != DraftState::Sent)
587            .cloned()
588            .map(|mut d| {
589                d.state = if d.kept || current(u, &d.anchor) {
590                    DraftState::Draft
591                } else {
592                    DraftState::Outdated
593                };
594                d
595            })
596            .collect(),
597        turn: tray.turns.last().map(|t| turn_view(u, t)),
598        revise_with: u.format.planning().revises.map(str::to_owned),
599    }
600}
601
602fn result(u: &Unit, tray: &Tray, message: &str) -> CommandResult {
603    CommandResult::new("review", CommandStatus::Ok, message)
604        .with_data(CommandData::Comments(Box::new(view_of(u, tray))))
605}
606
607/// `speccoder review comments`: the tray and the last turn.
608#[must_use]
609pub fn list(path: &Path, feature: Option<&str>, state: Option<&Path>) -> CommandResult {
610    let u = match unit(path, feature, state) {
611        Ok(u) => u,
612        Err(r) => return *r,
613    };
614    let tray = match load(&u.plane, &u.feature) {
615        Ok(t) => t,
616        Err(e) => return unreadable(&e),
617    };
618    let view = view_of(&u, &tray);
619    let outdated = view
620        .drafts
621        .iter()
622        .filter(|d| d.state == DraftState::Outdated)
623        .count();
624    let message = format!(
625        "{} comment(s){}{}",
626        view.drafts.len(),
627        if outdated > 0 {
628            format!(", {outdated} outdated")
629        } else {
630            String::new()
631        },
632        view.turn
633            .as_ref()
634            .map(|t| format!("; last revision {} {}", t.id, t.outcome))
635            .unwrap_or_default()
636    );
637    result(&u, &tray, &message)
638}
639
640/// Adds, deletes or keeps a comment (or keeps a revision turn, `id` `tN`),
641/// on the person's command.
642#[must_use]
643#[allow(clippy::too_many_arguments)]
644pub fn comment(
645    path: &Path,
646    feature: Option<&str>,
647    op: CommentOp,
648    anchor: Option<&CommentAnchor>,
649    id: Option<&str>,
650    text: Option<&str>,
651    via: Via,
652    state: Option<&Path>,
653) -> CommandResult {
654    let u = match unit(path, feature, state) {
655        Ok(u) => u,
656        Err(r) => return *r,
657    };
658    let usage = |m: &str| refuse(CommandStatus::UsageError, "USAGE", m);
659    let _lock = match Lock::take(&u.plane, &u.feature) {
660        Ok(l) => l,
661        Err(e) => return usage(&e),
662    };
663    let mut tray = match load(&u.plane, &u.feature) {
664        Ok(t) => t,
665        Err(e) => return unreadable(&e),
666    };
667    let message = match op {
668        CommentOp::Add => {
669            let Some(anchor) = anchor else {
670                return usage("a comment needs what it is about (its anchor)");
671            };
672            let Some(words) = text.map(str::trim).filter(|t| !t.is_empty()) else {
673                return usage("a comment needs its words");
674            };
675            let anchor = match check(&u, anchor.clone()) {
676                Ok(a) => a,
677                Err(e) => return usage(&e),
678            };
679            tray.next += 1;
680            let id = format!("c{}", tray.next);
681            let place = anchor.place();
682            tray.drafts.push(CommentDraft {
683                id: id.clone(),
684                anchor,
685                text: words.to_owned(),
686                by: crate::approvals::Who::local_in(&u.root, via).name,
687                at: now(),
688                state: DraftState::Draft,
689                kept: false,
690            });
691            format!("added {id} on {place}")
692        }
693        CommentOp::Delete => {
694            let Some(id) = id else {
695                return usage("name the comment to delete");
696            };
697            let before = tray.drafts.len();
698            tray.drafts
699                .retain(|d| !(d.id == id && d.state != DraftState::Sent));
700            if tray.drafts.len() == before {
701                return usage(&format!("no comment {id} in the tray"));
702            }
703            format!("deleted {id}")
704        }
705        CommentOp::Keep => {
706            let Some(id) = id else {
707                return usage("name the comment (or the revision) to keep");
708            };
709            if let Some(t) = tray.turns.iter_mut().find(|t| t.id == id) {
710                if t.outcome != "pending" {
711                    return usage(&format!("revision {id} is {} already", t.outcome));
712                }
713                t.outcome = "kept".to_owned();
714                let sent: Vec<String> = t.comments.clone();
715                tray.drafts.retain(|d| !sent.contains(&d.id));
716                format!("kept revision {id}")
717            } else if let Some(d) = tray
718                .drafts
719                .iter_mut()
720                .find(|d| d.id == id && d.state != DraftState::Sent)
721            {
722                d.kept = true;
723                format!("kept {id}: it is sent although its words moved")
724            } else {
725                return usage(&format!("no comment or revision {id}"));
726            }
727        }
728    };
729    if let Err(e) = save(&u.plane, &u.feature, &tray) {
730        return refuse(CommandStatus::Error, "INTERNAL", &e);
731    }
732    result(&u, &tray, &message)
733}
734
735/// The revising skill's arguments: each comment with its quote and section,
736/// the sections touched, and that every other section stays as it is.
737#[must_use]
738pub fn revision_argument(drafts: &[CommentDraft], sections: &[String]) -> String {
739    let mut out = String::from(
740        "Revise the spec from the reviewer's comments below. Change only these sections: ",
741    );
742    out.push_str(&if sections.is_empty() {
743        "(the ones the comments name)".to_owned()
744    } else {
745        sections.join("; ")
746    });
747    out.push_str(
748        ". Keep every requirement ID. Every other section stays exactly as it is.\n\nComments:\n",
749    );
750    for d in drafts {
751        let section = match &d.anchor {
752            CommentAnchor::Passage { section, .. } if !section.is_empty() => {
753                format!(" [{section}]")
754            }
755            _ => String::new(),
756        };
757        out.push_str(&format!(
758            "- {}{section} \"{}\": {}\n",
759            d.anchor.place(),
760            normal(d.anchor.quote())
761                .chars()
762                .take(400)
763                .collect::<String>(),
764            d.text
765        ));
766    }
767    out
768}
769
770fn digests(u: &Unit, docs: &[String]) -> Vec<FileDigest> {
771    docs.iter()
772        .map(|p| FileDigest {
773            path: p.clone(),
774            sha256: std::fs::read(u.root.join(p))
775                .map_or_else(|_| "absent".to_owned(), |b| digest(&b)),
776        })
777        .collect()
778}
779
780/// A section by its document and heading path — the same heading in two
781/// documents is two sections.
782fn section_key(path: &str, heading: &str) -> String {
783    if heading.is_empty() {
784        format!("{path}: (before the first heading)")
785    } else {
786        format!("{path}: {heading}")
787    }
788}
789
790/// The sections changed between `before` and `after` of one document.
791fn changed_sections(path: &str, before: &str, after: &str) -> Vec<String> {
792    let (a, b) = (sections(before), sections(after));
793    let mut seen = BTreeSet::new();
794    a.iter()
795        .chain(&b)
796        .map(|(h, _)| h.clone())
797        .filter(|h| seen.insert(h.clone()))
798        .filter(|h| {
799            let x = a.iter().find(|(n, _)| n == h).map(|(_, t)| t.trim_end());
800            let y = b.iter().find(|(n, _)| n == h).map(|(_, t)| t.trim_end());
801            x != y
802        })
803        .map(|h| section_key(path, &h))
804        .collect()
805}
806
807/// A tool call's path as project-relative (`./a`, `<root>/a` → `a`).
808fn rel_path(u: &Unit, p: &str) -> String {
809    let p = p.trim_start_matches("./");
810    let path = Path::new(p);
811    if path.is_absolute() {
812        let canonical = std::fs::canonicalize(&u.root).unwrap_or_else(|_| u.root.clone());
813        for root in [&canonical, &u.root] {
814            if let Ok(rel) = path.strip_prefix(root) {
815                return rel.to_string_lossy().replace('\\', "/");
816            }
817        }
818    }
819    p.to_owned()
820}
821
822/// The documents a turn changed that the skill's own writes do not
823/// account for: each one replayed from its text before the turn through
824/// the skill's `write_file` and `edit_file` calls differs from the file
825/// now — someone else (the person, a command) changed it while it ran.
826fn changed_beside(
827    u: &Unit,
828    before: &[(String, Option<String>)],
829    docs: &[String],
830    calls: &[serde_json::Value],
831) -> Vec<String> {
832    docs.iter()
833        .filter(|d| {
834            let mut expected = before
835                .iter()
836                .find(|(p, _)| p == *d)
837                .and_then(|(_, t)| t.clone());
838            for c in calls.iter().filter(|c| {
839                c["decision"] == "allow"
840                    && c["ok"] != false
841                    && c["arguments"]["path"]
842                        .as_str()
843                        .is_some_and(|p| rel_path(u, p) == **d)
844            }) {
845                let a = &c["arguments"];
846                match c["tool"].as_str() {
847                    Some("write_file") => {
848                        expected = a["content"].as_str().map(str::to_owned);
849                    }
850                    Some("edit_file") => {
851                        let (old, new) = (
852                            a["old_string"].as_str().unwrap_or_default(),
853                            a["new_string"].as_str().unwrap_or_default(),
854                        );
855                        expected = expected.map(|t| t.replace(old, new));
856                    }
857                    _ => {}
858                }
859            }
860            read(u, d) != expected
861        })
862        .cloned()
863        .collect()
864}
865
866/// Whether a path a tray names is one of the unit's documents' places: a
867/// project-relative Markdown path under the unit's directory, nothing
868/// that climbs out (a tampered tray is never a way to write elsewhere).
869fn document_path(u: &Unit, p: &str) -> bool {
870    let path = Path::new(p);
871    path.is_relative()
872        && path
873            .components()
874            .all(|c| matches!(c, std::path::Component::Normal(_)))
875        && path.starts_with(&u.dir)
876        && path.extension().is_some_and(|x| x == "md")
877}
878
879/// *Send N Comments*: one revision turn of the format's revising skill,
880/// scoped to the sections the comments touch; test comments become one
881/// *Ask Again* per requirement after it (unless the revision lapsed that
882/// requirement's tests). The drafts are consumed atomically: a second send
883/// of the same drafts is refused.
884#[must_use]
885pub fn send(
886    path: &Path,
887    feature: Option<&str>,
888    ids: &[String],
889    via: Via,
890    state: Option<&Path>,
891    options: &crate::RunOptions,
892) -> CommandResult {
893    let Some(st) = state.map(Path::to_path_buf).or_else(worktree::state_dir) else {
894        return refuse(
895            CommandStatus::ConfigError,
896            "NO-STATE-DIR",
897            "no state directory (set SPECCODER_STATE_DIR or HOME)",
898        );
899    };
900    // Nothing to send is said before any model is opened.
901    if let Ok(u) = unit(path, feature, Some(&st)) {
902        let tray = match load(&u.plane, &u.feature) {
903            Ok(t) => t,
904            Err(e) => return unreadable(&e),
905        };
906        let open: Vec<&CommentDraft> = tray
907            .drafts
908            .iter()
909            .filter(|d| d.state != DraftState::Sent)
910            .collect();
911        let missing = ids.iter().find(|id| !open.iter().any(|d| d.id == **id));
912        if open.is_empty() || missing.is_some() {
913            return refuse(
914                CommandStatus::UsageError,
915                "USAGE",
916                &missing.map_or_else(
917                    || "no comments to send".to_owned(),
918                    |id| format!("{id} was sent already (or deleted): look at the tray again"),
919                ),
920            );
921        }
922    }
923    crate::with_driver("review", path, feature, |_, provider, key| {
924        send_with(
925            path,
926            feature,
927            ids,
928            via,
929            &st,
930            Some(Arc::clone(provider)),
931            Some(key),
932            options,
933        )
934    })
935}
936
937/// [`send`] against `state`, with `provider` for the skill and the author.
938#[must_use]
939#[allow(clippy::too_many_arguments, clippy::too_many_lines)]
940pub fn send_with(
941    path: &Path,
942    feature: Option<&str>,
943    ids: &[String],
944    via: Via,
945    state: &Path,
946    provider: Option<Arc<dyn ModelProvider>>,
947    secret: Option<&str>,
948    options: &crate::RunOptions,
949) -> CommandResult {
950    let u = match unit(path, feature, Some(state)) {
951        Ok(u) => u,
952        Err(r) => return *r,
953    };
954    let usage = |m: &str| refuse(CommandStatus::UsageError, "USAGE", m);
955    // Consume the drafts and open the turn as one step.
956    let (turn, sent, docs) = {
957        let _lock = match Lock::take(&u.plane, &u.feature) {
958            Ok(l) => l,
959            Err(e) => return usage(&e),
960        };
961        let mut tray = match load(&u.plane, &u.feature) {
962            Ok(t) => t,
963            Err(e) => return unreadable(&e),
964        };
965        let open: Vec<&CommentDraft> = tray
966            .drafts
967            .iter()
968            .filter(|d| d.state != DraftState::Sent)
969            .collect();
970        let chosen: Vec<CommentDraft> = if ids.is_empty() {
971            open.iter()
972                .filter(|d| d.kept || current(&u, &d.anchor))
973                .map(|d| (*d).clone())
974                .collect()
975        } else {
976            let mut out = Vec::new();
977            for id in ids {
978                match open.iter().find(|d| d.id == *id) {
979                    Some(d) if d.kept || current(&u, &d.anchor) => out.push((*d).clone()),
980                    Some(_) => {
981                        return usage(&format!(
982                            "{id} is outdated: its words are no longer where it was anchored — keep it or delete it first"
983                        ));
984                    }
985                    None => {
986                        return usage(&format!(
987                            "{id} was sent already (or deleted): look at the tray again"
988                        ));
989                    }
990                }
991            }
992            out
993        };
994        if chosen.is_empty() {
995            return usage("no comments to send");
996        }
997        let docs = documents(&u);
998        let sections: Vec<String> = chosen
999            .iter()
1000            .filter_map(|d| match &d.anchor {
1001                CommentAnchor::Passage { path, section, .. } => Some(section_key(path, section)),
1002                CommentAnchor::DiffLine {
1003                    path, line, side, ..
1004                } => {
1005                    let text = if side == "old" {
1006                        at_head(&u, path)
1007                    } else {
1008                        read(&u, path)
1009                    };
1010                    text.map(|t| section_key(path, &section_at(&t, *line)))
1011                }
1012                _ => None,
1013            })
1014            .collect::<BTreeSet<_>>()
1015            .into_iter()
1016            .collect();
1017        let id = format!("t{}", tray.turns.len() + 1);
1018        // The snapshot: every document's bytes, before the turn.
1019        let snap = snapshot_dir(&u.plane, &u.feature, &id);
1020        let _ = std::fs::remove_dir_all(&snap);
1021        for d in &docs {
1022            if let Ok(bytes) = std::fs::read(u.root.join(d)) {
1023                let f = snap.join(d);
1024                if let Some(parent) = f.parent()
1025                    && let Err(e) = std::fs::create_dir_all(parent)
1026                {
1027                    return refuse(CommandStatus::Error, "INTERNAL", &e.to_string());
1028                }
1029                if let Err(e) = std::fs::write(&f, bytes) {
1030                    return refuse(CommandStatus::Error, "INTERNAL", &e.to_string());
1031                }
1032            }
1033        }
1034        let skill = u.format.planning().revises.unwrap_or_default().to_owned();
1035        let turn = Turn {
1036            id: id.clone(),
1037            comments: chosen.iter().map(|d| d.id.clone()).collect(),
1038            sections,
1039            skill,
1040            before: digests(&u, &docs),
1041            after: Vec::new(),
1042            changed: Vec::new(),
1043            outside: Vec::new(),
1044            outcome: "pending".to_owned(),
1045            asked_again: Vec::new(),
1046            beside: Vec::new(),
1047        };
1048        for d in &mut tray.drafts {
1049            if turn.comments.contains(&d.id) {
1050                d.state = DraftState::Sent;
1051            }
1052        }
1053        tray.turns.push(turn.clone());
1054        if let Err(e) = save(&u.plane, &u.feature, &tray) {
1055            return refuse(CommandStatus::Error, "INTERNAL", &e);
1056        }
1057        (turn, chosen, docs)
1058    };
1059    let for_skill: Vec<CommentDraft> = sent
1060        .iter()
1061        .filter(|d| !matches!(d.anchor, CommentAnchor::TestLine { .. }))
1062        .cloned()
1063        .collect();
1064    let mut notes: Vec<String> = Vec::new();
1065    let before_texts: Vec<(String, Option<String>)> =
1066        docs.iter().map(|d| (d.clone(), read(&u, d))).collect();
1067    let mut calls: Vec<serde_json::Value> = Vec::new();
1068    if !for_skill.is_empty() {
1069        if turn.skill.is_empty() {
1070            notes.push(
1071                "the format has no revising skill: only the test comments were sent".to_owned(),
1072            );
1073        } else if let Some(provider) = provider.clone() {
1074            let argument = revision_argument(&for_skill, &turn.sections);
1075            let ran = crate::hosting::run_in(
1076                path,
1077                Some(&u.feature),
1078                &turn.skill,
1079                &argument,
1080                state,
1081                provider,
1082                secret,
1083                options,
1084            );
1085            notes.push(ran.message().to_owned());
1086            if let Some(CommandData::Skill(report)) = ran.data() {
1087                calls.clone_from(&report.tool_calls);
1088            }
1089        } else {
1090            notes.push("no model: the revision was not run".to_owned());
1091        }
1092    }
1093    // After the turn: what changed, per section, and what no comment touched.
1094    let after_docs: Vec<String> = documents(&u)
1095        .into_iter()
1096        .chain(docs.iter().cloned())
1097        .collect::<BTreeSet<_>>()
1098        .into_iter()
1099        .collect();
1100    let after = digests(&u, &after_docs);
1101    let mut changed: Vec<String> = Vec::new();
1102    for d in &after_docs {
1103        let before = before_texts
1104            .iter()
1105            .find(|(p, _)| p == d)
1106            .and_then(|(_, t)| t.as_deref())
1107            .unwrap_or_default();
1108        let now = read(&u, d).unwrap_or_default();
1109        if before != now {
1110            changed.extend(changed_sections(d, before, &now));
1111        }
1112    }
1113    let outside: Vec<String> = changed
1114        .iter()
1115        .filter(|c| !turn.sections.iter().any(|s| s == *c))
1116        .cloned()
1117        .collect();
1118    // What changed while the turn ran that its own writes do not explain
1119    // is not the turn's: Undo never overwrites it.
1120    let beside = changed_beside(&u, &before_texts, &after_docs, &calls);
1121    // Test comments: one Ask Again per test, unless the revision lapsed
1122    // its requirement's tests.
1123    let mut asked_again = Vec::new();
1124    // A test comment whose Ask Again did not run goes back to the tray.
1125    let mut returned: Vec<String> = Vec::new();
1126    let mut by_test: Vec<(String, String, Vec<String>, Vec<String>)> = Vec::new();
1127    for d in &sent {
1128        if let CommentAnchor::TestLine {
1129            req,
1130            path: p,
1131            line,
1132            quote,
1133        } = &d.anchor
1134        {
1135            let line_note = format!("line {line} \"{}\": {}", normal(quote), d.text);
1136            match by_test.iter_mut().find(|(r, q, ..)| r == req && q == p) {
1137                Some((_, _, n, ids)) => {
1138                    n.push(line_note);
1139                    ids.push(d.id.clone());
1140                }
1141                None => by_test.push((req.clone(), p.clone(), vec![line_note], vec![d.id.clone()])),
1142            }
1143        }
1144    }
1145    let requirements = match crate::inspect(path, Some(&u.feature)).data() {
1146        Some(CommandData::Inspect(s)) => u.format.oracle_requirements(s),
1147        _ => u.format.oracle_requirements(&u.snapshot),
1148    };
1149    for (req, test, lines, ids) in &by_test {
1150        let lapsed = requirements.iter().find(|r| r.key == *req).is_none_or(|r| {
1151            crate::oracle::load(&u.plane, &u.feature, req)
1152                .ok()
1153                .flatten()
1154                .is_none_or(|(d, _)| d.requirement.sha256 != r.digest())
1155        });
1156        if lapsed {
1157            notes.push(format!(
1158                "{req}'s tests lapsed with the revision: not asked again — the comment is back in the tray"
1159            ));
1160            returned.extend(ids.iter().cloned());
1161            continue;
1162        }
1163        let text = lines.join("; ");
1164        let r = crate::oracle::tests_with(
1165            path,
1166            Some(&u.feature),
1167            &crate::oracle::Request {
1168                req,
1169                op: speccoder_core::oracle::OracleOp::Again,
1170                path: Some(test),
1171                scenario: None,
1172                reading: None,
1173                text: Some(&text),
1174            },
1175            via,
1176            state,
1177            provider.clone(),
1178            options,
1179        );
1180        if r.status() == CommandStatus::Ok {
1181            notes.push(r.message().to_owned());
1182            if !asked_again.contains(req) {
1183                asked_again.push(req.clone());
1184            }
1185        } else {
1186            notes.push(format!("{} — the comment is back in the tray", r.message()));
1187            returned.extend(ids.iter().cloned());
1188        }
1189    }
1190    let _lock = match Lock::wait(&u.plane, &u.feature) {
1191        Ok(l) => l,
1192        Err(e) => {
1193            return refuse(
1194                CommandStatus::Error,
1195                "INTERNAL",
1196                &format!("revision {}'s outcome was not recorded: {e}", turn.id),
1197            );
1198        }
1199    };
1200    let mut tray = match load(&u.plane, &u.feature) {
1201        Ok(t) => t,
1202        Err(e) => return unreadable(&e),
1203    };
1204    if let Some(t) = tray.turns.iter_mut().find(|t| t.id == turn.id) {
1205        t.after = after;
1206        t.changed.clone_from(&changed);
1207        t.outside.clone_from(&outside);
1208        t.asked_again.clone_from(&asked_again);
1209        t.beside.clone_from(&beside);
1210        t.comments.retain(|c| !returned.contains(c));
1211    }
1212    for d in &mut tray.drafts {
1213        if returned.contains(&d.id) {
1214            d.state = DraftState::Draft;
1215        }
1216    }
1217    if let Err(e) = save(&u.plane, &u.feature, &tray) {
1218        return refuse(CommandStatus::Error, "INTERNAL", &e);
1219    }
1220    let message = format!(
1221        "sent {} comment(s) as revision {}: {} section(s) changed{}{}{}",
1222        sent.len() - returned.len(),
1223        turn.id,
1224        changed.len(),
1225        if outside.is_empty() {
1226            String::new()
1227        } else {
1228            format!(
1229                ", {} outside your comments ({}) — keep or undo it",
1230                outside.len(),
1231                outside.join("; ")
1232            )
1233        },
1234        if beside.is_empty() {
1235            String::new()
1236        } else {
1237            format!(
1238                "; {} changed beside the revision while it ran — Undo will not overwrite it",
1239                beside.join(", ")
1240            )
1241        },
1242        if notes.is_empty() {
1243            String::new()
1244        } else {
1245            format!(" — {}", notes.join("; "))
1246        }
1247    );
1248    result(&u, &tray, &message)
1249}
1250
1251/// *Undo Revision*, on the person's command only: the documents back to
1252/// the snapshot before the turn, byte for byte — refused when one changed
1253/// since the turn (naming it); the comments return to the tray.
1254#[must_use]
1255pub fn undo(
1256    path: &Path,
1257    feature: Option<&str>,
1258    turn: Option<&str>,
1259    state: Option<&Path>,
1260) -> CommandResult {
1261    let u = match unit(path, feature, state) {
1262        Ok(u) => u,
1263        Err(r) => return *r,
1264    };
1265    let usage = |m: &str| refuse(CommandStatus::UsageError, "USAGE", m);
1266    let _lock = match Lock::take(&u.plane, &u.feature) {
1267        Ok(l) => l,
1268        Err(e) => return usage(&e),
1269    };
1270    let mut tray = match load(&u.plane, &u.feature) {
1271        Ok(t) => t,
1272        Err(e) => return unreadable(&e),
1273    };
1274    let Some(last) = tray.turns.last().cloned() else {
1275        return usage("no revision to undo");
1276    };
1277    if let Some(t) = turn
1278        && t != last.id
1279    {
1280        return usage(&format!(
1281            "only the last revision ({}) can be undone, not {t}",
1282            last.id
1283        ));
1284    }
1285    if last.outcome == "undone" {
1286        return usage(&format!("revision {} is undone already", last.id));
1287    }
1288    // Someone else changed a document while the turn ran: restoring the
1289    // snapshot would revert their edit with the skill's.
1290    if !last.beside.is_empty() {
1291        return refuse(
1292            CommandStatus::NeedsHuman,
1293            "USAGE",
1294            &format!(
1295                "{} changed beside revision {} while it ran: undo would revert that edit too — nothing was restored",
1296                last.beside.join(", "),
1297                last.id
1298            ),
1299        );
1300    }
1301    if let Some(bad) = last
1302        .before
1303        .iter()
1304        .chain(&last.after)
1305        .find(|f| !document_path(&u, &f.path))
1306    {
1307        return refuse(
1308            CommandStatus::NeedsHuman,
1309            "REVIEW-UNREADABLE",
1310            &format!(
1311                "revision {} names {}, which is not a document of {}: nothing was restored",
1312                last.id, bad.path, u.feature
1313            ),
1314        );
1315    }
1316    // Every document as the turn left it, or the undo is refused.
1317    let paths: Vec<String> = last
1318        .before
1319        .iter()
1320        .chain(&last.after)
1321        .map(|f| f.path.clone())
1322        .collect::<BTreeSet<_>>()
1323        .into_iter()
1324        .collect();
1325    let now = digests(&u, &paths);
1326    for f in &now {
1327        let expected = last
1328            .after
1329            .iter()
1330            .find(|a| a.path == f.path)
1331            .or_else(|| last.before.iter().find(|b| b.path == f.path))
1332            .map_or("absent", |a| a.sha256.as_str());
1333        if f.sha256 != expected {
1334            return refuse(
1335                CommandStatus::NeedsHuman,
1336                "USAGE",
1337                &format!(
1338                    "{} changed since revision {}: undo would overwrite that — nothing was restored",
1339                    f.path, last.id
1340                ),
1341            );
1342        }
1343    }
1344    let snap = snapshot_dir(&u.plane, &u.feature, &last.id);
1345    // Every snapshot checked before anything is written.
1346    let mut restore: Vec<(String, Option<Vec<u8>>)> = Vec::new();
1347    for f in &now {
1348        match last.before.iter().find(|b| b.path == f.path) {
1349            Some(b) if b.sha256 != "absent" => {
1350                let Ok(bytes) = std::fs::read(snap.join(&f.path)) else {
1351                    return refuse(
1352                        CommandStatus::Error,
1353                        "INTERNAL",
1354                        &format!("the snapshot of {} is missing: nothing restored", f.path),
1355                    );
1356                };
1357                if digest(&bytes) != b.sha256 {
1358                    return refuse(
1359                        CommandStatus::NeedsHuman,
1360                        "INTERNAL",
1361                        &format!("the snapshot of {} was changed: nothing restored", f.path),
1362                    );
1363                }
1364                if f.sha256 != b.sha256 {
1365                    restore.push((f.path.clone(), Some(bytes)));
1366                }
1367            }
1368            _ => {
1369                if f.sha256 != "absent" {
1370                    restore.push((f.path.clone(), None));
1371                }
1372            }
1373        }
1374    }
1375    let mut written = Vec::new();
1376    for (p, bytes) in restore {
1377        let target = u.root.join(&p);
1378        let done = match bytes {
1379            Some(b) => std::fs::write(&target, b),
1380            None => std::fs::remove_file(&target),
1381        };
1382        if let Err(e) = done {
1383            return refuse(CommandStatus::Error, "INTERNAL", &format!("{p}: {e}"));
1384        }
1385        written.push(p);
1386    }
1387    if let Some(t) = tray.turns.last_mut() {
1388        t.outcome = "undone".to_owned();
1389    }
1390    for d in &mut tray.drafts {
1391        if last.comments.contains(&d.id) {
1392            d.state = DraftState::Draft;
1393        }
1394    }
1395    if let Err(e) = save(&u.plane, &u.feature, &tray) {
1396        return refuse(CommandStatus::Error, "INTERNAL", &e);
1397    }
1398    let message = if written.is_empty() {
1399        format!(
1400            "undid revision {}: nothing to restore; the comments are back in the tray",
1401            last.id
1402        )
1403    } else {
1404        format!(
1405            "undid revision {}: restored {}; the comments are back in the tray",
1406            last.id,
1407            written.join(", ")
1408        )
1409    };
1410    result(&u, &tray, &message)
1411}
1412
1413/// *Send N Comments as a Nudge*: Code-view line comments on a task's diff
1414/// as one note for its next attempt (`path:line "quote" — text` per line).
1415/// A line of a frozen test is a review comment instead, never a nudge.
1416#[must_use]
1417pub fn code_nudge(
1418    path: &Path,
1419    feature: Option<&str>,
1420    task: &str,
1421    comments: &[CodeComment],
1422    state: Option<&Path>,
1423) -> CommandResult {
1424    let u = match unit(path, feature, state) {
1425        Ok(u) => u,
1426        Err(r) => return *r,
1427    };
1428    if comments.is_empty() || comments.iter().any(|c| c.text.trim().is_empty()) {
1429        return refuse(
1430            CommandStatus::UsageError,
1431            "USAGE",
1432            "a nudge needs at least one comment, each with its words",
1433        );
1434    }
1435    // Which tests are frozen: a record that cannot be read leaves it
1436    // unknown, so nothing becomes a nudge.
1437    let mut frozen: Vec<String> = Vec::new();
1438    for r in u.format.oracle_requirements(&u.snapshot) {
1439        match crate::pre_frozen::load(&u.plane, &u.feature, &r.key) {
1440            Ok(Some((rec, _))) => frozen.extend(rec.visible.into_iter().map(|v| v.path)),
1441            Ok(None) => {}
1442            Err(t) => {
1443                return refuse(
1444                    CommandStatus::NeedsHuman,
1445                    "ORACLE-TAMPERED",
1446                    &format!(
1447                        "{}'s frozen set cannot be read ({}): whether a commented line is a frozen test is unknown, so no nudge was saved",
1448                        r.key, t.0
1449                    ),
1450                );
1451            }
1452        }
1453    }
1454    // The Code view names a path from the repository's top; a frozen test
1455    // is named from the project's directory, which may sit below it.
1456    let prefix = crate::git::Git::at(&u.root)
1457        .line(&["rev-parse", "--show-prefix"])
1458        .unwrap_or_default();
1459    let is_frozen = |p: &str| {
1460        let p = p.trim_start_matches("./");
1461        frozen.iter().any(|f| f == p || format!("{prefix}{f}") == p)
1462    };
1463    if let Some(c) = comments.iter().find(|c| is_frozen(&c.path)) {
1464        return refuse(
1465            CommandStatus::UsageError,
1466            "USAGE",
1467            &format!(
1468                "{} is a frozen test: comment on it in Review › Tests, not as a nudge to the implementer",
1469                c.path
1470            ),
1471        )
1472        .with_fix(vec![
1473            "speccoder review comments add --test <REQ>:<PATH>:<LINE> \"…\"".to_owned(),
1474        ]);
1475    }
1476    let note = comments
1477        .iter()
1478        .map(CodeComment::nudge_line)
1479        .collect::<Vec<_>>()
1480        .join("\n");
1481    let Some(st) = state.map(Path::to_path_buf).or_else(worktree::state_dir) else {
1482        return refuse(
1483            CommandStatus::ConfigError,
1484            "NO-STATE-DIR",
1485            "no state directory",
1486        );
1487    };
1488    let r = crate::controls::nudge_with(path, Some(&u.feature), task, Some(&note), &st);
1489    if r.status() != CommandStatus::Ok {
1490        return r;
1491    }
1492    let message = format!(
1493        "nudge saved — the next attempt of {task} reads {} comment(s)",
1494        comments.len()
1495    );
1496    r.reworded(CommandStatus::Ok, &message)
1497}
1498
1499#[cfg(test)]
1500mod tests {
1501    use super::*;
1502
1503    const DOC: &str = "# Spec\n\nIntro.\n\n## Requirements\n\n### Functional\n\n- FR-001 a\n\n## Edge Cases\n\n- none\n";
1504
1505    #[test]
1506    fn the_turn_s_record_waits_for_a_short_writer_and_never_goes_without_the_lock() {
1507        let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
1508        let held = Lock::take(state.path(), "u").unwrap_or_else(|e| panic!("{e}"));
1509        assert!(
1510            Lock::take(state.path(), "u").is_err(),
1511            "one writer at a time"
1512        );
1513        let releaser = std::thread::spawn(move || {
1514            std::thread::sleep(std::time::Duration::from_millis(200));
1515            drop(held);
1516        });
1517        let waited = Lock::wait(state.path(), "u");
1518        assert!(waited.is_ok(), "{:?}", waited.err());
1519        releaser.join().unwrap_or_else(|_| panic!("joined"));
1520    }
1521
1522    #[test]
1523    fn a_line_knows_its_section() {
1524        assert_eq!(section_at(DOC, 3), "Spec");
1525        assert_eq!(section_at(DOC, 9), "Spec › Requirements › Functional");
1526        assert_eq!(section_at(DOC, 13), "Spec › Edge Cases");
1527        assert_eq!(section_at("text\n", 1), "");
1528    }
1529
1530    #[test]
1531    fn only_the_changed_sections_are_named() {
1532        let after = DOC.replace("- none", "- an empty name");
1533        assert_eq!(
1534            changed_sections("spec.md", DOC, &after),
1535            ["spec.md: Spec › Edge Cases"]
1536        );
1537        assert!(changed_sections("spec.md", DOC, DOC).is_empty());
1538        let added = format!("{DOC}\n## New\n\nx\n");
1539        assert_eq!(
1540            changed_sections("spec.md", DOC, &added),
1541            ["spec.md: Spec › New"]
1542        );
1543        // The same heading in another document is another section.
1544        assert_ne!(
1545            changed_sections("plan.md", DOC, &after),
1546            changed_sections("spec.md", DOC, &after)
1547        );
1548    }
1549
1550    #[test]
1551    fn a_quote_is_found_near_its_lines_only() {
1552        assert!(found(DOC, 9, 9, "FR-001 a"));
1553        assert!(found(DOC, 9, 9, "FR-001   a"));
1554        assert!(!found(DOC, 9, 9, "FR-002"));
1555        let far = format!("{}{DOC}", "x\n".repeat(40));
1556        assert!(!found(&far, 1, 1, "FR-001 a"));
1557    }
1558
1559    #[test]
1560    fn the_argument_scopes_the_turn() {
1561        let d = CommentDraft {
1562            id: "c1".to_owned(),
1563            anchor: CommentAnchor::Passage {
1564                path: "specs/001/spec.md".to_owned(),
1565                start_line: 9,
1566                end_line: 9,
1567                quote: "FR-001 a".to_owned(),
1568                quote_sha256: String::new(),
1569                section: "Spec › Requirements › Functional".to_owned(),
1570            },
1571            text: "say b".to_owned(),
1572            by: "Ada".to_owned(),
1573            at: 0,
1574            state: DraftState::Draft,
1575            kept: false,
1576        };
1577        let a = revision_argument(&[d], &["Spec › Requirements › Functional".to_owned()]);
1578        assert!(a.contains("Change only these sections: Spec › Requirements › Functional"));
1579        assert!(a.contains("Every other section stays exactly as it is"));
1580        assert!(a.contains("\"FR-001 a\": say b"));
1581    }
1582}