Skip to main content

headless_lms_utils/error/
util_error.rs

1/*!
2Contains error and result types for all the util functions.
3*/
4
5use std::fmt::Display;
6use std::panic::Location;
7
8use backtrace::Backtrace;
9use headless_lms_base::error::backend_error::BackendError;
10use tracing_error::SpanTrace;
11/**
12Used as the result types for all utils.
13
14See also [UtilError] for documentation on how to return errors from models.
15*/
16pub type UtilResult<T> = Result<T, UtilError>;
17
18/// The type of [UtilError] that occured.
19#[derive(Debug)]
20pub enum UtilErrorType {
21    UrlParse,
22    Walkdir,
23    StripPrefix,
24    TokioIo,
25    SerdeJson,
26    CloudStorage,
27    Other,
28    Unavailable,
29    DeserializationError,
30    TmcHttpError,
31    TmcErrorResponse,
32    SisuClientError(SisuErrorVariant),
33}
34#[derive(Debug)]
35
36pub enum SisuErrorVariant {
37    GenericSisuError,
38    InvalidCourseCode,
39    SisuResourceNotFound,
40}
41
42/**
43Error type used by all models. Used as the error type in [UtilError], which is used by all the controllers in the application.
44
45All the information in the error is meant to be seen by the user. The type of error is determined by the [UtilErrorType] enum, which is stored inside this struct.
46
47## Examples
48
49### Usage without source error
50
51```no_run
52# use headless_lms_utils::prelude::*;
53# fn random_function() -> UtilResult<()> {
54#    let erroneous_condition = 1 == 1;
55if erroneous_condition {
56    return Err(UtilError::new(
57        UtilErrorType::Other,
58        "File not found".to_string(),
59        None,
60    ));
61}
62# Ok(())
63# }
64```
65
66### Usage with a source error
67
68Used when calling a function that returns an error that cannot be automatically converted to an UtilError. (See `impl From<X>` implementations on this struct.)
69
70```no_run
71# use headless_lms_utils::prelude::*;
72# fn some_function_returning_an_error() -> UtilResult<()> {
73#    return Err(UtilError::new(
74#        UtilErrorType::Other,
75#        "File not found".to_string(),
76#        None,
77#    ));
78# }
79#
80# fn random_function() -> UtilResult<()> {
81#    let erroneous_condition = 1 == 1;
82some_function_returning_an_error().map_err(|original_error| {
83    UtilError::new(
84        UtilErrorType::Other,
85        "Library x failed to do y".to_string(),
86        Some(original_error.into()),
87    )
88})?;
89# Ok(())
90# }
91```
92*/
93pub struct UtilError {
94    error_type: <UtilError as BackendError>::ErrorType,
95    message: String,
96    /// Original error that caused this error.
97    source: Option<anyhow::Error>,
98    /// A trace of tokio tracing spans, generated automatically when the error is generated.
99    span_trace: Box<SpanTrace>,
100    /// Stack trace, generated automatically when the error is created.
101    backtrace: Box<Backtrace>,
102    /// Source location where the error was raised.
103    location: Option<&'static Location<'static>>,
104}
105
106impl std::error::Error for UtilError {
107    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
108        self.source
109            .as_deref()
110            .map(|e| e as &(dyn std::error::Error + 'static))
111    }
112
113    fn cause(&self) -> Option<&dyn std::error::Error> {
114        self.source()
115    }
116}
117
118// Generate the clean developer `Debug`/`clean_string` and a cause resolver.
119headless_lms_base::impl_clean_debug!(UtilError, [UtilError]);
120
121impl Display for UtilError {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        write!(f, "UtilError {:?} {:?}", self.error_type, self.message)
124    }
125}
126
127impl BackendError for UtilError {
128    type ErrorType = UtilErrorType;
129
130    fn backtrace(&self) -> Option<&Backtrace> {
131        Some(&self.backtrace)
132    }
133
134    fn error_type(&self) -> &Self::ErrorType {
135        &self.error_type
136    }
137
138    fn message(&self) -> &str {
139        &self.message
140    }
141
142    fn span_trace(&self) -> &SpanTrace {
143        &self.span_trace
144    }
145
146    fn location(&self) -> Option<&'static Location<'static>> {
147        self.location
148    }
149
150    fn new_with_traces_and_location<M: Into<String>, S: Into<Option<anyhow::Error>>>(
151        error_type: Self::ErrorType,
152        message: M,
153        source_error: S,
154        backtrace: Backtrace,
155        span_trace: SpanTrace,
156        location: Option<&'static Location<'static>>,
157    ) -> Self {
158        Self {
159            error_type,
160            message: message.into(),
161            source: source_error.into(),
162            span_trace: Box::new(span_trace),
163            backtrace: Box::new(backtrace),
164            location,
165        }
166    }
167}
168
169impl From<url::ParseError> for UtilError {
170    fn from(source: url::ParseError) -> Self {
171        UtilError::new(
172            UtilErrorType::UrlParse,
173            source.to_string(),
174            Some(source.into()),
175        )
176    }
177}
178
179impl From<walkdir::Error> for UtilError {
180    fn from(source: walkdir::Error) -> Self {
181        UtilError::new(
182            UtilErrorType::Walkdir,
183            source.to_string(),
184            Some(source.into()),
185        )
186    }
187}
188
189impl From<std::path::StripPrefixError> for UtilError {
190    fn from(source: std::path::StripPrefixError) -> Self {
191        UtilError::new(
192            UtilErrorType::StripPrefix,
193            source.to_string(),
194            Some(source.into()),
195        )
196    }
197}
198
199impl From<tokio::io::Error> for UtilError {
200    fn from(source: tokio::io::Error) -> Self {
201        UtilError::new(
202            UtilErrorType::TokioIo,
203            source.to_string(),
204            Some(source.into()),
205        )
206    }
207}
208
209impl From<serde_json::Error> for UtilError {
210    fn from(source: serde_json::Error) -> Self {
211        UtilError::new(
212            UtilErrorType::SerdeJson,
213            source.to_string(),
214            Some(source.into()),
215        )
216    }
217}
218
219impl From<google_cloud_storage::Error> for UtilError {
220    fn from(source: google_cloud_storage::Error) -> Self {
221        UtilError::new(
222            UtilErrorType::CloudStorage,
223            source.to_string(),
224            Some(source.into()),
225        )
226    }
227}
228
229impl From<anyhow::Error> for UtilError {
230    fn from(err: anyhow::Error) -> UtilError {
231        Self::new(UtilErrorType::Other, err.to_string(), Some(err))
232    }
233}
234
235// Generate error creation macros for UtilError
236crate::define_err_macro!(
237    util_err,
238    UtilError,
239    UtilErrorType,
240    UtilErrorType,
241    "Create a UtilError with less boilerplate."
242);
243
244/// Helper function for `.map_err()` chains to wrap any error as UtilError.
245///
246/// This function creates a closure that converts any error into a `UtilError`
247/// with the specified error type and message, including the original error as the source.
248///
249/// # Examples
250///
251/// ```ignore
252/// // Instead of:
253/// .map_err(|e| UtilError::new(UtilErrorType::Other, e.to_string(), Some(e.into())))?
254///
255/// // You can write:
256/// .map_err(as_util_error(UtilErrorType::Other, "Failed to process".to_string()))?
257/// ```
258pub fn as_util_error<E>(
259    error_type: UtilErrorType,
260    message: impl Into<String>,
261) -> impl FnOnce(E) -> UtilError
262where
263    E: Into<anyhow::Error>,
264{
265    let msg = message.into();
266    move |e| UtilError::new(error_type, msg, Some(e.into()))
267}
268
269/// Helper function for `.ok_or_else()` to create UtilError on None.
270///
271/// This function creates a closure that generates a `UtilError` with the
272/// specified error type and message when called.
273///
274/// # Examples
275///
276/// ```ignore
277/// // Instead of:
278/// .ok_or_else(|| UtilError::new(UtilErrorType::Other, "Item not found".to_string(), None))
279///
280/// // You can write:
281/// .ok_or_else(missing_util_error(UtilErrorType::Other, "Item not found".to_string()))
282/// ```
283pub fn missing_util_error(
284    error_type: UtilErrorType,
285    message: impl Into<String>,
286) -> impl FnOnce() -> UtilError {
287    let msg = message.into();
288    move || UtilError::new(error_type, msg, None)
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294
295    #[test]
296    fn test_util_err_macro_without_source() {
297        let err = util_err!(Other, "Test error message".to_string());
298        assert_eq!(err.message(), "Test error message");
299        assert!(matches!(err.error_type(), UtilErrorType::Other));
300    }
301
302    #[test]
303    fn test_util_err_macro_with_source() {
304        let source_err = std::io::Error::new(std::io::ErrorKind::NotFound, "file not found");
305        let err = util_err!(TokioIo, "Wrapped error".to_string(), source_err);
306        assert_eq!(err.message(), "Wrapped error");
307    }
308
309    #[test]
310    fn test_as_util_error_helper() {
311        let result: Result<(), std::io::Error> = Err(std::io::Error::new(
312            std::io::ErrorKind::NotFound,
313            "test error",
314        ));
315        let util_result = result.map_err(as_util_error(
316            UtilErrorType::TokioIo,
317            "Failed to read file".to_string(),
318        ));
319
320        assert!(util_result.is_err());
321        let err = util_result.unwrap_err();
322        assert_eq!(err.message(), "Failed to read file");
323        assert!(matches!(err.error_type(), UtilErrorType::TokioIo));
324    }
325
326    #[test]
327    fn test_missing_util_error_helper() {
328        let option: Option<String> = None;
329        let result = option.ok_or_else(missing_util_error(
330            UtilErrorType::Other,
331            "Item not found".to_string(),
332        ));
333
334        assert!(result.is_err());
335        let err = result.unwrap_err();
336        assert_eq!(err.message(), "Item not found");
337        assert!(matches!(err.error_type(), UtilErrorType::Other));
338    }
339
340    #[test]
341    fn test_util_err_with_format() {
342        let path = "/tmp/test.txt";
343        let err = util_err!(Other, format!("Failed to process file: {}", path));
344        assert_eq!(err.message(), "Failed to process file: /tmp/test.txt");
345    }
346
347    /// The captured `Location` points at the `util_err!` call site, not `macros.rs` or
348    /// the `new` constructor.
349    #[test]
350    fn err_macro_captures_the_real_call_site() {
351        let expected_line = line!() + 1;
352        let err = util_err!(Other, "boom".to_string());
353        let location = err.location().expect("location should be captured");
354        assert_eq!(location.line(), expected_line, "file: {}", location.file());
355        assert!(
356            location.file().ends_with("util_error.rs"),
357            "expected the call site, got: {}",
358            location.file()
359        );
360        assert!(
361            !location.file().contains("macros.rs"),
362            "got: {}",
363            location.file()
364        );
365    }
366
367    /// `Debug` renders the clean format without leaking infra paths.
368    #[test]
369    fn debug_uses_clean_format() {
370        let err = util_err!(Other, "boom".to_string());
371        let debug = format!("{err:?}");
372        assert!(debug.contains("UtilError ยท Other: boom"), "got: {debug}");
373        assert!(!debug.contains("backend_error.rs"), "got: {debug}");
374    }
375}