Skip to main content

ruso_runtime/runtime/
error.rs

1use thiserror::Error;
2
3use crate::contract::QualifiedMatch;
4use crate::runtime::binary::BytecodeError;
5
6/// An error that aborts execution. Most variants carry a message; the
7/// `#[from]` variants wrap lower-level errors (bytecode decode, HTTP, I/O,
8/// regex). A failed `match` does not produce one of these — only `assert`,
9/// `fail`, and genuine transport/decoding failures do.
10#[derive(Debug, Error)]
11pub enum RuntimeError {
12    #[error("bytecode: {0}")]
13    Bytecode(#[from] BytecodeError),
14    #[error("unknown request or probe: {0}")]
15    UnknownTarget(String),
16
17    #[error("request {name} is not HTTP (dns/tcp probe)")]
18    WrongProbeKind { name: String },
19
20    #[error("match failed: {0}")]
21    MatchFailed(String),
22
23    #[error("assertion failed: {0}")]
24    AssertFailed(String),
25
26    #[error("extract failed for variable {name}: {reason}")]
27    ExtractFailed { name: String, reason: String },
28
29    #[error("flow control: {0}")]
30    Flow(String),
31
32    #[error("invalid duration: {0}")]
33    InvalidDuration(String),
34
35    #[error("http error: {0}")]
36    Http(#[from] reqwest::Error),
37
38    #[error("io error: {0}")]
39    Io(#[from] std::io::Error),
40
41    #[error("regex error: {0}")]
42    Regex(#[from] regex::Error),
43
44    #[error("{0}")]
45    Other(String),
46}
47
48impl RuntimeError {
49    pub fn match_failed(matcher: &QualifiedMatch, detail: impl Into<String>) -> Self {
50        Self::MatchFailed(format!("{matcher:?}: {}", detail.into()))
51    }
52
53    pub fn assert_failed(matcher: &QualifiedMatch, detail: impl Into<String>) -> Self {
54        Self::AssertFailed(format!("{matcher:?}: {}", detail.into()))
55    }
56
57    /// The complete error message, including every underlying cause.
58    ///
59    /// `thiserror`'s `Display` renders only this error's own message. For the
60    /// wrapped HTTP and I/O variants the real reason — a rejected TLS
61    /// certificate, a connection reset, a response body that failed to decode —
62    /// lives in the [`source`](std::error::Error::source) chain and would
63    /// otherwise be dropped, leaving an opaque `"http error: …"`. This walks
64    /// that chain and joins it into one line so logs and scan reports carry the
65    /// actual cause.
66    pub fn full_message(&self) -> String {
67        join_source_chain(self)
68    }
69}
70
71/// Render an error and its [`source`](std::error::Error::source) chain as a
72/// single `"top: cause: root-cause"` line.
73///
74/// Only causes that add new text are appended: some errors (notably `reqwest`)
75/// repeat their own `Display` as their first source, which would otherwise
76/// duplicate a segment.
77fn join_source_chain(error: &dyn std::error::Error) -> String {
78    let mut message = error.to_string();
79    let mut next = error.source();
80    while let Some(cause) = next {
81        let cause_text = cause.to_string();
82        if !message.contains(&cause_text) {
83            message.push_str(": ");
84            message.push_str(&cause_text);
85        }
86        next = cause.source();
87    }
88    message
89}
90
91#[cfg(test)]
92mod tests {
93    use super::join_source_chain;
94    use std::error::Error;
95    use std::fmt;
96
97    /// A minimal error whose source chain we control, for exercising
98    /// [`join_source_chain`] without depending on reqwest/io internals.
99    #[derive(Debug)]
100    struct Layer {
101        message: &'static str,
102        source: Option<Box<Layer>>,
103    }
104
105    impl Layer {
106        fn leaf(message: &'static str) -> Box<Self> {
107            Box::new(Self {
108                message,
109                source: None,
110            })
111        }
112        fn wrap(message: &'static str, source: Box<Layer>) -> Self {
113            Self {
114                message,
115                source: Some(source),
116            }
117        }
118    }
119
120    impl fmt::Display for Layer {
121        fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122            f.write_str(self.message)
123        }
124    }
125
126    impl Error for Layer {
127        fn source(&self) -> Option<&(dyn Error + 'static)> {
128            self.source.as_deref().map(|s| s as &(dyn Error + 'static))
129        }
130    }
131
132    #[test]
133    fn single_error_has_no_suffix() {
134        let err = Layer {
135            message: "io error",
136            source: None,
137        };
138        assert_eq!(join_source_chain(&err), "io error");
139    }
140
141    #[test]
142    fn joins_each_distinct_cause() {
143        let err = Layer::wrap(
144            "error sending request",
145            Box::new(Layer::wrap(
146                "client error (Connect)",
147                Layer::leaf("invalid peer certificate"),
148            )),
149        );
150        assert_eq!(
151            join_source_chain(&err),
152            "error sending request: client error (Connect): invalid peer certificate"
153        );
154    }
155
156    #[test]
157    fn skips_a_cause_already_present() {
158        // reqwest repeats its top-level Display as its own first source.
159        let err = Layer::wrap(
160            "error sending request",
161            Box::new(Layer::wrap(
162                "error sending request",
163                Layer::leaf("UnknownIssuer"),
164            )),
165        );
166        assert_eq!(
167            join_source_chain(&err),
168            "error sending request: UnknownIssuer"
169        );
170    }
171}