use crate::canonical::compute_id;
use crate::store::Store;
use crate::tick::{Check, Ground, Tick};
use std::path::Path;
use std::process::Command;
#[derive(Default)]
struct DraftGround {
claim: String,
supports: String, revisit: Option<String>,
test_ref: Option<String>,
counter_test: Option<String>,
platforms: Vec<String>,
triggered_by: Vec<String>,
surfaces: Vec<String>,
}
fn need(args: &[String], i: usize, flag: &str) -> Result<String, String> {
args.get(i + 1)
.cloned()
.ok_or(format!("{flag} requires a value"))
}
fn last<'a>(g: &'a mut [DraftGround], flag: &str) -> Result<&'a mut DraftGround, String> {
g.last_mut()
.ok_or(format!("{flag} has no preceding --assume/--reject ground"))
}
pub(crate) fn resolve_blame(repo: &Path, blame_override: Option<String>) -> Result<String, String> {
if let Some(b) = blame_override {
let b = b.trim();
if b.is_empty() {
return Err("--blame must be non-empty".into());
}
return Ok(b.to_string());
}
let out = Command::new("git")
.arg("config")
.arg("user.name")
.current_dir(repo)
.output()
.map_err(|e| format!("cannot run git: {e}"))?;
let name = String::from_utf8_lossy(&out.stdout).trim().to_string();
if name.is_empty() {
return Err("no author: pass --blame, or set git config user.name".into());
}
Ok(name)
}
pub(crate) fn resolve_sha(repo: &Path, sha_override: &Option<String>) -> Result<String, String> {
let sha = match sha_override {
Some(s) => s.trim().to_string(),
None => {
let out = std::process::Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(repo)
.output()
.map_err(|e| format!("cannot run git: {e}"))?;
if !out.status.success() {
return Err(
"cannot resolve verified_at_sha (not a git repo?) — pass --verified-at-sha"
.into(),
);
}
String::from_utf8_lossy(&out.stdout).trim().to_string()
}
};
if !crate::tick::is_40_lower_hex(&sha) {
return Err(format!("verified_at_sha must be 40 lowercase hex: {sha}"));
}
Ok(sha)
}
fn t_grounds_text(grounds: &[Ground]) -> Vec<String> {
grounds.iter().map(|g| g.claim.clone()).collect()
}
fn git_show(repo: &Path, fmt: &str, commit: &str) -> Result<String, String> {
let out = Command::new("git")
.args(["show", "-s", fmt, commit])
.current_dir(repo)
.output()
.map_err(|e| format!("cannot run git: {e}"))?;
if !out.status.success() {
return Err(format!("decide: cannot read commit {commit}"));
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_string())
}
struct Envelope {
subject: String,
author: String,
refs: Vec<String>,
}
const SUBJECT_ROLES: &[&str] = &["Dev", "QA", "Product", "Mac", "User"];
fn subject_role(subject: &str) -> Option<&'static str> {
let head = subject.split_whitespace().next()?;
let word = head.strip_suffix(':')?;
SUBJECT_ROLES
.iter()
.find(|r| r.eq_ignore_ascii_case(word))
.copied()
}
fn subject_refs(subject: &str) -> Vec<String> {
subject
.split_whitespace()
.filter(|tok| {
let rest = tok
.strip_prefix('#')
.or_else(|| tok.strip_prefix('R'))
.or_else(|| tok.strip_prefix('r'));
matches!(rest, Some(d) if !d.is_empty() && d.bytes().all(|b| b.is_ascii_digit()))
})
.map(|t| t.to_string())
.collect()
}
fn read_envelope(repo: &Path, commit: &str) -> Result<Envelope, String> {
let subject = git_show(repo, "--format=%s", commit)?;
let author = git_show(repo, "--format=%an", commit)?;
let body = git_show(repo, "--format=%b", commit)?;
let refs = body
.lines()
.map(str::trim)
.filter(|l| l.starts_with("Refs #"))
.map(|l| l.to_string())
.collect();
Ok(Envelope {
subject,
author,
refs,
})
}
pub(crate) fn validate_authority(val: &str) -> Result<(), String> {
if val == "user-ruled" || val == "agent-disposable" {
Ok(())
} else {
Err("authority must be user-ruled or agent-disposable".into())
}
}
fn build_ground(
repo: &Path,
d: DraftGround,
sha_override: &Option<String>,
) -> Result<Ground, String> {
use crate::tick::Liveness;
if d.claim.is_empty() {
return Err("ground claim is empty".into());
}
if d.supports.starts_with("rejected:") && (d.test_ref.is_some() || d.revisit.is_some()) {
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());
}
if d.revisit.is_some() && d.test_ref.is_some() {
return Err("a ground cannot be both --revisit and --assume-test (R2)".into());
}
let has_test_fields = d.counter_test.is_some()
|| !d.platforms.is_empty()
|| !d.triggered_by.is_empty()
|| !d.surfaces.is_empty();
let check = match (d.test_ref, d.revisit) {
(Some(reference), _) => {
let counter_test = d
.counter_test
.ok_or("a test binding requires --counter-test (no vacuous binding)".to_string())?;
if d.platforms.is_empty() || d.triggered_by.is_empty() || d.surfaces.is_empty() {
return Err("a test binding requires at least one --on-platform, --triggered-by, and --surface".into());
}
let verified_at_sha = resolve_sha(repo, sha_override)?;
Some(Check::Test {
reference,
verified_at_sha,
counter_test,
liveness: Liveness {
platforms: d.platforms,
triggered_by: d.triggered_by,
surfaces: d.surfaces,
},
})
}
(None, Some(when)) => {
if has_test_fields {
return Err(
"--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
.into(),
);
}
Some(Check::Person { reference: when })
}
(None, None) => {
if has_test_fields {
return Err(
"--counter-test/--on-platform/--triggered-by/--surface require --assume-test"
.into(),
);
}
None
}
};
Ok(Ground {
claim: d.claim,
supports: d.supports,
check,
})
}
pub fn run(repo: &Path, decision: Option<&str>, args: &[String]) -> Result<Tick, String> {
let mut observe = String::new();
let mut blame_override: Option<String> = None;
let mut sha_override: Option<String> = None;
let mut authority: Option<String> = None;
let mut from_git: Option<String> = None;
let mut drafts: Vec<DraftGround> = Vec::new();
let mut i = 0;
while i < args.len() {
let flag = args[i].clone();
match flag.as_str() {
"--from-git" => {
from_git = Some(need(args, i, &flag)?);
}
"--observe" => {
observe = need(args, i, &flag)?;
}
"--blame" => {
blame_override = Some(need(args, i, &flag)?);
}
"--verified-at-sha" => {
sha_override = Some(need(args, i, &flag)?);
}
"--authority" => {
let v = need(args, i, &flag)?;
validate_authority(&v)?;
authority = Some(v);
}
"--reject" => {
let v = need(args, i, &flag)?;
let (opt, why) = v
.split_once(':')
.ok_or("--reject expects \"<option>: <why>\"".to_string())?;
let (opt, why) = (opt.trim(), why.trim());
if opt.is_empty() || why.is_empty() {
return Err("--reject needs non-empty <option> and <why>".into());
}
drafts.push(DraftGround {
claim: why.into(),
supports: format!("rejected:{opt}"),
..Default::default()
});
}
"--assume" => {
let claim = need(args, i, &flag)?;
drafts.push(DraftGround {
claim,
supports: "chosen".into(),
..Default::default()
});
}
"--revisit" => {
last(&mut drafts, &flag)?.revisit = Some(need(args, i, &flag)?);
}
"--assume-test" => {
last(&mut drafts, &flag)?.test_ref = Some(need(args, i, &flag)?);
}
"--counter-test" => {
last(&mut drafts, &flag)?.counter_test = Some(need(args, i, &flag)?);
}
"--on-platform" => {
let v = need(args, i, &flag)?;
last(&mut drafts, &flag)?.platforms.push(v);
}
"--triggered-by" => {
let v = need(args, i, &flag)?;
last(&mut drafts, &flag)?.triggered_by.push(v);
}
"--surface" => {
let v = need(args, i, &flag)?;
last(&mut drafts, &flag)?.surfaces.push(v);
}
other => return Err(format!("decide: unknown flag {other}")),
}
i += 2;
}
let (decision, observe) = match (decision, &from_git) {
(Some(_), Some(_)) => {
return Err("decide: decision given twice (positional and --from-git)".into())
}
(None, None) => return Err("decide: needs a decision (positional) or --from-git".into()),
(Some(d), None) => (d.to_string(), observe),
(None, Some(commit)) => {
let env = read_envelope(repo, commit)?;
if blame_override.is_none() {
blame_override = Some(match subject_role(&env.subject) {
Some(role) => role.to_string(),
None => env.author,
});
}
let observe = std::iter::once(observe)
.chain(subject_refs(&env.subject))
.chain(env.refs)
.filter(|s| !s.is_empty())
.collect::<Vec<_>>()
.join(" ");
(env.subject, observe)
}
};
if decision.trim().is_empty() {
return Err("decision text is empty".into());
}
let blame = resolve_blame(repo, blame_override)?;
let mut grounds = Vec::new();
for d in drafts {
grounds.push(build_ground(repo, d, &sha_override)?);
}
for field in std::iter::once(decision.to_string())
.chain(std::iter::once(observe.clone()))
.chain(t_grounds_text(&grounds))
{
for verb in crate::lint::r3_self_evolve(&field) {
eprintln!("warning: \"{verb}\" should take a human subject, not the system (best-effort lint; a re-wording evades it)");
}
}
let store = Store::at(repo);
if !store.exists() {
return Err("no .evolving/ store here — run `ev init` first".into());
}
let parent_id = store
.read_head()
.map_err(|e| format!("reading HEAD: {e}"))?;
let held_since = time::OffsetDateTime::now_utc()
.format(&time::format_description::well_known::Rfc3339)
.map_err(|e| format!("timestamp: {e}"))?;
let mut t = Tick {
id: String::new(),
parent_id,
observe,
decision: decision.to_string(),
grounds,
status: "live".into(),
held_since,
blame,
authority,
};
t.id = compute_id(&t);
store
.write_tick(&t)
.map_err(|e| format!("writing tick: {e}"))?;
Ok(t)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tick::Check;
fn repo() -> std::path::PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static N: AtomicU64 = AtomicU64::new(0);
let p = std::env::temp_dir().join(format!(
"ev-capture-{}-{}",
std::process::id(),
N.fetch_add(1, Ordering::Relaxed)
));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
Store::at(&p).init().unwrap();
p
}
fn s(v: &[&str]) -> Vec<String> {
v.iter().map(|x| x.to_string()).collect()
}
#[test]
fn decide_should_record_a_chosen_a_revisit_and_a_rejected_road_when_all_are_passed() {
let r = repo();
let t = run(
&r,
Some("build our own retrieval; reject pgvector"),
&s(&[
"--observe",
"evaluating backend",
"--assume",
"team has bandwidth long-term",
"--revisit",
"Q3 review",
"--reject",
"pgvector: would lock our schema",
"--blame",
"Wang Yu",
]),
)
.expect("ok");
assert_eq!(t.grounds.len(), 2);
assert!(matches!(t.grounds[0].check, Some(Check::Person { .. })));
assert_eq!(t.grounds[1].supports, "rejected:pgvector");
assert_eq!(t.blame, "Wang Yu");
assert_eq!(Store::at(&r).read_head().unwrap(), t.id);
}
#[test]
fn decide_should_stamp_held_since_with_a_nonempty_rfc3339_time_when_recording() {
let r = repo();
run(&r, Some("ship it"), &s(&["--blame", "Wang Yu"])).expect("ok");
let head = Store::at(&r).read_head().unwrap();
let tick = Store::at(&r).read_tick(&head).unwrap().unwrap();
assert!(!tick.held_since.is_empty());
time::OffsetDateTime::parse(
&tick.held_since,
&time::format_description::well_known::Rfc3339,
)
.expect("held_since parses as RFC 3339");
}
#[test]
fn decide_should_store_a_trimmed_blame_when_the_blame_is_padded() {
let r = repo();
let t = run(
&r,
Some("d"),
&s(&["--assume", "c", "--blame", " Wang Yu "]),
)
.expect("ok");
assert_eq!(t.blame, "Wang Yu");
}
#[test]
fn decide_should_refuse_the_ground_when_it_is_both_revisit_and_assume_test() {
let r = repo();
let e = run(
&r,
Some("d"),
&s(&[
"--assume",
"c",
"--revisit",
"Q3",
"--assume-test",
"pytest x",
"--blame",
"Wang Yu",
]),
);
assert!(e.is_err());
}
#[test]
fn decide_should_refuse_a_check_when_the_ground_is_a_rejected_road() {
let r = repo();
let e = run(
&r,
Some("d"),
&s(&[
"--reject",
"pgvector: would lock our schema",
"--assume-test",
"pytest x",
"--counter-test",
"ct",
"--on-platform",
"linux-ci",
"--triggered-by",
"f",
"--surface",
"s",
"--verified-at-sha",
"d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
"--blame",
"Wang Yu",
]),
);
assert!(e.is_err());
}
#[test]
fn decide_should_error_when_there_is_no_store() {
let p = std::env::temp_dir().join(format!("ev-nostore-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&p);
std::fs::create_dir_all(&p).unwrap();
let e = run(&p, Some("d"), &s(&["--blame", "x"]));
assert!(e.is_err());
}
#[test]
fn decide_should_build_a_self_verifying_test_binding_when_all_test_fields_are_present() {
let r = repo();
let t = run(
&r,
Some("restore-safety counter DB-backed; reject Redis"),
&s(&[
"--assume",
"Argus introduces no Redis; multi-pod coord via existing DB",
"--assume-test",
"pytest tests/test_redis_absent.py",
"--counter-test",
"pytest tests/test_redis_absent.py::test_redis_injection_flips_red",
"--on-platform",
"linux-ci",
"--triggered-by",
"pyproject.toml",
"--surface",
"pyproject-deps",
"--verified-at-sha",
"d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
"--reject",
"Redis: a new infra dependency",
"--blame",
"Wang Yu",
]),
)
.expect("ok");
match &t.grounds[0].check {
Some(Check::Test {
reference,
counter_test,
liveness,
verified_at_sha,
}) => {
assert_eq!(reference, "pytest tests/test_redis_absent.py");
assert!(counter_test.contains("flips_red"));
assert_eq!(liveness.platforms, vec!["linux-ci".to_string()]);
assert_eq!(verified_at_sha.len(), 40);
}
_ => panic!("expected a test check"),
}
}
#[test]
fn decide_should_reject_a_test_binding_when_there_is_no_counter_test() {
let r = repo();
let e = run(
&r,
Some("d"),
&s(&[
"--assume",
"c",
"--assume-test",
"pytest x",
"--on-platform",
"linux-ci",
"--triggered-by",
"f",
"--surface",
"s",
"--verified-at-sha",
"d308afac1b2c3d4e5f60718293a4b5c6d7e8f901",
"--blame",
"Wang Yu",
]),
);
assert!(e.is_err());
}
#[test]
fn decide_should_reject_a_test_binding_when_there_is_no_verified_at_sha_and_no_git() {
let r = repo();
let e = run(
&r,
Some("d"),
&s(&[
"--assume",
"c",
"--assume-test",
"pytest x",
"--counter-test",
"ct",
"--on-platform",
"linux-ci",
"--triggered-by",
"f",
"--surface",
"s",
"--blame",
"Wang Yu",
]),
);
assert!(e.is_err());
}
#[test]
fn decide_should_take_blame_from_git_config_when_no_blame_flag_is_given() {
let r = repo();
for a in [
["init"].as_slice(),
["config", "user.name", "Ada Lovelace"].as_slice(),
] {
std::process::Command::new("git")
.args(a)
.current_dir(&r)
.output()
.unwrap();
}
let t = run(&r, Some("d"), &s(&["--assume", "c"])).expect("ok");
assert_eq!(t.blame, "Ada Lovelace");
}
}