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 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 let r = repo();
282
283 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 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 let r = repo();
314
315 let t = run(&r, "d", &s(&["--assume", "c", "--blame", " Wang Yu "])).expect("ok");
317
318 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 let r = repo();
326
327 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 assert!(e.is_err());
345 }
346
347 #[test]
348 fn decide_should_refuse_a_check_when_the_ground_is_a_rejected_road() {
349 let r = repo();
351
352 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 assert!(e.is_err());
378 }
379
380 #[test]
381 fn decide_should_error_when_there_is_no_store() {
382 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 let e = run(&p, "d", &s(&["--blame", "x"]));
389
390 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 let r = repo();
398
399 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 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 let r = repo();
447
448 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 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 let r = repo();
478
479 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 assert!(e.is_err());
503 }
504
505 #[test]
506 fn decide_should_take_blame_from_git_config_when_no_blame_flag_is_given() {
507 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 let t = run(&r, "d", &s(&["--assume", "c"])).expect("ok");
522
523 assert_eq!(t.blame, "Ada Lovelace");
525 }
526}