Skip to main content

tabnas_alchemy/shared/
error.rs

1//! Failures, with stable codes.
2//!
3//! The code is the contract: scripts and agents branch on it, and every
4//! renderer, transducer and host uses the same set. The message, path and
5//! limit are informative. A code is never renamed, removed or repurposed;
6//! one may be added.
7
8use std::fmt;
9
10/// The stable failure codes.
11#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
12pub enum Code {
13    /// The DSL source does not parse (reader or layout error).
14    DslParseError,
15    /// The DSL program does not type check, or names something unknown.
16    DslTypeError,
17    /// A one-shot stream was consumed twice.
18    StreamReused,
19    /// The plan's streamability could not be established in strict mode.
20    StreamabilityUnknown,
21    /// Input arrived in an order the plan's contract forbids (a row before
22    /// its metadata).
23    InputOrderViolation,
24    /// Two captures select overlapping scopes.
25    CaptureOverlapUnsupported,
26    /// A required value is absent and no policy maps it.
27    MissingValue,
28    /// An object repeats a member name under a policy that rejects that.
29    DuplicateMember,
30    /// A number lexeme is not a valid number for the target.
31    InvalidNumber,
32    /// A protocol event arrived out of sequence (a row before the schema,
33    /// two schemas, a row of the wrong width, a missing end).
34    ProtocolOrderError,
35    /// The target format cannot represent the value (NaN in JSON, a table
36    /// with no columns in CSV).
37    TargetValueUnrepresentable,
38    /// A configured limit was exceeded.
39    ResourceLimitExceeded,
40    /// The input did not parse, or is invalid for the source.
41    InputInvalid,
42    /// Writing the output failed.
43    OutputFailed,
44    /// The run was cancelled.
45    Aborted,
46}
47
48impl Code {
49    /// The code as it is written in every output: `SCREAMING_SNAKE_CASE`.
50    pub fn as_str(self) -> &'static str {
51        match self {
52            Code::DslParseError => "DSL_PARSE_ERROR",
53            Code::DslTypeError => "DSL_TYPE_ERROR",
54            Code::StreamReused => "STREAM_REUSED",
55            Code::StreamabilityUnknown => "STREAMABILITY_UNKNOWN",
56            Code::InputOrderViolation => "INPUT_ORDER_VIOLATION",
57            Code::CaptureOverlapUnsupported => "CAPTURE_OVERLAP_UNSUPPORTED",
58            Code::MissingValue => "MISSING_VALUE",
59            Code::DuplicateMember => "DUPLICATE_MEMBER",
60            Code::InvalidNumber => "INVALID_NUMBER",
61            Code::ProtocolOrderError => "PROTOCOL_ORDER_ERROR",
62            Code::TargetValueUnrepresentable => "TARGET_VALUE_UNREPRESENTABLE",
63            Code::ResourceLimitExceeded => "RESOURCE_LIMIT_EXCEEDED",
64            Code::InputInvalid => "INPUT_INVALID",
65            Code::OutputFailed => "OUTPUT_FAILED",
66            Code::Aborted => "ABORTED",
67        }
68    }
69
70    /// Every code, in declaration order (for documentation checks).
71    pub const ALL: [Code; 15] = [
72        Code::DslParseError,
73        Code::DslTypeError,
74        Code::StreamReused,
75        Code::StreamabilityUnknown,
76        Code::InputOrderViolation,
77        Code::CaptureOverlapUnsupported,
78        Code::MissingValue,
79        Code::DuplicateMember,
80        Code::InvalidNumber,
81        Code::ProtocolOrderError,
82        Code::TargetValueUnrepresentable,
83        Code::ResourceLimitExceeded,
84        Code::InputInvalid,
85        Code::OutputFailed,
86        Code::Aborted,
87    ];
88
89    /// A code by its written form.
90    pub fn parse(text: &str) -> Option<Code> {
91        Code::ALL.iter().copied().find(|c| c.as_str() == text)
92    }
93}
94
95impl fmt::Display for Code {
96    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
97        f.write_str(self.as_str())
98    }
99}
100
101/// The limit a [`Code::ResourceLimitExceeded`] names.
102#[derive(Clone, Debug, PartialEq, Eq)]
103pub struct Limit {
104    /// The `Limits` field, as written there (`max_record_bytes`).
105    pub name: &'static str,
106    pub value: u64,
107}
108
109/// A failure: a code, and what is known about where and why.
110///
111/// `committed_output` says whether bytes had already been written when
112/// the failure was found: an incremental export cannot take them back, and
113/// the caller must be told the output may be partial.
114#[derive(Clone, Debug, PartialEq)]
115pub struct Fail {
116    pub code: Code,
117    pub message: String,
118    /// The input path the failure concerns, in jq syntax, when one applies.
119    pub path: Option<String>,
120    /// Boxed, with the file below, so that a `Fail` stays small enough
121    /// to return by value on every failure path without a warning.
122    pub limit: Option<Box<Limit>>,
123    /// 1-based source row and column, when the failure has a position.
124    pub row: Option<u64>,
125    pub column: Option<u64>,
126    /// The source file the position is in, when the program the failure
127    /// came from was compiled from several (a format's parts linked with
128    /// a program); absent, the position is the one source's.
129    pub file: Option<Box<str>>,
130    pub committed_output: bool,
131}
132
133impl Fail {
134    pub fn new(code: Code, message: impl Into<String>) -> Fail {
135        Fail {
136            code,
137            message: message.into(),
138            path: None,
139            limit: None,
140            row: None,
141            column: None,
142            file: None,
143            committed_output: false,
144        }
145    }
146
147    /// The failure with the source file its position is in.
148    pub fn in_file(mut self, file: impl Into<Box<str>>) -> Fail {
149        self.file = Some(file.into());
150        self
151    }
152
153    pub fn at_path(mut self, path: impl Into<String>) -> Fail {
154        self.path = Some(path.into());
155        self
156    }
157
158    pub fn at(mut self, row: u64, column: u64) -> Fail {
159        self.row = Some(row);
160        self.column = Some(column);
161        self
162    }
163
164    pub fn committed(mut self) -> Fail {
165        self.committed_output = true;
166        self
167    }
168
169    /// A limit failure, named after the `Limits` field that was passed.
170    pub fn limit(name: &'static str, value: u64, message: impl Into<String>) -> Fail {
171        Fail {
172            limit: Some(Box::new(Limit { name, value })),
173            ..Fail::new(Code::ResourceLimitExceeded, message)
174        }
175    }
176
177    pub fn protocol(message: impl Into<String>) -> Fail {
178        Fail::new(Code::ProtocolOrderError, message)
179    }
180
181    pub fn input(message: impl Into<String>) -> Fail {
182        Fail::new(Code::InputInvalid, message)
183    }
184
185    pub fn output(message: impl Into<String>) -> Fail {
186        Fail::new(Code::OutputFailed, message)
187    }
188
189    pub fn aborted() -> Fail {
190        Fail::new(Code::Aborted, "the run was cancelled")
191    }
192
193    /// The engine's own error, as an input failure carrying its code,
194    /// position and report. With the `tabnas` feature, which the
195    /// `language` feature turns on.
196    #[cfg(feature = "tabnas")]
197    pub fn from_tabnas(e: &tabnas::TabnasError) -> Fail {
198        let mut f = Fail::new(
199            Code::InputInvalid,
200            format!("{}: {}", e.code, e.detail.trim_end()),
201        );
202        if e.row > 0 {
203            f.row = Some(e.row as u64);
204            f.column = Some(e.col as u64);
205        }
206        f
207    }
208
209    /// The failure as a JSON object: `code`, `message`, and `path`,
210    /// `limit` (`{name, value}`), `row`, `col`, `output` ("partial" or
211    /// "none") when they apply. This is the shape hosts print.
212    pub fn to_json(&self) -> serde_json::Value {
213        let mut m = serde_json::Map::new();
214        m.insert("code".into(), self.code.as_str().into());
215        m.insert("message".into(), self.message.clone().into());
216        if let Some(p) = &self.path {
217            m.insert("path".into(), p.clone().into());
218        }
219        if let Some(l) = &self.limit {
220            m.insert(
221                "limit".into(),
222                serde_json::json!({ "name": l.name, "value": l.value }),
223            );
224        }
225        if let Some(r) = self.row {
226            m.insert("row".into(), r.into());
227        }
228        if let Some(c) = self.column {
229            m.insert("col".into(), c.into());
230        }
231        if let Some(file) = &self.file {
232            m.insert("file".into(), file.to_string().into());
233        }
234        m.insert(
235            "output".into(),
236            if self.committed_output {
237                "partial"
238            } else {
239                "none"
240            }
241            .into(),
242        );
243        serde_json::Value::Object(m)
244    }
245}
246
247impl fmt::Display for Fail {
248    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
249        write!(f, "{}: {}", self.code, self.message)?;
250        if let Some(p) = &self.path {
251            write!(f, " at {p}")?;
252        }
253        match (&self.file, self.row, self.column) {
254            (Some(file), Some(r), Some(c)) => write!(f, " ({file}:{r}:{c})")?,
255            (None, Some(r), Some(c)) => write!(f, " ({r}:{c})")?,
256            (Some(file), _, _) => write!(f, " (in {file})")?,
257            (None, _, _) => {}
258        }
259        if let Some(l) = &self.limit {
260            write!(f, " [{} = {}]", l.name, l.value)?;
261        }
262        Ok(())
263    }
264}
265
266impl std::error::Error for Fail {}
267
268#[cfg(test)]
269mod tests {
270    use super::*;
271
272    /// A position is written as it always was, and with its file when
273    /// the failure names one.
274    #[test]
275    fn a_position_names_its_file_when_it_has_one() {
276        let plain = Fail::new(Code::DslTypeError, "arity: one argument").at(2, 3);
277        assert_eq!(
278            plain.to_string(),
279            "DSL_TYPE_ERROR: arity: one argument (2:3)"
280        );
281        let filed = Fail::new(Code::DslTypeError, "arity: one argument")
282            .at(2, 3)
283            .in_file("render.alc");
284        assert_eq!(
285            filed.to_string(),
286            "DSL_TYPE_ERROR: arity: one argument (render.alc:2:3)"
287        );
288        let no_position = Fail::new(Code::DslParseError, "bad_def: a name").in_file("lift.alc");
289        assert_eq!(
290            no_position.to_string(),
291            "DSL_PARSE_ERROR: bad_def: a name (in lift.alc)"
292        );
293    }
294
295    #[test]
296    fn codes_are_stable_names() {
297        for code in Code::ALL {
298            assert_eq!(Code::parse(code.as_str()), Some(code));
299            assert!(code
300                .as_str()
301                .bytes()
302                .all(|b| b.is_ascii_uppercase() || b == b'_'));
303        }
304        assert_eq!(Code::parse("nope"), None);
305    }
306
307    #[test]
308    fn json_shape() {
309        let f = Fail::limit("max_record_bytes", 64, "a row of 65 bytes")
310            .at_path(".rows[3]")
311            .committed();
312        let j = f.to_json();
313        assert_eq!(j["code"], "RESOURCE_LIMIT_EXCEEDED");
314        assert_eq!(j["limit"]["name"], "max_record_bytes");
315        assert_eq!(j["limit"]["value"], 64);
316        assert_eq!(j["path"], ".rows[3]");
317        assert_eq!(j["output"], "partial");
318        assert_eq!(
319            f.to_string(),
320            "RESOURCE_LIMIT_EXCEEDED: a row of 65 bytes at .rows[3] [max_record_bytes = 64]"
321        );
322        // No file is written when there is none; the file beside the
323        // position when there is.
324        assert!(j.get("file").is_none(), "{j}");
325        let filed = Fail::new(Code::DslTypeError, "arity: one argument")
326            .at(2, 3)
327            .in_file("render.alc")
328            .to_json();
329        assert_eq!(filed["file"], "render.alc");
330        assert_eq!(
331            (filed["row"].clone(), filed["col"].clone()),
332            (2.into(), 3.into())
333        );
334    }
335}