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 pub corrects: Option<String>,
217}
218
219pub fn append(repo: &Path, d: Decision) -> Result<Tick, String> {
225 for field in std::iter::once(d.decision.clone())
226 .chain(std::iter::once(d.observe.clone()))
227 .chain(t_grounds_text(&d.grounds))
228 {
229 for verb in crate::lint::r3_self_evolve(&field) {
230 eprintln!("warning: \"{verb}\" should take a human subject, not the system (best-effort lint; a re-wording evades it)");
231 }
232 }
233 let store = Store::at(repo);
234 if !store.exists() {
235 return Err("no .evolving/ store here — run `ev init` first".into());
236 }
237 let parent_id = store
238 .read_head()
239 .map_err(|e| format!("reading HEAD: {e}"))?;
240 let held_since = time::OffsetDateTime::now_utc()
241 .format(&time::format_description::well_known::Rfc3339)
242 .map_err(|e| format!("timestamp: {e}"))?;
243 let mut t = Tick {
244 id: String::new(),
245 parent_id,
246 observe: d.observe,
247 decision: d.decision,
248 grounds: d.grounds,
249 status: "live".into(),
250 held_since,
251 blame: d.blame,
252 authority: d.authority,
253 jurisdiction: d.jurisdiction,
254 source_ref: d.source_ref,
255 provenance: d.provenance,
256 corrects: d.corrects,
257 };
258 t.id = compute_id(&t);
259 store
260 .write_tick(&t)
261 .map_err(|e| format!("writing tick: {e}"))?;
262 Ok(t)
263}
264
265fn build_ground(
266 repo: &Path,
267 d: DraftGround,
268 sha_override: &Option<String>,
269 authority: Option<&str>,
270) -> Result<Ground, String> {
271 use crate::tick::Liveness;
272 if d.claim.is_empty() {
273 return Err("ground claim is empty".into());
274 }
275 if d.supports.starts_with("rejected:") && d.revisit.is_some() {
278 return Err("a road-not-taken (rejected) ground cannot carry a human re-check".into());
279 }
280 if d.supports.starts_with("rejected:")
289 && d.test_ref.is_some()
290 && authority != Some("user-ruled")
291 {
292 return Err(
293 "a rejected road can carry a tripwire test only when the decision is --authority user-ruled"
294 .into(),
295 );
296 }
297 if d.revisit.is_some() && d.test_ref.is_some() {
298 return Err("a ground cannot be both --revisit and --assume-test (R2)".into());
299 }
300 let has_test_fields = d.counter_test.is_some()
301 || !d.platforms.is_empty()
302 || !d.triggered_by.is_empty()
303 || !d.surfaces.is_empty();
304 let check = match (d.test_ref, d.revisit) {
305 (Some(reference), _) => {
306 let counter_test = d
307 .counter_test
308 .ok_or("a test binding requires --counter-test (no vacuous binding)".to_string())?;
309 if counter_test.trim().is_empty() {
310 return Err("a test binding requires --counter-test (no vacuous binding)".into());
313 }
314 if d.platforms.is_empty() || d.triggered_by.is_empty() || d.surfaces.is_empty() {
315 return Err("a test binding requires at least one --on-platform, --triggered-by, and --surface".into());
316 }
317 let verified_at_sha = resolve_sha(repo, sha_override)?;
318 Some(Check::Test {
319 reference,
320 verified_at_sha,
321 counter_test: Some(counter_test),
322 liveness: Liveness {
323 platforms: d.platforms,
324 triggered_by: d.triggered_by,
325 surfaces: d.surfaces,
326 },
327 })
328 }
329 (None, Some(when)) => {
330 if has_test_fields {
331 return Err(
332 "--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
333 .into(),
334 );
335 }
336 Some(Check::Person { reference: when })
337 }
338 (None, None) => {
339 if has_test_fields {
340 return Err(
341 "--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
342 .into(),
343 );
344 }
345 None
346 }
347 };
348 Ok(Ground {
349 claim: d.claim,
350 supports: d.supports,
351 check,
352 })
353}
354
355pub fn run(repo: &Path, decision: Option<&str>, args: &[String]) -> Result<Tick, String> {
356 let mut observe = String::new();
357 let mut blame_override: Option<String> = None;
358 let mut sha_override: Option<String> = None;
359 let mut authority: Option<String> = None;
360 let mut jurisdiction: Option<String> = None;
361 let mut source_ref: Option<serde_json::Value> = None;
362 let mut from_git: Option<String> = None;
363 let mut drafts: Vec<DraftGround> = Vec::new();
364 let mut i = 0;
365 while i < args.len() {
366 let flag = args[i].clone();
367 match flag.as_str() {
368 "--from-git" => {
369 from_git = Some(need(args, i, &flag)?);
370 }
371 "--observe" => {
372 observe = need(args, i, &flag)?;
373 }
374 "--blame" => {
375 blame_override = Some(need(args, i, &flag)?);
376 }
377 "--verified-at-sha" => {
378 sha_override = Some(need(args, i, &flag)?);
379 }
380 "--authority" => {
381 let v = need(args, i, &flag)?;
382 validate_authority(&v)?;
383 authority = Some(v);
384 }
385 "--jurisdiction" => {
386 let v = need(args, i, &flag)?;
387 crate::tick::validate_jurisdiction(&v)?;
388 jurisdiction = Some(v);
389 }
390 "--source-ref" => {
391 let v = need(args, i, &flag)?;
394 if v.is_empty() {
395 return Err("--source-ref needs a non-empty value".into());
396 }
397 source_ref = Some(serde_json::Value::String(v));
398 }
399 "--reject" => {
400 let v = need(args, i, &flag)?;
401 let (opt, why) = v
402 .split_once(':')
403 .ok_or("--reject expects \"<option>: <why>\"".to_string())?;
404 let (opt, why) = (opt.trim(), why.trim());
405 if opt.is_empty() || why.is_empty() {
406 return Err("--reject needs non-empty <option> and <why>".into());
407 }
408 drafts.push(DraftGround {
409 claim: why.into(),
410 supports: format!("rejected:{opt}"),
411 ..Default::default()
412 });
413 }
414 "--assume" => {
415 let claim = need(args, i, &flag)?;
416 drafts.push(DraftGround {
417 claim,
418 supports: "chosen".into(),
419 ..Default::default()
420 });
421 }
422 "--revisit" => {
423 last(&mut drafts, &flag)?.revisit = Some(need(args, i, &flag)?);
424 }
425 "--assume-test" => {
426 last(&mut drafts, &flag)?.test_ref = Some(need(args, i, &flag)?);
427 }
428 "--counter-test" => {
429 last(&mut drafts, &flag)?.counter_test = Some(need(args, i, &flag)?);
430 }
431 "--on-platform" => {
432 let v = need(args, i, &flag)?;
433 last(&mut drafts, &flag)?.platforms.push(v);
434 }
435 "--triggered-by" => {
436 let v = need(args, i, &flag)?;
437 last(&mut drafts, &flag)?.triggered_by.push(v);
438 }
439 "--surface" => {
440 let v = need(args, i, &flag)?;
441 last(&mut drafts, &flag)?.surfaces.push(v);
442 }
443 other => return Err(format!("decide: unknown flag {other}")),
444 }
445 i += 2;
446 }
447
448 let (decision, observe) = match (decision, &from_git) {
452 (Some(_), Some(_)) => {
453 return Err("decide: decision given twice (positional and --from-git)".into())
454 }
455 (None, None) => return Err("decide: needs a decision (positional) or --from-git".into()),
456 (Some(d), None) => (d.to_string(), observe),
457 (None, Some(commit)) => {
458 let env = read_envelope(repo, commit)?;
459 if blame_override.is_none() {
462 blame_override = Some(match subject_role(&env.subject) {
463 Some(role) => role.to_string(),
464 None => env.author,
465 });
466 }
467 let observe = std::iter::once(observe)
469 .chain(subject_refs(&env.subject))
470 .chain(env.refs)
471 .filter(|s| !s.is_empty())
472 .collect::<Vec<_>>()
473 .join(" ");
474 (env.subject, observe)
475 }
476 };
477 if decision.trim().is_empty() {
478 return Err("decision text is empty".into());
479 }
480 let blame = resolve_blame(repo, blame_override)?;
481 let mut grounds = Vec::new();
482 for d in drafts {
483 grounds.push(build_ground(repo, d, &sha_override, authority.as_deref())?);
486 }
487 append(
490 repo,
491 Decision {
492 observe,
493 decision: decision.to_string(),
494 grounds,
495 blame,
496 authority,
497 jurisdiction,
498 source_ref,
499 provenance: None,
502 corrects: None,
503 },
504 )
505}
506
507#[cfg(test)]
508mod tests {
509 use super::*;
510 use crate::tick::Check;
511
512 fn repo() -> std::path::PathBuf {
513 use std::sync::atomic::{AtomicU64, Ordering};
514 static N: AtomicU64 = AtomicU64::new(0);
515 let p = std::env::temp_dir().join(format!(
516 "ev-capture-{}-{}",
517 std::process::id(),
518 N.fetch_add(1, Ordering::Relaxed)
519 ));
520 let _ = std::fs::remove_dir_all(&p);
521 std::fs::create_dir_all(&p).unwrap();
522 Store::at(&p).init().unwrap();
523 p
524 }
525 fn s(v: &[&str]) -> Vec<String> {
526 v.iter().map(|x| x.to_string()).collect()
527 }
528
529 #[test]
530 fn decide_should_record_a_chosen_a_revisit_and_a_rejected_road_when_all_are_passed() {
531 let r = repo();
533
534 let t = run(
536 &r,
537 Some("build our own retrieval; reject pgvector"),
538 &s(&[
539 "--observe",
540 "evaluating backend",
541 "--assume",
542 "team has bandwidth long-term",
543 "--revisit",
544 "Q3 review",
545 "--reject",
546 "pgvector: would lock our schema",
547 "--blame",
548 "Wang Yu",
549 ]),
550 )
551 .expect("ok");
552
553 assert_eq!(t.grounds.len(), 2);
555 assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
556 assert_eq!(t.grounds[1].supports, "rejected:pgvector");
557 assert_eq!(t.blame, "Wang Yu");
558 assert_eq!(Store::at(&r).read_head().unwrap(), t.id);
559 }
560
561 #[test]
562 fn decide_should_stamp_held_since_with_a_nonempty_rfc3339_time_when_recording() {
563 let r = repo();
565
566 run(&r, Some("ship it"), &s(&["--blame", "Wang Yu"])).expect("ok");
568
569 let head = Store::at(&r).read_head().unwrap();
571 let tick = Store::at(&r).read_tick(&head).unwrap().unwrap();
572 assert!(!tick.held_since.is_empty());
573 time::OffsetDateTime::parse(
574 &tick.held_since,
575 &time::format_description::well_known::Rfc3339,
576 )
577 .expect("held_since parses as RFC 3339");
578 }
579
580 #[test]
581 fn decide_should_store_a_trimmed_blame_when_the_blame_is_padded() {
582 let r = repo();
584
585 let t = run(
587 &r,
588 Some("d"),
589 &s(&["--assume", "c", "--blame", " Wang Yu "]),
590 )
591 .expect("ok");
592
593 assert_eq!(t.blame, "Wang Yu");
595 }
596
597 #[test]
598 fn decide_should_refuse_the_ground_when_it_is_both_revisit_and_assume_test() {
599 let r = repo();
601
602 let e = run(
604 &r,
605 Some("d"),
606 &s(&[
607 "--assume",
608 "c",
609 "--revisit",
610 "Q3",
611 "--assume-test",
612 "pytest x",
613 "--blame",
614 "Wang Yu",
615 ]),
616 );
617
618 assert!(e.is_err());
620 }
621
622 #[test]
623 fn decide_should_refuse_a_tripwire_on_a_rejected_road_when_authority_is_absent() {
624 let r = repo();
626
627 let e = run(
629 &r,
630 Some("d"),
631 &s(&[
632 "--reject",
633 "pgvector: would lock our schema",
634 "--assume-test",
635 "pytest x",
636 "--counter-test",
637 "ct",
638 "--on-platform",
639 "linux-ci",
640 "--triggered-by",
641 "f",
642 "--surface",
643 "s",
644 "--verified-at-sha",
645 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
646 "--blame",
647 "Wang Yu",
648 ]),
649 );
650
651 assert!(e.is_err());
653 }
654
655 #[test]
656 fn decide_should_refuse_a_tripwire_on_a_rejected_road_when_authority_is_agent_disposable() {
657 let r = repo();
659
660 let e = run(
662 &r,
663 Some("d"),
664 &s(&[
665 "--reject",
666 "pgvector: would lock our schema",
667 "--assume-test",
668 "pytest x",
669 "--counter-test",
670 "ct",
671 "--on-platform",
672 "linux-ci",
673 "--triggered-by",
674 "f",
675 "--surface",
676 "s",
677 "--verified-at-sha",
678 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
679 "--authority",
680 "agent-disposable",
681 "--blame",
682 "Wang Yu",
683 ]),
684 );
685
686 assert!(e.is_err());
688 }
689
690 #[test]
691 fn decide_should_accept_a_tripwire_on_a_rejected_road_when_authority_is_user_ruled() {
692 let r = repo();
694
695 let t = run(
697 &r,
698 Some("keep Redis out"),
699 &s(&[
700 "--reject",
701 "Redis: a new infra dependency",
702 "--assume-test",
703 "! grep -q redis pyproject.toml",
704 "--counter-test",
705 "grep -q redis pyproject.toml",
706 "--on-platform",
707 "linux-ci",
708 "--triggered-by",
709 "pyproject.toml",
710 "--surface",
711 "pyproject-deps",
712 "--verified-at-sha",
713 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
714 "--authority",
715 "user-ruled",
716 "--blame",
717 "Wang Yu",
718 ]),
719 )
720 .expect("a user-ruled rejected-road tripwire is allowed");
721
722 let g = t
724 .grounds
725 .iter()
726 .find(|g| g.supports.starts_with("rejected:"))
727 .expect("a rejected road");
728 assert!(
729 matches!(g.check, Some(Check::Test { .. })),
730 "the closed road carries a tripwire"
731 );
732 }
733
734 #[test]
735 fn decide_should_refuse_a_user_ruled_rejected_road_tripwire_when_the_counter_test_is_missing() {
736 let r = repo();
738
739 let e = run(
741 &r,
742 Some("keep Redis out"),
743 &s(&[
744 "--reject",
745 "Redis: a new infra dependency",
746 "--assume-test",
747 "! grep -q redis pyproject.toml",
748 "--on-platform",
749 "linux-ci",
750 "--triggered-by",
751 "pyproject.toml",
752 "--surface",
753 "pyproject-deps",
754 "--verified-at-sha",
755 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
756 "--authority",
757 "user-ruled",
758 "--blame",
759 "Wang Yu",
760 ]),
761 );
762
763 assert!(e.is_err());
765 }
766
767 #[test]
768 fn decide_should_still_refuse_a_revisit_on_a_rejected_road_even_when_user_ruled() {
769 let r = repo();
771
772 let e = run(
774 &r,
775 Some("keep Redis out"),
776 &s(&[
777 "--reject",
778 "Redis: a new infra dependency",
779 "--revisit",
780 "Q3 infra review",
781 "--authority",
782 "user-ruled",
783 "--blame",
784 "Wang Yu",
785 ]),
786 );
787
788 assert!(e.is_err());
790 }
791
792 #[test]
793 fn decide_should_error_when_there_is_no_store() {
794 let p = std::env::temp_dir().join(format!("ev-nostore-{}", std::process::id()));
796 let _ = std::fs::remove_dir_all(&p);
797 std::fs::create_dir_all(&p).unwrap();
798
799 let e = run(&p, Some("d"), &s(&["--blame", "x"]));
801
802 assert!(e.is_err());
804 }
805
806 #[test]
807 fn decide_should_build_a_self_verifying_test_binding_when_all_test_fields_are_present() {
808 let r = repo();
810
811 let t = run(
813 &r,
814 Some("restore-safety counter DB-backed; reject Redis"),
815 &s(&[
816 "--assume",
817 "Argus introduces no Redis; multi-pod coord via existing DB",
818 "--assume-test",
819 "pytest tests/test_redis_absent.py",
820 "--counter-test",
821 "pytest tests/test_redis_absent.py::test_redis_injection_flips_red",
822 "--on-platform",
823 "linux-ci",
824 "--triggered-by",
825 "pyproject.toml",
826 "--surface",
827 "pyproject-deps",
828 "--verified-at-sha",
829 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
830 "--reject",
831 "Redis: a new infra dependency",
832 "--blame",
833 "Wang Yu",
834 ]),
835 )
836 .expect("ok");
837
838 match &t.grounds[0].check {
840 Some(Check::Test {
841 reference,
842 counter_test,
843 liveness,
844 verified_at_sha,
845 }) => {
846 assert_eq!(reference, "pytest tests/test_redis_absent.py");
847 assert!(counter_test
848 .as_deref()
849 .is_some_and(|c| c.contains("flips_red")));
850 assert_eq!(liveness.platforms, vec!["linux-ci".to_string()]);
851 assert_eq!(verified_at_sha.len(), 40);
852 }
853 _ => panic!("expected a test check"),
854 }
855 }
856
857 #[test]
858 fn decide_should_reject_a_test_binding_when_there_is_no_counter_test() {
859 let r = repo();
861
862 let e = run(
864 &r,
865 Some("d"),
866 &s(&[
867 "--assume",
868 "c",
869 "--assume-test",
870 "pytest x",
871 "--on-platform",
872 "linux-ci",
873 "--triggered-by",
874 "f",
875 "--surface",
876 "s",
877 "--verified-at-sha",
878 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
879 "--blame",
880 "Wang Yu",
881 ]),
882 );
883
884 assert!(e.is_err());
886 }
887
888 #[test]
889 fn decide_should_reject_a_test_binding_when_the_counter_test_is_empty() {
890 let r = repo();
892
893 let e = run(
895 &r,
896 Some("d"),
897 &s(&[
898 "--assume",
899 "c",
900 "--assume-test",
901 "pytest x",
902 "--counter-test",
903 "",
904 "--on-platform",
905 "linux-ci",
906 "--triggered-by",
907 "f",
908 "--surface",
909 "s",
910 "--verified-at-sha",
911 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
912 "--blame",
913 "Wang Yu",
914 ]),
915 );
916
917 assert!(e.is_err());
919 }
920
921 #[test]
922 fn decide_should_reject_a_test_binding_when_there_is_no_verified_at_sha_and_no_git() {
923 let r = repo();
925
926 let e = run(
928 &r,
929 Some("d"),
930 &s(&[
931 "--assume",
932 "c",
933 "--assume-test",
934 "pytest x",
935 "--counter-test",
936 "ct",
937 "--on-platform",
938 "linux-ci",
939 "--triggered-by",
940 "f",
941 "--surface",
942 "s",
943 "--blame",
944 "Wang Yu",
945 ]),
946 );
947
948 assert!(e.is_err());
950 }
951
952 #[test]
953 fn migrate_bind_should_build_a_harvested_test_check_when_no_counter_test() {
954 let check = harvested_test_check(
957 "pytest tests/test_invariant_no_redis.py".into(),
958 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
959 vec!["linux-ci".into()],
960 vec!["pyproject.toml".into()],
961 vec!["pyproject-deps".into()],
962 )
963 .expect("the full liveness is present, so the harvested binding is well-formed");
964
965 match check {
967 Check::Test {
968 reference,
969 counter_test,
970 liveness,
971 verified_at_sha,
972 } => {
973 assert_eq!(reference, "pytest tests/test_invariant_no_redis.py");
974 assert_eq!(counter_test, None); assert_eq!(liveness.platforms, vec!["linux-ci".to_string()]);
976 assert_eq!(liveness.triggered_by, vec!["pyproject.toml".to_string()]);
977 assert_eq!(liveness.surfaces, vec!["pyproject-deps".to_string()]);
978 assert_eq!(verified_at_sha.len(), 40);
979 }
980 _ => panic!("expected a harvested test check"),
981 }
982 }
983
984 #[test]
985 fn migrate_bind_should_reject_a_harvested_binding_when_a_liveness_key_is_missing() {
986 let e = harvested_test_check(
988 "pytest x".into(),
989 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into(),
990 vec!["linux-ci".into()],
991 vec!["pyproject.toml".into()],
992 vec![], );
994
995 assert!(e.is_err());
997 }
998
999 #[test]
1000 fn decide_should_still_error_without_a_counter_test() {
1001 let r = repo();
1005
1006 let e = run(
1008 &r,
1009 Some("d"),
1010 &s(&[
1011 "--assume",
1012 "c",
1013 "--assume-test",
1014 "pytest x",
1015 "--on-platform",
1016 "linux-ci",
1017 "--triggered-by",
1018 "f",
1019 "--surface",
1020 "s",
1021 "--verified-at-sha",
1022 "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
1023 "--blame",
1024 "Wang Yu",
1025 ]),
1026 );
1027
1028 assert!(e.is_err());
1030 }
1031
1032 #[test]
1033 fn append_should_compute_the_frozen_genesis_id_when_given_the_genesis_decision() {
1034 let r = repo();
1037 let d = Decision {
1038 observe: "evaluating retrieval backend".into(),
1039 decision: "freeze the retrieval schema for v2".into(),
1040 grounds: vec![
1041 Ground {
1042 claim: "team still wants a frozen schema".into(),
1043 supports: "chosen".into(),
1044 check: Some(Check::Person {
1045 reference: "Q3 infra review".into(),
1046 }),
1047 },
1048 Ground {
1049 claim: "pgvector would lock our schema".into(),
1050 supports: "rejected:pgvector".into(),
1051 check: None,
1052 },
1053 ],
1054 blame: "Wang Yu".into(),
1055 authority: None,
1056 jurisdiction: None,
1057 source_ref: None,
1058 provenance: None,
1059 corrects: None,
1060 };
1061
1062 let t = append(&r, d).expect("ok");
1064
1065 assert_eq!(t.id, "e2b337f53a1f");
1068 assert_eq!(t.parent_id, "");
1069 assert_eq!(Store::at(&r).read_head().unwrap(), t.id);
1070 }
1071
1072 #[test]
1073 fn decide_should_take_blame_from_git_config_when_no_blame_flag_is_given() {
1074 let r = repo();
1076 for a in [
1077 ["init"].as_slice(),
1078 ["config", "user.name", "Ada Lovelace"].as_slice(),
1079 ] {
1080 std::process::Command::new("git")
1081 .args(a)
1082 .current_dir(&r)
1083 .output()
1084 .unwrap();
1085 }
1086
1087 let t = run(&r, Some("d"), &s(&["--assume", "c"])).expect("ok");
1089
1090 assert_eq!(t.blame, "Ada Lovelace");
1092 }
1093}