Skip to content

Commit 24e399c

Browse files
authored
Merge pull request #275 from egohygiene/copilot/introduce-plugin-based-transform-executor
🔌 Introduce plugin-based transform executor system
2 parents c8d175a + 08493f9 commit 24e399c

3 files changed

Lines changed: 541 additions & 13 deletions

File tree

src/transforms/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod command;
22
mod emoji;
3+
pub mod plugin;
34
mod registry;
45
mod syntax_highlight;
56
mod transform;

src/transforms/plugin.rs

Lines changed: 264 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,264 @@
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

Comments
 (0)