Skip to main content

areev_trigger/
error.rs

1//! `TRG-Ennn` error codes.
2//!
3//! Every user-facing error carries a stable code as the **leading token of its
4//! `Display` string**, plus a `code()` method, so a reported code alone locates
5//! the variant. Codes are **append-only** — never renumber or reuse one.
6
7use std::fmt;
8
9#[derive(Debug)]
10pub enum TriggerError {
11    /// TRG-E001 — the declaration cannot fire as written.
12    Malformed { what: String },
13    /// TRG-E002 — `workflow` does not resolve to a Workflow grain.
14    UnresolvedWorkflow { hash: String, why: String },
15    /// TRG-E003 — a trigger is due but the host configured no connector command.
16    ///
17    /// Loud on purpose, matching how a host tool with no `--tool-cmd` refuses
18    /// rather than quietly succeeding: a poll that silently does nothing looks
19    /// exactly like a source with no new items.
20    NoConnector { trigger: String, connector: String },
21    /// TRG-E004 — the connector failed: non-zero exit, timeout, or non-JSON.
22    ConnectorFailed { trigger: String, detail: String },
23    /// TRG-E005 — claim lost: the lease expired and another evaluator took over.
24    ClaimLost { trigger: String },
25    /// TRG-E006 — the cron expression is invalid, or names a timezone this
26    /// release refuses.
27    BadSchedule { expr: String, why: String },
28    /// TRG-E007 — unknown `trigger render` target.
29    UnknownTarget { target: String },
30    /// TRG-E008 — a composite predicate references a member it does not declare.
31    UnknownMember { trigger: String, member: String },
32    /// TRG-E009 — the connector tried to reach a host outside its allowlist.
33    EgressRefused { trigger: String, host: String },
34    /// TRG-E010 — the store refused or failed underneath us.
35    Storage { detail: String },
36    /// TRG-E011 — a connector's blob payload violated the contract:
37    /// undecodable `b64`, a dangling `"@N"` reference, or a per-item /
38    /// per-response budget overrun. The WHOLE poll fails with the cursor
39    /// unmoved — refusing loudly beats truncating, because a silently
40    /// dropped attachment is an invoice posting without evidence.
41    BlobContract { trigger: String, detail: String },
42}
43
44impl TriggerError {
45    pub fn code(&self) -> &'static str {
46        match self {
47            TriggerError::Malformed { .. } => "TRG-E001",
48            TriggerError::UnresolvedWorkflow { .. } => "TRG-E002",
49            TriggerError::NoConnector { .. } => "TRG-E003",
50            TriggerError::ConnectorFailed { .. } => "TRG-E004",
51            TriggerError::ClaimLost { .. } => "TRG-E005",
52            TriggerError::BadSchedule { .. } => "TRG-E006",
53            TriggerError::UnknownTarget { .. } => "TRG-E007",
54            TriggerError::UnknownMember { .. } => "TRG-E008",
55            TriggerError::EgressRefused { .. } => "TRG-E009",
56            TriggerError::Storage { .. } => "TRG-E010",
57            TriggerError::BlobContract { .. } => "TRG-E011",
58        }
59    }
60}
61
62impl fmt::Display for TriggerError {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        match self {
65            TriggerError::Malformed { what } => {
66                write!(f, "TRG-E001: malformed trigger declaration: {what}")
67            }
68            TriggerError::UnresolvedWorkflow { hash, why } => {
69                write!(f, "TRG-E002: workflow {hash} does not resolve: {why}")
70            }
71            TriggerError::NoConnector { trigger, connector } => write!(
72                f,
73                "TRG-E003: trigger {trigger} is due but no connector command is configured \
74                 for '{connector}' — pass --connector-cmd, or the poll would silently do nothing"
75            ),
76            TriggerError::ConnectorFailed { trigger, detail } => {
77                write!(f, "TRG-E004: connector for trigger {trigger} failed: {detail}")
78            }
79            TriggerError::ClaimLost { trigger } => write!(
80                f,
81                "TRG-E005: claim on trigger {trigger} was lost — its lease expired and another \
82                 evaluator took over; this firing's release was refused"
83            ),
84            TriggerError::BadSchedule { expr, why } => {
85                write!(f, "TRG-E006: schedule {expr:?} is unusable: {why}")
86            }
87            TriggerError::UnknownTarget { target } => {
88                write!(f, "TRG-E007: unknown render target '{target}'")
89            }
90            TriggerError::UnknownMember { trigger, member } => write!(
91                f,
92                "TRG-E008: composite trigger {trigger} references member '{member}' \
93                 which it does not declare"
94            ),
95            TriggerError::EgressRefused { trigger, host } => write!(
96                f,
97                "TRG-E009: connector for trigger {trigger} tried to reach '{host}', \
98                 which is outside its declared allowed_outbound_hosts"
99            ),
100            TriggerError::Storage { detail } => write!(f, "TRG-E010: store: {detail}"),
101            TriggerError::BlobContract { trigger, detail } => write!(
102                f,
103                "TRG-E011: connector blob contract violated for trigger {trigger}: {detail} — \
104                 the poll was refused whole and the cursor left unmoved"
105            ),
106        }
107    }
108}
109
110impl std::error::Error for TriggerError {}
111
112pub type Result<T> = std::result::Result<T, TriggerError>;
113
114#[cfg(test)]
115mod tests {
116    use super::*;
117
118    fn all() -> Vec<TriggerError> {
119        vec![
120            TriggerError::Malformed { what: "x".into() },
121            TriggerError::UnresolvedWorkflow { hash: "h".into(), why: "w".into() },
122            TriggerError::NoConnector { trigger: "t".into(), connector: "c".into() },
123            TriggerError::ConnectorFailed { trigger: "t".into(), detail: "d".into() },
124            TriggerError::ClaimLost { trigger: "t".into() },
125            TriggerError::BadSchedule { expr: "e".into(), why: "w".into() },
126            TriggerError::UnknownTarget { target: "t".into() },
127            TriggerError::UnknownMember { trigger: "t".into(), member: "m".into() },
128            TriggerError::EgressRefused { trigger: "t".into(), host: "h".into() },
129            TriggerError::Storage { detail: "d".into() },
130            TriggerError::BlobContract { trigger: "t".into(), detail: "d".into() },
131        ]
132    }
133
134    #[test]
135    fn the_code_leads_the_display_string() {
136        // A reported code alone has to locate the variant, so it must be the
137        // first token — not buried mid-sentence where a log grep misses it.
138        for e in all() {
139            let s = e.to_string();
140            assert!(s.starts_with(e.code()), "{s} should start with {}", e.code());
141        }
142    }
143
144    #[test]
145    fn codes_are_unique_and_contiguous() {
146        let codes: Vec<&str> = all().iter().map(|e| e.code()).collect();
147        let mut sorted = codes.clone();
148        sorted.sort_unstable();
149        sorted.dedup();
150        assert_eq!(sorted.len(), codes.len(), "codes must be unique");
151        for (i, c) in codes.iter().enumerate() {
152            assert_eq!(*c, format!("TRG-E{:03}", i + 1), "codes are append-only and dense");
153        }
154    }
155}