Skip to main content

hapi_rs/
errors.rs

1use crate::session::Session;
2
3pub use crate::ffi::raw::{HapiResult, StatusType, StatusVerbosity};
4use thiserror::Error;
5
6pub type Result<T> = std::result::Result<T, HapiError>;
7
8/// Error type returned by all APIs
9#[derive(Error, Debug)]
10#[non_exhaustive]
11pub enum HapiError {
12    /// HAPI function call failed
13    Hapi {
14        result_code: HapiResultCode,
15        server_message: Option<String>,
16        contexts: Vec<String>,
17    },
18
19    /// This is used by [`ErrorContext::context`] / [`ErrorContext::with_context`]
20    Context {
21        contexts: Vec<String>,
22        #[source]
23        source: Box<HapiError>,
24    },
25
26    /// `CString` conversion error - string contains null byte
27    NullByte(#[from] std::ffi::NulError),
28
29    /// UTF-8 conversion error
30    Utf8(#[from] std::string::FromUtf8Error),
31
32    /// IO error
33    Io(#[from] std::io::Error),
34
35    /// Internal library error
36    Internal(String),
37}
38
39// Wrapper for HapiResult to provide Display for error messages
40#[derive(Debug, Clone, Copy)]
41pub struct HapiResultCode(pub HapiResult);
42
43impl std::fmt::Display for HapiResultCode {
44    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
45        use HapiResult::{
46            AlreadyInitialized, AssetDefAlreadyLoaded, AssetInvalid, CantGeneratePreset,
47            CantLoadGeo, CantLoadPreset, CantLoadfile, DisallowedHengineindieW3partyPlugin,
48            DisallowedLcAssetWithCLicense, DisallowedNcAssetWithCLicense,
49            DisallowedNcAssetWithLcLicense, DisallowedNcLicenseFound, Failure, InvalidArgument,
50            InvalidSession, InvalidSharedMemoryBuffer, NoLicenseFound, NodeInvalid, NotInitialized,
51            ParmSetFailed, SharedMemoryBufferOverflow, Success, UserInterrupted,
52        };
53        let desc = match self.0 {
54            Success => "SUCCESS",
55            Failure => "FAILURE",
56            AlreadyInitialized => "ALREADY_INITIALIZED",
57            NotInitialized => "NOT_INITIALIZED",
58            CantLoadfile => "CANT_LOADFILE",
59            ParmSetFailed => "PARM_SET_FAILED",
60            InvalidArgument => "INVALID_ARGUMENT",
61            CantLoadGeo => "CANT_LOAD_GEO",
62            CantGeneratePreset => "CANT_GENERATE_PRESET",
63            CantLoadPreset => "CANT_LOAD_PRESET",
64            AssetDefAlreadyLoaded => "ASSET_DEF_ALREADY_LOADED",
65            NoLicenseFound => "NO_LICENSE_FOUND",
66            DisallowedNcLicenseFound => "DISALLOWED_NC_LICENSE_FOUND",
67            DisallowedNcAssetWithCLicense => "DISALLOWED_NC_ASSET_WITH_C_LICENSE",
68            DisallowedNcAssetWithLcLicense => "DISALLOWED_NC_ASSET_WITH_LC_LICENSE",
69            DisallowedLcAssetWithCLicense => "DISALLOWED_LC_ASSET_WITH_C_LICENSE",
70            DisallowedHengineindieW3partyPlugin => "DISALLOWED_HENGINEINDIE_W_3PARTY_PLUGIN",
71            AssetInvalid => "ASSET_INVALID",
72            NodeInvalid => "NODE_INVALID",
73            UserInterrupted => "USER_INTERRUPTED",
74            InvalidSession => "INVALID_SESSION",
75            SharedMemoryBufferOverflow => "SHARED_MEMORY_BUFFER_OVERFLOW",
76            InvalidSharedMemoryBuffer => "INVALID_SHARED_MEMORY_BUFFER",
77        };
78        write!(f, "{desc}")
79    }
80}
81
82// This special case for TryFrom<T, Error = HapiError> where conversion can't fail.
83// for example when "impl TryInto<AttributeName>" receives AttributeName.
84impl From<std::convert::Infallible> for HapiError {
85    fn from(_: std::convert::Infallible) -> Self {
86        unreachable!()
87    }
88}
89
90impl From<HapiResult> for HapiError {
91    fn from(r: HapiResult) -> Self {
92        HapiError::Hapi {
93            result_code: HapiResultCode(r),
94            server_message: None,
95            contexts: Vec::new(),
96        }
97    }
98}
99
100impl From<&str> for HapiError {
101    fn from(value: &str) -> Self {
102        HapiError::Internal(value.to_string())
103    }
104}
105
106pub(crate) trait ErrorContext<T> {
107    fn context<C>(self, context: C) -> Result<T>
108    where
109        C: Into<String>;
110
111    #[allow(unused)]
112    fn with_context<C, F>(self, func: F) -> Result<T>
113    where
114        C: Into<String>,
115        F: FnOnce() -> C;
116}
117
118impl<T> ErrorContext<T> for Result<T> {
119    fn context<C>(self, context: C) -> Result<T>
120    where
121        C: Into<String>,
122    {
123        match self {
124            Ok(ok) => Ok(ok),
125            Err(mut error) => {
126                let context = context.into();
127                match &mut error {
128                    HapiError::Hapi { contexts, .. } | HapiError::Context { contexts, .. } => {
129                        contexts.push(context);
130                        Err(error)
131                    }
132                    _ => Err(HapiError::Context {
133                        contexts: vec![context],
134                        source: Box::new(error),
135                    }),
136                }
137            }
138        }
139    }
140
141    fn with_context<C, F>(self, func: F) -> Result<T>
142    where
143        C: Into<String>,
144        F: FnOnce() -> C,
145    {
146        match self {
147            Ok(ok) => Ok(ok),
148            Err(mut error) => {
149                let context = func().into();
150                match &mut error {
151                    HapiError::Hapi { contexts, .. } | HapiError::Context { contexts, .. } => {
152                        contexts.push(context);
153                        Err(error)
154                    }
155                    _ => Err(HapiError::Context {
156                        contexts: vec![context],
157                        source: Box::new(error),
158                    }),
159                }
160            }
161        }
162    }
163}
164
165// Custom Display to show contexts properly for HAPI errors
166impl std::fmt::Display for HapiError {
167    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
168        fn fmt_base(err: &HapiError, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
169            match err {
170                HapiError::Hapi {
171                    result_code,
172                    server_message,
173                    ..
174                } => {
175                    write!(f, "[{result_code}]")?;
176                    if let Some(msg) = server_message {
177                        write!(f, ": [Engine Message]: {msg}")?;
178                    }
179                    Ok(())
180                }
181                HapiError::Context { source, .. } => fmt_base(source, f),
182                HapiError::NullByte(e) => {
183                    let vec = e.clone().into_vec();
184                    let text = String::from_utf8_lossy(&vec);
185                    write!(f, "String contains null byte in \"{text}\"")
186                }
187                HapiError::Utf8(e) => {
188                    let text = String::from_utf8_lossy(e.as_bytes());
189                    write!(f, "Invalid UTF-8 in string \"{text}\"")
190                }
191                HapiError::Io(e) => write!(f, "IO error: {e}"),
192                HapiError::Internal(e) => write!(f, "Internal error: {e}"),
193            }
194        }
195
196        fn collect_contexts<'a>(err: &'a HapiError, out: &mut Vec<&'a str>) {
197            match err {
198                HapiError::Hapi { contexts, .. } => {
199                    out.extend(contexts.iter().map(std::string::String::as_str));
200                }
201                HapiError::Context { contexts, source } => {
202                    collect_contexts(source, out);
203                    out.extend(contexts.iter().map(std::string::String::as_str));
204                }
205                _ => {}
206            }
207        }
208
209        fmt_base(self, f)?;
210
211        let mut contexts = Vec::new();
212        collect_contexts(self, &mut contexts);
213        if !contexts.is_empty() {
214            writeln!(f)?;
215            for (n, msg) in contexts.iter().enumerate() {
216                writeln!(f, "\t{n}. {msg}")?;
217            }
218        }
219        Ok(())
220    }
221}
222
223impl HapiResult {
224    /// Check `HAPI_Result` status and convert to `HapiError` if the status is not success.
225    pub(crate) fn check_err<F, M>(self, session: &Session, context: F) -> Result<()>
226    where
227        M: Into<String>,
228        F: FnOnce() -> M,
229    {
230        match self {
231            HapiResult::Success => Ok(()),
232            _err => {
233                let server_message = if session.is_valid() {
234                    session
235                        .get_status_string(StatusType::CallResult, StatusVerbosity::All)
236                        .ok()
237                } else {
238                    Some("Session is corrupted: error message not available".to_string())
239                };
240
241                Err(HapiError::Hapi {
242                    result_code: HapiResultCode(self),
243                    server_message: server_message
244                        .or_else(|| Some("Could not retrieve error message".to_string())),
245                    contexts: vec![context().into()],
246                })
247            }
248        }
249    }
250
251    /// Convert `HAPI_Result` to `HapiError` if the status is not success and add a message to the error.
252    pub(crate) fn add_context<I: Into<String>>(self, message: I) -> Result<()> {
253        match self {
254            HapiResult::Success => Ok(()),
255            _err => Err(HapiError::Hapi {
256                result_code: HapiResultCode(self),
257                server_message: None,
258                contexts: vec![message.into()],
259            }),
260        }
261    }
262
263    pub(crate) fn with_context<F, M>(self, func: F) -> Result<()>
264    where
265        F: FnOnce() -> M,
266        M: Into<String>,
267    {
268        self.add_context(func())
269    }
270
271    pub(crate) fn with_server_message<F, M>(self, func: F) -> Result<()>
272    where
273        F: FnOnce() -> M,
274        M: Into<String>,
275    {
276        match self {
277            HapiResult::Success => Ok(()),
278            _err => Err(HapiError::Hapi {
279                result_code: HapiResultCode(self),
280                server_message: Some(func().into()),
281                contexts: vec![],
282            }),
283        }
284    }
285}
286
287#[cfg(test)]
288mod tests {
289    use super::*;
290    use std::error::Error as _;
291
292    #[test]
293    fn context_chain_is_rendered_for_internal_errors() {
294        let err = (Err::<(), HapiError>(HapiError::Internal("root".to_string())))
295            .context("first context")
296            .context("second context")
297            .unwrap_err();
298
299        let s = err.to_string();
300        assert!(s.starts_with("Internal error: root"));
301        assert!(s.contains("\n\t0. first context\n\t1. second context\n"));
302
303        // Verify the source chain is preserved via #[source]
304        let source = err.source().expect("source");
305        assert_eq!(source.to_string(), "Internal error: root");
306    }
307
308    #[test]
309    fn hapi_errors_server_message_and_contexts() {
310        let err = (Err::<(), HapiError>(HapiError::Hapi {
311            result_code: HapiResultCode(HapiResult::Failure),
312            server_message: Some("could not cook".to_string()),
313            contexts: vec!["low-level".to_string()],
314        }))
315        .context("high-level")
316        .unwrap_err();
317
318        let s = err.to_string();
319        assert_eq!(
320            s,
321            "[FAILURE]: [Engine Message]: could not cook\n\t0. low-level\n\t1. high-level\n"
322        );
323    }
324
325    #[test]
326    fn context_added_outside_hapi_error_is_rendered_after_inner_contexts() {
327        // Create a HAPI error with one context, then add an outer wrapper context.
328        let base = HapiError::Hapi {
329            result_code: HapiResultCode(HapiResult::InvalidArgument),
330            server_message: None,
331            contexts: vec!["inner".to_string()],
332        };
333        let wrapped = (Err::<(), HapiError>(base)).context("outer").unwrap_err();
334
335        let s = wrapped.to_string();
336        // Base header comes from the underlying Hapi error
337        assert!(s.starts_with("[INVALID_ARGUMENT]"));
338        // Context order: inner first, then outer
339        assert!(s.contains("\n\t0. inner\n\t1. outer\n"));
340    }
341
342    #[test]
343    fn result_with_context_adds_context_on_error() {
344        let err = Err::<(), HapiError>(HapiError::Internal("root".to_string()))
345            .with_context(|| "deferred context")
346            .unwrap_err();
347
348        assert_eq!(
349            err.to_string(),
350            "Internal error: root\n\t0. deferred context\n"
351        );
352        assert_eq!(
353            err.source().expect("source").to_string(),
354            "Internal error: root"
355        );
356    }
357
358    #[test]
359    fn hapi_result_add_context_returns_hapi_error_on_failure() {
360        let err = HapiResult::InvalidArgument
361            .add_context("invalid parm")
362            .unwrap_err();
363
364        match err {
365            HapiError::Hapi {
366                result_code,
367                server_message,
368                contexts,
369            } => {
370                assert_eq!(result_code.to_string(), "INVALID_ARGUMENT");
371                assert_eq!(server_message, None);
372                assert_eq!(contexts, vec!["invalid parm"]);
373            }
374            other => panic!("expected Hapi error, got {other:?}"),
375        }
376    }
377
378    #[test]
379    fn hapi_result_with_context_returns_hapi_error_on_failure() {
380        let err = HapiResult::Failure
381            .with_context(|| "deferred hapi context")
382            .unwrap_err();
383
384        assert_eq!(err.to_string(), "[FAILURE]\n\t0. deferred hapi context\n");
385    }
386
387    #[test]
388    fn hapi_result_with_server_message_returns_hapi_error_on_failure() {
389        let err = HapiResult::CantLoadfile
390            .with_server_message(|| "could not load asset")
391            .unwrap_err();
392
393        match err {
394            HapiError::Hapi {
395                result_code,
396                server_message,
397                contexts,
398            } => {
399                assert_eq!(result_code.to_string(), "CANT_LOADFILE");
400                assert_eq!(server_message, Some("could not load asset".to_string()));
401                assert!(contexts.is_empty());
402            }
403            other => panic!("expected Hapi error, got {other:?}"),
404        }
405    }
406
407    #[test]
408    fn null_byte_errors_are_rendered() {
409        let err = std::ffi::CString::new(b"ab\0cd".to_vec()).unwrap_err();
410        let err = HapiError::from(err);
411
412        assert_eq!(err.to_string(), "String contains null byte in \"ab\0cd\"");
413    }
414
415    #[test]
416    fn utf8_errors_are_rendered() {
417        let err = String::from_utf8(vec![b'a', 0xff, b'b']).unwrap_err();
418        let err = HapiError::from(err);
419
420        assert_eq!(err.to_string(), "Invalid UTF-8 in string \"a\u{FFFD}b\"");
421    }
422
423    #[test]
424    fn io_errors_are_rendered() {
425        let err = HapiError::from(std::io::Error::new(
426            std::io::ErrorKind::NotFound,
427            "missing file",
428        ));
429
430        assert_eq!(err.to_string(), "IO error: missing file");
431    }
432
433    #[test]
434    fn str_converts_to_internal_error() {
435        let err = HapiError::from("bad state");
436
437        assert_eq!(err.to_string(), "Internal error: bad state");
438    }
439
440    #[test]
441    fn hapi_result_converts_to_hapi_error() {
442        let err = HapiError::from(HapiResult::ParmSetFailed);
443
444        match err {
445            HapiError::Hapi {
446                result_code,
447                server_message,
448                contexts,
449            } => {
450                assert_eq!(result_code.to_string(), "PARM_SET_FAILED");
451                assert_eq!(server_message, None);
452                assert!(contexts.is_empty());
453            }
454            other => panic!("expected Hapi error, got {other:?}"),
455        }
456    }
457}