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}