1use 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
31pub const DIR: &str = "comments";
33
34const DRIFT: usize = 20;
36
37#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
39pub struct FileDigest {
40 pub path: String,
42 pub sha256: String,
44}
45
46#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
48pub struct Turn {
49 pub id: String,
51 pub comments: Vec<String>,
53 pub sections: Vec<String>,
55 pub skill: String,
57 pub before: Vec<FileDigest>,
59 pub after: Vec<FileDigest>,
61 pub changed: Vec<String>,
63 pub outside: Vec<String>,
65 pub outcome: String,
67 #[serde(default)]
69 pub asked_again: Vec<String>,
70 #[serde(default)]
72 pub beside: Vec<String>,
73}
74
75#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
77pub struct Tray {
78 #[serde(default)]
80 pub next: u32,
81 #[serde(default)]
83 pub drafts: Vec<CommentDraft>,
84 #[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
97pub 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
112fn 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
128struct 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 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 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
187struct 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
235fn 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#[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#[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
314fn 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
339fn 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
350fn 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, "e) {
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("e).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, "e) {
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, "e) {
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 "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 "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
465fn 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
498fn 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#[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#[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#[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
780fn 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
790fn 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
807fn 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
822fn 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
866fn 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#[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 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#[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 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, §ion_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 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 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 let beside = changed_beside(&u, &before_texts, &after_docs, &calls);
1121 let mut asked_again = Vec::new();
1124 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#[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 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 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 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#[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 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 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(¬e), &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 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}