feat(plugin): name-addressed XRecord CRUD on the HostApi (API v8)

Add create/read/update/delete/list for XRecord objects under
named-object-dictionary paths to the plugin HostApi, with five additive
PluginRequest/PluginResponse variants, dispatch through the shared
handler, in-process HostSession methods and both out-of-process proxies.

- dictionary paths walk from the NOD root; create seeds missing
  dictionaries (ensure_image_dictionary bootstrap) and refuses existing
  names; update refuses missing ones; refusals precede the undo step
- entry group codes validated (1..=369, never 5/105); payload content is
  the plugin's own
- each mutating call is one undo step: the pending history snapshot
  restores the objects map byte-exactly, auto-created dictionaries
  included (tested)
- typed entries survive DWG and DXF write/reload round-trips (tested);
  update drops verbatim raw bytes so saves emit the new content
- bump API_VERSION 7 -> 8 with the build.rs version_info in lockstep,
  mirroring the v7 addition; MIN_SUPPORTED stays 3
This commit is contained in:
Sebastian committed 2026-10-11 19:57:20 +02:00
1 parent 6cde0e02d8
commit ca2f0c9857
11 files changed
+1212 -3

No files matched your search

+79
View File
@@ -1085,6 +1085,85 @@ pub trait HostApi {
let _ = cmd;
false
}
// ── XRecord objects (added in API v8; appended at the end to keep vtable
// indices stable for older plugins) ────────────────────────────────────────
/// Create an XRecord named `name` under the dictionary addressed by
/// `dictionary_path`, storing `entries` (API v8, additive).
///
/// The path is walked from the drawing's named-object dictionary root: an
/// empty path addresses the root itself, and missing intermediate
/// dictionaries are created, so the first call seeds a plugin's namespace.
/// Every refusal (empty or invalid names, a path fragment that names a
/// non-dictionary object, a name already in use — comparison is
/// case-insensitive like dictionary lookups) happens **before** any change,
/// so a failure leaves the drawing untouched. The record is registered in
/// its dictionary and owned by it; the returned handle stays stable across
/// updates. The change is one undo step.
fn create_xrecord(
&mut self,
_dictionary_path: &[&str],
_name: &str,
_entries: Vec<XRecordEntry>,
) -> Result<Handle, String> {
Err("XRecord creation is not supported by this host".to_owned())
}
/// Read the entries of an existing XRecord by name (API v8, additive).
///
/// Resolves `dictionary_path` under the named-object dictionary root and
/// returns the record's entries verbatim. Fails when the path, the name or
/// the record is missing, or when the name addresses a different object
/// kind.
fn read_xrecord(
&self,
_dictionary_path: &[&str],
_name: &str,
) -> Result<Vec<XRecordEntry>, String> {
Err("XRecord reads are not supported by this host".to_owned())
}
/// Atomically replace the entries of an existing XRecord (API v8,
/// additive). Returns the record's handle.
///
/// Neither the path nor its dictionaries are created: an update must never
/// create anything silently. Identity (handle, owning dictionary) stays;
/// the entries and their DWG handle-reference stream are replaced and the
/// record's verbatim raw payloads are dropped, so subsequent saves emit the
/// new content. A refusal leaves the drawing untouched; a successful change
/// is one undo step whose undo restores the previous entries byte-exactly.
fn update_xrecord(
&mut self,
_dictionary_path: &[&str],
_name: &str,
_entries: Vec<XRecordEntry>,
) -> Result<Handle, String> {
Err("XRecord updates are not supported by this host".to_owned())
}
/// Delete the XRecord named `name` and its dictionary entry (API v8,
/// additive). Returns the removed record, so audits keep the prior
/// content. A refusal leaves the drawing untouched; a successful delete is
/// one undo step.
fn delete_xrecord(
&mut self,
_dictionary_path: &[&str],
_name: &str,
) -> Result<XRecord, String> {
Err("XRecord deletes are not supported by this host".to_owned())
}
/// List the XRecord names under the dictionary addressed by
/// `dictionary_path` (API v8, additive).
///
/// Returns only the dictionary keys that address XRecord objects, in the
/// dictionary's own order, so consumers can audit and export without
/// knowing names in advance. Fails when the path does not address a
/// dictionary.
fn list_xrecords(&self, _dictionary_path: &[&str]) -> Result<Vec<String>, String> {
Err("XRecord listing is not supported by this host".to_owned())
}
}
/// Configuration properties for creating or modifying a layer.
+153
View File
@@ -603,6 +603,95 @@ impl HostApi for PluginHostApi {
}
}
}
fn create_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<crate::host::XRecordEntry>,
) -> Result<Handle, String> {
match self.client.request(PluginRequest::CreateXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
entries,
}) {
Ok(PluginResponse::XRecordCreateResult(result)) => {
if result.is_ok() {
self.document_cache = OnceCell::new();
}
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord create response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn read_xrecord(
&self,
dictionary_path: &[&str],
name: &str,
) -> Result<Vec<crate::host::XRecordEntry>, String> {
match self.client.request(PluginRequest::ReadXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
}) {
Ok(PluginResponse::XRecordEntries(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord read response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn update_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<crate::host::XRecordEntry>,
) -> Result<Handle, String> {
match self.client.request(PluginRequest::UpdateXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
entries,
}) {
Ok(PluginResponse::XRecordUpdateResult(result)) => {
if result.is_ok() {
self.document_cache = OnceCell::new();
}
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord update response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn delete_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
) -> Result<crate::host::XRecord, String> {
match self.client.request(PluginRequest::DeleteXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
}) {
Ok(PluginResponse::XRecordDeleteResult(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord delete response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
match self.client.request(PluginRequest::ListXRecords {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
}) {
Ok(PluginResponse::XRecordNames(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord list response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
}
/// Sentinel reader used when the shared-memory view could not be initialized.
@@ -810,4 +899,68 @@ mod tests {
assert!(api.remove_entity(Handle::new(7)));
peer_handle.join().unwrap();
}
#[test]
fn xrecord_requests_await_their_typed_responses() {
let (mut api, mut peer) = make_client();
let peer_handle = thread::spawn(move || {
let msg = recv::<PluginToHost>(&mut peer).unwrap();
match msg {
PluginToHost::Request(req) => match *req {
PluginRequest::CreateXRecord {
dictionary_path,
name,
entries,
} => {
assert_eq!(dictionary_path, vec!["CVIFC".to_owned()]);
assert_eq!(name, "alpha");
assert_eq!(
entries,
vec![crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}")]
);
}
other => panic!("unexpected: {other:?}"),
},
other => panic!("unexpected: {other:?}"),
}
send(
&mut peer,
&HostToPlugin::Response(Box::new(PluginResponse::XRecordCreateResult(Ok(
Handle::new(21),
)))),
)
.unwrap();
let msg = recv::<PluginToHost>(&mut peer).unwrap();
match msg {
PluginToHost::Request(req) => match *req {
PluginRequest::ListXRecords { dictionary_path } => {
assert_eq!(dictionary_path, vec!["CVIFC".to_owned()]);
}
other => panic!("unexpected: {other:?}"),
},
other => panic!("unexpected: {other:?}"),
}
send(
&mut peer,
&HostToPlugin::Response(Box::new(PluginResponse::XRecordNames(Ok(vec![
"alpha".to_owned()
])))),
)
.unwrap();
});
assert_eq!(
api.create_xrecord(
&["CVIFC"],
"alpha",
vec![crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}")]
)
.unwrap(),
Handle::new(21)
);
assert_eq!(
api.list_xrecords(&["CVIFC"]).unwrap(),
vec!["alpha".to_owned()]
);
peer_handle.join().unwrap();
}
}
+36
View File
@@ -200,6 +200,32 @@ pub enum PluginRequest {
RunCommand {
request: crate::host::CommandRequest,
},
/// V8 (additive): create an XRecord under a named-object-dictionary path.
CreateXRecord {
dictionary_path: Vec<String>,
name: String,
entries: Vec<crate::host::XRecordEntry>,
},
/// V8 (additive): read the entries of an existing XRecord.
ReadXRecord {
dictionary_path: Vec<String>,
name: String,
},
/// V8 (additive): replace the entries of an existing XRecord.
UpdateXRecord {
dictionary_path: Vec<String>,
name: String,
entries: Vec<crate::host::XRecordEntry>,
},
/// V8 (additive): delete an XRecord and its dictionary entry.
DeleteXRecord {
dictionary_path: Vec<String>,
name: String,
},
/// V8 (additive): list the XRecord names under a dictionary path.
ListXRecords {
dictionary_path: Vec<String>,
},
}
/// Responses the host sends back for `PluginRequest`.
@@ -236,6 +262,16 @@ pub enum PluginResponse {
SolidResult(Result<Handle, String>),
TableResult(Result<Handle, String>),
CommandResult(Result<crate::host::CommandOutcome, String>),
/// V8 (additive): handle of a created XRecord.
XRecordCreateResult(Result<Handle, String>),
/// V8 (additive): entries of a read XRecord.
XRecordEntries(Result<Vec<crate::host::XRecordEntry>, String>),
/// V8 (additive): handle of an updated XRecord.
XRecordUpdateResult(Result<Handle, String>),
/// V8 (additive): the removed XRecord, with its prior content.
XRecordDeleteResult(Result<crate::host::XRecord, String>),
/// V8 (additive): XRecord names under a dictionary path.
XRecordNames(Result<Vec<String>, String>),
}
/// Messages sent from the host to the plugin runner.
+34
View File
@@ -117,5 +117,39 @@ pub fn handle_plugin_request(
PluginResponse::TableResult(host.table_operation(operation))
}
RunCommand { request } => PluginResponse::CommandResult(host.run_command(request)),
CreateXRecord {
dictionary_path,
name,
entries,
} => {
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
PluginResponse::XRecordCreateResult(host.create_xrecord(&path, &name, entries))
}
ReadXRecord {
dictionary_path,
name,
} => {
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
PluginResponse::XRecordEntries(host.read_xrecord(&path, &name))
}
UpdateXRecord {
dictionary_path,
name,
entries,
} => {
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
PluginResponse::XRecordUpdateResult(host.update_xrecord(&path, &name, entries))
}
DeleteXRecord {
dictionary_path,
name,
} => {
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
PluginResponse::XRecordDeleteResult(host.delete_xrecord(&path, &name))
}
ListXRecords { dictionary_path } => {
let path: Vec<&str> = dictionary_path.iter().map(String::as_str).collect();
PluginResponse::XRecordNames(host.list_xrecords(&path))
}
}
}
@@ -898,6 +898,95 @@ impl HostApi for V4PluginHostApi {
}
}
}
fn create_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<crate::host::XRecordEntry>,
) -> Result<Handle, String> {
match self.request(PluginRequest::CreateXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
entries,
}) {
Ok(PluginResponse::XRecordCreateResult(result)) => {
if result.is_ok() {
self.document_cache = OnceCell::new();
}
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord create response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn read_xrecord(
&self,
dictionary_path: &[&str],
name: &str,
) -> Result<Vec<crate::host::XRecordEntry>, String> {
match self.request(PluginRequest::ReadXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
}) {
Ok(PluginResponse::XRecordEntries(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord read response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn update_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
entries: Vec<crate::host::XRecordEntry>,
) -> Result<Handle, String> {
match self.request(PluginRequest::UpdateXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
entries,
}) {
Ok(PluginResponse::XRecordUpdateResult(result)) => {
if result.is_ok() {
self.document_cache = OnceCell::new();
}
result
}
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord update response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn delete_xrecord(
&mut self,
dictionary_path: &[&str],
name: &str,
) -> Result<crate::host::XRecord, String> {
match self.request(PluginRequest::DeleteXRecord {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
name: name.to_string(),
}) {
Ok(PluginResponse::XRecordDeleteResult(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord delete response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
fn list_xrecords(&self, dictionary_path: &[&str]) -> Result<Vec<String>, String> {
match self.request(PluginRequest::ListXRecords {
dictionary_path: dictionary_path.iter().map(|s| s.to_string()).collect(),
}) {
Ok(PluginResponse::XRecordNames(result)) => result,
Ok(PluginResponse::Error(error)) => Err(error),
Ok(other) => Err(format!("unexpected XRecord list response: {other:?}")),
Err(error) => Err(error.to_string()),
}
}
}
#[cfg(test)]
@@ -388,6 +388,111 @@ mod tests {
);
}
#[test]
fn xrecord_request_and_response_roundtrip() {
// The five XRecord request variants (API v8) serialize with their
// dictionary paths and typed entries, and the responses answer with
// handles, entries, records and names.
let entries = vec![
crate::host::XRecordEntry::string(1, "{\"k\":\"road\"}"),
crate::host::XRecordEntry::int16(70, 3),
];
let bytes = bincode::serialize(&PluginRequest::CreateXRecord {
dictionary_path: vec!["CVIFC".to_owned()],
name: "alpha".to_owned(),
entries: entries.clone(),
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::CreateXRecord { dictionary_path, name, entries: decoded } if
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha" && decoded == entries)
);
let bytes = bincode::serialize(&PluginRequest::ReadXRecord {
dictionary_path: vec!["CVIFC".to_owned()],
name: "alpha".to_owned(),
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::ReadXRecord { dictionary_path, name } if
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha")
);
let bytes = bincode::serialize(&PluginRequest::UpdateXRecord {
dictionary_path: vec!["CVIFC".to_owned()],
name: "alpha".to_owned(),
entries: entries.clone(),
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::UpdateXRecord { dictionary_path, name, entries: decoded } if
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha" && decoded == entries)
);
let bytes = bincode::serialize(&PluginRequest::DeleteXRecord {
dictionary_path: vec!["CVIFC".to_owned()],
name: "alpha".to_owned(),
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::DeleteXRecord { dictionary_path, name } if
dictionary_path == vec!["CVIFC".to_owned()] && name == "alpha")
);
let bytes = bincode::serialize(&PluginRequest::ListXRecords {
dictionary_path: vec!["CVIFC".to_owned()],
})
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginRequest>(&bytes).unwrap(),
PluginRequest::ListXRecords { dictionary_path } if
dictionary_path == vec!["CVIFC".to_owned()])
);
let bytes = bincode::serialize(&PluginResponse::XRecordCreateResult(Ok(
codec::Handle::new(42),
)))
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordCreateResult(Ok(handle)) if handle == codec::Handle::new(42))
);
let bytes =
bincode::serialize(&PluginResponse::XRecordEntries(Ok(entries.clone()))).unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordEntries(Ok(decoded)) if decoded == entries)
);
let bytes = bincode::serialize(&PluginResponse::XRecordUpdateResult(Ok(
codec::Handle::new(42),
)))
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordUpdateResult(Ok(handle)) if handle == codec::Handle::new(42))
);
let bytes = bincode::serialize(&PluginResponse::XRecordNames(Ok(vec!["alpha".to_owned()])))
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordNames(Ok(names)) if names == vec!["alpha".to_owned()])
);
let deleted = crate::host::XRecord::named("alpha");
let bytes =
bincode::serialize(&PluginResponse::XRecordDeleteResult(Ok(deleted.clone()))).unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordDeleteResult(Ok(record)) if record == deleted)
);
let bytes = bincode::serialize(&PluginResponse::XRecordEntries(Err(
"XRecord reads are not supported by this host".to_owned(),
)))
.unwrap();
assert!(
matches!(bincode::deserialize::<PluginResponse>(&bytes).unwrap(),
PluginResponse::XRecordEntries(Err(error)) if error.contains("not supported"))
);
}
#[test]
fn plugin_notification_roundtrip() {
let frame = PluginToHostV4::Notification(NotificationEnvelope {
+5 -2
View File
@@ -21,8 +21,11 @@
/// adds the `BuiltinPlugin::on_load` lifecycle callback and
/// `HostApi::document_path`. v6 appends generic host system-variable access
/// (`HostApi::system_variable` / `set_system_variable`). v7 appends atomic,
/// undoable replacement of a set of existing entities.
pub const API_VERSION: u32 = 7;
/// undoable replacement of a set of existing entities. v8 appends name-addressed
/// XRecord object CRUD under named-object-dictionary paths
/// (`HostApi::create_xrecord` / `read_xrecord` / `update_xrecord` /
/// `delete_xrecord` / `list_xrecords`).
pub const API_VERSION: u32 = 8;
/// 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