Skip to main content

tabnas_transduce/
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.
195    pub fn from_tabnas(e: &tabnas::TabnasError) -> Fail {
196        let mut f = Fail::new(
197            Code::InputInvalid,
198            format!("{}: {}", e.code, e.detail.trim_end()),
199        );
200        if e.row > 0 {
201            f.row = Some(e.row as u64);
202            f.column = Some(e.col as u64);
203        }
204        f
205    }
206
207    /// The failure as a JSON object: `code`, `message`, and `path`,
208    /// `limit` (`{name, value}`), `row`, `col`, `output` ("partial" or
209    /// "none") when they apply. This is the shape hosts print.
210    pub fn to_json(&self) -> serde_json::Value {
211        let mut m = serde_json::Map::new();
212        m.insert("code".into(), self.code.as_str().into());
213        m.insert("message".into(), self.message.clone().into());
214        if let Some(p) = &self.path {
215            m.insert("path".into(), p.clone().into());
216        }
217        if let Some(l) = &self.limit {
218            m.insert(
219                "limit".into(),
220                serde_json::json!({ "name": l.name, "value": l.value }),
221            );
222        }
223        if let Some(r) = self.row {
224            m.insert("row".into(), r.into());
225        }
226        if let Some(c) = self.column {
227            m.insert("col".into(), c.into());
228        }
229        if let Some(file) = &self.file {
230            m.insert("file".into(), file.to_string().into());
231        }
232        m.insert(
233            "output".into(),
234            if self.committed_output {
235                "partial"
236            } else {
237                "none"
238            }
239            .into(),
240        );
241        serde_json::Value::Object(m)
242    }
243}
244
245impl fmt::Display for Fail {
246    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
247        write!(f, "{}: {}", self.code, self.message)?;
248        if let Some(p) = &self.path {
249            write!(f, " at {p}")?;
250        }
251        match (&self.file, self.row, self.column) {
252            (Some(file), Some(r), Some(c)) => write!(f, " ({file}:{r}:{c})")?,
253            (None, Some(r), Some(c)) => write!(f, " ({r}:{c})")?,
254            (Some(file), _, _) => write!(f, " (in {file})")?,
255            (None, _, _) => {}
256        }
257        if let Some(l) = &self.limit {
258            write!(f, " [{} = {}]", l.name, l.value)?;
259        }
260        Ok(())
261    }
262}
263
264impl std::error::Error for Fail {}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    /// A position is written as it always was, and with its file when
271    /// the failure names one.
272    #[test]
273    fn a_position_names_its_file_when_it_has_one() {
274        let plain = Fail::new(Code::DslTypeError, "arity: one argument").at(2, 3);
275        assert_eq!(
276            plain.to_string(),
277            "DSL_TYPE_ERROR: arity: one argument (2:3)"
278        );
279        let filed = Fail::new(Code::DslTypeError, "arity: one argument")
280            .at(2, 3)
281            .in_file("render.alc");
282        assert_eq!(
283            filed.to_string(),
284            "DSL_TYPE_ERROR: arity: one argument (render.alc:2:3)"
285        );
286        let no_position = Fail::new(Code::DslParseError, "bad_def: a name").in_file("lift.alc");
287        assert_eq!(
288            no_position.to_string(),
289            "DSL_PARSE_ERROR: bad_def: a name (in lift.alc)"
290        );
291    }
292
293    #[test]
294    fn codes_are_stable_names() {
295        for code in Code::ALL {
296            assert_eq!(Code::parse(code.as_str()), Some(code));
297            assert!(code
298                .as_str()
299                .bytes()
300                .all(|b| b.is_ascii_uppercase() || b == b'_'));
301        }
302        assert_eq!(Code::parse("nope"), None);
303    }
304
305    #[test]
306    fn json_shape() {
307        let f = Fail::limit("max_record_bytes", 64, "a row of 65 bytes")
308            .at_path(".rows[3]")
309            .committed();
310        let j = f.to_json();
311        assert_eq!(j["code"], "RESOURCE_LIMIT_EXCEEDED");
312        assert_eq!(j["limit"]["name"], "max_record_bytes");
313        assert_eq!(j["limit"]["value"], 64);
314        assert_eq!(j["path"], ".rows[3]");
315        assert_eq!(j["output"], "partial");
316        assert_eq!(
317            f.to_string(),
318            "RESOURCE_LIMIT_EXCEEDED: a row of 65 bytes at .rows[3] [max_record_bytes = 64]"
319        );
320        // No file is written when there is none; the file beside the
321        // position when there is.
322        assert!(j.get("file").is_none(), "{j}");
323        let filed = Fail::new(Code::DslTypeError, "arity: one argument")
324            .at(2, 3)
325            .in_file("render.alc")
326            .to_json();
327        assert_eq!(filed["file"], "render.alc");
328        assert_eq!(
329            (filed["row"].clone(), filed["col"].clone()),
330            (2.into(), 3.into())
331        );
332    }
333}