1use std::ffi::OsStr;
13use std::path::{Path, PathBuf};
14use std::sync::Arc;
15
16use libloading::Library;
17use thiserror::Error;
18
19use rpi_plugin_sdk::{
20 LegacyPluginApiV1, LegacyRpiPluginRegister, PluginApiVt, RpiPluginRegister,
21 LEGACY_PLUGIN_ABI_VERSION, RPI_PLUGIN_ABI_VERSION,
22};
23
24use crate::registry::{ExtensionRegistry, RegistrySnapshot};
25use crate::{
26 clear_current_api, set_current_api, ActionBridge, HostApi, NullDiagnostics, PluginDiagnostics,
27};
28
29#[derive(Debug, Error)]
36pub enum PluginLoadError {
37 #[error("could not open library {path}: {source}")]
38 Open {
39 path: PathBuf,
40 #[source]
41 source: libloading::Error,
42 },
43 #[error(
44 "neither `rpi_plugin_register_v2` nor legacy `rpi_plugin_register` was found in {path} (v2: {v2_error}; v1: {legacy_error})"
45 )]
46 Symbol {
47 path: PathBuf,
48 v2_error: String,
49 legacy_error: String,
50 },
51 #[error("register returned nonzero code {code} for {path}")]
52 RegisterReturned { path: PathBuf, code: i32 },
53 #[error(
55 "ABI version mismatch in {path}: plugin built for {plugin_version}, host is {host_version}"
56 )]
57 AbiVersionMismatch {
58 path: PathBuf,
59 plugin_version: u32,
60 host_version: u32,
61 },
62}
63
64pub struct LoadedPlugin {
72 pub library: Library,
74 pub path: PathBuf,
76 pub abi_version: u32,
78 pub registry: ExtensionRegistry,
81}
82
83#[derive(Clone, Copy)]
84enum RegisterEntrypoint {
85 V2(RpiPluginRegister),
86 V1(LegacyRpiPluginRegister),
87}
88
89impl RegisterEntrypoint {
90 fn abi_version(self) -> u32 {
91 match self {
92 Self::V2(_) => RPI_PLUGIN_ABI_VERSION,
93 Self::V1(_) => LEGACY_PLUGIN_ABI_VERSION,
94 }
95 }
96}
97
98fn select_register<V, L, E>(
99 v2: Result<V, E>,
100 legacy: impl FnOnce() -> Result<L, E>,
101) -> Result<Result<V, L>, (E, E)> {
102 match v2 {
103 Ok(register) => Ok(Ok(register)),
104 Err(v2_error) => match legacy() {
105 Ok(register) => Ok(Err(register)),
106 Err(legacy_error) => Err((v2_error, legacy_error)),
107 },
108 }
109}
110
111fn call_register(entrypoint: RegisterEntrypoint, host_api: &Arc<HostApi>) -> i32 {
112 match entrypoint {
113 RegisterEntrypoint::V2(register) => {
114 let vtable = host_api.build_vtable();
115 let vt_ref: &PluginApiVt = &vtable;
116 register(vt_ref as *const PluginApiVt, RPI_PLUGIN_ABI_VERSION)
117 }
118 RegisterEntrypoint::V1(register) => {
119 let vtable = host_api.build_legacy_vtable();
120 let vt_ref: &LegacyPluginApiV1 = &vtable;
121 register(
122 vt_ref as *const LegacyPluginApiV1,
123 LEGACY_PLUGIN_ABI_VERSION,
124 )
125 }
126 }
127}
128
129pub fn load_one(
150 path: impl AsRef<Path>,
151 diagnostics: Arc<dyn PluginDiagnostics>,
152 action_bridge: Option<Arc<ActionBridge>>,
153) -> Result<LoadedPlugin, PluginLoadError> {
154 let path = path.as_ref().to_path_buf();
155 let library = unsafe { Library::new(&path) }.map_err(|e| PluginLoadError::Open {
157 path: path.clone(),
158 source: e,
159 })?;
160
161 let entrypoint = unsafe {
164 select_register(
165 library
166 .get::<RpiPluginRegister>(rpi_plugin_sdk::REGISTER_SYMBOL_V2)
167 .map(|symbol| *symbol),
168 || {
169 library
170 .get::<LegacyRpiPluginRegister>(rpi_plugin_sdk::LEGACY_REGISTER_SYMBOL)
171 .map(|symbol| *symbol)
172 },
173 )
174 }
175 .map(|selected| match selected {
176 Ok(register) => RegisterEntrypoint::V2(register),
177 Err(register) => RegisterEntrypoint::V1(register),
178 })
179 .map_err(|(v2_error, legacy_error)| PluginLoadError::Symbol {
180 path: path.clone(),
181 v2_error: v2_error.to_string(),
182 legacy_error: legacy_error.to_string(),
183 })?;
184 let abi_version = entrypoint.abi_version();
185
186 let registry = ExtensionRegistry::new();
188 let host_api = match action_bridge {
189 Some(bridge) => HostApi::with_action_bridge(registry, Arc::clone(&diagnostics), bridge),
190 None => HostApi::new(registry, Arc::clone(&diagnostics)),
191 };
192 unsafe { set_current_api(&host_api) };
197 let rc = call_register(entrypoint, &host_api);
202 clear_current_api();
203
204 if rc != 0 {
205 diagnostics.warn(&format!(
211 "plugin {} ABI v{} register returned code {} — skipped",
212 path.display(),
213 abi_version,
214 rc
215 ));
216 return Err(PluginLoadError::RegisterReturned { path, code: rc });
217 }
218
219 let registry = host_api
223 .take_registry()
224 .ok_or_else(|| PluginLoadError::RegisterReturned {
225 path: path.clone(),
226 code: -2,
227 })?;
228
229 tracing::debug!(path = %path.display(), abi_version, "loaded native plugin");
230
231 Ok(LoadedPlugin {
232 library,
233 path,
234 abi_version,
235 registry,
236 })
237}
238
239const CDYLIB_EXTS: &[&str] = &["dll", "so", "dylib", "pyd"];
245
246pub fn load_dir(
253 dir: impl AsRef<Path>,
254 diagnostics: Arc<dyn PluginDiagnostics>,
255 action_bridge: Option<Arc<ActionBridge>>,
256) -> Vec<LoadedPlugin> {
257 let dir = dir.as_ref();
258 let mut out = Vec::new();
259 let read = match std::fs::read_dir(dir) {
260 Ok(r) => r,
261 Err(e) => {
262 diagnostics.warn(&format!(
263 "extensions dir {} unreadable: {}",
264 dir.display(),
265 e
266 ));
267 return out;
268 }
269 };
270 for entry in read.flatten() {
271 let path = entry.path();
272 if !is_cdylib(&path) {
273 continue;
274 }
275 match load_one(&path, Arc::clone(&diagnostics), action_bridge.clone()) {
276 Ok(p) => out.push(p),
277 Err(e) => diagnostics.warn(&format!("skipped plugin {}: {e}", path.display())),
278 }
279 }
280 out
281}
282
283fn is_cdylib(path: &Path) -> bool {
285 path.extension()
286 .and_then(OsStr::to_str)
287 .map(|ext| CDYLIB_EXTS.iter().any(|e| e.eq_ignore_ascii_case(ext)))
288 .unwrap_or(false)
289}
290
291pub fn merge_registries(plugins: &mut [LoadedPlugin]) -> ExtensionRegistry {
296 let mut session = ExtensionRegistry::new();
297 for p in plugins.iter_mut() {
305 let taken = std::mem::take(&mut p.registry);
307 session.absorb(taken);
308 }
309 session
310}
311
312pub struct PluginKeepalive {
326 #[allow(dead_code)]
327 libraries: Vec<Library>,
328 #[allow(dead_code)]
335 action_bridge: Option<Arc<ActionBridge>>,
336}
337
338impl PluginKeepalive {
339 pub fn new(libraries: Vec<Library>, action_bridge: Option<Arc<ActionBridge>>) -> Self {
343 Self {
344 libraries,
345 action_bridge,
346 }
347 }
348
349 pub fn empty() -> Arc<Self> {
353 Arc::new(Self::new(Vec::new(), None))
354 }
355}
356
357#[derive(Clone)]
373pub struct ExtensionSession {
374 keepalive: Arc<PluginKeepalive>,
375 snapshot: Option<Arc<RegistrySnapshot>>,
376 loaded_paths: Vec<PathBuf>,
377 action_bridge: Option<Arc<ActionBridge>>,
384}
385
386impl ExtensionSession {
387 pub fn from_parts(
391 snapshot: Arc<RegistrySnapshot>,
392 keepalive: Arc<PluginKeepalive>,
393 loaded_paths: Vec<PathBuf>,
394 action_bridge: Option<Arc<ActionBridge>>,
395 ) -> Self {
396 Self {
397 keepalive,
398 snapshot: Some(snapshot),
399 loaded_paths,
400 action_bridge,
401 }
402 }
403
404 pub fn none() -> Self {
406 Self {
407 keepalive: Arc::new(PluginKeepalive::new(Vec::new(), None)),
408 snapshot: None,
409 loaded_paths: Vec::new(),
410 action_bridge: None,
411 }
412 }
413
414 pub fn keepalive(&self) -> Arc<PluginKeepalive> {
417 Arc::clone(&self.keepalive)
418 }
419
420 pub fn snapshot(&self) -> Option<&RegistrySnapshot> {
425 self.snapshot.as_deref()
426 }
427
428 pub fn snapshot_arc(&self) -> Option<Arc<RegistrySnapshot>> {
433 self.snapshot.clone()
434 }
435
436 pub fn loaded_paths(&self) -> &[PathBuf] {
438 &self.loaded_paths
439 }
440
441 pub fn is_empty(&self) -> bool {
443 self.loaded_paths.is_empty()
444 }
445
446 pub fn summary(&self) -> Option<String> {
449 if self.is_empty() {
450 return None;
451 }
452 let tools = self.snapshot.as_ref().map(|s| s.tools().len()).unwrap_or(0);
453 Some(format!(
454 "loaded {} plugin(s) ({} tool(s))",
455 self.loaded_paths.len(),
456 tools
457 ))
458 }
459
460 pub fn action_bridge(&self) -> Option<Arc<ActionBridge>> {
468 self.action_bridge.clone()
469 }
470}
471
472pub fn load_session(
487 dirs: &[PathBuf],
488 diagnostics: Arc<dyn PluginDiagnostics>,
489 action_bridge: Option<Arc<ActionBridge>>,
490) -> ExtensionSession {
491 load_session_mixed(dirs, &[], diagnostics, action_bridge)
492}
493
494pub fn load_session_mixed(
498 dirs: &[PathBuf],
499 files: &[PathBuf],
500 diagnostics: Arc<dyn PluginDiagnostics>,
501 action_bridge: Option<Arc<ActionBridge>>,
502) -> ExtensionSession {
503 let mut loaded: Vec<LoadedPlugin> = Vec::new();
504 for dir in dirs {
505 loaded.extend(load_dir(
506 dir,
507 Arc::clone(&diagnostics),
508 action_bridge.clone(),
509 ));
510 }
511 for f in files {
512 if let Ok(plugin) = load_one(f, Arc::clone(&diagnostics), action_bridge.clone()) {
513 loaded.push(plugin);
514 }
515 }
516 if loaded.is_empty() {
517 return ExtensionSession::none();
518 }
519 let loaded_paths: Vec<PathBuf> = loaded.iter().map(|p| p.path.clone()).collect();
520 let session_registry = merge_registries(&mut loaded);
524 let mut libs: Vec<Library> = Vec::with_capacity(loaded.len());
528 for p in loaded {
529 let LoadedPlugin {
530 library,
531 registry: _,
532 path: _,
533 abi_version: _,
534 } = p;
535 libs.push(library);
536 }
537 let snapshot = Arc::new(session_registry.snapshot());
538 ExtensionSession {
539 keepalive: Arc::new(PluginKeepalive::new(libs, action_bridge.clone())),
540 snapshot: Some(snapshot),
541 loaded_paths,
542 action_bridge,
543 }
544}
545
546#[cfg(test)]
551mod tests {
552 use super::*;
553 use std::fs;
554 use std::process::Command;
555 use std::sync::atomic::{AtomicI32, AtomicU32, AtomicUsize, Ordering};
556 use std::sync::Mutex;
557
558 static V2_CALLS: AtomicUsize = AtomicUsize::new(0);
559 static V1_CALLS: AtomicUsize = AtomicUsize::new(0);
560 static V1_LOOKUPS: AtomicUsize = AtomicUsize::new(0);
561 static V2_SEEN_VERSION: AtomicU32 = AtomicU32::new(0);
562 static V1_SEEN_VERSION: AtomicU32 = AtomicU32::new(0);
563 static V2_RETURN: AtomicI32 = AtomicI32::new(0);
564
565 extern "C" fn test_register_v2(api: *const PluginApiVt, abi_version: u32) -> i32 {
566 if api.is_null() {
567 return -99;
568 }
569 V2_CALLS.fetch_add(1, Ordering::SeqCst);
570 V2_SEEN_VERSION.store(abi_version, Ordering::SeqCst);
571 V2_RETURN.load(Ordering::SeqCst)
572 }
573
574 extern "C" fn test_register_v1(api: *const LegacyPluginApiV1, abi_version: u32) -> i32 {
575 if api.is_null() {
576 return -99;
577 }
578 V1_CALLS.fetch_add(1, Ordering::SeqCst);
579 V1_SEEN_VERSION.store(abi_version, Ordering::SeqCst);
580 0
581 }
582
583 #[derive(Default)]
584 struct CapturingDiag {
585 warns: Mutex<Vec<String>>,
586 }
587 impl PluginDiagnostics for CapturingDiag {
588 fn warn(&self, msg: &str) {
589 self.warns.lock().unwrap().push(msg.to_string());
590 }
591 fn unsupported(&self, msg: &str) {
592 self.warn(msg);
593 }
594 }
595
596 fn test_host_api() -> Arc<HostApi> {
597 HostApi::new(ExtensionRegistry::new(), Arc::new(CapturingDiag::default()))
598 }
599
600 struct CdylibFixture {
601 dir: PathBuf,
602 path: PathBuf,
603 }
604
605 impl Drop for CdylibFixture {
606 fn drop(&mut self) {
607 let _ = fs::remove_dir_all(&self.dir);
608 }
609 }
610
611 fn build_cdylib_fixture(name: &str, source: &str) -> CdylibFixture {
612 static NEXT_FIXTURE: AtomicUsize = AtomicUsize::new(0);
613 let unique = NEXT_FIXTURE.fetch_add(1, Ordering::SeqCst);
614 let dir = std::env::temp_dir().join(format!(
615 "rpi-abi-loader-{}-{name}-{unique}",
616 std::process::id()
617 ));
618 fs::create_dir_all(&dir).expect("create ABI fixture directory");
619 let source_path = dir.join("fixture.rs");
620 fs::write(&source_path, source).expect("write ABI fixture source");
621 let filename = if cfg!(windows) {
622 format!("{name}.dll")
623 } else if cfg!(target_os = "macos") {
624 format!("lib{name}.dylib")
625 } else {
626 format!("lib{name}.so")
627 };
628 let path = dir.join(filename);
629 let output = Command::new(std::env::var_os("RUSTC").unwrap_or_else(|| "rustc".into()))
630 .arg("--crate-name")
631 .arg(name)
632 .arg("--crate-type")
633 .arg("cdylib")
634 .arg("--edition")
635 .arg("2021")
636 .arg(&source_path)
637 .arg("-o")
638 .arg(&path)
639 .output()
640 .expect("run rustc for ABI fixture");
641 assert!(
642 output.status.success(),
643 "fixture build failed: {}",
644 String::from_utf8_lossy(&output.stderr)
645 );
646 CdylibFixture { dir, path }
647 }
648
649 const V011_PLUGIN_SOURCE: &str = r#"
656use std::ffi::c_void;
657
658#[repr(C)]
659#[derive(Clone, Copy)]
660pub struct StbString {
661 pub ptr: *mut u8,
662 pub len: usize,
663}
664
665#[repr(C)]
666#[derive(Clone, Copy)]
667pub struct StbStringRef {
668 pub ptr: *const u8,
669 pub len: usize,
670}
671
672#[repr(u32)]
673#[derive(Clone, Copy)]
674pub enum RuntimeActionId {
675 SendMessage = 0,
676 SendUserMessage = 1,
677 AppendEntry = 2,
678 SetSessionName = 3,
679 GetActiveTools = 4,
680 SetActiveTools = 5,
681 SetModel = 6,
682 GetThinkingLevel = 7,
683 SetThinkingLevel = 8,
684 Compact = 9,
685 GetSystemPrompt = 10,
686 NewSession = 11,
687 Fork = 12,
688 NavigateTree = 13,
689 SwitchSession = 14,
690 Reload = 15,
691}
692
693pub type FreeStringFn = extern "C" fn(StbString);
694pub type OpaqueRegistrarFn = extern "C" fn();
695pub type RuntimeActionFn = extern "C" fn(
696 action: RuntimeActionId,
697 args_json: StbStringRef,
698 out: *mut StbString,
699 user_data: *mut c_void,
700) -> i32;
701
702#[repr(C)]
703pub struct PluginApiVt {
704 pub free_string: FreeStringFn,
705 pub register_tool: Option<OpaqueRegistrarFn>,
706 pub register_command: Option<OpaqueRegistrarFn>,
707 pub register_shortcut: Option<OpaqueRegistrarFn>,
708 pub register_flag: Option<OpaqueRegistrarFn>,
709 pub register_provider: Option<OpaqueRegistrarFn>,
710 pub register_message_renderer: Option<OpaqueRegistrarFn>,
711 pub register_markdown_transformer: Option<OpaqueRegistrarFn>,
712 pub register_entry_renderer: Option<OpaqueRegistrarFn>,
713 pub register_event_handler: Option<OpaqueRegistrarFn>,
714 pub register_resources_discover: Option<OpaqueRegistrarFn>,
715 pub runtime_action: RuntimeActionFn,
716 pub dispatch_event: Option<OpaqueRegistrarFn>,
717 pub user_data: *mut c_void,
718}
719
720#[no_mangle]
721pub extern "C" fn rpi_plugin_register(api: *const PluginApiVt, abi_version: u32) -> i32 {
722 if abi_version != 1 {
723 return 91;
724 }
725 if api.is_null() {
726 return 92;
727 }
728
729 let api = unsafe { &*api };
730 let args = b"{}";
731 let args_ref = StbStringRef {
732 ptr: args.as_ptr(),
733 len: args.len(),
734 };
735 let mut out = StbString {
736 ptr: std::ptr::null_mut(),
737 len: 0,
738 };
739 let rc = (api.runtime_action)(
740 RuntimeActionId::Reload,
741 args_ref,
742 &mut out,
743 api.user_data,
744 );
745 if !out.ptr.is_null() {
746 (api.free_string)(out);
747 }
748 rc
749}
750"#;
751
752 struct LegacyReloadHost {
753 reloads: Arc<AtomicUsize>,
754 }
755
756 fn unexpected_legacy_action() -> Result<serde_json::Value, String> {
757 Err("unexpected action from ABI v1 fixture".to_string())
758 }
759
760 #[async_trait::async_trait]
761 impl crate::RuntimeActionHost for LegacyReloadHost {
762 async fn send_message(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
763 unexpected_legacy_action()
764 }
765
766 async fn send_user_message(
767 &self,
768 _: serde_json::Value,
769 ) -> Result<serde_json::Value, String> {
770 unexpected_legacy_action()
771 }
772
773 async fn append_entry(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
774 unexpected_legacy_action()
775 }
776
777 async fn set_session_name(
778 &self,
779 _: serde_json::Value,
780 ) -> Result<serde_json::Value, String> {
781 unexpected_legacy_action()
782 }
783
784 async fn get_active_tools(
785 &self,
786 _: serde_json::Value,
787 ) -> Result<serde_json::Value, String> {
788 unexpected_legacy_action()
789 }
790
791 async fn set_active_tools(
792 &self,
793 _: serde_json::Value,
794 ) -> Result<serde_json::Value, String> {
795 unexpected_legacy_action()
796 }
797
798 async fn set_model(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
799 unexpected_legacy_action()
800 }
801
802 async fn get_thinking_level(
803 &self,
804 _: serde_json::Value,
805 ) -> Result<serde_json::Value, String> {
806 unexpected_legacy_action()
807 }
808
809 async fn set_thinking_level(
810 &self,
811 _: serde_json::Value,
812 ) -> Result<serde_json::Value, String> {
813 unexpected_legacy_action()
814 }
815
816 async fn compact(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
817 unexpected_legacy_action()
818 }
819
820 async fn get_system_prompt(
821 &self,
822 _: serde_json::Value,
823 ) -> Result<serde_json::Value, String> {
824 unexpected_legacy_action()
825 }
826
827 async fn new_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
828 unexpected_legacy_action()
829 }
830
831 async fn fork(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
832 unexpected_legacy_action()
833 }
834
835 async fn navigate_tree(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
836 unexpected_legacy_action()
837 }
838
839 async fn switch_session(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
840 unexpected_legacy_action()
841 }
842
843 async fn reload(&self, _: serde_json::Value) -> Result<serde_json::Value, String> {
844 self.reloads.fetch_add(1, Ordering::SeqCst);
845 Ok(serde_json::Value::Null)
846 }
847 }
848
849 #[test]
850 fn entrypoint_selection_supports_v1_v2_and_never_falls_back_after_call() {
851 V2_CALLS.store(0, Ordering::SeqCst);
852 V1_CALLS.store(0, Ordering::SeqCst);
853 V1_LOOKUPS.store(0, Ordering::SeqCst);
854 V2_RETURN.store(0, Ordering::SeqCst);
855
856 let selected = select_register::<RpiPluginRegister, LegacyRpiPluginRegister, &str>(
857 Ok(test_register_v2),
858 || {
859 V1_LOOKUPS.fetch_add(1, Ordering::SeqCst);
860 Ok(test_register_v1)
861 },
862 )
863 .expect("v2 selected");
864 let entrypoint = match selected {
865 Ok(register) => RegisterEntrypoint::V2(register),
866 Err(register) => RegisterEntrypoint::V1(register),
867 };
868 assert_eq!(call_register(entrypoint, &test_host_api()), 0);
869 assert_eq!(V2_CALLS.load(Ordering::SeqCst), 1);
870 assert_eq!(V1_CALLS.load(Ordering::SeqCst), 0);
871 assert_eq!(V1_LOOKUPS.load(Ordering::SeqCst), 0);
872 assert_eq!(V2_SEEN_VERSION.load(Ordering::SeqCst), 2);
873
874 let selected = select_register::<RpiPluginRegister, LegacyRpiPluginRegister, &str>(
875 Err("v2 missing"),
876 || {
877 V1_LOOKUPS.fetch_add(1, Ordering::SeqCst);
878 Ok(test_register_v1)
879 },
880 )
881 .expect("legacy selected");
882 let entrypoint = match selected {
883 Ok(register) => RegisterEntrypoint::V2(register),
884 Err(register) => RegisterEntrypoint::V1(register),
885 };
886 assert_eq!(call_register(entrypoint, &test_host_api()), 0);
887 assert_eq!(V1_CALLS.load(Ordering::SeqCst), 1);
888 assert_eq!(V1_LOOKUPS.load(Ordering::SeqCst), 1);
889 assert_eq!(V1_SEEN_VERSION.load(Ordering::SeqCst), 1);
890
891 V2_RETURN.store(73, Ordering::SeqCst);
894 let selected = select_register::<RpiPluginRegister, LegacyRpiPluginRegister, &str>(
895 Ok(test_register_v2),
896 || {
897 V1_LOOKUPS.fetch_add(1, Ordering::SeqCst);
898 Ok(test_register_v1)
899 },
900 )
901 .expect("v2 selected even though its later call will fail");
902 let entrypoint = match selected {
903 Ok(register) => RegisterEntrypoint::V2(register),
904 Err(register) => RegisterEntrypoint::V1(register),
905 };
906 assert_eq!(call_register(entrypoint, &test_host_api()), 73);
907 assert_eq!(V2_CALLS.load(Ordering::SeqCst), 2);
908 assert_eq!(V1_CALLS.load(Ordering::SeqCst), 1);
909 assert_eq!(V1_LOOKUPS.load(Ordering::SeqCst), 1);
910 }
911
912 #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
913 async fn loads_real_v011_plugin_and_dispatches_old_enum_reload() {
914 let fixture = build_cdylib_fixture("abi_v011_real", V011_PLUGIN_SOURCE);
915 let reloads = Arc::new(AtomicUsize::new(0));
916 let host: Arc<dyn crate::RuntimeActionHost> = Arc::new(LegacyReloadHost {
917 reloads: Arc::clone(&reloads),
918 });
919 let bridge = ActionBridge::new(tokio::runtime::Handle::current(), host);
920 let diagnostics = Arc::new(CapturingDiag::default());
921
922 let loaded = load_one(
923 &fixture.path,
924 Arc::clone(&diagnostics) as Arc<dyn PluginDiagnostics>,
925 Some(bridge),
926 )
927 .expect("load plugin built against the vendored v0.1.11 ABI");
928
929 assert_eq!(loaded.abi_version, LEGACY_PLUGIN_ABI_VERSION);
930 assert_eq!(reloads.load(Ordering::SeqCst), 1);
931 assert!(diagnostics.warns.lock().unwrap().is_empty());
932 drop(loaded);
933 drop(fixture);
934 }
935
936 #[test]
937 fn load_one_supports_both_abis_prefers_v2_and_never_retries_failed_v2() {
938 const PREFIX: &str = "use std::ffi::c_void;\n";
939 let diag: Arc<dyn PluginDiagnostics> = Arc::new(CapturingDiag::default());
940
941 let v1 = build_cdylib_fixture(
942 "abi_v1_only",
943 &format!(
944 "{PREFIX}#[no_mangle]\npub extern \"C\" fn rpi_plugin_register(api: *const c_void, abi: u32) -> i32 {{ if !api.is_null() && abi == 1 {{ 0 }} else {{ 91 }} }}\n"
945 ),
946 );
947 let loaded_v1 = load_one(&v1.path, Arc::clone(&diag), None).expect("load ABI v1 plugin");
948 assert_eq!(loaded_v1.abi_version, 1);
949 drop(loaded_v1);
950 drop(v1);
951
952 let v2 = build_cdylib_fixture(
953 "abi_v2_only",
954 &format!(
955 "{PREFIX}#[no_mangle]\npub extern \"C\" fn rpi_plugin_register_v2(api: *const c_void, abi: u32) -> i32 {{ if !api.is_null() && abi == 2 {{ 0 }} else {{ 92 }} }}\n"
956 ),
957 );
958 let loaded_v2 = load_one(&v2.path, Arc::clone(&diag), None).expect("load ABI v2 plugin");
959 assert_eq!(loaded_v2.abi_version, 2);
960 drop(loaded_v2);
961 drop(v2);
962
963 let dual = build_cdylib_fixture(
964 "abi_dual",
965 &format!(
966 "{PREFIX}#[no_mangle]\npub extern \"C\" fn rpi_plugin_register(_: *const c_void, _: u32) -> i32 {{ 93 }}\n#[no_mangle]\npub extern \"C\" fn rpi_plugin_register_v2(api: *const c_void, abi: u32) -> i32 {{ if !api.is_null() && abi == 2 {{ 0 }} else {{ 94 }} }}\n"
967 ),
968 );
969 let loaded_dual =
970 load_one(&dual.path, Arc::clone(&diag), None).expect("dual-symbol plugin uses v2");
971 assert_eq!(loaded_dual.abi_version, 2);
972 drop(loaded_dual);
973 drop(dual);
974
975 let failed_v2 = build_cdylib_fixture(
976 "abi_v2_failure",
977 &format!(
978 "{PREFIX}#[no_mangle]\npub extern \"C\" fn rpi_plugin_register(_: *const c_void, _: u32) -> i32 {{ 0 }}\n#[no_mangle]\npub extern \"C\" fn rpi_plugin_register_v2(_: *const c_void, _: u32) -> i32 {{ 73 }}\n"
979 ),
980 );
981 let error = match load_one(&failed_v2.path, diag, None) {
982 Ok(_) => panic!("failed v2 registration must not fall back to v1"),
983 Err(error) => error,
984 };
985 assert!(matches!(
986 error,
987 PluginLoadError::RegisterReturned { code: 73, .. }
988 ));
989 drop(failed_v2);
990 }
991
992 #[test]
993 fn load_one_missing_file_reports_open_error() {
994 let diag: Arc<dyn PluginDiagnostics> = Arc::new(CapturingDiag::default());
995 let res = load_one("definitely_not_a_plugin.dll", diag, None);
996 assert!(matches!(res, Err(PluginLoadError::Open { .. })));
997 }
998
999 #[test]
1000 fn load_dir_missing_dir_returns_empty_and_warns() {
1001 let empty = load_dir(
1002 "no_such_dir_xyz",
1003 Arc::new(CapturingDiag::default()) as Arc<dyn PluginDiagnostics>,
1004 None,
1005 );
1006 assert!(empty.is_empty());
1007 }
1008
1009 #[test]
1010 fn is_cdylib_recognizes_extensions() {
1011 assert!(is_cdylib(Path::new("foo.dll")));
1012 assert!(is_cdylib(Path::new("foo.so")));
1013 assert!(is_cdylib(Path::new("foo.dylib")));
1014 assert!(is_cdylib(Path::new("FOO.DLL")));
1015 assert!(!is_cdylib(Path::new("foo.md")));
1016 assert!(!is_cdylib(Path::new("foo")));
1017 }
1018}
1019
1020#[allow(dead_code)]
1022fn _ensure_nulldiagnostics_referenced() -> Arc<dyn PluginDiagnostics> {
1023 Arc::new(NullDiagnostics)
1024}