1use std::path::Path;
8
9use serde_json::{Value, json};
10use speccoder_core::hooks::{
11 Count, GateMark, GateState, TaskStopped, TaskTicked, stopped_summary, ticked_summary,
12};
13
14use crate::git::Git;
15
16const SCHEMAS: &str = "https://hupe1980.github.io/speccoder/schemas/v2/events";
18
19#[derive(Debug, Clone)]
21pub struct Event {
22 pub id: String,
24 pub kind: &'static str,
26 pub envelope: Value,
28}
29
30fn message(git: &Git, rev: &str) -> Option<String> {
34 git.run(&[
35 "log",
36 "-1",
37 "--format=%(trailers:only=true,unfold=true)",
38 rev,
39 ])
40 .ok()
41 .map(|b| String::from_utf8_lossy(&b).into_owned())
42}
43
44fn trailer<'a>(message: &'a str, key: &str) -> Option<&'a str> {
45 let prefix = format!("{key}: ");
46 message
47 .lines()
48 .rev()
49 .find_map(|l| l.strip_prefix(prefix.as_str()))
50 .map(str::trim)
51}
52
53fn gates(message: &str) -> Vec<GateMark> {
54 message
55 .lines()
56 .filter_map(|l| l.strip_prefix("Gate: "))
57 .filter_map(|v| {
58 let (name, outcome) = v.trim().rsplit_once(' ')?;
59 Some(GateMark {
60 name: name.to_owned(),
61 outcome: match outcome {
62 "passed" => GateState::Passed,
63 "blocked" => GateState::Blocked,
64 _ => GateState::NotRun,
65 },
66 })
67 })
68 .collect()
69}
70
71fn spec_task(message: &str) -> Option<(String, String)> {
72 let (feature, task) = trailer(message, "Spec-Task")?.split_once('/')?;
73 Some((feature.to_owned(), task.to_owned()))
74}
75
76#[must_use]
78pub fn ticked(git: &Git, commit: &str) -> Option<TaskTicked> {
79 let m = message(git, commit)?;
80 let (feature, task) = spec_task(&m)?;
81 let verdict = trailer(&m, "Verdict")?.to_owned();
82 let held_out = trailer(&m, "Held-Out")
83 .and_then(|v| v.strip_prefix("passed="))
84 .and_then(|v| v.split_whitespace().next())
85 .and_then(|v| v.split_once('/'))
86 .and_then(|(p, t)| {
87 Some(Count {
88 passed: p.parse().ok()?,
89 total: t.parse().ok()?,
90 })
91 });
92 let scenarios = trailer(&m, "Scenarios")
93 .map(|v| {
94 v.split(',')
95 .map(|s| s.trim().split('@').next().unwrap_or_default().to_owned())
96 .filter(|s| !s.is_empty())
97 .collect()
98 })
99 .unwrap_or_default();
100 let mut t = TaskTicked {
101 feature,
102 task,
103 verdict,
104 commit: commit.to_owned(),
105 scenarios,
106 held_out,
107 acceptance_strength: trailer(&m, "Acceptance-Strength")
108 .and_then(|v| v.split_whitespace().next())
109 .and_then(|v| v.parse().ok()),
110 gates: gates(&m),
111 executor: trailer(&m, "Executor").map(str::to_owned),
112 run: trailer(&m, "Run").map(str::to_owned),
113 summary: String::new(),
114 };
115 t.summary = ticked_summary(&t);
116 Some(t)
117}
118
119fn code_of(outcome: &str) -> Option<&'static str> {
121 Some(match outcome {
122 "verification_failed" => "VERIFICATION-FAILED",
123 "needs_approval" => "NEEDS-APPROVAL",
124 "blocked" => "BLOCKED",
125 "incomplete" => "ATTEMPT-INCOMPLETE",
126 "budget_exhausted" | "denied" => "BUDGET-EXHAUSTED",
127 "suspected_gaming" => "SUSPECTED-GAMING",
128 "acceptance_weak" => "ACCEPTANCE-WEAK",
129 "blocked_by_gate" => "HOOK-BLOCKED",
130 "interrupted" => "INTERRUPTED",
131 _ => return None,
132 })
133}
134
135#[must_use]
137pub fn stopped(git: &Git, reference: &str, commit: &str) -> Option<TaskStopped> {
138 let m = message(git, commit)?;
139 let (feature, task) = spec_task(&m)?;
140 let outcome = trailer(&m, "Outcome")?.to_owned();
141 let mut s = TaskStopped {
142 feature,
143 task,
144 code: code_of(&outcome).map(str::to_owned),
145 outcome,
146 attempt_ref: reference.to_owned(),
147 gates: gates(&m),
148 run: trailer(&m, "Run").map(str::to_owned),
149 summary: String::new(),
150 };
151 s.summary = stopped_summary(&s);
152 Some(s)
153}
154
155#[must_use]
157pub fn envelope(
158 project: &str,
159 kind: &str,
160 id: &str,
161 feature: &str,
162 task: &str,
163 data: &Value,
164) -> Value {
165 json!({
166 "specversion": "1.0",
167 "id": id,
168 "source": format!("speccoder://{project}/{feature}"),
169 "type": format!("dev.speccoder.{kind}"),
170 "subject": task,
171 "time": crate::attest::now_rfc3339(),
172 "datacontenttype": "application/json",
173 "dataschema": format!("{SCHEMAS}/{kind}.json"),
174 "data": data,
175 })
176}
177
178#[must_use]
180pub fn of(git: &Git, project: &str, reference: &str, commit: &str) -> Option<Event> {
181 if reference.starts_with(crate::attempts::PREFIX) {
182 let s = stopped(git, reference, commit)?;
183 let id = format!("attempt:{commit}");
184 let data = serde_json::to_value(&s).ok()?;
185 Some(Event {
186 envelope: envelope(project, "task.stopped", &id, &s.feature, &s.task, &data),
187 id,
188 kind: "task.stopped",
189 })
190 } else {
191 let t = ticked(git, commit)?;
192 let id = format!("tick:{commit}");
193 let data = serde_json::to_value(&t).ok()?;
194 Some(Event {
195 envelope: envelope(project, "task.ticked", &id, &t.feature, &t.task, &data),
196 id,
197 kind: "task.ticked",
198 })
199 }
200}
201
202#[must_use]
206pub fn tips(root: &Path) -> Vec<(String, String)> {
207 let git = Git::at(root);
208 let mut out: Vec<(String, String)> = git
209 .run(&[
210 "for-each-ref",
211 "--format=%(refname) %(objectname)",
212 "refs/heads/speccoder/",
213 crate::attempts::PREFIX,
214 ])
215 .map(|b| String::from_utf8_lossy(&b).into_owned())
216 .unwrap_or_default()
217 .lines()
218 .filter_map(|l| l.split_once(' '))
219 .map(|(r, c)| (r.to_owned(), c.to_owned()))
220 .collect();
221 if let (Ok(head), Ok(commit)) = (
222 git.line(&["symbolic-ref", "-q", "HEAD"]),
223 git.line(&["rev-parse", "HEAD"]),
224 ) && !out.iter().any(|(r, _)| *r == head)
225 {
226 out.push((head.trim().to_owned(), commit.trim().to_owned()));
227 }
228 out
229}
230
231#[must_use]
235pub fn ticks_between(git: &Git, old: &str, new: &str) -> Vec<String> {
236 git.run(&[
237 "log",
238 "--reverse",
239 "--format=%H",
240 "--grep=^Verdict: ",
241 &format!("{old}..{new}"),
242 ])
243 .map(|b| String::from_utf8_lossy(&b).into_owned())
244 .unwrap_or_default()
245 .lines()
246 .map(str::to_owned)
247 .collect()
248}
249
250#[must_use]
252pub fn descends(git: &Git, old: &str, new: &str) -> bool {
253 git.succeeds(&["merge-base", "--is-ancestor", old, new])
254 .unwrap_or(false)
255}
256
257#[must_use]
259pub fn sample(project: &str) -> Value {
260 let mut t = TaskTicked {
261 feature: "TEST".to_owned(),
262 task: "TEST".to_owned(),
263 verdict: "verified".to_owned(),
264 commit: "0".repeat(40),
265 scenarios: vec!["US1-1".to_owned()],
266 held_out: None,
267 acceptance_strength: None,
268 gates: Vec::new(),
269 executor: None,
270 run: None,
271 summary: String::new(),
272 };
273 t.summary = format!("A test from speccoder: {}", ticked_summary(&t));
274 let data = serde_json::to_value(&t).unwrap_or_default();
275 let mut e = envelope(project, "task.ticked", "test:sample", "TEST", "TEST", &data);
276 e["test"] = json!(true);
277 e
278}
279
280#[cfg(test)]
281mod tests {
282 use super::*;
283
284 fn git(dir: &Path, args: &[&str]) {
285 let ok = std::process::Command::new("git")
286 .args([
287 "-c",
288 "user.name=t",
289 "-c",
290 "user.email=t@x",
291 "-c",
292 "commit.gpgsign=false",
293 ])
294 .args(args)
295 .current_dir(dir)
296 .status()
297 .unwrap()
298 .success();
299 assert!(ok, "{args:?}");
300 }
301
302 #[test]
303 fn a_tick_commit_projects_ids_counts_and_a_sentence_only() {
304 let d = tempfile::tempdir().unwrap();
305 git(d.path(), &["init", "-q"]);
306 let msg = "T004 Add login\n\nAdd login in src/login.rs, asserting `secret_name()` returns 42\n\nSpec-Task: 001-md2csv/T004\nVerdict: verified\nHeld-Out: passed=2/2 manifest=sha256:ab\nScenarios: US1-1@aaa, US1-2@bbb\nAcceptance-Strength: 0.83 near-miss=1/1\nGate: licence passed\nRun: run_01\n";
307 git(d.path(), &["commit", "-q", "--allow-empty", "-m", msg]);
308 let g = Git::at(d.path());
309 let head = g.line(&["rev-parse", "HEAD"]).unwrap();
310 let head = head.trim();
311 let e = of(&g, "9a3b", "refs/heads/speccoder/001-md2csv", head).unwrap();
312 assert_eq!(e.id, format!("tick:{head}"));
313 assert_eq!(e.envelope["type"], "dev.speccoder.task.ticked");
314 assert_eq!(e.envelope["source"], "speccoder://9a3b/001-md2csv");
315 assert_eq!(e.envelope["subject"], "T004");
316 let data = &e.envelope["data"];
317 assert_eq!(data["scenarios"], json!(["US1-1", "US1-2"]));
318 assert_eq!(data["held_out"], json!({"passed": 2, "total": 2}));
319 assert_eq!(data["gates"][0]["name"], "licence");
320 assert_eq!(
321 data["summary"],
322 "T004 verified in 001-md2csv — 2 scenarios, held-out 2 of 2"
323 );
324 let text = e.envelope.to_string();
326 for leak in ["Add login", "secret_name", "src/login.rs"] {
327 assert!(!text.contains(leak), "{leak} in {text}");
328 }
329 }
330
331 #[test]
332 fn a_saved_attempt_is_a_stop_with_its_code() {
333 let d = tempfile::tempdir().unwrap();
334 git(d.path(), &["init", "-q"]);
335 let msg = "speccoder attempt: T005 blocked_by_gate\n\nBlocked by licence — GPL in foo\n\nSpec-Task: 001-md2csv/T005\nOutcome: blocked_by_gate\nRun: run_02\nGate: licence blocked\nEarned-Verdict: verified\n";
336 git(d.path(), &["commit", "-q", "--allow-empty", "-m", msg]);
337 let g = Git::at(d.path());
338 let head = g.line(&["rev-parse", "HEAD"]).unwrap();
339 let r = format!("{}/001-md2csv/T005/run_02", crate::attempts::PREFIX);
340 let e = of(&g, "9a3b", &r, head.trim()).unwrap();
341 assert_eq!(e.kind, "task.stopped");
342 assert_eq!(e.envelope["data"]["code"], "HOOK-BLOCKED");
343 assert_eq!(
344 e.envelope["data"]["summary"],
345 "T005 blocked by licence in 001-md2csv"
346 );
347 assert!(!e.envelope.to_string().contains("GPL in foo"));
348 }
349}