Skip to main content

rs_teststand/
error.rs

1//! The error type surfaced by every fallible wrapper call.
2
3use std::fmt;
4
5use rs_teststand_sys::ComError;
6
7use crate::error_codes::code_name;
8
9/// An error from a TestStand™ COM operation.
10///
11/// Every fallible member of the public API returns `Result<_, Error>` (i.e.
12/// `rs_teststand::Error`). Library code never panics; failures always arrive
13/// here.
14///
15/// Engine failures are reported by name where the engine defines one, so a
16/// caller is not left decoding a bare number:
17///
18/// ```
19/// use rs_teststand::Error;
20///
21/// // `dispid` names the member that failed; 0 means the failure came from
22/// // outside a member call.
23/// let out_of_memory = Error::Engine { code: -17000, dispid: 0 };
24/// assert_eq!(out_of_memory.code_name(), Some("TS_Err_OutOfMemory"));
25/// assert!(out_of_memory.to_string().contains("TS_Err_OutOfMemory"));
26/// ```
27#[derive(Debug, thiserror::Error)]
28#[non_exhaustive]
29pub enum Error {
30    /// The station holds no license.
31    ///
32    /// Raised by [`Engine::require_license`](crate::Engine::require_license),
33    /// not by the engine, so that "unlicensed" is reported once and up front
34    /// rather than as whatever operation later happened to need a license.
35    #[error(
36        "the station holds no TestStand license; activate one, or check Engine::license_type first"
37    )]
38    NoLicense,
39
40    /// The engine named a license value this build does not know.
41    ///
42    /// A newer engine may define one this crate predates. The number is carried
43    /// through rather than mapped onto a near neighbour.
44    #[error("engine reported license value {bits}, which this build does not name")]
45    UnknownLicenseType {
46        /// The value the engine reported.
47        bits: i32,
48    },
49
50    /// A property tree was walked deeper than the caller allowed.
51    ///
52    /// Usually a cycle rather than genuine depth. A live `SequenceContext`
53    /// lists `ThisContext` among its own sub-properties, so it contains itself,
54    /// and a walk with no limit recurses until the stack is gone. `path` is
55    /// where the limit was reached, which is normally enough to see the loop.
56    #[error(
57        "property tree deeper than {limit} levels at {path:?}; a sequence context contains itself, so walk a named subtree instead"
58    )]
59    RecursionLimit {
60        /// The lookup path at which the limit was hit.
61        path: String,
62        /// The limit that was exceeded.
63        limit: usize,
64    },
65
66    /// The engine raised a failure it has a name for.
67    ///
68    /// `code` is the engine's own error code, taken from the raised exception
69    /// rather than the generic COM `DISP_E_EXCEPTION` wrapper.
70    #[error("{}", EngineDisplay { code: *code, dispid: *dispid })]
71    Engine {
72        /// The engine error code.
73        code: i32,
74        /// The dispatch id of the member that failed, or `0` when the failure
75        /// did not come from a member call.
76        ///
77        /// A bare engine code does not say *which* call refused, which is the
78        /// first thing worth knowing; this narrows it to one member.
79        dispid: i32,
80    },
81    /// A COM call failed with a code the engine does not name, a standard
82    /// Windows `HRESULT`, or a code newer than the generated table.
83    #[error("{}", ComDisplay { hresult: *hresult, dispid: *dispid })]
84    Com {
85        /// The 32-bit `HRESULT`.
86        hresult: i32,
87        /// The dispatch id of the member that failed, or `0`.
88        dispid: i32,
89    },
90    /// A returned value was not the type the wrapper expected, an internal
91    /// mismatch between the wrapper and the live object model.
92    #[error("unexpected value type: expected {expected}, got {actual}")]
93    UnexpectedType {
94        /// The type the wrapper asked for.
95        expected: &'static str,
96        /// The type actually returned.
97        actual: &'static str,
98    },
99}
100
101impl Error {
102    /// The engine's name for this error, when it has one.
103    ///
104    /// Returns `None` for plain Windows `HRESULT`s and for type mismatches.
105    #[must_use]
106    pub const fn code_name(&self) -> Option<&'static str> {
107        match self {
108            Self::Engine { code, .. } => code_name(*code),
109            Self::Com { hresult, .. } => code_name(*hresult),
110            // Raised by this crate, not by the engine, so there is no engine
111            // name to report.
112            Self::UnexpectedType { .. }
113            | Self::RecursionLimit { .. }
114            | Self::NoLicense
115            | Self::UnknownLicenseType { .. } => None,
116        }
117    }
118
119    /// The underlying numeric code, when the failure came from COM.
120    #[must_use]
121    pub const fn code(&self) -> Option<i32> {
122        match self {
123            Self::Engine { code, .. } => Some(*code),
124            Self::Com { hresult, .. } => Some(*hresult),
125            Self::UnexpectedType { .. }
126            | Self::RecursionLimit { .. }
127            | Self::NoLicense
128            | Self::UnknownLicenseType { .. } => None,
129        }
130    }
131}
132
133/// Renders a named engine code, falling back to the number if the table has no
134/// name for it (which `From<ComError>` normally prevents).
135struct EngineDisplay {
136    code: i32,
137    dispid: i32,
138}
139
140impl fmt::Display for EngineDisplay {
141    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
142        match code_name(self.code) {
143            Some(name) => write!(formatter, "engine error {name} ({})", self.code)?,
144            None => write!(formatter, "engine error {}", self.code)?,
145        }
146        if self.dispid != 0 {
147            write!(formatter, " from DISPID {:#x}", self.dispid)?;
148        }
149        Ok(())
150    }
151}
152
153/// Renders an unnamed `HRESULT`, naming the member when one is known.
154struct ComDisplay {
155    hresult: i32,
156    dispid: i32,
157}
158
159impl fmt::Display for ComDisplay {
160    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
161        write!(
162            formatter,
163            "COM call failed (HRESULT {:#010x})",
164            self.hresult
165        )?;
166        if self.dispid != 0 {
167            write!(formatter, " from DISPID {:#x}", self.dispid)?;
168        }
169        Ok(())
170    }
171}
172
173impl From<ComError> for Error {
174    fn from(error: ComError) -> Self {
175        match error {
176            // Route to the named variant only when the engine actually names
177            // the code, so `Com` stays honest about being an opaque HRESULT.
178            ComError::Hresult { code, dispid, .. } => {
179                if code_name(code).is_some() {
180                    Self::Engine { code, dispid }
181                } else {
182                    Self::Com {
183                        hresult: code,
184                        dispid,
185                    }
186                }
187            }
188            ComError::UnexpectedType { expected, actual } => {
189                Self::UnexpectedType { expected, actual }
190            }
191        }
192    }
193}
194
195#[cfg(test)]
196mod tests {
197    use super::Error;
198    use rs_teststand_sys::ComError;
199
200    #[test]
201    fn known_engine_code_becomes_a_named_error() {
202        let error = Error::from(ComError::hresult(-17000, "test"));
203        assert!(
204            matches!(error, Error::Engine { code: -17000, .. }),
205            "expected Engine variant, got {error:?}"
206        );
207        assert_eq!(error.code_name(), Some("TS_Err_OutOfMemory"));
208        assert!(
209            error.to_string().contains("TS_Err_OutOfMemory"),
210            "message should name the error, got {error}"
211        );
212    }
213
214    #[test]
215    fn a_member_failure_names_the_dispid_that_refused() {
216        // A bare engine code does not say which call failed. Carrying the
217        // DISPID through turns "something returned -17308" into a message that
218        // points at one member.
219        let error = Error::from(ComError::member(-17308, "IDispatch::Invoke (call)", 0x1f5));
220        let message = error.to_string();
221        assert!(
222            message.contains("TS_Err_UnexpectedType"),
223            "should name the code, got {message}"
224        );
225        assert!(
226            message.contains("0x1f5"),
227            "should name the member that refused, got {message}"
228        );
229    }
230
231    #[test]
232    fn a_failure_outside_a_member_call_omits_the_dispid() {
233        // Apartment and class-creation failures have no member to blame, and a
234        // dangling "from DISPID 0x0" would be noise.
235        let error = Error::from(ComError::hresult(-17000, "CoCreateInstance"));
236        assert!(!error.to_string().contains("DISPID"), "got {error}");
237    }
238
239    #[test]
240    fn unknown_code_stays_an_opaque_com_error() {
241        // A standard COM HRESULT the engine does not name.
242        let error = Error::from(ComError::hresult(-2_147_209_215, "test"));
243        assert!(
244            matches!(error, Error::Com { .. }),
245            "expected Com variant, got {error:?}"
246        );
247        assert_eq!(error.code_name(), None);
248        assert_eq!(error.code(), Some(-2_147_209_215));
249    }
250
251    #[test]
252    fn type_mismatch_carries_no_code() {
253        let error = Error::from(ComError::UnexpectedType {
254            expected: "I32",
255            actual: "Str",
256        });
257        assert_eq!(error.code(), None);
258        assert_eq!(error.code_name(), None);
259    }
260}