Skip to main content

ev/
capture.rs

1//! `ev decide` — walk the trailing args left-to-right into a draft, validate, append a child.
2use 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, // "chosen" | "rejected:<opt>"
12    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
31/// Resolve the declared author: --blame, else `git config user.name`.
32pub(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    let ok = sha.len() == 40
72        && sha
73            .bytes()
74            .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b));
75    if !ok {
76        return Err(format!("verified_at_sha must be 40 lowercase hex: {sha}"));
77    }
78    Ok(sha)
79}
80
81fn t_grounds_text(grounds: &[Ground]) -> Vec<String> {
82    grounds.iter().map(|g| g.claim.clone()).collect()
83}
84
85fn build_ground(
86    repo: &Path,
87    d: DraftGround,
88    sha_override: &Option<String>,
89) -> Result<Ground, String> {
90    use crate::tick::Liveness;
91    if d.claim.is_empty() {
92        return Err("ground claim is empty".into());
93    }
94    if d.supports.starts_with("rejected:") && (d.test_ref.is_some() || d.revisit.is_some()) {
95        return Err("a road-not-taken (rejected) ground cannot carry a check in 0.1.0 — reserved for a future rejection-rationale liveness feature".into());
96    }
97    if d.revisit.is_some() && d.test_ref.is_some() {
98        return Err("a ground cannot be both --revisit and --assume-test (R2)".into());
99    }
100    let has_test_fields = d.counter_test.is_some()
101        || !d.platforms.is_empty()
102        || !d.triggered_by.is_empty()
103        || !d.surfaces.is_empty();
104    let check = match (d.test_ref, d.revisit) {
105        (Some(reference), _) => {
106            let counter_test = d
107                .counter_test
108                .ok_or("a test binding requires --counter-test (no vacuous binding)".to_string())?;
109            if d.platforms.is_empty() || d.triggered_by.is_empty() || d.surfaces.is_empty() {
110                return Err("a test binding requires at least one --on-platform, --triggered-by, and --surface".into());
111            }
112            let verified_at_sha = resolve_sha(repo, sha_override)?;
113            Some(Check::Test {
114                reference,
115                verified_at_sha,
116                counter_test,
117                liveness: Liveness {
118                    platforms: d.platforms,
119                    triggered_by: d.triggered_by,
120                    surfaces: d.surfaces,
121                },
122            })
123        }
124        (None, Some(when)) => {
125            if has_test_fields {
126                return Err(
127                    "--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
128                        .into(),
129                );
130            }
131            Some(Check::Person { reference: when })
132        }
133        (None, None) => {
134            if has_test_fields {
135                return Err(
136                    "--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
137                        .into(),
138                );
139            }
140            None
141        }
142    };
143    Ok(Ground {
144        claim: d.claim,
145        supports: d.supports,
146        check,
147    })
148}
149
150pub fn run(repo: &Path, decision: &str, args: &[String]) -> Result<Tick, String> {
151    if decision.trim().is_empty() {
152        return Err("decision text is empty".into());
153    }
154    let mut observe = String::new();
155    let mut blame_override: Option<String> = None;
156    let mut sha_override: Option<String> = None;
157    let mut drafts: Vec<DraftGround> = Vec::new();
158    let mut i = 0;
159    while i < args.len() {
160        let flag = args[i].clone();
161        match flag.as_str() {
162            "--observe" => {
163                observe = need(args, i, &flag)?;
164            }
165            "--blame" => {
166                blame_override = Some(need(args, i, &flag)?);
167            }
168            "--verified-at-sha" => {
169                sha_override = Some(need(args, i, &flag)?);
170            }
171            "--reject" => {
172                let v = need(args, i, &flag)?;
173                let (opt, why) = v
174                    .split_once(':')
175                    .ok_or("--reject expects \"<option>: <why>\"".to_string())?;
176                let (opt, why) = (opt.trim(), why.trim());
177                if opt.is_empty() || why.is_empty() {
178                    return Err("--reject needs non-empty <option> and <why>".into());
179                }
180                drafts.push(DraftGround {
181                    claim: why.into(),
182                    supports: format!("rejected:{opt}"),
183                    ..Default::default()
184                });
185            }
186            "--assume" => {
187                let claim = need(args, i, &flag)?;
188                drafts.push(DraftGround {
189                    claim,
190                    supports: "chosen".into(),
191                    ..Default::default()
192                });
193            }
194            "--revisit" => {
195                last(&mut drafts, &flag)?.revisit = Some(need(args, i, &flag)?);
196            }
197            "--assume-test" => {
198                last(&mut drafts, &flag)?.test_ref = Some(need(args, i, &flag)?);
199            }
200            "--counter-test" => {
201                last(&mut drafts, &flag)?.counter_test = Some(need(args, i, &flag)?);
202            }
203            "--on-platform" => {
204                let v = need(args, i, &flag)?;
205                last(&mut drafts, &flag)?.platforms.push(v);
206            }
207            "--triggered-by" => {
208                let v = need(args, i, &flag)?;
209                last(&mut drafts, &flag)?.triggered_by.push(v);
210            }
211            "--surface" => {
212                let v = need(args, i, &flag)?;
213                last(&mut drafts, &flag)?.surfaces.push(v);
214            }
215            other => return Err(format!("decide: unknown flag {other}")),
216        }
217        i += 2;
218    }
219    let blame = resolve_blame(repo, blame_override)?;
220    let mut grounds = Vec::new();
221    for d in drafts {
222        grounds.push(build_ground(repo, d, &sha_override)?);
223    }
224    for field in std::iter::once(decision.to_string())
225        .chain(std::iter::once(observe.clone()))
226        .chain(t_grounds_text(&grounds))
227    {
228        for verb in crate::lint::r3_self_evolve(&field) {
229            eprintln!("warning: \"{verb}\" should take a human subject, not the system (best-effort lint; a re-wording evades it)");
230        }
231    }
232    let store = Store::at(repo);
233    if !store.exists() {
234        return Err("no .evolving/ store here — run `ev init` first".into());
235    }
236    let parent_id = store
237        .read_head()
238        .map_err(|e| format!("reading HEAD: {e}"))?;
239    let mut t = Tick {
240        id: String::new(),
241        parent_id,
242        observe,
243        decision: decision.to_string(),
244        grounds,
245        status: "live".into(),
246        held_since: String::new(),
247        blame,
248    };
249    t.id = compute_id(&t);
250    store
251        .write_tick(&t)
252        .map_err(|e| format!("writing tick: {e}"))?;
253    Ok(t)
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::tick::Check;
260
261    fn repo() -> std::path::PathBuf {
262        use std::sync::atomic::{AtomicU64, Ordering};
263        static N: AtomicU64 = AtomicU64::new(0);
264        let p = std::env::temp_dir().join(format!(
265            "ev-capture-{}-{}",
266            std::process::id(),
267            N.fetch_add(1, Ordering::Relaxed)
268        ));
269        let _ = std::fs::remove_dir_all(&p);
270        std::fs::create_dir_all(&p).unwrap();
271        Store::at(&p).init().unwrap();
272        p
273    }
274    fn s(v: &[&str]) -> Vec<String> {
275        v.iter().map(|x| x.to_string()).collect()
276    }
277
278    #[test]
279    fn decide_should_record_a_chosen_a_revisit_and_a_rejected_road_when_all_are_passed() {
280        // given: a store and decide args with a chosen+revisit ground and a rejected road
281        let r = repo();
282
283        // when: the decision is captured
284        let t = run(
285            &r,
286            "build our own retrieval; reject pgvector",
287            &s(&[
288                "--observe",
289                "evaluating backend",
290                "--assume",
291                "team has bandwidth long-term",
292                "--revisit",
293                "Q3 review",
294                "--reject",
295                "pgvector: would lock our schema",
296                "--blame",
297                "Wang Yu",
298            ]),
299        )
300        .expect("ok");
301
302        // then: both grounds, the person check, the rejected support, blame, and HEAD all hold
303        assert_eq!(t.grounds.len(), 2);
304        assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
305        assert_eq!(t.grounds[1].supports, "rejected:pgvector");
306        assert_eq!(t.blame, "Wang Yu");
307        assert_eq!(Store::at(&r).read_head().unwrap(), t.id);
308    }
309
310    #[test]
311    fn decide_should_store_a_trimmed_blame_when_the_blame_is_padded() {
312        // given: a store and decide args with a padded --blame
313        let r = repo();
314
315        // when: the decision is captured
316        let t = run(&r, "d", &s(&["--assume", "c", "--blame", "  Wang Yu  "])).expect("ok");
317
318        // then: the stored blame is trimmed
319        assert_eq!(t.blame, "Wang Yu");
320    }
321
322    #[test]
323    fn decide_should_refuse_the_ground_when_it_is_both_revisit_and_assume_test() {
324        // given: a store and decide args binding one ground to both --revisit and --assume-test
325        let r = repo();
326
327        // when: the decision is captured
328        let e = run(
329            &r,
330            "d",
331            &s(&[
332                "--assume",
333                "c",
334                "--revisit",
335                "Q3",
336                "--assume-test",
337                "pytest x",
338                "--blame",
339                "Wang Yu",
340            ]),
341        );
342
343        // then: it is refused
344        assert!(e.is_err());
345    }
346
347    #[test]
348    fn decide_should_refuse_a_check_when_the_ground_is_a_rejected_road() {
349        // given: a store and decide args attaching an --assume-test to a --reject road
350        let r = repo();
351
352        // when: the decision is captured
353        let e = run(
354            &r,
355            "d",
356            &s(&[
357                "--reject",
358                "pgvector: would lock our schema",
359                "--assume-test",
360                "pytest x",
361                "--counter-test",
362                "ct",
363                "--on-platform",
364                "linux-ci",
365                "--triggered-by",
366                "f",
367                "--surface",
368                "s",
369                "--verified-at-sha",
370                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
371                "--blame",
372                "Wang Yu",
373            ]),
374        );
375
376        // then: it is refused
377        assert!(e.is_err());
378    }
379
380    #[test]
381    fn decide_should_error_when_there_is_no_store() {
382        // given: a directory with no .evolving/ store
383        let p = std::env::temp_dir().join(format!("ev-nostore-{}", std::process::id()));
384        let _ = std::fs::remove_dir_all(&p);
385        std::fs::create_dir_all(&p).unwrap();
386
387        // when: a decision is captured there
388        let e = run(&p, "d", &s(&["--blame", "x"]));
389
390        // then: it errors
391        assert!(e.is_err());
392    }
393
394    #[test]
395    fn decide_should_build_a_self_verifying_test_binding_when_all_test_fields_are_present() {
396        // given: a store and decide args with a fully specified test binding plus a rejected road
397        let r = repo();
398
399        // when: the decision is captured
400        let t = run(
401            &r,
402            "restore-safety counter DB-backed; reject Redis",
403            &s(&[
404                "--assume",
405                "Argus introduces no Redis; multi-pod coord via existing DB",
406                "--assume-test",
407                "pytest tests/test_redis_absent.py",
408                "--counter-test",
409                "pytest tests/test_redis_absent.py::test_redis_injection_flips_red",
410                "--on-platform",
411                "linux-ci",
412                "--triggered-by",
413                "pyproject.toml",
414                "--surface",
415                "pyproject-deps",
416                "--verified-at-sha",
417                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
418                "--reject",
419                "Redis: a new infra dependency",
420                "--blame",
421                "Wang Yu",
422            ]),
423        )
424        .expect("ok");
425
426        // then: the first ground carries a fully populated test check
427        match &t.grounds[0].check {
428            Some(Check::Test {
429                reference,
430                counter_test,
431                liveness,
432                verified_at_sha,
433            }) => {
434                assert_eq!(reference, "pytest tests/test_redis_absent.py");
435                assert!(counter_test.contains("flips_red"));
436                assert_eq!(liveness.platforms, vec!["linux-ci".to_string()]);
437                assert_eq!(verified_at_sha.len(), 40);
438            }
439            _ => panic!("expected a test check"),
440        }
441    }
442
443    #[test]
444    fn decide_should_reject_a_test_binding_when_there_is_no_counter_test() {
445        // given: a store and a test binding missing --counter-test
446        let r = repo();
447
448        // when: the decision is captured
449        let e = run(
450            &r,
451            "d",
452            &s(&[
453                "--assume",
454                "c",
455                "--assume-test",
456                "pytest x",
457                "--on-platform",
458                "linux-ci",
459                "--triggered-by",
460                "f",
461                "--surface",
462                "s",
463                "--verified-at-sha",
464                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
465                "--blame",
466                "Wang Yu",
467            ]),
468        );
469
470        // then: it is rejected
471        assert!(e.is_err());
472    }
473
474    #[test]
475    fn decide_should_reject_a_test_binding_when_there_is_no_verified_at_sha_and_no_git() {
476        // given: a store and a test binding with no --verified-at-sha in a non-git dir
477        let r = repo();
478
479        // when: the decision is captured
480        let e = run(
481            &r,
482            "d",
483            &s(&[
484                "--assume",
485                "c",
486                "--assume-test",
487                "pytest x",
488                "--counter-test",
489                "ct",
490                "--on-platform",
491                "linux-ci",
492                "--triggered-by",
493                "f",
494                "--surface",
495                "s",
496                "--blame",
497                "Wang Yu",
498            ]),
499        );
500
501        // then: it is rejected
502        assert!(e.is_err());
503    }
504
505    #[test]
506    fn decide_should_take_blame_from_git_config_when_no_blame_flag_is_given() {
507        // given: a store inside a git repo with a configured author, and no --blame
508        let r = repo();
509        for a in [
510            ["init"].as_slice(),
511            ["config", "user.name", "Ada Lovelace"].as_slice(),
512        ] {
513            std::process::Command::new("git")
514                .args(a)
515                .current_dir(&r)
516                .output()
517                .unwrap();
518        }
519
520        // when: a decision is captured without --blame
521        let t = run(&r, "d", &s(&["--assume", "c"])).expect("ok");
522
523        // then: blame is resolved from git config user.name
524        assert_eq!(t.blame, "Ada Lovelace");
525    }
526}