main
Drew Owens 0.3.3 source + crate build: BOM exported from the SOLIDWORKS assembly, build log §1–11, release script 477e64f 1mo ago

Rust port brief — solidworks-bridge (swx)

Goal: replace the Python+PowerShell implementation in this repo with a single Rust binary (swx-bridge.exe) of the SAME 23-verb surface and the same response shapes. Quality bar: the kicad, pup, and native-browser bridges — rich _hints on every response, honest error codes, background etiquette, progress blocks, self-cleaning scratch, a suite that cannot lie.

Why Rust / what the SDK says

Adom Bridge (ab) treats compiled bridges as first-class: spawn.kind: "exe", ab spawns the entrypoint DIRECTLY appending --port <port> to argv (~10ms cold start, no interpreter provisioning). Contract identical to node/python:

  • Parse --port N from argv. Bind ADOM_BIND_HOST env (always 127.0.0.1; default to it when absent). NEVER 0.0.0.0 (firewall prompt).
  • Bind the port FIRST, before anything slow. A failed bind = another instance owns it → print a line and exit 0 (single-instance guard). No SO_REUSEADDR.
  • HTTP: GET /status and /health → 200 JSON with {status:"ok", led, summary, tooltip, bridgeVersion, solidworks:{...}, mock}. POST /command with {"command":"swx_...","args":{...}} → JSON result. Unknown path → 404.
  • Every response ab relays VERBATIM to the calling AI. The _hint on every verb response IS the documentation the AI reads. Response contract keys: success, errorCode (stable, branchable — e.g. host_app_not_installed, reason_required), windowEtiquette: "background" on anything that touches windows, progress blocks on slow verbs (terminal frame percent:100 + elapsedSec) and a fast swx_progress verb returning active[] for polling.
  • swx_uninstall: idempotent, non-interactive, fast; removes ONLY what the bridge created (%LOCALAPPDATA%\Adom Bridge\bridges-cache\solidworks\), never the host app or user docs. ab calls it with {"cascade":true} on app uninstall.
  • Prune what you write: exports scratch dir capped (48h age, 300 files, oldest-first, rate-limited 1/10min).
  • Caller identity: ab delivers args.caller = {aiThread, containerName} (and/or X-Adom-Caller-* headers). Log one compact line per verb: [swx] <command> caller="<thread>" | args: {...} (truncate args ~400 chars). Self-asserted — logging/UX only, NEVER authorization.
  • Logging volume rules: one line per incoming verb; never per-item in loops; never a parallel log file; line-buffered stdout/stderr (ab captures them).
  • bridge.json changes for the port: spawn.kind:"exe", entrypoint:"swx-bridge.exe", killImageName:"swx-bridge.exe". Keep verbs, timeouts, detect, risk, uninstall, updateManifestUrl as-is.

Architecture (required)

