Skip to main content

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