From 60eb7b086eef9ae637aeffbfe9696478791c550e Mon Sep 17 00:00:00 2001 From: Sebastian <106036+schoeller@users.noreply.github.com> Date: Thu, 15 Oct 2026 22:39:09 +0200 Subject: [PATCH] feat(plugin-api): entity-class display declarations on the manifest (API v10) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plugins can now declare the entity classes they identify via XDATA, and the host resolves them to display-only type labels while the declaring plugin is loaded. - Append `BuiltinPlugin::entity_classes()` (a guarded trailing vtable slot, the same additive pattern as `on_load` in v5) returning a &'static `EntityClasses { app_id, class_prefix, labels }`. The static `PluginManifest` struct is deliberately untouched: older cdylibs share it by pointer and its layout is frozen ABI since v1, so adding a field would read past their allocation. - `OwnedEntityClasses` travels on the registered manifest as `OwnedPluginManifest.entity_classes` with `#[serde(default)]`, so manifests serialized before the field keep decoding (older runners tolerate it both ways). - The runner folds the declaration into the GetManifest registration only when the loaded cdylib's exported `ocs_plugin_api_version` symbol reports >= 10 — an older binary's vtable predates the slot, so it is never called; its entities simply keep their native type names. - `PluginManager::entity_class_declarations` exposes the live set, filtered to alive, non-disabled plugins in load order. The registered manifests are the only source and nothing is cached: not installed, disabled, cap-excluded (`OCS_PLUGIN_MAX_API_VERSION`), failed at load, removed, or a dead process all contribute nothing. - Embedded version info `api_version` follows the bump to 10; `API_VERSION_MIN_SUPPORTED` stays 3. Tests: runner fold (v10 folds, pre-v10 skipped, `entity_classes()` panic is contained and reported), manager filtering (disabled / dead / undeclaring skipped; declarations disappear once the process dies — verified with a real killed child), bincode registration round-trip, and older-manifest serde default. --- crates/ocs_plugin_api/build.rs | 2 +- crates/ocs_plugin_api/src/host.rs | 17 +- crates/ocs_plugin_api/src/ipc/mod.rs | 65 ++++++++ crates/ocs_plugin_api/src/ipc/protocol.rs | 20 ++- crates/ocs_plugin_api/src/manifest.rs | 36 +++- crates/ocs_plugin_api/src/process.rs | 69 ++++++++ crates/ocs_plugin_api/src/process/manager.rs | 124 +++++++++++++- crates/ocs_plugin_api/src/ribbon/owned.rs | 17 ++ crates/ocs_plugin_api/src/runner.rs | 165 ++++++++++++++++++- 9 files changed, 502 insertions(+), 13 deletions(-) diff --git a/crates/ocs_plugin_api/build.rs b/crates/ocs_plugin_api/build.rs index 6fba1b930..14a6e33e7 100644 --- a/crates/ocs_plugin_api/build.rs +++ b/crates/ocs_plugin_api/build.rs @@ -1263,7 +1263,7 @@ fn generate_version_info(out_dir: &Path) { "acadrust_version": opencadcodec.version.to_string(), "acadrust_source": opencadcodec.source.as_ref().map(|s| s.to_string()), "rustc_version": rustc_version, - "api_version": 9, + "api_version": 10, "api_version_min_supported": 3, "build_timestamp": build_timestamp, }); diff --git a/crates/ocs_plugin_api/src/host.rs b/crates/ocs_plugin_api/src/host.rs index e45ef77aa..78a3f50f7 100644 --- a/crates/ocs_plugin_api/src/host.rs +++ b/crates/ocs_plugin_api/src/host.rs @@ -16,7 +16,7 @@ use std::path::PathBuf; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use crate::manifest::PluginManifest; +use crate::manifest::{EntityClasses, PluginManifest}; use crate::ribbon::CadModule; // Re-export the opencadcodec crate and the types that appear in the HostApi trait @@ -659,6 +659,21 @@ pub trait BuiltinPlugin: Send + Sync { /// Added in API v5. The runner only calls this for plugins that report API /// major 5 or newer. fn on_load(&mut self, _host: &mut dyn HostApi) {} + + /// Entity-class display declarations (API v10): the XDATA + /// class-identity keys the plugin stamps on entities, mapped to the + /// display labels the host shows while this plugin is loaded (e.g. the + /// Properties panel title becomes `"Linear Element (Line)"`). Display-only + /// — the labels never round-trip the drawing and never govern behavior; + /// entities carry only the stable identity keys in their XDATA. + /// + /// The default (`None`) declares no entity classes. The runner only calls + /// this for plugins whose exported `ocs_plugin_api_version` symbol reports + /// API major 10 or newer, so older cdylibs are never touched past their + /// vtable. + fn entity_classes(&self) -> Option<&'static EntityClasses> { + None + } } /// A point-driven interactive command a plugin starts via diff --git a/crates/ocs_plugin_api/src/ipc/mod.rs b/crates/ocs_plugin_api/src/ipc/mod.rs index befb23aac..3d61567bd 100644 --- a/crates/ocs_plugin_api/src/ipc/mod.rs +++ b/crates/ocs_plugin_api/src/ipc/mod.rs @@ -158,6 +158,71 @@ mod tests { } } + #[test] + fn registered_manifest_roundtrips_entity_classes_over_bincode() { + // The registration handshake is length-framed bincode, so the manifest + // and its API-v10 entity-class declaration must survive exactly that + // wire format. + let manifest = crate::ribbon::owned::OwnedPluginManifest { + id: "opencad.civil_ifc".to_string(), + name: "Civil IFC".to_string(), + version: "0.1.0".to_string(), + description: "test".to_string(), + api_version: 10, + ribbon_order: 50, + xdata_apps: vec!["CVIFC".to_string()], + command_prefixes: vec![], + entity_classes: Some(crate::ribbon::owned::OwnedEntityClasses { + app_id: "CVIFC".to_string(), + class_prefix: "CVIFC-CLASS:".to_string(), + labels: [ + ("LINE".to_string(), "Linear Element".to_string()), + ( + "CIRCULARARC".to_string(), + "Circular Arc Element".to_string(), + ), + ] + .into_iter() + .collect(), + }), + }; + let resp = HostResponse::Manifest(manifest); + let bytes = bincode::serialize(&resp).unwrap(); + let got: HostResponse = bincode::deserialize(&bytes).unwrap(); + match got { + HostResponse::Manifest(m) => { + let classes = m.entity_classes.expect("declaration survives"); + assert_eq!(classes.app_id, "CVIFC"); + assert_eq!(classes.class_prefix, "CVIFC-CLASS:"); + assert_eq!( + classes.labels.get("LINE").map(String::as_str), + Some("Linear Element") + ); + } + other => panic!("unexpected: {other:?}"), + } + } + + #[test] + fn manifest_entity_classes_default_when_absent() { + // A manifest serialized before the field existed (an older runner) + // decodes with the declaration defaulting to `None`. + let older = r#"{ + "id": "old.plugin", + "name": "Old", + "version": "0.1.0", + "description": "test", + "api_version": 9, + "ribbon_order": 0, + "xdata_apps": [], + "command_prefixes": [] + }"#; + let m: crate::ribbon::owned::OwnedPluginManifest = + serde_json::from_str(older).expect("older manifest decodes"); + assert_eq!(m.id, "old.plugin"); + assert!(m.entity_classes.is_none()); + } + #[test] fn v4_additions_preserve_legacy_discriminants() { fn discriminant(value: &T) -> u32 { diff --git a/crates/ocs_plugin_api/src/ipc/protocol.rs b/crates/ocs_plugin_api/src/ipc/protocol.rs index 44a2f2ec0..0925b4021 100644 --- a/crates/ocs_plugin_api/src/ipc/protocol.rs +++ b/crates/ocs_plugin_api/src/ipc/protocol.rs @@ -19,7 +19,7 @@ use serde::{Deserialize, Serialize}; use crate::host::{CommandSource, CommandStep, HostSettingValue}; use crate::manifest::ApiVersion; -use crate::ribbon::owned::{OwnedPluginManifest, OwnedRibbonGroup}; +use crate::ribbon::owned::{OwnedEntityClasses, OwnedPluginManifest, OwnedRibbonGroup}; pub use crate::host::PreviewWire; pub use codec::xdata::{ExtendedDataRecord, XDataValue}; @@ -307,6 +307,24 @@ impl From<&'static crate::manifest::PluginManifest> for OwnedPluginManifest { ribbon_order: m.ribbon_order, xdata_apps: m.xdata_apps.iter().map(|s| s.to_string()).collect(), command_prefixes: m.command_prefixes.iter().map(|s| s.to_string()).collect(), + // The declaration does not live on the static manifest struct (its + // layout is frozen ABI, shared by pointer with older cdylibs); the + // runner folds the API-v10 trait accessor in after conversion. + entity_classes: None, + } + } +} + +impl From<&'static crate::manifest::EntityClasses> for OwnedEntityClasses { + fn from(c: &'static crate::manifest::EntityClasses) -> Self { + Self { + app_id: c.app_id.to_string(), + class_prefix: c.class_prefix.to_string(), + labels: c + .labels + .iter() + .map(|(key, label)| (key.to_string(), label.to_string())) + .collect(), } } } diff --git a/crates/ocs_plugin_api/src/manifest.rs b/crates/ocs_plugin_api/src/manifest.rs index 8fb07e446..47332a609 100644 --- a/crates/ocs_plugin_api/src/manifest.rs +++ b/crates/ocs_plugin_api/src/manifest.rs @@ -27,8 +27,11 @@ /// `delete_xrecord` / `list_xrecords`). v9 appends the reference-only entity /// designation (`HostApi::set_reference_only`): display-only geometry a plugin /// still owns and refreshes while the built-in edit paths treat it like a -/// locked layer. -pub const API_VERSION: u32 = 9; +/// locked layer. v10 appends `BuiltinPlugin::entity_classes`: plugin-declared +/// XDATA class-identity display labels, folded into the registered manifest. +/// Display-only; the host consults the declaration only while the declaring +/// plugin is loaded, and entities without one keep their native type names. +pub const API_VERSION: u32 = 10; /// Oldest plugin API major the current host still loads. This keeps previously /// compiled cdylibs usable as long as their vtable layout is a prefix of the @@ -77,6 +80,35 @@ pub fn host_accepts_plugin_version(plugin_major: u32) -> bool { plugin_major >= API_VERSION_MIN_SUPPORTED && plugin_major <= effective_max_api_version() } +/// A plugin's declaration of the entity classes it identifies via XDATA, +/// surfaced by the host as display-only type labels (the Properties panel +/// title and the automation `display_type` field). +/// +/// The split is deliberate: the *identity* is a stable class KEY stamped into +/// the entity's XDATA (one string value per entity, e.g. `"MYAPP-CLASS:LINE"`) +/// and round-trips with the drawing; the *display labels* live in this +/// declaration, so they are plugin-localizable and fixable without rewriting +/// drawings. The declaration is consulted only while the declaring plugin is +/// loaded — a plugin that is not installed, disabled, failed to load, or whose +/// process has died contributes nothing, and its entities display as their +/// bare native type. +/// +/// Declared through [`crate::host::BuiltinPlugin::entity_classes`] (API v10); +/// see `docs/plugin-architecture.md` for the full convention. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EntityClasses { + /// XDATA application name whose records carry the class-identity keys + /// (e.g. `"CVIFC"`). Should be one of the manifest's `xdata_apps` so the + /// host knows the plugin writes under this id. + pub app_id: &'static str, + /// Prefix that marks a string XDATA value as a class-identity key + /// (e.g. `"CVIFC-CLASS:"`). The remainder of the value is the class key. + pub class_prefix: &'static str, + /// Class key → display label (e.g. `"LINE"` → `"Linear Element"`), in + /// declaration order. Display text never round-trips the drawing. + pub labels: &'static [(&'static str, &'static str)], +} + #[cfg(test)] mod tests { use crate::test_lock::ENV_LOCK; diff --git a/crates/ocs_plugin_api/src/process.rs b/crates/ocs_plugin_api/src/process.rs index 052ccf780..58a51ce34 100644 --- a/crates/ocs_plugin_api/src/process.rs +++ b/crates/ocs_plugin_api/src/process.rs @@ -1150,6 +1150,74 @@ fn generate_token() -> Result { Ok(out) } +/// In-crate test support: build a `PluginProcess` whose fields are otherwise +/// private to this module, so sibling test modules (`process::manager`) can +/// put one in a `PluginManager` without spawning a real runner. +#[cfg(all(test, feature = "host"))] +impl PluginProcess { + /// A process backed by a real sleeping child process and a local socket + /// pair, carrying the given manifest (no cdylib, no runner). The + /// runner-side peer socket is dropped immediately; nothing reads it. + pub(crate) fn fake(manifest: OwnedPluginManifest) -> Self { + use interprocess::local_socket::traits::Stream as StreamTrait; + + static COUNTER: AtomicU64 = AtomicU64::new(0); + let name = format!( + "ocs_plugin_fake_process_test_{}_{}", + std::process::id(), + COUNTER.fetch_add(1, Ordering::Relaxed) + ); + let name_ref = name + .clone() + .to_ns_name::() + .expect("valid name"); + let listener = ListenerOptions::new() + .name(name_ref) + .create_sync() + .expect("listener"); + let client_name = name.clone(); + let client_thread: std::thread::JoinHandle = std::thread::spawn(move || { + StreamTrait::connect(client_name.to_ns_name::().unwrap()) + .expect("connect") + }); + let host_stream = listener.accept().expect("accept"); + drop(client_thread.join().expect("client thread")); + + Self { + stream: Mutex::new(Some(host_stream)), + v4: None, + child: Mutex::new(Some(sleepy_test_child())), + id: manifest.id.clone(), + manifest, + ribbon: vec![], + io_lines: Mutex::new(None), + } + } +} + +/// A child that lives ~30 s — long enough to read as "alive", short enough to +/// never hang a leaked test. Shared by `PluginProcess::fake` and this module's +/// own tests. +#[cfg(all(test, feature = "host"))] +fn sleepy_test_child() -> Child { + #[cfg(windows)] + { + std::process::Command::new("cmd") + .arg("/c") + .arg("ping -n 30 127.0.0.1") + .stdout(std::process::Stdio::null()) + .spawn() + .expect("spawn sleep") + } + #[cfg(not(windows))] + { + std::process::Command::new("sleep") + .arg("30") + .spawn() + .expect("spawn sleep") + } +} + #[cfg(test)] mod tests { use super::*; @@ -1352,6 +1420,7 @@ mod timeout_tests { ribbon_order: 0, xdata_apps: vec![], command_prefixes: vec![], + entity_classes: None, } } diff --git a/crates/ocs_plugin_api/src/process/manager.rs b/crates/ocs_plugin_api/src/process/manager.rs index 8d1578c9f..69e10fe3d 100644 --- a/crates/ocs_plugin_api/src/process/manager.rs +++ b/crates/ocs_plugin_api/src/process/manager.rs @@ -21,7 +21,7 @@ use std::time::Duration; use crate::host::{HostApi, HostNotification, PluginNotification}; use crate::process::{PluginError, PluginProcess}; -use crate::ribbon::owned::{to_shared_module, SharedCadModule}; +use crate::ribbon::owned::{to_shared_module, OwnedEntityClasses, SharedCadModule}; /// Callback the host installs to receive plugin-to-host notifications. pub type NotificationHandler = Arc, PluginNotification) + Send + Sync>; @@ -197,6 +197,29 @@ impl PluginManager { out } + /// Entity-class declarations of every alive, non-disabled plugin that + /// made one (API v10), in load order, as `(plugin id, declaration)`. + /// + /// The host consults this at display time only — never cached — so the + /// result always mirrors the live loaded set: a plugin that failed to + /// load, is disabled, was removed, or whose process has died contributes + /// nothing, and entities it stamped fall back to their native type names. + pub fn entity_class_declarations bool>( + &self, + is_disabled: F, + ) -> Vec<(String, OwnedEntityClasses)> { + let mut out = Vec::new(); + for p in &self.plugins { + if is_disabled(p.process.id()) || !p.process.is_alive() { + continue; + } + if let Some(classes) = p.process.manifest().entity_classes.clone() { + out.push((p.process.id().to_string(), classes)); + } + } + out + } + /// Drain any plugin→host requests that have arrived asynchronously across /// all loaded plugins. Errors are logged; this is best-effort. pub fn drain_requests( @@ -358,4 +381,103 @@ mod tests { assert!(!result.handled); assert!(result.started.is_none()); } + + fn manifest_with_entity_classes(id: &str) -> crate::ribbon::owned::OwnedPluginManifest { + crate::ribbon::owned::OwnedPluginManifest { + id: id.to_string(), + name: format!("{id} name"), + version: "0.1.0".to_string(), + description: "test".to_string(), + api_version: 10, + ribbon_order: 0, + xdata_apps: vec![], + command_prefixes: vec![], + entity_classes: Some(OwnedEntityClasses { + app_id: "CVIFC".to_string(), + class_prefix: "CVIFC-CLASS:".to_string(), + labels: [("LINE".to_string(), "Linear Element".to_string())] + .into_iter() + .collect(), + }), + } + } + + fn plain_manifest(id: &str) -> crate::ribbon::owned::OwnedPluginManifest { + crate::ribbon::owned::OwnedPluginManifest { + id: id.to_string(), + name: format!("{id} name"), + version: "0.1.0".to_string(), + description: "test".to_string(), + api_version: 10, + ribbon_order: 0, + xdata_apps: vec![], + command_prefixes: vec![], + entity_classes: None, + } + } + + #[test] + fn entity_class_declarations_skip_disabled_and_undeclaring_plugins() { + let mut manager = PluginManager::new(); + manager.plugins.push(LoadedPlugin { + process: Arc::new(PluginProcess::fake(manifest_with_entity_classes( + "test.live", + ))), + module: to_shared_module("test.live".into(), "Live".into(), vec![]), + }); + manager.plugins.push(LoadedPlugin { + process: Arc::new(PluginProcess::fake(plain_manifest("test.plain"))), + module: to_shared_module("test.plain".into(), "Plain".into(), vec![]), + }); + manager.plugins.push(LoadedPlugin { + process: Arc::new(PluginProcess::fake(manifest_with_entity_classes( + "test.off", + ))), + module: to_shared_module("test.off".into(), "Off".into(), vec![]), + }); + + // Alive + enabled + declaring → reported, with the declaration intact. + let declarations = manager.entity_class_declarations(|id| id == "test.off"); + assert_eq!( + declarations.len(), + 1, + "only the live, enabled plugin reports" + ); + assert_eq!(declarations[0].0, "test.live"); + assert_eq!(declarations[0].1.app_id, "CVIFC"); + assert_eq!(declarations[0].1.class_prefix, "CVIFC-CLASS:"); + assert_eq!( + declarations[0].1.labels.get("LINE").map(String::as_str), + Some("Linear Element") + ); + + // Everything disabled → nothing contributes. + assert!(manager.entity_class_declarations(|_| true).is_empty()); + } + + #[test] + fn entity_class_declarations_revert_when_the_plugin_process_dies() { + let mut manager = PluginManager::new(); + manager.plugins.push(LoadedPlugin { + process: Arc::new(PluginProcess::fake(manifest_with_entity_classes( + "test.live", + ))), + module: to_shared_module("test.live".into(), "Live".into(), vec![]), + }); + assert_eq!( + manager.entity_class_declarations(|_| false).len(), + 1, + "declaration visible while the process is alive" + ); + + manager.plugins[0] + .process + .shutdown_and_wait(Duration::from_secs(5)) + .expect("child shut down"); + + assert!( + manager.entity_class_declarations(|_| false).is_empty(), + "a dead plugin process must contribute no declarations" + ); + } } diff --git a/crates/ocs_plugin_api/src/ribbon/owned.rs b/crates/ocs_plugin_api/src/ribbon/owned.rs index 4e055a7ce..e42f1f5fd 100644 --- a/crates/ocs_plugin_api/src/ribbon/owned.rs +++ b/crates/ocs_plugin_api/src/ribbon/owned.rs @@ -123,6 +123,18 @@ fn collect_item_command_ids(item: &OwnedRibbonItem, out: &mut Vec) { } } +/// Owned mirror of [`crate::manifest::EntityClasses`]: the entity-class +/// declaration as it travels on the registered manifest across the IPC wire. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OwnedEntityClasses { + /// XDATA application name whose records carry the class-identity keys. + pub app_id: String, + /// Prefix that marks a string XDATA value as a class-identity key. + pub class_prefix: String, + /// Class key → display label. + pub labels: std::collections::BTreeMap, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct OwnedPluginManifest { pub id: String, @@ -133,6 +145,11 @@ pub struct OwnedPluginManifest { pub ribbon_order: i32, pub xdata_apps: Vec, pub command_prefixes: Vec, + /// Entity-class display declarations (API v10), folded in by the runner + /// for plugins that declare them. `#[serde(default)]` keeps manifests + /// serialized before the field existed decoding as `None`. + #[serde(default)] + pub entity_classes: Option, } impl From for OwnedIconKind { diff --git a/crates/ocs_plugin_api/src/runner.rs b/crates/ocs_plugin_api/src/runner.rs index e7b17c184..43e7c906c 100644 --- a/crates/ocs_plugin_api/src/runner.rs +++ b/crates/ocs_plugin_api/src/runner.rs @@ -29,7 +29,7 @@ use crate::ipc::protocol::{ }; use crate::ipc::transport::{recv, send}; use crate::ipc::v4::client::{RunnerFrame, V4Client}; -use crate::ribbon::owned::OwnedRibbonGroup; +use crate::ribbon::owned::{OwnedEntityClasses, OwnedPluginManifest, OwnedRibbonGroup}; /// Entry point for the plugin runner child process. /// @@ -59,7 +59,7 @@ pub fn run(socket_name: &str, cdylib_path: &Path) -> Result<(), Box= 5 { invoke_on_load(&mut *plugin, &client, &interactive)?; } - run_v4(&mut *plugin, &interactive, client) + run_v4(&mut *plugin, &interactive, client, version) } else { let client = IpcClient::connect(socket_name)?; eprintln!("[runner] connected to host"); @@ -106,6 +106,7 @@ fn run_v4( plugin: &mut dyn BuiltinPlugin, interactive: &InteractiveRegistry, client: V4Client, + api_version: u32, ) -> Result<(), Box> { loop { // Drain any host notifications and dispatch them to the plugin's @@ -119,9 +120,14 @@ fn run_v4( match client.recv_runner_frame_timeout(std::time::Duration::from_millis(50)) { Ok(RunnerFrame::Request { id, payload }) => { - if let Some(resp) = - handle_host_request_v4(&mut *plugin, interactive, &client, id, payload) - { + if let Some(resp) = handle_host_request_v4( + &mut *plugin, + interactive, + &client, + id, + api_version, + payload, + ) { client.send_response(id, resp)?; } } @@ -277,12 +283,37 @@ fn handle_host_request_v4( interactive: &InteractiveRegistry, client: &V4Client, id: u64, + api_version: u32, req: HostRequest, ) -> Option { match req { HostRequest::GetManifest => { match std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| plugin.manifest())) { - Ok(m) => Some(HostResponse::Manifest(m.into())), + Ok(m) => { + let mut manifest = OwnedPluginManifest::from(m); + // API v10: fold the plugin's entity-class declarations into + // the registered manifest. The call is guarded by the ABI + // version the cdylib was actually built against (the + // exported symbol, not the manifest's claim): an older + // cdylib's vtable predates the slot, so touching it would + // be undefined behavior. Its entities simply keep their + // native type names. + if api_version >= 10 { + let classes = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + plugin.entity_classes() + })); + manifest.entity_classes = match classes { + Ok(classes) => classes.map(OwnedEntityClasses::from), + Err(_) => { + return Some(HostResponse::Error( + "plugin entity_classes() panicked".to_string(), + )) + } + }; + } + Some(HostResponse::Manifest(manifest)) + } Err(_) => Some(HostResponse::Error( "plugin manifest() panicked".to_string(), )), @@ -568,7 +599,7 @@ mod tests { code: "1+1".to_string(), tab_index: 0, }; - let resp = handle_host_request_v4(&mut plugin, &interactive, &client, 99, req); + let resp = handle_host_request_v4(&mut plugin, &interactive, &client, 99, 4, req); match resp { Some(HostResponse::Error(msg)) => { assert!(msg.contains("does not support code execution")); @@ -577,4 +608,124 @@ mod tests { } drop(host_stream); } + + static V10_MANIFEST: PluginManifest = PluginManifest { + id: "test.classed", + name: "Test Classed", + version: "0.1.0", + description: "test", + api_version: ApiVersion { major: 10 }, + ribbon_order: 0, + xdata_apps: &["CVIFC"], + command_prefixes: &[], + }; + + static TEST_ENTITY_CLASSES: crate::manifest::EntityClasses = crate::manifest::EntityClasses { + app_id: "CVIFC", + class_prefix: "CVIFC-CLASS:", + labels: &[ + ("LINE", "Linear Element"), + ("CIRCULARARC", "Circular Arc Element"), + ], + }; + + struct ClassedPlugin; + impl BuiltinPlugin for ClassedPlugin { + fn manifest(&self) -> &'static PluginManifest { + &V10_MANIFEST + } + fn ribbon(&self) -> Box { + Box::new(EmptyModule) + } + fn dispatch(&self, _host: &mut dyn HostApi, _cmd: &str) -> bool { + false + } + fn entity_classes(&self) -> Option<&'static crate::manifest::EntityClasses> { + Some(&TEST_ENTITY_CLASSES) + } + } + + fn manifest_response_of( + plugin: &mut dyn BuiltinPlugin, + api_version: u32, + ) -> Option { + let (host_stream, runner_stream) = connect_pair(); + let client = V4Client::from_stream_for_test(runner_stream); + let interactive: InteractiveRegistry = Rc::new(RefCell::new(HashMap::new())); + let resp = handle_host_request_v4( + plugin, + &interactive, + &client, + 99, + api_version, + HostRequest::GetManifest, + ); + drop(host_stream); + resp + } + + #[test] + fn get_manifest_folds_entity_classes_for_v10_plugin() { + let mut plugin = ClassedPlugin; + match manifest_response_of(&mut plugin, 10) { + Some(HostResponse::Manifest(manifest)) => { + let classes = manifest.entity_classes.expect("declaration folded in"); + assert_eq!(classes.app_id, "CVIFC"); + assert_eq!(classes.class_prefix, "CVIFC-CLASS:"); + assert_eq!( + classes.labels.get("LINE").map(String::as_str), + Some("Linear Element") + ); + assert_eq!( + classes.labels.get("CIRCULARARC").map(String::as_str), + Some("Circular Arc Element") + ); + } + other => panic!("expected manifest response, got {other:?}"), + } + } + + #[test] + fn get_manifest_leaves_entity_classes_default_below_v10() { + // A cdylib whose exported ABI symbol predates v10 must not have the + // trait slot called, even if the in-process plugin object happens to + // implement it: the declaration stays `None` (native display types). + let mut plugin = ClassedPlugin; + match manifest_response_of(&mut plugin, 9) { + Some(HostResponse::Manifest(manifest)) => { + assert!( + manifest.entity_classes.is_none(), + "entity classes must not be folded in for pre-v10 plugins" + ); + } + other => panic!("expected manifest response, got {other:?}"), + } + } + + #[test] + fn get_manifest_reports_entity_classes_panic() { + struct PanickingClassesPlugin; + impl BuiltinPlugin for PanickingClassesPlugin { + fn manifest(&self) -> &'static PluginManifest { + &V10_MANIFEST + } + fn ribbon(&self) -> Box { + Box::new(EmptyModule) + } + fn dispatch(&self, _host: &mut dyn HostApi, _cmd: &str) -> bool { + false + } + fn entity_classes(&self) -> Option<&'static crate::manifest::EntityClasses> { + panic!("boom") + } + } + + let mut plugin = PanickingClassesPlugin; + match manifest_response_of(&mut plugin, 10) { + Some(HostResponse::Error(msg)) => { + assert!(msg.contains("entity_classes() panicked"), "got: {msg}"); + } + other => panic!("expected error response, got {other:?}"), + } + } }