1use crate::canonical::compute_id;
4use crate::store::Store;
5use crate::tick::{Check, Ground, Liveness, Tick};
6use std::path::Path;
7
8pub struct GuardArgs {
9 pub selector: String,
10 pub id: String,
11 pub target: Option<String>, pub counter_test: String,
13 pub platforms: Vec<String>,
14 pub triggered_by: Vec<String>,
15 pub surfaces: Vec<String>,
16 pub verified_at_sha: Option<String>,
17 pub blame: Option<String>,
18}
19
20fn resolve_target(grounds: &[Ground], target: &Option<String>) -> Result<usize, String> {
21 let unbound: Vec<usize> = grounds
22 .iter()
23 .enumerate()
24 .filter(|(_, g)| g.check.is_none())
25 .map(|(i, _)| i)
26 .collect();
27 match target {
28 None => match unbound.as_slice() {
29 [one] => Ok(*one),
30 [] => Err("no unbound ground to guard".into()),
31 _ => Err("more than one unbound ground — name the target (claim or index)".into()),
32 },
33 Some(t) => {
34 if let Ok(idx) = t.parse::<usize>() {
35 if idx < grounds.len() {
36 return Ok(idx);
37 }
38 return Err(format!("ground index {idx} out of range"));
39 }
40 let matches: Vec<usize> = grounds
41 .iter()
42 .enumerate()
43 .filter(|(_, g)| g.claim == *t)
44 .map(|(i, _)| i)
45 .collect();
46 match matches.as_slice() {
47 [one] => Ok(*one),
48 [] => Err(format!("no ground with claim {t:?}")),
49 _ => Err(format!("ambiguous: multiple grounds with claim {t:?}")),
50 }
51 }
52 }
53}
54
55pub fn run(repo: &Path, a: GuardArgs) -> Result<Tick, String> {
56 let store = Store::at(repo);
57 let parent = store
58 .read_tick(&a.id)
59 .map_err(|e| format!("{e}"))?
60 .ok_or(format!("no tick with id {}", a.id))?;
61 let head = store
62 .read_head()
63 .map_err(|e| format!("reading HEAD: {e}"))?;
64 if a.id != head {
65 return Err(format!(
66 "guard can only amend the current HEAD decision; {} is not HEAD ({})",
67 a.id, head
68 ));
69 }
70 let idx = resolve_target(&parent.grounds, &a.target)?;
71 let g = &parent.grounds[idx];
72 if let Some(Check::Person { .. }) = g.check {
74 return Err("a human-rechecked ground cannot carry a test (R2 hard error)".into());
75 }
76 if g.supports.starts_with("rejected:") {
77 return Err("a road-not-taken (rejected) ground cannot carry a test in 0.1.0 — reserved for a future rejection-rationale liveness feature".into());
78 }
79 if g.check.is_some() {
80 return Err("ground already has a check".into());
81 }
82 if a.counter_test.trim().is_empty() {
83 return Err("a test binding requires a counter-test (no vacuous binding)".into());
84 }
85 if a.platforms.is_empty() || a.triggered_by.is_empty() || a.surfaces.is_empty() {
86 return Err(
87 "a test binding requires at least one platform, triggered-by, and surface".into(),
88 );
89 }
90 let verified_at_sha = crate::capture::resolve_sha(repo, &a.verified_at_sha)?;
91 let blame = crate::capture::resolve_blame(repo, a.blame)?;
92
93 let mut grounds = parent.grounds.clone();
94 grounds[idx] = Ground {
95 claim: grounds[idx].claim.clone(),
96 supports: grounds[idx].supports.clone(),
97 check: Some(Check::Test {
98 reference: a.selector,
99 verified_at_sha,
100 counter_test: a.counter_test,
101 liveness: Liveness {
102 platforms: a.platforms,
103 triggered_by: a.triggered_by,
104 surfaces: a.surfaces,
105 },
106 }),
107 };
108 let mut child = Tick {
109 id: String::new(),
110 parent_id: parent.id.clone(),
111 observe: parent.observe.clone(),
112 decision: parent.decision.clone(),
113 grounds,
114 status: "live".into(),
115 held_since: String::new(),
116 blame,
117 };
118 child.id = compute_id(&child);
119 store
120 .write_tick(&child)
121 .map_err(|e| format!("writing tick: {e}"))?;
122 Ok(child)
123}
124
125#[cfg(test)]
126mod tests {
127 use super::*;
128
129 fn repo_with_unbound() -> (std::path::PathBuf, String) {
130 use std::sync::atomic::{AtomicU64, Ordering};
131 static N: AtomicU64 = AtomicU64::new(0);
132 let p = std::env::temp_dir().join(format!(
133 "ev-guard-{}-{}",
134 std::process::id(),
135 N.fetch_add(1, Ordering::Relaxed)
136 ));
137 let _ = std::fs::remove_dir_all(&p);
138 std::fs::create_dir_all(&p).unwrap();
139 Store::at(&p).init().unwrap();
140 let args: Vec<String> = [
141 "--assume",
142 "schema stays frozen",
143 "--assume",
144 "team ok",
145 "--revisit",
146 "Q3",
147 "--blame",
148 "Wang Yu",
149 ]
150 .iter()
151 .map(|x| x.to_string())
152 .collect();
153 let t = crate::capture::run(&p, "build our own retrieval", &args).unwrap();
154 (p, t.id)
155 }
156 fn args(selector: &str, id: &str, target: Option<&str>) -> GuardArgs {
157 GuardArgs {
158 selector: selector.into(),
159 id: id.into(),
160 target: target.map(|s| s.into()),
161 counter_test: "pytest x::counter".into(),
162 platforms: vec!["linux-ci".into()],
163 triggered_by: vec!["f".into()],
164 surfaces: vec!["s".into()],
165 verified_at_sha: Some("d308afac1b2c3d4e5f60718293a4b5c6d7e8f901".into()),
166 blame: Some("Wang Yu".into()),
167 }
168 }
169
170 #[test]
171 fn guard_should_bind_a_named_unbound_ground_and_write_a_child_when_the_target_is_named() {
172 let (p, id) = repo_with_unbound();
174
175 let child = run(
177 &p,
178 args(
179 "pytest tests/test_schema_frozen.py",
180 &id,
181 Some("schema stays frozen"),
182 ),
183 )
184 .expect("ok");
185
186 assert_eq!(child.parent_id, id);
188 let i = child
189 .grounds
190 .iter()
191 .position(|g| g.claim == "schema stays frozen")
192 .unwrap();
193 assert!(matches!(child.grounds[i].check, Some(Check::Test { .. })));
194 }
195
196 #[test]
197 fn guard_should_refuse_the_target_when_the_ground_is_human_rechecked() {
198 let (p, id) = repo_with_unbound();
200
201 let e = run(&p, args("pytest x", &id, Some("team ok")));
203
204 assert!(e.is_err());
206 }
207
208 #[test]
209 fn guard_should_require_a_target_when_more_than_one_ground_is_unbound() {
210 let (p, _id) = repo_with_unbound();
212 let t2 = crate::capture::run(
213 &p,
214 "d2",
215 &["--assume", "a", "--assume", "b", "--blame", "Wang Yu"]
216 .iter()
217 .map(|x| x.to_string())
218 .collect::<Vec<_>>(),
219 )
220 .unwrap();
221
222 let e = run(&p, args("pytest x", &t2.id, None));
224
225 assert!(e.is_err());
227 }
228
229 #[test]
230 fn guard_should_refuse_the_target_when_it_is_not_head() {
231 let p = repo_with_unbound().0;
233 let t1 = crate::capture::run(
234 &p,
235 "d1",
236 &["--assume", "a", "--blame", "Wang Yu"]
237 .iter()
238 .map(|x| x.to_string())
239 .collect::<Vec<_>>(),
240 )
241 .unwrap();
242 let _t2 = crate::capture::run(
243 &p,
244 "d2",
245 &["--assume", "b", "--blame", "Wang Yu"]
246 .iter()
247 .map(|x| x.to_string())
248 .collect::<Vec<_>>(),
249 )
250 .unwrap();
251
252 let e = run(&p, args("pytest x", &t1.id, Some("a")));
254
255 assert!(e.is_err());
257 }
258
259 #[test]
260 fn guard_should_refuse_the_target_when_the_ground_is_a_rejected_road() {
261 let p = repo_with_unbound().0;
263 let t = crate::capture::run(
264 &p,
265 "d",
266 &["--reject", "x: y", "--blame", "Wang Yu"]
267 .iter()
268 .map(|x| x.to_string())
269 .collect::<Vec<_>>(),
270 )
271 .unwrap();
272
273 let e = run(&p, args("pytest x", &t.id, Some("y")));
275
276 assert!(e.is_err());
278 }
279}