main
Drew Owens 0.11.0 source 1b7115e 6d ago
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663
//! Late-bound COM plumbing: VARIANTs and IDispatch calls, Windows only.
//!
//! SOLIDWORKS ships a type library, but a bridge that early-binds it needs the
//! interop assemblies of the exact installed version. Late binding
//! (`GetIDsOfNames` + `Invoke`) is what the PowerShell sidecar did and what
//! every SOLIDWORKS macro does — it works against whatever version is on the
//! machine, which is the only thing a bridge can promise.
//!
//! Everything here is `unsafe` at the edges and small on purpose: a [`Var`]
//! owns its VARIANT and clears it on drop; [`Disp`] turns a name plus arguments
//! into an `Invoke`, and turns an HRESULT plus EXCEPINFO into an error message
//! a human can act on.

use std::ffi::c_void;
use windows::core::{IUnknown, Interface, BSTR, GUID, PCWSTR};
use windows::Win32::System::Com::{
    IDispatch, DISPATCH_FLAGS, DISPATCH_METHOD, DISPATCH_PROPERTYGET, DISPATCH_PROPERTYPUT,
    DISPPARAMS, EXCEPINFO, SAFEARRAY,
};
use windows::Win32::System::Ole::{
    SafeArrayCreateVector, SafeArrayGetElement, SafeArrayGetLBound, SafeArrayGetUBound,
    SafeArrayGetVartype, SafeArrayPutElement, DISPID_PROPERTYPUT,
};
use windows::Win32::System::Variant::{
    VariantChangeType, VariantClear, VariantCopy, VARENUM, VARIANT, VT_ARRAY, VT_BOOL, VT_BSTR,
    VT_BYREF, VT_DISPATCH, VT_EMPTY, VT_I2, VT_I4, VT_NULL, VT_R8, VT_UI1, VT_UNKNOWN, VT_VARIANT,
};

use super::host::HostError;

/// Method-or-property-get: let the callee decide which it is. This is what
/// VBScript and the SOLIDWORKS macro recorder do, and it saves the bridge from
/// having to know whether `ActiveDoc` is a property and `GetTitle` a method.
pub const CALL: DISPATCH_FLAGS = DISPATCH_FLAGS(DISPATCH_METHOD.0 | DISPATCH_PROPERTYGET.0);

/// An owned VARIANT. Clears itself, so a BSTR or interface pointer coming back
/// from SOLIDWORKS is released exactly once.
pub struct Var(pub VARIANT);

impl Drop for Var {
    fn drop(&mut self) {
        unsafe {
            let _ = VariantClear(&mut self.0);
        }
    }
}

unsafe fn build(vt: VARENUM, fill: impl FnOnce(&mut VARIANT)) -> Var {
    let mut v = VARIANT::default();
    (*v.Anonymous.Anonymous).vt = vt;
    fill(&mut v);
    Var(v)
}

impl Var {
    pub fn empty() -> Var {
        unsafe { build(VT_EMPTY, |_| {}) }
    }

    pub fn i4(n: i32) -> Var {
        unsafe { build(VT_I4, |v| (*v.Anonymous.Anonymous).Anonymous.lVal = n) }
    }

    /// A `short` (VT_I2) — ISketchManager::CreateArc's Direction is one.
    pub fn i2(n: i16) -> Var {
        unsafe { build(VT_I2, |v| (*v.Anonymous.Anonymous).Anonymous.iVal = n) }
    }

    pub fn r8(n: f64) -> Var {
        unsafe { build(VT_R8, |v| (*v.Anonymous.Anonymous).Anonymous.dblVal = n) }
    }

    pub fn boolean(b: bool) -> Var {
        unsafe {
            build(VT_BOOL, |v| {
                // VARIANT_BOOL: -1 is TRUE, 0 is FALSE. Not 1.
                (*v.Anonymous.Anonymous).Anonymous.boolVal =
                    windows::Win32::Foundation::VARIANT_BOOL(if b { -1 } else { 0 });
            })
        }
    }

