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 pub extension: Option<(String, String)>,
113}
114
115impl HookDef {
116 #[must_use]
118 pub const fn at(&self) -> Option<Point> {
119 match self.role {
120 Role::Gate { at, .. } => Some(at),
121 Role::Observer { .. } => None,
122 }
123 }
124
125 #[must_use]
127 pub fn hears(&self, event: &str) -> bool {
128 matches!(&self.role, Role::Observer { on } if on.iter().any(|e| e == "*" || e == event))
129 }
130
131 #[must_use]
133 pub fn listens(&self) -> String {
134 match &self.role {
135 Role::Gate { at, .. } => at.words().to_owned(),
136 Role::Observer { on } if on.iter().any(|e| e == "*") => {
137 format!("hears {}", EVENTS.join(", "))
138 }
139 Role::Observer { on } => format!("hears {}", on.join(", ")),
140 }
141 }
142
143 #[must_use]
145 pub fn argv(&self) -> Option<&[String]> {
146 match &self.runner {
147 Runner::Argv(a) => Some(a),
148 Runner::Url { .. } => None,
149 }
150 }
151}
152
153#[must_use]
155pub fn valid_name(name: &str) -> bool {
156 !name.is_empty()
157 && name
158 .bytes()
159 .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'_' | b'-'))
160}
161
162pub fn url_allowed(url: &str) -> Result<(), String> {
169 if url.starts_with("https://") && url.len() > "https://".len() {
170 return Ok(());
171 }
172 let Some(rest) = url.strip_prefix("http://") else {
173 return Err(format!("`{url}` is not an https URL"));
174 };
175 let host = rest.split(['/', '?', '#']).next().unwrap_or_default();
176 let host = host.rsplit_once('@').map_or(host, |(_, h)| h);
177 let host = if host.starts_with('[') {
178 host.split(']')
179 .next()
180 .unwrap_or_default()
181 .trim_start_matches('[')
182 } else {
183 host.split(':').next().unwrap_or_default()
184 };
185 if matches!(host, "localhost" | "127.0.0.1" | "::1") {
186 Ok(())
187 } else {
188 Err(format!(
189 "`{url}` is plain http to another machine: use https (http only to localhost)"
190 ))
191 }
192}
193
194#[must_use]
197pub fn canonical(value: &Value) -> Value {
198 match value {
199 Value::Object(m) => {
200 let mut keys: Vec<&String> = m.keys().collect();
201 keys.sort();
202 let mut out = Map::new();
203 for k in keys {
204 out.insert(k.clone(), canonical(&m[k]));
205 }
206 Value::Object(out)
207 }
208 Value::Array(a) => Value::Array(a.iter().map(canonical).collect()),
209 v => v.clone(),
210 }
211}
212
213const KEYS: &[&str] = &[
214 "at",
215 "on",
216 "argv",
217 "url",
218 "secret_env",
219 "timeout",
220 "env",
221 "network",
222 "files",
223];
224
225#[allow(clippy::too_many_lines)] pub fn parse(name: &str, table: &Value) -> Result<HookDef, String> {
232 let at_hook = |m: String| format!("[hooks.{name}]: {m}");
233 if !valid_name(name) {
234 return Err(at_hook(
235 "a hook's name is lowercase letters, digits, `-` and `_`".to_owned(),
236 ));
237 }
238 let Some(t) = table.as_object() else {
239 return Err(at_hook("must be a table".to_owned()));
240 };
241 if let Some(k) = t.keys().find(|k| !KEYS.contains(&k.as_str())) {
242 return Err(at_hook(format!(
243 "unknown key `{k}` (known: {})",
244 KEYS.join(", ")
245 )));
246 }
247 let strings = |key: &str| -> Result<Option<Vec<String>>, String> {
248 match t.get(key) {
249 None => Ok(None),
250 Some(Value::Array(a)) => a
251 .iter()
252 .map(|v| v.as_str().map(str::to_owned))
253 .collect::<Option<Vec<_>>>()
254 .map(Some)
255 .ok_or_else(|| at_hook(format!("`{key}` is a list of strings"))),
256 Some(_) => Err(at_hook(format!("`{key}` is a list of strings"))),
257 }
258 };
259 let string = |key: &str| -> Result<Option<String>, String> {
260 match t.get(key) {
261 None => Ok(None),
262 Some(Value::String(s)) => Ok(Some(s.clone())),
263 Some(_) => Err(at_hook(format!("`{key}` is a string"))),
264 }
265 };
266 let argv = strings("argv")?;
267 let url = string("url")?;
268 let secret_env = string("secret_env")?;
269 let env = strings("env")?.unwrap_or_default();
270 let files = strings("files")?.unwrap_or_default();
271 if let Some(f) = files
272 .iter()
273 .find(|f| f.is_empty() || f.starts_with('/') || f.split('/').any(|s| s == ".."))
274 {
275 return Err(at_hook(format!(
276 "`files`: `{f}` is not a path inside the repository"
277 )));
278 }
279 let role = match (string("at")?, strings("on")?) {
280 (Some(_), Some(_)) => {
281 return Err(at_hook(
282 "`at` makes a gate and `on` an observer: keep one".to_owned(),
283 ));
284 }
285 (None, None) => {
286 return Err(at_hook(
287 "add `at = \"tick\"` (a gate) or `on = [\"task.ticked\"]` (an observer)".to_owned(),
288 ));
289 }
290 (Some(at), None) => {
291 let Some(at) = Point::named(&at) else {
292 return Err(at_hook(format!(
293 "`at = \"{at}\"`: a gate stands at \"tick\" or \"pr\""
294 )));
295 };
296 let network = match t.get("network") {
297 None => false,
298 Some(Value::Bool(b)) => *b,
299 Some(_) => return Err(at_hook("`network` is true or false".to_owned())),
300 };
301 Role::Gate { at, network }
302 }
303 (None, Some(on)) => {
304 if on.is_empty() {
305 return Err(at_hook(format!(
306 "`on` names the events to hear: {} or \"*\"",
307 EVENTS.join(", ")
308 )));
309 }
310 if let Some(e) = on
311 .iter()
312 .find(|e| *e != "*" && !EVENTS.contains(&e.as_str()))
313 {
314 return Err(at_hook(format!(
315 "`{e}` is not an event an observer can hear ({} or \"*\")",
316 EVENTS.join(", ")
317 )));
318 }
319 if t.contains_key("network") {
320 return Err(at_hook(
321 "`network` is for gates; an observer always has the network".to_owned(),
322 ));
323 }
324 Role::Observer { on }
325 }
326 };
327 let runner = match (argv, url) {
328 (Some(_), Some(_)) => {
329 return Err(at_hook("give `argv` or `url`, not both".to_owned()));
330 }
331 (None, None) => return Err(at_hook("add `argv = [\"…\"]` to run".to_owned())),
332 (Some(a), None) if a.is_empty() || a[0].trim().is_empty() => {
333 return Err(at_hook("`argv` names the program first".to_owned()));
334 }
335 (Some(a), None) => {
336 if secret_env.is_some() {
337 return Err(at_hook("`secret_env` signs a `url`'s requests".to_owned()));
338 }
339 Runner::Argv(a)
340 }
341 (None, Some(url)) => {
342 if matches!(role, Role::Gate { .. }) {
343 return Err(at_hook(
344 "a gate runs `argv`; only an observer posts to a `url`".to_owned(),
345 ));
346 }
347 url_allowed(&url).map_err(at_hook)?;
348 Runner::Url { url, secret_env }
349 }
350 };
351 let timeout = match t.get("timeout") {
352 None if matches!(role, Role::Gate { .. }) => 60,
353 None => 10,
354 Some(v) => match v.as_u64() {
355 Some(s) if (1..=MAX_TIMEOUT).contains(&s) => s,
356 _ => {
357 return Err(at_hook(format!("`timeout` is seconds, 1 to {MAX_TIMEOUT}")));
358 }
359 },
360 };
361 Ok(HookDef {
362 name: name.to_owned(),
363 role,
364 runner,
365 timeout,
366 env,
367 files,
368 definition: canonical(table),
369 extension: None,
370 })
371}
372
373#[derive(Debug, Clone, Default)]
375pub struct GateExit<'a> {
376 pub exit_code: Option<i32>,
378 pub timed_out: bool,
380 pub spawn_error: Option<&'a str>,
382 pub stdout: &'a str,
384 pub stderr: &'a str,
386 pub changed: &'a [String],
388 pub timeout: u64,
390}
391
392#[derive(Debug, Clone, PartialEq, Eq)]
394pub enum GateOutcome {
395 Passed,
397 Blocked(String),
399}
400
401impl GateOutcome {
402 #[must_use]
405 pub fn of(e: &GateExit<'_>) -> Self {
406 if let Some(err) = e.spawn_error {
407 return Self::Blocked(clean(&format!("could not start: {err}")));
408 }
409 if e.timed_out {
410 return Self::Blocked(format!("timed out after {} s", e.timeout));
411 }
412 if !e.changed.is_empty() {
413 let shown: Vec<String> = e.changed.iter().take(3).map(|p| format!("`{p}`")).collect();
414 let more = e.changed.len().saturating_sub(3);
415 let more = if more > 0 {
416 format!(" (+{more})")
417 } else {
418 String::new()
419 };
420 return Self::Blocked(clean(&format!("changed {}{more}", shown.join(", "))));
421 }
422 match e.exit_code {
423 Some(0) => Self::Passed,
424 Some(code) => Self::Blocked(
425 last_line(e.stderr)
426 .or_else(|| last_line(e.stdout))
427 .map_or_else(|| format!("exit {code}"), clean),
428 ),
429 None => Self::Blocked("was killed".to_owned()),
430 }
431 }
432}
433
434fn last_line(s: &str) -> Option<&str> {
435 s.lines().map(str::trim).rfind(|l| !l.is_empty())
436}
437
438#[must_use]
441pub fn clean(reason: &str) -> String {
442 let mut out = String::new();
443 for c in reason.chars() {
444 if c == '\n' || c == '\r' || c == '\t' {
445 out.push(' ');
446 } else if c.is_control() {
447 out.push_str(&c.escape_unicode().to_string());
448 } else {
449 out.push(c);
450 }
451 }
452 let out = out.trim();
453 if out.chars().count() > MAX_REASON {
454 let mut cut: String = out.chars().take(MAX_REASON - 1).collect();
455 cut.push('…');
456 cut
457 } else {
458 out.to_owned()
459 }
460}
461
462#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
464#[serde(rename_all = "snake_case")]
465pub enum GateState {
466 Passed,
468 Blocked,
470 NotRun,
472}
473
474impl GateState {
475 #[must_use]
477 pub const fn as_str(self) -> &'static str {
478 match self {
479 Self::Passed => "passed",
480 Self::Blocked => "blocked",
481 Self::NotRun => "not_run",
482 }
483 }
484}
485
486#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
488pub struct GateRecord {
489 pub name: String,
491 pub point: Point,
493 pub outcome: GateState,
495 #[serde(default, skip_serializing_if = "Option::is_none")]
497 pub reason: Option<String>,
498 #[serde(default, skip_serializing_if = "Option::is_none")]
500 pub code: Option<String>,
501 pub duration_ms: u64,
503 #[serde(default, skip_serializing_if = "Vec::is_empty")]
505 pub findings: Vec<crate::extensions::SarifFinding>,
506}
507
508impl GateRecord {
509 #[must_use]
511 pub fn blocked_line(&self) -> String {
512 format!(
513 "Blocked by {} — {}",
514 self.name,
515 self.reason.as_deref().unwrap_or("no reason given")
516 )
517 }
518}
519
520#[must_use]
522pub fn first_block(gates: &[GateRecord]) -> Option<&GateRecord> {
523 gates.iter().find(|g| g.outcome == GateState::Blocked)
524}
525
526#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
528pub struct GateMark {
529 pub name: String,
531 pub outcome: GateState,
533}
534
535#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
537pub struct Count {
538 pub passed: u32,
540 pub total: u32,
542}
543
544#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
546pub struct TaskTicked {
547 pub feature: String,
549 pub task: String,
551 pub verdict: String,
553 pub commit: String,
555 pub scenarios: Vec<String>,
557 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub held_out: Option<Count>,
560 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub acceptance_strength: Option<f64>,
563 pub gates: Vec<GateMark>,
565 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub executor: Option<String>,
568 #[serde(default, skip_serializing_if = "Option::is_none")]
570 pub run: Option<String>,
571 pub summary: String,
573}
574
575#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
577pub struct TaskStopped {
578 pub feature: String,
580 pub task: String,
582 pub outcome: String,
584 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub code: Option<String>,
587 pub attempt_ref: String,
589 pub gates: Vec<GateMark>,
591 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub run: Option<String>,
594 pub summary: String,
596}
597
598#[must_use]
600pub fn ticked_summary(t: &TaskTicked) -> String {
601 let mut parts = Vec::new();
602 match t.scenarios.len() {
603 0 => {}
604 1 => parts.push("1 scenario".to_owned()),
605 n => parts.push(format!("{n} scenarios")),
606 }
607 if let Some(h) = t.held_out {
608 parts.push(format!("held-out {} of {}", h.passed, h.total));
609 }
610 let verdict = t.verdict.replace('_', " ");
611 let tail = if parts.is_empty() {
612 String::new()
613 } else {
614 format!(" — {}", parts.join(", "))
615 };
616 format!("{} {verdict} in {}{tail}", t.task, t.feature)
617}
618
619#[must_use]
622pub fn stopped_summary(s: &TaskStopped) -> String {
623 if s.outcome == "blocked_by_gate"
624 && let Some(g) = s.gates.iter().find(|g| g.outcome == GateState::Blocked)
625 {
626 return format!("{} blocked by {} in {}", s.task, g.name, s.feature);
627 }
628 let code = s
629 .code
630 .as_deref()
631 .map(|c| format!(" ({c})"))
632 .unwrap_or_default();
633 format!(
634 "{} stopped in {}: {}{code}",
635 s.task,
636 s.feature,
637 s.outcome.replace('_', " ")
638 )
639}
640
641#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
643#[serde(rename_all = "snake_case")]
644pub enum TrustState {
645 Trusted,
647 Untrusted,
649 Changed,
651}
652
653impl TrustState {
654 #[must_use]
656 pub const fn as_str(self) -> &'static str {
657 match self {
658 Self::Trusted => "trusted",
659 Self::Untrusted => "untrusted",
660 Self::Changed => "changed",
661 }
662 }
663}
664
665#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
667pub struct HookFile {
668 pub path: String,
670 pub digest: String,
672}
673
674#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
676pub struct HookRun {
677 pub at: String,
679 pub what: String,
681 pub outcome: String,
683 pub duration_ms: u64,
685 #[serde(default, skip_serializing_if = "Option::is_none")]
687 pub error: Option<String>,
688 #[serde(default, skip_serializing_if = "Vec::is_empty")]
690 pub findings: Vec<crate::extensions::SarifFinding>,
691 #[serde(default, skip_serializing_if = "Option::is_none")]
693 pub findings_error: Option<String>,
694}
695
696#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
698pub struct HookEntry {
699 pub name: String,
701 pub role: String,
703 pub listens: String,
705 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub at: Option<Point>,
708 #[serde(default, skip_serializing_if = "Vec::is_empty")]
710 pub on: Vec<String>,
711 pub runs: String,
713 pub state: TrustState,
715 pub digest: String,
717 pub files: Vec<HookFile>,
719 #[serde(default, skip_serializing_if = "Vec::is_empty")]
721 pub outside: Vec<String>,
722 pub definition: String,
724 #[serde(default, skip_serializing_if = "Option::is_none")]
726 pub trusted_definition: Option<String>,
727 pub recent: Vec<HookRun>,
729 pub trust_flag: String,
731 #[serde(default, skip_serializing_if = "Option::is_none")]
734 pub problem: Option<String>,
735}
736
737#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
739pub struct HookTested {
740 pub name: String,
742 pub outcome: String,
744 #[serde(default, skip_serializing_if = "Option::is_none")]
746 pub detail: Option<String>,
747 pub duration_ms: u64,
749}
750
751#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
754pub struct HooksReport {
755 pub hooks: Vec<HookEntry>,
757 #[serde(default, skip_serializing_if = "Option::is_none")]
759 pub problem: Option<String>,
760 #[serde(default, skip_serializing_if = "Vec::is_empty")]
762 pub trusted: Vec<String>,
763 #[serde(default, skip_serializing_if = "Option::is_none")]
765 pub tested: Option<HookTested>,
766 #[serde(default, skip_serializing_if = "Option::is_none")]
768 pub delivered: Option<u32>,
769 #[serde(default, skip_serializing_if = "Option::is_none")]
771 pub pending: Option<u32>,
772}
773
774#[cfg(test)]
775mod tests {
776 use super::*;
777 use serde_json::json;
778
779 fn hook(v: Value) -> Result<HookDef, String> {
780 parse("licence", &v)
781 }
782
783 #[test]
784 fn a_table_with_at_is_a_gate_and_one_with_on_an_observer() {
785 let g = hook(json!({"at": "tick", "argv": ["cargo", "deny", "check"]})).unwrap();
786 assert_eq!(g.at(), Some(Point::Tick));
787 assert_eq!(g.timeout, 60);
788 assert_eq!(g.listens(), "before tick");
789 let o =
790 hook(json!({"on": ["*"], "url": "https://example.com/h", "secret_env": "S"})).unwrap();
791 assert!(o.hears("task.stopped"));
792 assert_eq!(o.timeout, 10);
793 assert_eq!(o.listens(), "hears task.ticked, task.stopped");
794 let only = hook(json!({"on": ["task.ticked"], "argv": ["./n.sh"]})).unwrap();
795 assert!(only.hears("task.ticked") && !only.hears("task.stopped"));
796 }
797
798 #[test]
799 fn every_misconfiguration_names_its_fix() {
800 for (v, words) in [
801 (
802 json!({"at": "tick", "on": ["*"], "argv": ["x"]}),
803 "keep one",
804 ),
805 (json!({"argv": ["x"]}), "add `at"),
806 (json!({"at": "commit", "argv": ["x"]}), "\"tick\" or \"pr\""),
807 (json!({"on": ["task.done"], "argv": ["x"]}), "not an event"),
808 (json!({"on": [], "argv": ["x"]}), "names the events"),
809 (
810 json!({"at": "tick", "url": "https://x.dev"}),
811 "only an observer",
812 ),
813 (
814 json!({"on": ["*"], "url": "http://example.com"}),
815 "plain http",
816 ),
817 (json!({"at": "tick"}), "add `argv"),
818 (json!({"at": "tick", "argv": []}), "program first"),
819 (
820 json!({"at": "tick", "argv": ["x"], "tiemout": 5}),
821 "unknown key `tiemout`",
822 ),
823 (
824 json!({"at": "tick", "argv": ["x"], "timeout": 0}),
825 "1 to 600",
826 ),
827 (
828 json!({"at": "tick", "argv": ["x"], "timeout": 601}),
829 "1 to 600",
830 ),
831 (
832 json!({"on": ["*"], "argv": ["x"], "network": true}),
833 "for gates",
834 ),
835 (
836 json!({"on": ["*"], "argv": ["x"], "secret_env": "S"}),
837 "signs a `url`",
838 ),
839 (
840 json!({"at": "tick", "argv": "cargo deny"}),
841 "list of strings",
842 ),
843 ] {
844 let e = hook(v.clone()).unwrap_err();
845 assert!(e.starts_with("[hooks.licence]: "), "{e}");
846 assert!(e.contains(words), "{v} → {e}");
847 }
848 assert!(parse("Licence", &json!({"at": "tick", "argv": ["x"]})).is_err());
849 }
850
851 #[test]
852 fn plain_http_only_to_this_machine() {
853 assert!(url_allowed("https://hooks.slack.com/x").is_ok());
854 for ok in [
855 "http://localhost:8080/h",
856 "http://127.0.0.1/h",
857 "http://[::1]:9/h",
858 ] {
859 assert!(url_allowed(ok).is_ok(), "{ok}");
860 }
861 for bad in [
862 "http://example.com",
863 "http://localhost.evil.com/",
864 "http://user@10.0.0.1/",
865 "ftp://x",
866 "https://",
867 ] {
868 assert!(url_allowed(bad).is_err(), "{bad}");
869 }
870 }
871
872 #[test]
873 fn key_order_does_not_change_the_definition() {
874 let a = hook(json!({"at": "tick", "argv": ["x"], "timeout": 5})).unwrap();
875 let b = hook(json!({"timeout": 5, "argv": ["x"], "at": "tick"})).unwrap();
876 assert_eq!(a.definition.to_string(), b.definition.to_string());
877 }
878
879 fn exit(code: Option<i32>) -> GateExit<'static> {
880 GateExit {
881 exit_code: code,
882 timeout: 60,
883 ..GateExit::default()
884 }
885 }
886
887 #[test]
888 fn only_exit_zero_passes() {
889 assert_eq!(GateOutcome::of(&exit(Some(0))), GateOutcome::Passed);
890 let blocked = |e: &GateExit<'_>| match GateOutcome::of(e) {
891 GateOutcome::Blocked(r) => r,
892 GateOutcome::Passed => panic!("{e:?} passed"),
893 };
894 assert_eq!(blocked(&exit(Some(1))), "exit 1");
895 assert_eq!(blocked(&exit(Some(2))), "exit 2");
896 assert_eq!(blocked(&exit(None)), "was killed");
897 let words = GateExit {
898 stderr: "checking…\nerror: GPL-3.0 in `foo`\n\n",
899 ..exit(Some(1))
900 };
901 assert_eq!(blocked(&words), "error: GPL-3.0 in `foo`");
902 let stdout = GateExit {
903 stdout: "2 problems",
904 ..exit(Some(3))
905 };
906 assert_eq!(blocked(&stdout), "2 problems");
907 let late = GateExit {
908 timed_out: true,
909 ..exit(None)
910 };
911 assert_eq!(blocked(&late), "timed out after 60 s");
912 let missing = GateExit {
913 spawn_error: Some("`cargo-deny` not found"),
914 ..exit(None)
915 };
916 assert_eq!(blocked(&missing), "could not start: `cargo-deny` not found");
917 let changed = ["Cargo.lock".to_owned()];
918 let wrote = GateExit {
919 changed: &changed,
920 ..exit(Some(0))
921 };
922 assert_eq!(blocked(&wrote), "changed `Cargo.lock`");
923 }
924
925 #[test]
926 fn no_end_but_a_clean_exit_zero_passes() {
927 let changed = ["a".to_owned()];
928 for code in [
929 None,
930 Some(-1),
931 Some(0),
932 Some(1),
933 Some(2),
934 Some(127),
935 Some(255),
936 ] {
937 for timed_out in [false, true] {
938 for spawn in [None, Some("x")] {
939 for ch in [&[][..], &changed[..]] {
940 let e = GateExit {
941 exit_code: code,
942 timed_out,
943 spawn_error: spawn,
944 changed: ch,
945 timeout: 1,
946 ..GateExit::default()
947 };
948 let clean_zero =
949 code == Some(0) && !timed_out && spawn.is_none() && ch.is_empty();
950 assert_eq!(
951 GateOutcome::of(&e) == GateOutcome::Passed,
952 clean_zero,
953 "{e:?}"
954 );
955 }
956 }
957 }
958 }
959 }
960
961 #[test]
962 fn a_reason_is_one_short_line() {
963 assert_eq!(clean("a\tb\u{1b}[31m"), "a b\\u{1b}[31m");
964 let long = "x".repeat(400);
965 let c = clean(&long);
966 assert_eq!(c.chars().count(), MAX_REASON);
967 assert!(c.ends_with('…'));
968 }
969
970 #[test]
971 fn the_summaries_read_as_sentences() {
972 let t = TaskTicked {
973 feature: "001-md2csv".into(),
974 task: "T004".into(),
975 verdict: "verified".into(),
976 commit: "4f1c".into(),
977 scenarios: vec!["US1-1".into(), "US1-2".into(), "US1-3".into()],
978 held_out: Some(Count {
979 passed: 2,
980 total: 2,
981 }),
982 acceptance_strength: None,
983 gates: vec![],
984 executor: None,
985 run: None,
986 summary: String::new(),
987 };
988 assert_eq!(
989 ticked_summary(&t),
990 "T004 verified in 001-md2csv — 3 scenarios, held-out 2 of 2"
991 );
992 let mut s = TaskStopped {
993 feature: "001-md2csv".into(),
994 task: "T005".into(),
995 outcome: "verification_failed".into(),
996 code: Some("CHECK-FAILED".into()),
997 attempt_ref: "refs/x".into(),
998 gates: vec![],
999 run: None,
1000 summary: String::new(),
1001 };
1002 assert_eq!(
1003 stopped_summary(&s),
1004 "T005 stopped in 001-md2csv: verification failed (CHECK-FAILED)"
1005 );
1006 s.outcome = "blocked_by_gate".into();
1007 s.gates = vec![GateMark {
1008 name: "licence".into(),
1009 outcome: GateState::Blocked,
1010 }];
1011 assert_eq!(stopped_summary(&s), "T005 blocked by licence in 001-md2csv");
1012 }
1013}