1use std::path::{Path, PathBuf};
25
26use mlua::{Function, Lua, RegistryKey, Table};
27use thiserror::Error;
28
29#[derive(Debug, Error)]
31pub enum PluginError {
32 #[error("Lua error: {0}")]
34 Lua(#[from] mlua::Error),
35 #[error("failed to read plugin {path}: {source}")]
37 Io {
38 path: PathBuf,
40 #[source]
42 source: std::io::Error,
43 },
44}
45
46pub struct PluginEngine {
55 lua: Lua,
56 on_select_keys: Vec<RegistryKey>,
58 on_enter_dir_keys: Vec<RegistryKey>,
60 registered_action_keys: Vec<(String, RegistryKey)>,
62}
63
64impl std::fmt::Debug for PluginEngine {
65 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
66 f.debug_struct("PluginEngine")
67 .field("on_select_hooks", &self.on_select_keys.len())
68 .field("on_enter_dir_hooks", &self.on_enter_dir_keys.len())
69 .field("registered_actions", &self.registered_action_keys.len())
70 .finish()
71 }
72}
73
74impl PluginEngine {
75 pub fn new() -> Result<Self, PluginError> {
85 let lua = Lua::new();
86 Self::install_trail_api(&lua)?;
87
88 Ok(Self {
89 lua,
90 on_select_keys: Vec::new(),
91 on_enter_dir_keys: Vec::new(),
92 registered_action_keys: Vec::new(),
93 })
94 }
95
96 fn install_trail_api(lua: &Lua) -> Result<(), mlua::Error> {
101 let registry = lua.create_table()?;
103 registry.set("_on_select", lua.create_table()?)?;
104 registry.set("_on_enter_dir", lua.create_table()?)?;
105 registry.set("_actions", lua.create_table()?)?;
106 lua.globals().set("__trail_registry", registry)?;
107
108 let on_select_fn = lua.create_function(|lua, f: Function| {
110 let registry: Table = lua.globals().get("__trail_registry")?;
111 let tbl: Table = registry.get("_on_select")?;
112 tbl.push(f)?;
113 Ok(())
114 })?;
115
116 let on_enter_dir_fn = lua.create_function(|lua, f: Function| {
118 let registry: Table = lua.globals().get("__trail_registry")?;
119 let tbl: Table = registry.get("_on_enter_dir")?;
120 tbl.push(f)?;
121 Ok(())
122 })?;
123
124 let register_action_fn = lua.create_function(|lua, (name, f): (String, Function)| {
126 let registry: Table = lua.globals().get("__trail_registry")?;
127 let actions: Table = registry.get("_actions")?;
128 let entry = lua.create_table()?;
130 entry.set(1, name)?;
131 entry.set(2, f)?;
132 actions.push(entry)?;
133 Ok(())
134 })?;
135
136 let log_fn = lua.create_function(|_lua, msg: String| {
138 tracing::info!(plugin = true, "{}", msg);
139 Ok(())
140 })?;
141
142 let trail_api = lua.create_table()?;
143 trail_api.set("on_select", on_select_fn)?;
144 trail_api.set("on_enter_dir", on_enter_dir_fn)?;
145 trail_api.set("register_action", register_action_fn)?;
146 trail_api.set("log", log_fn)?;
147 lua.globals().set("trail", trail_api)?;
148
149 Ok(())
150 }
151
152 fn drain_registry(&mut self) -> Result<(), mlua::Error> {
155 let registry: Table = self.lua.globals().get("__trail_registry")?;
156
157 let on_select_tbl: Table = registry.get("_on_select")?;
159 for val in on_select_tbl.sequence_values::<Function>() {
160 let key = self.lua.create_registry_value(val?)?;
161 self.on_select_keys.push(key);
162 }
163 registry.set("_on_select", self.lua.create_table()?)?;
164
165 let on_enter_dir_tbl: Table = registry.get("_on_enter_dir")?;
167 for val in on_enter_dir_tbl.sequence_values::<Function>() {
168 let key = self.lua.create_registry_value(val?)?;
169 self.on_enter_dir_keys.push(key);
170 }
171 registry.set("_on_enter_dir", self.lua.create_table()?)?;
172
173 let actions_tbl: Table = registry.get("_actions")?;
175 for val in actions_tbl.sequence_values::<Table>() {
176 let entry = val?;
177 let name: String = entry.get(1)?;
178 let func: Function = entry.get(2)?;
179 let key = self.lua.create_registry_value(func)?;
180 self.registered_action_keys.push((name, key));
181 }
182 registry.set("_actions", self.lua.create_table()?)?;
183
184 Ok(())
185 }
186
187 pub fn load_plugin(&mut self, path: &Path) -> Result<(), PluginError> {
197 let source = std::fs::read_to_string(path).map_err(|e| PluginError::Io {
198 path: path.to_owned(),
199 source: e,
200 })?;
201 self.lua
202 .load(&source)
203 .set_name(path.display().to_string())
204 .exec()?;
205 self.drain_registry()?;
206 tracing::debug!("loaded plugin: {}", path.display());
207 Ok(())
208 }
209
210 pub fn load_plugin_str(&mut self, name: &str, source: &str) -> Result<(), PluginError> {
219 self.lua.load(source).set_name(name).exec()?;
220 self.drain_registry()?;
221 Ok(())
222 }
223
224 pub fn fire_on_select(&self, path: &Path) {
228 let path_str = path.display().to_string();
229 for key in &self.on_select_keys {
230 let func: Result<Function, _> = self.lua.registry_value(key);
231 match func {
232 Ok(f) => {
233 if let Err(e) = f.call::<_, ()>(path_str.clone()) {
234 tracing::debug!("on_select hook error: {e}");
235 }
236 }
237 Err(e) => tracing::debug!("on_select registry lookup error: {e}"),
238 }
239 }
240 }
241
242 pub fn fire_on_enter_dir(&self, dir: &Path) {
246 let dir_str = dir.display().to_string();
247 for key in &self.on_enter_dir_keys {
248 let func: Result<Function, _> = self.lua.registry_value(key);
249 match func {
250 Ok(f) => {
251 if let Err(e) = f.call::<_, ()>(dir_str.clone()) {
252 tracing::debug!("on_enter_dir hook error: {e}");
253 }
254 }
255 Err(e) => tracing::debug!("on_enter_dir registry lookup error: {e}"),
256 }
257 }
258 }
259
260 #[allow(dead_code)]
267 pub fn fire_action(&self, action_name: &str, arg: &str) -> bool {
268 for (name, key) in &self.registered_action_keys {
269 if name == action_name {
270 let func: Result<Function, _> = self.lua.registry_value(key);
271 match func {
272 Ok(f) => {
273 if let Err(e) = f.call::<_, ()>(arg.to_owned()) {
274 tracing::debug!("action handler '{action_name}' error: {e}");
275 }
276 }
277 Err(e) => tracing::debug!("action registry lookup error: {e}"),
278 }
279 return true;
280 }
281 }
282 false
283 }
284
285 #[allow(dead_code)]
290 pub fn action_names(&self) -> impl Iterator<Item = &str> {
291 self.registered_action_keys
292 .iter()
293 .map(|(name, _)| name.as_str())
294 }
295
296 #[allow(dead_code)]
299 pub fn is_empty(&self) -> bool {
300 self.on_select_keys.is_empty()
301 && self.on_enter_dir_keys.is_empty()
302 && self.registered_action_keys.is_empty()
303 }
304}
305
306#[cfg(test)]
307mod tests {
308 use super::*;
309
310 #[test]
311 fn new_engine_is_empty() {
312 let engine = PluginEngine::new().expect("engine");
313 assert!(engine.is_empty());
314 }
315
316 #[test]
317 fn on_select_hook_fires() {
318 let mut engine = PluginEngine::new().expect("engine");
319 engine
320 .load_plugin_str(
321 "test_select",
322 r#"
323trail.on_select(function(path)
324 trail.log("selected: " .. path)
325end)
326"#,
327 )
328 .expect("load");
329 assert_eq!(engine.on_select_keys.len(), 1);
330 engine.fire_on_select(Path::new("/tmp/test.txt"));
332 }
333
334 #[test]
335 fn on_enter_dir_hook_fires() {
336 let mut engine = PluginEngine::new().expect("engine");
337 engine
338 .load_plugin_str(
339 "test_enter",
340 r#"
341trail.on_enter_dir(function(dir)
342 trail.log("entered: " .. dir)
343end)
344"#,
345 )
346 .expect("load");
347 assert_eq!(engine.on_enter_dir_keys.len(), 1);
348 engine.fire_on_enter_dir(Path::new("/tmp"));
349 }
350
351 #[test]
352 fn register_action_hook_fires() {
353 let mut engine = PluginEngine::new().expect("engine");
354 engine
355 .load_plugin_str(
356 "test_action",
357 r#"
358trail.register_action("my_action", function(arg)
359 trail.log("action with: " .. arg)
360end)
361"#,
362 )
363 .expect("load");
364 assert_eq!(engine.registered_action_keys.len(), 1);
365 assert!(engine.fire_action("my_action", "hello"));
366 assert!(!engine.fire_action("nonexistent", ""));
367 }
368
369 #[test]
370 fn action_names_iterator() {
371 let mut engine = PluginEngine::new().expect("engine");
372 engine
373 .load_plugin_str(
374 "test_names",
375 r#"
376trail.register_action("foo", function() end)
377trail.register_action("bar", function() end)
378"#,
379 )
380 .expect("load");
381 let names: Vec<_> = engine.action_names().collect();
382 assert_eq!(names, vec!["foo", "bar"]);
383 }
384
385 #[test]
386 fn multiple_plugins_accumulate_hooks() {
387 let mut engine = PluginEngine::new().expect("engine");
388 engine
389 .load_plugin_str("p1", r#"trail.on_select(function(p) end)"#)
390 .expect("p1");
391 engine
392 .load_plugin_str("p2", r#"trail.on_select(function(p) end)"#)
393 .expect("p2");
394 assert_eq!(engine.on_select_keys.len(), 2);
395 }
396
397 #[test]
398 fn hook_error_does_not_panic() {
399 let mut engine = PluginEngine::new().expect("engine");
400 engine
401 .load_plugin_str(
402 "bad_hook",
403 r#"
404trail.on_select(function(path)
405 error("intentional test error")
406end)
407"#,
408 )
409 .expect("load");
410 engine.fire_on_select(Path::new("/tmp"));
412 }
413
414 #[test]
415 fn register_action_unknown_returns_false() {
416 let engine = PluginEngine::new().expect("engine");
417 assert!(!engine.fire_action("ghost", "arg"));
418 }
419
420 #[test]
421 fn trail_log_emits_info_log() {
422 let mut engine = PluginEngine::new().expect("engine");
423 let result = engine.load_plugin_str(
424 "test_log",
425 r#"
426trail.log("hello from plugin log test")
427"#,
428 );
429 assert!(result.is_ok());
430 }
431}