ONE binary, two modes:

  1. Server mode (default): the HTTP bridge server. tiny_http + serde/serde_json (this is what the official hello-rust sample uses; see the size-optimized release profile in its Cargo.toml). Verb handlers port 1:1 from the Python server.py in git history / reference copy — same names, same arg names, same response keys, same hints (improve wording freely, never drop information).

  2. --com-host mode: a child process the server spawns FROM ITS OWN EXE (std::env::current_exe()), speaking the same JSON-lines stdio protocol the PowerShell sidecar spoke: {"id":N,"cmd":"...","args":{...}} per line in, {"id":N,"ok":true,"result":{...}} / {"id":N,"ok":false,"error":"...", "detail":"..."} per line out, plus one {"event":"ready","pid":N} line at boot. Diagnostics to stderr only.

    WHY a child process and not an in-process COM thread: a wedged COM call (modal dialog, hung SOLIDWORKS) cannot be killed as a thread, but the child can be killed + respawned. The server owns: lazy spawn, one request in flight (mutex), per-request timeout, kill + respawn after crash/hang, stderr tail capture surfaced in detail. On Windows spawn the child with CREATE_NO_WINDOW (0x08000000) — no console may ever blip on screen.

  3. Backend trait inside com-host mode:

    • MockBackend (all platforms; selected by SWX_MOCK=1 or non-Windows): port of sidecar/mock_host.py — same canned data (3-row BOM with bracket/shaft/m4-screw, qty 2/1/8, mass 0.4173 kg, 1 interference, etc.) so the existing HTTP test expectations keep passing.
    • ComBackend (cfg(windows)): real COM via the windows crate, late-bound IDispatch (GetIDsOfNames + Invoke), STA-initialized main thread in com-host mode. Sidecar semantics to preserve from the PS version: attach via GetActiveObject("SldWorks.Application") first, only CoCreateInstance (launch) when launch:true; OpenDoc6 with swOpenDocOptions_Silent=1 and byref VT_I4 errors/warnings (fall back to legacy OpenDoc if it returns null); Save3(1, byref, byref); SaveAs3(path, 0, 1) keyed off destination extension; CustomPropertyManager("").GetNames / Get4(name, false, byref BSTR, byref BSTR) / Add3(name, 30 /text/, value, 2 /replace/); CreateMassProperty (Mass, Volume, SurfaceArea, Density, CenterOfMass safearray of f64); assembly GetComponents(topOnly) safearray of IDispatch → per-component GetSuppression()!=0 filter, GetPathName, ReferencedConfiguration, GetModelDoc2 → props; InterferenceDetectionManager (UseTransform=true, TreatCoincidenceAsInterference, GetInterferences / GetInterferenceCount, Done() in a finally); ForceRebuild3(false) + feature walk FirstFeature/GetNextFeature/GetErrorCode2; Pack and Go via Extension.GetPackAndGo → IncludeDrawings, SetSaveToName(true, dir), GetDocumentSaveToNames byref, Extension.SavePackAndGo. Doc types: .sldprt=1 .sldasm=2 .slddrw=3. Doc info shape: {title, path, type ("part"/"assembly"/"drawing"), config, dirty}. The COM layer CANNOT be exercised until SOLIDWORKS installs (trial serial pending) — it must COMPILE for x86_64-pc-windows-gnu, degrade gracefully (clear errors, never panic across the protocol boundary), and keep the eval→swx_com_eval escape hatch (in Rust: accept a limited expression — at minimum <method>(args...) chains on $sw — or document what it supports; reason-gated as today).

Verbs that DON'T go through COM (implement directly in the server, all platforms): swx_status, swx_readiness, swx_describe, swx_progress, swx_find_files (disk walk: .sldprt/.sldasm/.slddrw, skip ~$ lock files, dot/$ dirs, node_modules; newest first; limit + 150s guard; roots default to ~/Documents + ~/Desktop), swx_uninstall, swx_screenshot (POST to ADOM_DIRECT_API_URL/command with X-Adom-Bridge-Token + forwarded X-Adom-Caller-* headers + X-Adom-Caller-Delegate: solidworks — plain HTTP to loopback, hand-roll over TcpStream or use a tiny client; never fail the verb because the callback failed), swx_state (detection + docs via com-host when running), and the BOM CSV assembly (sidecar returns rows; server sorts by name, collects the union of property names as columns, writes CSV to the scratch dir).

Detection (server, cfg(windows)): App Paths registry key HKLM/HKCU ...\App Paths\SLDWORKS.exe, then path globs (Program Files SOLIDWORKS Corp variants), plus HKLM\SOFTWARE\SolidWorks subkey enum for registryVersions, plus tasklist check for SLDWORKS.exe running. Non-Windows: installed:false. swx_status re-detects each call.

Host-app gate: when NOT mock and NOT installed, every COM-backed verb returns {success:false, errorCode:"host_app_not_installed", error:"SOLIDWORKS is not installed", _hint:<trial-pending hint>}. The trial hint: a 30-day desktop trial was requested from Hawk Ridge Systems (reseller); serial arrives by email; never tell the user to install by hand.

Testing (adapted from the app-testing skill — build ALL of it)

