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    /// The extension it comes from, and that extension's folder relative
110    /// to the project: its trust is the extension's, its `./` paths are
111    /// inside that folder.
112    pub extension: Option<(String, String)>,
113}
114
115impl HookDef {
116    /// The point this gate stands at; `None` for an observer.
117    #[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    /// Whether this observer hears `event`.
126    #[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    /// What it guards or hears, in words: `before tick`, `hears task.ticked, task.stopped`.
132    #[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    /// The command it runs, if it runs one.
144    #[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/// Whether `name` may name a hook: `[a-z0-9_-]+`.
154#[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
162/// Whether a webhook may go to `url`: https, or plain http only to this
163/// machine.
164///
165/// # Errors
166///
167/// Why not, with the fix.
168pub 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/// The value with every object's keys sorted, so key order and comments in
195/// the configuration never change a digest.
196#[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/// Parses `[hooks.<name>]` (as JSON).
226///
227/// # Errors
228///
229/// What is wrong, with the fix — a `HOOK-MISCONFIGURED` message.
230#[allow(clippy::too_many_lines)] // one rule per key, each with its fix
231pub 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/// How a gate's run ended.
374#[derive(Debug, Clone, Default)]
375pub struct GateExit<'a> {
376    /// Its exit code; `None` when it was killed or never started.
377    pub exit_code: Option<i32>,
378    /// It ran past its timeout.
379    pub timed_out: bool,
380    /// It could not be started.
381    pub spawn_error: Option<&'a str>,
382    /// What it printed.
383    pub stdout: &'a str,
384    /// What it printed on stderr.
385    pub stderr: &'a str,
386    /// Files the tick would commit that it changed.
387    pub changed: &'a [String],
388    /// Its timeout, for the words.
389    pub timeout: u64,
390}
391
392/// A gate's verdict on its point.
393#[derive(Debug, Clone, PartialEq, Eq)]
394pub enum GateOutcome {
395    /// It exited 0 and changed nothing.
396    Passed,
397    /// Anything else, with why.
398    Blocked(String),
399}
400
401impl GateOutcome {
402    /// The rule: exit 0 and nothing changed passes; every other end blocks,
403    /// with the gate's own last line when it printed one, else the cause.
404    #[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/// One line, control characters shown as `\u{…}`, at most [`MAX_REASON`]
439/// characters.
440#[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/// How one gate ended at its point.
463#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
464#[serde(rename_all = "snake_case")]
465pub enum GateState {
466    /// It exited 0.
467    Passed,
468    /// It stopped the point.
469    Blocked,
470    /// An earlier gate at the point blocked first.
471    NotRun,
472}
473
474impl GateState {
475    /// `passed`, `blocked`, `not_run`.
476    #[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/// One gate's run at its point, as a report and the evidence carry it.
487#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
488pub struct GateRecord {
489    /// The hook's name.
490    pub name: String,
491    /// Where it stood.
492    pub point: Point,
493    /// How it ended.
494    pub outcome: GateState,
495    /// Why it blocked.
496    #[serde(default, skip_serializing_if = "Option::is_none")]
497    pub reason: Option<String>,
498    /// `HOOK-BLOCKED`, or `HOOK-UNTRUSTED` for a gate that waits for trust.
499    #[serde(default, skip_serializing_if = "Option::is_none")]
500    pub code: Option<String>,
501    /// How long it ran.
502    pub duration_ms: u64,
503    /// The findings it left (SARIF), as shown.
504    #[serde(default, skip_serializing_if = "Vec::is_empty")]
505    pub findings: Vec<crate::extensions::SarifFinding>,
506}
507
508impl GateRecord {
509    /// The one line a blocked point shows: `Blocked by licence — <reason>`.
510    #[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/// The first gate that blocked, if one did.
521#[must_use]
522pub fn first_block(gates: &[GateRecord]) -> Option<&GateRecord> {
523    gates.iter().find(|g| g.outcome == GateState::Blocked)
524}
525
526/// A gate's mark on an event: its name and how it ended.
527#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
528pub struct GateMark {
529    /// The hook's name.
530    pub name: String,
531    /// How it ended.
532    pub outcome: GateState,
533}
534
535/// `k` of `n`.
536#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
537pub struct Count {
538    /// How many passed.
539    pub passed: u32,
540    /// Of how many.
541    pub total: u32,
542}
543
544/// `task.ticked`: a tick commit on a unit's branch.
545#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
546pub struct TaskTicked {
547    /// The unit (feature) it belongs to.
548    pub feature: String,
549    /// The task id.
550    pub task: String,
551    /// `verified` or `regression_only`.
552    pub verdict: String,
553    /// The tick commit.
554    pub commit: String,
555    /// The scenarios its tests showed green (spec ids).
556    pub scenarios: Vec<String>,
557    /// The held-out tests that passed, when there were any.
558    #[serde(default, skip_serializing_if = "Option::is_none")]
559    pub held_out: Option<Count>,
560    /// How strong its acceptance tests were (0–1).
561    #[serde(default, skip_serializing_if = "Option::is_none")]
562    pub acceptance_strength: Option<f64>,
563    /// The gates it passed.
564    pub gates: Vec<GateMark>,
565    /// The external agent that did it, when one did.
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub executor: Option<String>,
568    /// The run.
569    #[serde(default, skip_serializing_if = "Option::is_none")]
570    pub run: Option<String>,
571    /// One sentence, ready for a chat message.
572    pub summary: String,
573}
574
575/// `task.stopped`: an attempt saved without a tick.
576#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
577pub struct TaskStopped {
578    /// The unit (feature) it belongs to.
579    pub feature: String,
580    /// The task id.
581    pub task: String,
582    /// `verification_failed`, `needs_approval`, `blocked_by_gate`, …
583    pub outcome: String,
584    /// The reason's code, when it has one.
585    #[serde(default, skip_serializing_if = "Option::is_none")]
586    pub code: Option<String>,
587    /// The saved attempt.
588    pub attempt_ref: String,
589    /// The gates that ran.
590    pub gates: Vec<GateMark>,
591    /// The run.
592    #[serde(default, skip_serializing_if = "Option::is_none")]
593    pub run: Option<String>,
594    /// One sentence, ready for a chat message.
595    pub summary: String,
596}
597
598/// *T004 verified in 001-md2csv — 3 scenarios, held-out 2 of 2*.
599#[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/// *T006 blocked by licence in 001-md2csv* · *T005 stopped in 001-md2csv:
620/// verification failed (CHECK-FAILED)*.
621#[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/// Whether a hook may run.
642#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
643#[serde(rename_all = "snake_case")]
644pub enum TrustState {
645    /// A person trusted these exact bytes.
646    Trusted,
647    /// Never trusted here.
648    Untrusted,
649    /// Trusted once; the definition or a file it runs changed since.
650    Changed,
651}
652
653impl TrustState {
654    /// `trusted`, `untrusted`, `changed`.
655    #[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/// A file a hook runs, and its content's digest.
666#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
667pub struct HookFile {
668    /// Its path in the repository.
669    pub path: String,
670    /// `sha256:<hex>` of its content.
671    pub digest: String,
672}
673
674/// One run of a hook.
675#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
676pub struct HookRun {
677    /// When it started (RFC 3339).
678    pub at: String,
679    /// The event id, `tick T004`, `pr` or `test`.
680    pub what: String,
681    /// `delivered`, `failed`, `passed`, `blocked`, `not_run`, `skipped_untrusted`.
682    pub outcome: String,
683    /// How long it ran.
684    pub duration_ms: u64,
685    /// Why it failed or blocked.
686    #[serde(default, skip_serializing_if = "Option::is_none")]
687    pub error: Option<String>,
688    /// The findings a gate left (SARIF), as shown.
689    #[serde(default, skip_serializing_if = "Vec::is_empty")]
690    pub findings: Vec<crate::extensions::SarifFinding>,
691    /// Why the findings it left could not be shown.
692    #[serde(default, skip_serializing_if = "Option::is_none")]
693    pub findings_error: Option<String>,
694}
695
696/// One hook as `speccoder hooks` shows it.
697#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
698pub struct HookEntry {
699    /// The table's name.
700    pub name: String,
701    /// `gate` or `observer`.
702    pub role: String,
703    /// What it guards or hears, in words.
704    pub listens: String,
705    /// Where a gate stands.
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub at: Option<Point>,
708    /// The events an observer hears.
709    #[serde(default, skip_serializing_if = "Vec::is_empty")]
710    pub on: Vec<String>,
711    /// The command it runs, or the URL it posts to.
712    pub runs: String,
713    /// Whether it may run here.
714    pub state: TrustState,
715    /// `sha256:<hex>` over its definition and files.
716    pub digest: String,
717    /// The repository files it runs.
718    pub files: Vec<HookFile>,
719    /// Programs it names outside the repository.
720    #[serde(default, skip_serializing_if = "Vec::is_empty")]
721    pub outside: Vec<String>,
722    /// Its definition as TOML, as a trust prompt shows it.
723    pub definition: String,
724    /// The definition last trusted, for a changed hook.
725    #[serde(default, skip_serializing_if = "Option::is_none")]
726    pub trusted_definition: Option<String>,
727    /// Its recent runs, oldest first.
728    pub recent: Vec<HookRun>,
729    /// The flag that trusts it in CI: `--trust-hook name=sha256:…`.
730    pub trust_flag: String,
731    /// What keeps it from running here, with the fix (a missing program
732    /// or signing variable).
733    #[serde(default, skip_serializing_if = "Option::is_none")]
734    pub problem: Option<String>,
735}
736
737/// How `speccoder hooks test` went.
738#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
739pub struct HookTested {
740    /// The hook.
741    pub name: String,
742    /// `passed` or `blocked` (a gate), `delivered` or `failed` (an observer).
743    pub outcome: String,
744    /// Why it blocked or failed, or what the receiver answered.
745    #[serde(default, skip_serializing_if = "Option::is_none")]
746    pub detail: Option<String>,
747    /// How long it took.
748    pub duration_ms: u64,
749}
750
751/// `speccoder hooks`: every hook, and what a `trust`, `test` or `deliver`
752/// just did.
753#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)]
754pub struct HooksReport {
755    /// Every hook, by name.
756    pub hooks: Vec<HookEntry>,
757    /// Why the hooks could not be read (`HOOK-MISCONFIGURED`).
758    #[serde(default, skip_serializing_if = "Option::is_none")]
759    pub problem: Option<String>,
760    /// The hooks just trusted.
761    #[serde(default, skip_serializing_if = "Vec::is_empty")]
762    pub trusted: Vec<String>,
763    /// A test just run.
764    #[serde(default, skip_serializing_if = "Option::is_none")]
765    pub tested: Option<HookTested>,
766    /// Events just delivered, per observer.
767    #[serde(default, skip_serializing_if = "Option::is_none")]
768    pub delivered: Option<u32>,
769    /// Events still waiting for an observer that failed.
770    #[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}