//! Device plugin runtime for audiofiles. //! //! Loads TOML manifests (bundled + user) describing hardware sampler constraints, //! and optionally runs Rhai scripts for custom export logic. //! //! ## Why Rhai //! //! - **Sandboxed:** No filesystem, network, or FFI access from scripts. Unlike Lua or //! Python plugins, a malicious Rhai script cannot escape the sandbox. //! - **Resource-capped:** Configurable operation count, expression depth, and array size //! limits prevent infinite loops and memory exhaustion. //! - **Rust-native:** Compiles as a regular Rust crate — no external interpreter binary, //! no C bindings, no build-time code generation. //! //! # Architecture //! //! - `manifest` — TOML parsing into `DeviceProfile` //! - `engine` — sandboxed Rhai engine setup //! - `types` — Rhai-compatible wrapper types //! - `host_api` — functions registered into Rhai //! - `hooks` — compile and run Rhai scripts //! - `loader` — filesystem discovery of user plugins //! - `registry` — plugin storage and lookup //! - `bundled` — embedded bundled plugin manifests pub mod bundled; pub mod engine; pub mod error; pub mod hooks; pub mod host_api; pub mod loader; pub mod manifest; pub mod registry; pub mod types; use std::path::Path; use tracing::instrument; use error::PluginError; use hooks::CompiledHooks; use manifest::manifest_to_profile; use registry::{LoadedPlugin, PluginRegistry}; /// Create a fully loaded plugin registry with bundled + user plugins. /// /// User plugins are loaded from `~/.config/audiofiles/plugins/user/`. /// User plugins with the same device name override bundled ones. #[instrument(skip_all)] pub fn create_registry() -> Result { let mut registry = PluginRegistry::new(); // Load bundled plugins first bundled::load_bundled(&mut registry)?; // Load user plugins (override bundled if same name) if let Some(config_dir) = dirs::config_dir() { let user_plugins_dir = config_dir.join("audiofiles").join("plugins").join("user"); load_user_plugins(&mut registry, &user_plugins_dir)?; } Ok(registry) } /// Load user plugins from a directory into the registry. #[instrument(skip_all, fields(dir = ?dir))] pub fn load_user_plugins( registry: &mut PluginRegistry, dir: &Path, ) -> Result<(), PluginError> { let discovered = loader::discover_plugins(dir); for plugin in discovered { let profile = manifest_to_profile(&plugin.manifest)?; // Compile any hook scripts let hooks = compile_plugin_hooks(registry.engine(), &plugin)?; registry.insert(LoadedPlugin { profile, hooks, bundled: false, }); } Ok(()) } /// Compile Rhai hook scripts for a discovered plugin. #[instrument(skip_all)] fn compile_plugin_hooks( engine: &rhai::Engine, plugin: &loader::DiscoveredPlugin, ) -> Result { let hooks_section = &plugin.manifest.hooks; let compile_if_present = |path: &Option| -> Result, PluginError> { match path { Some(rel_path) => { let source = loader::load_hook_script(&plugin.plugin_dir, rel_path)?; let ast = hooks::compile_hook(engine, &source)?; Ok(Some(ast)) } None => Ok(None), } }; match hooks_section { Some(hooks) => Ok(CompiledHooks { validate_sample: compile_if_present(&hooks.validate_sample)?, transform_filename: compile_if_present(&hooks.transform_filename)?, pre_export: compile_if_present(&hooks.pre_export)?, post_export: compile_if_present(&hooks.post_export)?, }), None => Ok(CompiledHooks { validate_sample: None, transform_filename: None, pre_export: None, post_export: None, }), } } #[cfg(test)] mod tests { use super::*; use crate::hooks::{run_validate_sample, run_transform_filename}; use crate::types::{RhaiSampleInfo, RhaiExportContext}; #[test] fn load_user_plugins_from_empty_dir() { let dir = tempfile::tempdir().unwrap(); let mut registry = PluginRegistry::new(); load_user_plugins(&mut registry, dir.path()).unwrap(); assert!(registry.is_empty()); } #[test] fn load_user_plugin_with_hooks() { let dir = tempfile::tempdir().unwrap(); let plugin_dir = dir.path().join("my-sampler"); std::fs::create_dir(&plugin_dir).unwrap(); let hooks_dir = plugin_dir.join("hooks"); std::fs::create_dir(&hooks_dir).unwrap(); std::fs::write( plugin_dir.join("manifest.toml"), r#" [device] name = "Test Sampler" manufacturer = "Test Co" version = "1.0" [audio] formats = ["wav"] sample_rates = [44100] bit_depths = [16] channels = "mono" [hooks] validate_sample = "hooks/validate.rhai" "#, ) .unwrap(); std::fs::write( hooks_dir.join("validate.rhai"), "info.channels == 1", ) .unwrap(); let mut registry = PluginRegistry::new(); load_user_plugins(&mut registry, dir.path()).unwrap(); assert_eq!(registry.len(), 1); let plugin = registry.get("Test Sampler").unwrap(); assert!(plugin.hooks.validate_sample.is_some()); assert!(plugin.hooks.transform_filename.is_none()); } #[test] fn nonexistent_user_dir_is_ok() { let mut registry = PluginRegistry::new(); let result = load_user_plugins(&mut registry, Path::new("/nonexistent/path")); assert!(result.is_ok()); assert!(registry.is_empty()); } // ── Full plugin lifecycle (Item 32) ── fn sample_info(rate: u32, channels: u16) -> RhaiSampleInfo { RhaiSampleInfo { hash: "abc123def456".to_string(), name: "kick_drum.wav".to_string(), extension: "wav".to_string(), sample_rate: rate, bit_depth: 16, channels, duration: 0.5, file_size: 44100, } } fn export_ctx() -> RhaiExportContext { RhaiExportContext { device_name: "Test Device".to_string(), destination: "/tmp/export".to_string(), filename: "kick_drum".to_string(), extension: "wav".to_string(), index: 3, total: 20, } } /// Create a temp plugin directory with manifest and hook scripts. /// Returns the temp dir (must be kept alive for the duration of the test). fn create_test_plugin( dir: &std::path::Path, name: &str, validate_script: &str, transform_script: &str, ) { let plugin_dir = dir.join(name); std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap(); std::fs::write( plugin_dir.join("manifest.toml"), format!( r#" [device] name = "{name}" manufacturer = "Test Co" version = "1.0" [audio] formats = ["wav"] sample_rates = [44100, 48000] bit_depths = [16, 24] channels = "mono" [hooks] validate_sample = "hooks/validate.rhai" transform_filename = "hooks/transform.rhai" "# ), ) .unwrap(); std::fs::write( plugin_dir.join("hooks").join("validate.rhai"), validate_script, ) .unwrap(); std::fs::write( plugin_dir.join("hooks").join("transform.rhai"), transform_script, ) .unwrap(); } #[test] fn full_plugin_lifecycle() { // 1. Create temp dir with a plugin: manifest + two hook scripts let dir = tempfile::tempdir().unwrap(); create_test_plugin( dir.path(), "Lifecycle Sampler", // validate: accept mono 44100 or 48000 "info.channels == 1 && (info.sample_rate == 44100 || info.sample_rate == 48000)", // transform: uppercase + zero-padded index r#"to_upper(name) + "_" + format_index(ctx.index, 3)"#, ); // 2. Discover plugins from temp dir let discovered = loader::discover_plugins(dir.path()); assert_eq!(discovered.len(), 1); assert_eq!(discovered[0].manifest.device.name, "Lifecycle Sampler"); // 3. Load into registry (manifest -> profile + compile hooks) let mut registry = PluginRegistry::new(); load_user_plugins(&mut registry, dir.path()).unwrap(); assert_eq!(registry.len(), 1); let plugin = registry.get("Lifecycle Sampler").unwrap(); assert!(!plugin.bundled); assert_eq!(plugin.profile.name, "Lifecycle Sampler"); assert!(plugin.hooks.validate_sample.is_some()); assert!(plugin.hooks.transform_filename.is_some()); assert!(plugin.hooks.pre_export.is_none()); assert!(plugin.hooks.post_export.is_none()); // 4. Run validate_sample -- mono 44100 should pass let valid = run_validate_sample( registry.engine(), plugin.hooks.validate_sample.as_ref().unwrap(), sample_info(44100, 1), ).unwrap(); assert!(valid); // 5. Run validate_sample -- stereo should fail let invalid = run_validate_sample( registry.engine(), plugin.hooks.validate_sample.as_ref().unwrap(), sample_info(44100, 2), ).unwrap(); assert!(!invalid); // 6. Run transform_filename let transformed = run_transform_filename( registry.engine(), plugin.hooks.transform_filename.as_ref().unwrap(), "kick_drum".to_string(), export_ctx(), ).unwrap(); assert_eq!(transformed, "KICK_DRUM_003"); } #[test] fn validate_sample_returns_true_for_matching_criteria() { let dir = tempfile::tempdir().unwrap(); let plugin_dir = dir.path().join("validator"); std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap(); std::fs::write( plugin_dir.join("manifest.toml"), r#" [device] name = "Validator" manufacturer = "Test" version = "1.0" [audio] formats = ["wav"] sample_rates = [44100] bit_depths = [16] channels = "both" [hooks] validate_sample = "hooks/validate.rhai" "#, ).unwrap(); // Script checks: sample rate is 44100, bit depth is 16, file size under 1MB std::fs::write( plugin_dir.join("hooks").join("validate.rhai"), "info.sample_rate == 44100 && info.bit_depth == 16 && info.file_size < 1048576", ).unwrap(); let mut registry = PluginRegistry::new(); load_user_plugins(&mut registry, dir.path()).unwrap(); let plugin = registry.get("Validator").unwrap(); let ast = plugin.hooks.validate_sample.as_ref().unwrap(); // Matching sample -- should return true let result = run_validate_sample( registry.engine(), ast, sample_info(44100, 1), ).unwrap(); assert!(result, "expected true for matching sample"); // Wrong sample rate -- should return false let result = run_validate_sample( registry.engine(), ast, sample_info(48000, 1), ).unwrap(); assert!(!result, "expected false for non-matching sample rate"); } #[test] fn transform_filename_renames_with_context() { let dir = tempfile::tempdir().unwrap(); let plugin_dir = dir.path().join("renamer"); std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap(); std::fs::write( plugin_dir.join("manifest.toml"), r#" [device] name = "Renamer" manufacturer = "Test" version = "1.0" [audio] formats = ["wav"] sample_rates = [44100] bit_depths = [16] channels = "both" [hooks] transform_filename = "hooks/transform.rhai" "#, ).unwrap(); // Script: lowercase name + device abbreviation + padded index std::fs::write( plugin_dir.join("hooks").join("transform.rhai"), r#"let stem = to_lower(name); stem + "_" + truncate(ctx.device_name, 3) + format_index(ctx.index, 4)"#, ).unwrap(); let mut registry = PluginRegistry::new(); load_user_plugins(&mut registry, dir.path()).unwrap(); let plugin = registry.get("Renamer").unwrap(); let ast = plugin.hooks.transform_filename.as_ref().unwrap(); let result = run_transform_filename( registry.engine(), ast, "KICK_DRUM".to_string(), export_ctx(), ).unwrap(); assert_eq!(result, "kick_drum_Tes0003"); // Different name and index let mut ctx2 = export_ctx(); ctx2.index = 0; let result = run_transform_filename( registry.engine(), ast, "HiHat".to_string(), ctx2, ).unwrap(); assert_eq!(result, "hihat_Tes0000"); } #[test] fn lifecycle_with_all_four_hooks() { let dir = tempfile::tempdir().unwrap(); let plugin_dir = dir.path().join("full-hooks"); std::fs::create_dir_all(plugin_dir.join("hooks")).unwrap(); std::fs::write( plugin_dir.join("manifest.toml"), r#" [device] name = "Full Hooks Device" manufacturer = "Test" version = "1.0" [audio] formats = ["wav", "aiff"] sample_rates = [44100, 48000] bit_depths = [16, 24] channels = "both" [naming] case = "upper" separator = "_" max_length = 16 strip_special = true [limits] max_file_size_bytes = 134217728 [hooks] validate_sample = "hooks/validate.rhai" transform_filename = "hooks/transform.rhai" pre_export = "hooks/pre_export.rhai" post_export = "hooks/post_export.rhai" "#, ).unwrap(); std::fs::write( plugin_dir.join("hooks").join("validate.rhai"), "info.duration < 30.0", ).unwrap(); std::fs::write( plugin_dir.join("hooks").join("transform.rhai"), r#"to_upper(name)"#, ).unwrap(); std::fs::write( plugin_dir.join("hooks").join("pre_export.rhai"), r#"let x = ctx.total; x"#, ).unwrap(); std::fs::write( plugin_dir.join("hooks").join("post_export.rhai"), r#"let done = ctx.index == ctx.total - 1; done"#, ).unwrap(); let mut registry = PluginRegistry::new(); load_user_plugins(&mut registry, dir.path()).unwrap(); let plugin = registry.get("Full Hooks Device").unwrap(); // All four hooks should be compiled assert!(plugin.hooks.validate_sample.is_some()); assert!(plugin.hooks.transform_filename.is_some()); assert!(plugin.hooks.pre_export.is_some()); assert!(plugin.hooks.post_export.is_some()); assert!(!plugin.hooks.is_empty()); // Profile should have naming and limits from manifest assert!(plugin.profile.naming.is_some()); assert!(plugin.profile.limits.is_some()); assert_eq!(plugin.profile.audio.formats.len(), 2); assert_eq!(plugin.profile.audio.sample_rates, vec![44100, 48000]); // Run validate: 0.5s duration passes, but we can test it let valid = run_validate_sample( registry.engine(), plugin.hooks.validate_sample.as_ref().unwrap(), sample_info(44100, 1), ).unwrap(); assert!(valid); // Run transform let name = run_transform_filename( registry.engine(), plugin.hooks.transform_filename.as_ref().unwrap(), "kick".to_string(), export_ctx(), ).unwrap(); assert_eq!(name, "KICK"); // Run pre_export (side-effect hook, should not error) hooks::run_pre_export( registry.engine(), plugin.hooks.pre_export.as_ref().unwrap(), export_ctx(), ).unwrap(); // Run post_export (side-effect hook, should not error) hooks::run_post_export( registry.engine(), plugin.hooks.post_export.as_ref().unwrap(), export_ctx(), ).unwrap(); } }