gravel_core/
plugin.rs

1use abi_stable::library::{IsLayoutChecked, LibHeader, LibraryError, RootModule, RootModuleError};
2use abi_stable::{abi_stability::abi_checking::check_layout_compatibility, library::lib_header_from_path};
3use abi_stable::{sabi_types::VersionNumber, std_types::RBoxError, type_layout::TypeLayout};
4use gravel_ffi::PluginDefinition;
5use std::{collections::HashMap, path::Path};
6
7/// Facilitates registering and finding plugins.
8#[derive(Default)]
9pub struct PluginRegistry {
10	plugins: HashMap<String, PluginDefinition>,
11}
12
13impl PluginRegistry {
14	/// Registers the plugin.
15	///
16	/// If the plugin is incorrectly defined or another plugin with identical
17	/// name and type is already registered, an error is logged and the plugin
18	/// is skipped.
19	pub fn register(&mut self, plugin: PluginDefinition) -> &mut Self {
20		let name = plugin.meta.name.as_str();
21
22		if self.plugins.contains_key(name) {
23			log::warn!("attempted to register duplicate plugin '{}', skipping", name);
24			return self;
25		}
26
27		log::trace!("registered plugin '{name}'");
28
29		self.plugins.insert(name.to_owned(), plugin);
30		self
31	}
32
33	#[must_use]
34	pub fn get(&self, name: &str) -> Option<&PluginDefinition> {
35		self.plugins.get(name)
36	}
37}
38
39pub fn load_library_from_path<M: RootModule>(path: &Path) -> Result<M, LibraryError> {
40	let header = lib_header_from_path(path)?;
41	check_module_version::<M>(header)?;
42
43	if let IsLayoutChecked::Yes(layout) = header.root_mod_consts().layout() {
44		check_layout::<M>(layout)?;
45	};
46
47	let lib = unsafe { header.unchecked_layout::<M>() }.map_err(RootModuleError::into_library_error::<M>)?;
48	Ok(lib)
49}
50
51fn check_layout<M: RootModule>(plugin_layout: &'static TypeLayout) -> Result<(), LibraryError> {
52	let application_layout = M::LAYOUT;
53
54	// `abi_stable` normally checks if the library has an equal or newer version, but
55	// this doesn't work for a plugin system. We want to inverse behaviour, where the
56	// library can be *older* than the caller, missing certain functionality.
57	//
58	// By passing the loaded lib's layout as the "interface" we achieve this,
59	// checking compatibility in reverse.
60	check_layout_compatibility(plugin_layout, application_layout).map_err(|e| {
61		let formatted = RBoxError::new(e).to_formatted_error();
62		LibraryError::AbiInstability(formatted)
63	})
64}
65
66fn check_module_version<M: RootModule>(header: &'static LibHeader) -> Result<(), LibraryError> {
67	let app_version = VersionNumber::new(M::VERSION_STRINGS)?;
68	let plugin_version = VersionNumber::new(header.version_strings())?;
69
70	if !are_compatible(&app_version, &plugin_version) {
71		return Err(LibraryError::IncompatibleVersionNumber {
72			library_name: M::NAME,
73			expected_version: app_version,
74			actual_version: plugin_version,
75		});
76	}
77
78	Ok(())
79}
80
81/// Checks like normal semver, except patch bumbs in 0.x are acceptable.
82fn are_compatible(app: &VersionNumber, plugin: &VersionNumber) -> bool {
83	app.major == plugin.major
84		&& app.minor >= plugin.minor
85		&& (app.major != 0 || app.minor == plugin.minor)
86		&& (app.minor != plugin.minor || app.patch >= plugin.patch)
87}
88
89#[cfg(test)]
90mod test {
91	use super::*;
92	use abi_stable::sabi_types::VersionStrings;
93	use rstest::rstest;
94
95	#[rstest]
96	// major
97	#[case("1.0.0", "1.0.0", true)]
98	#[case("1.0.0", "2.0.0", false)]
99	// minor (>=1.0)
100	#[case("1.1.0", "1.1.0", true)]
101	#[case("1.2.0", "1.1.0", true)]
102	#[case("1.0.0", "1.1.0", false)]
103	#[case("1.0.7", "1.1.5", false)]
104	// patch (>=1.0)
105	#[case("1.0.1", "1.0.0", true)]
106	#[case("1.1.0", "1.0.4", true)]
107	#[case("1.0.2", "1.0.4", false)]
108	// minor (<1.0)
109	#[case("0.1.0", "0.1.0", true)]
110	#[case("0.2.0", "0.1.0", false)]
111	#[case("0.0.0", "0.1.0", false)]
112	#[case("0.0.7", "0.1.5", false)]
113	// patch (<1.0)
114	#[case("0.0.1", "0.0.0", true)]
115	#[case("0.3.4", "0.3.1", true)]
116	#[case("0.1.0", "0.0.4", false)]
117	#[case("0.0.2", "0.0.4", false)]
118	fn version_compatibility(
119		#[case] application: &'static str,
120		#[case] plugin: &'static str,
121		#[case] compatible: bool,
122	) {
123		let app_version = VersionStrings::new(application).parsed().expect("input error");
124		let plugin_version = VersionStrings::new(plugin).parsed().expect("input error");
125
126		assert_eq!(compatible, are_compatible(&app_version, &plugin_version));
127	}
128}