    pub fn bstr(s: &str) -> Var {
        unsafe {
            build(VT_BSTR, |v| {
                (*v.Anonymous.Anonymous).Anonymous.bstrVal =
                    std::mem::ManuallyDrop::new(BSTR::from(s));
            })
        }
    }

    /// **"NOTHING", for an `[in]` OBJECT PARAMETER.**
    ///
    /// A `VT_DISPATCH` variant whose `pdispVal` is NULL — which is what a
    /// late-bound caller passes where VBA writes `Nothing`. It is NOT the same as
    /// `VT_EMPTY`, and this bridge believed it was from v0.2.0 until 2026-09-17.
    ///
    /// MEASURED THAT DAY, on the live install: every STEP import failed, and with
    /// the structured COM failure finally reaching the caller (0.7.8) it read
    ///
    /// ```text
    /// hostError.loadFile4.detail = "Type mismatch. (0x80020005) (argument index 1)"
    /// ```
    ///
    /// `rgvarg` is packed REVERSED (see `call_raw`), so `puArgErr = 1` is the
    /// THIRD parameter of `LoadFile4(path, "r", importData, byref errors)` —
    /// `importData` — which was being passed as `VT_EMPTY`. SOLIDWORKS wants an
    /// object there and will take a null one; it will not take "no value at all".
    ///
    /// The consequence worth recording: **`LoadFile4` had probably never worked
    /// live.** The imports of 09-05 to 09-11 went through the `LoadFile2`
    /// fallback, and the reason that route looked like the normal one was that
    /// nothing ever showed the caller why the first had failed.
    pub fn null_dispatch() -> Var {
        unsafe {
            build(VT_DISPATCH, |v| {
                (*v.Anonymous.Anonymous).Anonymous.pdispVal = std::mem::ManuallyDrop::new(None);
            })
        }
    }

    pub fn dispatch(d: &IDispatch) -> Var {
        unsafe {
            build(VT_DISPATCH, |v| {
                (*v.Anonymous.Anonymous).Anonymous.pdispVal =
                    std::mem::ManuallyDrop::new(Some(d.clone()));
            })
        }
    }

    /// A SAFEARRAY of doubles — what `IMathUtility::CreateTransform` wants, and
    /// the only array this bridge ever BUILDS (as opposed to reads). Sixteen
    /// doubles is a component transform; nothing else here needs one.
    pub fn r8_array(values: &[f64]) -> Var {
        unsafe {
            let psa = SafeArrayCreateVector(VT_R8, 0, values.len() as u32);
            if psa.is_null() {
                return Var::empty();
            }
            for (i, v) in values.iter().enumerate() {
                let idx = [i as i32];
                let _ = SafeArrayPutElement(
                    psa,
                    idx.as_ptr(),
                    v as *const f64 as *const std::ffi::c_void,
                );
            }
            build(VARENUM(VT_ARRAY.0 | VT_R8.0), |v| {
                (*v.Anonymous.Anonymous).Anonymous.parray = psa;
            })
        }
    }

    /// A SAFEARRAY of VARIANTs, each holding one object — the shape a VBA
    /// `Array(body)` has, and what `IMassProperty::AddBodies(BodyListIn)` takes.
    pub fn dispatch_array(items: &[IDispatch]) -> Var {
        unsafe {
            let psa = SafeArrayCreateVector(VT_VARIANT, 0, items.len() as u32);
            if psa.is_null() {
                return Var::empty();
            }
            for (i, d) in items.iter().enumerate() {
                let one = Var::dispatch(d);
                let idx = [i as i32];
                // SafeArrayPutElement COPIES a VARIANT element, so `one` is
                // released on drop without double-freeing the array's copy.
                let _ = SafeArrayPutElement(psa, idx.as_ptr(), &one.0 as *const VARIANT as *const std::ffi::c_void);
            }
            build(VARENUM(VT_ARRAY.0 | VT_VARIANT.0), |v| {
                (*v.Anonymous.Anonymous).Anonymous.parray = psa;
            })
        }
    }