The suite must not be able to lie. No test may assert by reading source files as strings; every test drives the REAL compiled binary over HTTP/stdio.

  1. Harness: build (debug) and spawn the real server binary on a free port with SWX_MOCK=1 and temp dirs for anything it writes; poll /status until healthy; always kill + clean up. Rust integration tests (tests/*.rs, use std or minimal deps) or keep/extend the existing Python tests/test_verbs.py (works: it drives HTTP exactly as ab does). Prefer BOTH: cargo test for Rust-side units (find_files filters, CSV assembly, prune logic, protocol framing) + the HTTP suite for end-to-end.
  2. Verb-coverage gate (the interaction-coverage analog): read bridge.json verbs[] at test time and FAIL if any listed verb has no test entry, and FAIL if swx_describe returns a verb absent from bridge.json or vice versa. A new verb shipped without a test must turn the suite red.
  3. Error-gate analog: every response must parse as JSON, carry a boolean success (or status ok for GET), never HTTP 500 on well-formed input; a malformed body → clean 400. Assert numbered error paths: unknown verb (hint lists valid commands), missing required args, wrong doc type for bom/ interference, reason gate on swx_com_eval.
  4. Protocol e2e: the real server spawning the REAL com-host child (mock backend) — the over-the-wire loop between our two processes: verb → child → response; child killed mid-run → next verb respawns it and succeeds (crash recovery is a required test); a child that answers garbage lines → server skips them without wedging.
  5. Effects, not existence: swx_export writes a real file (assert bytes on disk); swx_bom csvPath exists and parses as CSV with the property columns; find_files honors query/types/limit and skips ~$ files (create real fixture files in a temp dir).
  6. Exit non-zero on any failure. Suite runs green on this Linux container.

Build requirements

  • cargo build --release --target x86_64-pc-windows-gnu MUST succeed here (toolchain + mingw already installed). Also plain cargo build (linux) for the mock-mode test run. Windows-only code behind cfg(windows).
  • Use the hello-rust release profile (opt-level z, lto, strip) for the shipped exe. Dependencies: tiny_http, serde, serde_json; windows + winreg behind [target.'cfg(windows)'.dependencies]. Justify any addition in Cargo.toml comments. No async runtime.
  • Remove the Python implementation files (server.py, com_host.py, swx_detect.py, caller_identity.py, quiet_console.py, sidecar/*.ps1, sidecar/mock_host.py) — git history keeps them; port tests/test_verbs.py to drive the Rust binary. Update README.md (architecture diagram, dev instructions) and SKILL.md where they mention Python/PowerShell. Update bridge.json spawn block. Keep package.json/install.sh/uninstall.sh (skills pkg) as-is except doc touch-ups.
  • Keep BRIDGE_VERSION and Cargo.toml version in lockstep: bump to 0.2.0.

References on disk (read them)

  • This repo pre-port (Python impl): git show HEAD — or the files before your removal commit. The response shapes and hints in server.py are the spec.
  • Official Rust sample bridge: /tmp/claude-1001/-home-adom-project/ca530728-41cc-4f93-a92d-d32d4afe1153/scratchpad/hr/ (src/main.rs, Cargo.toml, bridge.json — the exe contract in miniature).
  • kicad-bridge latest release (the quality bar for hints/etiquette/logging): /tmp/claude-1001/-home-adom-project/ca530728-41cc-4f93-a92d-d32d4afe1153/scratchpad/kb-latest/ (server.py, caller_identity.py, bridge.json).
  • pup-bridge page (README/SKILL/docs — the quality bar for docs + response design): /tmp/claude-1001/-home-adom-project/ca530728-41cc-4f93-a92d-d32d4afe1153/scratchpad/pup-bridge/
  • Local skills: ~/.claude/skills/adom-bridge-sdk*/SKILL.md (the SDK, manifest, runtime contracts), ~/.claude/skills/pup/SKILL.md, ~/.claude/skills/native-browser-safety/SKILL.md.

Non-goals for this port

  • No publishing (release zip / wiki page) — a later step.
  • No new verbs beyond the 23 (parity port + quality).
  • No UI. No PowerShell anywhere.

Build notes — 2026-09-01

What the port actually did differently from this brief, and why. Everything here is a deliberate deviation, not a shortcut.

Release profile: no panic = "abort". The hello-rust sample sets it for size, but the com-host child catches panics (catch_unwind) and answers them as ordinary ok:false responses — which needs unwinding. A late-bound COM call meeting something it did not expect should cost one verb, not the user's whole SOLIDWORKS session (open documents included). The exe is still 824 KB.

Crash recovery is transparent, and narrowly so. The brief asked that a killed child be respawned by the next verb. Naively that costs one failed verb: the server writes into a dead pipe, then reports it. So ComHost::call now (a) reaps a child that has already exited BEFORE writing, and (b) retries once if the write itself fails. The retry is deliberately limited to failures that happen before the request reaches the child — nothing can have half-happened. A child that dies MID-request is still reported, never silently re-run: quietly repeating an export or a Pack and Go would be worse than the error. Stale-id and non-JSON lines are skipped as specified, under an overall deadline.

swx_status.com.hostPid (additive). Which process holds the COM object is the one fact you need to debug a wedge, and it is what makes crash recovery observable — tests/protocol.rs kills that pid and asserts the next verb succeeds against a different one. Nothing else in the response changed.

swx_com_eval is a call-chain evaluator, not a script host. There is no PowerShell any more and Rust has no eval, so the escape hatch became a small grammar: $sw / $doc rooted dotted chains with string, number and boolean literals ($doc.Extension.CustomPropertyManager("").GetNames()). That is exactly what prototyping a verb needs, and it is auditable, which for something driving a live CAD application is the better trade. The parser lives in comhost/expr.rs OUTSIDE cfg(windows) precisely so it is unit-tested on this Linux host; only the evaluation is Windows-only. The verb's hints say what it supports.

lib + bin instead of one main.rs. The integration tests need the crate's own HTTP client to drive the binary, and the pure logic (CSV assembly, prune selection, find_files filters, the eval grammar, path helpers) deserves unit tests. Splitting src/lib.rs off gets both while the tests still spawn the REAL CARGO_BIN_EXE_swx-bridge.

Version lockstep is enforced by the compiler. BRIDGE_VERSION is checked against CARGO_PKG_VERSION in a const assertion — they cannot drift. The suite additionally asserts /status.bridgeVersion == bridge.json.version, which is what caught bridge.json still saying 0.1.0.

SWX_SCRATCH_DIR override. The Python fallback (<repo>/.scratch when LOCALAPPDATA is unset) makes no sense for a shipped exe, and left the tests writing into the developer's real cache. The root is now SWX_SCRATCH_DIR → %LOCALAPPDATA%\... → OS temp, and swx_uninstall removes that root — so "uninstall really deleted what the bridge created" became an assertable fact instead of a claim.

Two test-only knobs in the mock backend, both documented in place and never set in production: SWX_MOCK_NOISE=1 makes the child emit a non-JSON line, an event line and a stale-id response before every answer (this is how the skip-what-you-cannot-parse path is proven over the real wire), and SWX_MOCK_SLOW_MS gives the "cold start" real duration (this is how swx_progress answering DURING a slow verb is proven).

_reportIssues on every failure and on the unknown-verb response, per adom-bridge-sdk-audit. The Python version predated that contract.

Small correctness wins carried over deliberately: the mock's document titles now use a real basename that splits on both separators (Python's os.path.basename returned the whole C:\... string when the mock ran on Linux); detection.platform reports std::env::consts::OS (windows/linux) rather than Python's win32; the log line truncates on a char boundary, so a non-ASCII folder name cannot panic the logger.

