1use std::path::{Path, PathBuf};
16use std::sync::Arc;
17
18use agentplane::model::ModelProvider;
19use serde::{Deserialize, Serialize};
20use serde_json::{Value, json};
21use speccoder_core::acceptance::{Decided, PreFrozenAcceptance, Producer, SealedRef, VisibleTest};
22use speccoder_core::actions::Via;
23use speccoder_core::context::{ContextEntry, ContextKind};
24use speccoder_core::ladder::Rung;
25use speccoder_core::oracle::{
26 Ambiguity, AuthoredBy, Expectation, HeldOutSummary, OracleDiscarded, OracleOp, OracleRejected,
27 OracleState, OracleTest, OracleView, Reading, Resolution, Untestable,
28};
29use speccoder_core::snapshot::ProjectSnapshot;
30use speccoder_core::verify::{RedClass, classify_red};
31use speccoder_core::{CommandData, CommandResult, CommandStatus};
32
33use crate::format::{Adapter, OracleRequirement};
34use crate::integrity::sha256_hex;
35use crate::{sandbox, substrate, tools, worktree};
36
37pub const DIR: &str = "oracle";
39
40pub const LIMIT: usize = 4;
42
43#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
45pub struct Draft {
46 pub requirement: DraftRequirement,
48 pub samples: u8,
50 pub tests: Vec<DraftTest>,
52 pub discarded: Vec<OracleDiscarded>,
54 pub rejected: Vec<OracleRejected>,
56 pub expectations: Samples<Expectation>,
58 pub untestable: Samples<Untestable>,
60 pub ambiguities: Vec<Ambiguity>,
62 pub held_out: HeldOutSummary,
64 pub sealed: Option<SealedRef>,
66 pub notes: Vec<String>,
68 pub runs: Vec<String>,
70 pub context: Vec<ContextEntry>,
73}
74
75#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
77pub struct DraftRequirement {
78 pub key: String,
80 pub title: String,
82 pub text: String,
84 pub sha256: String,
86}
87
88#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
90pub struct DraftTest {
91 pub path: String,
93 pub sha256: String,
95 pub scenarios: Vec<String>,
97 pub argv: Vec<String>,
99 pub red_class: RedClass,
101 pub authored_by: AuthoredBy,
103 pub edited_by: Option<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
109#[serde(bound(deserialize = "T: Deserialize<'de>"))]
110pub struct Samples<T> {
111 pub sample1: Vec<T>,
113 pub sample2: Vec<T>,
115}
116
117impl<T> Default for Samples<T> {
118 fn default() -> Self {
119 Self {
120 sample1: Vec::new(),
121 sample2: Vec::new(),
122 }
123 }
124}
125
126#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
128pub struct OracleEntry {
129 pub requirement_sha256: String,
131 pub manifest_sha256: String,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub held_out_sha256: Option<String>,
136 pub by: String,
138 pub via: Via,
140 pub at: u64,
142}
143
144#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
147pub struct Frozen {
148 pub requirement_sha256: String,
150 pub wording: String,
152 pub manifest_sha256: String,
154 pub tests: Vec<FrozenTest>,
156}
157
158#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
160pub struct FrozenTest {
161 pub path: String,
163 pub sha256: String,
165 pub argv: Vec<String>,
167 pub scenarios: Vec<String>,
169}
170
171pub type Loaded = (Draft, Vec<(String, String)>);
173
174type Kept = (String, String, Vec<String>, Vec<String>);
176
177#[derive(Debug, Clone, PartialEq, Eq)]
179pub struct Tampered(pub String);
180
181fn slug(key: &str) -> String {
182 key.chars()
183 .map(|c| {
184 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
185 c
186 } else {
187 '_'
188 }
189 })
190 .collect()
191}
192
193#[must_use]
195pub fn dir(project_state: &Path, unit: &str, key: &str) -> PathBuf {
196 project_state.join(DIR).join(unit).join(slug(key))
197}
198
199fn now() -> u64 {
200 std::time::SystemTime::now()
201 .duration_since(std::time::UNIX_EPOCH)
202 .map_or(0, |d| d.as_secs())
203}
204
205fn digest_of(text: &str) -> String {
206 format!("sha256:{}", sha256_hex(text.as_bytes()))
207}
208
209fn write_atomic(file: &Path, text: &str) -> Result<(), String> {
210 if let Some(parent) = file.parent() {
211 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
212 }
213 let tmp = file.with_extension("json.tmp");
214 std::fs::write(&tmp, text).map_err(|e| format!("{}: {e}", tmp.display()))?;
215 std::fs::rename(&tmp, file).map_err(|e| format!("{}: {e}", file.display()))
216}
217
218pub fn store(
221 project_state: &Path,
222 unit: &str,
223 draft: &Draft,
224 contents: &[(String, String)],
225) -> Result<(), String> {
226 let d = dir(project_state, unit, &draft.requirement.key);
227 let _ = std::fs::remove_dir_all(d.join("files"));
228 for t in &draft.tests {
229 let content = contents
230 .iter()
231 .find(|(p, _)| *p == t.path)
232 .map(|(_, c)| c)
233 .ok_or_else(|| format!("{} has no content", t.path))?;
234 if digest_of(content) != t.sha256 {
235 return Err(format!("{} does not match its digest", t.path));
236 }
237 let file = d.join("files").join(&t.path);
238 if let Some(parent) = file.parent() {
239 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
240 }
241 std::fs::write(&file, content).map_err(|e| format!("{}: {e}", file.display()))?;
242 }
243 let text = serde_json::to_string_pretty(draft).map_err(|e| e.to_string())?;
244 write_atomic(&d.join("draft.json"), &text)
245}
246
247pub fn load(project_state: &Path, unit: &str, key: &str) -> Result<Option<Loaded>, Tampered> {
250 let d = dir(project_state, unit, key);
251 let Ok(text) = std::fs::read_to_string(d.join("draft.json")) else {
252 return Ok(None);
253 };
254 let draft: Draft = serde_json::from_str(&text)
255 .map_err(|e| Tampered(format!("the oracle draft of {key} is not valid: {e}")))?;
256 let mut contents = Vec::new();
257 for t in &draft.tests {
258 let content = std::fs::read_to_string(d.join("files").join(&t.path))
259 .map_err(|_| Tampered(format!("the test {} of {key} is missing", t.path)))?;
260 if digest_of(&content) != t.sha256 {
261 return Err(Tampered(format!("the test {} of {key} changed", t.path)));
262 }
263 contents.push((t.path.clone(), content));
264 }
265 Ok(Some((draft, contents)))
266}
267
268#[must_use]
270pub fn load_frozen(project_state: &Path, unit: &str, key: &str) -> Option<Frozen> {
271 std::fs::read_to_string(dir(project_state, unit, key).join("frozen.json"))
272 .ok()
273 .and_then(|t| serde_json::from_str(&t).ok())
274}
275
276#[must_use]
279pub fn manifest_digest<'a>(
280 tests: impl IntoIterator<Item = (&'a str, &'a str, &'a [String])>,
281) -> String {
282 let mut lines: Vec<String> = tests
283 .into_iter()
284 .map(|(path, sha, argv)| {
285 format!(
286 "{path}={sha} argv={}",
287 serde_json::to_string(argv).unwrap_or_default()
288 )
289 })
290 .collect();
291 lines.sort();
292 digest_of(&lines.join("\n"))
293}
294
295impl Draft {
296 pub fn red(&self) -> impl Iterator<Item = &DraftTest> {
298 self.tests.iter().filter(|t| t.red_class.is_red())
299 }
300
301 #[must_use]
303 pub fn manifest(&self) -> Option<String> {
304 let red: Vec<&DraftTest> = self.red().collect();
305 (!red.is_empty()).then(|| {
306 manifest_digest(
307 red.iter()
308 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
309 )
310 })
311 }
312
313 pub fn open_ambiguities(&self) -> impl Iterator<Item = &Ambiguity> {
315 self.ambiguities.iter().filter(|a| a.open())
316 }
317
318 fn all_notes(&self) -> Vec<String> {
320 self.rejected
321 .iter()
322 .map(|r| format!("Rejected `{}`: {}", r.path, r.reason))
323 .chain(self.notes.iter().cloned())
324 .collect()
325 }
326}
327
328#[derive(Debug, Clone, Copy)]
330pub struct ViewConfig {
331 pub samples: u8,
333 pub strength_target: Option<u8>,
335 pub held_out: bool,
337 pub acceptance: bool,
339}
340
341impl ViewConfig {
342 #[must_use]
344 pub fn at(root: &Path) -> Self {
345 let rc = crate::verify::find_config(root)
346 .and_then(|d| std::fs::read_to_string(d.join(crate::verify::config::FILE)).ok())
347 .and_then(|t| substrate::run_config::parse(&t).ok())
348 .unwrap_or_default();
349 Self::of(&rc)
350 }
351
352 #[must_use]
354 pub fn of(rc: &substrate::run_config::RunConfig) -> Self {
355 Self {
356 samples: rc.oracle_samples,
357 strength_target: rc.strength.min.map(|m| {
358 let percent = (m * 100.0).round().clamp(0.0, 100.0);
360 format!("{percent:.0}").parse().unwrap_or(0)
361 }),
362 held_out: rc.held_out,
363 acceptance: rc.acceptance,
364 }
365 }
366}
367
368fn test_view(t: &DraftTest, content: &str) -> OracleTest {
369 OracleTest {
370 path: t.path.clone(),
371 content: content.to_owned(),
372 scenarios: t.scenarios.clone(),
373 red_class: crate::pipeline::class_word(t.red_class),
374 red: t.red_class.is_red(),
375 authored_by: t.authored_by,
376 edited_by: t.edited_by.clone(),
377 }
378}
379
380#[must_use]
384pub fn view(
385 project_state: &Path,
386 unit: &str,
387 req: &OracleRequirement,
388 entry: Option<&OracleEntry>,
389 cfg: ViewConfig,
390) -> OracleView {
391 let digest = req.digest();
392 let mut v = OracleView {
393 req: req.key.clone(),
394 title: req.title.clone(),
395 scenarios: req.scenarios.clone(),
396 state: OracleState::None,
397 frozen_on: None,
398 frozen_by: None,
399 frozen_at: None,
400 tests: Vec::new(),
401 discarded: Vec::new(),
402 rejected: Vec::new(),
403 uncovered: req.scenarios.iter().map(|s| s.id.clone()).collect(),
404 untestable: Vec::new(),
405 ambiguities: Vec::new(),
406 held_out: HeldOutSummary {
407 state: if cfg.held_out { "absent" } else { "off" }.to_owned(),
408 count: 0,
409 manifest_sha256: None,
410 reason: None,
411 },
412 strength_target: cfg.strength_target,
413 calls: cfg.samples,
414 ambiguity_check: cfg.samples >= 2,
415 manifest_sha256: None,
416 error: (!cfg.acceptance)
417 .then(|| "acceptance tests are off ([acceptance] generate = false)".to_owned()),
418 };
419 let loaded = load(project_state, unit, &req.key);
420 let draft = match loaded {
421 Ok(d) => d,
422 Err(t) => {
423 v.state = OracleState::Failed;
424 v.error = Some(t.0);
425 None
426 }
427 };
428 let current = draft
429 .as_ref()
430 .filter(|(d, _)| d.requirement.sha256 == digest);
431 if let Some((d, contents)) = current {
432 let content = |p: &str| {
433 contents
434 .iter()
435 .find(|(q, _)| q == p)
436 .map(|(_, c)| c.as_str())
437 .unwrap_or_default()
438 };
439 v.tests = d
440 .tests
441 .iter()
442 .map(|t| test_view(t, content(&t.path)))
443 .collect();
444 v.discarded.clone_from(&d.discarded);
445 v.rejected.clone_from(&d.rejected);
446 v.untestable.clone_from(&d.untestable.sample1);
447 v.ambiguities.clone_from(&d.ambiguities);
448 v.held_out = if cfg.held_out {
449 d.held_out.clone()
450 } else {
451 HeldOutSummary {
452 state: "off".to_owned(),
453 count: 0,
454 manifest_sha256: None,
455 reason: None,
456 }
457 };
458 v.manifest_sha256 = d.manifest();
459 let covered: Vec<&String> = d.red().flat_map(|t| t.scenarios.iter()).collect();
460 v.uncovered.retain(|s| !covered.contains(&s));
461 v.state = if d.open_ambiguities().next().is_some() {
462 OracleState::Ambiguous
463 } else if d.red().next().is_none() {
464 OracleState::NotRed
465 } else {
466 OracleState::Written
467 };
468 } else if draft.is_some() {
469 v.error = Some("written for an earlier wording: write them again".to_owned());
470 }
471 if let Some(e) = entry {
472 if e.requirement_sha256 == digest {
473 v.frozen_by = Some(e.by.clone());
474 v.frozen_at = Some(e.at);
475 v.state = match crate::pre_frozen::load(project_state, unit, &req.key) {
476 Ok(Some((rec, files))) => {
477 let drafted = current.map(|(d, _)| d);
480 v.tests = rec
481 .visible
482 .iter()
483 .map(|t| {
484 let same = drafted.and_then(|d| {
485 d.tests
486 .iter()
487 .find(|x| x.path == t.path && x.sha256 == t.sha256)
488 });
489 OracleTest {
490 path: t.path.clone(),
491 content: files
492 .iter()
493 .find(|(p, _)| *p == t.path)
494 .map(|(_, c)| c.clone())
495 .unwrap_or_default(),
496 scenarios: t.cites.clone(),
497 red_class: same.map_or_else(
498 || crate::pipeline::class_word(RedClass::Assertion),
499 |x| crate::pipeline::class_word(x.red_class),
500 ),
501 red: true,
502 authored_by: same.map_or(AuthoredBy::Model, |x| x.authored_by),
503 edited_by: same.and_then(|x| x.edited_by.clone()),
504 }
505 })
506 .collect();
507 v.manifest_sha256 = Some(e.manifest_sha256.clone());
508 let covered: Vec<&String> =
509 rec.visible.iter().flat_map(|t| t.cites.iter()).collect();
510 v.uncovered = req
511 .scenarios
512 .iter()
513 .map(|s| s.id.clone())
514 .filter(|s| !covered.contains(&s))
515 .collect();
516 if cfg.held_out {
517 v.held_out = match &rec.held_out_sealed {
518 Some(h) => HeldOutSummary {
519 state: "kept".to_owned(),
520 count: h.count,
521 manifest_sha256: Some(format!("sha256:{}", h.manifest_sha256)),
522 reason: None,
523 },
524 None => HeldOutSummary {
525 state: "absent".to_owned(),
526 count: 0,
527 manifest_sha256: None,
528 reason: Some("none_kept".to_owned()),
529 },
530 };
531 }
532 OracleState::Frozen
533 }
534 Ok(None) => OracleState::FrozenElsewhere,
535 Err(t) => {
536 v.error = Some(format!("the frozen set was tampered with: {}", t.0));
537 OracleState::Frozen
538 }
539 };
540 } else if current.is_none() {
541 v.state = OracleState::Lapsed;
542 v.frozen_on = load_frozen(project_state, unit, &req.key).map(|f| f.wording);
543 v.frozen_by = Some(e.by.clone());
544 v.frozen_at = Some(e.at);
545 }
546 }
547 v
548}
549
550pub fn open_ambiguities(
557 project_state: &Path,
558 unit: &str,
559 requirements: &[OracleRequirement],
560) -> Result<Vec<(String, String)>, Tampered> {
561 let mut out = Vec::new();
562 for r in requirements {
563 let Some((d, _)) = load(project_state, unit, &r.key)? else {
564 continue;
565 };
566 if d.requirement.sha256 != r.digest() {
567 continue;
568 }
569 out.extend(
570 d.open_ambiguities()
571 .map(|a| (d.requirement.key.clone(), a.scenario.clone())),
572 );
573 }
574 Ok(out)
575}
576
577#[must_use]
579pub fn rung(format: &dyn Adapter, root: &Path, unit: &str) -> Rung {
580 speccoder_core::ladder::rung_of(unit, &format.standing_units(root))
581}
582
583#[derive(Debug, Clone)]
585pub struct Request<'a> {
586 pub req: &'a str,
588 pub op: OracleOp,
590 pub path: Option<&'a str>,
592 pub scenario: Option<&'a str>,
594 pub reading: Option<Reading>,
596 pub text: Option<&'a str>,
598}
599
600impl Request<'_> {
601 #[must_use]
603 pub fn needs_model(&self) -> bool {
604 matches!(self.op, OracleOp::Write | OracleOp::Again)
605 || (self.op == OracleOp::Reading && self.reading == Some(Reading::B))
606 }
607}
608
609struct Ctx {
611 root: PathBuf,
612 feature: String,
613 dir: String,
614 format: &'static dyn Adapter,
615 snapshot: ProjectSnapshot,
616 plane_dir: PathBuf,
617 config: substrate::run_config::RunConfig,
618 sandbox: sandbox::Config,
619 policy: tools::policy::Policy,
620 model: Option<substrate::model_config::ModelConfig>,
621}
622
623fn refuse(status: CommandStatus, code: &str, message: &str) -> CommandResult {
624 CommandResult::new("review", status, message).with_code(code)
625}
626
627fn open(path: &Path, feature: Option<&str>, state: &Path) -> Result<Ctx, Box<CommandResult>> {
628 let inspected = crate::inspect(path, feature);
629 let Some(CommandData::Inspect(snapshot)) = inspected.data() else {
630 return Err(Box::new(CommandResult::carried("review", &inspected)));
631 };
632 let snapshot = (**snapshot).clone();
633 let Some(f) = snapshot.feature.as_ref() else {
634 return Err(Box::new(CommandResult::new(
635 "review",
636 CommandStatus::SelectionNeeded,
637 inspected.message(),
638 )));
639 };
640 let root = PathBuf::from(&snapshot.root);
641 let config_root = crate::verify::find_config(&root).ok_or_else(|| {
642 Box::new(refuse(
643 CommandStatus::ConfigError,
644 "CONFIG-MISSING",
645 "the project has no speccoder.toml",
646 ))
647 })?;
648 let text =
649 std::fs::read_to_string(config_root.join(crate::verify::config::FILE)).unwrap_or_default();
650 let config = substrate::run_config::parse(&text).map_err(|e| {
651 Box::new(refuse(
652 CommandStatus::ConfigError,
653 "CONFIG-INVALID",
654 &format!("speccoder.toml: {e}"),
655 ))
656 })?;
657 let sandbox = sandbox::parse(&text).map_err(|e| {
658 Box::new(refuse(
659 CommandStatus::ConfigError,
660 "CONFIG-INVALID",
661 &format!("speccoder.toml: {e}"),
662 ))
663 })?;
664 let format = crate::format::of(&snapshot);
665 let policy =
666 tools::policy::Policy::new(&crate::verify::check_argvs(&config_root), &config.allow)
667 .with_prefixes(config.allow_prefix.clone())
668 .rules(format.rules());
669 let model = substrate::model_config::parse(&text).ok().flatten();
670 Ok(Ctx {
671 feature: f.feature.name.clone(),
672 dir: f.feature.dir.clone(),
673 plane_dir: worktree::project_state_dir(state, &root),
674 root,
675 format,
676 snapshot,
677 config,
678 sandbox,
679 policy,
680 model,
681 })
682}
683
684fn project_in(root: &Path, worktree_root: &Path) -> PathBuf {
686 let canonical = |p: &Path| std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
687 let top = crate::git::Git::at(root)
688 .toplevel()
689 .ok()
690 .flatten()
691 .map_or_else(|| root.to_path_buf(), |t| canonical(&t));
692 match canonical(root).strip_prefix(&top) {
693 Ok(rel) if !rel.as_os_str().is_empty() => worktree_root.join(rel),
694 _ => worktree_root.to_path_buf(),
695 }
696}
697
698pub(crate) struct UnitLock(PathBuf);
702
703impl UnitLock {
704 pub(crate) fn take(project_state: &Path, unit: &str) -> Result<Self, String> {
705 let path = project_state.join(DIR).join(format!("{unit}.lock"));
706 if let Some(parent) = path.parent() {
707 std::fs::create_dir_all(parent).map_err(|e| format!("{}: {e}", parent.display()))?;
708 }
709 if std::fs::metadata(&path)
711 .ok()
712 .and_then(|m| m.modified().ok())
713 .and_then(|m| m.elapsed().ok())
714 .is_some_and(|age| age.as_secs() > 3600)
715 {
716 let _ = std::fs::remove_file(&path);
717 }
718 std::fs::OpenOptions::new()
719 .write(true)
720 .create_new(true)
721 .open(&path)
722 .map(|_| Self(path))
723 .map_err(|_| {
724 format!("{unit}'s tests are being written or frozen elsewhere right now: look again when that ends")
725 })
726 }
727}
728
729impl Drop for UnitLock {
730 fn drop(&mut self) {
731 let _ = std::fs::remove_file(&self.0);
732 }
733}
734
735fn stage(options: &crate::RunOptions, req: &str, stage: &str, n: u32, of: u32) {
737 if let Some(p) = &options.progress {
738 p.event(&crate::progress::Event::Oracle {
739 step: crate::progress::OracleEvent::Stage {
740 requirement: req.to_owned(),
741 stage: stage.to_owned(),
742 n,
743 of,
744 },
745 });
746 }
747}
748
749#[must_use]
752pub fn tests(
753 path: &Path,
754 feature: Option<&str>,
755 request: &Request<'_>,
756 via: Via,
757 state: Option<&Path>,
758 options: &crate::RunOptions,
759) -> CommandResult {
760 let Some(state) = state.map(Path::to_path_buf).or_else(worktree::state_dir) else {
761 return refuse(
762 CommandStatus::ConfigError,
763 "NO-STATE-DIR",
764 "no state directory (set SPECCODER_STATE_DIR or HOME)",
765 );
766 };
767 if !request.needs_model() {
768 return tests_with(path, feature, request, via, &state, None, options);
769 }
770 let ctx = match open(path, feature, &state) {
771 Ok(c) => c,
772 Err(r) => return *r,
773 };
774 if let Err(r) = check(&ctx, request) {
775 return *r;
776 }
777 let Some(model) = ctx.model.as_ref() else {
778 return refuse(
779 CommandStatus::ConfigError,
780 "CONFIG",
781 "speccoder.toml has no [model] section: the test author needs a model",
782 );
783 };
784 match substrate::model_config::open_driver(model) {
785 Ok((provider, _)) => {
786 tests_with(path, feature, request, via, &state, Some(provider), options)
787 }
788 Err(e) => substrate::model_config::refused("review", &e),
789 }
790}
791
792#[must_use]
794pub fn tests_with(
795 path: &Path,
796 feature: Option<&str>,
797 request: &Request<'_>,
798 via: Via,
799 state: &Path,
800 provider: Option<Arc<dyn ModelProvider>>,
801 options: &crate::RunOptions,
802) -> CommandResult {
803 let ctx = match open(path, feature, state) {
804 Ok(c) => c,
805 Err(r) => return *r,
806 };
807 let req = match check(&ctx, request) {
808 Ok(r) => r,
809 Err(r) => return *r,
810 };
811 let _lock = match UnitLock::take(&ctx.plane_dir, &ctx.feature) {
812 Ok(l) => l,
813 Err(e) => return refuse(CommandStatus::GitPrecondition, "RUN-ACTIVE", &e),
814 };
815 let who = crate::approvals::Who::local_in(&ctx.root, via);
816 let done = match request.op {
817 OracleOp::Write => write(&ctx, &req, provider, options),
818 OracleOp::Reject => reject(&ctx, &req, request, &who.name),
819 OracleOp::Again => again(&ctx, &req, request, &who.name, provider, options),
820 OracleOp::Edit => edit(&ctx, &req, request, &who.name),
821 OracleOp::Reading => reading(&ctx, &req, request, &who.name, provider, options),
822 };
823 match done {
824 Ok(message) => {
825 let r = crate::review::report_in(path, feature, Some(state));
826 let status = r.status();
827 r.reworded(status, &message)
828 }
829 Err(r) => *r,
830 }
831}
832
833fn check(ctx: &Ctx, request: &Request<'_>) -> Result<OracleRequirement, Box<CommandResult>> {
836 let unit_rung = rung(ctx.format, &ctx.root, &ctx.feature);
837 if unit_rung != Rung::Feature {
838 return Err(Box::new(
839 refuse(
840 CommandStatus::UsageError,
841 "REVIEW-TESTS-OWN-PIPELINE",
842 &format!(
843 "{} is a {} unit: its tests are written and confirmed by its own pipeline",
844 ctx.feature,
845 if unit_rung == Rung::Quick {
846 "quick-change"
847 } else {
848 "bug"
849 }
850 ),
851 )
852 .with_fix(vec![if unit_rung == Rung::Quick {
853 "speccoder do \"…\"".to_owned()
854 } else {
855 format!("speccoder bug confirm {}/<task>", ctx.feature)
856 }]),
857 ));
858 }
859 if !ctx.config.acceptance {
860 return Err(Box::new(refuse(
861 CommandStatus::ConfigError,
862 "ACCEPTANCE-OFF",
863 "acceptance tests are off ([acceptance] generate = false): no tests are written at review",
864 )));
865 }
866 let requirements = ctx.format.oracle_requirements(&ctx.snapshot);
867 let Some(req) = requirements
868 .iter()
869 .find(|r| r.key.eq_ignore_ascii_case(request.req))
870 .cloned()
871 else {
872 return Err(Box::new(refuse(
873 CommandStatus::UsageError,
874 "REVIEW-NO-SCENARIOS",
875 &format!(
876 "the spec has no requirement {}; it names {}",
877 request.req,
878 if requirements.is_empty() {
879 "none with scenarios".to_owned()
880 } else {
881 requirements
882 .iter()
883 .map(|r| r.key.as_str())
884 .collect::<Vec<_>>()
885 .join(", ")
886 }
887 ),
888 )));
889 };
890 if req.scenarios.is_empty() {
891 return Err(Box::new(refuse(
892 CommandStatus::UsageError,
893 "REVIEW-NO-SCENARIOS",
894 &format!(
895 "{} has no acceptance scenarios to write tests from",
896 req.key
897 ),
898 )));
899 }
900 let (entries, _) = crate::review::oracle_entries(&ctx.root, &ctx.dir)
903 .map_err(|e| Box::new(refuse(CommandStatus::NeedsHuman, "REVIEW-UNREADABLE", &e)))?;
904 if let Some(e) = entries
905 .get(&req.key)
906 .filter(|e| e.requirement_sha256 == req.digest())
907 {
908 return Err(Box::new(refuse(
909 CommandStatus::UsageError,
910 "ORACLE-FROZEN",
911 &format!(
912 "{}'s tests were frozen with the approval by {}: the run uses them as frozen — reword {} to lapse the freeze, then write its tests again",
913 req.key, e.by, req.key
914 ),
915 )));
916 }
917 Ok(req)
918}
919
920type Outcome = Result<String, Box<CommandResult>>;
921
922fn failed(message: impl Into<String>) -> Box<CommandResult> {
923 Box::new(refuse(
924 CommandStatus::RunFailed,
925 "ORACLE-AUTHOR-FAILED",
926 &message.into(),
927 ))
928}
929
930fn usage(message: &str) -> Box<CommandResult> {
931 Box::new(refuse(CommandStatus::UsageError, "USAGE", message))
932}
933
934fn existing(ctx: &Ctx, req: &OracleRequirement) -> Result<Loaded, Box<CommandResult>> {
935 match load(&ctx.plane_dir, &ctx.feature, &req.key) {
936 Ok(Some((d, c))) if d.requirement.sha256 == req.digest() => Ok((d, c)),
937 Ok(Some(_)) => Err(usage(&format!(
938 "{}'s tests were written for an earlier wording: write them again (`speccoder review tests {} --write`)",
939 req.key, req.key
940 ))),
941 Ok(None) => Err(usage(&format!(
942 "{} has no tests yet: `speccoder review tests {} --write`",
943 req.key, req.key
944 ))),
945 Err(t) => Err(Box::new(refuse(
946 CommandStatus::NeedsHuman,
947 "ORACLE-TAMPERED",
948 &t.0,
949 ))),
950 }
951}
952
953struct Author<'a> {
955 ctx: &'a Ctx,
956 rt: tokio::runtime::Runtime,
957 plane: substrate::Plane,
958 bundle: Value,
959 instructions: Vec<crate::instructions::Instruction>,
960 test_layout: String,
962}
963
964impl<'a> Author<'a> {
965 fn open(
966 ctx: &'a Ctx,
967 req: &OracleRequirement,
968 provider: Option<Arc<dyn ModelProvider>>,
969 options: &crate::RunOptions,
970 ) -> Result<Self, Box<CommandResult>> {
971 let (Some(provider), Some(model)) = (provider, ctx.model.as_ref()) else {
972 return Err(Box::new(refuse(
973 CommandStatus::ConfigError,
974 "CONFIG",
975 "the test author needs a model ([model] in speccoder.toml)",
976 )));
977 };
978 let rt = crate::pipeline::tokio_runtime().map_err(failed)?;
979 let plane = rt
980 .block_on(async {
981 substrate::Plane::open_with(
982 &ctx.plane_dir,
983 model,
984 provider,
985 None,
986 substrate::Hooks {
987 format: Some(ctx.format),
988 progress: options.progress.clone(),
989 ..substrate::Hooks::default()
990 },
991 )
992 })
993 .map_err(|e| failed(e.0))?;
994 let unit_dir = ctx.root.join(&ctx.dir);
995 let [constitution, _, plan, _] = ctx.format.convergence_texts(&ctx.root, &unit_dir);
996 let bundle = json!({
997 "feature": ctx.feature,
998 "constitution": constitution,
999 "plan": plan,
1000 "story": req.text,
1001 "task": {
1002 "id": format!("req-{}", req.key),
1003 "title": format!("Acceptance tests for {}: {}", req.key, req.title),
1004 "phase": "Review",
1005 "subsection": "",
1006 "story": req.key,
1007 "parallel": false,
1008 },
1009 "dependencies": [],
1010 });
1011 let instructions =
1012 crate::instructions::collect(&ctx.root, &ctx.root, &[], &ctx.config.instructions);
1013 Ok(Self {
1014 ctx,
1015 rt,
1016 plane,
1017 bundle,
1018 instructions,
1019 test_layout: crate::layout::text_at(&ctx.root),
1020 })
1021 }
1022
1023 fn context(
1025 &self,
1026 req: &OracleRequirement,
1027 notes: &[String],
1028 ) -> speccoder_core::context::ContextManifest {
1029 let mut m = crate::context::Manifest::new();
1030 m.push(
1031 ContextKind::Story,
1032 "person",
1033 "/requirement",
1034 req.text.as_bytes(),
1035 );
1036 for s in &req.scenarios {
1037 m.push(
1038 ContextKind::Scenario,
1039 "person",
1040 &format!("/scenarios/{}", s.id),
1041 s.text.as_bytes(),
1042 );
1043 }
1044 m.push(
1045 ContextKind::Constitution,
1046 "worktree",
1047 "/bundle/constitution",
1048 self.bundle["constitution"]
1049 .as_str()
1050 .unwrap_or_default()
1051 .as_bytes(),
1052 );
1053 m.push(
1054 ContextKind::Plan,
1055 "worktree",
1056 "/bundle/plan",
1057 self.bundle["plan"].as_str().unwrap_or_default().as_bytes(),
1058 );
1059 m.push(
1060 ContextKind::TestLayout,
1061 "worktree",
1062 "/test_layout",
1063 self.test_layout.as_bytes(),
1064 );
1065 for (i, n) in notes.iter().enumerate() {
1066 let kind = if n.starts_with("Rejected `") {
1067 ContextKind::Rejection
1068 } else if n.starts_with("Comment ") {
1069 ContextKind::Comment
1070 } else {
1071 ContextKind::Note
1072 };
1073 m.push(kind, "person", &format!("/notes/{i}"), n.as_bytes());
1074 }
1075 m.finish()
1076 }
1077
1078 #[allow(clippy::too_many_arguments)]
1080 fn call(
1081 &self,
1082 req: &OracleRequirement,
1083 files: &[String],
1084 manifest: &str,
1085 notes: &[String],
1086 again: Option<(&str, &str)>,
1087 sample: u8,
1088 ) -> Result<(Value, String, Vec<ContextEntry>), String> {
1089 let context = self.context(req, notes);
1090 let input = substrate::acceptance::review_input(&substrate::acceptance::ReviewParts {
1091 bundle: self.bundle.clone(),
1092 requirement: json!({ "key": req.key, "title": req.title, "text": req.text }),
1093 scenarios: &req.scenarios,
1094 files,
1095 manifest,
1096 notes,
1097 again,
1098 instructions: &self.instructions,
1099 context: &context,
1100 sample,
1101 test_layout: Some(&self.test_layout),
1102 });
1103 let task = format!("req-{}", req.key);
1104 let outcome = self
1105 .rt
1106 .block_on(self.plane.acceptance(&self.ctx.feature, &task, input))
1107 .map_err(|e| format!("the test author failed: {}", e.0))?;
1108 let output = outcome
1109 .output
1110 .as_ref()
1111 .map(|o| o.peek().clone())
1112 .unwrap_or_default();
1113 let answer = output["answer"].clone();
1114 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null()
1115 {
1116 let status = format!("{:?}", outcome.status);
1117 return Err(substrate::provider_refusal(&status).map_or_else(
1118 || {
1119 format!(
1120 "the test author did not answer: {}",
1121 outcome.status.as_str()
1122 )
1123 },
1124 |r| format!("the model provider refused the request: {r}"),
1125 ));
1126 }
1127 Ok((answer, outcome.run_id.to_string(), context.entries))
1128 }
1129}
1130
1131fn expectations(
1137 answer: &Value,
1138 req: &OracleRequirement,
1139) -> Result<(Vec<Expectation>, Vec<Untestable>), String> {
1140 let canonical = |id: &str| {
1141 req.scenarios
1142 .iter()
1143 .find(|s| s.id.eq_ignore_ascii_case(id.trim()))
1144 .map(|s| s.id.clone())
1145 .ok_or_else(|| {
1146 format!(
1147 "the test author's expectations name `{id}`, which {} does not have",
1148 req.key
1149 )
1150 })
1151 };
1152 let list = |field: &str| -> Result<Value, String> {
1153 match &answer[field] {
1154 Value::Null => Ok(Value::Array(Vec::new())),
1155 v @ Value::Array(_) => Ok(v.clone()),
1156 _ => Err(format!("the test author's `{field}` is not a list")),
1157 }
1158 };
1159 let mut e: Vec<Expectation> = serde_json::from_value(list("expectations")?)
1160 .map_err(|x| format!("the test author's expectations cannot be read: {x}"))?;
1161 let mut u: Vec<Untestable> = serde_json::from_value(list("untestable")?)
1162 .map_err(|x| format!("the test author's untestable list cannot be read: {x}"))?;
1163 for x in &mut e {
1164 x.scenario = canonical(&x.scenario)?;
1165 }
1166 for x in &mut u {
1167 x.scenario = canonical(&x.scenario)?;
1168 }
1169 if e.is_empty() && u.is_empty() {
1170 return Err(
1171 "the test author stated no expectation for any scenario: the readings cannot be compared"
1172 .to_owned(),
1173 );
1174 }
1175 Ok((e, u))
1176}
1177
1178struct Bench {
1181 scratch: worktree::Scratch,
1182 project: PathBuf,
1183 runner: sandbox::Runner,
1184 tmp: PathBuf,
1185}
1186
1187impl Bench {
1188 fn open(ctx: &Ctx) -> Result<Self, Box<CommandResult>> {
1189 let tmp = ctx.plane_dir.join("tmp");
1190 let scratch = worktree::scratch(&ctx.root, &tmp)
1191 .map_err(|e| failed(format!("the scratch worktree could not be made: {}", e.0)))?;
1192 let project = project_in(&ctx.root, &scratch.path);
1193 let (runner, notice) = sandbox::runner(&scratch.path, &ctx.sandbox)
1194 .map_err(|e| Box::new(refuse(CommandStatus::ConfigError, "CONFIG", &e)))?;
1195 if !runner.is_sandboxed() {
1198 return Err(Box::new(
1199 refuse(
1200 CommandStatus::ConfigError,
1201 "SANDBOX-UNAVAILABLE",
1202 &format!(
1203 "the red check runs model-written tests and needs the OS sandbox: {}",
1204 notice.unwrap_or_else(|| "commands would run unsandboxed".to_owned())
1205 ),
1206 )
1207 .with_fix(vec![
1208 "set [sandbox] mode = \"auto\" on a machine with a sandbox backend (speccoder doctor)".to_owned(),
1209 ]),
1210 ));
1211 }
1212 Ok(Self {
1213 scratch,
1214 project,
1215 runner,
1216 tmp,
1217 })
1218 }
1219
1220 fn files(&self) -> (Vec<String>, String) {
1221 let files: Vec<String> = worktree::checkpoint(&self.scratch.path, &self.tmp)
1222 .and_then(|c| worktree::files(&self.scratch.path, &c))
1223 .unwrap_or_default()
1224 .into_iter()
1225 .take(400)
1226 .collect();
1227 let manifest = crate::pipeline::MANIFESTS
1228 .iter()
1229 .find_map(|m| {
1230 std::fs::read_to_string(self.project.join(m))
1231 .ok()
1232 .map(|t| format!("{m}:\n{t}"))
1233 })
1234 .map(|t| t.chars().take(8192).collect::<String>())
1235 .unwrap_or_default();
1236 (files, manifest)
1237 }
1238
1239 fn red(
1245 &self,
1246 tests: &[(String, String, Vec<String>)],
1247 ) -> Result<Vec<Result<RedClass, &'static str>>, String> {
1248 let mut out = Vec::new();
1249 for (path, content, argv) in tests {
1250 let full = self.project.join(path);
1251 let _ = std::fs::remove_file(&full);
1252 let run = || crate::pipeline::acceptance_run(&self.project, argv, &self.runner);
1253 let (bare, summary) = run();
1254 if classify_red(&bare, summary.as_ref()).is_red() {
1255 out.push(Err("red_without_it"));
1256 continue;
1257 }
1258 if let Some(parent) = full.parent() {
1259 std::fs::create_dir_all(parent).map_err(|e| format!("{path}: {e}"))?;
1260 }
1261 std::fs::write(&full, content).map_err(|e| format!("{path}: {e}"))?;
1262 let (first, summary) = run();
1263 let class = classify_red(&first, summary.as_ref());
1264 let class = if !class.is_red() {
1265 class
1266 } else {
1267 let (second, summary) = run();
1268 if classify_red(&second, summary.as_ref()).is_red() {
1269 class
1270 } else {
1271 RedClass::Flaky
1272 }
1273 };
1274 std::fs::remove_file(&full).map_err(|e| format!("{path}: {e}"))?;
1275 out.push(Ok(class));
1276 }
1277 Ok(out)
1278 }
1279}
1280
1281fn keep(
1283 ctx: &Ctx,
1284 bench: &Bench,
1285 answer: &Value,
1286 req: &OracleRequirement,
1287 taken: &[(String, Vec<String>)],
1288) -> (Vec<Kept>, Vec<OracleDiscarded>) {
1289 let (valid, refused) =
1290 crate::pipeline::validate_answer(answer, &bench.project, &ctx.policy, &req.scenarios);
1291 let mut discarded: Vec<OracleDiscarded> = refused
1292 .into_iter()
1293 .map(|r| OracleDiscarded {
1294 path: r.path,
1295 why: r.why.to_owned(),
1296 })
1297 .collect();
1298 let set_argv: Vec<String> = answer["argv"]
1299 .as_array()
1300 .into_iter()
1301 .flatten()
1302 .filter_map(|v| v.as_str().map(str::to_owned))
1303 .collect();
1304 let layout = crate::layout::detect(&bench.project, &crate::verify::check_argvs(&ctx.root));
1305 let mut kept = Vec::new();
1306 for f in valid {
1307 let mut discard = |why: &str| {
1308 discarded.push(OracleDiscarded {
1309 path: f.path.clone(),
1310 why: why.to_owned(),
1311 });
1312 };
1313 if f.cited.is_empty() {
1314 discard("cites_no_scenario");
1315 continue;
1316 }
1317 if taken.iter().any(|(p, _)| *p == f.path)
1318 || kept.iter().any(|(p, ..): &(String, _, _, _)| *p == f.path)
1319 {
1320 discard("exists");
1321 continue;
1322 }
1323 let argv = if f.argv.is_empty() {
1324 set_argv.clone()
1325 } else {
1326 f.argv.clone()
1327 };
1328 if taken.iter().any(|(_, a)| *a == argv)
1331 || kept
1332 .iter()
1333 .any(|(.., a): &(_, _, _, Vec<String>)| *a == argv)
1334 {
1335 discard("command_shared");
1336 continue;
1337 }
1338 if argv.is_empty()
1339 || !crate::pipeline::test_command_allowed(
1340 &ctx.policy,
1341 Some(&layout),
1342 &argv,
1343 &[f.path.as_str()],
1344 )
1345 {
1346 discard("command_not_allowed");
1347 continue;
1348 }
1349 if kept.len() + taken.len() >= LIMIT {
1350 discard("limit");
1351 continue;
1352 }
1353 kept.push((f.path, f.content, f.cited, argv));
1354 }
1355 (kept, discarded)
1356}
1357
1358fn write(
1361 ctx: &Ctx,
1362 req: &OracleRequirement,
1363 provider: Option<Arc<dyn ModelProvider>>,
1364 options: &crate::RunOptions,
1365) -> Outcome {
1366 let samples = ctx.config.oracle_samples.clamp(1, 2);
1367 let author = Author::open(ctx, req, provider, options)?;
1368 let bench = Bench::open(ctx)?;
1369 let (files, manifest) = bench.files();
1370 let earlier = load(&ctx.plane_dir, &ctx.feature, &req.key).map_err(|t| {
1373 Box::new(
1374 refuse(
1375 CommandStatus::NeedsHuman,
1376 "ORACLE-TAMPERED",
1377 &format!(
1378 "{}: writing anew would lose the rejections and notes it holds",
1379 t.0
1380 ),
1381 )
1382 .with_fix(vec![format!(
1383 "→ restore it, or remove {} to start {}'s tests over",
1384 dir(&ctx.plane_dir, &ctx.feature, &req.key).display(),
1385 req.key
1386 )]),
1387 )
1388 })?;
1389 let (rejected, mut notes) = earlier
1390 .as_ref()
1391 .filter(|(d, _)| d.requirement.sha256 == req.digest())
1392 .map(|(d, _)| (d.rejected.clone(), d.notes.clone()))
1393 .unwrap_or_default();
1394 notes.dedup();
1395 let told: Vec<String> = rejected
1396 .iter()
1397 .map(|r| format!("Rejected `{}`: {}", r.path, r.reason))
1398 .chain(notes.iter().cloned())
1399 .collect();
1400 let of = u32::from(samples);
1401 stage(options, &req.key, "writing", 1, of);
1402 let (first, run1, mut context) = author
1403 .call(req, &files, &manifest, &told, None, 1)
1404 .map_err(failed)?;
1405 let read = |answer: &Value| {
1408 let got = expectations(answer, req);
1409 if samples >= 2 {
1410 got.map_err(failed)
1411 } else {
1412 Ok(got.unwrap_or_default())
1413 }
1414 };
1415 let (e1, u1) = read(&first)?;
1416 let mut runs = vec![run1];
1417 let (mut e2, mut u2) = (Vec::new(), Vec::new());
1418 if samples >= 2 {
1419 stage(options, &req.key, "writing", 2, of);
1420 let (second, run2, more) = author
1421 .call(req, &files, &manifest, &told, None, 2)
1422 .map_err(failed)?;
1423 (e2, u2) = read(&second)?;
1424 runs.push(run2);
1425 for c in more {
1426 if !context.contains(&c) {
1427 context.push(c);
1428 }
1429 }
1430 stage(options, &req.key, "comparing", 1, 1);
1431 }
1432 let ambiguities = if samples >= 2 {
1433 speccoder_core::oracle::compare(&e1, &u1, &e2, &u2)
1434 } else {
1435 Vec::new()
1436 };
1437 let (kept, mut discarded) = keep(ctx, &bench, &first, req, &[]);
1438 stage(options, &req.key, "checking_red", 1, 1);
1439 let plan: Vec<(String, String, Vec<String>)> = kept
1440 .iter()
1441 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1442 .collect();
1443 let classes = bench.red(&plan).map_err(failed)?;
1444 let mut tests: Vec<DraftTest> = Vec::new();
1445 let mut contents: Vec<(String, String)> = Vec::new();
1446 for ((path, content, cited, argv), class) in kept.into_iter().zip(classes) {
1447 match class {
1448 Ok(red_class) => {
1449 tests.push(DraftTest {
1450 path: path.clone(),
1451 sha256: digest_of(&content),
1452 scenarios: cited,
1453 argv,
1454 red_class,
1455 authored_by: AuthoredBy::Model,
1456 edited_by: None,
1457 });
1458 contents.push((path, content));
1459 }
1460 Err(why) => discarded.push(OracleDiscarded {
1461 path,
1462 why: why.to_owned(),
1463 }),
1464 }
1465 }
1466 let (held_out, sealed) = held_out(ctx, &author, &bench, req, &tests, options);
1467 let draft = Draft {
1468 requirement: DraftRequirement {
1469 key: req.key.clone(),
1470 title: req.title.clone(),
1471 text: req.text.clone(),
1472 sha256: req.digest(),
1473 },
1474 samples,
1475 tests,
1476 discarded,
1477 rejected,
1478 expectations: Samples {
1479 sample1: e1,
1480 sample2: e2,
1481 },
1482 untestable: Samples {
1483 sample1: u1,
1484 sample2: u2,
1485 },
1486 ambiguities,
1487 held_out,
1488 sealed,
1489 notes,
1490 runs,
1491 context,
1492 };
1493 store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
1494 let red = draft.red().count();
1495 let open = draft.open_ambiguities().count();
1496 Ok(format!(
1497 "wrote {} test(s) for {}: {red} red{}{}",
1498 draft.tests.len(),
1499 req.key,
1500 if draft.held_out.state == "kept" {
1501 format!(", {} held out", draft.held_out.count)
1502 } else {
1503 String::new()
1504 },
1505 if open > 0 {
1506 format!(
1507 "; {} — {open} scenario(s) read two ways: choose a reading or reword (`speccoder review tests {} --reading A|B <scenario>`)",
1508 req.key, req.key
1509 )
1510 } else {
1511 String::new()
1512 }
1513 ))
1514}
1515
1516fn held_out(
1520 ctx: &Ctx,
1521 author: &Author<'_>,
1522 bench: &Bench,
1523 req: &OracleRequirement,
1524 tests: &[DraftTest],
1525 options: &crate::RunOptions,
1526) -> (HeldOutSummary, Option<SealedRef>) {
1527 let summary = |state: &str, reason: Option<&str>| HeldOutSummary {
1528 state: state.to_owned(),
1529 count: 0,
1530 manifest_sha256: None,
1531 reason: reason.map(str::to_owned),
1532 };
1533 if !ctx.config.held_out {
1534 return (summary("off", None), None);
1535 }
1536 let red: Vec<&DraftTest> = tests.iter().filter(|t| t.red_class.is_red()).collect();
1537 if red.is_empty() {
1538 return (summary("absent", Some("not_red")), None);
1539 }
1540 stage(options, &req.key, "held_out", 1, 1);
1541 let visible = crate::pipeline::PreparedAcceptance {
1542 report: json!({ "files": red.iter().map(|t| t.path.clone()).collect::<Vec<_>>() }),
1543 argv: red.first().map(|t| t.argv.clone()),
1544 more_argv: Vec::new(),
1545 red: true,
1546 red_class: Some(RedClass::Assertion),
1547 held_out: None,
1548 generation_failed: None,
1549 skip_cause: None,
1550 };
1551 let task = format!("req-{}", req.key);
1552 let store = crate::held_out::sealed_dir(&ctx.plane_dir, &ctx.feature, &req.key);
1553 let (report, sha) = crate::pipeline::prepare_held_out(
1554 &crate::pipeline::AcceptanceSetup {
1555 rt: &author.rt,
1556 plane: &author.plane,
1557 project: &bench.project,
1558 worktree: &bench.scratch.path,
1559 tmp: &bench.tmp,
1560 policy: &ctx.policy,
1561 feature: &ctx.feature,
1562 task: &task,
1563 bundle: author.bundle.clone(),
1564 scenarios: &req.scenarios,
1565 instructions: &author.instructions,
1566 runner: &bench.runner,
1567 layout: None,
1568 story_files: &[],
1569 },
1570 &visible,
1571 &req.scenarios,
1572 &store,
1573 false,
1574 );
1575 match sha {
1576 Some(sha) => {
1577 let count = report["kept"]
1578 .as_u64()
1579 .and_then(|n| usize::try_from(n).ok())
1580 .unwrap_or(0);
1581 (
1582 HeldOutSummary {
1583 state: "kept".to_owned(),
1584 count,
1585 manifest_sha256: Some(format!("sha256:{sha}")),
1586 reason: None,
1587 },
1588 Some(SealedRef {
1589 key: req.key.clone(),
1590 manifest_sha256: sha,
1591 count,
1592 }),
1593 )
1594 }
1595 None => (
1596 summary(
1597 "absent",
1598 Some(report["reason"].as_str().unwrap_or("none_kept")),
1599 ),
1600 None,
1601 ),
1602 }
1603}
1604
1605fn reject(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1606 let Some(path) = r.path else {
1607 return Err(usage("name the test to reject (--reject PATH)"));
1608 };
1609 let Some(reason) = r.text.map(str::trim).filter(|t| !t.is_empty()) else {
1610 return Err(usage(
1611 "a rejection needs a reason: why is this test wrong? (--reason \"…\")",
1612 ));
1613 };
1614 let (mut d, contents) = existing(ctx, req)?;
1615 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1616 return Err(usage(&format!("{} has no test {path}", req.key)));
1617 };
1618 d.tests.remove(at);
1619 d.rejected.push(OracleRejected {
1620 path: path.to_owned(),
1621 reason: reason.to_owned(),
1622 by: by.to_owned(),
1623 at: now(),
1624 });
1625 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1626 Ok(format!(
1627 "rejected {path} of {}: \"{reason}\" — later author calls for {} are told",
1628 req.key, req.key
1629 ))
1630}
1631
1632fn edit(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1633 let Some(path) = r.path else {
1634 return Err(usage("name the test to edit (--edit PATH)"));
1635 };
1636 let Some(content) = r.text.filter(|t| !t.trim().is_empty()) else {
1637 return Err(usage("an edit needs the test's new content (--from FILE)"));
1638 };
1639 let (mut d, mut contents) = existing(ctx, req)?;
1640 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1641 return Err(usage(&format!("{} has no test {path}", req.key)));
1642 };
1643 let bench = Bench::open(ctx)?;
1644 let plan = [(
1645 path.to_owned(),
1646 content.to_owned(),
1647 d.tests[at].argv.clone(),
1648 )];
1649 let checked = bench
1650 .red(&plan)
1651 .map_err(failed)?
1652 .pop()
1653 .unwrap_or(Ok(RedClass::Unclassified));
1654 let class = checked.unwrap_or(RedClass::Unclassified);
1656 let t = &mut d.tests[at];
1657 t.sha256 = digest_of(content);
1658 t.red_class = class;
1659 t.authored_by = AuthoredBy::Person;
1660 t.edited_by = Some(by.to_owned());
1661 if let Some(c) = contents.iter_mut().find(|(p, _)| p == path) {
1662 c.1 = content.to_owned();
1663 }
1664 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1665 Ok(format!(
1666 "edited {path} of {}: {}",
1667 req.key,
1668 if class.is_red() {
1669 format!("red ({})", crate::pipeline::class_word(class))
1670 } else if checked.is_err() {
1671 "its command is red without it — it decides nothing about this test, which cannot be frozen".to_owned()
1672 } else {
1673 format!(
1674 "not red ({}) — no evidence, it cannot be frozen",
1675 crate::pipeline::class_word(class)
1676 )
1677 }
1678 ))
1679}
1680
1681#[allow(clippy::too_many_arguments)]
1684fn rewrite(
1685 ctx: &Ctx,
1686 req: &OracleRequirement,
1687 mut d: Draft,
1688 mut contents: Vec<(String, String)>,
1689 replaced: &[String],
1690 note: String,
1691 again: Option<(&str, &str)>,
1692 only: Option<&str>,
1693 provider: Option<Arc<dyn ModelProvider>>,
1694 options: &crate::RunOptions,
1695) -> Result<(Draft, Vec<String>), Box<CommandResult>> {
1696 let author = Author::open(ctx, req, provider, options)?;
1697 let bench = Bench::open(ctx)?;
1698 let (files, manifest) = bench.files();
1699 d.notes.push(note);
1700 let told = d.all_notes();
1701 stage(options, &req.key, "writing", 1, 1);
1702 let (answer, run, context) = author
1703 .call(req, &files, &manifest, &told, again, 1)
1704 .map_err(failed)?;
1705 d.runs.push(run);
1706 for c in context {
1707 if !d.context.contains(&c) {
1708 d.context.push(c);
1709 }
1710 }
1711 let keep_paths: Vec<(String, Vec<String>)> = d
1712 .tests
1713 .iter()
1714 .filter(|t| !replaced.contains(&t.path))
1715 .map(|t| (t.path.clone(), t.argv.clone()))
1716 .collect();
1717 let mut answer = answer;
1719 if let Some(files) = answer["files"].as_array_mut() {
1720 files.retain(|f| {
1721 only.is_none_or(|s| {
1722 f["scenarios"]
1723 .as_array()
1724 .is_some_and(|c| c.iter().any(|v| v.as_str() == Some(s)))
1725 })
1726 });
1727 }
1728 let (new, discarded) = keep(ctx, &bench, &answer, req, &keep_paths);
1729 if new.is_empty() {
1730 return Err(failed(format!(
1731 "the test author wrote no usable test{}",
1732 discarded
1733 .first()
1734 .map(|x| format!(" ({} {})", x.path, x.why))
1735 .unwrap_or_default()
1736 )));
1737 }
1738 d.tests.retain(|t| !replaced.contains(&t.path));
1739 contents.retain(|(p, _)| !replaced.contains(p));
1740 d.discarded.extend(discarded);
1741 stage(options, &req.key, "checking_red", 1, 1);
1742 let plan: Vec<(String, String, Vec<String>)> = new
1744 .iter()
1745 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1746 .collect();
1747 let classes = bench.red(&plan).map_err(failed)?;
1748 let mut written = Vec::new();
1749 for ((path, content, cited, argv), class) in new.into_iter().zip(classes) {
1750 let red_class = match class {
1751 Ok(c) => c,
1752 Err(why) => {
1753 d.discarded.push(OracleDiscarded {
1754 path,
1755 why: why.to_owned(),
1756 });
1757 continue;
1758 }
1759 };
1760 d.tests.push(DraftTest {
1761 sha256: digest_of(&content),
1762 scenarios: cited,
1763 argv,
1764 red_class,
1765 authored_by: AuthoredBy::Model,
1766 edited_by: None,
1767 path: path.clone(),
1768 });
1769 contents.push((path.clone(), content));
1770 written.push(path);
1771 }
1772 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1773 Ok((d, written))
1774}
1775
1776fn again(
1777 ctx: &Ctx,
1778 req: &OracleRequirement,
1779 r: &Request<'_>,
1780 _by: &str,
1781 provider: Option<Arc<dyn ModelProvider>>,
1782 options: &crate::RunOptions,
1783) -> Outcome {
1784 let Some(path) = r.path else {
1785 return Err(usage("name the test to write again (--again PATH)"));
1786 };
1787 let comment = r
1788 .text
1789 .map(str::trim)
1790 .filter(|t| !t.is_empty())
1791 .unwrap_or("write it again");
1792 let (d, contents) = existing(ctx, req)?;
1793 let Some(content) = contents
1794 .iter()
1795 .find(|(p, _)| p == path)
1796 .map(|(_, c)| c.clone())
1797 else {
1798 return Err(usage(&format!("{} has no test {path}", req.key)));
1799 };
1800 let (d, written) = rewrite(
1801 ctx,
1802 req,
1803 d,
1804 contents,
1805 &[path.to_owned()],
1806 format!("Comment on `{path}`: {comment}"),
1807 Some((path, &content)),
1808 None,
1809 provider,
1810 options,
1811 )?;
1812 let red = d
1813 .tests
1814 .iter()
1815 .filter(|t| written.contains(&t.path) && t.red_class.is_red())
1816 .count();
1817 Ok(format!(
1818 "wrote {path} of {} again: {}",
1819 req.key,
1820 if written.is_empty() {
1821 "nothing kept — its command is red without it, so it decides nothing".to_owned()
1822 } else if red == written.len() {
1823 "red".to_owned()
1824 } else {
1825 "not red — no evidence, it cannot be frozen".to_owned()
1826 }
1827 ))
1828}
1829
1830fn reading(
1831 ctx: &Ctx,
1832 req: &OracleRequirement,
1833 r: &Request<'_>,
1834 by: &str,
1835 provider: Option<Arc<dyn ModelProvider>>,
1836 options: &crate::RunOptions,
1837) -> Outcome {
1838 let (Some(scenario), Some(choice)) = (r.scenario, r.reading) else {
1839 return Err(usage(
1840 "name the reading and the scenario (--reading A|B US1-2)",
1841 ));
1842 };
1843 let (mut d, contents) = existing(ctx, req)?;
1844 let Some(item) = d.ambiguities.iter_mut().find(|a| a.scenario == scenario) else {
1845 return Err(usage(&format!(
1846 "{} has no ambiguity on {scenario}",
1847 req.key
1848 )));
1849 };
1850 item.resolution = Some(Resolution {
1851 reading: choice,
1852 by: by.to_owned(),
1853 at: now(),
1854 });
1855 let meant = if choice == Reading::A {
1856 item.a.clone()
1857 } else {
1858 item.b.clone()
1859 };
1860 if choice == Reading::A {
1861 d.notes.push(format!(
1862 "For {scenario} the person means reading A: given {}, expect {}",
1863 meant.given, meant.expect
1864 ));
1865 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1866 return Ok(format!(
1867 "{scenario} of {}: reading A is meant — the tests stay",
1868 req.key
1869 ));
1870 }
1871 let replaced: Vec<String> = d
1872 .tests
1873 .iter()
1874 .filter(|t| t.scenarios.iter().any(|s| s == scenario))
1875 .map(|t| t.path.clone())
1876 .collect();
1877 let (_, written) = rewrite(
1878 ctx,
1879 req,
1880 d,
1881 contents,
1882 &replaced,
1883 format!(
1884 "For {scenario} the person means reading B: given {}, expect {}",
1885 meant.given, meant.expect
1886 ),
1887 None,
1888 Some(scenario),
1889 provider,
1890 options,
1891 )?;
1892 Ok(format!(
1893 "{scenario} of {}: reading B is meant — wrote {} anew",
1894 req.key,
1895 written.join(", ")
1896 ))
1897}
1898
1899#[derive(Debug, Clone)]
1902pub struct Freezable {
1903 pub req: OracleRequirement,
1905 pub draft: Draft,
1907 pub contents: Vec<(String, String)>,
1909 pub manifest: String,
1911}
1912
1913pub fn freezable(
1916 project_state: &Path,
1917 unit: &str,
1918 requirements: &[OracleRequirement],
1919) -> Result<Vec<Freezable>, Tampered> {
1920 let mut out = Vec::new();
1921 for r in requirements {
1922 let Some((draft, contents)) = load(project_state, unit, &r.key)? else {
1923 continue;
1924 };
1925 if draft.requirement.sha256 != r.digest() || draft.open_ambiguities().next().is_some() {
1926 continue;
1927 }
1928 let Some(manifest) = draft.manifest() else {
1929 continue;
1930 };
1931 let red: Vec<String> = draft.red().map(|t| t.path.clone()).collect();
1932 out.push(Freezable {
1933 req: r.clone(),
1934 contents: contents
1935 .into_iter()
1936 .filter(|(p, _)| red.contains(p))
1937 .collect(),
1938 draft,
1939 manifest,
1940 });
1941 }
1942 Ok(out)
1943}
1944
1945pub fn freeze(
1949 project_state: &Path,
1950 unit: &str,
1951 f: &Freezable,
1952 approval_digest: &str,
1953 decided: Decided,
1954) -> Result<PreFrozenAcceptance, String> {
1955 let sealed = match &f.draft.sealed {
1956 Some(s) => {
1957 crate::held_out::open_sealed(project_state, unit, s)
1958 .map_err(|t| format!("{}'s held-out set was tampered with: {}", f.req.key, t.0))?;
1959 Some(s.clone())
1960 }
1961 None => None,
1962 };
1963 let red: Vec<&DraftTest> = f.draft.red().collect();
1964 let record = PreFrozenAcceptance {
1965 unit: unit.to_owned(),
1966 requirement: f.req.key.clone(),
1967 wording_digest: f.draft.requirement.sha256.clone(),
1968 approval_digest: approval_digest.to_owned(),
1969 visible: red
1970 .iter()
1971 .map(|t| VisibleTest {
1972 path: t.path.clone(),
1973 sha256: t.sha256.clone(),
1974 cites: t.scenarios.clone(),
1975 })
1976 .collect(),
1977 held_out_sealed: sealed,
1978 producer: Producer::Review,
1979 decided,
1980 };
1981 crate::pre_frozen::store(project_state, &record, &f.contents)?;
1982 let frozen = Frozen {
1983 requirement_sha256: f.draft.requirement.sha256.clone(),
1984 wording: format!(
1985 "{}\n{}",
1986 f.draft.requirement.title, f.draft.requirement.text
1987 ),
1988 manifest_sha256: f.manifest.clone(),
1989 tests: red
1990 .iter()
1991 .map(|t| FrozenTest {
1992 path: t.path.clone(),
1993 sha256: t.sha256.clone(),
1994 argv: t.argv.clone(),
1995 scenarios: t.scenarios.clone(),
1996 })
1997 .collect(),
1998 };
1999 let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
2000 write_atomic(
2001 &dir(project_state, unit, &f.req.key).join("frozen.json"),
2002 &text,
2003 )?;
2004 Ok(record)
2005}
2006
2007#[derive(Debug)]
2009pub(crate) enum TaskReuse {
2010 Ready {
2012 record: Box<PreFrozenAcceptance>,
2014 files: Vec<(String, String)>,
2016 tests: Vec<crate::pipeline::ReusedTest>,
2018 manifest: String,
2020 },
2021 Reused {
2023 req: String,
2025 },
2026 Elsewhere {
2028 req: String,
2030 },
2031 Lapsed {
2033 req: String,
2035 frozen: String,
2037 current: String,
2039 },
2040 Tampered(String),
2042 Unreadable(String),
2044}
2045
2046pub(crate) fn for_task(
2051 project_state: &Path,
2052 project: &Path,
2053 wt_root: &Path,
2054 format: &dyn Adapter,
2055 snapshot: &ProjectSnapshot,
2056 scenarios: &[speccoder_core::spec::ScenarioRef],
2057) -> Option<TaskReuse> {
2058 let f = snapshot.feature.as_ref()?;
2059 let unit = f.feature.name.as_str();
2060 let (entries, approval) = match crate::review::oracle_entries(project, &f.feature.dir) {
2061 Ok(x) => x,
2062 Err(e) => return Some(TaskReuse::Unreadable(e)),
2064 };
2065 if entries.is_empty() {
2066 return None;
2067 }
2068 let ids: Vec<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
2069 let requirements = format.oracle_requirements(snapshot);
2070 let (req, entry) = requirements.iter().find_map(|r| {
2071 let e = entries.get(&r.key)?;
2072 r.scenarios
2073 .iter()
2074 .any(|s| ids.contains(&s.id.as_str()))
2075 .then_some((r, e))
2076 })?;
2077 let digest = req.digest();
2078 if entry.requirement_sha256 != digest {
2079 return Some(TaskReuse::Lapsed {
2080 req: req.key.clone(),
2081 frozen: entry.requirement_sha256.clone(),
2082 current: digest,
2083 });
2084 }
2085 let (record, files) = match crate::pre_frozen::load(project_state, unit, &req.key) {
2086 Ok(Some(x)) => x,
2087 Ok(None) => {
2088 return Some(TaskReuse::Elsewhere {
2089 req: req.key.clone(),
2090 });
2091 }
2092 Err(t) => return Some(TaskReuse::Tampered(t.0)),
2093 };
2094 let needle = format!("Acceptance: review manifest={} ", entry.manifest_sha256);
2098 let reused = crate::git::Git::at(wt_root)
2099 .run(&[
2100 "log",
2101 "--format=%H",
2102 "--fixed-strings",
2103 &format!("--grep={needle}"),
2104 "HEAD",
2105 ])
2106 .map(|out| !out.is_empty());
2107 match reused {
2108 Ok(true) => {
2109 return Some(TaskReuse::Reused {
2110 req: req.key.clone(),
2111 });
2112 }
2113 Ok(false) => {}
2114 Err(e) => {
2115 return Some(TaskReuse::Unreadable(format!(
2116 "the worktree's history cannot be read: {}",
2117 e.0
2118 )));
2119 }
2120 }
2121 if !record.is_current(&digest) {
2122 return Some(TaskReuse::Lapsed {
2123 req: req.key.clone(),
2124 frozen: record.wording_digest.clone(),
2125 current: digest,
2126 });
2127 }
2128 let Some(frozen) = load_frozen(project_state, unit, &req.key) else {
2129 return Some(TaskReuse::Tampered(format!(
2130 "{}'s frozen commands are missing from the state directory",
2131 req.key
2132 )));
2133 };
2134 let manifest = manifest_digest(
2135 frozen
2136 .tests
2137 .iter()
2138 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2139 );
2140 let same_files = frozen.tests.len() == record.visible.len()
2141 && frozen.tests.iter().all(|t| {
2142 record
2143 .visible
2144 .iter()
2145 .any(|v| v.path == t.path && v.sha256 == t.sha256)
2146 });
2147 if manifest != entry.manifest_sha256 || manifest != frozen.manifest_sha256 || !same_files {
2148 return Some(TaskReuse::Tampered(format!(
2149 "{}'s frozen tests no longer match the manifest review.json names",
2150 req.key
2151 )));
2152 }
2153 if approval.as_deref() != Some(record.approval_digest.as_str()) {
2156 return Some(TaskReuse::Tampered(format!(
2157 "{}'s frozen set names another approval than review.json{}",
2158 req.key,
2159 if approval.is_none() {
2160 " (which records none)"
2161 } else {
2162 ""
2163 }
2164 )));
2165 }
2166 let sealed = record
2167 .held_out_sealed
2168 .as_ref()
2169 .map(|h| format!("sha256:{}", h.manifest_sha256));
2170 if sealed != entry.held_out_sha256 {
2171 return Some(TaskReuse::Tampered(format!(
2172 "{}'s frozen held-out set is not the one review.json names",
2173 req.key
2174 )));
2175 }
2176 let tests = frozen
2177 .tests
2178 .iter()
2179 .map(|t| crate::pipeline::ReusedTest {
2180 path: t.path.clone(),
2181 cites: t.scenarios.clone(),
2182 argv: t.argv.clone(),
2183 green: false,
2184 })
2185 .collect();
2186 Some(TaskReuse::Ready {
2187 record: Box::new(record),
2188 files,
2189 tests,
2190 manifest,
2191 })
2192}
2193
2194pub(crate) fn note_lapse(project_state: &Path, unit: &str, req: &str, frozen: &str) -> bool {
2197 let file = project_state.join(DIR).join(unit).join("lapsed.json");
2198 let mut seen: Vec<String> = std::fs::read_to_string(&file)
2199 .ok()
2200 .and_then(|t| serde_json::from_str(&t).ok())
2201 .unwrap_or_default();
2202 let key = format!("{req}={frozen}");
2203 if seen.contains(&key) {
2204 return false;
2205 }
2206 seen.push(key);
2207 let _ = write_atomic(&file, &serde_json::to_string(&seen).unwrap_or_default());
2208 true
2209}
2210
2211#[must_use]
2213pub fn autopilot_denials(
2214 task: &str,
2215 tool_calls: &[Value],
2216) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2217 tool_calls
2218 .iter()
2219 .enumerate()
2220 .filter(|(_, c)| {
2221 c["decision"] == "deny" && c["reason"].as_str() == Some(tools::policy::AUTOPILOT_DENIED)
2222 })
2223 .map(|(i, c)| {
2224 let object: Option<tools::policy::Object> =
2225 serde_json::from_value(c["object"].clone()).ok();
2226 speccoder_core::oracle::AutopilotDenial {
2227 task: task.to_owned(),
2228 turn: c["turn"]
2229 .as_u64()
2230 .and_then(|n| u32::try_from(n).ok())
2231 .unwrap_or_else(|| u32::try_from(i + 1).unwrap_or(u32::MAX)),
2232 class: object
2233 .as_ref()
2234 .map(|o| o.class().as_str().to_owned())
2235 .unwrap_or_default(),
2236 object: object.as_ref().map_or_else(
2237 || {
2238 c["arguments"]["path"]
2239 .as_str()
2240 .map(str::to_owned)
2241 .or_else(|| {
2242 c["arguments"]["argv"].as_array().map(|a| {
2243 a.iter()
2244 .filter_map(Value::as_str)
2245 .collect::<Vec<_>>()
2246 .join(" ")
2247 })
2248 })
2249 .unwrap_or_else(|| c["tool"].as_str().unwrap_or_default().to_owned())
2250 },
2251 tools::policy::Object::describe,
2252 ),
2253 reason: tools::policy::AUTOPILOT_DENIED.to_owned(),
2254 }
2255 })
2256 .collect()
2257}
2258
2259#[must_use]
2261pub fn last_denials(
2262 project_state: &Path,
2263 unit: &str,
2264) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2265 std::fs::read_to_string(project_state.join(DIR).join(unit).join("autopilot.json"))
2266 .ok()
2267 .and_then(|t| serde_json::from_str(&t).ok())
2268 .unwrap_or_default()
2269}
2270
2271pub(crate) fn record_denials(
2273 project_state: &Path,
2274 unit: &str,
2275 denials: &[speccoder_core::oracle::AutopilotDenial],
2276) {
2277 let file = project_state.join(DIR).join(unit).join("autopilot.json");
2278 if denials.is_empty() {
2279 let _ = std::fs::remove_file(file);
2280 return;
2281 }
2282 let _ = write_atomic(
2283 &file,
2284 &serde_json::to_string_pretty(denials).unwrap_or_default(),
2285 );
2286}
2287
2288#[cfg(test)]
2289mod tests {
2290 use super::*;
2291
2292 fn draft(content: &str) -> Draft {
2293 Draft {
2294 requirement: DraftRequirement {
2295 key: "US1".to_owned(),
2296 title: "Greet".to_owned(),
2297 text: "### User Story 1 - Greet".to_owned(),
2298 sha256: "sha256:w".to_owned(),
2299 },
2300 samples: 2,
2301 tests: vec![DraftTest {
2302 path: "tests/acceptance_us1.rs".to_owned(),
2303 sha256: digest_of(content),
2304 scenarios: vec!["US1-1".to_owned()],
2305 argv: vec!["cargo".to_owned(), "test".to_owned()],
2306 red_class: RedClass::Assertion,
2307 authored_by: AuthoredBy::Model,
2308 edited_by: None,
2309 }],
2310 discarded: Vec::new(),
2311 rejected: Vec::new(),
2312 expectations: Samples::default(),
2313 untestable: Samples::default(),
2314 ambiguities: Vec::new(),
2315 held_out: HeldOutSummary {
2316 state: "absent".to_owned(),
2317 count: 0,
2318 manifest_sha256: None,
2319 reason: None,
2320 },
2321 sealed: None,
2322 notes: Vec::new(),
2323 runs: Vec::new(),
2324 context: Vec::new(),
2325 }
2326 }
2327
2328 #[test]
2329 fn a_draft_round_trips_and_a_changed_test_is_tampering() {
2330 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2331 let d = draft("#[test] fn t() {}");
2332 let files = vec![(
2333 "tests/acceptance_us1.rs".to_owned(),
2334 "#[test] fn t() {}".to_owned(),
2335 )];
2336 store(state.path(), "001-greet", &d, &files).unwrap_or_else(|e| panic!("{e}"));
2337 let (back, contents) = load(state.path(), "001-greet", "US1")
2338 .unwrap_or_else(|e| panic!("{e:?}"))
2339 .unwrap_or_else(|| panic!("stored"));
2340 assert_eq!(back, d);
2341 assert_eq!(contents, files);
2342 std::fs::write(
2343 dir(state.path(), "001-greet", "US1").join("files/tests/acceptance_us1.rs"),
2344 "changed",
2345 )
2346 .unwrap_or_else(|e| panic!("{e}"));
2347 assert!(load(state.path(), "001-greet", "US1").is_err());
2348 assert!(store(state.path(), "001-greet", &d, &[]).is_err());
2349 assert!(
2350 load(state.path(), "001-greet", "US2")
2351 .unwrap_or_default()
2352 .is_none()
2353 );
2354 }
2355
2356 #[test]
2357 fn the_manifest_names_paths_contents_and_commands() {
2358 let argv = vec!["cargo".to_owned()];
2359 let other = vec!["make".to_owned()];
2360 let a = manifest_digest([("a", "sha256:1", argv.as_slice())]);
2361 assert_eq!(a, manifest_digest([("a", "sha256:1", argv.as_slice())]));
2362 assert_ne!(a, manifest_digest([("a", "sha256:2", argv.as_slice())]));
2363 assert_ne!(a, manifest_digest([("a", "sha256:1", other.as_slice())]));
2364 let both = manifest_digest([("a", "s", argv.as_slice()), ("b", "s", argv.as_slice())]);
2365 assert_eq!(
2366 both,
2367 manifest_digest([("b", "s", argv.as_slice()), ("a", "s", argv.as_slice())])
2368 );
2369 let mut d = draft("x");
2370 assert!(d.manifest().is_some());
2371 d.tests[0].red_class = RedClass::Passed;
2372 assert!(d.manifest().is_none());
2373 assert_eq!(slug("cap: a / b"), "cap__a___b");
2374 }
2375
2376 #[test]
2377 fn a_lapse_is_noted_once() {
2378 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2379 assert!(note_lapse(state.path(), "u", "US1", "sha256:a"));
2380 assert!(!note_lapse(state.path(), "u", "US1", "sha256:a"));
2381 assert!(note_lapse(state.path(), "u", "US1", "sha256:b"));
2382 }
2383}