    /// A TYPED object array: SAFEARRAY of VT_DISPATCH (VT_ARRAY|VT_DISPATCH),
    /// the shape .NET's `object[] Objects` (IModelDocExtension::MultiSelect2)
    /// marshals as. The VARIANT-wrapped form above was MEASURED on SOLIDWORKS
    /// 2026 to select nothing (0.10.3).
    pub fn dispatch_typed_array(items: &[IDispatch]) -> Var {
        unsafe {
            let psa = SafeArrayCreateVector(VT_DISPATCH, 0, items.len() as u32);
            if psa.is_null() {
                return Var::empty();
            }
            for (i, d) in items.iter().enumerate() {
                let idx = [i as i32];
                // For VT_DISPATCH the element IS the interface pointer (no extra
                // indirection); SafeArrayPutElement AddRefs it.
                let _ = SafeArrayPutElement(psa, idx.as_ptr(), d.as_raw() as *const std::ffi::c_void);
            }
            build(VARENUM(VT_ARRAY.0 | VT_DISPATCH.0), |v| {
                (*v.Anonymous.Anonymous).Anonymous.parray = psa;
            })
        }
    }

    /// `[ref] i32` — the byref out-parameter shape SOLIDWORKS uses everywhere
    /// (OpenDoc6 errors/warnings, Save3, ...). The i32 must outlive the call.
    pub fn byref_i4(p: *mut i32) -> Var {
        unsafe {
            build(VARENUM(VT_I4.0 | VT_BYREF.0), |v| {
                (*v.Anonymous.Anonymous).Anonymous.plVal = p
            })
        }
    }

    /// `[ref] BSTR` — CustomPropertyManager::Get4's two out-parameters.
    pub fn byref_bstr(p: *mut BSTR) -> Var {
        unsafe {
            build(VARENUM(VT_BSTR.0 | VT_BYREF.0), |v| {
                (*v.Anonymous.Anonymous).Anonymous.pbstrVal = p
            })
        }
    }

    /// `[ref] VARIANT` — Pack and Go's GetDocumentSaveToNames.
    pub fn byref_variant(p: *mut VARIANT) -> Var {
        unsafe {
            build(VARENUM(VT_VARIANT.0 | VT_BYREF.0), |v| {
                (*v.Anonymous.Anonymous).Anonymous.pvarVal = p
            })
        }
    }

    pub fn vt(&self) -> u16 {
        unsafe { self.0.Anonymous.Anonymous.vt.0 }
    }

    pub fn is_null(&self) -> bool {
        let vt = self.vt();
        if vt == VT_EMPTY.0 || vt == VT_NULL.0 {
            return true;
        }
        if vt == VT_DISPATCH.0 {
            return self.as_dispatch().is_none();
        }
        false
    }

    pub fn as_i32(&self) -> Option<i32> {
        unsafe { coerce(&self.0, VT_I4).map(|v| v.0.Anonymous.Anonymous.Anonymous.lVal) }
    }

    pub fn as_f64(&self) -> Option<f64> {
        unsafe { coerce(&self.0, VT_R8).map(|v| v.0.Anonymous.Anonymous.Anonymous.dblVal) }
    }

    pub fn as_bool(&self) -> Option<bool> {
        unsafe { coerce(&self.0, VT_BOOL).map(|v| v.0.Anonymous.Anonymous.Anonymous.boolVal.0 != 0) }
    }

    /// Anything printable, coerced to a string (None for EMPTY/NULL).
    pub fn as_string(&self) -> Option<String> {
        if self.is_null() {
            return None;
        }
        unsafe {
            coerce(&self.0, VT_BSTR)
                .map(|v| v.0.Anonymous.Anonymous.Anonymous.bstrVal.to_string())
        }
    }

    pub fn as_dispatch(&self) -> Option<IDispatch> {
        unsafe {
            let inner = &self.0.Anonymous.Anonymous;
            if inner.vt.0 == VT_DISPATCH.0 {
                (*inner.Anonymous.pdispVal).clone()
            } else if inner.vt.0 == (VT_DISPATCH.0 | VT_BYREF.0) {
                let p = inner.Anonymous.ppdispVal;
                if p.is_null() {
                    None
                } else {
                    (*p).clone()
                }
            } else {
                None
            }
        }
    }

