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}
961
962impl<'a> Author<'a> {
963 fn open(
964 ctx: &'a Ctx,
965 req: &OracleRequirement,
966 provider: Option<Arc<dyn ModelProvider>>,
967 options: &crate::RunOptions,
968 ) -> Result<Self, Box<CommandResult>> {
969 let (Some(provider), Some(model)) = (provider, ctx.model.as_ref()) else {
970 return Err(Box::new(refuse(
971 CommandStatus::ConfigError,
972 "CONFIG",
973 "the test author needs a model ([model] in speccoder.toml)",
974 )));
975 };
976 let rt = crate::pipeline::tokio_runtime().map_err(failed)?;
977 let plane = rt
978 .block_on(async {
979 substrate::Plane::open_with(
980 &ctx.plane_dir,
981 model,
982 provider,
983 None,
984 substrate::Hooks {
985 format: Some(ctx.format),
986 progress: options.progress.clone(),
987 ..substrate::Hooks::default()
988 },
989 )
990 })
991 .map_err(|e| failed(e.0))?;
992 let unit_dir = ctx.root.join(&ctx.dir);
993 let [constitution, _, plan, _] = ctx.format.convergence_texts(&ctx.root, &unit_dir);
994 let bundle = json!({
995 "feature": ctx.feature,
996 "constitution": constitution,
997 "plan": plan,
998 "story": req.text,
999 "task": {
1000 "id": format!("req-{}", req.key),
1001 "title": format!("Acceptance tests for {}: {}", req.key, req.title),
1002 "phase": "Review",
1003 "subsection": "",
1004 "story": req.key,
1005 "parallel": false,
1006 },
1007 "dependencies": [],
1008 });
1009 let instructions =
1010 crate::instructions::collect(&ctx.root, &ctx.root, &[], &ctx.config.instructions);
1011 Ok(Self {
1012 ctx,
1013 rt,
1014 plane,
1015 bundle,
1016 instructions,
1017 })
1018 }
1019
1020 fn context(
1022 &self,
1023 req: &OracleRequirement,
1024 notes: &[String],
1025 ) -> speccoder_core::context::ContextManifest {
1026 let mut m = crate::context::Manifest::new();
1027 m.push(
1028 ContextKind::Story,
1029 "person",
1030 "/requirement",
1031 req.text.as_bytes(),
1032 );
1033 for s in &req.scenarios {
1034 m.push(
1035 ContextKind::Scenario,
1036 "person",
1037 &format!("/scenarios/{}", s.id),
1038 s.text.as_bytes(),
1039 );
1040 }
1041 m.push(
1042 ContextKind::Constitution,
1043 "worktree",
1044 "/bundle/constitution",
1045 self.bundle["constitution"]
1046 .as_str()
1047 .unwrap_or_default()
1048 .as_bytes(),
1049 );
1050 m.push(
1051 ContextKind::Plan,
1052 "worktree",
1053 "/bundle/plan",
1054 self.bundle["plan"].as_str().unwrap_or_default().as_bytes(),
1055 );
1056 for (i, n) in notes.iter().enumerate() {
1057 let kind = if n.starts_with("Rejected `") {
1058 ContextKind::Rejection
1059 } else if n.starts_with("Comment ") {
1060 ContextKind::Comment
1061 } else {
1062 ContextKind::Note
1063 };
1064 m.push(kind, "person", &format!("/notes/{i}"), n.as_bytes());
1065 }
1066 m.finish()
1067 }
1068
1069 #[allow(clippy::too_many_arguments)]
1071 fn call(
1072 &self,
1073 req: &OracleRequirement,
1074 files: &[String],
1075 manifest: &str,
1076 notes: &[String],
1077 again: Option<(&str, &str)>,
1078 sample: u8,
1079 ) -> Result<(Value, String, Vec<ContextEntry>), String> {
1080 let context = self.context(req, notes);
1081 let input = substrate::acceptance::review_input(&substrate::acceptance::ReviewParts {
1082 bundle: self.bundle.clone(),
1083 requirement: json!({ "key": req.key, "title": req.title, "text": req.text }),
1084 scenarios: &req.scenarios,
1085 files,
1086 manifest,
1087 notes,
1088 again,
1089 instructions: &self.instructions,
1090 context: &context,
1091 sample,
1092 });
1093 let task = format!("req-{}", req.key);
1094 let outcome = self
1095 .rt
1096 .block_on(self.plane.acceptance(&self.ctx.feature, &task, input))
1097 .map_err(|e| format!("the test author failed: {}", e.0))?;
1098 let output = outcome
1099 .output
1100 .as_ref()
1101 .map(|o| o.peek().clone())
1102 .unwrap_or_default();
1103 let answer = output["answer"].clone();
1104 if !matches!(outcome.status, agentplane::prelude::RunStatus::Succeeded) || answer.is_null()
1105 {
1106 let status = format!("{:?}", outcome.status);
1107 return Err(substrate::provider_refusal(&status).map_or_else(
1108 || {
1109 format!(
1110 "the test author did not answer: {}",
1111 outcome.status.as_str()
1112 )
1113 },
1114 |r| format!("the model provider refused the request: {r}"),
1115 ));
1116 }
1117 Ok((answer, outcome.run_id.to_string(), context.entries))
1118 }
1119}
1120
1121fn expectations(
1127 answer: &Value,
1128 req: &OracleRequirement,
1129) -> Result<(Vec<Expectation>, Vec<Untestable>), String> {
1130 let canonical = |id: &str| {
1131 req.scenarios
1132 .iter()
1133 .find(|s| s.id.eq_ignore_ascii_case(id.trim()))
1134 .map(|s| s.id.clone())
1135 .ok_or_else(|| {
1136 format!(
1137 "the test author's expectations name `{id}`, which {} does not have",
1138 req.key
1139 )
1140 })
1141 };
1142 let list = |field: &str| -> Result<Value, String> {
1143 match &answer[field] {
1144 Value::Null => Ok(Value::Array(Vec::new())),
1145 v @ Value::Array(_) => Ok(v.clone()),
1146 _ => Err(format!("the test author's `{field}` is not a list")),
1147 }
1148 };
1149 let mut e: Vec<Expectation> = serde_json::from_value(list("expectations")?)
1150 .map_err(|x| format!("the test author's expectations cannot be read: {x}"))?;
1151 let mut u: Vec<Untestable> = serde_json::from_value(list("untestable")?)
1152 .map_err(|x| format!("the test author's untestable list cannot be read: {x}"))?;
1153 for x in &mut e {
1154 x.scenario = canonical(&x.scenario)?;
1155 }
1156 for x in &mut u {
1157 x.scenario = canonical(&x.scenario)?;
1158 }
1159 if e.is_empty() && u.is_empty() {
1160 return Err(
1161 "the test author stated no expectation for any scenario: the readings cannot be compared"
1162 .to_owned(),
1163 );
1164 }
1165 Ok((e, u))
1166}
1167
1168struct Bench {
1171 scratch: worktree::Scratch,
1172 project: PathBuf,
1173 runner: sandbox::Runner,
1174 tmp: PathBuf,
1175}
1176
1177impl Bench {
1178 fn open(ctx: &Ctx) -> Result<Self, Box<CommandResult>> {
1179 let tmp = ctx.plane_dir.join("tmp");
1180 let scratch = worktree::scratch(&ctx.root, &tmp)
1181 .map_err(|e| failed(format!("the scratch worktree could not be made: {}", e.0)))?;
1182 let project = project_in(&ctx.root, &scratch.path);
1183 let (runner, notice) = sandbox::runner(&scratch.path, &ctx.sandbox)
1184 .map_err(|e| Box::new(refuse(CommandStatus::ConfigError, "CONFIG", &e)))?;
1185 if !runner.is_sandboxed() {
1188 return Err(Box::new(
1189 refuse(
1190 CommandStatus::ConfigError,
1191 "SANDBOX-UNAVAILABLE",
1192 &format!(
1193 "the red check runs model-written tests and needs the OS sandbox: {}",
1194 notice.unwrap_or_else(|| "commands would run unsandboxed".to_owned())
1195 ),
1196 )
1197 .with_fix(vec![
1198 "set [sandbox] mode = \"auto\" on a machine with a sandbox backend (speccoder doctor)".to_owned(),
1199 ]),
1200 ));
1201 }
1202 Ok(Self {
1203 scratch,
1204 project,
1205 runner,
1206 tmp,
1207 })
1208 }
1209
1210 fn files(&self) -> (Vec<String>, String) {
1211 let files: Vec<String> = worktree::checkpoint(&self.scratch.path, &self.tmp)
1212 .and_then(|c| worktree::files(&self.scratch.path, &c))
1213 .unwrap_or_default()
1214 .into_iter()
1215 .take(400)
1216 .collect();
1217 let manifest = crate::pipeline::MANIFESTS
1218 .iter()
1219 .find_map(|m| {
1220 std::fs::read_to_string(self.project.join(m))
1221 .ok()
1222 .map(|t| format!("{m}:\n{t}"))
1223 })
1224 .map(|t| t.chars().take(8192).collect::<String>())
1225 .unwrap_or_default();
1226 (files, manifest)
1227 }
1228
1229 fn red(
1235 &self,
1236 tests: &[(String, String, Vec<String>)],
1237 ) -> Result<Vec<Result<RedClass, &'static str>>, String> {
1238 let mut out = Vec::new();
1239 for (path, content, argv) in tests {
1240 let full = self.project.join(path);
1241 let _ = std::fs::remove_file(&full);
1242 let run = || crate::pipeline::acceptance_run(&self.project, argv, &self.runner);
1243 let (bare, summary) = run();
1244 if classify_red(&bare, summary.as_ref()).is_red() {
1245 out.push(Err("red_without_it"));
1246 continue;
1247 }
1248 if let Some(parent) = full.parent() {
1249 std::fs::create_dir_all(parent).map_err(|e| format!("{path}: {e}"))?;
1250 }
1251 std::fs::write(&full, content).map_err(|e| format!("{path}: {e}"))?;
1252 let (first, summary) = run();
1253 let class = classify_red(&first, summary.as_ref());
1254 let class = if !class.is_red() {
1255 class
1256 } else {
1257 let (second, summary) = run();
1258 if classify_red(&second, summary.as_ref()).is_red() {
1259 class
1260 } else {
1261 RedClass::Flaky
1262 }
1263 };
1264 std::fs::remove_file(&full).map_err(|e| format!("{path}: {e}"))?;
1265 out.push(Ok(class));
1266 }
1267 Ok(out)
1268 }
1269}
1270
1271fn keep(
1273 ctx: &Ctx,
1274 bench: &Bench,
1275 answer: &Value,
1276 req: &OracleRequirement,
1277 taken: &[(String, Vec<String>)],
1278) -> (Vec<Kept>, Vec<OracleDiscarded>) {
1279 let (valid, refused) =
1280 crate::pipeline::validate_answer(answer, &bench.project, &ctx.policy, &req.scenarios);
1281 let mut discarded: Vec<OracleDiscarded> = refused
1282 .into_iter()
1283 .map(|r| OracleDiscarded {
1284 path: r.path,
1285 why: r.why.to_owned(),
1286 })
1287 .collect();
1288 let set_argv: Vec<String> = answer["argv"]
1289 .as_array()
1290 .into_iter()
1291 .flatten()
1292 .filter_map(|v| v.as_str().map(str::to_owned))
1293 .collect();
1294 let mut kept = Vec::new();
1295 for f in valid {
1296 let mut discard = |why: &str| {
1297 discarded.push(OracleDiscarded {
1298 path: f.path.clone(),
1299 why: why.to_owned(),
1300 });
1301 };
1302 if f.cited.is_empty() {
1303 discard("cites_no_scenario");
1304 continue;
1305 }
1306 if taken.iter().any(|(p, _)| *p == f.path)
1307 || kept.iter().any(|(p, ..): &(String, _, _, _)| *p == f.path)
1308 {
1309 discard("exists");
1310 continue;
1311 }
1312 let argv = if f.argv.is_empty() {
1313 set_argv.clone()
1314 } else {
1315 f.argv.clone()
1316 };
1317 if taken.iter().any(|(_, a)| *a == argv)
1320 || kept
1321 .iter()
1322 .any(|(.., a): &(_, _, _, Vec<String>)| *a == argv)
1323 {
1324 discard("command_shared");
1325 continue;
1326 }
1327 if argv.is_empty()
1328 || ctx
1329 .policy
1330 .classify("run_command", &json!({ "argv": argv }), None)
1331 != tools::policy::Decision::Allow
1332 {
1333 discard("command_not_allowed");
1334 continue;
1335 }
1336 if kept.len() + taken.len() >= LIMIT {
1337 discard("limit");
1338 continue;
1339 }
1340 kept.push((f.path, f.content, f.cited, argv));
1341 }
1342 (kept, discarded)
1343}
1344
1345fn write(
1348 ctx: &Ctx,
1349 req: &OracleRequirement,
1350 provider: Option<Arc<dyn ModelProvider>>,
1351 options: &crate::RunOptions,
1352) -> Outcome {
1353 let samples = ctx.config.oracle_samples.clamp(1, 2);
1354 let author = Author::open(ctx, req, provider, options)?;
1355 let bench = Bench::open(ctx)?;
1356 let (files, manifest) = bench.files();
1357 let earlier = load(&ctx.plane_dir, &ctx.feature, &req.key).map_err(|t| {
1360 Box::new(
1361 refuse(
1362 CommandStatus::NeedsHuman,
1363 "ORACLE-TAMPERED",
1364 &format!(
1365 "{}: writing anew would lose the rejections and notes it holds",
1366 t.0
1367 ),
1368 )
1369 .with_fix(vec![format!(
1370 "→ restore it, or remove {} to start {}'s tests over",
1371 dir(&ctx.plane_dir, &ctx.feature, &req.key).display(),
1372 req.key
1373 )]),
1374 )
1375 })?;
1376 let (rejected, mut notes) = earlier
1377 .as_ref()
1378 .filter(|(d, _)| d.requirement.sha256 == req.digest())
1379 .map(|(d, _)| (d.rejected.clone(), d.notes.clone()))
1380 .unwrap_or_default();
1381 notes.dedup();
1382 let told: Vec<String> = rejected
1383 .iter()
1384 .map(|r| format!("Rejected `{}`: {}", r.path, r.reason))
1385 .chain(notes.iter().cloned())
1386 .collect();
1387 let of = u32::from(samples);
1388 stage(options, &req.key, "writing", 1, of);
1389 let (first, run1, mut context) = author
1390 .call(req, &files, &manifest, &told, None, 1)
1391 .map_err(failed)?;
1392 let read = |answer: &Value| {
1395 let got = expectations(answer, req);
1396 if samples >= 2 {
1397 got.map_err(failed)
1398 } else {
1399 Ok(got.unwrap_or_default())
1400 }
1401 };
1402 let (e1, u1) = read(&first)?;
1403 let mut runs = vec![run1];
1404 let (mut e2, mut u2) = (Vec::new(), Vec::new());
1405 if samples >= 2 {
1406 stage(options, &req.key, "writing", 2, of);
1407 let (second, run2, more) = author
1408 .call(req, &files, &manifest, &told, None, 2)
1409 .map_err(failed)?;
1410 (e2, u2) = read(&second)?;
1411 runs.push(run2);
1412 for c in more {
1413 if !context.contains(&c) {
1414 context.push(c);
1415 }
1416 }
1417 stage(options, &req.key, "comparing", 1, 1);
1418 }
1419 let ambiguities = if samples >= 2 {
1420 speccoder_core::oracle::compare(&e1, &u1, &e2, &u2)
1421 } else {
1422 Vec::new()
1423 };
1424 let (kept, mut discarded) = keep(ctx, &bench, &first, req, &[]);
1425 stage(options, &req.key, "checking_red", 1, 1);
1426 let plan: Vec<(String, String, Vec<String>)> = kept
1427 .iter()
1428 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1429 .collect();
1430 let classes = bench.red(&plan).map_err(failed)?;
1431 let mut tests: Vec<DraftTest> = Vec::new();
1432 let mut contents: Vec<(String, String)> = Vec::new();
1433 for ((path, content, cited, argv), class) in kept.into_iter().zip(classes) {
1434 match class {
1435 Ok(red_class) => {
1436 tests.push(DraftTest {
1437 path: path.clone(),
1438 sha256: digest_of(&content),
1439 scenarios: cited,
1440 argv,
1441 red_class,
1442 authored_by: AuthoredBy::Model,
1443 edited_by: None,
1444 });
1445 contents.push((path, content));
1446 }
1447 Err(why) => discarded.push(OracleDiscarded {
1448 path,
1449 why: why.to_owned(),
1450 }),
1451 }
1452 }
1453 let (held_out, sealed) = held_out(ctx, &author, &bench, req, &tests, options);
1454 let draft = Draft {
1455 requirement: DraftRequirement {
1456 key: req.key.clone(),
1457 title: req.title.clone(),
1458 text: req.text.clone(),
1459 sha256: req.digest(),
1460 },
1461 samples,
1462 tests,
1463 discarded,
1464 rejected,
1465 expectations: Samples {
1466 sample1: e1,
1467 sample2: e2,
1468 },
1469 untestable: Samples {
1470 sample1: u1,
1471 sample2: u2,
1472 },
1473 ambiguities,
1474 held_out,
1475 sealed,
1476 notes,
1477 runs,
1478 context,
1479 };
1480 store(&ctx.plane_dir, &ctx.feature, &draft, &contents).map_err(failed)?;
1481 let red = draft.red().count();
1482 let open = draft.open_ambiguities().count();
1483 Ok(format!(
1484 "wrote {} test(s) for {}: {red} red{}{}",
1485 draft.tests.len(),
1486 req.key,
1487 if draft.held_out.state == "kept" {
1488 format!(", {} held out", draft.held_out.count)
1489 } else {
1490 String::new()
1491 },
1492 if open > 0 {
1493 format!(
1494 "; {} — {open} scenario(s) read two ways: choose a reading or reword (`speccoder review tests {} --reading A|B <scenario>`)",
1495 req.key, req.key
1496 )
1497 } else {
1498 String::new()
1499 }
1500 ))
1501}
1502
1503fn held_out(
1507 ctx: &Ctx,
1508 author: &Author<'_>,
1509 bench: &Bench,
1510 req: &OracleRequirement,
1511 tests: &[DraftTest],
1512 options: &crate::RunOptions,
1513) -> (HeldOutSummary, Option<SealedRef>) {
1514 let summary = |state: &str, reason: Option<&str>| HeldOutSummary {
1515 state: state.to_owned(),
1516 count: 0,
1517 manifest_sha256: None,
1518 reason: reason.map(str::to_owned),
1519 };
1520 if !ctx.config.held_out {
1521 return (summary("off", None), None);
1522 }
1523 let red: Vec<&DraftTest> = tests.iter().filter(|t| t.red_class.is_red()).collect();
1524 if red.is_empty() {
1525 return (summary("absent", Some("not_red")), None);
1526 }
1527 stage(options, &req.key, "held_out", 1, 1);
1528 let visible = crate::pipeline::PreparedAcceptance {
1529 report: json!({ "files": red.iter().map(|t| t.path.clone()).collect::<Vec<_>>() }),
1530 argv: red.first().map(|t| t.argv.clone()),
1531 more_argv: Vec::new(),
1532 red: true,
1533 red_class: Some(RedClass::Assertion),
1534 held_out: None,
1535 generation_failed: None,
1536 };
1537 let task = format!("req-{}", req.key);
1538 let store = crate::held_out::sealed_dir(&ctx.plane_dir, &ctx.feature, &req.key);
1539 let (report, sha) = crate::pipeline::prepare_held_out(
1540 &crate::pipeline::AcceptanceSetup {
1541 rt: &author.rt,
1542 plane: &author.plane,
1543 project: &bench.project,
1544 worktree: &bench.scratch.path,
1545 tmp: &bench.tmp,
1546 policy: &ctx.policy,
1547 feature: &ctx.feature,
1548 task: &task,
1549 bundle: author.bundle.clone(),
1550 scenarios: &req.scenarios,
1551 instructions: &author.instructions,
1552 runner: &bench.runner,
1553 },
1554 &visible,
1555 &req.scenarios,
1556 &store,
1557 false,
1558 );
1559 match sha {
1560 Some(sha) => {
1561 let count = report["kept"]
1562 .as_u64()
1563 .and_then(|n| usize::try_from(n).ok())
1564 .unwrap_or(0);
1565 (
1566 HeldOutSummary {
1567 state: "kept".to_owned(),
1568 count,
1569 manifest_sha256: Some(format!("sha256:{sha}")),
1570 reason: None,
1571 },
1572 Some(SealedRef {
1573 key: req.key.clone(),
1574 manifest_sha256: sha,
1575 count,
1576 }),
1577 )
1578 }
1579 None => (
1580 summary(
1581 "absent",
1582 Some(report["reason"].as_str().unwrap_or("none_kept")),
1583 ),
1584 None,
1585 ),
1586 }
1587}
1588
1589fn reject(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1590 let Some(path) = r.path else {
1591 return Err(usage("name the test to reject (--reject PATH)"));
1592 };
1593 let Some(reason) = r.text.map(str::trim).filter(|t| !t.is_empty()) else {
1594 return Err(usage(
1595 "a rejection needs a reason: why is this test wrong? (--reason \"…\")",
1596 ));
1597 };
1598 let (mut d, contents) = existing(ctx, req)?;
1599 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1600 return Err(usage(&format!("{} has no test {path}", req.key)));
1601 };
1602 d.tests.remove(at);
1603 d.rejected.push(OracleRejected {
1604 path: path.to_owned(),
1605 reason: reason.to_owned(),
1606 by: by.to_owned(),
1607 at: now(),
1608 });
1609 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1610 Ok(format!(
1611 "rejected {path} of {}: \"{reason}\" — later author calls for {} are told",
1612 req.key, req.key
1613 ))
1614}
1615
1616fn edit(ctx: &Ctx, req: &OracleRequirement, r: &Request<'_>, by: &str) -> Outcome {
1617 let Some(path) = r.path else {
1618 return Err(usage("name the test to edit (--edit PATH)"));
1619 };
1620 let Some(content) = r.text.filter(|t| !t.trim().is_empty()) else {
1621 return Err(usage("an edit needs the test's new content (--from FILE)"));
1622 };
1623 let (mut d, mut contents) = existing(ctx, req)?;
1624 let Some(at) = d.tests.iter().position(|t| t.path == path) else {
1625 return Err(usage(&format!("{} has no test {path}", req.key)));
1626 };
1627 let bench = Bench::open(ctx)?;
1628 let plan = [(
1629 path.to_owned(),
1630 content.to_owned(),
1631 d.tests[at].argv.clone(),
1632 )];
1633 let checked = bench
1634 .red(&plan)
1635 .map_err(failed)?
1636 .pop()
1637 .unwrap_or(Ok(RedClass::Unclassified));
1638 let class = checked.unwrap_or(RedClass::Unclassified);
1640 let t = &mut d.tests[at];
1641 t.sha256 = digest_of(content);
1642 t.red_class = class;
1643 t.authored_by = AuthoredBy::Person;
1644 t.edited_by = Some(by.to_owned());
1645 if let Some(c) = contents.iter_mut().find(|(p, _)| p == path) {
1646 c.1 = content.to_owned();
1647 }
1648 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1649 Ok(format!(
1650 "edited {path} of {}: {}",
1651 req.key,
1652 if class.is_red() {
1653 format!("red ({})", crate::pipeline::class_word(class))
1654 } else if checked.is_err() {
1655 "its command is red without it — it decides nothing about this test, which cannot be frozen".to_owned()
1656 } else {
1657 format!(
1658 "not red ({}) — no evidence, it cannot be frozen",
1659 crate::pipeline::class_word(class)
1660 )
1661 }
1662 ))
1663}
1664
1665#[allow(clippy::too_many_arguments)]
1668fn rewrite(
1669 ctx: &Ctx,
1670 req: &OracleRequirement,
1671 mut d: Draft,
1672 mut contents: Vec<(String, String)>,
1673 replaced: &[String],
1674 note: String,
1675 again: Option<(&str, &str)>,
1676 only: Option<&str>,
1677 provider: Option<Arc<dyn ModelProvider>>,
1678 options: &crate::RunOptions,
1679) -> Result<(Draft, Vec<String>), Box<CommandResult>> {
1680 let author = Author::open(ctx, req, provider, options)?;
1681 let bench = Bench::open(ctx)?;
1682 let (files, manifest) = bench.files();
1683 d.notes.push(note);
1684 let told = d.all_notes();
1685 stage(options, &req.key, "writing", 1, 1);
1686 let (answer, run, context) = author
1687 .call(req, &files, &manifest, &told, again, 1)
1688 .map_err(failed)?;
1689 d.runs.push(run);
1690 for c in context {
1691 if !d.context.contains(&c) {
1692 d.context.push(c);
1693 }
1694 }
1695 let keep_paths: Vec<(String, Vec<String>)> = d
1696 .tests
1697 .iter()
1698 .filter(|t| !replaced.contains(&t.path))
1699 .map(|t| (t.path.clone(), t.argv.clone()))
1700 .collect();
1701 let mut answer = answer;
1703 if let Some(files) = answer["files"].as_array_mut() {
1704 files.retain(|f| {
1705 only.is_none_or(|s| {
1706 f["scenarios"]
1707 .as_array()
1708 .is_some_and(|c| c.iter().any(|v| v.as_str() == Some(s)))
1709 })
1710 });
1711 }
1712 let (new, discarded) = keep(ctx, &bench, &answer, req, &keep_paths);
1713 if new.is_empty() {
1714 return Err(failed(format!(
1715 "the test author wrote no usable test{}",
1716 discarded
1717 .first()
1718 .map(|x| format!(" ({} {})", x.path, x.why))
1719 .unwrap_or_default()
1720 )));
1721 }
1722 d.tests.retain(|t| !replaced.contains(&t.path));
1723 contents.retain(|(p, _)| !replaced.contains(p));
1724 d.discarded.extend(discarded);
1725 stage(options, &req.key, "checking_red", 1, 1);
1726 let plan: Vec<(String, String, Vec<String>)> = new
1728 .iter()
1729 .map(|(p, c, _, a)| (p.clone(), c.clone(), a.clone()))
1730 .collect();
1731 let classes = bench.red(&plan).map_err(failed)?;
1732 let mut written = Vec::new();
1733 for ((path, content, cited, argv), class) in new.into_iter().zip(classes) {
1734 let red_class = match class {
1735 Ok(c) => c,
1736 Err(why) => {
1737 d.discarded.push(OracleDiscarded {
1738 path,
1739 why: why.to_owned(),
1740 });
1741 continue;
1742 }
1743 };
1744 d.tests.push(DraftTest {
1745 sha256: digest_of(&content),
1746 scenarios: cited,
1747 argv,
1748 red_class,
1749 authored_by: AuthoredBy::Model,
1750 edited_by: None,
1751 path: path.clone(),
1752 });
1753 contents.push((path.clone(), content));
1754 written.push(path);
1755 }
1756 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1757 Ok((d, written))
1758}
1759
1760fn again(
1761 ctx: &Ctx,
1762 req: &OracleRequirement,
1763 r: &Request<'_>,
1764 _by: &str,
1765 provider: Option<Arc<dyn ModelProvider>>,
1766 options: &crate::RunOptions,
1767) -> Outcome {
1768 let Some(path) = r.path else {
1769 return Err(usage("name the test to write again (--again PATH)"));
1770 };
1771 let comment = r
1772 .text
1773 .map(str::trim)
1774 .filter(|t| !t.is_empty())
1775 .unwrap_or("write it again");
1776 let (d, contents) = existing(ctx, req)?;
1777 let Some(content) = contents
1778 .iter()
1779 .find(|(p, _)| p == path)
1780 .map(|(_, c)| c.clone())
1781 else {
1782 return Err(usage(&format!("{} has no test {path}", req.key)));
1783 };
1784 let (d, written) = rewrite(
1785 ctx,
1786 req,
1787 d,
1788 contents,
1789 &[path.to_owned()],
1790 format!("Comment on `{path}`: {comment}"),
1791 Some((path, &content)),
1792 None,
1793 provider,
1794 options,
1795 )?;
1796 let red = d
1797 .tests
1798 .iter()
1799 .filter(|t| written.contains(&t.path) && t.red_class.is_red())
1800 .count();
1801 Ok(format!(
1802 "wrote {path} of {} again: {}",
1803 req.key,
1804 if written.is_empty() {
1805 "nothing kept — its command is red without it, so it decides nothing".to_owned()
1806 } else if red == written.len() {
1807 "red".to_owned()
1808 } else {
1809 "not red — no evidence, it cannot be frozen".to_owned()
1810 }
1811 ))
1812}
1813
1814fn reading(
1815 ctx: &Ctx,
1816 req: &OracleRequirement,
1817 r: &Request<'_>,
1818 by: &str,
1819 provider: Option<Arc<dyn ModelProvider>>,
1820 options: &crate::RunOptions,
1821) -> Outcome {
1822 let (Some(scenario), Some(choice)) = (r.scenario, r.reading) else {
1823 return Err(usage(
1824 "name the reading and the scenario (--reading A|B US1-2)",
1825 ));
1826 };
1827 let (mut d, contents) = existing(ctx, req)?;
1828 let Some(item) = d.ambiguities.iter_mut().find(|a| a.scenario == scenario) else {
1829 return Err(usage(&format!(
1830 "{} has no ambiguity on {scenario}",
1831 req.key
1832 )));
1833 };
1834 item.resolution = Some(Resolution {
1835 reading: choice,
1836 by: by.to_owned(),
1837 at: now(),
1838 });
1839 let meant = if choice == Reading::A {
1840 item.a.clone()
1841 } else {
1842 item.b.clone()
1843 };
1844 if choice == Reading::A {
1845 d.notes.push(format!(
1846 "For {scenario} the person means reading A: given {}, expect {}",
1847 meant.given, meant.expect
1848 ));
1849 store(&ctx.plane_dir, &ctx.feature, &d, &contents).map_err(failed)?;
1850 return Ok(format!(
1851 "{scenario} of {}: reading A is meant — the tests stay",
1852 req.key
1853 ));
1854 }
1855 let replaced: Vec<String> = d
1856 .tests
1857 .iter()
1858 .filter(|t| t.scenarios.iter().any(|s| s == scenario))
1859 .map(|t| t.path.clone())
1860 .collect();
1861 let (_, written) = rewrite(
1862 ctx,
1863 req,
1864 d,
1865 contents,
1866 &replaced,
1867 format!(
1868 "For {scenario} the person means reading B: given {}, expect {}",
1869 meant.given, meant.expect
1870 ),
1871 None,
1872 Some(scenario),
1873 provider,
1874 options,
1875 )?;
1876 Ok(format!(
1877 "{scenario} of {}: reading B is meant — wrote {} anew",
1878 req.key,
1879 written.join(", ")
1880 ))
1881}
1882
1883#[derive(Debug, Clone)]
1886pub struct Freezable {
1887 pub req: OracleRequirement,
1889 pub draft: Draft,
1891 pub contents: Vec<(String, String)>,
1893 pub manifest: String,
1895}
1896
1897pub fn freezable(
1900 project_state: &Path,
1901 unit: &str,
1902 requirements: &[OracleRequirement],
1903) -> Result<Vec<Freezable>, Tampered> {
1904 let mut out = Vec::new();
1905 for r in requirements {
1906 let Some((draft, contents)) = load(project_state, unit, &r.key)? else {
1907 continue;
1908 };
1909 if draft.requirement.sha256 != r.digest() || draft.open_ambiguities().next().is_some() {
1910 continue;
1911 }
1912 let Some(manifest) = draft.manifest() else {
1913 continue;
1914 };
1915 let red: Vec<String> = draft.red().map(|t| t.path.clone()).collect();
1916 out.push(Freezable {
1917 req: r.clone(),
1918 contents: contents
1919 .into_iter()
1920 .filter(|(p, _)| red.contains(p))
1921 .collect(),
1922 draft,
1923 manifest,
1924 });
1925 }
1926 Ok(out)
1927}
1928
1929pub fn freeze(
1933 project_state: &Path,
1934 unit: &str,
1935 f: &Freezable,
1936 approval_digest: &str,
1937 decided: Decided,
1938) -> Result<PreFrozenAcceptance, String> {
1939 let sealed = match &f.draft.sealed {
1940 Some(s) => {
1941 crate::held_out::open_sealed(project_state, unit, s)
1942 .map_err(|t| format!("{}'s held-out set was tampered with: {}", f.req.key, t.0))?;
1943 Some(s.clone())
1944 }
1945 None => None,
1946 };
1947 let red: Vec<&DraftTest> = f.draft.red().collect();
1948 let record = PreFrozenAcceptance {
1949 unit: unit.to_owned(),
1950 requirement: f.req.key.clone(),
1951 wording_digest: f.draft.requirement.sha256.clone(),
1952 approval_digest: approval_digest.to_owned(),
1953 visible: red
1954 .iter()
1955 .map(|t| VisibleTest {
1956 path: t.path.clone(),
1957 sha256: t.sha256.clone(),
1958 cites: t.scenarios.clone(),
1959 })
1960 .collect(),
1961 held_out_sealed: sealed,
1962 producer: Producer::Review,
1963 decided,
1964 };
1965 crate::pre_frozen::store(project_state, &record, &f.contents)?;
1966 let frozen = Frozen {
1967 requirement_sha256: f.draft.requirement.sha256.clone(),
1968 wording: format!(
1969 "{}\n{}",
1970 f.draft.requirement.title, f.draft.requirement.text
1971 ),
1972 manifest_sha256: f.manifest.clone(),
1973 tests: red
1974 .iter()
1975 .map(|t| FrozenTest {
1976 path: t.path.clone(),
1977 sha256: t.sha256.clone(),
1978 argv: t.argv.clone(),
1979 scenarios: t.scenarios.clone(),
1980 })
1981 .collect(),
1982 };
1983 let text = serde_json::to_string_pretty(&frozen).map_err(|e| e.to_string())?;
1984 write_atomic(
1985 &dir(project_state, unit, &f.req.key).join("frozen.json"),
1986 &text,
1987 )?;
1988 Ok(record)
1989}
1990
1991#[derive(Debug)]
1993pub(crate) enum TaskReuse {
1994 Ready {
1996 record: Box<PreFrozenAcceptance>,
1998 files: Vec<(String, String)>,
2000 tests: Vec<crate::pipeline::ReusedTest>,
2002 manifest: String,
2004 },
2005 Reused {
2007 req: String,
2009 },
2010 Elsewhere {
2012 req: String,
2014 },
2015 Lapsed {
2017 req: String,
2019 frozen: String,
2021 current: String,
2023 },
2024 Tampered(String),
2026 Unreadable(String),
2028}
2029
2030pub(crate) fn for_task(
2035 project_state: &Path,
2036 project: &Path,
2037 wt_root: &Path,
2038 format: &dyn Adapter,
2039 snapshot: &ProjectSnapshot,
2040 scenarios: &[speccoder_core::spec::ScenarioRef],
2041) -> Option<TaskReuse> {
2042 let f = snapshot.feature.as_ref()?;
2043 let unit = f.feature.name.as_str();
2044 let (entries, approval) = match crate::review::oracle_entries(project, &f.feature.dir) {
2045 Ok(x) => x,
2046 Err(e) => return Some(TaskReuse::Unreadable(e)),
2048 };
2049 if entries.is_empty() {
2050 return None;
2051 }
2052 let ids: Vec<&str> = scenarios.iter().map(|s| s.id.as_str()).collect();
2053 let requirements = format.oracle_requirements(snapshot);
2054 let (req, entry) = requirements.iter().find_map(|r| {
2055 let e = entries.get(&r.key)?;
2056 r.scenarios
2057 .iter()
2058 .any(|s| ids.contains(&s.id.as_str()))
2059 .then_some((r, e))
2060 })?;
2061 let digest = req.digest();
2062 if entry.requirement_sha256 != digest {
2063 return Some(TaskReuse::Lapsed {
2064 req: req.key.clone(),
2065 frozen: entry.requirement_sha256.clone(),
2066 current: digest,
2067 });
2068 }
2069 let (record, files) = match crate::pre_frozen::load(project_state, unit, &req.key) {
2070 Ok(Some(x)) => x,
2071 Ok(None) => {
2072 return Some(TaskReuse::Elsewhere {
2073 req: req.key.clone(),
2074 });
2075 }
2076 Err(t) => return Some(TaskReuse::Tampered(t.0)),
2077 };
2078 let needle = format!("Acceptance: review manifest={} ", entry.manifest_sha256);
2082 let reused = crate::git::Git::at(wt_root)
2083 .run(&[
2084 "log",
2085 "--format=%H",
2086 "--fixed-strings",
2087 &format!("--grep={needle}"),
2088 "HEAD",
2089 ])
2090 .map(|out| !out.is_empty());
2091 match reused {
2092 Ok(true) => {
2093 return Some(TaskReuse::Reused {
2094 req: req.key.clone(),
2095 });
2096 }
2097 Ok(false) => {}
2098 Err(e) => {
2099 return Some(TaskReuse::Unreadable(format!(
2100 "the worktree's history cannot be read: {}",
2101 e.0
2102 )));
2103 }
2104 }
2105 if !record.is_current(&digest) {
2106 return Some(TaskReuse::Lapsed {
2107 req: req.key.clone(),
2108 frozen: record.wording_digest.clone(),
2109 current: digest,
2110 });
2111 }
2112 let Some(frozen) = load_frozen(project_state, unit, &req.key) else {
2113 return Some(TaskReuse::Tampered(format!(
2114 "{}'s frozen commands are missing from the state directory",
2115 req.key
2116 )));
2117 };
2118 let manifest = manifest_digest(
2119 frozen
2120 .tests
2121 .iter()
2122 .map(|t| (t.path.as_str(), t.sha256.as_str(), t.argv.as_slice())),
2123 );
2124 let same_files = frozen.tests.len() == record.visible.len()
2125 && frozen.tests.iter().all(|t| {
2126 record
2127 .visible
2128 .iter()
2129 .any(|v| v.path == t.path && v.sha256 == t.sha256)
2130 });
2131 if manifest != entry.manifest_sha256 || manifest != frozen.manifest_sha256 || !same_files {
2132 return Some(TaskReuse::Tampered(format!(
2133 "{}'s frozen tests no longer match the manifest review.json names",
2134 req.key
2135 )));
2136 }
2137 if approval.as_deref() != Some(record.approval_digest.as_str()) {
2140 return Some(TaskReuse::Tampered(format!(
2141 "{}'s frozen set names another approval than review.json{}",
2142 req.key,
2143 if approval.is_none() {
2144 " (which records none)"
2145 } else {
2146 ""
2147 }
2148 )));
2149 }
2150 let sealed = record
2151 .held_out_sealed
2152 .as_ref()
2153 .map(|h| format!("sha256:{}", h.manifest_sha256));
2154 if sealed != entry.held_out_sha256 {
2155 return Some(TaskReuse::Tampered(format!(
2156 "{}'s frozen held-out set is not the one review.json names",
2157 req.key
2158 )));
2159 }
2160 let tests = frozen
2161 .tests
2162 .iter()
2163 .map(|t| crate::pipeline::ReusedTest {
2164 path: t.path.clone(),
2165 cites: t.scenarios.clone(),
2166 argv: t.argv.clone(),
2167 green: false,
2168 })
2169 .collect();
2170 Some(TaskReuse::Ready {
2171 record: Box::new(record),
2172 files,
2173 tests,
2174 manifest,
2175 })
2176}
2177
2178pub(crate) fn note_lapse(project_state: &Path, unit: &str, req: &str, frozen: &str) -> bool {
2181 let file = project_state.join(DIR).join(unit).join("lapsed.json");
2182 let mut seen: Vec<String> = std::fs::read_to_string(&file)
2183 .ok()
2184 .and_then(|t| serde_json::from_str(&t).ok())
2185 .unwrap_or_default();
2186 let key = format!("{req}={frozen}");
2187 if seen.contains(&key) {
2188 return false;
2189 }
2190 seen.push(key);
2191 let _ = write_atomic(&file, &serde_json::to_string(&seen).unwrap_or_default());
2192 true
2193}
2194
2195#[must_use]
2197pub fn autopilot_denials(
2198 task: &str,
2199 tool_calls: &[Value],
2200) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2201 tool_calls
2202 .iter()
2203 .enumerate()
2204 .filter(|(_, c)| {
2205 c["decision"] == "deny" && c["reason"].as_str() == Some(tools::policy::AUTOPILOT_DENIED)
2206 })
2207 .map(|(i, c)| {
2208 let object: Option<tools::policy::Object> =
2209 serde_json::from_value(c["object"].clone()).ok();
2210 speccoder_core::oracle::AutopilotDenial {
2211 task: task.to_owned(),
2212 turn: c["turn"]
2213 .as_u64()
2214 .and_then(|n| u32::try_from(n).ok())
2215 .unwrap_or_else(|| u32::try_from(i + 1).unwrap_or(u32::MAX)),
2216 class: object
2217 .as_ref()
2218 .map(|o| o.class().as_str().to_owned())
2219 .unwrap_or_default(),
2220 object: object.as_ref().map_or_else(
2221 || {
2222 c["arguments"]["path"]
2223 .as_str()
2224 .map(str::to_owned)
2225 .or_else(|| {
2226 c["arguments"]["argv"].as_array().map(|a| {
2227 a.iter()
2228 .filter_map(Value::as_str)
2229 .collect::<Vec<_>>()
2230 .join(" ")
2231 })
2232 })
2233 .unwrap_or_else(|| c["tool"].as_str().unwrap_or_default().to_owned())
2234 },
2235 tools::policy::Object::describe,
2236 ),
2237 reason: tools::policy::AUTOPILOT_DENIED.to_owned(),
2238 }
2239 })
2240 .collect()
2241}
2242
2243#[must_use]
2245pub fn last_denials(
2246 project_state: &Path,
2247 unit: &str,
2248) -> Vec<speccoder_core::oracle::AutopilotDenial> {
2249 std::fs::read_to_string(project_state.join(DIR).join(unit).join("autopilot.json"))
2250 .ok()
2251 .and_then(|t| serde_json::from_str(&t).ok())
2252 .unwrap_or_default()
2253}
2254
2255pub(crate) fn record_denials(
2257 project_state: &Path,
2258 unit: &str,
2259 denials: &[speccoder_core::oracle::AutopilotDenial],
2260) {
2261 let file = project_state.join(DIR).join(unit).join("autopilot.json");
2262 if denials.is_empty() {
2263 let _ = std::fs::remove_file(file);
2264 return;
2265 }
2266 let _ = write_atomic(
2267 &file,
2268 &serde_json::to_string_pretty(denials).unwrap_or_default(),
2269 );
2270}
2271
2272#[cfg(test)]
2273mod tests {
2274 use super::*;
2275
2276 fn draft(content: &str) -> Draft {
2277 Draft {
2278 requirement: DraftRequirement {
2279 key: "US1".to_owned(),
2280 title: "Greet".to_owned(),
2281 text: "### User Story 1 - Greet".to_owned(),
2282 sha256: "sha256:w".to_owned(),
2283 },
2284 samples: 2,
2285 tests: vec![DraftTest {
2286 path: "tests/acceptance_us1.rs".to_owned(),
2287 sha256: digest_of(content),
2288 scenarios: vec!["US1-1".to_owned()],
2289 argv: vec!["cargo".to_owned(), "test".to_owned()],
2290 red_class: RedClass::Assertion,
2291 authored_by: AuthoredBy::Model,
2292 edited_by: None,
2293 }],
2294 discarded: Vec::new(),
2295 rejected: Vec::new(),
2296 expectations: Samples::default(),
2297 untestable: Samples::default(),
2298 ambiguities: Vec::new(),
2299 held_out: HeldOutSummary {
2300 state: "absent".to_owned(),
2301 count: 0,
2302 manifest_sha256: None,
2303 reason: None,
2304 },
2305 sealed: None,
2306 notes: Vec::new(),
2307 runs: Vec::new(),
2308 context: Vec::new(),
2309 }
2310 }
2311
2312 #[test]
2313 fn a_draft_round_trips_and_a_changed_test_is_tampering() {
2314 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2315 let d = draft("#[test] fn t() {}");
2316 let files = vec![(
2317 "tests/acceptance_us1.rs".to_owned(),
2318 "#[test] fn t() {}".to_owned(),
2319 )];
2320 store(state.path(), "001-greet", &d, &files).unwrap_or_else(|e| panic!("{e}"));
2321 let (back, contents) = load(state.path(), "001-greet", "US1")
2322 .unwrap_or_else(|e| panic!("{e:?}"))
2323 .unwrap_or_else(|| panic!("stored"));
2324 assert_eq!(back, d);
2325 assert_eq!(contents, files);
2326 std::fs::write(
2327 dir(state.path(), "001-greet", "US1").join("files/tests/acceptance_us1.rs"),
2328 "changed",
2329 )
2330 .unwrap_or_else(|e| panic!("{e}"));
2331 assert!(load(state.path(), "001-greet", "US1").is_err());
2332 assert!(store(state.path(), "001-greet", &d, &[]).is_err());
2333 assert!(
2334 load(state.path(), "001-greet", "US2")
2335 .unwrap_or_default()
2336 .is_none()
2337 );
2338 }
2339
2340 #[test]
2341 fn the_manifest_names_paths_contents_and_commands() {
2342 let argv = vec!["cargo".to_owned()];
2343 let other = vec!["make".to_owned()];
2344 let a = manifest_digest([("a", "sha256:1", argv.as_slice())]);
2345 assert_eq!(a, manifest_digest([("a", "sha256:1", argv.as_slice())]));
2346 assert_ne!(a, manifest_digest([("a", "sha256:2", argv.as_slice())]));
2347 assert_ne!(a, manifest_digest([("a", "sha256:1", other.as_slice())]));
2348 let both = manifest_digest([("a", "s", argv.as_slice()), ("b", "s", argv.as_slice())]);
2349 assert_eq!(
2350 both,
2351 manifest_digest([("b", "s", argv.as_slice()), ("a", "s", argv.as_slice())])
2352 );
2353 let mut d = draft("x");
2354 assert!(d.manifest().is_some());
2355 d.tests[0].red_class = RedClass::Passed;
2356 assert!(d.manifest().is_none());
2357 assert_eq!(slug("cap: a / b"), "cap__a___b");
2358 }
2359
2360 #[test]
2361 fn a_lapse_is_noted_once() {
2362 let state = tempfile::tempdir().unwrap_or_else(|e| panic!("{e}"));
2363 assert!(note_lapse(state.path(), "u", "US1", "sha256:a"));
2364 assert!(!note_lapse(state.path(), "u", "US1", "sha256:a"));
2365 assert!(note_lapse(state.path(), "u", "US1", "sha256:b"));
2366 }
2367}