Skip to main content

speccoder_core/
hooks.rs

1//! Hooks: a person's own commands around a run. A gate can stop a tick or
2//! a pull request — only exit 0 lets it through; an observer hears what
3//! speccoder decided and never changes it. The pure parts live here: the
4//! `[hooks.<name>]` grammar, the gate outcome rule, the two public events
5//! and the sentence each carries.
6
7use schemars::JsonSchema;
8use serde::{Deserialize, Serialize};
9use serde_json::{Map, Value};
10
11/// The longest gate reason kept, in characters.
12pub const MAX_REASON: usize = 300;
13
14/// The longest timeout a hook may ask for, in seconds.
15pub const MAX_TIMEOUT: u64 = 600;
16
17/// The events an observer can hear.
18pub const EVENTS: &[&str] = &["task.ticked", "task.stopped"];
19
20/// Where a gate stands.
21#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
22#[serde(rename_all = "snake_case")]
23pub enum Point {
24    /// After the verdict, before the tick commit.
25    Tick,
26    /// After the person confirms a pull request, before anything is pushed.
27    Pr,
28}
29
30impl Point {
31    /// `tick` or `pr`, as `at` names it.
32    #[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    /// The point in words: `before tick`, `before pull request`.
41    #[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    /// The point `at = "<word>"` names.
50    #[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/// A gate or an observer.
61#[derive(Debug, Clone, PartialEq, Eq)]
62pub enum Role {
63    /// Stops a point unless it exits 0.
64    Gate {
65        /// Where it stands.
66        at: Point,
67        /// Whether it may reach the network.
68        network: bool,
69    },
70    /// Hears events.
71    Observer {
72        /// The event names, or `*`.
73        on: Vec<String>,
74    },
75}
76
77/// What a hook runs.
78#[derive(Debug, Clone, PartialEq, Eq)]
79pub enum Runner {
80    /// A command; an observer gets the event on stdin.
81    Argv(Vec<String>),
82    /// A webhook (observers only).
83    Url {
84        /// Where to post.
85        url: String,
86        /// The variable holding the signing key.
87        secret_env: Option<String>,
88    },
89}
90
91/// One `[hooks.<name>]` table.
92#[derive(Debug, Clone, PartialEq)]
93pub struct HookDef {
94    /// The table's name.
95    pub name: String,
96    /// Gate or observer.
97    pub role: Role,
98    /// What it runs.
99    pub runner: Runner,
100    /// Seconds before it is stopped.
101    pub timeout: u64,
102    /// Variable names passed through the scrubbed environment.
103    pub env: Vec<String>,
104    /// More files it depends on (its configuration, scripts a shell line
105    /// runs): digested with it, never writable by the model.
106    pub files: Vec<String>,
107    /// The table as canonical JSON: what a digest and a trust prompt cover.
108    pub definition: Value,
109}
110
111impl HookDef {
112    /// The point this gate stands at; `None` for an observer.
113    #[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    /// Whether this observer hears `event`.
122    #[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    /// What it guards or hears, in words: `before tick`, `hears task.ticked, task.stopped`.
128    #[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    /// The command it runs, if it runs one.
140    #[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/// Whether `name` may name a hook: `[a-z0-9_-]+`.
150#[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
158/// Whether a webhook may go to `url`: https, or plain http only to this
159/// machine.
160///
161/// # Errors
162///
163/// Why not, with the fix.
164pub 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/// The value with every object's keys sorted, so key order and comments in
191/// the configuration never change a digest.
192#[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/// Parses `[hooks.<name>]` (as JSON).
222///
223/// # Errors
224///
225/// What is wrong, with the fix — a `HOOK-MISCONFIGURED` message.
226#[allow(clippy::too_many_lines)] // one rule per key, each with its fix
227pub 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/// How a gate's run ended.
369#[derive(Debug, Clone, Default)]
370pub struct GateExit<'a> {
371    /// Its exit code; `None` when it was killed or never started.
372    pub exit_code: Option<i32>,
373    /// It ran past its timeout.
374    pub timed_out: bool,
375    /// It could not be started.
376    pub spawn_error: Option<&'a str>,
377    /// What it printed.
378    pub stdout: &'a str,
379    /// What it printed on stderr.
380    pub stderr: &'a str,
381    /// Files the tick would commit that it changed.
382    pub changed: &'a [String],
383    /// Its timeout, for the words.
384    pub timeout: u64,
385}
386
387/// A gate's verdict on its point.
388#[derive(Debug, Clone, PartialEq, Eq)]
389pub enum GateOutcome {
390    /// It exited 0 and changed nothing.
391    Passed,
392    /// Anything else, with why.
393    Blocked(String),
394}
395
396impl GateOutcome {
397    /// The rule: exit 0 and nothing changed passes; every other end blocks,
398    /// with the gate's own last line when it printed one, else the cause.
399    #[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/// One line, control characters shown as `\u{…}`, at most [`MAX_REASON`]
434/// characters.
435#[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/// How one gate ended at its point.
458#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
459#[serde(rename_all = "snake_case")]
460pub enum GateState {
461    /// It exited 0.
462    Passed,
463    /// It stopped the point.
464    Blocked,
465    /// An earlier gate at the point blocked first.
466    NotRun,
467}
468
469impl GateState {
470    /// `passed`, `blocked`, `not_run`.
471    #[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/// One gate's run at its point, as a report and the evidence carry it.
482#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
483pub struct GateRecord {
484    /// The hook's name.
485    pub name: String,
486    /// Where it stood.
487    pub point: Point,
488    /// How it ended.
489    pub outcome: GateState,
490    /// Why it blocked.
491    #[serde(default, skip_serializing_if = "Option::is_none")]
492    pub reason: Option<String>,
493    /// `HOOK-BLOCKED`, or `HOOK-UNTRUSTED` for a gate that waits for trust.
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub code: Option<String>,
496    /// How long it ran.
497    pub duration_ms: u64,
498}
499
500impl GateRecord {
501    /// The one line a blocked point shows: `Blocked by licence — <reason>`.
502    #[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/// The first gate that blocked, if one did.
513#[must_use]
514pub fn first_block(gates: &[GateRecord]) -> Option<&GateRecord> {
515    gates.iter().find(|g| g.outcome == GateState::Blocked)
516}
517
518/// A gate's mark on an event: its name and how it ended.
519#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
520pub struct GateMark {
521    /// The hook's name.
522    pub name: String,
523    /// How it ended.
524    pub outcome: GateState,
525}
526
527/// `k` of `n`.
528#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
529pub struct Count {
530    /// How many passed.
531    pub passed: u32,
532    /// Of how many.
533    pub total: u32,
534}
535
536/// `task.ticked`: a tick commit on a unit's branch.
537#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
538pub struct TaskTicked {
539    /// The unit (feature) it belongs to.
540    pub feature: String,
541    /// The task id.
542    pub task: String,
543    /// `verified` or `regression_only`.
544    pub verdict: String,
545    /// The tick commit.
546    pub commit: String,
547    /// The scenarios its tests showed green (spec ids).
548    pub scenarios: Vec<String>,
549    /// The held-out tests that passed, when there were any.
550    #[serde(default, skip_serializing_if = "Option::is_none")]
551    pub held_out: Option<Count>,
552    /// How strong its acceptance tests were (0–1).
553    #[serde(default, skip_serializing_if = "Option::is_none")]
554    pub acceptance_strength: Option<f64>,
555    /// The gates it passed.
556    pub gates: Vec<GateMark>,
557    /// The external agent that did it, when one did.
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub executor: Option<String>,
560    /// The run.
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub run: Option<String>,
563    /// One sentence, ready for a chat message.
564    pub summary: String,
565}
566
567/// `task.stopped`: an attempt saved without a tick.
568#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
569pub struct TaskStopped {
570    /// The unit (feature) it belongs to.
571    pub feature: String,
572    /// The task id.
573    pub task: String,
574    /// `verification_failed`, `needs_approval`, `blocked_by_gate`, …
575    pub outcome: String,
576    /// The reason's code, when it has one.
577    #[serde(default, skip_serializing_if = "Option::is_none")]
578    pub code: Option<String>,
579    /// The saved attempt.
580    pub attempt_ref: String,
581    /// The gates that ran.
582    pub gates: Vec<GateMark>,
583    /// The run.
584    #[serde(default, skip_serializing_if = "Option::is_none")]
585    pub run: Option<String>,
586    /// One sentence, ready for a chat message.
587    pub summary: String,
588}
589
590/// *T004 verified in 001-md2csv — 3 scenarios, held-out 2 of 2*.
591#[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/// *T006 blocked by licence in 001-md2csv* · *T005 stopped in 001-md2csv:
612/// verification failed (CHECK-FAILED)*.
613#[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/// Whether a hook may run.
634#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
635#[serde(rename_all = "snake_case")]
636pub enum TrustState {
637    /// A person trusted these exact bytes.
638    Trusted,
639    /// Never trusted here.
640    Untrusted,
641    /// Trusted once; the definition or a file it runs changed since.
642    Changed,
643}
644
645impl TrustState {
646    /// `trusted`, `untrusted`, `changed`.
647    #[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/// A file a hook runs, and its content's digest.
658#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
659pub struct HookFile {
660    /// Its path in the repository.
661    pub path: String,
662    /// `sha256:<hex>` of its content.
663    pub digest: String,
664}
665
666/// One run of a hook.
667#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
668pub struct HookRun {
669    /// When it started (RFC 3339).
670    pub at: String,
671    /// The event id, `tick T004`, `pr` or `test`.
672    pub what: String,
673    /// `delivered`, `failed`, `passed`, `blocked`, `not_run`, `skipped_untrusted`.
674    pub outcome: String,
675    /// How long it ran.
676    pub duration_ms: u64,
677    /// Why it failed or blocked.
678    #[serde(default, skip_serializing_if = "Option::is_none")]
679    pub error: Option<String>,
680}
681
682/// One hook as `speccoder hooks` shows it.
683#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
684pub struct HookEntry {
685    /// The table's name.
686    pub name: String,
687    /// `gate` or `observer`.
688    pub role: String,
689    /// What it guards or hears, in words.
690    pub listens: String,
691    /// Where a gate stands.
692    #[serde(default, skip_serializing_if = "Option::is_none")]
693    pub at: Option<Point>,
694    /// The events an observer hears.
695    #[serde(default, skip_serializing_if = "Vec::is_empty")]
696    pub on: Vec<String>,
697    /// The command it runs, or the URL it posts to.
698    pub runs: String,
699    /// Whether it may run here.
700    pub state: TrustState,
701    /// `sha256:<hex>` over its definition and files.
702    pub digest: String,
703    /// The repository files it runs.
704    pub files: Vec<HookFile>,
705    /// Programs it names outside the repository.
706    #[serde(default, skip_serializing_if = "Vec::is_empty")]
707    pub outside: Vec<String>,
708    /// Its definition as TOML, as a trust prompt shows it.
709    pub definition: String,
710    /// The definition last trusted, for a changed hook.
711    #[serde(default, skip_serializing_if = "Option::is_none")]
712    pub trusted_definition: Option<String>,
713    /// Its recent runs, oldest first.
714    pub recent: Vec<HookRun>,
715    /// The flag that trusts it in CI: `--trust-hook name=sha256:…`.
716    pub trust_flag: String,
717    /// What keeps it from running here, with the fix (a missing program
718    /// or signing variable).
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub problem: Option<String>,
721}
722
723/// How `speccoder hooks test` went.
724#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
725pub struct HookTested {
726    /// The hook.
727    pub name: String,
728    /// `passed` or `blocked` (a gate), `delivered` or `failed` (an observer).
729    pub outcome: String,
730    /// Why it blocked or failed, or what the receiver answered.
731    #[serde(default, skip_serializing_if = "Option::is_none")]
732    pub detail: Option<String>,
733    /// How long it took.
734    pub duration_ms: u64,
735}
736
737/// `speccoder hooks`: every hook, and what a `trust`, `test` or `deliver`
738/// just did.
739#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
740pub struct HooksReport {
741    /// Every hook, by name.
742    pub hooks: Vec<HookEntry>,
743    /// Why the hooks could not be read (`HOOK-MISCONFIGURED`).
744    #[serde(default, skip_serializing_if = "Option::is_none")]
745    pub problem: Option<String>,
746    /// The hooks just trusted.
747    #[serde(default, skip_serializing_if = "Vec::is_empty")]
748    pub trusted: Vec<String>,
749    /// A test just run.
750    #[serde(default, skip_serializing_if = "Option::is_none")]
751    pub tested: Option<HookTested>,
752    /// Events just delivered, per observer.
753    #[serde(default, skip_serializing_if = "Option::is_none")]
754    pub delivered: Option<u32>,
755    /// Events still waiting for an observer that failed.
756    #[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}