    /// An owned deep copy (VariantCopy → new refs / new BSTR), so the result and
    /// `self` can both drop without a double-free. Used to lift a scalar into a
    /// one-element array.
    pub fn copy(&self) -> Var {
        unsafe {
            let mut dest = VARIANT::default();
            if VariantCopy(&mut dest, &self.0).is_err() {
                return Var::empty();
            }
            Var(dest)
        }
    }

    /// The elements of a SAFEARRAY-carrying VARIANT, each boxed as a `Var`.
    /// Handles the element types SOLIDWORKS returns: VARIANT (the usual),
    /// DISPATCH/UNKNOWN (component lists), R8 (center of mass, boxes), BSTR, I4.
    ///
    /// A NON-array scalar becomes a one-element vec — matching the PowerShell
    /// `@(...)` coercion the sidecar relied on, so a single open document, a
    /// single interference, or a lone configuration name is reported as a
    /// one-item list, never as `[]`.
    pub fn as_array(&self) -> Vec<Var> {
        unsafe {
            let inner = &self.0.Anonymous.Anonymous;
            if inner.vt.0 & VT_ARRAY.0 == 0 {
                return if self.is_null() { Vec::new() } else { vec![self.copy()] };
            }
            let psa: *mut SAFEARRAY = if inner.vt.0 & VT_BYREF.0 != 0 {
                let pp = inner.Anonymous.pparray;
                if pp.is_null() {
                    return Vec::new();
                }
                *pp
            } else {
                inner.Anonymous.parray
            };
            if psa.is_null() {
                return Vec::new();
            }
            let (lb, ub) = match (SafeArrayGetLBound(psa, 1), SafeArrayGetUBound(psa, 1)) {
                (Ok(l), Ok(u)) => (l, u),
                _ => return Vec::new(),
            };
            let elem_vt = SafeArrayGetVartype(psa).map(|v| v.0).unwrap_or(VT_VARIANT.0);
            let mut out = Vec::new();
            for i in lb..=ub {
                let idx = [i];
                if elem_vt == VT_VARIANT.0 {
                    let mut v = VARIANT::default();
                    if SafeArrayGetElement(psa, idx.as_ptr(), &mut v as *mut _ as *mut c_void)
                        .is_ok()
                    {
                        out.push(Var(v));
                    }
                } else if elem_vt == VT_DISPATCH.0 || elem_vt == VT_UNKNOWN.0 {
                    let mut p: *mut c_void = std::ptr::null_mut();
                    if SafeArrayGetElement(psa, idx.as_ptr(), &mut p as *mut _ as *mut c_void)
                        .is_ok()
                        && !p.is_null()
                    {
                        // SafeArrayGetElement AddRef'd for us. Query for
                        // IDispatch (SOLIDWORKS objects are all dispatch); `unk`
                        // takes the ref SafeArrayGetElement added and releases it,
                        // Var::dispatch adds its own.
                        let unk = IUnknown::from_raw(p);
                        if let Ok(d) = unk.cast::<IDispatch>() {
                            out.push(Var::dispatch(&d));
                        }
                    }
                } else if elem_vt == VT_R8.0 {
                    let mut d = 0f64;
                    if SafeArrayGetElement(psa, idx.as_ptr(), &mut d as *mut _ as *mut c_void)
                        .is_ok()
                    {
                        out.push(Var::r8(d));
                    }
                } else if elem_vt == VT_BSTR.0 {
                    let mut b = BSTR::new();
                    if SafeArrayGetElement(psa, idx.as_ptr(), &mut b as *mut _ as *mut c_void)
                        .is_ok()
                    {
                        out.push(Var::bstr(&b.to_string()));
                    }
                } else if elem_vt == VT_UI1.0 {
                    // A byte array: IModelDocExtension::GetPersistReference3's
                    // persistent id (0.10.2), the de-duplication key.
                    let mut b = 0u8;
                    if SafeArrayGetElement(psa, idx.as_ptr(), &mut b as *mut _ as *mut c_void)
                        .is_ok()
                    {
                        out.push(Var::i4(b as i32));
                    }
                } else if elem_vt == VT_I4.0 {
                    let mut n = 0i32;
                    if SafeArrayGetElement(psa, idx.as_ptr(), &mut n as *mut _ as *mut c_void)
                        .is_ok()
                    {
                        out.push(Var::i4(n));
                    }
                }
            }
            out
        }
    }