Threading. tiny_http is fed by a std TcpListener we bind ourselves, so the single-instance guard is explicit and nothing enables address reuse. Four worker threads serve requests — not for throughput (COM work serializes on the host child regardless) but so swx_progress can answer while a 90-second interference check is in flight. There is a test for exactly that.

Not done, by design: the COM backend has never executed — it compiles for x86_64-pc-windows-gnu and is written to fail honestly (every error carries what SOLIDWORKS said, via EXCEPINFO), but its live shakedown waits on the trial serial. swx_screenshot's ab callback is exercised only in its "ab unreachable" shape, which is the only shape available off a real desktop.

Build notes — 2026-09-01 (round 2: adversarial-review fixes)

The adversarial reviewer and a parity diff found real defects. What changed:

CRITICAL — VARIANT double-free (variant.rs). The reviewer was right and my earlier comment was wrong: VARIANT has real drop glue in windows 0.62.2 (needs_drop::<VARIANT>() is true), so building the DISPPARAMS array with ptr::read made a second owner that double-freed every BSTR / IDispatch argument. Fixed: the arg array is now Vec<ManuallyDrop<VARIANT>> — the Var arguments stay the sole owners and clear exactly once. Re-audited every Var handoff (Var::dispatch, as_dispatch, as_array, coerce): each is now balanced one-AddRef/one-Release. This was the one that had to be right before the serial lands.

