1use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11pub const MAX_REASON: usize = 300;
13
14pub const MAX_TIMEOUT: u64 = 600;
16
17pub const EVENTS: &[&str] = &["task.ticked", "task.stopped"];
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
22#[serde(rename_all = "snake_case")]
23pub enum Point {
24 Tick,
26 Pr,
28}
29
30impl Point {
31 #[must_use]
33 pub const fn as_str(self) -> &'static str {
34 match self {
35 Self::Tick => "tick",
36 Self::Pr => "pr",
37 }
38 }
39
40 #[must_use]
42 pub const fn words(self) -> &'static str {
43 match self {
44 Self::Tick => "before tick",
45 Self::Pr => "before pull request",
46 }
47 }
48
49 #[must_use]
51 pub fn named(word: &str) -> Option<Self> {
52 match word {
53 "tick" => Some(Self::Tick),
54 "pr" => Some(Self::Pr),
55 _ => None,
56 }
57 }
58}
59
60#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum Role {
63 Gate {
65 at: Point,
67 network: bool,
69 },
70 Observer {
72 on: Vec<String>,
74 },
75}
76
77#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum Runner {
80 Argv(Vec<String>),
82 Url {
84 url: String,
86 secret_env: Option<String>,
88 },
89}
90
91#[derive(Debug, Clone, PartialEq)]
93pub struct HookDef {
94 pub name: String,
96 pub role: Role,
98 pub runner: Runner,
100 pub timeout: u64,
102 pub env: Vec<String>,
104 pub files: Vec<String>,
107 pub definition: Value,
109}
110
111impl HookDef {
112 #[must_use]
114 pub const fn at(&self) -> Option<Point> {
115 match self.role {
116 Role::Gate { at, .. } => Some(at),
117 Role::Observer { .. } => None,
118 }
119 }
120
121 #[must_use]
123 pub fn hears(&self, event: &str) -> bool {
124 matches!(&self.role, Role::Observer { on } if on.iter().any(|e| e == "*" || e == event))
125 }
126
127 #[must_use]
129 pub fn listens(&self) -> String {
130 match &self.role {
131 Role::Gate { at, .. } => at.words().to_owned(),
132 Role::Observer { on } if on.iter().any(|e| e == "*") => {
133 format!("hears {}", EVENTS.join(", "))
134 }
135 Role::Observer { on } => format!("hears {}", on.join(", ")),
136 }
137 }
138
139 #[must_use]
141 pub fn argv(&self) -> Option<&[String]> {
142 match &self.runner {
143 Runner::Argv(a) => Some(a),
144 Runner::Url { .. } => None,
145 }
146 }
147}
148
149#[must_use]
151pub fn valid_name(name: &str) -> bool {
152 !name.is_empty()
153 && name
154 .bytes()
155 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
156}
157
158pub fn url_allowed(url: &str) -> Result<(), String> {
165 if url.starts_with("https://") && url.len() > "https://".len() {
166 return Ok(());
167 }
168 let Some(rest) = url.strip_prefix("http://") else {
169 return Err(format!("`{url}` is not an https URL"));
170 };
171 let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
172 let host = host.rsplit_once('@').map_or(host, |(_, h)| h);
173 let host = if host.starts_with('[') {
174 host.split(']')
175 .next()
176 .unwrap_or_default()
177 .trim_start_matches('[')
178 } else {
179 host.split(':').next().unwrap_or_default()
180 };
181 if matches!(host, "localhost" | "127.0.0.1" | "::1") {
182 Ok(())
183 } else {
184 Err(format!(
185 "`{url}` is plain http to another machine: use https (http only to localhost)"
186 ))
187 }
188}
189
190#[must_use]
193pub fn canonical(value: &Value) -> Value {
194 match value {
195 Value::Object(m) => {
196 let mut keys: Vec<&String> = m.keys().collect();
197 keys.sort();
198 let mut out = Map::new();
199 for k in keys {
200 out.insert(k.clone(), canonical(&m[k]));
201 }
202 Value::Object(out)
203 }
204 Value::Array(a) => Value::Array(a.iter().map(canonical).collect()),
205 v => v.clone(),
206 }
207}
208
209const KEYS: &[&str] = &[
210 "at",
211 "on",
212 "argv",
213 "url",
214 "secret_env",
215 "timeout",
216 "env",
217 "network",
218 "files",
219];
220
221#[allow(clippy::too_many_lines)] pub fn parse(name: &str, table: &Value) -> Result<HookDef, String> {
228 let at_hook = |m: String| format!("[hooks.{name}]: {m}");
229 if !valid_name(name) {
230 return Err(at_hook(
231 "a hook's name is lowercase letters, digits, `-` and `_`".to_owned(),
232 ));
233 }
234 let Some(t) = table.as_object() else {
235 return Err(at_hook("must be a table".to_owned()));
236 };
237 if let Some(k) = t.keys().find(|k| !KEYS.contains(&k.as_str())) {
238 return Err(at_hook(format!(
239 "unknown key `{k}` (known: {})",
240 KEYS.join(", ")
241 )));
242 }
243 let strings = |key: &str| -> Result<Option<Vec<String>>, String> {
244 match t.get(key) {
245 None => Ok(None),
246 Some(Value::Array(a)) => a
247 .iter()
248 .map(|v| v.as_str().map(str::to_owned))
249 .collect::<Option<Vec<_>>>()
250 .map(Some)
251 .ok_or_else(|| at_hook(format!("`{key}` is a list of strings"))),
252 Some(_) => Err(at_hook(format!("`{key}` is a list of strings"))),
253 }
254 };
255 let string = |key: &str| -> Result<Option<String>, String> {
256 match t.get(key) {
257 None => Ok(None),
258 Some(Value::String(s)) => Ok(Some(s.clone())),
259 Some(_) => Err(at_hook(format!("`{key}` is a string"))),
260 }
261 };
262 let argv = strings("argv")?;
263 let url = string("url")?;
264 let secret_env = string("secret_env")?;
265 let env = strings("env")?.unwrap_or_default();
266 let files = strings("files")?.unwrap_or_default();
267 if let Some(f) = files
268 .iter()
269 .find(|f| f.is_empty() || f.starts_with('/') || f.split('/').any(|s| s == ".."))
270 {
271 return Err(at_hook(format!(
272 "`files`: `{f}` is not a path inside the repository"
273 )));
274 }
275 let role = match (string("at")?, strings("on")?) {
276 (Some(_), Some(_)) => {
277 return Err(at_hook(
278 "`at` makes a gate and `on` an observer: keep one".to_owned(),
279 ));
280 }
281 (None, None) => {
282 return Err(at_hook(
283 "add `at = \"tick\"` (a gate) or `on = [\"task.ticked\"]` (an observer)".to_owned(),
284 ));
285 }
286 (Some(at), None) => {
287 let Some(at) = Point::named(&at) else {
288 return Err(at_hook(format!(
289 "`at = \"{at}\"`: a gate stands at \"tick\" or \"pr\""
290 )));
291 };
292 let network = match t.get("network") {
293 None => false,
294 Some(Value::Bool(b)) => *b,
295 Some(_) => return Err(at_hook("`network` is true or false".to_owned())),
296 };
297 Role::Gate { at, network }
298 }
299 (None, Some(on)) => {
300 if on.is_empty() {
301 return Err(at_hook(format!(
302 "`on` names the events to hear: {} or \"*\"",
303 EVENTS.join(", ")
304 )));
305 }
306 if let Some(e) = on
307 .iter()
308 .find(|e| *e != "*" && !EVENTS.contains(&e.as_str()))
309 {
310 return Err(at_hook(format!(
311 "`{e}` is not an event an observer can hear ({} or \"*\")",
312 EVENTS.join(", ")
313 )));
314 }
315 if t.contains_key("network") {
316 return Err(at_hook(
317 "`network` is for gates; an observer always has the network".to_owned(),
318 ));
319 }
320 Role::Observer { on }
321 }
322 };
323 let runner = match (argv, url) {
324 (Some(_), Some(_)) => {
325 return Err(at_hook("give `argv` or `url`, not both".to_owned()));
326 }
327 (None, None) => return Err(at_hook("add `argv = [\"…\"]` to run".to_owned())),
328 (Some(a), None) if a.is_empty() || a[0].trim().is_empty() => {
329 return Err(at_hook("`argv` names the program first".to_owned()));
330 }
331 (Some(a), None) => {
332 if secret_env.is_some() {
333 return Err(at_hook("`secret_env` signs a `url`'s requests".to_owned()));
334 }
335 Runner::Argv(a)
336 }
337 (None, Some(url)) => {
338 if matches!(role, Role::Gate { .. }) {
339 return Err(at_hook(
340 "a gate runs `argv`; only an observer posts to a `url`".to_owned(),
341 ));
342 }
343 url_allowed(&url).map_err(at_hook)?;
344 Runner::Url { url, secret_env }
345 }
346 };
347 let timeout = match t.get("timeout") {
348 None if matches!(role, Role::Gate { .. }) => 60,
349 None => 10,
350 Some(v) => match v.as_u64() {
351 Some(s) if (1..=MAX_TIMEOUT).contains(&s) => s,
352 _ => {
353 return Err(at_hook(format!("`timeout` is seconds, 1 to {MAX_TIMEOUT}")));
354 }
355 },
356 };
357 Ok(HookDef {
358 name: name.to_owned(),
359 role,
360 runner,
361 timeout,
362 env,
363 files,
364 definition: canonical(table),
365 })
366}
367
368#[derive(Debug, Clone, Default)]
370pub struct GateExit<'a> {
371 pub exit_code: Option<i32>,
373 pub timed_out: bool,
375 pub spawn_error: Option<&'a str>,
377 pub stdout: &'a str,
379 pub stderr: &'a str,
381 pub changed: &'a [String],
383 pub timeout: u64,
385}
386
387#[derive(Debug, Clone, PartialEq, Eq)]
389pub enum GateOutcome {
390 Passed,
392 Blocked(String),
394}
395
396impl GateOutcome {
397 #[must_use]
400 pub fn of(e: &GateExit<'_>) -> Self {
401 if let Some(err) = e.spawn_error {
402 return Self::Blocked(clean(&format!("could not start: {err}")));
403 }
404 if e.timed_out {
405 return Self::Blocked(format!("timed out after {} s", e.timeout));
406 }
407 if !e.changed.is_empty() {
408 let shown: Vec<String> = e.changed.iter().take(3).map(|p| format!("`{p}`")).collect();
409 let more = e.changed.len().saturating_sub(3);
410 let more = if more > 0 {
411 format!(" (+{more})")
412 } else {
413 String::new()
414 };
415 return Self::Blocked(clean(&format!("changed {}{more}", shown.join(", "))));
416 }
417 match e.exit_code {
418 Some(0) => Self::Passed,
419 Some(code) => Self::Blocked(
420 last_line(e.stderr)
421 .or_else(|| last_line(e.stdout))
422 .map_or_else(|| format!("exit {code}"), clean),
423 ),
424 None => Self::Blocked("was killed".to_owned()),
425 }
426 }
427}
428
429fn last_line(s: &str) -> Option<&str> {
430 s.lines().map(str::trim).rfind(|l| !l.is_empty())
431}
432
433#[must_use]
436pub fn clean(reason: &str) -> String {
437 let mut out = String::new();
438 for c in reason.chars() {
439 if c == '\n' || c == '\r' || c == '\t' {
440 out.push(' ');
441 } else if c.is_control() {
442 out.push_str(&c.escape_unicode().to_string());
443 } else {
444 out.push(c);
445 }
446 }
447 let out = out.trim();
448 if out.chars().count() > MAX_REASON {
449 let mut cut: String = out.chars().take(MAX_REASON - 1).collect();
450 cut.push('…');
451 cut
452 } else {
453 out.to_owned()
454 }
455}
456
457#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
459#[serde(rename_all = "snake_case")]
460pub enum GateState {
461 Passed,
463 Blocked,
465 NotRun,
467}
468
469impl GateState {
470 #[must_use]
472 pub const fn as_str(self) -> &'static str {
473 match self {
474 Self::Passed => "passed",
475 Self::Blocked => "blocked",
476 Self::NotRun => "not_run",
477 }
478 }
479}
480
481#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
483pub struct GateRecord {
484 pub name: String,
486 pub point: Point,
488 pub outcome: GateState,
490 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub reason: Option<String>,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub code: Option<String>,
496 pub duration_ms: u64,
498}
499
500impl GateRecord {
501 #[must_use]
503 pub fn blocked_line(&self) -> String {
504 format!(
505 "Blocked by {} — {}",
506 self.name,
507 self.reason.as_deref().unwrap_or("no reason given")
508 )
509 }
510}
511
512#[must_use]
514pub fn first_block(gates: &[GateRecord]) -> Option<&GateRecord> {
515 gates.iter().find(|g| g.outcome == GateState::Blocked)
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
520pub struct GateMark {
521 pub name: String,
523 pub outcome: GateState,
525}
526
527#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
529pub struct Count {
530 pub passed: u32,
532 pub total: u32,
534}
535
536#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
538pub struct TaskTicked {
539 pub feature: String,
541 pub task: String,
543 pub verdict: String,
545 pub commit: String,
547 pub scenarios: Vec<String>,
549 #[serde(default, skip_serializing_if = "Option::is_none")]
551 pub held_out: Option<Count>,
552 #[serde(default, skip_serializing_if = "Option::is_none")]
554 pub acceptance_strength: Option<f64>,
555 pub gates: Vec<GateMark>,
557 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub executor: Option<String>,
560 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub run: Option<String>,
563 pub summary: String,
565}
566
567#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
569pub struct TaskStopped {
570 pub feature: String,
572 pub task: String,
574 pub outcome: String,
576 #[serde(default, skip_serializing_if = "Option::is_none")]
578 pub code: Option<String>,
579 pub attempt_ref: String,
581 pub gates: Vec<GateMark>,
583 #[serde(default, skip_serializing_if = "Option::is_none")]
585 pub run: Option<String>,
586 pub summary: String,
588}
589
590#[must_use]
592pub fn ticked_summary(t: &TaskTicked) -> String {
593 let mut parts = Vec::new();
594 match t.scenarios.len() {
595 0 => {}
596 1 => parts.push("1 scenario".to_owned()),
597 n => parts.push(format!("{n} scenarios")),
598 }
599 if let Some(h) = t.held_out {
600 parts.push(format!("held-out {} of {}", h.passed, h.total));
601 }
602 let verdict = t.verdict.replace('_', " ");
603 let tail = if parts.is_empty() {
604 String::new()
605 } else {
606 format!(" — {}", parts.join(", "))
607 };
608 format!("{} {verdict} in {}{tail}", t.task, t.feature)
609}
610
611#[must_use]
614pub fn stopped_summary(s: &TaskStopped) -> String {
615 if s.outcome == "blocked_by_gate"
616 && let Some(g) = s.gates.iter().find(|g| g.outcome == GateState::Blocked)
617 {
618 return format!("{} blocked by {} in {}", s.task, g.name, s.feature);
619 }
620 let code = s
621 .code
622 .as_deref()
623 .map(|c| format!(" ({c})"))
624 .unwrap_or_default();
625 format!(
626 "{} stopped in {}: {}{code}",
627 s.task,
628 s.feature,
629 s.outcome.replace('_', " ")
630 )
631}
632
633#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
635#[serde(rename_all = "snake_case")]
636pub enum TrustState {
637 Trusted,
639 Untrusted,
641 Changed,
643}
644
645impl TrustState {
646 #[must_use]
648 pub const fn as_str(self) -> &'static str {
649 match self {
650 Self::Trusted => "trusted",
651 Self::Untrusted => "untrusted",
652 Self::Changed => "changed",
653 }
654 }
655}
656
657#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
659pub struct HookFile {
660 pub path: String,
662 pub digest: String,
664}
665
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
668pub struct HookRun {
669 pub at: String,
671 pub what: String,
673 pub outcome: String,
675 pub duration_ms: u64,
677 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub error: Option<String>,
680}
681
682#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
684pub struct HookEntry {
685 pub name: String,
687 pub role: String,
689 pub listens: String,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub at: Option<Point>,
694 #[serde(default, skip_serializing_if = "Vec::is_empty")]
696 pub on: Vec<String>,
697 pub runs: String,
699 pub state: TrustState,
701 pub digest: String,
703 pub files: Vec<HookFile>,
705 #[serde(default, skip_serializing_if = "Vec::is_empty")]
707 pub outside: Vec<String>,
708 pub definition: String,
710 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub trusted_definition: Option<String>,
713 pub recent: Vec<HookRun>,
715 pub trust_flag: String,
717 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub problem: Option<String>,
721}
722
723#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
725pub struct HookTested {
726 pub name: String,
728 pub outcome: String,
730 #[serde(default, skip_serializing_if = "Option::is_none")]
732 pub detail: Option<String>,
733 pub duration_ms: u64,
735}
736
737#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
740pub struct HooksReport {
741 pub hooks: Vec<HookEntry>,
743 #[serde(default, skip_serializing_if = "Option::is_none")]
745 pub problem: Option<String>,
746 #[serde(default, skip_serializing_if = "Vec::is_empty")]
748 pub trusted: Vec<String>,
749 #[serde(default, skip_serializing_if = "Option::is_none")]
751 pub tested: Option<HookTested>,
752 #[serde(default, skip_serializing_if = "Option::is_none")]
754 pub delivered: Option<u32>,
755 #[serde(default, skip_serializing_if = "Option::is_none")]
757 pub pending: Option<u32>,
758}
759
760#[cfg(test)]
761mod tests {
762 use super::*;
763 use serde_json::json;
764
765 fn hook(v: Value) -> Result<HookDef, String> {
766 parse("licence", &v)
767 }
768
769 #[test]
770 fn a_table_with_at_is_a_gate_and_one_with_on_an_observer() {
771 let g = hook(json!({"at": "tick", "argv": ["cargo", "deny", "check"]})).unwrap();
772 assert_eq!(g.at(), Some(Point::Tick));
773 assert_eq!(g.timeout, 60);
774 assert_eq!(g.listens(), "before tick");
775 let o =
776 hook(json!({"on": ["*"], "url": "https://example.com/h", "secret_env": "S"})).unwrap();
777 assert!(o.hears("task.stopped"));
778 assert_eq!(o.timeout, 10);
779 assert_eq!(o.listens(), "hears task.ticked, task.stopped");
780 let only = hook(json!({"on": ["task.ticked"], "argv": ["./n.sh"]})).unwrap();
781 assert!(only.hears("task.ticked") && !only.hears("task.stopped"));
782 }
783
784 #[test]
785 fn every_misconfiguration_names_its_fix() {
786 for (v, words) in [
787 (
788 json!({"at": "tick", "on": ["*"], "argv": ["x"]}),
789 "keep one",
790 ),
791 (json!({"argv": ["x"]}), "add `at"),
792 (json!({"at": "commit", "argv": ["x"]}), "\"tick\" or \"pr\""),
793 (json!({"on": ["task.done"], "argv": ["x"]}), "not an event"),
794 (json!({"on": [], "argv": ["x"]}), "names the events"),
795 (
796 json!({"at": "tick", "url": "https://x.dev"}),
797 "only an observer",
798 ),
799 (
800 json!({"on": ["*"], "url": "http://example.com"}),
801 "plain http",
802 ),
803 (json!({"at": "tick"}), "add `argv"),
804 (json!({"at": "tick", "argv": []}), "program first"),
805 (
806 json!({"at": "tick", "argv": ["x"], "tiemout": 5}),
807 "unknown key `tiemout`",
808 ),
809 (
810 json!({"at": "tick", "argv": ["x"], "timeout": 0}),
811 "1 to 600",
812 ),
813 (
814 json!({"at": "tick", "argv": ["x"], "timeout": 601}),
815 "1 to 600",
816 ),
817 (
818 json!({"on": ["*"], "argv": ["x"], "network": true}),
819 "for gates",
820 ),
821 (
822 json!({"on": ["*"], "argv": ["x"], "secret_env": "S"}),
823 "signs a `url`",
824 ),
825 (
826 json!({"at": "tick", "argv": "cargo deny"}),
827 "list of strings",
828 ),
829 ] {
830 let e = hook(v.clone()).unwrap_err();
831 assert!(e.starts_with("[hooks.licence]: "), "{e}");
832 assert!(e.contains(words), "{v} → {e}");
833 }
834 assert!(parse("Licence", &json!({"at": "tick", "argv": ["x"]})).is_err());
835 }
836
837 #[test]
838 fn plain_http_only_to_this_machine() {
839 assert!(url_allowed("https://hooks.slack.com/x").is_ok());
840 for ok in [
841 "http://localhost:8080/h",
842 "http://127.0.0.1/h",
843 "http://[::1]:9/h",
844 ] {
845 assert!(url_allowed(ok).is_ok(), "{ok}");
846 }
847 for bad in [
848 "http://example.com",
849 "http://localhost.evil.com/",
850 "http://user@10.0.0.1/",
851 "ftp://x",
852 "https://",
853 ] {
854 assert!(url_allowed(bad).is_err(), "{bad}");
855 }
856 }
857
858 #[test]
859 fn key_order_does_not_change_the_definition() {
860 let a = hook(json!({"at": "tick", "argv": ["x"], "timeout": 5})).unwrap();
861 let b = hook(json!({"timeout": 5, "argv": ["x"], "at": "tick"})).unwrap();
862 assert_eq!(a.definition.to_string(), b.definition.to_string());
863 }
864
865 fn exit(code: Option<i32>) -> GateExit<'static> {
866 GateExit {
867 exit_code: code,
868 timeout: 60,
869 ..GateExit::default()
870 }
871 }
872
873 #[test]
874 fn only_exit_zero_passes() {
875 assert_eq!(GateOutcome::of(&exit(Some(0))), GateOutcome::Passed);
876 let blocked = |e: &GateExit<'_>| match GateOutcome::of(e) {
877 GateOutcome::Blocked(r) => r,
878 GateOutcome::Passed => panic!("{e:?} passed"),
879 };
880 assert_eq!(blocked(&exit(Some(1))), "exit 1");
881 assert_eq!(blocked(&exit(Some(2))), "exit 2");
882 assert_eq!(blocked(&exit(None)), "was killed");
883 let words = GateExit {
884 stderr: "checking…\nerror: GPL-3.0 in `foo`\n\n",
885 ..exit(Some(1))
886 };
887 assert_eq!(blocked(&words), "error: GPL-3.0 in `foo`");
888 let stdout = GateExit {
889 stdout: "2 problems",
890 ..exit(Some(3))
891 };
892 assert_eq!(blocked(&stdout), "2 problems");
893 let late = GateExit {
894 timed_out: true,
895 ..exit(None)
896 };
897 assert_eq!(blocked(&late), "timed out after 60 s");
898 let missing = GateExit {
899 spawn_error: Some("`cargo-deny` not found"),
900 ..exit(None)
901 };
902 assert_eq!(blocked(&missing), "could not start: `cargo-deny` not found");
903 let changed = ["Cargo.lock".to_owned()];
904 let wrote = GateExit {
905 changed: &changed,
906 ..exit(Some(0))
907 };
908 assert_eq!(blocked(&wrote), "changed `Cargo.lock`");
909 }
910
911 #[test]
912 fn no_end_but_a_clean_exit_zero_passes() {
913 let changed = ["a".to_owned()];
914 for code in [
915 None,
916 Some(-1),
917 Some(0),
918 Some(1),
919 Some(2),
920 Some(127),
921 Some(255),
922 ] {
923 for timed_out in [false, true] {
924 for spawn in [None, Some("x")] {
925 for ch in [&[][..], &changed[..]] {
926 let e = GateExit {
927 exit_code: code,
928 timed_out,
929 spawn_error: spawn,
930 changed: ch,
931 timeout: 1,
932 ..GateExit::default()
933 };
934 let clean_zero =
935 code == Some(0) && !timed_out && spawn.is_none() && ch.is_empty();
936 assert_eq!(
937 GateOutcome::of(&e) == GateOutcome::Passed,
938 clean_zero,
939 "{e:?}"
940 );
941 }
942 }
943 }
944 }
945 }
946
947 #[test]
948 fn a_reason_is_one_short_line() {
949 assert_eq!(clean("a\tb\u{1b}[31m"), "a b\\u{1b}[31m");
950 let long = "x".repeat(400);
951 let c = clean(&long);
952 assert_eq!(c.chars().count(), MAX_REASON);
953 assert!(c.ends_with('…'));
954 }
955
956 #[test]
957 fn the_summaries_read_as_sentences() {
958 let t = TaskTicked {
959 feature: "001-md2csv".into(),
960 task: "T004".into(),
961 verdict: "verified".into(),
962 commit: "4f1c".into(),
963 scenarios: vec!["US1-1".into(), "US1-2".into(), "US1-3".into()],
964 held_out: Some(Count {
965 passed: 2,
966 total: 2,
967 }),
968 acceptance_strength: None,
969 gates: vec![],
970 executor: None,
971 run: None,
972 summary: String::new(),
973 };
974 assert_eq!(
975 ticked_summary(&t),
976 "T004 verified in 001-md2csv — 3 scenarios, held-out 2 of 2"
977 );
978 let mut s = TaskStopped {
979 feature: "001-md2csv".into(),
980 task: "T005".into(),
981 outcome: "verification_failed".into(),
982 code: Some("CHECK-FAILED".into()),
983 attempt_ref: "refs/x".into(),
984 gates: vec![],
985 run: None,
986 summary: String::new(),
987 };
988 assert_eq!(
989 stopped_summary(&s),
990 "T005 stopped in 001-md2csv: verification failed (CHECK-FAILED)"
991 );
992 s.outcome = "blocked_by_gate".into();
993 s.gates = vec![GateMark {
994 name: "licence".into(),
995 outcome: GateState::Blocked,
996 }];
997 assert_eq!(stopped_summary(&s), "T005 blocked by licence in 001-md2csv");
998 }
999}