    /// Best-effort JSON for a value we do not know the shape of (the escape
    /// hatch, and anything SOLIDWORKS hands back loosely typed).
    pub fn to_json(&self) -> serde_json::Value {
        use serde_json::Value;
        let vt = self.vt();
        if self.is_null() {
            return Value::Null;
        }
        if vt & VT_ARRAY.0 != 0 {
            return Value::Array(self.as_array().iter().map(|v| v.to_json()).collect());
        }
        let base = vt & !VT_BYREF.0;
        if base == VT_BOOL.0 {
            return self.as_bool().map(Value::from).unwrap_or(Value::Null);
        }
        if base == VT_R8.0 {
            return self.as_f64().map(Value::from).unwrap_or(Value::Null);
        }
        if base == VT_I4.0 {
            return self.as_i32().map(Value::from).unwrap_or(Value::Null);
        }
        if base == VT_DISPATCH.0 {
            return Value::from("<COM object>");
        }
        self.as_string().map(Value::from).unwrap_or(Value::Null)
    }
}

/// A copy of `src` as type `to`, owned (and freed) by the returned Var.
/// VariantChangeType handles the same-type case as a plain copy, so there is no
/// special case to get wrong.
unsafe fn coerce(src: &VARIANT, to: VARENUM) -> Option<Var> {
    let mut dest = VARIANT::default();
    VariantChangeType(&mut dest, src, Default::default(), to).ok().map(|_| Var(dest))
}

/// A live IDispatch we can call by name.
#[derive(Clone)]
pub struct Disp(pub IDispatch);

impl Disp {
    /// DISPID for a member name. A missing name is the single most useful COM
    /// error to report well: it usually means the SOLIDWORKS version does not
    /// have that API, not that the bridge is broken.
    pub fn dispid(&self, name: &str) -> Result<i32, HostError> {
        let wide: Vec<u16> = name.encode_utf16().chain(std::iter::once(0)).collect();
        let names = [PCWSTR(wide.as_ptr())];
        let mut ids = [0i32];
        unsafe {
            self.0
                .GetIDsOfNames(&GUID::zeroed(), names.as_ptr(), 1, 0, ids.as_mut_ptr())
                .map_err(|e| {
                    HostError::detailed(
                        format!("SOLIDWORKS has no member named '{name}'"),
                        format!("{e}"),
                    )
                })?;
        }
        Ok(ids[0])
    }

    /// QueryInterface for another DUAL interface of the same object (e.g. an
    /// IFace2's IEntity), so late binding resolves THAT interface's names.
    /// The caller must know `iid` is dual — a vtable-only interface called
    /// through IDispatch slots would be a wild call.
    pub fn query_dual(&self, iid: &GUID) -> Option<Disp> {
        let mut p: *mut c_void = std::ptr::null_mut();
        unsafe {
            if self.0.query(iid, &mut p).is_err() || p.is_null() {
                return None;
            }
            Some(Disp(IDispatch::from_raw(p)))
        }
    }

    /// SOLIDWORKS is a single-threaded-apartment server with no message filter
    /// of its own: while it is mid-rebuild or pumping its UI it REJECTS incoming
    /// calls rather than queueing them. Retrying is the documented answer.
    pub const RPC_E_CALL_REJECTED: i32 = 0x8001_0001u32 as i32;
    pub const RPC_E_SERVERCALL_RETRYLATER: i32 = 0x8001_010Au32 as i32;

