Skip to main content

linesmith_core/plugins/
registry.rs

1//! Plugin registry: the single source of truth for compiled `.rhai`
2//! scripts after discovery. Owns the parsed ASTs + resolved header
3//! data. Wrapping a [`CompiledPlugin`] in the `Segment` trait is the
4//! `RhaiSegment` adapter's job, not this module's.
5//!
6//! [`PluginRegistry::load`] walks the discovery order from
7//! [`super::discovery::scan_plugin_dirs`], compiles each script,
8//! resolves its `@data_deps` header, and extracts the required
9//! `const ID` declaration. Non-fatal errors (compile failure, unknown
10//! dep, id collision) are returned alongside the registry so
11//! `linesmith doctor` can surface them; a single bad plugin does not
12//! abort the whole load.
13
14use std::collections::HashMap;
15use std::path::{Path, PathBuf};
16
17use rhai::{Engine, AST};
18
19use super::discovery::{scan_dirs, scan_plugin_dirs};
20use super::errors::{CollisionWinner, PluginError};
21use super::header::{parse_data_deps_header, HeaderError};
22use crate::data_context::DataDep;
23
24/// A single compiled plugin ready to be wrapped by the `RhaiSegment`
25/// adapter.
26///
27/// Field visibility is `pub(crate)` — the registry is the only
28/// factory (`compile_plugin` is the sole construction site), and
29/// the only consumers live inside this crate. This keeps the
30/// non-empty-id, Status-first-dep, non-reserved-dep invariants the
31/// factory enforces from being silently violated by a third-party
32/// caller that constructs the struct directly.
33///
34/// `declared_deps` is a raw `Vec<DataDep>` rather than the
35/// `&'static` slice the `Segment::data_deps` contract requires; the
36/// `RhaiSegment` wrapper does the `Vec::leak` promotion at segment-
37/// build time so the registry itself doesn't leak memory on every
38/// reload.
39///
40/// Construction runs the script's top-level statements once to
41/// extract `const ID`; plugin authors with side effects at module
42/// scope pay that cost at registry build, not at first render.
43#[derive(Debug)]
44pub struct CompiledPlugin {
45    pub(crate) id: String,
46    pub(crate) path: PathBuf,
47    pub(crate) ast: AST,
48    pub(crate) declared_deps: Vec<DataDep>,
49}
50
51impl CompiledPlugin {
52    #[must_use]
53    pub fn id(&self) -> &str {
54        &self.id
55    }
56
57    #[must_use]
58    pub fn path(&self) -> &Path {
59        &self.path
60    }
61
62    #[must_use]
63    pub fn declared_deps(&self) -> &[DataDep] {
64        &self.declared_deps
65    }
66}
67
68/// Keyed collection of compiled plugins. Lookup is by `id`; iteration
69/// preserves discovery order. Non-fatal load errors (compile failure,
70/// unknown dep, id collision) live alongside the compiled plugins so
71/// post-load consumers (e.g. `linesmith doctor`) can query them at
72/// any point without re-running discovery.
73pub struct PluginRegistry {
74    plugins: Vec<CompiledPlugin>,
75    errors: Vec<PluginError>,
76}
77
78impl PluginRegistry {
79    /// Discover, compile, and register every plugin across
80    /// `config_dirs` plus the default XDG segments directory.
81    /// `built_in_ids` is the set of reserved ids that plugins cannot
82    /// shadow (plugins attempting to register one of these names are
83    /// rejected as `IdCollision`).
84    ///
85    /// Non-fatal load errors are collected on the returned registry;
86    /// query them via [`Self::load_errors`]. A missing or unreadable
87    /// directory is not an error — the discovery layer silently
88    /// skips it.
89    #[must_use]
90    pub fn load(config_dirs: &[PathBuf], engine: &Engine, built_in_ids: &[&str]) -> Self {
91        Self::load_from_paths(&scan_plugin_dirs(config_dirs), engine, built_in_ids)
92    }
93
94    /// Explicit-XDG variant of [`Self::load`]. Passes `xdg_dir`
95    /// through to the discovery scan rather than reading
96    /// `XDG_CONFIG_HOME` from the process env. Use `None` to skip the
97    /// XDG fallback entirely — driver paths pass an env-derived
98    /// [`PathBuf`] so test harnesses with a hermetic `CliEnv` don't
99    /// pick up the developer's real `~/.config/linesmith/segments/`.
100    #[must_use]
101    pub fn load_with_xdg(
102        config_dirs: &[PathBuf],
103        xdg_dir: Option<&Path>,
104        engine: &Engine,
105        built_in_ids: &[&str],
106    ) -> Self {
107        Self::load_from_paths(&scan_dirs(config_dirs, xdg_dir), engine, built_in_ids)
108    }
109
110    /// Core load logic: given an already-discovered list of plugin
111    /// file paths (in discovery order), compile each one, detect id
112    /// collisions, and build the registry.
113    fn load_from_paths(paths: &[PathBuf], engine: &Engine, built_in_ids: &[&str]) -> Self {
114        let mut plugins = Vec::new();
115        let mut errors = Vec::new();
116        // Track plugin ids we've already registered → path of the
117        // winning (first-discovered) occurrence.
118        let mut seen_ids: HashMap<String, PathBuf> = HashMap::new();
119
120        for path in paths {
121            match compile_plugin(path, engine) {
122                Ok(plugin) => {
123                    if built_in_ids.iter().any(|b| *b == plugin.id) {
124                        errors.push(PluginError::IdCollision {
125                            id: plugin.id,
126                            winner: CollisionWinner::BuiltIn,
127                            loser_path: path.clone(),
128                        });
129                        continue;
130                    }
131                    if let Some(first_path) = seen_ids.get(&plugin.id) {
132                        errors.push(PluginError::IdCollision {
133                            id: plugin.id.clone(),
134                            winner: CollisionWinner::Plugin(first_path.clone()),
135                            loser_path: path.clone(),
136                        });
137                        continue;
138                    }
139                    seen_ids.insert(plugin.id.clone(), path.clone());
140                    plugins.push(plugin);
141                }
142                Err(err) => errors.push(err),
143            }
144        }
145
146        Self { plugins, errors }
147    }
148
149    /// Non-fatal errors from the most recent load. Includes compile
150    /// failures, malformed `@data_deps` headers, unknown dep names,
151    /// and id collisions (with built-ins or other plugins). Returns
152    /// an empty slice when every plugin loaded cleanly.
153    #[must_use]
154    pub fn load_errors(&self) -> &[PluginError] {
155        &self.errors
156    }
157
158    /// Look up a compiled plugin by its `const ID` value.
159    #[must_use]
160    pub fn get(&self, id: &str) -> Option<&CompiledPlugin> {
161        self.plugins.iter().find(|p| p.id == id)
162    }
163
164    /// Iterate every compiled plugin in discovery order.
165    pub fn iter(&self) -> impl Iterator<Item = &CompiledPlugin> {
166        self.plugins.iter()
167    }
168
169    /// Total number of compiled plugins.
170    #[must_use]
171    pub fn len(&self) -> usize {
172        self.plugins.len()
173    }
174
175    /// `true` when no plugins were discovered or compiled.
176    #[must_use]
177    pub fn is_empty(&self) -> bool {
178        self.plugins.is_empty()
179    }
180
181    /// Consume the registry, yielding every compiled plugin by value.
182    /// The segment builder pulls plugins out by id this way to move
183    /// each [`CompiledPlugin`] into a [`RhaiSegment`].
184    ///
185    /// [`RhaiSegment`]: super::segment::RhaiSegment
186    #[must_use]
187    pub fn into_plugins(self) -> Vec<CompiledPlugin> {
188        self.plugins
189    }
190}
191
192/// Compile one plugin file. Reads the source, parses the `@data_deps`
193/// header, compiles the AST, and extracts the required `const ID`.
194fn compile_plugin(path: &Path, engine: &Engine) -> Result<CompiledPlugin, PluginError> {
195    let src = std::fs::read_to_string(path).map_err(|e| PluginError::Compile {
196        path: path.to_path_buf(),
197        message: format!("read: {e}"),
198    })?;
199
200    // Header parse first — surfaces malformed / unknown-dep errors
201    // without paying the AST compile cost. `const ID` isn't known
202    // yet (the AST hasn't compiled), so these variants carry `path`
203    // rather than a plugin `id` field.
204    let deps = match parse_data_deps_header(&src) {
205        Ok(d) => d,
206        Err(HeaderError::Malformed(m)) => {
207            return Err(PluginError::MalformedDataDeps {
208                path: path.to_path_buf(),
209                message: m,
210            });
211        }
212        Err(HeaderError::UnknownDep(name)) => {
213            return Err(PluginError::UnknownDataDep {
214                path: path.to_path_buf(),
215                name,
216            });
217        }
218    };
219
220    let ast = engine.compile(&src).map_err(|e| PluginError::Compile {
221        path: path.to_path_buf(),
222        message: e.to_string(),
223    })?;
224
225    // Extract `const ID = "..."` by running the top-level statements.
226    // Rhai's `fn` declarations are not executed (they register for
227    // later calls), so only `const` / `let` at module level run.
228    let mut scope = rhai::Scope::new();
229    engine
230        .run_ast_with_scope(&mut scope, &ast)
231        .map_err(|e| PluginError::Compile {
232            path: path.to_path_buf(),
233            message: format!("top-level exec: {e}"),
234        })?;
235
236    // Distinguish "ID binding absent" from "ID bound to the wrong
237    // type" so the error message tells the author what to fix.
238    // `Scope::get_value::<String>` collapses both into `None`; use
239    // `get` + type inspection instead.
240    let id = match scope.get("ID") {
241        None => {
242            return Err(PluginError::Compile {
243                path: path.to_path_buf(),
244                message: "missing required `const ID = \"...\"`".into(),
245            });
246        }
247        Some(v) => match v.clone().into_string() {
248            Ok(s) => s,
249            Err(actual_type) => {
250                return Err(PluginError::Compile {
251                    path: path.to_path_buf(),
252                    message: format!("`const ID` must be a string, found `{actual_type}`"),
253                });
254            }
255        },
256    };
257
258    if id.is_empty() {
259        return Err(PluginError::Compile {
260            path: path.to_path_buf(),
261            message: "`const ID` must not be empty".into(),
262        });
263    }
264
265    Ok(CompiledPlugin {
266        id,
267        path: path.to_path_buf(),
268        ast,
269        declared_deps: deps,
270    })
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::plugins::build_engine;
277    use std::fs;
278    use tempfile::TempDir;
279
280    const BUILTINS: &[&str] = &["model", "workspace", "cost"];
281
282    fn write_plugin(dir: &Path, name: &str, src: &str) -> PathBuf {
283        let path = dir.join(name);
284        fs::write(&path, src).expect("write plugin");
285        path
286    }
287
288    #[test]
289    fn empty_config_dirs_produces_empty_registry() {
290        let engine = build_engine();
291        // No dirs configured AND no XDG scan (unit-tested sibling
292        // pieces handle XDG); registry loads zero plugins.
293        let tmp = TempDir::new().expect("tempdir");
294        let reg =
295            PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
296        let errors = reg.load_errors();
297        assert!(reg.is_empty());
298        assert_eq!(reg.len(), 0);
299        assert!(errors.is_empty());
300    }
301
302    #[test]
303    fn valid_plugin_compiles_and_registers() {
304        let engine = build_engine();
305        let tmp = TempDir::new().expect("tempdir");
306        write_plugin(
307            tmp.path(),
308            "foo.rhai",
309            r#"
310            const ID = "foo";
311            fn render(ctx) { () }
312            "#,
313        );
314        let reg =
315            PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
316        let errors = reg.load_errors();
317        assert!(errors.is_empty(), "unexpected errors: {errors:?}");
318        assert_eq!(reg.len(), 1);
319        let plugin = reg.get("foo").expect("registered by id");
320        assert_eq!(plugin.id, "foo");
321        assert_eq!(plugin.declared_deps, vec![DataDep::Status]);
322    }
323
324    #[test]
325    fn plugin_with_data_deps_header_resolves_correctly() {
326        let engine = build_engine();
327        let tmp = TempDir::new().expect("tempdir");
328        write_plugin(
329            tmp.path(),
330            "u.rhai",
331            r#"// @data_deps = ["usage", "git"]
332            const ID = "u";
333            fn render(ctx) { () }
334            "#,
335        );
336        let reg =
337            PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
338        let errors = reg.load_errors();
339        assert!(errors.is_empty());
340        let plugin = reg.get("u").expect("registered");
341        assert_eq!(
342            plugin.declared_deps,
343            vec![DataDep::Status, DataDep::Usage, DataDep::Git]
344        );
345    }
346
347    #[test]
348    fn missing_id_const_surfaces_compile_error() {
349        let engine = build_engine();
350        let tmp = TempDir::new().expect("tempdir");
351        write_plugin(
352            tmp.path(),
353            "noid.rhai",
354            r#"
355            fn render(ctx) { () }
356            "#,
357        );
358        let reg =
359            PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
360        let errors = reg.load_errors();
361        assert!(reg.is_empty());
362        assert_eq!(errors.len(), 1);
363        assert!(matches!(errors[0], PluginError::Compile { .. }));
364        let msg = format!("{}", errors[0]);
365        assert!(msg.contains("ID"), "expected ID reference in error: {msg}");
366    }
367
368    #[test]
369    fn empty_id_string_rejected() {
370        let engine = build_engine();
371        let tmp = TempDir::new().expect("tempdir");
372        write_plugin(
373            tmp.path(),
374            "empty_id.rhai",
375            r#"
376            const ID = "";
377            fn render(ctx) { () }
378            "#,
379        );
380        let reg =
381            PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
382        let errors = reg.load_errors();
383        assert_eq!(errors.len(), 1);
384        assert!(matches!(errors[0], PluginError::Compile { .. }));
385    }
386
387    #[test]
388    fn syntax_error_surfaces_compile_error() {
389        let engine = build_engine();
390        let tmp = TempDir::new().expect("tempdir");
391        write_plugin(
392            tmp.path(),
393            "bad.rhai",
394            r#"
395            const ID = "bad
396            fn render(ctx) { () }
397            "#,
398        );
399        let reg =
400            PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
401        let errors = reg.load_errors();
402        assert!(reg.is_empty());
403        assert_eq!(errors.len(), 1);
404        assert!(matches!(errors[0], PluginError::Compile { .. }));
405    }
406
407    #[test]
408    fn unknown_data_dep_surfaces_unknown_dep_error() {
409        let engine = build_engine();
410        let tmp = TempDir::new().expect("tempdir");
411        write_plugin(
412            tmp.path(),
413            "mystery.rhai",
414            r#"// @data_deps = ["mystery"]
415            const ID = "mystery";
416            fn render(ctx) { () }
417            "#,
418        );
419        let reg =
420            PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
421        let errors = reg.load_errors();
422        assert!(reg.is_empty());
423        assert_eq!(errors.len(), 1);
424        let PluginError::UnknownDataDep { name, .. } = &errors[0] else {
425            panic!("expected UnknownDataDep, got {:?}", errors[0]);
426        };
427        assert_eq!(name, "mystery");
428    }
429
430    #[test]
431    fn reserved_credentials_dep_surfaces_unknown_dep_error() {
432        // `credentials` is plugin-reserved per spec §@data_deps
433        // header syntax; must fail as UnknownDataDep at load time.
434        let engine = build_engine();
435        let tmp = TempDir::new().expect("tempdir");
436        write_plugin(
437            tmp.path(),
438            "cr.rhai",
439            r#"// @data_deps = ["credentials"]
440            const ID = "cr";
441            fn render(ctx) { () }
442            "#,
443        );
444        let reg =
445            PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
446        let errors = reg.load_errors();
447        assert!(reg.is_empty());
448        assert!(matches!(errors[0], PluginError::UnknownDataDep { .. }));
449    }
450
451    #[test]
452    fn malformed_data_deps_surfaces_malformed_error() {
453        let engine = build_engine();
454        let tmp = TempDir::new().expect("tempdir");
455        write_plugin(
456            tmp.path(),
457            "mal.rhai",
458            r#"// @data_deps = ["usage"
459            const ID = "mal";
460            fn render(ctx) { () }
461            "#,
462        );
463        let reg =
464            PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
465        let errors = reg.load_errors();
466        assert!(reg.is_empty());
467        assert!(matches!(errors[0], PluginError::MalformedDataDeps { .. }));
468    }
469
470    #[test]
471    fn plugin_id_colliding_with_built_in_rejected() {
472        let engine = build_engine();
473        let tmp = TempDir::new().expect("tempdir");
474        write_plugin(
475            tmp.path(),
476            "model.rhai",
477            r#"
478            const ID = "model";
479            fn render(ctx) { () }
480            "#,
481        );
482        let reg =
483            PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
484        let errors = reg.load_errors();
485        assert!(reg.is_empty());
486        let PluginError::IdCollision { winner, .. } = &errors[0] else {
487            panic!("expected IdCollision, got {:?}", errors[0]);
488        };
489        assert_eq!(*winner, super::super::errors::CollisionWinner::BuiltIn);
490    }
491
492    #[test]
493    fn non_string_id_const_surfaces_typed_error() {
494        let engine = build_engine();
495        let tmp = TempDir::new().expect("tempdir");
496        write_plugin(
497            tmp.path(),
498            "num_id.rhai",
499            r#"
500            const ID = 42;
501            fn render(ctx) { () }
502            "#,
503        );
504        let reg =
505            PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
506        let errors = reg.load_errors();
507        assert!(reg.is_empty());
508        let PluginError::Compile { message, .. } = &errors[0] else {
509            panic!("expected Compile, got {:?}", errors[0]);
510        };
511        assert!(
512            message.contains("must be a string"),
513            "error must distinguish wrong-type from missing: {message}"
514        );
515    }
516
517    #[test]
518    fn duplicate_plugin_id_first_wins_second_rejected() {
519        let engine = build_engine();
520        let tmp_a = TempDir::new().expect("tempdir");
521        let tmp_b = TempDir::new().expect("tempdir");
522        let winner = write_plugin(
523            tmp_a.path(),
524            "x.rhai",
525            r#"
526            const ID = "dup";
527            fn render(ctx) { () }
528            "#,
529        );
530        let loser = write_plugin(
531            tmp_b.path(),
532            "y.rhai",
533            r#"
534            const ID = "dup";
535            fn render(ctx) { () }
536            "#,
537        );
538        let reg = PluginRegistry::load_with_xdg(
539            &[tmp_a.path().to_path_buf(), tmp_b.path().to_path_buf()],
540            None,
541            &engine,
542            BUILTINS,
543        );
544        let errors = reg.load_errors();
545        assert_eq!(reg.len(), 1);
546        assert_eq!(reg.get("dup").expect("first wins").path, winner);
547        assert_eq!(errors.len(), 1);
548        let PluginError::IdCollision {
549            id,
550            winner: collision_winner,
551            loser_path,
552        } = &errors[0]
553        else {
554            panic!("expected IdCollision, got {:?}", errors[0]);
555        };
556        assert_eq!(id, "dup");
557        assert_eq!(
558            *collision_winner,
559            super::super::errors::CollisionWinner::Plugin(winner.clone())
560        );
561        assert_eq!(loser_path, &loser);
562    }
563
564    #[test]
565    fn mix_of_good_and_bad_plugins_registers_good_and_reports_bad() {
566        // A bad plugin doesn't block the registry from picking up
567        // the good ones — important for a multi-plugin user install
568        // where one broken script shouldn't silently drop the rest.
569        let engine = build_engine();
570        let tmp = TempDir::new().expect("tempdir");
571        write_plugin(
572            tmp.path(),
573            "a_good.rhai",
574            r#"
575            const ID = "good";
576            fn render(ctx) { () }
577            "#,
578        );
579        write_plugin(
580            tmp.path(),
581            "b_bad.rhai",
582            r#"
583            fn render(ctx) { () }
584            "#,
585        );
586        let reg =
587            PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
588        let errors = reg.load_errors();
589        assert_eq!(reg.len(), 1);
590        assert!(reg.get("good").is_some());
591        assert_eq!(errors.len(), 1);
592        assert!(matches!(errors[0], PluginError::Compile { .. }));
593    }
594}