|
| 1 | +use std::collections::HashMap; |
| 2 | +use std::sync::Arc; |
| 3 | + |
| 4 | +use anyhow::Result; |
| 5 | + |
| 6 | +use super::Transform; |
| 7 | + |
| 8 | +/// The interface that every plugin executor must implement. |
| 9 | +/// |
| 10 | +/// A `PluginExecutor` processes document content in the same way as a built-in |
| 11 | +/// [`Transform`], but it is registered at runtime rather than compiled into the |
| 12 | +/// core engine. This lets external crates extend the transform pipeline without |
| 13 | +/// modifying renderflow itself. |
| 14 | +/// |
| 15 | +/// # Example |
| 16 | +/// |
| 17 | +/// ```rust |
| 18 | +/// use renderflow::transforms::plugin::PluginExecutor; |
| 19 | +/// |
| 20 | +/// struct ReversePlugin; |
| 21 | +/// |
| 22 | +/// impl PluginExecutor for ReversePlugin { |
| 23 | +/// fn name(&self) -> &str { |
| 24 | +/// "reverse" |
| 25 | +/// } |
| 26 | +/// |
| 27 | +/// fn execute(&self, input: String) -> anyhow::Result<String> { |
| 28 | +/// Ok(input.chars().rev().collect()) |
| 29 | +/// } |
| 30 | +/// } |
| 31 | +/// ``` |
| 32 | +pub trait PluginExecutor: Send + Sync { |
| 33 | + /// Human-readable name identifying this plugin. |
| 34 | + /// |
| 35 | + /// The name is used as the lookup key when plugins are referenced from |
| 36 | + /// transform definitions (e.g. the `plugin` field of a YAML transform |
| 37 | + /// entry). It also appears in log messages and error context so it should |
| 38 | + /// be descriptive and unique within a [`PluginRegistry`]. |
| 39 | + fn name(&self) -> &str; |
| 40 | + |
| 41 | + /// Execute the plugin on `input` and return the transformed content. |
| 42 | + fn execute(&self, input: String) -> Result<String>; |
| 43 | +} |
| 44 | + |
| 45 | +/// A [`Transform`] that delegates to a [`PluginExecutor`]. |
| 46 | +/// |
| 47 | +/// `PluginTransform` bridges the plugin system with the standard transform |
| 48 | +/// pipeline: it holds a shared reference to a [`PluginExecutor`] and |
| 49 | +/// implements [`Transform`] by forwarding calls to |
| 50 | +/// [`PluginExecutor::execute`]. |
| 51 | +/// |
| 52 | +/// Create instances via [`PluginTransform::new`]. |
| 53 | +pub struct PluginTransform { |
| 54 | + executor: Arc<dyn PluginExecutor>, |
| 55 | +} |
| 56 | + |
| 57 | +impl PluginTransform { |
| 58 | + /// Wrap `executor` in a `PluginTransform`. |
| 59 | + pub fn new(executor: Arc<dyn PluginExecutor>) -> Self { |
| 60 | + Self { executor } |
| 61 | + } |
| 62 | +} |
| 63 | + |
| 64 | +impl Transform for PluginTransform { |
| 65 | + fn name(&self) -> &str { |
| 66 | + self.executor.name() |
| 67 | + } |
| 68 | + |
| 69 | + fn apply(&self, input: String) -> Result<String> { |
| 70 | + self.executor.execute(input) |
| 71 | + } |
| 72 | +} |
| 73 | + |
| 74 | +/// A registry of named [`PluginExecutor`] implementations. |
| 75 | +/// |
| 76 | +/// Plugins are stored by name and can be looked up when constructing transform |
| 77 | +/// pipelines from external configuration (e.g. YAML files that reference a |
| 78 | +/// `plugin` field). |
| 79 | +/// |
| 80 | +/// # Example |
| 81 | +/// |
| 82 | +/// ```rust |
| 83 | +/// use std::sync::Arc; |
| 84 | +/// use renderflow::transforms::plugin::{PluginExecutor, PluginRegistry}; |
| 85 | +/// |
| 86 | +/// struct UpperPlugin; |
| 87 | +/// impl PluginExecutor for UpperPlugin { |
| 88 | +/// fn name(&self) -> &str { "upper" } |
| 89 | +/// fn execute(&self, input: String) -> anyhow::Result<String> { |
| 90 | +/// Ok(input.to_uppercase()) |
| 91 | +/// } |
| 92 | +/// } |
| 93 | +/// |
| 94 | +/// let mut registry = PluginRegistry::new(); |
| 95 | +/// registry.register(Arc::new(UpperPlugin)); |
| 96 | +/// assert!(registry.get("upper").is_some()); |
| 97 | +/// assert!(registry.get("missing").is_none()); |
| 98 | +/// ``` |
| 99 | +pub struct PluginRegistry { |
| 100 | + plugins: HashMap<String, Arc<dyn PluginExecutor>>, |
| 101 | +} |
| 102 | + |
| 103 | +impl PluginRegistry { |
| 104 | + /// Create an empty `PluginRegistry`. |
| 105 | + pub fn new() -> Self { |
| 106 | + Self { |
| 107 | + plugins: HashMap::new(), |
| 108 | + } |
| 109 | + } |
| 110 | + |
| 111 | + /// Register a plugin executor. |
| 112 | + /// |
| 113 | + /// The executor's [`name`](PluginExecutor::name) is used as the lookup key. |
| 114 | + /// If a plugin with the same name is already registered it is silently |
| 115 | + /// replaced. |
| 116 | + /// |
| 117 | + /// Returns `&mut self` to support method chaining. |
| 118 | + #[allow(dead_code)] |
| 119 | + pub fn register(&mut self, executor: Arc<dyn PluginExecutor>) -> &mut Self { |
| 120 | + self.plugins.insert(executor.name().to_string(), executor); |
| 121 | + self |
| 122 | + } |
| 123 | + |
| 124 | + /// Look up a plugin by name. |
| 125 | + /// |
| 126 | + /// Returns `Some(Arc<dyn PluginExecutor>)` when a plugin with the given |
| 127 | + /// name exists, or `None` otherwise. |
| 128 | + pub fn get(&self, name: &str) -> Option<Arc<dyn PluginExecutor>> { |
| 129 | + self.plugins.get(name).cloned() |
| 130 | + } |
| 131 | +} |
| 132 | + |
| 133 | +impl Default for PluginRegistry { |
| 134 | + fn default() -> Self { |
| 135 | + Self::new() |
| 136 | + } |
| 137 | +} |
| 138 | + |
| 139 | +#[cfg(test)] |
| 140 | +mod tests { |
| 141 | + use super::*; |
| 142 | + |
| 143 | + // ── PluginExecutor / PluginTransform ────────────────────────────────────── |
| 144 | + |
| 145 | + struct UpperPlugin; |
| 146 | + impl PluginExecutor for UpperPlugin { |
| 147 | + fn name(&self) -> &str { |
| 148 | + "upper" |
| 149 | + } |
| 150 | + fn execute(&self, input: String) -> Result<String> { |
| 151 | + Ok(input.to_uppercase()) |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + struct AppendPlugin(&'static str); |
| 156 | + impl PluginExecutor for AppendPlugin { |
| 157 | + fn name(&self) -> &str { |
| 158 | + "append" |
| 159 | + } |
| 160 | + fn execute(&self, input: String) -> Result<String> { |
| 161 | + Ok(format!("{}{}", input, self.0)) |
| 162 | + } |
| 163 | + } |
| 164 | + |
| 165 | + #[test] |
| 166 | + fn test_plugin_transform_name_delegates_to_executor() { |
| 167 | + let t = PluginTransform::new(Arc::new(UpperPlugin)); |
| 168 | + assert_eq!(t.name(), "upper"); |
| 169 | + } |
| 170 | + |
| 171 | + #[test] |
| 172 | + fn test_plugin_transform_apply_delegates_to_executor() { |
| 173 | + let t = PluginTransform::new(Arc::new(UpperPlugin)); |
| 174 | + let result = t.apply("hello".to_string()).unwrap(); |
| 175 | + assert_eq!(result, "HELLO"); |
| 176 | + } |
| 177 | + |
| 178 | + #[test] |
| 179 | + fn test_plugin_transform_error_propagated() { |
| 180 | + use anyhow::bail; |
| 181 | + |
| 182 | + struct FailPlugin; |
| 183 | + impl PluginExecutor for FailPlugin { |
| 184 | + fn name(&self) -> &str { |
| 185 | + "fail" |
| 186 | + } |
| 187 | + fn execute(&self, _input: String) -> Result<String> { |
| 188 | + bail!("plugin error") |
| 189 | + } |
| 190 | + } |
| 191 | + |
| 192 | + let t = PluginTransform::new(Arc::new(FailPlugin)); |
| 193 | + assert!(t.apply("input".to_string()).is_err()); |
| 194 | + } |
| 195 | + |
| 196 | + // ── PluginRegistry ──────────────────────────────────────────────────────── |
| 197 | + |
| 198 | + #[test] |
| 199 | + fn test_registry_get_returns_none_for_missing_plugin() { |
| 200 | + let registry = PluginRegistry::new(); |
| 201 | + assert!(registry.get("missing").is_none()); |
| 202 | + } |
| 203 | + |
| 204 | + #[test] |
| 205 | + fn test_registry_register_and_get() { |
| 206 | + let mut registry = PluginRegistry::new(); |
| 207 | + registry.register(Arc::new(UpperPlugin)); |
| 208 | + let executor = registry.get("upper").expect("plugin must be present"); |
| 209 | + let result = executor.execute("hello".to_string()).unwrap(); |
| 210 | + assert_eq!(result, "HELLO"); |
| 211 | + } |
| 212 | + |
| 213 | + #[test] |
| 214 | + fn test_registry_register_multiple_plugins() { |
| 215 | + let mut registry = PluginRegistry::new(); |
| 216 | + registry |
| 217 | + .register(Arc::new(UpperPlugin)) |
| 218 | + .register(Arc::new(AppendPlugin("!"))); |
| 219 | + assert!(registry.get("upper").is_some()); |
| 220 | + assert!(registry.get("append").is_some()); |
| 221 | + } |
| 222 | + |
| 223 | + #[test] |
| 224 | + fn test_registry_register_replaces_existing_plugin() { |
| 225 | + struct PrefixPlugin(&'static str); |
| 226 | + impl PluginExecutor for PrefixPlugin { |
| 227 | + fn name(&self) -> &str { |
| 228 | + "upper" |
| 229 | + } |
| 230 | + fn execute(&self, input: String) -> Result<String> { |
| 231 | + Ok(format!("{}{}", self.0, input)) |
| 232 | + } |
| 233 | + } |
| 234 | + |
| 235 | + let mut registry = PluginRegistry::new(); |
| 236 | + registry.register(Arc::new(UpperPlugin)); |
| 237 | + registry.register(Arc::new(PrefixPlugin(">>"))); // same name, different impl |
| 238 | + |
| 239 | + let executor = registry.get("upper").unwrap(); |
| 240 | + let result = executor.execute("x".to_string()).unwrap(); |
| 241 | + // The second registration must win. |
| 242 | + assert_eq!(result, ">>x"); |
| 243 | + } |
| 244 | + |
| 245 | + #[test] |
| 246 | + fn test_registry_default_is_empty() { |
| 247 | + let registry = PluginRegistry::default(); |
| 248 | + assert!(registry.get("anything").is_none()); |
| 249 | + } |
| 250 | + |
| 251 | + // ── integration with TransformRegistry ─────────────────────────────────── |
| 252 | + |
| 253 | + #[test] |
| 254 | + fn test_plugin_transform_integrates_with_transform_registry() { |
| 255 | + use crate::transforms::TransformRegistry; |
| 256 | + |
| 257 | + let mut registry = TransformRegistry::new(); |
| 258 | + registry.register(Box::new(PluginTransform::new(Arc::new(UpperPlugin)))); |
| 259 | + registry.register(Box::new(PluginTransform::new(Arc::new(AppendPlugin("!"))))); |
| 260 | + |
| 261 | + let result = registry.apply_all("hello".to_string()).unwrap(); |
| 262 | + assert_eq!(result, "HELLO!"); |
| 263 | + } |
| 264 | +} |
0 commit comments