    /// `invoke`, with a backoff for a BUSY SOLIDWORKS.
    ///
    /// Without this a rejected call surfaced as a plain failure and, worse, as a
    /// WRONG ANSWER: `GetDocuments` handed back an empty/null VARIANT (not an
    /// error), `as_array()` turned that into an empty Vec, and `swx_docs`
    /// answered `success:true` with zero documents while 51 were open. Every
    /// title resolution and ownership check built on that read then concluded
    /// "no open document is titled 'Assem24'" or "this bridge did not create it"
    /// for a document made seconds earlier.
    pub fn invoke(&self, name: &str, flags: DISPATCH_FLAGS, args: &[&Var]) -> Result<Var, HostError> {
        let mut delay_ms = 100u64;
        let mut total = 0u64;
        loop {
            match self.invoke_once(name, flags, args) {
                Err(e) if e.code.as_deref() == Some("com_call_rejected") && total < 5_000 => {
                    std::thread::sleep(std::time::Duration::from_millis(delay_ms));
                    total += delay_ms;
                    delay_ms = (delay_ms * 2).min(1_600);
                }
                other => return other,
            }
        }
    }

    /// Invoke `name` with `args` in source order (DISPPARAMS wants them
    /// reversed; that is this function's job, not the caller's).
    pub fn invoke_once(&self, name: &str, flags: DISPATCH_FLAGS, args: &[&Var]) -> Result<Var, HostError> {
        let id = self.dispid(name)?;
        // OWNERSHIP: DISPPARAMS wants an array of [in] VARIANTs, and Invoke does
        // NOT take ownership of them. VARIANT has real drop glue in this crate
        // (it clears via VariantClear), so the array MUST NOT be a second owner
        // of each argument's contents — otherwise the array's drop and the
        // caller's `Var` drop both VariantClear the same BSTR / IDispatch, which
        // is a heap double-free and a premature Release of a live SOLIDWORKS
        // interface. So the array holds ManuallyDrop bitwise copies: the `Var`
        // arguments (borrowed here, still alive) remain the sole owners and are
        // the only things that clear.
        let mut raw: Vec<std::mem::ManuallyDrop<VARIANT>> = args
            .iter()
            .rev()
            .map(|v| std::mem::ManuallyDrop::new(unsafe { std::ptr::read(&v.0) }))
            .collect();
        let mut put_id = DISPID_PROPERTYPUT;
        let params = DISPPARAMS {
            rgvarg: if raw.is_empty() {
                std::ptr::null_mut()
            } else {
                raw.as_mut_ptr() as *mut VARIANT
            },
            rgdispidNamedArgs: if flags == DISPATCH_PROPERTYPUT {
                &mut put_id
            } else {
                std::ptr::null_mut()
            },
            cArgs: raw.len() as u32,
            cNamedArgs: u32::from(flags == DISPATCH_PROPERTYPUT),
        };
        let mut result = VARIANT::default();
        let mut excep = EXCEPINFO::default();
        let mut arg_err = 0u32;
        let outcome = unsafe {
            self.0.Invoke(
                id,
                &GUID::zeroed(),
                0,
                flags,
                &params,
                Some(&mut result),
                Some(&mut excep),
                Some(&mut arg_err),
            )
        };
        // EXCEPINFO's BSTR fields are ManuallyDrop<BSTR> with no Drop of their
        // own; on a failed Invoke they may hold allocated strings, so read and
        // then free them (a null BSTR frees to a no-op, so this is safe always).
        let (source, description) = (excep.bstrSource.to_string(), excep.bstrDescription.to_string());
        unsafe {
            std::mem::ManuallyDrop::drop(&mut excep.bstrSource);
            std::mem::ManuallyDrop::drop(&mut excep.bstrDescription);
            std::mem::ManuallyDrop::drop(&mut excep.bstrHelpFile);
        }
        if let Err(ref e) = outcome {
            let code = e.code().0;
            if code == Self::RPC_E_CALL_REJECTED || code == Self::RPC_E_SERVERCALL_RETRYLATER {
                return Err(HostError {
                    message: format!("SOLIDWORKS was busy and rejected '{name}'"),
                    detail: Some(format!("{e}")),
                    code: Some("com_call_rejected".to_string()),
                    // A REJECTION, not a timeout: SOLIDWORKS answered promptly
                    // that it was busy. Reporting it as a timeout would supply
                    // half of a `host_app_hung` verdict for a message loop that
                    // is running perfectly.
                    timed_out: false,
                    // A rejected call did nothing at all, so it made nothing.
                    created: None,
                    data: Some(serde_json::json!({
                        "hresult": format!("0x{:08X}", code as u32),
                        "call": name,
                    })),
                });
            }
        }
        outcome.map_err(|e| {
            let mut detail = format!("{e}");
            if !source.is_empty() {
                detail.push_str(&format!(" [{source}]"));
            }
            if !description.is_empty() {
                detail.push_str(&format!(": {description}"));
            }
            if arg_err != 0 || !args.is_empty() {
                detail.push_str(&format!(" (argument index {arg_err})"));
            }
            // AND THE SAME FACTS, STRUCTURED. The prose is for a person; the
            // HRESULT is what a person diagnoses a translator from, and it was
            // being flattened into a sentence that callers then truncated.
            HostError::detailed(format!("SOLIDWORKS rejected '{name}'"), detail).with_data(
                serde_json::json!({
                    "call": name,
                    "hresult": format!("0x{:08X}", e.code().0 as u32),
                    "source": if source.is_empty() { serde_json::Value::Null } else { serde_json::json!(source) },
                    "description": if description.is_empty() { serde_json::Value::Null } else { serde_json::json!(description) },
                    "argumentIndex": arg_err,
                }),
            )
        })?;
        Ok(Var(result))
    }

