1use crate::canonical::compute_id;
3use crate::store::Store;
4use crate::tick::{Check, Ground, Tick};
5use std::path::Path;
6use std::process::Command;
7
8#[derive(Default)]
9struct DraftGround {
10 claim: String,
11 supports: String, revisit: Option<String>,
13 test_ref: Option<String>,
14 counter_test: Option<String>,
15 platforms: Vec<String>,
16 triggered_by: Vec<String>,
17 surfaces: Vec<String>,
18}
19
20fn need(args: &[String], i: usize, flag: &str) -> Result<String, String> {
21 args.get(i + 1)
22 .cloned()
23 .ok_or(format!("{flag} requires a value"))
24}
25
26fn last<'a>(g: &'a mut [DraftGround], flag: &str) -> Result<&'a mut DraftGround, String> {
27 g.last_mut()
28 .ok_or(format!("{flag} has no preceding --assume/--reject ground"))
29}
30
31pub(crate) fn resolve_blame(repo: &Path, blame_override: Option<String>) -> Result<String, String> {
33 if let Some(b) = blame_override {
34 let b = b.trim();
35 if b.is_empty() {
36 return Err("--blame must be non-empty".into());
37 }
38 return Ok(b.to_string());
39 }
40 let out = Command::new("git")
41 .arg("config")
42 .arg("user.name")
43 .current_dir(repo)
44 .output()
45 .map_err(|e| format!("cannot run git: {e}"))?;
46 let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
47 if name.is_empty() {
48 return Err("no author: pass --blame, or set git config user.name".into());
49 }
50 Ok(name)
51}
52
53pub(crate) fn resolve_sha(repo: &Path, sha_override: &Option<String>) -> Result<String, String> {
54 let sha = match sha_override {
55 Some(s) => s.trim().to_string(),
56 None => {
57 let out = std::process::Command::new("git")
58 .args(["rev-parse", "HEAD"])
59 .current_dir(repo)
60 .output()
61 .map_err(|e| format!("cannot run git: {e}"))?;
62 if !out.status.success() {
63 return Err(
64 "cannot resolve verified_at_sha (not a git repo?) — pass --verified-at-sha"
65 .into(),
66 );
67 }
68 String::from_utf8_lossy(&out.stdout).trim().to_string()
69 }
70 };
71 if !crate::tick::is_40_lower_hex(&sha) {
72 return Err(format!("verified_at_sha must be 40 lowercase hex: {sha}"));
73 }
74 Ok(sha)
75}
76
77fn t_grounds_text(grounds: &[Ground]) -> Vec<String> {
78 grounds.iter().map(|g| g.claim.clone()).collect()
79}
80
81fn git_show(repo: &Path, fmt: &str, commit: &str) -> Result<String, String> {
84 let out = Command::new("git")
85 .args(["show", "-s", fmt, commit])
86 .current_dir(repo)
87 .output()
88 .map_err(|e| format!("cannot run git: {e}"))?;
89 if !out.status.success() {
90 return Err(format!("decide: cannot read commit {commit}"));
91 }
92 Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
93}
94
95struct Envelope {
99 subject: String,
100 author: String,
101 refs: Vec<String>,
102}
103
104const SUBJECT_ROLES: &[&str] = &["Dev", "QA", "Product", "Mac", "User"];
106
107fn subject_role(subject: &str) -> Option<&'static str> {
110 let head = subject.split_whitespace().next()?;
111 let word = head.strip_suffix(':')?;
112 SUBJECT_ROLES
113 .iter()
114 .find(|r| r.eq_ignore_ascii_case(word))
115 .copied()
116}
117
118fn subject_refs(subject: &str) -> Vec<String> {
121 subject
122 .split_whitespace()
123 .filter(|tok| {
124 let rest = tok
125 .strip_prefix('#')
126 .or_else(|| tok.strip_prefix('R'))
127 .or_else(|| tok.strip_prefix('r'));
128 matches!(rest, Some(d) if !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
129 })
130 .map(|t| t.to_string())
131 .collect()
132}
133
134fn read_envelope(repo: &Path, commit: &str) -> Result<Envelope, String> {
135 let subject = git_show(repo, "--format=%s", commit)?;
136 let author = git_show(repo, "--format=%an", commit)?;
137 let body = git_show(repo, "--format=%b", commit)?;
138 let refs = body
139 .lines()
140 .map(str::trim)
141 .filter(|l| l.starts_with("Refs #"))
142 .map(|l| l.to_string())
143 .collect();
144 Ok(Envelope {
145 subject,
146 author,
147 refs,
148 })
149}
150
151pub(crate) fn validate_authority(val: &str) -> Result<(), String> {
153 if val == "user-ruled" || val == "agent-disposable" {
154 Ok(())
155 } else {
156 Err("authority must be user-ruled or agent-disposable".into())
157 }
158}
159
160pub fn harvested_test_check(
168 reference: String,
169 verified_at_sha: String,
170 platforms: Vec<String>,
171 triggered_by: Vec<String>,
172 surfaces: Vec<String>,
173) -> Result<Check, String> {
174 use crate::tick::Liveness;
175 if reference.trim().is_empty() {
176 return Err("a harvested binding requires a non-empty test reference".into());
177 }
178 if !crate::tick::is_40_lower_hex(&verified_at_sha) {
179 return Err(format!(
180 "verified_at_sha must be 40 lowercase hex: {verified_at_sha}"
181 ));
182 }
183 if platforms.is_empty() || triggered_by.is_empty() || surfaces.is_empty() {
184 return Err(
185 "a harvested binding requires at least one platform, triggered-by, and surface (no half-harvest)"
186 .into(),
187 );
188 }
189 Ok(Check::Test {
190 reference,
191 verified_at_sha,
192 counter_test: None, liveness: Liveness {
194 platforms,
195 triggered_by,
196 surfaces,
197 },
198 })
199}
200
201pub struct Decision {
206 pub observe: String,
207 pub decision: String,
208 pub grounds: Vec<Ground>,
209 pub blame: String,
210 pub authority: Option<String>,
211 pub jurisdiction: Option<String>,
212 pub source_ref: Option<serde_json::Value>,
213 pub provenance: Option<String>,
214}
215
216pub fn append(repo: &Path, d: Decision) -> Result<Tick, String> {
222 for field in std::iter::once(d.decision.clone())
223 .chain(std::iter::once(d.observe.clone()))
224 .chain(t_grounds_text(&d.grounds))
225 {
226 for verb in crate::lint::r3_self_evolve(&field) {
227 eprintln!("warning: \"{verb}\" should take a human subject, not the system (best-effort lint; a re-wording evades it)");
228 }
229 }
230 let store = Store::at(repo);
231 if !store.exists() {
232 return Err("no .evolving/ store here — run `ev init` first".into());
233 }
234 let parent_id = store
235 .read_head()
236 .map_err(|e| format!("reading HEAD: {e}"))?;
237 let held_since = time::OffsetDateTime::now_utc()
238 .format(&time::format_description::well_known::Rfc3339)
239 .map_err(|e| format!("timestamp: {e}"))?;
240 let mut t = Tick {
241 id: String::new(),
242 parent_id,
243 observe: d.observe,
244 decision: d.decision,
245 grounds: d.grounds,
246 status: "live".into(),
247 held_since,
248 blame: d.blame,
249 authority: d.authority,
250 jurisdiction: d.jurisdiction,
251 source_ref: d.source_ref,
252 provenance: d.provenance,
253 };
254 t.id = compute_id(&t);
255 store
256 .write_tick(&t)
257 .map_err(|e| format!("writing tick: {e}"))?;
258 Ok(t)
259}
260
261fn build_ground(
262 repo: &Path,
263 d: DraftGround,
264 sha_override: &Option<String>,
265 authority: Option<&str>,
266) -> Result<Ground, String> {
267 use crate::tick::Liveness;
268 if d.claim.is_empty() {
269 return Err("ground claim is empty".into());
270 }
271 if d.supports.starts_with("rejected:") && d.revisit.is_some() {
274 return Err("a road-not-taken (rejected) ground cannot carry a human re-check".into());
275 }
276 if d.supports.starts_with("rejected:")
285 && d.test_ref.is_some()
286 && authority != Some("user-ruled")
287 {
288 return Err(
289 "a rejected road can carry a tripwire test only when the decision is --authority user-ruled"
290 .into(),
291 );
292 }
293 if d.revisit.is_some() && d.test_ref.is_some() {
294 return Err("a ground cannot be both --revisit and --assume-test (R2)".into());
295 }
296 let has_test_fields = d.counter_test.is_some()
297 || !d.platforms.is_empty()
298 || !d.triggered_by.is_empty()
299 || !d.surfaces.is_empty();
300 let check = match (d.test_ref, d.revisit) {
301 (Some(reference), _) => {
302 let counter_test = d
303 .counter_test
304 .ok_or("a test binding requires --counter-test (no vacuous binding)".to_string())?;
305 if counter_test.trim().is_empty() {
306 return Err("a test binding requires --counter-test (no vacuous binding)".into());
309 }
310 if d.platforms.is_empty() || d.triggered_by.is_empty() || d.surfaces.is_empty() {
311 return Err("a test binding requires at least one --on-platform, --triggered-by, and --surface".into());
312 }
313 let verified_at_sha = resolve_sha(repo, sha_override)?;
314 Some(Check::Test {
315 reference,
316 verified_at_sha,
317 counter_test: Some(counter_test),
318 liveness: Liveness {
319 platforms: d.platforms,
320 triggered_by: d.triggered_by,
321 surfaces: d.surfaces,
322 },
323 })
324 }
325 (None, Some(when)) => {
326 if has_test_fields {
327 return Err(
328 "--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
329 .into(),
330 );
331 }
332 Some(Check::Person { reference: when })
333 }
334 (None, None) => {
335 if has_test_fields {
336 return Err(
337 "--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
338 .into(),
339 );
340 }
341 None
342 }
343 };
344 Ok(Ground {
345 claim: d.claim,
346 supports: d.supports,
347 check,
348 })
349}
350
351pub fn run(repo: &Path, decision: Option<&str>, args: &[String]) -> Result<Tick, String> {
352 let mut observe = String::new();
353 let mut blame_override: Option<String> = None;
354 let mut sha_override: Option<String> = None;
355 let mut authority: Option<String> = None;
356 let mut jurisdiction: Option<String> = None;
357 let mut source_ref: Option<serde_json::Value> = None;
358 let mut from_git: Option<String> = None;
359 let mut drafts: Vec<DraftGround> = Vec::new();
360 let mut i = 0;
361 while i < args.len() {
362 let flag = args[i].clone();
363 match flag.as_str() {
364 "--from-git" => {
365 from_git = Some(need(args, i, &flag)?);
366 }
367 "--observe" => {
368 observe = need(args, i, &flag)?;
369 }
370 "--blame" => {
371 blame_override = Some(need(args, i, &flag)?);
372 }
373 "--verified-at-sha" => {
374 sha_override = Some(need(args, i, &flag)?);
375 }
376 "--authority" => {
377 let v = need(args, i, &flag)?;
378 validate_authority(&v)?;
379 authority = Some(v);
380 }
381 "--jurisdiction" => {
382 let v = need(args, i, &flag)?;
383 crate::tick::validate_jurisdiction(&v)?;
384 jurisdiction = Some(v);
385 }
386 "--source-ref" => {
387 let v = need(args, i, &flag)?;
390 if v.is_empty() {
391 return Err("--source-ref needs a non-empty value".into());
392 }
393 source_ref = Some(serde_json::Value::String(v));
394 }
395 "--reject" => {
396 let v = need(args, i, &flag)?;
397 let (opt, why) = v
398 .split_once(':')
399 .ok_or("--reject expects \"<option>: <why>\"".to_string())?;
400 let (opt, why) = (opt.trim(), why.trim());
401 if opt.is_empty() || why.is_empty() {
402 return Err("--reject needs non-empty <option> and <why>".into());
403 }
404 drafts.push(DraftGround {
405 claim: why.into(),
406 supports: format!("rejected:{opt}"),
407 ..Default::default()
408 });
409 }
410 "--assume" => {
411 let claim = need(args, i, &flag)?;
412 drafts.push(DraftGround {
413 claim,
414 supports: "chosen".into(),
415 ..Default::default()
416 });
417 }
418 "--revisit" => {
419 last(&mut drafts, &flag)?.revisit = Some(need(args, i, &flag)?);
420 }
421 "--assume-test" => {
422 last(&mut drafts, &flag)?.test_ref = Some(need(args, i, &flag)?);
423 }
424 "--counter-test" => {
425 last(&mut drafts, &flag)?.counter_test = Some(need(args, i, &flag)?);
426 }
427 "--on-platform" => {
428 let v = need(args, i, &flag)?;
429 last(&mut drafts, &flag)?.platforms.push(v);
430 }
431 "--triggered-by" => {
432 let v = need(args, i, &flag)?;
433 last(&mut drafts, &flag)?.triggered_by.push(v);
434 }
435 "--surface" => {
436 let v = need(args, i, &flag)?;
437 last(&mut drafts, &flag)?.surfaces.push(v);
438 }
439 other => return Err(format!("decide: unknown flag {other}")),
440 }
441 i += 2;
442 }
443
444 let (decision, observe) = match (decision, &from_git) {
448 (Some(_), Some(_)) => {
449 return Err("decide: decision given twice (positional and --from-git)".into())
450 }
451 (None, None) => return Err("decide: needs a decision (positional) or --from-git".into()),
452 (Some(d), None) => (d.to_string(), observe),
453 (None, Some(commit)) => {
454 let env = read_envelope(repo, commit)?;
455 if blame_override.is_none() {
458 blame_override = Some(match subject_role(&env.subject) {
459 Some(role) => role.to_string(),
460 None => env.author,
461 });
462 }
463 let observe = std::iter::once(observe)
465 .chain(subject_refs(&env.subject))
466 .chain(env.refs)
467 .filter(|s| !s.is_empty())
468 .collect::<Vec<_>>()
469 .join(" ");
470 (env.subject, observe)
471 }
472 };
473 if decision.trim().is_empty() {
474 return Err("decision text is empty".into());
475 }
476 let blame = resolve_blame(repo, blame_override)?;
477 let mut grounds = Vec::new();
478 for d in drafts {
479 grounds.push(build_ground(repo, d, &sha_override, authority.as_deref())?);
482 }
483 append(
486 repo,
487 Decision {
488 observe,
489 decision: decision.to_string(),
490 grounds,
491 blame,
492 authority,
493 jurisdiction,
494 source_ref,
495 provenance: None,
498 },
499 )
500}
501
502#[cfg(test)]
503mod tests {
504 use super::*;
505 use crate::tick::Check;
506
507 fn repo() -> std::path::PathBuf {
508 use std::sync::atomic::{AtomicU64, Ordering};
509 static N: AtomicU64 = AtomicU64::new(0);
510 let p = std::env::temp_dir().join(format!(
511 "ev-capture-{}-{}",
512 std::process::id(),
513 N.fetch_add(1, Ordering::Relaxed)
514 ));
515 let _ = std::fs::remove_dir_all(&p);
516 std::fs::create_dir_all(&p).unwrap();
517 Store::at(&p).init().unwrap();
518 p
519 }
520 fn s(v: &[&str]) -> Vec<String> {
521 v.iter().map(|x| x.to_string()).collect()
522 }
523
524 #[test]
525 fn decide_should_record_a_chosen_a_revisit_and_a_rejected_road_when_all_are_passed() {
526 let r = repo();
528
529 let t = run(
531 &r,
532 Some("build our own retrieval; reject pgvector"),
533 &s(&[
534 "--observe",
535 "evaluating backend",
536 "--assume",
537 "team has bandwidth long-term",
538 "--revisit",
539 "Q3 review",
540 "--reject",
541 "pgvector: would lock our schema",
542 "--blame",
543 "Wang Yu",
544 ]),
545 )
546 .expect("ok");
547
548 assert_eq!(t.grounds.len(), 2);
550 assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
551 assert_eq!(t.grounds[1].supports, "rejected:pgvector");
552 assert_eq!(t.blame, "Wang Yu");
553 assert_eq!(Store::at(&r).read_head().unwrap(), t.id);
554 }
555
556 #[test]
557 fn decide_should_stamp_held_since_with_a_nonempty_rfc3339_time_when_recording() {
558 let r = repo();
560
561 run(&r, Some("ship it"), &s(&["--blame", "Wang Yu"])).expect("ok");
563
564 let head = Store::at(&r).read_head().unwrap();
566 let tick = Store::at(&r).read_tick(&head).unwrap().unwrap();
567 assert!(!tick.held_since.is_empty());
568 time::OffsetDateTime::parse(
569 &tick.held_since,
570 &time::format_description::well_known::Rfc3339,
571 )
572 .expect("held_since parses as RFC 3339");
573 }
574
575 #[test]
576 fn decide_should_store_a_trimmed_blame_when_the_blame_is_padded() {
577 let r = repo();
579
580 let t = run(
582 &r,
583 Some("d"),
584 &s(&["--assume", "c", "--blame", " Wang Yu "]),
585 )
586 .expect("ok");
587
588 assert_eq!(t.blame, "Wang Yu");
590 }
591
592 #[test]
593 fn decide_should_refuse_the_ground_when_it_is_both_revisit_and_assume_test() {
594 let r = repo();
596
597 let e = run(
599 &r,
600 Some("d"),
601 &s(&[
602 "--assume",
603 "c",
604 "--revisit",
605 "Q3",
606 "--assume-test",
607 "pytest x",
608 "--blame",
609 "Wang Yu",
610 ]),
611 );
612
613 assert!(e.is_err());
615 }
616
617 #[test]
618 fn decide_should_refuse_a_tripwire_on_a_rejected_road_when_authority_is_absent() {
619 let r = repo();
621
622 let e = run(
624 &r,
625 Some("d"),
626 &s(&[
627 "--reject",
628 "pgvector: would lock our schema",
629 "--assume-test",
630 "pytest x",
631 "--counter-test",
632 "ct",
633 "--on-platform",
634 "linux-ci",
635 "--triggered-by",
636 "f",
637 "--surface",
638 "s",
639 "--verified-at-sha",
640 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
641 "--blame",
642 "Wang Yu",
643 ]),
644 );
645
646 assert!(e.is_err());
648 }
649
650 #[test]
651 fn decide_should_refuse_a_tripwire_on_a_rejected_road_when_authority_is_agent_disposable() {
652 let r = repo();
654
655 let e = run(
657 &r,
658 Some("d"),
659 &s(&[
660 "--reject",
661 "pgvector: would lock our schema",
662 "--assume-test",
663 "pytest x",
664 "--counter-test",
665 "ct",
666 "--on-platform",
667 "linux-ci",
668 "--triggered-by",
669 "f",
670 "--surface",
671 "s",
672 "--verified-at-sha",
673 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
674 "--authority",
675 "agent-disposable",
676 "--blame",
677 "Wang Yu",
678 ]),
679 );
680
681 assert!(e.is_err());
683 }
684
685 #[test]
686 fn decide_should_accept_a_tripwire_on_a_rejected_road_when_authority_is_user_ruled() {
687 let r = repo();
689
690 let t = run(
692 &r,
693 Some("keep Redis out"),
694 &s(&[
695 "--reject",
696 "Redis: a new infra dependency",
697 "--assume-test",
698 "! grep -q redis pyproject.toml",
699 "--counter-test",
700 "grep -q redis pyproject.toml",
701 "--on-platform",
702 "linux-ci",
703 "--triggered-by",
704 "pyproject.toml",
705 "--surface",
706 "pyproject-deps",
707 "--verified-at-sha",
708 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
709 "--authority",
710 "user-ruled",
711 "--blame",
712 "Wang Yu",
713 ]),
714 )
715 .expect("a user-ruled rejected-road tripwire is allowed");
716
717 let g = t
719 .grounds
720 .iter()
721 .find(|g| g.supports.starts_with("rejected:"))
722 .expect("a rejected road");
723 assert!(
724 matches!(g.check, Some(Check::Test { .. })),
725 "the closed road carries a tripwire"
726 );
727 }
728
729 #[test]
730 fn decide_should_refuse_a_user_ruled_rejected_road_tripwire_when_the_counter_test_is_missing() {
731 let r = repo();
733
734 let e = run(
736 &r,
737 Some("keep Redis out"),
738 &s(&[
739 "--reject",
740 "Redis: a new infra dependency",
741 "--assume-test",
742 "! grep -q redis pyproject.toml",
743 "--on-platform",
744 "linux-ci",
745 "--triggered-by",
746 "pyproject.toml",
747 "--surface",
748 "pyproject-deps",
749 "--verified-at-sha",
750 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
751 "--authority",
752 "user-ruled",
753 "--blame",
754 "Wang Yu",
755 ]),
756 );
757
758 assert!(e.is_err());
760 }
761
762 #[test]
763 fn decide_should_still_refuse_a_revisit_on_a_rejected_road_even_when_user_ruled() {
764 let r = repo();
766
767 let e = run(
769 &r,
770 Some("keep Redis out"),
771 &s(&[
772 "--reject",
773 "Redis: a new infra dependency",
774 "--revisit",
775 "Q3 infra review",
776 "--authority",
777 "user-ruled",
778 "--blame",
779 "Wang Yu",
780 ]),
781 );
782
783 assert!(e.is_err());
785 }
786
787 #[test]
788 fn decide_should_error_when_there_is_no_store() {
789 let p = std::env::temp_dir().join(format!("ev-nostore-{}", std::process::id()));
791 let _ = std::fs::remove_dir_all(&p);
792 std::fs::create_dir_all(&p).unwrap();
793
794 let e = run(&p, Some("d"), &s(&["--blame", "x"]));
796
797 assert!(e.is_err());
799 }
800
801 #[test]
802 fn decide_should_build_a_self_verifying_test_binding_when_all_test_fields_are_present() {
803 let r = repo();
805
806 let t = run(
808 &r,
809 Some("restore-safety counter DB-backed; reject Redis"),
810 &s(&[
811 "--assume",
812 "Argus introduces no Redis; multi-pod coord via existing DB",
813 "--assume-test",
814 "pytest tests/test_redis_absent.py",
815 "--counter-test",
816 "pytest tests/test_redis_absent.py::test_redis_injection_flips_red",
817 "--on-platform",
818 "linux-ci",
819 "--triggered-by",
820 "pyproject.toml",
821 "--surface",
822 "pyproject-deps",
823 "--verified-at-sha",
824 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
825 "--reject",
826 "Redis: a new infra dependency",
827 "--blame",
828 "Wang Yu",
829 ]),
830 )
831 .expect("ok");
832
833 match &t.grounds[0].check {
835 Some(Check::Test {
836 reference,
837 counter_test,
838 liveness,
839 verified_at_sha,
840 }) => {
841 assert_eq!(reference, "pytest tests/test_redis_absent.py");
842 assert!(counter_test
843 .as_deref()
844 .is_some_and(|c| c.contains("flips_red")));
845 assert_eq!(liveness.platforms, vec!["linux-ci".to_string()]);
846 assert_eq!(verified_at_sha.len(), 40);
847 }
848 _ => panic!("expected a test check"),
849 }
850 }
851
852 #[test]
853 fn decide_should_reject_a_test_binding_when_there_is_no_counter_test() {
854 let r = repo();
856
857 let e = run(
859 &r,
860 Some("d"),
861 &s(&[
862 "--assume",
863 "c",
864 "--assume-test",
865 "pytest x",
866 "--on-platform",
867 "linux-ci",
868 "--triggered-by",
869 "f",
870 "--surface",
871 "s",
872 "--verified-at-sha",
873 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
874 "--blame",
875 "Wang Yu",
876 ]),
877 );
878
879 assert!(e.is_err());
881 }
882
883 #[test]
884 fn decide_should_reject_a_test_binding_when_the_counter_test_is_empty() {
885 let r = repo();
887
888 let e = run(
890 &r,
891 Some("d"),
892 &s(&[
893 "--assume",
894 "c",
895 "--assume-test",
896 "pytest x",
897 "--counter-test",
898 "",
899 "--on-platform",
900 "linux-ci",
901 "--triggered-by",
902 "f",
903 "--surface",
904 "s",
905 "--verified-at-sha",
906 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
907 "--blame",
908 "Wang Yu",
909 ]),
910 );
911
912 assert!(e.is_err());
914 }
915
916 #[test]
917 fn decide_should_reject_a_test_binding_when_there_is_no_verified_at_sha_and_no_git() {
918 let r = repo();
920
921 let e = run(
923 &r,
924 Some("d"),
925 &s(&[
926 "--assume",
927 "c",
928 "--assume-test",
929 "pytest x",
930 "--counter-test",
931 "ct",
932 "--on-platform",
933 "linux-ci",
934 "--triggered-by",
935 "f",
936 "--surface",
937 "s",
938 "--blame",
939 "Wang Yu",
940 ]),
941 );
942
943 assert!(e.is_err());
945 }
946
947 #[test]
948 fn migrate_bind_should_build_a_harvested_test_check_when_no_counter_test() {
949 let check = harvested_test_check(
952 "pytest tests/test_invariant_no_redis.py".into(),
953 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
954 vec!["linux-ci".into()],
955 vec!["pyproject.toml".into()],
956 vec!["pyproject-deps".into()],
957 )
958 .expect("the full liveness is present, so the harvested binding is well-formed");
959
960 match check {
962 Check::Test {
963 reference,
964 counter_test,
965 liveness,
966 verified_at_sha,
967 } => {
968 assert_eq!(reference, "pytest tests/test_invariant_no_redis.py");
969 assert_eq!(counter_test, None); assert_eq!(liveness.platforms, vec!["linux-ci".to_string()]);
971 assert_eq!(liveness.triggered_by, vec!["pyproject.toml".to_string()]);
972 assert_eq!(liveness.surfaces, vec!["pyproject-deps".to_string()]);
973 assert_eq!(verified_at_sha.len(), 40);
974 }
975 _ => panic!("expected a harvested test check"),
976 }
977 }
978
979 #[test]
980 fn migrate_bind_should_reject_a_harvested_binding_when_a_liveness_key_is_missing() {
981 let e = harvested_test_check(
983 "pytest x".into(),
984 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
985 vec!["linux-ci".into()],
986 vec!["pyproject.toml".into()],
987 vec![], );
989
990 assert!(e.is_err());
992 }
993
994 #[test]
995 fn decide_should_still_error_without_a_counter_test() {
996 let r = repo();
1000
1001 let e = run(
1003 &r,
1004 Some("d"),
1005 &s(&[
1006 "--assume",
1007 "c",
1008 "--assume-test",
1009 "pytest x",
1010 "--on-platform",
1011 "linux-ci",
1012 "--triggered-by",
1013 "f",
1014 "--surface",
1015 "s",
1016 "--verified-at-sha",
1017 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
1018 "--blame",
1019 "Wang Yu",
1020 ]),
1021 );
1022
1023 assert!(e.is_err());
1025 }
1026
1027 #[test]
1028 fn append_should_compute_the_frozen_genesis_id_when_given_the_genesis_decision() {
1029 let r = repo();
1032 let d = Decision {
1033 observe: "evaluating retrieval backend".into(),
1034 decision: "freeze the retrieval schema for v2".into(),
1035 grounds: vec![
1036 Ground {
1037 claim: "team still wants a frozen schema".into(),
1038 supports: "chosen".into(),
1039 check: Some(Check::Person {
1040 reference: "Q3 infra review".into(),
1041 }),
1042 },
1043 Ground {
1044 claim: "pgvector would lock our schema".into(),
1045 supports: "rejected:pgvector".into(),
1046 check: None,
1047 },
1048 ],
1049 blame: "Wang Yu".into(),
1050 authority: None,
1051 jurisdiction: None,
1052 source_ref: None,
1053 provenance: None,
1054 };
1055
1056 let t = append(&r, d).expect("ok");
1058
1059 assert_eq!(t.id, "e2b337f53a1f");
1062 assert_eq!(t.parent_id, "");
1063 assert_eq!(Store::at(&r).read_head().unwrap(), t.id);
1064 }
1065
1066 #[test]
1067 fn decide_should_take_blame_from_git_config_when_no_blame_flag_is_given() {
1068 let r = repo();
1070 for a in [
1071 ["init"].as_slice(),
1072 ["config", "user.name", "Ada Lovelace"].as_slice(),
1073 ] {
1074 std::process::Command::new("git")
1075 .args(a)
1076 .current_dir(&r)
1077 .output()
1078 .unwrap();
1079 }
1080
1081 let t = run(&r, Some("d"), &s(&["--assume", "c"])).expect("ok");
1083
1084 assert_eq!(t.blame, "Ada Lovelace");
1086 }
1087}