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    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
81/// One `git show -s --format=<fmt> <commit>` field, run in `repo`. Returns the trimmed
82/// stdout, or an error if git can't resolve the commit (the caller maps this to a clear message).
83fn 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
95/// The commit ENVELOPE we are allowed to seed from: subject (the decision text), author name
96/// (the default blame), and any `Refs #<n>` provenance lines from the body. The body is scanned
97/// ONLY for Refs lines — never parsed for grounds (those stay human-authored via --assume/--reject).
98struct Envelope {
99    subject: String,
100    author: String,
101    refs: Vec<String>,
102}
103
104/// The closed set of authoring roles a commit subject may declare, leading + `:`.
105const SUBJECT_ROLES: &[&str] = &["Dev", "QA", "Product", "Mac", "User"];
106
107/// The canonical role declared by a leading `<Role>:` prefix on the subject, if any
108/// (case-insensitive match against the closed vocabulary). The subject is otherwise untouched.
109fn 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
118/// Every `#<digits>` / `R<digits>` provenance token found in the subject, in order — the
119/// issue + round-id references a commit subject may carry (`re-milestone #1194 R2415`).
120fn 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
151/// Validate a declared authority value against the closed vocabulary.
152pub(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
160fn build_ground(
161    repo: &Path,
162    d: DraftGround,
163    sha_override: &Option<String>,
164) -> Result<Ground, String> {
165    use crate::tick::Liveness;
166    if d.claim.is_empty() {
167        return Err("ground claim is empty".into());
168    }
169    if d.supports.starts_with("rejected:") && (d.test_ref.is_some() || d.revisit.is_some()) {
170        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());
171    }
172    if d.revisit.is_some() && d.test_ref.is_some() {
173        return Err("a ground cannot be both --revisit and --assume-test (R2)".into());
174    }
175    let has_test_fields = d.counter_test.is_some()
176        || !d.platforms.is_empty()
177        || !d.triggered_by.is_empty()
178        || !d.surfaces.is_empty();
179    let check = match (d.test_ref, d.revisit) {
180        (Some(reference), _) => {
181            let counter_test = d
182                .counter_test
183                .ok_or("a test binding requires --counter-test (no vacuous binding)".to_string())?;
184            if d.platforms.is_empty() || d.triggered_by.is_empty() || d.surfaces.is_empty() {
185                return Err("a test binding requires at least one --on-platform, --triggered-by, and --surface".into());
186            }
187            let verified_at_sha = resolve_sha(repo, sha_override)?;
188            Some(Check::Test {
189                reference,
190                verified_at_sha,
191                counter_test,
192                liveness: Liveness {
193                    platforms: d.platforms,
194                    triggered_by: d.triggered_by,
195                    surfaces: d.surfaces,
196                },
197            })
198        }
199        (None, Some(when)) => {
200            if has_test_fields {
201                return Err(
202                    "--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
203                        .into(),
204                );
205            }
206            Some(Check::Person { reference: when })
207        }
208        (None, None) => {
209            if has_test_fields {
210                return Err(
211                    "--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
212                        .into(),
213                );
214            }
215            None
216        }
217    };
218    Ok(Ground {
219        claim: d.claim,
220        supports: d.supports,
221        check,
222    })
223}
224
225pub fn run(repo: &Path, decision: Option<&str>, args: &[String]) -> Result<Tick, String> {
226    let mut observe = String::new();
227    let mut blame_override: Option<String> = None;
228    let mut sha_override: Option<String> = None;
229    let mut authority: Option<String> = None;
230    let mut from_git: Option<String> = None;
231    let mut drafts: Vec<DraftGround> = Vec::new();
232    let mut i = 0;
233    while i < args.len() {
234        let flag = args[i].clone();
235        match flag.as_str() {
236            "--from-git" => {
237                from_git = Some(need(args, i, &flag)?);
238            }
239            "--observe" => {
240                observe = need(args, i, &flag)?;
241            }
242            "--blame" => {
243                blame_override = Some(need(args, i, &flag)?);
244            }
245            "--verified-at-sha" => {
246                sha_override = Some(need(args, i, &flag)?);
247            }
248            "--authority" => {
249                let v = need(args, i, &flag)?;
250                validate_authority(&v)?;
251                authority = Some(v);
252            }
253            "--reject" => {
254                let v = need(args, i, &flag)?;
255                let (opt, why) = v
256                    .split_once(':')
257                    .ok_or("--reject expects \"<option>: <why>\"".to_string())?;
258                let (opt, why) = (opt.trim(), why.trim());
259                if opt.is_empty() || why.is_empty() {
260                    return Err("--reject needs non-empty <option> and <why>".into());
261                }
262                drafts.push(DraftGround {
263                    claim: why.into(),
264                    supports: format!("rejected:{opt}"),
265                    ..Default::default()
266                });
267            }
268            "--assume" => {
269                let claim = need(args, i, &flag)?;
270                drafts.push(DraftGround {
271                    claim,
272                    supports: "chosen".into(),
273                    ..Default::default()
274                });
275            }
276            "--revisit" => {
277                last(&mut drafts, &flag)?.revisit = Some(need(args, i, &flag)?);
278            }
279            "--assume-test" => {
280                last(&mut drafts, &flag)?.test_ref = Some(need(args, i, &flag)?);
281            }
282            "--counter-test" => {
283                last(&mut drafts, &flag)?.counter_test = Some(need(args, i, &flag)?);
284            }
285            "--on-platform" => {
286                let v = need(args, i, &flag)?;
287                last(&mut drafts, &flag)?.platforms.push(v);
288            }
289            "--triggered-by" => {
290                let v = need(args, i, &flag)?;
291                last(&mut drafts, &flag)?.triggered_by.push(v);
292            }
293            "--surface" => {
294                let v = need(args, i, &flag)?;
295                last(&mut drafts, &flag)?.surfaces.push(v);
296            }
297            other => return Err(format!("decide: unknown flag {other}")),
298        }
299        i += 2;
300    }
301
302    // Decision source: exactly one of {a positional decision, --from-git}. When --from-git is
303    // used, the decision text is the commit subject, the default blame is the commit author, and
304    // any `Refs #<n>` body lines are appended to observe as provenance (grounds stay human-authored).
305    let (decision, observe) = match (decision, &from_git) {
306        (Some(_), Some(_)) => {
307            return Err("decide: decision given twice (positional and --from-git)".into())
308        }
309        (None, None) => return Err("decide: needs a decision (positional) or --from-git".into()),
310        (Some(d), None) => (d.to_string(), observe),
311        (None, Some(commit)) => {
312            let env = read_envelope(repo, commit)?;
313            // A leading `<Role>:` on the subject declares the author (unless --blame overrides);
314            // otherwise the default blame is the commit author. The subject is left untouched.
315            if blame_override.is_none() {
316                blame_override = Some(match subject_role(&env.subject) {
317                    Some(role) => role.to_string(),
318                    None => env.author,
319                });
320            }
321            // Provenance from the subject's own #issue / R<round> tokens, plus body Refs lines.
322            let observe = std::iter::once(observe)
323                .chain(subject_refs(&env.subject))
324                .chain(env.refs)
325                .filter(|s| !s.is_empty())
326                .collect::<Vec<_>>()
327                .join(" ");
328            (env.subject, observe)
329        }
330    };
331    if decision.trim().is_empty() {
332        return Err("decision text is empty".into());
333    }
334    let blame = resolve_blame(repo, blame_override)?;
335    let mut grounds = Vec::new();
336    for d in drafts {
337        grounds.push(build_ground(repo, d, &sha_override)?);
338    }
339    for field in std::iter::once(decision.to_string())
340        .chain(std::iter::once(observe.clone()))
341        .chain(t_grounds_text(&grounds))
342    {
343        for verb in crate::lint::r3_self_evolve(&field) {
344            eprintln!("warning: \"{verb}\" should take a human subject, not the system (best-effort lint; a re-wording evades it)");
345        }
346    }
347    let store = Store::at(repo);
348    if !store.exists() {
349        return Err("no .evolving/ store here — run `ev init` first".into());
350    }
351    let parent_id = store
352        .read_head()
353        .map_err(|e| format!("reading HEAD: {e}"))?;
354    let held_since = time::OffsetDateTime::now_utc()
355        .format(&time::format_description::well_known::Rfc3339)
356        .map_err(|e| format!("timestamp: {e}"))?;
357    let mut t = Tick {
358        id: String::new(),
359        parent_id,
360        observe,
361        decision: decision.to_string(),
362        grounds,
363        status: "live".into(),
364        held_since,
365        blame,
366        authority,
367    };
368    t.id = compute_id(&t);
369    store
370        .write_tick(&t)
371        .map_err(|e| format!("writing tick: {e}"))?;
372    Ok(t)
373}
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::tick::Check;
379
380    fn repo() -> std::path::PathBuf {
381        use std::sync::atomic::{AtomicU64, Ordering};
382        static N: AtomicU64 = AtomicU64::new(0);
383        let p = std::env::temp_dir().join(format!(
384            "ev-capture-{}-{}",
385            std::process::id(),
386            N.fetch_add(1, Ordering::Relaxed)
387        ));
388        let _ = std::fs::remove_dir_all(&p);
389        std::fs::create_dir_all(&p).unwrap();
390        Store::at(&p).init().unwrap();
391        p
392    }
393    fn s(v: &[&str]) -> Vec<String> {
394        v.iter().map(|x| x.to_string()).collect()
395    }
396
397    #[test]
398    fn decide_should_record_a_chosen_a_revisit_and_a_rejected_road_when_all_are_passed() {
399        // given: a store and decide args with a chosen+revisit ground and a rejected road
400        let r = repo();
401
402        // when: the decision is captured
403        let t = run(
404            &r,
405            Some("build our own retrieval; reject pgvector"),
406            &s(&[
407                "--observe",
408                "evaluating backend",
409                "--assume",
410                "team has bandwidth long-term",
411                "--revisit",
412                "Q3 review",
413                "--reject",
414                "pgvector: would lock our schema",
415                "--blame",
416                "Wang Yu",
417            ]),
418        )
419        .expect("ok");
420
421        // then: both grounds, the person check, the rejected support, blame, and HEAD all hold
422        assert_eq!(t.grounds.len(), 2);
423        assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
424        assert_eq!(t.grounds[1].supports, "rejected:pgvector");
425        assert_eq!(t.blame, "Wang Yu");
426        assert_eq!(Store::at(&r).read_head().unwrap(), t.id);
427    }
428
429    #[test]
430    fn decide_should_stamp_held_since_with_a_nonempty_rfc3339_time_when_recording() {
431        // given: a store
432        let r = repo();
433
434        // when: run records a decision
435        run(&r, Some("ship it"), &s(&["--blame", "Wang Yu"])).expect("ok");
436
437        // then: the stored HEAD tick's held_since is non-empty and parses as RFC 3339
438        let head = Store::at(&r).read_head().unwrap();
439        let tick = Store::at(&r).read_tick(&head).unwrap().unwrap();
440        assert!(!tick.held_since.is_empty());
441        time::OffsetDateTime::parse(
442            &tick.held_since,
443            &time::format_description::well_known::Rfc3339,
444        )
445        .expect("held_since parses as RFC 3339");
446    }
447
448    #[test]
449    fn decide_should_store_a_trimmed_blame_when_the_blame_is_padded() {
450        // given: a store and decide args with a padded --blame
451        let r = repo();
452
453        // when: the decision is captured
454        let t = run(
455            &r,
456            Some("d"),
457            &s(&["--assume", "c", "--blame", "  Wang Yu  "]),
458        )
459        .expect("ok");
460
461        // then: the stored blame is trimmed
462        assert_eq!(t.blame, "Wang Yu");
463    }
464
465    #[test]
466    fn decide_should_refuse_the_ground_when_it_is_both_revisit_and_assume_test() {
467        // given: a store and decide args binding one ground to both --revisit and --assume-test
468        let r = repo();
469
470        // when: the decision is captured
471        let e = run(
472            &r,
473            Some("d"),
474            &s(&[
475                "--assume",
476                "c",
477                "--revisit",
478                "Q3",
479                "--assume-test",
480                "pytest x",
481                "--blame",
482                "Wang Yu",
483            ]),
484        );
485
486        // then: it is refused
487        assert!(e.is_err());
488    }
489
490    #[test]
491    fn decide_should_refuse_a_check_when_the_ground_is_a_rejected_road() {
492        // given: a store and decide args attaching an --assume-test to a --reject road
493        let r = repo();
494
495        // when: the decision is captured
496        let e = run(
497            &r,
498            Some("d"),
499            &s(&[
500                "--reject",
501                "pgvector: would lock our schema",
502                "--assume-test",
503                "pytest x",
504                "--counter-test",
505                "ct",
506                "--on-platform",
507                "linux-ci",
508                "--triggered-by",
509                "f",
510                "--surface",
511                "s",
512                "--verified-at-sha",
513                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
514                "--blame",
515                "Wang Yu",
516            ]),
517        );
518
519        // then: it is refused
520        assert!(e.is_err());
521    }
522
523    #[test]
524    fn decide_should_error_when_there_is_no_store() {
525        // given: a directory with no .evolving/ store
526        let p = std::env::temp_dir().join(format!("ev-nostore-{}", std::process::id()));
527        let _ = std::fs::remove_dir_all(&p);
528        std::fs::create_dir_all(&p).unwrap();
529
530        // when: a decision is captured there
531        let e = run(&p, Some("d"), &s(&["--blame", "x"]));
532
533        // then: it errors
534        assert!(e.is_err());
535    }
536
537    #[test]
538    fn decide_should_build_a_self_verifying_test_binding_when_all_test_fields_are_present() {
539        // given: a store and decide args with a fully specified test binding plus a rejected road
540        let r = repo();
541
542        // when: the decision is captured
543        let t = run(
544            &r,
545            Some("restore-safety counter DB-backed; reject Redis"),
546            &s(&[
547                "--assume",
548                "Argus introduces no Redis; multi-pod coord via existing DB",
549                "--assume-test",
550                "pytest tests/test_redis_absent.py",
551                "--counter-test",
552                "pytest tests/test_redis_absent.py::test_redis_injection_flips_red",
553                "--on-platform",
554                "linux-ci",
555                "--triggered-by",
556                "pyproject.toml",
557                "--surface",
558                "pyproject-deps",
559                "--verified-at-sha",
560                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
561                "--reject",
562                "Redis: a new infra dependency",
563                "--blame",
564                "Wang Yu",
565            ]),
566        )
567        .expect("ok");
568
569        // then: the first ground carries a fully populated test check
570        match &t.grounds[0].check {
571            Some(Check::Test {
572                reference,
573                counter_test,
574                liveness,
575                verified_at_sha,
576            }) => {
577                assert_eq!(reference, "pytest tests/test_redis_absent.py");
578                assert!(counter_test.contains("flips_red"));
579                assert_eq!(liveness.platforms, vec!["linux-ci".to_string()]);
580                assert_eq!(verified_at_sha.len(), 40);
581            }
582            _ => panic!("expected a test check"),
583        }
584    }
585
586    #[test]
587    fn decide_should_reject_a_test_binding_when_there_is_no_counter_test() {
588        // given: a store and a test binding missing --counter-test
589        let r = repo();
590
591        // when: the decision is captured
592        let e = run(
593            &r,
594            Some("d"),
595            &s(&[
596                "--assume",
597                "c",
598                "--assume-test",
599                "pytest x",
600                "--on-platform",
601                "linux-ci",
602                "--triggered-by",
603                "f",
604                "--surface",
605                "s",
606                "--verified-at-sha",
607                "d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
608                "--blame",
609                "Wang Yu",
610            ]),
611        );
612
613        // then: it is rejected
614        assert!(e.is_err());
615    }
616
617    #[test]
618    fn decide_should_reject_a_test_binding_when_there_is_no_verified_at_sha_and_no_git() {
619        // given: a store and a test binding with no --verified-at-sha in a non-git dir
620        let r = repo();
621
622        // when: the decision is captured
623        let e = run(
624            &r,
625            Some("d"),
626            &s(&[
627                "--assume",
628                "c",
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                "--blame",
640                "Wang Yu",
641            ]),
642        );
643
644        // then: it is rejected
645        assert!(e.is_err());
646    }
647
648    #[test]
649    fn decide_should_take_blame_from_git_config_when_no_blame_flag_is_given() {
650        // given: a store inside a git repo with a configured author, and no --blame
651        let r = repo();
652        for a in [
653            ["init"].as_slice(),
654            ["config", "user.name", "Ada Lovelace"].as_slice(),
655        ] {
656            std::process::Command::new("git")
657                .args(a)
658                .current_dir(&r)
659                .output()
660                .unwrap();
661        }
662
663        // when: a decision is captured without --blame
664        let t = run(&r, Some("d"), &s(&["--assume", "c"])).expect("ok");
665
666        // then: blame is resolved from git config user.name
667        assert_eq!(t.blame, "Ada Lovelace");
668    }
669}