Skip to main content

miden_protobuf/
error.rs

1use alloc::boxed::Box;
2use alloc::format;
3use alloc::string::String;
4use alloc::vec::Vec;
5use core::any::type_name;
6use core::error::Error;
7use core::fmt;
8
9/// Error produced while converting a Protobuf message into a domain object.
10#[derive(Debug)]
11pub struct ConversionError {
12    path: Vec<String>,
13    source: Box<dyn core::error::Error + Send + Sync>,
14}
15
16impl ConversionError {
17    pub fn new(source: impl Error + Send + Sync + 'static) -> Self {
18        let source: Box<dyn Error + Send + Sync> = Box::new(source);
19        match source.downcast::<Self>() {
20            Ok(error) => *error,
21            Err(source) => Self { path: Vec::new(), source },
22        }
23    }
24
25    #[must_use]
26    pub fn context(mut self, field: impl Into<String>) -> Self {
27        self.path.push(field.into());
28        self
29    }
30
31    pub fn missing_field<T: prost::Message>(field_name: &'static str) -> Self {
32        Self::message(format!("field {}::{field_name} is missing", type_name::<T>()))
33    }
34
35    /// Reports a oneof extraction mismatch using exact wire variant names.
36    ///
37    /// The actual variant is added to the field path. No payload is decoded or verified when
38    /// the requested variant does not match.
39    pub fn wrong_variant(expected: &'static str, actual: &'static str) -> Self {
40        Self::message(format!("expected oneof variant `{expected}`, got `{actual}`"))
41            .context(actual)
42    }
43
44    pub fn deserialization(
45        entity: &'static str,
46        source: impl Error + Send + Sync + 'static,
47    ) -> Self {
48        let message = format!("failed to deserialize {entity}: {source}");
49        Self::with_source(message, source)
50    }
51
52    pub fn message(message: impl Into<String>) -> Self {
53        Self {
54            path: Vec::new(),
55            source: Box::new(StringError(message.into())),
56        }
57    }
58
59    pub fn with_source(
60        message: impl Into<String>,
61        source: impl Error + Send + Sync + 'static,
62    ) -> Self {
63        Self::new(ContextualError {
64            message: message.into(),
65            source: Box::new(source),
66        })
67    }
68
69    /// Converts this input conversion failure into a gRPC `InvalidArgument` status.
70    ///
71    /// The message includes the field path and every source-chain level, even when a source's
72    /// `Display` omits its own cause. Deeper causes are separated by `\ncaused by: `; a cause may
73    /// appear more than once if an outer error already includes it in its display text.
74    /// The original error is also retained as the status's local source.
75    ///
76    /// Available with the optional `tonic` feature, which enables `std` without transport.
77    #[cfg(feature = "tonic")]
78    pub fn into_status(self) -> tonic::Status {
79        use alloc::string::ToString;
80        use alloc::sync::Arc;
81        use core::fmt::Write;
82
83        let mut message = self.to_string();
84        // Display already includes the immediate source; walk the causes it may omit.
85        let mut cause = self.source.source();
86        while let Some(error) = cause {
87            write!(message, "\ncaused by: {error}").expect("writing to a String cannot fail");
88            cause = error.source();
89        }
90        let mut status = tonic::Status::invalid_argument(message);
91        status.set_source(Arc::new(self));
92        status
93    }
94}
95
96impl fmt::Display for ConversionError {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        for (index, segment) in self.path.iter().rev().enumerate() {
99            if index > 0 {
100                f.write_str(".")?;
101            }
102            f.write_str(segment)?;
103        }
104        if !self.path.is_empty() {
105            f.write_str(": ")?;
106        }
107        self.source.fmt(f)
108    }
109}
110
111impl Error for ConversionError {
112    fn source(&self) -> Option<&(dyn Error + 'static)> {
113        Some(&*self.source)
114    }
115}
116
117#[derive(Debug)]
118struct StringError(String);
119
120impl fmt::Display for StringError {
121    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
122        f.write_str(&self.0)
123    }
124}
125
126impl Error for StringError {}
127
128#[derive(Debug)]
129struct ContextualError {
130    message: String,
131    source: Box<dyn Error + Send + Sync>,
132}
133
134impl fmt::Display for ContextualError {
135    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
136        f.write_str(&self.message)
137    }
138}
139
140impl Error for ContextualError {
141    fn source(&self) -> Option<&(dyn Error + 'static)> {
142        Some(&*self.source)
143    }
144}
145
146pub trait ConversionResultExt<T> {
147    /// Adds a field path segment if the result is an error.
148    fn context(self, field: impl Into<String>) -> Result<T, ConversionError>;
149
150    /// Computes and adds a field path segment only if the result is an error.
151    fn with_context<F, S>(self, field: F) -> Result<T, ConversionError>
152    where
153        F: FnOnce() -> S,
154        S: Into<String>;
155}
156
157impl<T, E> ConversionResultExt<T> for Result<T, E>
158where
159    E: Error + Send + Sync + 'static,
160{
161    fn context(self, field: impl Into<String>) -> Result<T, ConversionError> {
162        self.with_context(|| field)
163    }
164
165    fn with_context<F, S>(self, field: F) -> Result<T, ConversionError>
166    where
167        F: FnOnce() -> S,
168        S: Into<String>,
169    {
170        self.map_err(|error| ConversionError::new(error).context(field()))
171    }
172}
173
174#[cfg(test)]
175mod tests {
176    use alloc::string::ToString;
177    use core::error::Error;
178    use core::num::TryFromIntError;
179
180    use super::{ConversionError, ConversionResultExt};
181
182    #[test]
183    fn with_context_does_not_evaluate_the_closure_on_success() {
184        let value = Ok::<_, TryFromIntError>(7)
185            .with_context(|| -> &'static str { panic!("context must not be evaluated") })
186            .unwrap();
187
188        assert_eq!(value, 7);
189    }
190
191    #[test]
192    fn with_context_evaluates_once_and_preserves_the_path_and_source() {
193        let source = u8::try_from(256_u16).unwrap_err();
194        let inner = ConversionError::new(source).context("inner");
195        let field = "outer".to_string();
196        let mut calls = 0;
197
198        let error = Err::<(), _>(inner)
199            .with_context(|| {
200                calls += 1;
201                field
202            })
203            .unwrap_err();
204
205        assert_eq!(calls, 1);
206        assert_eq!(error.to_string(), alloc::format!("outer.inner: {source}"));
207        assert!(error.source().unwrap().is::<TryFromIntError>());
208    }
209
210    #[test]
211    fn deserialization_errors_preserve_the_source() {
212        let source = u8::try_from(256_u16).unwrap_err();
213        let error = ConversionError::deserialization("Payload", source);
214        assert_eq!(error.to_string(), alloc::format!("failed to deserialize Payload: {source}"));
215        assert!(error.source().unwrap().source().unwrap().is::<TryFromIntError>());
216    }
217
218    #[test]
219    fn wrapping_a_conversion_error_preserves_its_path() {
220        let inner = ConversionError::message("invalid value").context("inner");
221        let outer = ConversionError::new(inner).context("outer");
222
223        assert_eq!(outer.to_string(), "outer.inner: invalid value");
224    }
225}