Skip to main content

ev/
guard.rs

1//! `ev guard "<selector>" <id> [<ground>]` — attach an existing test to a ground as a
2//! data check (after the fact). Because `check` is hashed, this writes a NEW CHILD.
3use 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>, // ground claim or index; required if >1 unbound ground
12    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    // R2: a human-rechecked (person) ground can never be force-bound to a test.
73    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        // given: a HEAD tick with an unbound "schema stays frozen" ground
173        let (p, id) = repo_with_unbound();
174
175        // when: that named ground is guarded
176        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        // then: a child is written and the named ground now carries a test check
187        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        // given: a HEAD tick whose "team ok" ground is a human-rechecked (person) check
199        let (p, id) = repo_with_unbound();
200
201        // when: that person ground is guarded with a test
202        let e = run(&p, args("pytest x", &id, Some("team ok")));
203
204        // then: it is refused
205        assert!(e.is_err());
206    }
207
208    #[test]
209    fn guard_should_require_a_target_when_more_than_one_ground_is_unbound() {
210        // given: a HEAD tick with two unbound grounds and no target named
211        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        // when: the guard is run without naming a target
223        let e = run(&p, args("pytest x", &t2.id, None));
224
225        // then: it is refused
226        assert!(e.is_err());
227    }
228
229    #[test]
230    fn guard_should_refuse_the_target_when_it_is_not_head() {
231        // given: two decisions in a chain, so the first is no longer HEAD
232        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        // when: the non-HEAD first tick is guarded
253        let e = run(&p, args("pytest x", &t1.id, Some("a")));
254
255        // then: it is refused
256        assert!(e.is_err());
257    }
258
259    #[test]
260    fn guard_should_refuse_the_target_when_the_ground_is_a_rejected_road() {
261        // given: a HEAD tick whose only ground is a rejected road
262        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        // when: that rejected ground is guarded with a test
274        let e = run(&p, args("pytest x", &t.id, Some("y")));
275
276        // then: it is refused
277        assert!(e.is_err());
278    }
279}