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
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 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 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 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 let r = repo();
401
402 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 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 let r = repo();
433
434 run(&r, Some("ship it"), &s(&["--blame", "Wang Yu"])).expect("ok");
436
437 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 let r = repo();
452
453 let t = run(
455 &r,
456 Some("d"),
457 &s(&["--assume", "c", "--blame", " Wang Yu "]),
458 )
459 .expect("ok");
460
461 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 let r = repo();
469
470 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 assert!(e.is_err());
488 }
489
490 #[test]
491 fn decide_should_refuse_a_check_when_the_ground_is_a_rejected_road() {
492 let r = repo();
494
495 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 assert!(e.is_err());
521 }
522
523 #[test]
524 fn decide_should_error_when_there_is_no_store() {
525 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 let e = run(&p, Some("d"), &s(&["--blame", "x"]));
532
533 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 let r = repo();
541
542 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 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 let r = repo();
590
591 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 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 let r = repo();
621
622 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 assert!(e.is_err());
646 }
647
648 #[test]
649 fn decide_should_take_blame_from_git_config_when_no_blame_flag_is_given() {
650 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 let t = run(&r, Some("d"), &s(&["--assume", "c"])).expect("ok");
665
666 assert_eq!(t.blame, "Ada Lovelace");
668 }
669}