rs_teststand/engine.rs
1//! The `Engine`, root of the object model and factory for everything else.
2
3use rs_teststand_sys::{Dispatch, OutKind, Value, create_dispatch};
4
5#[path = "engine_startup.rs"]
6mod startup;
7
8use rs_teststand_sys::DialogInfo;
9
10use crate::dispids::engine as dispid;
11use crate::error::Error;
12use crate::expression::{EvaluationOptions, ExpressionError};
13
14/// ProgID of the version-independent engine coclass. Resolves to the active
15/// installation's `teapi.dll` via the registry.
16const ENGINE_PROG_ID: &str = "TestStand.Engine";
17
18/// The TestStand™ Engine.
19///
20/// Constructing an `Engine` creates the underlying COM object; dropping it
21/// releases it. It is the entry point of the API:
22///
23/// ```no_run
24/// use rs_teststand::Engine;
25///
26/// let engine = Engine::new()?;
27/// println!("TestStand {}", engine.version_string()?);
28/// # Ok::<(), rs_teststand::Error>(())
29/// ```
30#[derive(Debug)]
31pub struct Engine {
32 dispatch: Box<dyn Dispatch>,
33 /// Dialogs closed while this engine was being created. See
34 /// [`startup_dialogs`](Engine::startup_dialogs).
35 startup_dialogs: Vec<DialogInfo>,
36}
37
38impl Engine {
39 /// Creates the engine (STA COM apartment plus the `TestStand.Engine` object).
40 ///
41 /// # Errors
42 /// [`Error::Com`] if COM cannot be initialized or the engine class
43 /// cannot be created (e.g. no TestStand™ installation is registered).
44 pub fn new() -> Result<Self, Error> {
45 // Creating the engine can itself raise a dialog, before any option can
46 // be set, see the `startup` module. The sweeper has to be running
47 // before the call, because the call is what blocks.
48 let sweeper = startup::Sweeper::start();
49 let dispatch = create_dispatch(ENGINE_PROG_ID);
50 let startup_dialogs = sweeper.stop();
51 let engine = Self {
52 dispatch: Box::new(dispatch?),
53 startup_dialogs,
54 };
55 engine.suppress_modal_dialogs();
56 engine.load_type_palettes();
57 Ok(engine)
58 }
59
60 /// Dialogs that were closed while this engine was being created.
61 ///
62 /// A non-empty list is worth logging: it is the only record that something
63 /// asked a question and was answered by closing the window.
64 ///
65 /// Empty means nothing *owned by this process* was found, which is not the
66 /// same as no dialog having appeared. Detection cannot see another
67 /// process's windows, and whether the engine's unreleased-files warning is
68 /// raised in-process has not been established. Do not treat an empty list
69 /// as proof that startup was clean.
70 #[must_use]
71 pub fn startup_dialogs(&self) -> &[DialogInfo] {
72 &self.startup_dialogs
73 }
74
75 /// Loads the station's type palettes.
76 ///
77 /// Step types live in the palettes, so without this
78 /// [`new_step`](Self::new_step) fails with `TS_Err_StepTypeNotFound` for
79 /// every built-in type. The sequence editor does this as it starts; an
80 /// engine created directly over COM does not, so the crate does it here.
81 ///
82 /// Conflicts are resolved by failing rather than prompting, and a failure
83 /// is ignored for the same reason the dialog settings are: a station with
84 /// no palettes configured is still a usable engine.
85 fn load_type_palettes(&self) {
86 let _ = self.load_type_palette_files_ex(crate::ConflictHandler::Error, 0);
87 }
88
89 /// Loads the type palette files (`Engine.LoadTypePaletteFilesEx`).
90 ///
91 /// Called during construction; exposed for a caller that reconfigures the
92 /// palette list and needs to reload.
93 ///
94 /// # Errors
95 /// [`Error`] if the COM call fails.
96 pub fn load_type_palette_files_ex(
97 &self,
98 handler: crate::ConflictHandler,
99 options: i32,
100 ) -> Result<(), Error> {
101 self.dispatch.call(
102 dispid::LOAD_TYPE_PALETTE_FILES_EX,
103 &[Value::I32(handler.bits()), Value::I32(options)],
104 )?;
105 Ok(())
106 }
107
108 /// Loads the type palette files (`Engine.LoadTypePaletteFiles`).
109 ///
110 /// The older form, without conflict handling. Kept because it is the member
111 /// available on engines from TestStand 2016.
112 ///
113 /// # Errors
114 /// [`Error`] if the COM call fails.
115 pub fn load_type_palette_files(&self) -> Result<(), Error> {
116 self.dispatch.call(dispid::LOAD_TYPE_PALETTE_FILES, &[])?;
117 Ok(())
118 }
119
120 /// Unloads the type palette files (`Engine.UnloadTypePaletteFiles`).
121 ///
122 /// # Errors
123 /// [`Error`] if the COM call fails.
124 pub fn unload_type_palette_files(&self) -> Result<(), Error> {
125 self.dispatch.call(dispid::UNLOAD_TYPE_PALETTE_FILES, &[])?;
126 Ok(())
127 }
128
129 /// Points the station's dialog-raising options at their non-interactive
130 /// settings for this session.
131 ///
132 /// A modal dialog is fatal to an unattended host: no one is there to
133 /// dismiss it, so the call blocks forever. That is unacceptable for CI,
134 /// provisioning, or a long-lived service, so engine construction always
135 /// applies these.
136 ///
137 /// Failures are deliberately ignored: an engine that cannot be configured
138 /// is still usable, and construction must not fail over a hardening step.
139 ///
140 /// One gap is worth knowing about. Automatic login uses the operating
141 /// system identity and skips password authentication, but only if that
142 /// account is also a known engine user; if it is not, the engine falls back
143 /// to asking, which is the one dialog this method cannot rule out. A
144 /// station that runs headless should therefore have its service account
145 /// present in the user file. Guard the first calls with a
146 /// [`Watchdog`](crate::Watchdog) if that cannot be guaranteed.
147 ///
148 /// Tracing is left alone. Only the execution bits that halt and wait for a
149 /// person are cleared, so a host keeps whatever tracing the station was
150 /// configured for, see [`ExecutionMask`](crate::ExecutionMask).
151 fn suppress_modal_dialogs(&self) {
152 let Ok(options) = self.station_options() else {
153 return;
154 };
155
156 // A run-time error must resolve itself rather than prompt.
157 let _ = options.set_rte_option(crate::RunTimeErrorOption::Abort);
158
159 // Every prompt the engine can raise while loading or editing files.
160 let _ = options.set_prompt_to_find_files(false);
161 let _ = options.set_type_version_auto_increment_prompt_opt(false);
162 let _ = options.set_use_dialog_for_check_out(false);
163 let _ = options.set_prompt_when_adding_files_to_sc(false);
164 let _ = options.set_check_out_files_when_edited(false);
165
166 // Logging in must never stop on a dialog. Privilege checking off and
167 // login not required means no gate; auto-login uses the operating
168 // system identity, which the engine accepts without asking for a
169 // password. The residual risk is documented on this method.
170 let _ = options.set_enable_user_privilege_checking(false);
171 let _ = options.set_require_user_login(false);
172 let _ = options.set_auto_login_system_user(true);
173
174 // Read-modify-write, for the same reason as the execution mask below:
175 // only two debug bits raise a dialog at shutdown, and the rest are
176 // choices the operator made. Clearing all of them would silently turn
177 // off stack and buffer checking on somebody's station.
178 if let Ok(current) = options.debug_options() {
179 let quiet = current.difference(crate::DebugOptions::MODAL_ON_SHUTDOWN);
180 let _ = options.set_debug_options(quiet);
181 }
182
183 // Read-modify-write: keep the station's tracing choices, drop only the
184 // break bits, which suspend an execution until an operator acts.
185 if let Ok(current) = options.execution_mask() {
186 let running = current.difference(crate::ExecutionMask::BREAKS);
187 let _ = options.set_execution_mask(running);
188 }
189 }
190
191 /// The engine's major version number (`Engine.MajorVersion`): the two-digit
192 /// major, so TestStand™ 2026 reports `26` and 2016 reports `16`.
193 ///
194 /// # Errors
195 /// [`Error`] if the COM call fails or returns an unexpected type.
196 pub fn major_version(&self) -> Result<i32, Error> {
197 Ok(self.dispatch.get(dispid::MAJOR_VERSION)?.as_i32()?)
198 }
199
200 /// The engine's minor version number (`Engine.MinorVersion`).
201 ///
202 /// # Errors
203 /// [`Error`] if the COM call fails or returns an unexpected type.
204 pub fn minor_version(&self) -> Result<i32, Error> {
205 Ok(self.dispatch.get(dispid::MINOR_VERSION)?.as_i32()?)
206 }
207
208 /// The engine's revision version number (`Engine.RevisionVersion`).
209 ///
210 /// # Errors
211 /// [`Error`] if the COM call fails or returns an unexpected type.
212 pub fn revision_version(&self) -> Result<i32, Error> {
213 Ok(self.dispatch.get(dispid::REVISION_VERSION)?.as_i32()?)
214 }
215
216 /// The engine's build version number (`Engine.BuildVersion`).
217 ///
218 /// # Errors
219 /// [`Error`] if the COM call fails or returns an unexpected type.
220 pub fn build_version(&self) -> Result<i32, Error> {
221 Ok(self.dispatch.get(dispid::BUILD_VERSION)?.as_i32()?)
222 }
223
224 /// The engine's full version string (`Engine.VersionString`).
225 ///
226 /// # Errors
227 /// [`Error`] if the COM call fails or returns an unexpected type.
228 pub fn version_string(&self) -> Result<String, Error> {
229 Ok(self.dispatch.get(dispid::VERSION_STRING)?.into_string()?)
230 }
231
232 /// Returns `true` if the TestStand™ engine is running as a 64-bit process (`Engine.Is64Bit`).
233 ///
234 /// # Errors
235 /// [`Error`] if the COM call fails or returns an unexpected type.
236 pub fn is_64bit(&self) -> Result<bool, Error> {
237 Ok(self.dispatch.get(dispid::IS_64BIT)?.as_bool()?)
238 }
239
240 /// The path to the TestStand™ root directory (`Engine.TestStandDirectory`).
241 ///
242 /// # Errors
243 /// [`Error`] if the COM call fails or returns an unexpected type.
244 pub fn teststand_directory(&self) -> Result<String, Error> {
245 Ok(self
246 .dispatch
247 .get(dispid::TESTSTAND_DIRECTORY)?
248 .into_string()?)
249 }
250
251 /// The path to the TestStand™ `Bin` directory (`Engine.BinDirectory`).
252 ///
253 /// # Errors
254 /// [`Error`] if the COM call fails or returns an unexpected type.
255 pub fn bin_directory(&self) -> Result<String, Error> {
256 Ok(self.dispatch.get(dispid::BIN_DIRECTORY)?.into_string()?)
257 }
258
259 /// The path to the TestStand™ `Cfg` directory (`Engine.ConfigDirectory`).
260 ///
261 /// # Errors
262 /// [`Error`] if the COM call fails or returns an unexpected type.
263 pub fn config_directory(&self) -> Result<String, Error> {
264 Ok(self.dispatch.get(dispid::CONFIG_DIRECTORY)?.into_string()?)
265 }
266
267 /// Accesses the station's configuration settings (`Engine.StationOptions`).
268 ///
269 /// # Errors
270 /// [`Error`] if the COM call fails or returns an unexpected type.
271 pub fn station_options(&self) -> Result<crate::station::StationOptions, Error> {
272 let dispatch = self.dispatch.get(dispid::STATION_OPTIONS)?.into_object()?;
273 Ok(crate::station::StationOptions::new(dispatch))
274 }
275
276 /// Creates an empty sequence file (`Engine.NewSequenceFile`).
277 ///
278 /// The file exists only in memory until it is saved.
279 ///
280 /// # Errors
281 /// [`Error`] if the COM call fails or returns an unexpected type.
282 pub fn new_sequence_file(&self) -> Result<crate::SequenceFile, Error> {
283 Ok(crate::SequenceFile::new(
284 self.dispatch
285 .call(dispid::NEW_SEQUENCE_FILE, &[])?
286 .into_object()?,
287 ))
288 }
289
290 /// Starts a sequence running (`Engine.NewExecution`).
291 ///
292 /// The execution begins immediately.
293 ///
294 /// Pass `None` for `process_model` to run the sequence directly; supply one
295 /// to run a process-model entry point instead. `execution_type_mask` is
296 /// normally `0`.
297 ///
298 /// # Errors
299 /// [`Error`] if the sequence cannot be started or the COM call fails.
300 pub fn new_execution(
301 &self,
302 sequence_file: &crate::SequenceFile,
303 sequence_name: &str,
304 process_model: Option<&crate::SequenceFile>,
305 break_at_first_step: bool,
306 execution_type_mask: i32,
307 ) -> Result<crate::Execution, Error> {
308 let file = sequence_file
309 .duplicate_dispatch()
310 .ok_or(Error::UnexpectedType {
311 expected: "a live sequence file",
312 actual: "a test fake with no COM identity",
313 })?;
314 // "No process model" is a null object reference, not a null variant.
315 let model = process_model
316 .and_then(crate::SequenceFile::duplicate_dispatch)
317 .map_or(Value::NullObject, Value::Object);
318
319 Ok(crate::Execution::new(
320 self.dispatch
321 .call(
322 dispid::NEW_EXECUTION,
323 &[
324 Value::Object(file),
325 Value::Str(sequence_name.to_owned()),
326 model,
327 Value::Bool(break_at_first_step),
328 Value::I32(execution_type_mask),
329 ],
330 )?
331 .into_object()?,
332 ))
333 }
334
335 /// Posts a message on behalf of an execution (`Engine.PostUIMessage`).
336 ///
337 /// The counterpart to
338 /// [`Thread::post_ui_message_ex`](crate::Thread::post_ui_message_ex), for
339 /// code that is not itself running inside the sequence and therefore has no
340 /// current thread to post from. Because there is no implied context, the
341 /// execution and thread the message belongs to are given explicitly.
342 ///
343 /// `activex_data` carries structured data, read back by the host from
344 /// [`UIMessage::activex_data`](crate::UIMessage::activex_data). Pass `None`
345 /// to leave the slot empty.
346 ///
347 /// Pass `synchronous = true` in the ordinary case; see
348 /// [`Thread::post_ui_message_ex`](crate::Thread::post_ui_message_ex) for why
349 /// the blocking form is the safe default.
350 ///
351 /// # Errors
352 /// [`Error`] if the COM call fails, or if a wrapper has no COM identity.
353 #[allow(
354 clippy::too_many_arguments,
355 reason = "mirrors Engine.PostUIMessage's parameter list and order, which is the point of a twin API: grouping them into a struct would make the Rust call unpredictable from the COM documentation"
356 )]
357 pub fn post_ui_message(
358 &self,
359 execution: &crate::Execution,
360 thread: &crate::Thread,
361 event_code: i32,
362 numeric_data: f64,
363 string_data: &str,
364 activex_data: Option<&crate::PropertyObject>,
365 synchronous: bool,
366 ) -> Result<(), Error> {
367 let missing = || Error::UnexpectedType {
368 expected: "a live execution and thread",
369 actual: "a test fake with no COM identity",
370 };
371 let execution_handle = execution.duplicate_dispatch().ok_or_else(missing)?;
372 let thread_handle = thread.duplicate_dispatch().ok_or_else(missing)?;
373 self.dispatch.call(
374 dispid::POST_UI_MESSAGE,
375 &[
376 Value::Object(execution_handle),
377 Value::Object(thread_handle),
378 Value::I32(event_code),
379 Value::F64(numeric_data),
380 Value::Str(string_data.to_owned()),
381 crate::execution::thread::object_argument(activex_data)?,
382 Value::Bool(synchronous),
383 ],
384 )?;
385 Ok(())
386 }
387
388 /// Logs a user in, or logs the current one out (`Engine.CurrentUser`).
389 ///
390 /// `Some(user)` makes that user current; `None` clears it, which the engine
391 /// documents as logging out.
392 ///
393 /// **This does not check the password.** Setting the property is the act of
394 /// logging in, not an authentication step: a host that cares must call
395 /// [`User::validate_password`](crate::User::validate_password) first and
396 /// refuse on `false`. Written this way because the engine draws the same
397 /// line, and hiding a check inside a setter would make it unclear which one
398 /// a caller had actually performed.
399 ///
400 /// A host built on the `ActiveX` UI controls should use their own login
401 /// method instead, so the controls raise the event they expect; this is the
402 /// headless path.
403 ///
404 /// # Errors
405 /// [`Error`] if the COM call fails, or if `user` has no COM identity.
406 pub fn set_current_user(&self, user: Option<&crate::users::User>) -> Result<(), Error> {
407 let value = match user {
408 None => Value::NullObject,
409 Some(user) => {
410 user.duplicate_dispatch()
411 .map(Value::Object)
412 .ok_or(Error::UnexpectedType {
413 expected: "a live user",
414 actual: "a test fake with no COM identity",
415 })?
416 }
417 };
418 self.dispatch.put(dispid::CURRENT_USER, value)?;
419 Ok(())
420 }
421
422 /// Asks every execution to stop (`Engine.TerminateAll`).
423 ///
424 /// Termination, not abort: cleanup groups still run, so hardware is left in
425 /// a safe state. Like [`Execution::terminate`](crate::Execution::terminate)
426 /// it is a request, and returns before the runs have finished unwinding. A
427 /// caller that needs them stopped must then wait for
428 /// [`UIMessageCode::EndExecution`](crate::UIMessageCode::EndExecution).
429 ///
430 /// # Errors
431 /// [`Error`] if the COM call fails.
432 pub fn terminate_all(&self) -> Result<(), Error> {
433 self.dispatch.call(dispid::TERMINATE_ALL, &[])?;
434 Ok(())
435 }
436
437 /// Stops every execution without running cleanup (`Engine.AbortAll`).
438 ///
439 /// The blunt counterpart to [`terminate_all`](Self::terminate_all). Cleanup
440 /// groups do **not** run, so anything a sequence would have switched off
441 /// stays on. Prefer terminating unless the point is to stop now.
442 ///
443 /// # Errors
444 /// [`Error`] if the COM call fails.
445 pub fn abort_all(&self) -> Result<(), Error> {
446 self.dispatch.call(dispid::ABORT_ALL, &[])?;
447 Ok(())
448 }
449
450 /// The license the engine is currently using (`Engine.LicenseType`).
451 ///
452 /// **Using, not holding.** A freshly created engine has acquired nothing
453 /// and reports [`LicenseType::NoLicense`](crate::LicenseType::NoLicense)
454 /// even on a fully licensed station; the answer only becomes meaningful
455 /// after something acquires. Use
456 /// [`require_license`](Self::require_license) to ask whether the station
457 /// can license this host.
458 ///
459 /// Reads state, so it acquires nothing and raises no dialog.
460 ///
461 /// # Errors
462 /// [`Error`] if the COM call fails, or [`Error::UnknownLicenseType`] if the
463 /// engine reports a type this build does not name.
464 pub fn license_type(&self) -> Result<crate::LicenseType, Error> {
465 let raw = self.dispatch.get(dispid::LICENSE_TYPE)?.as_i32()?;
466 crate::LicenseType::from_bits(raw).map_err(|bits| Error::UnknownLicenseType { bits })
467 }
468
469 /// Acquires a license, or fails if the station cannot grant one.
470 ///
471 /// The check a headless host should make before anything else, and the
472 /// object it should keep alive while it runs.
473 ///
474 /// Acquiring is what makes a license real.
475 /// [`license_type`](Self::license_type) reports the license the engine is
476 /// *using*, and a freshly created engine is using none, measured on a
477 /// station with a valid development system license, it reads `NoLicense`
478 /// until something acquires. So reading before acquiring answers the wrong
479 /// question, and this method acquires first.
480 ///
481 /// The request is [`ApplicationLicense::Unspecified`](crate::ApplicationLicense), which lets the engine
482 /// grant whatever it has. Naming a kind can be refused even when the
483 /// station is properly licensed: on a development system station,
484 /// [`ApplicationLicense::OperatorInterface`](crate::ApplicationLicense) is turned down while
485 /// unspecified succeeds. Ask for a specific kind through
486 /// [`acquire_license`](Self::acquire_license) only when the host genuinely
487 /// requires that one.
488 ///
489 /// The startup dialog is suppressed, so an unlicensed station returns an
490 /// error rather than opening a window nobody will close.
491 ///
492 /// **Refusal is retried for a few seconds before it is believed.** The
493 /// licensing subsystem is not ready the instant the engine object exists:
494 /// measured on a properly licensed station, acquiring immediately after
495 /// construction is refused, while the same call half a second later
496 /// succeeds. A host that trusted the first answer would report an
497 /// unlicensed station to its operator and stop. So a refusal is retried
498 /// until it stops changing, which costs an unlicensed station a few seconds
499 /// once, at startup.
500 ///
501 /// Success is the handle, not the type.
502 /// [`HeldLicense::kind`](crate::HeldLicense::kind) reports what the engine
503 /// says it is using and can still read
504 /// [`NoLicense`](crate::LicenseType::NoLicense) after an unspecified
505 /// request was granted, so treat it as information rather than as the
506 /// verdict.
507 ///
508 /// # Errors
509 /// [`Error::NoLicense`] if no license can be acquired, or [`Error`] if the
510 /// COM call fails.
511 pub fn require_license(&self) -> Result<crate::HeldLicense<'_>, Error> {
512 /// Longest to keep asking before calling the station unlicensed.
513 const PATIENCE: core::time::Duration = core::time::Duration::from_secs(3);
514 /// Gap between attempts.
515 const RETRY_INTERVAL: core::time::Duration = core::time::Duration::from_millis(100);
516
517 let started = std::time::Instant::now();
518 let handle = loop {
519 match self.acquire_license(
520 crate::ApplicationLicense::Unspecified,
521 crate::AcquireLicenseOptions::SUPPRESS_STARTUP_DIALOG,
522 ) {
523 Ok(handle) => break handle,
524 Err(Error::NoLicense) if started.elapsed() < PATIENCE => {
525 std::thread::sleep(RETRY_INTERVAL);
526 }
527 Err(other) => return Err(other),
528 }
529 };
530 // The grant is the handle. `LicenseType` is informational and does not
531 // always follow an unspecified request: measured on a licensed station,
532 // acquiring unspecified returns a handle while the type still reads
533 // `NoLicense`, and only a named request such as a sequence editor makes
534 // it report `DevelopmentSystem`. So the type is recorded, not gated on.
535 let kind = self.license_type()?;
536 Ok(crate::HeldLicense::new(self, handle, kind))
537 }
538
539 /// A description of the current license (`Engine.GetLicenseDescription`).
540 ///
541 /// Free text meant for a person, so log it rather than branch on it; use
542 /// [`license_type`](Self::license_type) for decisions.
543 ///
544 /// # Errors
545 /// [`Error`] if the COM call fails or returns an unexpected type.
546 pub fn get_license_description(&self) -> Result<String, Error> {
547 // The engine declares one reserved parameter, documented as always
548 // zero.
549 Ok(self
550 .dispatch
551 .call(dispid::GET_LICENSE_DESCRIPTION, &[Value::I32(0)])?
552 .into_string()?)
553 }
554
555 /// The license this application requested (`Engine.ApplicationLicense`).
556 ///
557 /// # Errors
558 /// [`Error`] if the COM call fails, or [`Error::UnknownLicenseType`] if the
559 /// engine reports a value this build does not name.
560 pub fn application_license(&self) -> Result<crate::ApplicationLicense, Error> {
561 let raw = self.dispatch.get(dispid::APPLICATION_LICENSE)?.as_i32()?;
562 crate::ApplicationLicense::from_bits(raw).map_err(|bits| Error::UnknownLicenseType { bits })
563 }
564
565 /// Acquires a license and returns its handle (`Engine.AcquireLicense`).
566 ///
567 /// Release it with [`release_license`](Self::release_license); the license
568 /// is held until every handle for it is released.
569 ///
570 /// **Pass [`AcquireLicenseOptions::SUPPRESS_STARTUP_DIALOG`](crate::AcquireLicenseOptions) on any station
571 /// without a person at it.** Without it, an engine that cannot acquire the
572 /// license opens a window offering to evaluate, activate or buy, and waits.
573 /// A headless host stops there until something kills it. With it, the same
574 /// situation returns an error this method propagates.
575 ///
576 /// Prefer
577 /// [`ApplicationLicense::Unspecified`](crate::ApplicationLicense),
578 /// which lets the engine grant whatever it has. Naming a kind is a
579 /// constraint, not a preference, and a smaller request is not a safer one:
580 /// on a station licensed for a development system,
581 /// [`OperatorInterface`](crate::ApplicationLicense::OperatorInterface) is
582 /// refused while unspecified succeeds. Name a kind only when the host truly
583 /// requires it.
584 ///
585 /// Most callers want [`require_license`](Self::require_license) instead,
586 /// which acquires and hands back a guard that releases on drop.
587 ///
588 /// # Errors
589 /// [`Error::NoLicense`] if the license was not granted, or [`Error`] if the
590 /// COM call fails.
591 ///
592 /// A handle of zero is treated as refusal. The reference says this member
593 /// returns an error when it cannot acquire the license; measured against an
594 /// unlicensed station it succeeds and hands back zero instead. A caller
595 /// that trusted the documented behavior would carry on unlicensed, so the
596 /// zero is turned into the error the caller was promised.
597 pub fn acquire_license(
598 &self,
599 license: crate::ApplicationLicense,
600 options: crate::AcquireLicenseOptions,
601 ) -> Result<i32, Error> {
602 let handle = self
603 .dispatch
604 .call(
605 dispid::ACQUIRE_LICENSE,
606 &[Value::I32(license.bits()), Value::I32(options.bits())],
607 )?
608 .as_i32()?;
609 if handle == 0 {
610 return Err(Error::NoLicense);
611 }
612 Ok(handle)
613 }
614
615 /// Releases a license handle (`Engine.ReleaseLicense`).
616 ///
617 /// # Errors
618 /// [`Error`] if the COM call fails.
619 pub fn release_license(&self, handle: i32) -> Result<(), Error> {
620 // Second parameter is reserved and documented as zero.
621 self.dispatch.call(
622 dispid::RELEASE_LICENSE,
623 &[Value::I32(handle), Value::I32(0)],
624 )?;
625 Ok(())
626 }
627
628 /// Whether the station licenses an add-on feature
629 /// (`Engine.HasAddonLicense`).
630 ///
631 /// # Errors
632 /// [`Error`] if the COM call fails or returns an unexpected type.
633 pub fn has_addon_license(&self, feature_name: &str) -> Result<bool, Error> {
634 Ok(self
635 .dispatch
636 .call(
637 dispid::HAS_ADDON_LICENSE,
638 &[Value::Str(feature_name.to_owned())],
639 )?
640 .as_bool()?)
641 }
642
643 /// Releases every code module the engine has loaded
644 /// (`Engine.UnloadAllModules`).
645 ///
646 /// Loading a sequence file loads its modules, and they stay loaded until
647 /// that file is closed. That is what makes the second run fast, and also
648 /// what holds a DLL open against the build that wants to replace it.
649 /// Unloading here frees them all at once, without closing anything.
650 ///
651 /// Call it between runs, not during one: a module in use by a live
652 /// execution is not a candidate, and the next run reloads whatever it needs.
653 ///
654 /// **State inside a module does not survive.** Anything a module kept in a
655 /// static or a global is gone once it is unloaded, and the reload starts
656 /// from nothing. A station whose modules carry state between steps that way
657 /// should keep that state in the engine instead, or not call this.
658 ///
659 /// # Errors
660 /// [`Error`] if the COM call fails.
661 pub fn unload_all_modules(&self) -> Result<(), Error> {
662 self.dispatch.call(dispid::UNLOAD_ALL_MODULES, &[])?;
663 Ok(())
664 }
665
666 /// Whether breakpoints stop an execution (`Engine.BreakpointsEnabled`).
667 ///
668 /// The master switch. With it off, breakpoints stay set but nothing stops
669 /// on them, which is how a station runs unattended without anyone having to
670 /// strip a sequence file of the breakpoints someone left in it.
671 ///
672 /// Distinct from the station option of the same name, which is the setting
673 /// written to disk. This is the engine's live state.
674 ///
675 /// # Errors
676 /// [`Error`] if the COM call fails or returns an unexpected type.
677 pub fn breakpoints_enabled(&self) -> Result<bool, Error> {
678 Ok(self.dispatch.get(dispid::BREAKPOINTS_ENABLED)?.as_bool()?)
679 }
680
681 /// Turns breakpoints on or off (`Engine.BreakpointsEnabled`).
682 ///
683 /// # Errors
684 /// [`Error`] if the COM call fails.
685 pub fn set_breakpoints_enabled(&self, enabled: bool) -> Result<(), Error> {
686 self.dispatch
687 .put(dispid::BREAKPOINTS_ENABLED, Value::Bool(enabled))?;
688 Ok(())
689 }
690
691 /// Whether breakpoints survive the file they are set in
692 /// (`Engine.PersistBreakpoints`).
693 ///
694 /// On, the engine remembers them across a close and reopen. A host that
695 /// sets breakpoints on behalf of a remote panel usually wants this off, so
696 /// that a debugging session leaves nothing behind on the station.
697 ///
698 /// # Errors
699 /// [`Error`] if the COM call fails or returns an unexpected type.
700 pub fn persist_breakpoints(&self) -> Result<bool, Error> {
701 Ok(self.dispatch.get(dispid::PERSIST_BREAKPOINTS)?.as_bool()?)
702 }
703
704 /// Chooses whether breakpoints are remembered (`Engine.PersistBreakpoints`).
705 ///
706 /// # Errors
707 /// [`Error`] if the COM call fails.
708 pub fn set_persist_breakpoints(&self, persist: bool) -> Result<(), Error> {
709 self.dispatch
710 .put(dispid::PERSIST_BREAKPOINTS, Value::Bool(persist))?;
711 Ok(())
712 }
713
714 /// Runs a .NET garbage collection now
715 /// (`Engine.DoDotNetGarbageCollection`).
716 ///
717 /// Only relevant to a station whose steps call .NET code. Collection is
718 /// otherwise periodic, on the
719 /// [interval](Self::dot_net_garbage_collection_interval); this forces one,
720 /// which is worth doing between runs on a long-lived host rather than
721 /// during a measurement, since collection pauses the runtime.
722 ///
723 /// # Errors
724 /// [`Error`] if the COM call fails.
725 pub fn do_dot_net_garbage_collection(&self) -> Result<(), Error> {
726 // The engine declares one reserved parameter, optional and defaulting
727 // to zero. Supplying it keeps the call correct if the default ever
728 // stops being applied.
729 self.dispatch
730 .call(dispid::DO_DOT_NET_GARBAGE_COLLECTION, &[Value::I32(0)])?;
731 Ok(())
732 }
733
734 /// How often the engine collects .NET garbage, in milliseconds
735 /// (`Engine.DotNetGarbageCollectionInterval`).
736 ///
737 /// Zero or less means automatic collection is off. A host built on this
738 /// crate will normally read `-1`, and that is correct rather than broken:
739 /// the three-second default belongs to applications built on the UI
740 /// control, and a headless host does not create one. Nothing collects on a
741 /// timer unless this is set to a positive interval, so a long-lived host
742 /// that runs .NET steps should either set one or call
743 /// [`do_dot_net_garbage_collection`](Self::do_dot_net_garbage_collection)
744 /// between runs.
745 ///
746 /// # Errors
747 /// [`Error`] if the COM call fails or returns an unexpected type.
748 pub fn dot_net_garbage_collection_interval(&self) -> Result<i32, Error> {
749 Ok(self
750 .dispatch
751 .get(dispid::DOT_NET_GARBAGE_COLLECTION_INTERVAL)?
752 .as_i32()?)
753 }
754
755 /// Sets the .NET collection interval, in milliseconds
756 /// (`Engine.DotNetGarbageCollectionInterval`).
757 ///
758 /// Zero or less switches automatic collection off.
759 ///
760 /// # Errors
761 /// [`Error`] if the COM call fails.
762 pub fn set_dot_net_garbage_collection_interval(&self, milliseconds: i32) -> Result<(), Error> {
763 self.dispatch.put(
764 dispid::DOT_NET_GARBAGE_COLLECTION_INTERVAL,
765 Value::I32(milliseconds),
766 )?;
767 Ok(())
768 }
769
770 /// The .NET runtime version the engine loaded (`Engine.DotNetCLRVersion`).
771 ///
772 /// Empty on a station where nothing has pulled the runtime in yet, so treat
773 /// an empty string as "not loaded" rather than as an error.
774 ///
775 /// # Errors
776 /// [`Error`] if the COM call fails or returns an unexpected type.
777 pub fn dot_net_clr_version(&self) -> Result<String, Error> {
778 Ok(self
779 .dispatch
780 .get(dispid::DOT_NET_CLR_VERSION)?
781 .into_string()?)
782 }
783
784 /// The station's user list, as a file (`Engine.UsersFile`).
785 ///
786 /// The users the engine loaded at startup, and the only route to writing
787 /// them back. [`new_user`](Self::new_user) builds a user in memory; without
788 /// saving through this file the station is unchanged once the process
789 /// exits.
790 ///
791 /// # Errors
792 /// [`Error`] if the COM call fails or returns an unexpected type.
793 pub fn users_file(&self) -> Result<crate::UsersFile, Error> {
794 Ok(crate::UsersFile::new(
795 self.dispatch.get(dispid::USERS_FILE)?.into_object()?,
796 ))
797 }
798
799 /// Whether the host polls for messages (`Engine.UIMessagePollingEnabled`).
800 ///
801 /// # Errors
802 /// [`Error`] if the COM call fails or returns an unexpected type.
803 pub fn ui_message_polling_enabled(&self) -> Result<bool, Error> {
804 Ok(self
805 .dispatch
806 .get(dispid::UI_MESSAGE_POLLING_ENABLED)?
807 .as_bool()?)
808 }
809
810 /// Turns message polling on or off (`Engine.UIMessagePollingEnabled`).
811 ///
812 /// Off by default. A headless host must turn it on before anything appears
813 /// in the queue, without it the queue stays empty however much a sequence
814 /// posts.
815 ///
816 /// # Errors
817 /// [`Error`] if the COM call fails.
818 pub fn set_ui_message_polling_enabled(&self, enabled: bool) -> Result<(), Error> {
819 self.dispatch
820 .put(dispid::UI_MESSAGE_POLLING_ENABLED, Value::Bool(enabled))?;
821 Ok(())
822 }
823
824 /// Whether the message queue is empty (`Engine.IsUIMessageQueueEmpty`).
825 ///
826 /// # Errors
827 /// [`Error`] if the COM call fails or returns an unexpected type.
828 pub fn is_ui_message_queue_empty(&self) -> Result<bool, Error> {
829 Ok(self
830 .dispatch
831 .get(dispid::IS_UI_MESSAGE_QUEUE_EMPTY)?
832 .as_bool()?)
833 }
834
835 /// Takes the next message from the queue (`Engine.GetUIMessage`).
836 ///
837 /// Check [`is_ui_message_queue_empty`](Self::is_ui_message_queue_empty)
838 /// first. The message must be acknowledged once handled, see
839 /// [`UIMessage::acknowledge`](crate::UIMessage::acknowledge).
840 ///
841 /// # Errors
842 /// [`Error`] if the COM call fails or returns an unexpected type.
843 pub fn get_ui_message(&self) -> Result<crate::UIMessage, Error> {
844 Ok(crate::UIMessage::new(
845 self.dispatch
846 .call(dispid::GET_UI_MESSAGE, &[])?
847 .into_object()?,
848 ))
849 }
850
851 /// Creates a step (`Engine.NewStep`).
852 ///
853 /// `adapter_key_name` selects the code-module adapter, see
854 /// [`AdapterKeyName`](crate::AdapterKeyName). `step_type_name` names the
855 /// step type, for example `NumericLimitTest` or `Action`.
856 ///
857 /// An empty key does **not** mean "no code module". It means the step type
858 /// chooses, falling back to the station's `DefaultAdapter` when the type
859 /// designates none, so an empty key on an `Action` yields whatever adapter
860 /// the station happens to default to. Pass
861 /// [`AdapterKeyName::NoneAdapter`](crate::AdapterKeyName::NoneAdapter) to
862 /// actually mean no code module.
863 ///
864 /// The step is not part of any sequence until it is inserted.
865 ///
866 /// # Errors
867 /// [`Error`] if the step type is unknown or the COM call fails.
868 pub fn new_step(
869 &self,
870 adapter_key_name: &str,
871 step_type_name: &str,
872 ) -> Result<crate::Step, Error> {
873 Ok(crate::Step::new(
874 self.dispatch
875 .call(
876 dispid::NEW_STEP,
877 &[
878 Value::Str(adapter_key_name.to_owned()),
879 Value::Str(step_type_name.to_owned()),
880 ],
881 )?
882 .into_object()?,
883 ))
884 }
885
886 /// Creates a sequence (`Engine.NewSequence`).
887 ///
888 /// The sequence is not part of any file until it is inserted.
889 ///
890 /// # Errors
891 /// [`Error`] if the COM call fails or returns an unexpected type.
892 pub fn new_sequence(&self) -> Result<crate::Sequence, Error> {
893 Ok(crate::Sequence::new(
894 self.dispatch
895 .call(dispid::NEW_SEQUENCE, &[])?
896 .into_object()?,
897 ))
898 }
899
900 /// Creates a user account object (`Engine.NewUser`).
901 ///
902 /// Pass an existing user as `profile` to inherit its privileges; the new
903 /// user does **not** join any group the profile belongs to. Pass `None` for
904 /// a user with no privileges.
905 ///
906 /// The result exists only in memory, nothing is written to the station's
907 /// users file by creating one.
908 ///
909 /// # Errors
910 /// [`Error`] if the COM call fails or returns an unexpected type.
911 pub fn new_user(
912 &self,
913 profile: Option<&crate::users::User>,
914 ) -> Result<crate::users::User, Error> {
915 // The engine reads the profile's privileges, so it needs a real
916 // handle; a null means "no privileges to inherit".
917 // The profile is required, and "no profile" is a null object
918 // reference, a VT_DISPATCH holding nothing. VT_NULL and VT_EMPTY are
919 // both refused here, and omitting the argument reports it as missing.
920 let argument = profile
921 .and_then(crate::users::User::duplicate_dispatch)
922 .map_or(Value::NullObject, Value::Object);
923 Ok(crate::users::User::new(
924 self.dispatch
925 .call(dispid::NEW_USER, &[argument])?
926 .into_object()?,
927 ))
928 }
929
930 /// Looks up a running execution by its id (`Engine.GetExecution`).
931 ///
932 /// The engine exposes no collection of executions, so a host that wants to
933 /// address one later keeps the id from [`Self::new_execution`] and resolves
934 /// it here. Returns `None` when the engine no longer knows the id, which is
935 /// an ordinary result of holding one across the end of a run rather than a
936 /// failure.
937 ///
938 /// **Resolving does not mean running.** A finished execution was observed
939 /// still resolving on one run and absent on another against the same engine
940 /// version, so how long the engine keeps one addressable is not something
941 /// to depend on. Read [`Execution::result_status`](crate::Execution::result_status)
942 /// to find out whether it is still going.
943 ///
944 /// Not available on every supported engine: the member is absent from the
945 /// TestStand 2016 type library and present in 2026, so a call on an older
946 /// engine fails rather than returning `None`. Check
947 /// [`major_version`](Self::major_version) before relying on it.
948 ///
949 /// # Errors
950 /// [`Error`] if the COM call fails or returns an unexpected type.
951 pub fn get_execution(&self, execution_id: i32) -> Result<Option<crate::Execution>, Error> {
952 match self
953 .dispatch
954 .call(dispid::GET_EXECUTION, &[Value::I32(execution_id)])?
955 {
956 Value::Object(dispatch) => Ok(Some(crate::Execution::new(dispatch))),
957 Value::Null | Value::Empty => Ok(None),
958 other => Err(Error::UnexpectedType {
959 expected: "Object or Null",
960 actual: other.kind(),
961 }),
962 }
963 }
964
965 /// Creates an empty expression object (`Engine.NewExpression`).
966 ///
967 /// Set its text, then evaluate it as often as needed: the engine keeps the
968 /// parsed form, unlike
969 /// [`PropertyObject::evaluate_ex`](crate::PropertyObject::evaluate_ex),
970 /// which re-parses on every call.
971 ///
972 /// # Errors
973 /// [`Error`] if the COM call fails or returns an unexpected type.
974 pub fn new_expression(&self) -> Result<crate::Expression, Error> {
975 Ok(crate::Expression::new(
976 self.dispatch
977 .call(dispid::NEW_EXPRESSION, &[])?
978 .into_object()?,
979 ))
980 }
981
982 /// Checks an expression's syntax (`Engine.CheckExprSyntax`).
983 ///
984 /// Returns `None` when the syntax is correct, or the error and the span it
985 /// covers when it is not. Only the syntax is checked: an expression naming
986 /// a variable that does not exist still passes, which is what
987 /// [`Self::check_expression`] is for.
988 ///
989 /// # Errors
990 /// [`Error`] if the engine cannot perform the check.
991 pub fn check_expr_syntax(&self, expression: &str) -> Result<Option<ExpressionError>, Error> {
992 let (valid, written) = self.dispatch.call_with_outputs(
993 dispid::CHECK_EXPR_SYNTAX,
994 &[Value::Str(expression.to_owned())],
995 &[OutKind::Text, OutKind::Int, OutKind::Int],
996 )?;
997 expression_check_result(&valid, written)
998 }
999
1000 /// Checks an expression against a context (`Engine.CheckExpression`).
1001 ///
1002 /// Like [`Self::check_expr_syntax`], but it also resolves the variables the
1003 /// expression names against `evaluation_context`. Pass `None` for the
1004 /// context to check the syntax alone.
1005 ///
1006 /// Returns `None` when the expression is correct, or the error otherwise.
1007 ///
1008 /// # Errors
1009 /// [`Error`] if the engine cannot perform the check.
1010 pub fn check_expression(
1011 &self,
1012 evaluation_context: Option<&crate::PropertyObject>,
1013 expression: &str,
1014 evaluation_options: EvaluationOptions,
1015 ) -> Result<Option<ExpressionError>, Error> {
1016 // "Check the syntax only" is a null object reference: a VT_DISPATCH
1017 // carrying no pointer, which is a different type from VT_NULL.
1018 let context = evaluation_context
1019 .and_then(crate::PropertyObject::duplicate_dispatch)
1020 .map_or(Value::NullObject, Value::Object);
1021 let (valid, written) = self.dispatch.call_with_outputs(
1022 dispid::CHECK_EXPRESSION,
1023 &[
1024 context,
1025 Value::Str(expression.to_owned()),
1026 Value::I32(evaluation_options.bits()),
1027 ],
1028 &[OutKind::Text, OutKind::Int, OutKind::Int],
1029 )?;
1030 expression_check_result(&valid, written)
1031 }
1032
1033 /// Converts expression text to the station's locale
1034 /// (`Engine.LocalizeExpression`).
1035 ///
1036 /// Expression text is not locale-neutral: the decimal separator and list
1037 /// separator differ. A host that shows an expression to an operator, or
1038 /// accepts one typed by them, converts rather than assuming a point.
1039 ///
1040 /// # Errors
1041 /// [`Error`] if the COM call fails or returns an unexpected type.
1042 pub fn localize_expression(
1043 &self,
1044 expression: &str,
1045 decimal_point_option: crate::DecimalPointLocalizationOption,
1046 ) -> Result<String, Error> {
1047 Ok(self
1048 .dispatch
1049 .call(
1050 dispid::LOCALIZE_EXPRESSION,
1051 &[
1052 Value::Str(expression.to_owned()),
1053 Value::I32(decimal_point_option.bits()),
1054 ],
1055 )?
1056 .into_string()?)
1057 }
1058
1059 /// Converts locale-specific expression text back to the neutral form
1060 /// (`Engine.DelocalizeExpression`).
1061 ///
1062 /// The inverse of [`localize_expression`](Self::localize_expression). Store
1063 /// the delocalized form; show the localized one.
1064 ///
1065 /// # Errors
1066 /// [`Error`] if the COM call fails or returns an unexpected type.
1067 pub fn delocalize_expression(
1068 &self,
1069 localized_expression: &str,
1070 decimal_point_option: crate::DecimalPointLocalizationOption,
1071 ) -> Result<String, Error> {
1072 Ok(self
1073 .dispatch
1074 .call(
1075 dispid::DELOCALIZE_EXPRESSION,
1076 &[
1077 Value::Str(localized_expression.to_owned()),
1078 Value::I32(decimal_point_option.bits()),
1079 ],
1080 )?
1081 .into_string()?)
1082 }
1083
1084 /// Finds a user by login name (`Engine.GetUser`).
1085 ///
1086 /// Returns `None` when no user has that name, rather than erroring.
1087 ///
1088 /// # Errors
1089 /// [`Error`] if the COM call fails or returns an unexpected type.
1090 pub fn get_user(&self, login_name: &str) -> Result<Option<crate::users::User>, Error> {
1091 match self
1092 .dispatch
1093 .call(dispid::GET_USER, &[Value::Str(login_name.to_owned())])?
1094 {
1095 Value::Object(dispatch) => Ok(Some(crate::users::User::new(dispatch))),
1096 Value::Null | Value::Empty => Ok(None),
1097 other => Err(Error::UnexpectedType {
1098 expected: "Object or Null",
1099 actual: other.kind(),
1100 }),
1101 }
1102 }
1103
1104 /// Whether a login name is already taken (`Engine.UserNameExists`).
1105 ///
1106 /// # Errors
1107 /// [`Error`] if the COM call fails or returns an unexpected type.
1108 pub fn user_name_exists(&self, login_name: &str) -> Result<bool, Error> {
1109 Ok(self
1110 .dispatch
1111 .call(
1112 dispid::USER_NAME_EXISTS,
1113 &[Value::Str(login_name.to_owned())],
1114 )?
1115 .as_bool()?)
1116 }
1117
1118 /// The user currently logged in (`Engine.CurrentUser`).
1119 ///
1120 /// Returns `None` when nobody is logged in, which is the normal state on a
1121 /// station that does not require a login.
1122 ///
1123 /// # Errors
1124 /// [`Error`] if the COM call fails or returns an unexpected type.
1125 pub fn current_user(&self) -> Result<Option<crate::users::User>, Error> {
1126 match self.dispatch.get(dispid::CURRENT_USER)? {
1127 Value::Object(dispatch) => Ok(Some(crate::users::User::new(dispatch))),
1128 Value::Null | Value::Empty => Ok(None),
1129 other => Err(Error::UnexpectedType {
1130 expected: "Object or Null",
1131 actual: other.kind(),
1132 }),
1133 }
1134 }
1135
1136 /// Whether the logged-in user holds a privilege
1137 /// (`Engine.CurrentUserHasPrivilege`).
1138 ///
1139 /// # Errors
1140 /// [`Error`] if the COM call fails or returns an unexpected type.
1141 pub fn current_user_has_privilege(
1142 &self,
1143 privilege: crate::users::UserPrivilege,
1144 ) -> Result<bool, Error> {
1145 Ok(self
1146 .dispatch
1147 .call(
1148 dispid::CURRENT_USER_HAS_PRIVILEGE,
1149 &[Value::Str(privilege.name().to_owned())],
1150 )?
1151 .as_bool()?)
1152 }
1153
1154 /// Creates a standalone `PropertyObject` (`Engine.NewPropertyObject`).
1155 ///
1156 /// The object belongs to no sequence file or station; it is useful as the
1157 /// root of a tree you build in memory. Pass a type name only when
1158 /// `value_type` is `NamedType`.
1159 ///
1160 /// # Errors
1161 /// [`Error`] if the COM call fails or returns an unexpected type.
1162 pub fn new_property_object(
1163 &self,
1164 value_type: crate::PropValType,
1165 as_array: bool,
1166 type_name: &str,
1167 options: i32,
1168 ) -> Result<crate::property::PropertyObject, Error> {
1169 Ok(crate::property::PropertyObject::new(
1170 self.dispatch
1171 .call(
1172 dispid::NEW_PROPERTY_OBJECT,
1173 &[
1174 Value::I32(value_type as i32),
1175 Value::Bool(as_array),
1176 Value::Str(type_name.to_owned()),
1177 Value::I32(options),
1178 ],
1179 )?
1180 .into_object()?,
1181 ))
1182 }
1183
1184 /// Shuts the engine down and leaves this thread's COM apartment.
1185 ///
1186 /// For a host that owns the engine on a **spawned** thread. Such a thread
1187 /// really does detach when it ends, so the apartment it initialized has to
1188 /// be closed or the COM runtime is left believing a live thread still owns
1189 /// one. The process's main thread does not need this: it is ending anyway.
1190 ///
1191 /// Consuming `self` is what makes the ordering safe, the engine is
1192 /// released before the apartment closes, and no caller can hold a reference
1193 /// across the boundary.
1194 ///
1195 /// # Errors
1196 /// [`Error`] if a COM call during shutdown fails. The apartment is closed
1197 /// either way.
1198 pub fn close(self, timeout: std::time::Duration) -> Result<bool, Error> {
1199 let confirmed = self.shutdown(timeout);
1200 rs_teststand_sys::close_apartment(self.dispatch);
1201 confirmed
1202 }
1203
1204 /// Closes files, terminates executions, and waits for the engine to say it
1205 /// is done (`Engine.ShutDown`).
1206 ///
1207 /// `ShutDown` is **asynchronous**. It returns as soon as the request is
1208 /// accepted, having only *started* terminating executions and closing
1209 /// files; the engine reports completion later by posting
1210 /// [`UIMessageCode::ShutDownComplete`](crate::UIMessageCode::ShutDownComplete)
1211 /// to its message queue. So a caller that simply calls it and drops the
1212 /// engine tears down COM underneath work that is still running.
1213 ///
1214 /// This does the whole protocol: enables message polling, asks the engine
1215 /// to shut down, then pumps and drains until the engine confirms or
1216 /// `timeout` elapses.
1217 ///
1218 /// Returns `true` when the engine confirmed. `false` means the timeout came
1219 /// first, or the engine posted
1220 /// [`ShutDownCanceled`](crate::UIMessageCode::ShutDownCanceled), which a
1221 /// sequence can cause, for instance by refusing to terminate. Either way the
1222 /// wait is **bounded**: an unattended host must not be able to hang here.
1223 ///
1224 /// Shutting down twice is harmless; the second call simply finds nothing to
1225 /// do and returns once the engine answers.
1226 ///
1227 /// # Errors
1228 /// [`Error`] if a COM call fails.
1229 pub fn shutdown(&self, timeout: std::time::Duration) -> Result<bool, Error> {
1230 // Without polling the completion message goes to an event sink that a
1231 // headless caller does not have, and the wait could never end.
1232 self.set_ui_message_polling_enabled(true)?;
1233 self.dispatch
1234 .call(dispid::SHUT_DOWN, &[Value::Bool(true)])?;
1235
1236 let started = std::time::Instant::now();
1237 while started.elapsed() < timeout {
1238 if crate::pump_thread_messages() {
1239 return Ok(false);
1240 }
1241 while !self.is_ui_message_queue_empty()? {
1242 let message = self.get_ui_message()?;
1243 let code = crate::UIMessageCode::from_bits(message.event()?);
1244 // Acknowledge before deciding: an unacknowledged synchronous
1245 // message would hold up the very shutdown being waited on.
1246 message.acknowledge()?;
1247 match code {
1248 Ok(crate::UIMessageCode::ShutDownComplete) => return Ok(true),
1249 Ok(crate::UIMessageCode::ShutDownCanceled) => return Ok(false),
1250 _ => {}
1251 }
1252 }
1253 }
1254 Ok(false)
1255 }
1256
1257 /// The station's templates file (`Engine.GetTemplatesFile`).
1258 ///
1259 /// Holds the variable, step and sequence prototypes the editor offers when
1260 /// inserting. It is a station-wide file, so it is empty until someone adds
1261 /// templates to it, an empty one is the normal state, not a failure.
1262 ///
1263 /// A template is an ordinary [`PropertyObject`](crate::PropertyObject), not
1264 /// a type of its own, so a program is free to keep its own prototypes in a
1265 /// container it builds itself rather than in this file.
1266 ///
1267 /// # Errors
1268 /// [`Error`] if the COM call fails or returns an unexpected type.
1269 pub fn get_templates_file(
1270 &self,
1271 options: crate::GetTemplatesFileOptions,
1272 ) -> Result<crate::property::PropertyObjectFile, Error> {
1273 Ok(crate::property::PropertyObjectFile::new(
1274 self.dispatch
1275 .call(dispid::GET_TEMPLATES_FILE, &[Value::I32(options.bits())])?
1276 .into_object()?,
1277 ))
1278 }
1279
1280 /// Opens a sequence file, or returns the already-loaded one
1281 /// (`Engine.GetSequenceFileEx`).
1282 ///
1283 /// The engine caches the file and counts load references, so every
1284 /// successful call must be paired with
1285 /// [`release_sequence_file_ex`](Self::release_sequence_file_ex).
1286 ///
1287 /// Both option arguments matter on an unattended host:
1288 /// [`crate::sequence::GetSeqFileOptions::DO_NOT_RUN_LOAD_CALLBACK`] suppresses a load
1289 /// callback that could raise a dialog, and [`crate::sequence::ConflictHandler::Error`]
1290 /// fails the load instead of prompting.
1291 ///
1292 /// # Errors
1293 /// [`Error`] if the file cannot be opened or the COM call fails.
1294 pub fn get_sequence_file_ex(
1295 &self,
1296 path: &str,
1297 options: crate::sequence::GetSeqFileOptions,
1298 handler: crate::sequence::ConflictHandler,
1299 ) -> Result<crate::sequence::SequenceFile, Error> {
1300 let dispatch = self
1301 .dispatch
1302 .call(
1303 dispid::GET_SEQUENCE_FILE_EX,
1304 &[
1305 Value::Str(path.to_owned()),
1306 Value::I32(options.bits()),
1307 Value::I32(handler.bits()),
1308 ],
1309 )?
1310 .into_object()?;
1311 Ok(crate::sequence::SequenceFile::new(dispatch))
1312 }
1313
1314 /// Drops one load reference on a sequence file
1315 /// (`Engine.ReleaseSequenceFileEx`).
1316 ///
1317 /// Returns `true` when that was the last reference and the engine has
1318 /// discarded the file. `false` means something else still holds it open,
1319 /// so the file stays loaded, which is why only the `true` case also
1320 /// releases the wrapper's own COM reference.
1321 ///
1322 /// # Errors
1323 /// [`Error`] if the COM call fails.
1324 pub fn release_sequence_file_ex(
1325 &self,
1326 sequence_file: crate::sequence::SequenceFile,
1327 options: i32,
1328 ) -> Result<bool, Error> {
1329 let released = self
1330 .dispatch
1331 .call(
1332 dispid::RELEASE_SEQUENCE_FILE_EX,
1333 &[
1334 Value::Object(sequence_file.into_dispatch()),
1335 Value::I32(options),
1336 ],
1337 )?
1338 .as_bool()?;
1339 Ok(released)
1340 }
1341
1342 /// Accesses the collection of search directories (`Engine.SearchDirectories`).
1343 ///
1344 /// # Errors
1345 /// [`Error`] if the COM call fails or returns an unexpected type.
1346 pub fn search_directories(&self) -> Result<crate::station::SearchDirectories, Error> {
1347 let dispatch = self
1348 .dispatch
1349 .get(dispid::SEARCH_DIRECTORIES)?
1350 .into_object()?;
1351 Ok(crate::station::SearchDirectories::new(dispatch))
1352 }
1353
1354 /// Accesses the station global variables container (`Engine.Globals`).
1355 ///
1356 /// # Errors
1357 /// [`Error`] if the COM call fails or returns an unexpected type.
1358 pub fn globals(&self) -> Result<crate::property::PropertyObject, Error> {
1359 let dispatch = self.dispatch.get(dispid::GLOBALS)?.into_object()?;
1360 Ok(crate::property::PropertyObject::new(dispatch))
1361 }
1362
1363 /// Creates a new workspace file object (`Engine.NewWorkspaceFile`).
1364 ///
1365 /// # Errors
1366 /// [`Error`] if the COM call fails or returns an unexpected type.
1367 pub fn new_workspace_file(&self) -> Result<crate::workspace::WorkspaceFile, Error> {
1368 let dispatch = self
1369 .dispatch
1370 .call(dispid::NEW_WORKSPACE_FILE, &[])?
1371 .into_object()?;
1372 Ok(crate::workspace::WorkspaceFile::new(dispatch))
1373 }
1374
1375 /// Opens an existing workspace file (`Engine.OpenWorkspaceFile`).
1376 ///
1377 /// # Errors
1378 /// [`Error`] if the COM call fails or returns an unexpected type.
1379 pub fn open_workspace_file(
1380 &self,
1381 path: &str,
1382 read_only: bool,
1383 options: i32,
1384 ) -> Result<crate::workspace::WorkspaceFile, Error> {
1385 let dispatch = self
1386 .dispatch
1387 .call(
1388 dispid::OPEN_WORKSPACE_FILE,
1389 &[
1390 Value::Str(path.to_string()),
1391 Value::Bool(read_only),
1392 Value::I32(options),
1393 ],
1394 )?
1395 .into_object()?;
1396 Ok(crate::workspace::WorkspaceFile::new(dispatch))
1397 }
1398
1399 /// Flushes modified station globals and configuration to disk (`Engine.CommitGlobalsToDisk`).
1400 ///
1401 /// # Errors
1402 /// [`Error`] if the COM call fails.
1403 pub fn commit_globals_to_disk(&self, prompt_on_save_conflicts: bool) -> Result<(), Error> {
1404 self.dispatch.call(
1405 dispid::COMMIT_GLOBALS_TO_DISK,
1406 &[Value::Bool(prompt_on_save_conflicts)],
1407 )?;
1408 Ok(())
1409 }
1410
1411 /// Builds an engine over a caller-supplied dispatch handle. Test-only seam
1412 /// for exercising wrapper logic against a fake, with no live COM.
1413 #[cfg(test)]
1414 pub(crate) fn from_dispatch(dispatch: Box<dyn Dispatch>) -> Self {
1415 Self {
1416 dispatch,
1417 // Nothing was created, so nothing could have asked anything.
1418 startup_dialogs: Vec::new(),
1419 }
1420 }
1421}
1422
1423/// Folds the engine's answer to an expression check into one value.
1424///
1425/// Both checks report the same way: `True` and nothing more when the expression
1426/// is fine, `False` plus a description and a span when it is not.
1427fn expression_check_result(
1428 valid: &Value,
1429 written: Vec<Value>,
1430) -> Result<Option<ExpressionError>, Error> {
1431 if valid.as_bool()? {
1432 return Ok(None);
1433 }
1434 let mut written = written.into_iter();
1435 let description = written
1436 .next()
1437 .ok_or(Error::UnexpectedType {
1438 expected: "an error description",
1439 actual: "nothing",
1440 })?
1441 .into_string()?;
1442 let mut position = || -> Result<i32, Error> {
1443 Ok(written
1444 .next()
1445 .ok_or(Error::UnexpectedType {
1446 expected: "an error position",
1447 actual: "nothing",
1448 })?
1449 .as_i32()?)
1450 };
1451 let start = position()?;
1452 let end = position()?;
1453 Ok(Some(ExpressionError::new(description, start, end)))
1454}
1455
1456#[cfg(test)]
1457mod tests {
1458 use std::collections::HashMap;
1459
1460 use rs_teststand_sys::{ComError, Value};
1461
1462 use super::{Engine, dispid};
1463 use crate::error::Error;
1464
1465 /// A scripted response for one dispatch id, so the fake needs no COM and no
1466 /// `Clone` on `Value`.
1467 #[derive(Debug, Clone)]
1468 enum Scripted {
1469 Bool(bool),
1470 I32(i32),
1471 Str(&'static str),
1472 Fail(i32),
1473 }
1474
1475 #[derive(Debug)]
1476 struct FakeDispatch {
1477 responses: HashMap<i32, Scripted>,
1478 /// Every `put` and `call` in order, so a test can assert what a wrapper
1479 /// sent rather than only what it read back.
1480 written: Written,
1481 }
1482
1483 /// Shared with the test, because `Engine` takes the dispatch by value.
1484 type Written = std::rc::Rc<core::cell::RefCell<Vec<(i32, Value)>>>;
1485
1486 impl FakeDispatch {
1487 fn new(entries: impl IntoIterator<Item = (i32, Scripted)>, written: Written) -> Self {
1488 Self {
1489 responses: entries.into_iter().collect(),
1490 written,
1491 }
1492 }
1493 }
1494
1495 impl rs_teststand_sys::Dispatch for FakeDispatch {
1496 fn get(&self, dispid: i32) -> Result<Value, ComError> {
1497 match self.responses.get(&dispid) {
1498 Some(Scripted::Bool(value)) => Ok(Value::Bool(*value)),
1499 Some(Scripted::I32(value)) => Ok(Value::I32(*value)),
1500 Some(Scripted::Str(value)) => Ok(Value::Str((*value).to_owned())),
1501 Some(Scripted::Fail(code)) => Err(ComError::hresult(*code, "fake")),
1502 None => Err(ComError::hresult(0, "fake: unscripted dispid")),
1503 }
1504 }
1505
1506 fn put(&self, dispid: i32, value: Value) -> Result<(), ComError> {
1507 self.written.borrow_mut().push((dispid, value));
1508 Ok(())
1509 }
1510
1511 fn call(&self, dispid: i32, args: &[Value]) -> Result<Value, ComError> {
1512 let first = match args.first() {
1513 Some(Value::I32(value)) => Value::I32(*value),
1514 _ => Value::Empty,
1515 };
1516 self.written.borrow_mut().push((dispid, first));
1517 Ok(Value::Empty)
1518 }
1519 }
1520
1521 fn engine_with(entries: impl IntoIterator<Item = (i32, Scripted)>) -> Engine {
1522 engine_recording(entries).0
1523 }
1524
1525 /// An engine plus the log of everything it writes.
1526 fn engine_recording(entries: impl IntoIterator<Item = (i32, Scripted)>) -> (Engine, Written) {
1527 let written: Written = std::rc::Rc::default();
1528 let dispatch = FakeDispatch::new(entries, std::rc::Rc::clone(&written));
1529 (Engine::from_dispatch(Box::new(dispatch)), written)
1530 }
1531
1532 /// What a wrapper sent, reduced to the shapes these members use.
1533 ///
1534 /// `Value` carries COM payloads that have no meaningful equality, so it does
1535 /// not implement `PartialEq`. Comparing the handful of scalar cases here is
1536 /// enough and keeps that out of the public type.
1537 #[derive(Debug, PartialEq, Eq)]
1538 enum Sent {
1539 Empty,
1540 Bool(bool),
1541 I32(i32),
1542 Other,
1543 }
1544
1545 impl From<&Value> for Sent {
1546 fn from(value: &Value) -> Self {
1547 match *value {
1548 Value::Empty => Self::Empty,
1549 Value::Bool(flag) => Self::Bool(flag),
1550 Value::I32(number) => Self::I32(number),
1551 _ => Self::Other,
1552 }
1553 }
1554 }
1555
1556 /// Whether the log holds exactly this one entry.
1557 fn wrote(written: &Written, dispid: i32, expected: &Sent) -> bool {
1558 let log = written.borrow();
1559 matches!(log.as_slice(), [(id, sent)] if *id == dispid && Sent::from(sent) == *expected)
1560 }
1561
1562 #[test]
1563 fn major_version_reads_i4_property() -> Result<(), Error> {
1564 let engine = engine_with([(dispid::MAJOR_VERSION, Scripted::I32(26))]);
1565 assert_eq!(engine.major_version()?, 26);
1566 Ok(())
1567 }
1568
1569 #[test]
1570 fn get_execution_asks_by_id_and_reports_a_stale_id_as_absent() -> Result<(), Error> {
1571 let (engine, written) = engine_recording([]);
1572 // An id the engine no longer knows is a normal outcome for a host that
1573 // kept an id across the execution ending, not a failure.
1574 assert!(engine.get_execution(7)?.is_none());
1575 assert!(
1576 wrote(&written, dispid::GET_EXECUTION, &Sent::I32(7)),
1577 "expected the execution id to be sent, got {:?}",
1578 written.borrow(),
1579 );
1580 Ok(())
1581 }
1582
1583 #[test]
1584 fn version_string_reads_bstr_property() -> Result<(), Error> {
1585 let engine = engine_with([(dispid::VERSION_STRING, Scripted::Str("26.0.0.123"))]);
1586 assert_eq!(engine.version_string()?, "26.0.0.123");
1587 Ok(())
1588 }
1589
1590 #[test]
1591 fn is_64bit_reads_bool_property() -> Result<(), Error> {
1592 let engine = engine_with([(dispid::IS_64BIT, Scripted::Bool(true))]);
1593 assert!(engine.is_64bit()?);
1594 Ok(())
1595 }
1596
1597 #[test]
1598 fn directories_read_bstr_properties() -> Result<(), Error> {
1599 let engine = engine_with([
1600 (dispid::TESTSTAND_DIRECTORY, Scripted::Str("T:\\TestStand")),
1601 (dispid::BIN_DIRECTORY, Scripted::Str("T:\\TestStand\\Bin")),
1602 (
1603 dispid::CONFIG_DIRECTORY,
1604 Scripted::Str("T:\\TestStand\\Cfg"),
1605 ),
1606 ]);
1607 assert_eq!(engine.teststand_directory()?, "T:\\TestStand");
1608 assert_eq!(engine.bin_directory()?, "T:\\TestStand\\Bin");
1609 assert_eq!(engine.config_directory()?, "T:\\TestStand\\Cfg");
1610 Ok(())
1611 }
1612
1613 #[test]
1614 fn unload_all_modules_calls_the_method_with_no_arguments() -> Result<(), Error> {
1615 let (engine, written) = engine_recording([]);
1616 engine.unload_all_modules()?;
1617 assert!(
1618 wrote(&written, dispid::UNLOAD_ALL_MODULES, &Sent::Empty),
1619 "expected one argument-free call, got {written:?}",
1620 );
1621 Ok(())
1622 }
1623
1624 #[test]
1625 fn breakpoints_enabled_round_trips() -> Result<(), Error> {
1626 let engine = engine_with([(dispid::BREAKPOINTS_ENABLED, Scripted::Bool(true))]);
1627 assert!(engine.breakpoints_enabled()?);
1628
1629 let (engine, written) = engine_recording([]);
1630 engine.set_breakpoints_enabled(false)?;
1631 assert!(
1632 wrote(&written, dispid::BREAKPOINTS_ENABLED, &Sent::Bool(false)),
1633 "expected the flag to be written as a bool, got {written:?}",
1634 );
1635 Ok(())
1636 }
1637
1638 #[test]
1639 fn persist_breakpoints_round_trips() -> Result<(), Error> {
1640 let engine = engine_with([(dispid::PERSIST_BREAKPOINTS, Scripted::Bool(false))]);
1641 assert!(!engine.persist_breakpoints()?);
1642
1643 let (engine, written) = engine_recording([]);
1644 engine.set_persist_breakpoints(true)?;
1645 assert!(
1646 wrote(&written, dispid::PERSIST_BREAKPOINTS, &Sent::Bool(true)),
1647 "expected the flag to be written as a bool, got {written:?}",
1648 );
1649 Ok(())
1650 }
1651
1652 #[test]
1653 fn dot_net_collection_passes_the_reserved_argument() -> Result<(), Error> {
1654 // The engine declares the parameter optional with a zero default. Send
1655 // it explicitly so the call stays correct if the default is dropped.
1656 let (engine, written) = engine_recording([]);
1657 engine.do_dot_net_garbage_collection()?;
1658 assert!(
1659 wrote(
1660 &written,
1661 dispid::DO_DOT_NET_GARBAGE_COLLECTION,
1662 &Sent::I32(0)
1663 ),
1664 "expected the reserved argument to be sent as zero, got {written:?}",
1665 );
1666 Ok(())
1667 }
1668
1669 #[test]
1670 fn dot_net_collection_interval_round_trips() -> Result<(), Error> {
1671 let engine = engine_with([(
1672 dispid::DOT_NET_GARBAGE_COLLECTION_INTERVAL,
1673 Scripted::I32(30_000),
1674 )]);
1675 assert_eq!(engine.dot_net_garbage_collection_interval()?, 30_000);
1676
1677 let (engine, written) = engine_recording([]);
1678 engine.set_dot_net_garbage_collection_interval(5_000)?;
1679 assert!(
1680 wrote(
1681 &written,
1682 dispid::DOT_NET_GARBAGE_COLLECTION_INTERVAL,
1683 &Sent::I32(5_000)
1684 ),
1685 "expected the interval to be written as an i4, got {written:?}",
1686 );
1687 Ok(())
1688 }
1689
1690 #[test]
1691 fn dot_net_clr_version_is_empty_when_the_runtime_is_not_loaded() -> Result<(), Error> {
1692 // Documented behavior: empty means "not loaded", not "failed".
1693 let engine = engine_with([(dispid::DOT_NET_CLR_VERSION, Scripted::Str(""))]);
1694 assert_eq!(engine.dot_net_clr_version()?, "");
1695 Ok(())
1696 }
1697
1698 #[test]
1699 fn com_failure_propagates_as_typed_error() {
1700 // 0x8004_2001 stands in for an engine HRESULT; the exact code must survive.
1701 let engine = engine_with([(dispid::MAJOR_VERSION, Scripted::Fail(-2_147_209_215))]);
1702 let result = engine.major_version();
1703 assert!(
1704 matches!(result, Err(Error::Com { hresult, .. }) if hresult == -2_147_209_215),
1705 "expected Com error carrying the HRESULT, got {result:?}",
1706 );
1707 }
1708
1709 #[test]
1710 fn wrong_variant_type_is_reported_not_coerced() {
1711 // Property answers with a string where the wrapper wants an i32.
1712 let engine = engine_with([(dispid::MAJOR_VERSION, Scripted::Str("not a number"))]);
1713 let result = engine.major_version();
1714 assert!(
1715 matches!(
1716 result,
1717 Err(Error::UnexpectedType {
1718 expected: "I32",
1719 ..
1720 })
1721 ),
1722 "expected a type-mismatch error, got {result:?}",
1723 );
1724 }
1725}