    /// `obj.Name(...)` — method or property get.
    pub fn call(&self, name: &str, args: &[&Var]) -> Result<Var, HostError> {
        self.invoke(name, CALL, args)
    }

    /// `obj.Name` where the result must be another object.
    pub fn obj(&self, name: &str, args: &[&Var]) -> Result<Option<Disp>, HostError> {
        Ok(self.call(name, args)?.as_dispatch().map(Disp))
    }

    /// `obj.Name = value`.
    pub fn put(&self, name: &str, value: &Var) -> Result<(), HostError> {
        self.invoke(name, DISPATCH_PROPERTYPUT, &[value])?;
        Ok(())
    }

    /// A property/method whose failure does not matter (best-effort settings).
    pub fn try_put(&self, name: &str, value: &Var) {
        let _ = self.put(name, value);
    }

    pub fn string(&self, name: &str, args: &[&Var]) -> Option<String> {
        self.call(name, args).ok().and_then(|v| v.as_string())
    }

    pub fn int(&self, name: &str, args: &[&Var]) -> Option<i32> {
        self.call(name, args).ok().and_then(|v| v.as_i32())
    }

    pub fn float(&self, name: &str, args: &[&Var]) -> Option<f64> {
        self.call(name, args).ok().and_then(|v| v.as_f64())
    }

    pub fn flag(&self, name: &str, args: &[&Var]) -> Option<bool> {
        self.call(name, args).ok().and_then(|v| v.as_bool())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    /// "NOTHING" IS NOT "OMITTED", and the difference is what broke every import.
    ///
    /// This runs only where `variant.rs` compiles — a Windows build — which is
    /// also the only place the encoding means anything. It is the one test that
    /// inspects the bytes: the simulator has no VARIANTs to look at, so it cannot
    /// tell these two apart no matter how it is written.
    #[test]
    fn nothing_is_a_null_dispatch_and_not_an_empty_variant() {
        let nothing = Var::null_dispatch();
        let omitted = Var::empty();
        unsafe {
            let n = &*nothing.0.Anonymous.Anonymous;
            let o = &*omitted.0.Anonymous.Anonymous;
            assert_eq!(
                n.vt, VT_DISPATCH,
                "an [in] object parameter set to Nothing must be VT_DISPATCH; SOLIDWORKS answered \
                 Type mismatch (0x80020005) to VT_EMPTY on LoadFile4's importData, live 2026-09-17"
            );
            assert!(
                n.Anonymous.pdispVal.is_none(),
                "Nothing means a NULL pdispVal, not a live interface pointer"
            );
            assert_eq!(o.vt, VT_EMPTY, "Var::empty must stay VT_EMPTY — it is a different thing");
        }
    }
}