Skip to main content

trail/plugin/
lua_api.rs

1//! Lua scripting API (v1, via `mlua`).
2//!
3//! Exposes `on_select`, `on_enter_dir`, and `register_action` hooks to
4//! Lua scripts. Hooks are registered during plugin load; Trail fires them at
5//! appropriate runtime moments via the `fire_*` methods.
6//!
7//! # Hook Contract
8//!
9//! Each loaded plugin is a Lua chunk executed once at load time. The chunk
10//! calls `trail.on_select(fn)`, `trail.on_enter_dir(fn)`, or
11//! `trail.register_action(name, fn)` to register callbacks. Trail fires those
12//! callbacks by calling `fire_on_select`, `fire_on_enter_dir`, or
13//! `fire_action`.
14//!
15//! Hook errors are logged at `debug` level and never propagate — a
16//! misbehaving plugin must not crash the application.
17//!
18//! # Lifetime notes
19//!
20//! `mlua::Function` carries a `'lua` lifetime bound to the `Lua` instance.
21//! We store callbacks using `mlua::RegistryKey` instead, which is `'static`
22//! and keeps the value alive as long as the `Lua` state exists.
23
24use std::path::{Path, PathBuf};
25
26use mlua::{Function, Lua, RegistryKey, Table};
27use thiserror::Error;
28
29/// Errors that can arise when loading or running Lua plugins.
30#[derive(Debug, Error)]
31pub enum PluginError {
32    /// A Lua runtime error occurred while loading or executing a plugin.
33    #[error("Lua error: {0}")]
34    Lua(#[from] mlua::Error),
35    /// A plugin file could not be read from disk.
36    #[error("failed to read plugin {path}: {source}")]
37    Io {
38        /// Path of the plugin file.
39        path: PathBuf,
40        /// Underlying I/O error.
41        #[source]
42        source: std::io::Error,
43    },
44}
45
46/// The v1 plugin engine: an embedded Lua interpreter with Trail's hook API.
47///
48/// Holds a single `mlua::Lua` state shared across all loaded plugins.
49/// Callbacks are stored as [`mlua::RegistryKey`] values so they remain
50/// `'static` while the `Lua` state is alive.
51///
52/// The hook API is intentionally minimal for v1 — resist expanding it until
53/// a real plugin author needs more (coding standard §13 / decision log).
54pub struct PluginEngine {
55    lua: Lua,
56    /// Registry keys for registered `on_select` callbacks, in load order.
57    on_select_keys: Vec<RegistryKey>,
58    /// Registry keys for registered `on_enter_dir` callbacks, in load order.
59    on_enter_dir_keys: Vec<RegistryKey>,
60    /// Registry keys for registered custom action callbacks: `(name, key)`.
61    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    /// Creates a new `PluginEngine` and installs the Trail Lua API surface.
76    ///
77    /// Sets up the `trail` global table with `on_select`, `on_enter_dir`,
78    /// `register_action`, and `log` functions for use by plugins.
79    ///
80    /// # Errors
81    ///
82    /// Returns [`PluginError::Lua`] if the Lua state cannot be created or the
83    /// API table cannot be installed.
84    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    /// Installs the `trail` global API table into `lua`.
97    ///
98    /// Also installs a `__trail_registry` table used internally during plugin
99    /// loading to capture hook registrations.
100    fn install_trail_api(lua: &Lua) -> Result<(), mlua::Error> {
101        // __trail_registry collects registrations during a single load_plugin call.
102        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        // trail.on_select(fn) — appends fn to _on_select.
109        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        // trail.on_enter_dir(fn) — appends fn to _on_enter_dir.
117        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        // trail.register_action(name, fn) — appends {name, fn} pair to _actions.
125        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            // Store as a two-element table: {name, fn}.
129            let entry = lua.create_table()?;
130            entry.set(1, name)?;
131            entry.set(2, f)?;
132            actions.push(entry)?;
133            Ok(())
134        })?;
135
136        // trail.log(msg) — emits an info log from plugin code.
137        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    /// Drains newly registered hooks from `__trail_registry` into the engine's
153    /// internal key lists, then resets the registry tables for the next load.
154    fn drain_registry(&mut self) -> Result<(), mlua::Error> {
155        let registry: Table = self.lua.globals().get("__trail_registry")?;
156
157        // Drain on_select hooks.
158        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        // Drain on_enter_dir hooks.
166        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        // Drain registered actions.
174        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    /// Loads and executes a Lua plugin from `path`.
188    ///
189    /// The plugin chunk runs once; any `trail.*` registration calls are
190    /// captured and stored as registry keys.
191    ///
192    /// # Errors
193    ///
194    /// Returns [`PluginError::Io`] if the file cannot be read, or
195    /// [`PluginError::Lua`] if the chunk fails to compile or execute.
196    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    /// Loads a Lua plugin from a source string.
211    ///
212    /// `name` is used as a debug label in error messages. This is the
213    /// preferred path for embedded (built-in) plugins and unit tests.
214    ///
215    /// # Errors
216    ///
217    /// Returns [`PluginError::Lua`] if the chunk fails to compile or execute.
218    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    /// Fires all registered `on_select` hooks with the path of the selected entry.
225    ///
226    /// Hook errors are logged at `debug` level and do not propagate.
227    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    /// Fires all registered `on_enter_dir` hooks with the entered directory path.
243    ///
244    /// Hook errors are logged at `debug` level and do not propagate.
245    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    /// Calls the registered handler for `action_name` with `arg`.
261    ///
262    /// Returns `true` if an action with that name was found and called,
263    /// `false` if no plugin registered it.
264    /// Handler errors are logged at `debug` level and do not propagate.
265    // clippy: dead_code — API consumed in Phase 9 UI
266    #[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    /// Returns an iterator over the names of all registered custom actions.
286    ///
287    /// Used by the command parser to offer tab-completion for plugin actions.
288    // clippy: dead_code — API consumed in Phase 9 UI
289    #[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    /// Returns `true` if no plugins have registered any hooks or actions.
297    // clippy: dead_code — API consumed in Phase 9 UI
298    #[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        // Fire should not panic.
331        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        // Fire should not panic even when the Lua hook errors.
411        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}