HIGH. find_files now collects all matches, sorts by mtime, THEN truncates, so the limit can no longer drop the newest file (regression test added). swx_uninstall removes ONLY <cache>/exports, best-effort per item, never the cache root ab owns (unit + e2e tests). swx_status (the statusVerb) try_locks a non-blocking snapshot so polling it never waits on a 580s Pack-and-Go; shutdown kills the child by pid when the request lock is held, so the cascade can't block. HTTP workers now catch_unwind at two layers (per-verb → JSON error with _reportIssues; per-request → worker survives); test-only fault injection (SWX_TEST_PANIC, debug builds only) proves it end to end. swx_bom relabelled risk "write", and a path-traversal guard (safe_out) refuses a relative or .. destination for bom-csv, export and pack-and-go.

MED COM invariants (compile-only until the serial lands). Interference Done() is now a Drop guard (survives panics), not a trailing statement. The OpenDoc6 legacy fallback is gone (it was non-silent — a modal-dialog risk). rebuild_check distinguishes a clean feature from an unreadable one (unreadableFeatures, code:null). swx_stop treats the expected RPC disconnect from ExitApp as success. as_array coerces a scalar to a one-element vec (the PowerShell @(...) semantics), and handles VT_UNKNOWN. bridge.json's hostAppOptionalVerbs gains uninstall + state (so the cascade and state work with SOLIDWORKS absent — the current target), and the 3DEXPERIENCE glob is added. The mock now runs the SAME parse_chain as the real backend, so the eval grammar is genuinely exercised over the wire.

Child watchdog (#14). The child now runs a watchdog thread that polls the parent server's liveness (SWX_PARENT_PID) and exits if the server is hard-killed while a COM call is wedged — the one case the plain stdin-EOF path (only checked between requests) cannot catch. Proven to work in a plain shell; the wedged-case integration test is #[ignore]d because it is PID-namespace sensitive (a sandboxed observer cannot always see or signal the child in its namespace), with the always-on graceful-orphan test as cover.

LOW. EXCEPINFO BSTRs are freed after each failed Invoke and the bad-arg index is included in the error. 8MB request-body cap → 413. 400/413 responses carry _hint + _reportIssues. swx_screenshot no longer flips to failure on a present-but-null error, and its gate/doc are reconciled (it needs a running window, so it is gated). swx_export echoes the ACTUAL output extension (outputExtension). The detect.rs env test no longer calls set_var (was a data race). find_files accepts a bare-string types and a numeric-string limit (Python parity); an empty/unmatched types means "all" again (the previous code returned zero files, contradicting its own doc).

Intentional, documented parity changes (NOT silent drift):

  • solidworks.platform reports "windows"/"linux" (std::env::consts::OS), not Python's "win32". Cleaner; a caller branching on the old literal must update. Called out here so it is a decision, not an accident.
  • BOM row file uses a backslash separator (C:\mock\bracket.sldprt); Python emitted a forward slash when the mock ran on Linux. Rust is correct.
  • Re-opening an already-open doc moves it to the end of the mock's list (Python kept its position); this only affects close_doc's fallback target in mock mode. No test locks either old string.

Rebutted / deferred:

  • Coverage gate "accepts a bare call with zero assertions" (#21): left as is. The harness RECORDS every call and the gate checks bridge.json ⇔ describe ⇔ calls-made in all three directions; in practice every verb in the suite is asserted on. Requiring a per-verb assertion count would add machinery for no real coverage the three-way check doesn't already give. free_port TOCTOU is acknowledged and, as the reviewer agreed, fine to leave (the single-instance guard turns any collision into a clean failure, not a hang).
  • swx_bom.topLevelOnly defaulting to false (#E): kept — false is the documented default and the flag is echoed back, so a caller always sees what ran.