Skip to main content

crush_core/plugin/
registry.rs

1//! Plugin registry for runtime plugin management
2//!
3//! Provides a thread-safe registry that wraps the compile-time `COMPRESSION_ALGORITHMS`
4//! distributed slice with runtime validation and management capabilities.
5
6use crate::error::{PluginError, Result};
7use crate::plugin::{CompressionAlgorithm, PluginMetadata, COMPRESSION_ALGORITHMS};
8use std::collections::HashMap;
9use std::sync::RwLock;
10
11/// Thread-safe plugin registry
12///
13/// Wraps the compile-time plugin slice with runtime validation and lookup capabilities.
14/// Uses `RwLock` for concurrent read access with exclusive write access during initialization.
15static PLUGIN_REGISTRY: RwLock<Option<PluginRegistry>> = RwLock::new(None);
16
17/// Plugin registry state
18struct PluginRegistry {
19    /// Maps magic numbers to plugin references
20    plugins: HashMap<[u8; 4], &'static dyn CompressionAlgorithm>,
21    /// Whether the registry has been initialized
22    initialized: bool,
23}
24
25impl PluginRegistry {
26    /// Create a new empty registry
27    fn new() -> Self {
28        Self {
29            plugins: HashMap::new(),
30            initialized: false,
31        }
32    }
33
34    /// Clear the registry (used for re-initialization)
35    fn clear(&mut self) {
36        self.plugins.clear();
37        self.initialized = false;
38    }
39
40    /// Register a plugin after validation
41    fn register(&mut self, plugin: &'static dyn CompressionAlgorithm) -> Result<()> {
42        let metadata = plugin.metadata();
43
44        // Validate metadata
45        if metadata.name.is_empty() {
46            return Err(
47                PluginError::InvalidMetadata("Plugin name cannot be empty".to_string()).into(),
48            );
49        }
50
51        if metadata.version.is_empty() {
52            return Err(
53                PluginError::InvalidMetadata("Plugin version cannot be empty".to_string()).into(),
54            );
55        }
56
57        if metadata.throughput <= 0.0 {
58            return Err(PluginError::InvalidMetadata(format!(
59                "Plugin {} has invalid throughput: {}",
60                metadata.name, metadata.throughput
61            ))
62            .into());
63        }
64
65        if metadata.compression_ratio <= 0.0 || metadata.compression_ratio > 1.0 {
66            return Err(PluginError::InvalidMetadata(format!(
67                "Plugin {} has invalid compression ratio: {}",
68                metadata.name, metadata.compression_ratio
69            ))
70            .into());
71        }
72
73        // Check for duplicate magic number
74        if let Some(existing) = self.plugins.get(&metadata.magic_number) {
75            let existing_metadata = existing.metadata();
76            eprintln!(
77                "Warning: Duplicate magic number {:02X?} detected. \
78                 Plugin '{}' conflicts with '{}'. \
79                 Using first-registered plugin '{}'.",
80                metadata.magic_number,
81                metadata.name,
82                existing_metadata.name,
83                existing_metadata.name
84            );
85            return Ok(()); // Skip registration, keep first-registered
86        }
87
88        // Register the plugin
89        self.plugins.insert(metadata.magic_number, plugin);
90
91        Ok(())
92    }
93
94    /// Get all registered plugins
95    fn list(&self) -> Vec<PluginMetadata> {
96        self.plugins
97            .values()
98            .map(|plugin| plugin.metadata())
99            .collect()
100    }
101
102    /// Get plugin by magic number
103    fn get(&self, magic: [u8; 4]) -> Option<&'static dyn CompressionAlgorithm> {
104        self.plugins.get(&magic).copied()
105    }
106}
107
108/// Initialize the plugin system
109///
110/// Scans all compile-time registered plugins from the `COMPRESSION_ALGORITHMS`
111/// distributed slice, validates their metadata, and registers them in the runtime registry.
112///
113/// # Behavior
114///
115/// - Can be called multiple times (re-initialization clears and re-scans)
116/// - Detects duplicate magic numbers and logs warnings
117/// - Validates plugin metadata (non-empty names, valid performance metrics)
118/// - Thread-safe via `RwLock`
119///
120/// # Errors
121///
122/// Returns an error if:
123/// - A plugin has invalid metadata (empty name/version, invalid performance metrics)
124/// - Lock acquisition fails (should never happen in single-threaded tests)
125///
126/// # Examples
127///
128/// ```
129/// use crush_core::init_plugins;
130///
131/// init_plugins().expect("Failed to initialize plugins");
132/// ```
133pub fn init_plugins() -> Result<()> {
134    let mut guard = PLUGIN_REGISTRY
135        .write()
136        .map_err(|_| PluginError::OperationFailed("Failed to acquire registry lock".to_string()))?;
137
138    // Create or clear registry
139    let registry = guard.get_or_insert_with(PluginRegistry::new);
140
141    // Clear existing plugins (support re-initialization)
142    if registry.initialized {
143        registry.clear();
144    }
145
146    // Scan and register all compile-time plugins
147    for &plugin in COMPRESSION_ALGORITHMS {
148        // Validate and register (duplicates are logged and skipped)
149        registry.register(plugin)?;
150    }
151
152    registry.initialized = true;
153
154    Ok(())
155}
156
157/// List all registered plugins
158///
159/// Returns metadata for all plugins currently registered in the runtime registry.
160///
161/// # Returns
162///
163/// A vector of `PluginMetadata` for all registered plugins. If `init_plugins()`
164/// has not been called yet, returns an empty vector.
165///
166/// # Examples
167///
168/// ```
169/// use crush_core::{init_plugins, list_plugins};
170///
171/// init_plugins().expect("Failed to initialize plugins");
172/// let plugins = list_plugins();
173///
174/// for plugin in plugins {
175///     println!("Plugin: {} v{}", plugin.name, plugin.version);
176/// }
177/// ```
178#[must_use]
179pub fn list_plugins() -> Vec<PluginMetadata> {
180    PLUGIN_REGISTRY
181        .read()
182        .ok()
183        .and_then(|guard| guard.as_ref().map(PluginRegistry::list))
184        .unwrap_or_default()
185}
186
187/// Get a plugin by magic number (internal use)
188///
189/// Used by decompression to route to the correct plugin based on file header.
190pub(crate) fn get_plugin_by_magic(magic: [u8; 4]) -> Option<&'static dyn CompressionAlgorithm> {
191    PLUGIN_REGISTRY
192        .read()
193        .ok()
194        .and_then(|guard| guard.as_ref().and_then(|registry| registry.get(magic)))
195}
196
197/// Get the default plugin (internal use)
198///
199/// Returns the DEFLATE plugin (magic 0x43525100) for use by the `compress()` API.
200pub(crate) fn get_default_plugin() -> Option<&'static dyn CompressionAlgorithm> {
201    get_plugin_by_magic([0x43, 0x52, 0x01, 0x00])
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207
208    /// Macro that creates a one-shot `InvalidPlugin` with the given metadata fields,
209    /// leaks it, and returns a `&'static dyn CompressionAlgorithm`.
210    ///
211    /// Each invocation creates a fresh struct in its own scope, so multiple
212    /// expansions in different test functions do not conflict.
213    macro_rules! make_invalid_plugin {
214        ($name:expr, $version:expr, $magic:expr, $throughput:expr, $ratio:expr) => {{
215            struct InvalidPlugin;
216            impl CompressionAlgorithm for InvalidPlugin {
217                fn name(&self) -> &'static str {
218                    "test_invalid"
219                }
220                fn metadata(&self) -> PluginMetadata {
221                    PluginMetadata {
222                        name: $name,
223                        version: $version,
224                        magic_number: $magic,
225                        throughput: $throughput,
226                        compression_ratio: $ratio,
227                        description: "Test",
228                    }
229                }
230                fn compress(
231                    &self,
232                    _input: &[u8],
233                    _cancel_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
234                ) -> crate::error::Result<Vec<u8>> {
235                    Ok(vec![])
236                }
237                fn decompress(
238                    &self,
239                    _input: &[u8],
240                    _cancel_flag: std::sync::Arc<std::sync::atomic::AtomicBool>,
241                ) -> crate::error::Result<Vec<u8>> {
242                    Ok(vec![])
243                }
244                fn detect(&self, _file_header: &[u8]) -> bool {
245                    false
246                }
247            }
248            let plugin: &'static dyn CompressionAlgorithm = Box::leak(Box::new(InvalidPlugin));
249            plugin
250        }};
251    }
252
253    #[test]
254    #[allow(clippy::unwrap_used)]
255    fn test_init_plugins() {
256        init_plugins().unwrap();
257
258        let plugins = list_plugins();
259        assert!(
260            !plugins.is_empty(),
261            "Should discover at least DEFLATE plugin"
262        );
263    }
264
265    #[test]
266    #[allow(clippy::unwrap_used)]
267    fn test_get_default_plugin() {
268        init_plugins().unwrap();
269
270        let plugin = get_default_plugin();
271        assert!(
272            plugin.is_some(),
273            "Default DEFLATE plugin should be available"
274        );
275
276        let plugin = plugin.unwrap();
277        assert_eq!(plugin.name(), "deflate");
278    }
279
280    #[test]
281    #[allow(clippy::unwrap_used)]
282    fn test_reinitialization() {
283        init_plugins().unwrap();
284        let count1 = list_plugins().len();
285
286        init_plugins().unwrap();
287        let count2 = list_plugins().len();
288
289        assert_eq!(
290            count1, count2,
291            "Re-initialization should maintain plugin count"
292        );
293    }
294
295    #[test]
296    #[allow(clippy::unwrap_used)]
297    fn test_registry_validation_empty_name() {
298        let mut registry = PluginRegistry::new();
299        let plugin = make_invalid_plugin!("", "1.0.0", [0x43, 0x52, 0x01, 0xFF], 100.0, 0.5);
300        let result = registry.register(plugin);
301
302        assert!(result.is_err());
303        let err_msg = result.unwrap_err().to_string();
304        assert!(err_msg.contains("name cannot be empty"));
305    }
306
307    #[test]
308    #[allow(clippy::unwrap_used)]
309    fn test_registry_validation_empty_version() {
310        let mut registry = PluginRegistry::new();
311        let plugin = make_invalid_plugin!("test", "", [0x43, 0x52, 0x01, 0xFE], 100.0, 0.5);
312        let result = registry.register(plugin);
313
314        assert!(result.is_err());
315        let err_msg = result.unwrap_err().to_string();
316        assert!(err_msg.contains("version cannot be empty"));
317    }
318
319    #[test]
320    #[allow(clippy::unwrap_used)]
321    fn test_registry_validation_invalid_throughput() {
322        let mut registry = PluginRegistry::new();
323        // Negative throughput is invalid
324        let plugin = make_invalid_plugin!("test", "1.0.0", [0x43, 0x52, 0x01, 0xFD], -10.0, 0.5);
325        let result = registry.register(plugin);
326
327        assert!(result.is_err());
328        let err_msg = result.unwrap_err().to_string();
329        assert!(err_msg.contains("invalid throughput"));
330    }
331
332    #[test]
333    #[allow(clippy::unwrap_used)]
334    fn test_registry_validation_invalid_compression_ratio() {
335        let mut registry = PluginRegistry::new();
336        // Ratio > 1.0 is invalid
337        let plugin = make_invalid_plugin!("test", "1.0.0", [0x43, 0x52, 0x01, 0xFC], 100.0, 1.5);
338        let result = registry.register(plugin);
339
340        assert!(result.is_err());
341        let err_msg = result.unwrap_err().to_string();
342        assert!(err_msg.contains("invalid compression ratio"));
343    }
344
345    #[test]
346    #[allow(clippy::unwrap_used)]
347    fn test_get_plugin_by_magic() {
348        init_plugins().unwrap();
349
350        // Test retrieving DEFLATE plugin by magic number
351        let plugin = get_plugin_by_magic([0x43, 0x52, 0x01, 0x00]);
352        assert!(plugin.is_some());
353        assert_eq!(plugin.unwrap().name(), "deflate");
354
355        // Test non-existent magic number
356        let plugin = get_plugin_by_magic([0xFF, 0xFF, 0xFF, 0xFF]);
357        assert!(plugin.is_none());
358    }
359
360    #[test]
361    fn test_list_plugins_before_init() {
362        // Clear registry by creating new one (this is a bit hacky for testing)
363        // In reality, users should always call init_plugins() first
364        let plugins = list_plugins();
365        // Should return empty list or default based on registry state
366        // Just verify it doesn't panic
367        let _ = plugins.len();
368    }
369
370    #[test]
371    #[allow(clippy::unwrap_used)]
372    fn test_registry_clear() {
373        init_plugins().unwrap();
374        let count1 = list_plugins().len();
375        assert!(count1 > 0);
376
377        // Re-initialize to trigger clear
378        init_plugins().unwrap();
379        let count2 = list_plugins().len();
380        assert_eq!(count1, count2);
381    }
382}