Skip to main content

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}