use std::collections::HashMap;
use std::path::{Path, PathBuf};
use rhai::{Engine, AST};
use super::discovery::{scan_dirs, scan_plugin_dirs};
use super::error::{CollisionWinner, PluginError};
use super::header::{parse_data_deps_header, HeaderError};
#[derive(Debug)]
pub struct CompiledPlugin {
pub(crate) id: String,
pub(crate) path: PathBuf,
pub(crate) ast: AST,
pub(crate) declared_deps: Vec<String>,
}
impl CompiledPlugin {
#[must_use]
pub fn id(&self) -> &str {
&self.id
}
#[must_use]
pub fn path(&self) -> &Path {
&self.path
}
#[must_use]
pub fn declared_deps(&self) -> &[String] {
&self.declared_deps
}
#[must_use]
pub fn into_parts(self) -> CompiledPluginParts {
CompiledPluginParts {
id: self.id,
path: self.path,
ast: self.ast,
declared_deps: self.declared_deps,
}
}
}
#[derive(Debug)]
pub struct CompiledPluginParts {
pub id: String,
pub path: PathBuf,
pub ast: AST,
pub declared_deps: Vec<String>,
}
pub struct PluginRegistry {
plugins: Vec<CompiledPlugin>,
errors: Vec<PluginError>,
}
impl PluginRegistry {
#[must_use]
pub fn load(config_dirs: &[PathBuf], engine: &Engine, built_in_ids: &[&str]) -> Self {
Self::load_from_paths(&scan_plugin_dirs(config_dirs), engine, built_in_ids)
}
#[must_use]
pub fn load_with_xdg(
config_dirs: &[PathBuf],
xdg_dir: Option<&Path>,
engine: &Engine,
built_in_ids: &[&str],
) -> Self {
Self::load_from_paths(&scan_dirs(config_dirs, xdg_dir), engine, built_in_ids)
}
fn load_from_paths(paths: &[PathBuf], engine: &Engine, built_in_ids: &[&str]) -> Self {
let mut plugins = Vec::new();
let mut errors = Vec::new();
let mut seen_ids: HashMap<String, PathBuf> = HashMap::new();
for path in paths {
match compile_plugin(path, engine) {
Ok(plugin) => {
if built_in_ids.iter().any(|b| *b == plugin.id) {
errors.push(PluginError::IdCollision {
id: plugin.id,
winner: CollisionWinner::BuiltIn,
loser_path: path.clone(),
});
continue;
}
if let Some(first_path) = seen_ids.get(&plugin.id) {
errors.push(PluginError::IdCollision {
id: plugin.id.clone(),
winner: CollisionWinner::Plugin(first_path.clone()),
loser_path: path.clone(),
});
continue;
}
seen_ids.insert(plugin.id.clone(), path.clone());
plugins.push(plugin);
}
Err(err) => errors.push(err),
}
}
Self { plugins, errors }
}
#[must_use]
pub fn load_errors(&self) -> &[PluginError] {
&self.errors
}
#[must_use]
pub fn get(&self, id: &str) -> Option<&CompiledPlugin> {
self.plugins.iter().find(|p| p.id == id)
}
pub fn iter(&self) -> impl Iterator<Item = &CompiledPlugin> {
self.plugins.iter()
}
#[must_use]
pub fn len(&self) -> usize {
self.plugins.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.plugins.is_empty()
}
#[must_use]
pub fn into_plugins(self) -> Vec<CompiledPlugin> {
self.plugins
}
}
fn compile_plugin(path: &Path, engine: &Engine) -> Result<CompiledPlugin, PluginError> {
let src = std::fs::read_to_string(path).map_err(|e| PluginError::Compile {
path: path.to_path_buf(),
message: format!("read: {e}"),
})?;
let deps = match parse_data_deps_header(&src) {
Ok(d) => d,
Err(HeaderError::Malformed(m)) => {
return Err(PluginError::MalformedDataDeps {
path: path.to_path_buf(),
message: m,
});
}
Err(HeaderError::UnknownDep(name)) => {
return Err(PluginError::UnknownDataDep {
path: path.to_path_buf(),
name,
});
}
};
let ast = engine.compile(&src).map_err(|e| PluginError::Compile {
path: path.to_path_buf(),
message: e.to_string(),
})?;
let mut scope = rhai::Scope::new();
engine
.run_ast_with_scope(&mut scope, &ast)
.map_err(|e| PluginError::Compile {
path: path.to_path_buf(),
message: format!("top-level exec: {e}"),
})?;
let id = match scope.get("ID") {
None => {
return Err(PluginError::Compile {
path: path.to_path_buf(),
message: "missing required `const ID = \"...\"`".into(),
});
}
Some(v) => match v.clone().into_string() {
Ok(s) => s,
Err(actual_type) => {
return Err(PluginError::Compile {
path: path.to_path_buf(),
message: format!("`const ID` must be a string, found `{actual_type}`"),
});
}
},
};
if id.is_empty() {
return Err(PluginError::Compile {
path: path.to_path_buf(),
message: "`const ID` must not be empty".into(),
});
}
Ok(CompiledPlugin {
id,
path: path.to_path_buf(),
ast,
declared_deps: deps,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::engine::build_engine;
use std::fs;
use tempfile::TempDir;
const BUILTINS: &[&str] = &["model", "workspace", "cost"];
fn write_plugin(dir: &Path, name: &str, src: &str) -> PathBuf {
let path = dir.join(name);
fs::write(&path, src).expect("write plugin");
path
}
fn deps(names: &[&str]) -> Vec<String> {
names.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn empty_config_dirs_produces_empty_registry() {
let engine = build_engine();
let tmp = TempDir::new().expect("tempdir");
let reg =
PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
let errors = reg.load_errors();
assert!(reg.is_empty());
assert_eq!(reg.len(), 0);
assert!(errors.is_empty());
}
#[test]
fn valid_plugin_compiles_and_registers() {
let engine = build_engine();
let tmp = TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"foo.rhai",
r#"
const ID = "foo";
fn render(ctx) { () }
"#,
);
let reg =
PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
let errors = reg.load_errors();
assert!(errors.is_empty(), "unexpected errors: {errors:?}");
assert_eq!(reg.len(), 1);
let plugin = reg.get("foo").expect("registered by id");
assert_eq!(plugin.id, "foo");
assert_eq!(plugin.declared_deps, deps(&["status"]));
}
#[test]
fn plugin_with_data_deps_header_resolves_correctly() {
let engine = build_engine();
let tmp = TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"u.rhai",
r#"// @data_deps = ["usage", "git"]
const ID = "u";
fn render(ctx) { () }
"#,
);
let reg =
PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
let errors = reg.load_errors();
assert!(errors.is_empty());
let plugin = reg.get("u").expect("registered");
assert_eq!(plugin.declared_deps, deps(&["status", "usage", "git"]));
}
#[test]
fn missing_id_const_surfaces_compile_error() {
let engine = build_engine();
let tmp = TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"noid.rhai",
r#"
fn render(ctx) { () }
"#,
);
let reg =
PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
let errors = reg.load_errors();
assert!(reg.is_empty());
assert_eq!(errors.len(), 1);
assert!(matches!(errors[0], PluginError::Compile { .. }));
let msg = format!("{}", errors[0]);
assert!(msg.contains("ID"), "expected ID reference in error: {msg}");
}
#[test]
fn empty_id_string_rejected() {
let engine = build_engine();
let tmp = TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"empty_id.rhai",
r#"
const ID = "";
fn render(ctx) { () }
"#,
);
let reg =
PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
let errors = reg.load_errors();
assert_eq!(errors.len(), 1);
assert!(matches!(errors[0], PluginError::Compile { .. }));
}
#[test]
fn syntax_error_surfaces_compile_error() {
let engine = build_engine();
let tmp = TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"bad.rhai",
r#"
const ID = "bad
fn render(ctx) { () }
"#,
);
let reg =
PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
let errors = reg.load_errors();
assert!(reg.is_empty());
assert_eq!(errors.len(), 1);
assert!(matches!(errors[0], PluginError::Compile { .. }));
}
#[test]
fn unknown_data_dep_surfaces_unknown_dep_error() {
let engine = build_engine();
let tmp = TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"mystery.rhai",
r#"// @data_deps = ["mystery"]
const ID = "mystery";
fn render(ctx) { () }
"#,
);
let reg =
PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
let errors = reg.load_errors();
assert!(reg.is_empty());
assert_eq!(errors.len(), 1);
let PluginError::UnknownDataDep { name, .. } = &errors[0] else {
panic!("expected UnknownDataDep, got {:?}", errors[0]);
};
assert_eq!(name, "mystery");
}
#[test]
fn reserved_credentials_dep_surfaces_unknown_dep_error() {
let engine = build_engine();
let tmp = TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"cr.rhai",
r#"// @data_deps = ["credentials"]
const ID = "cr";
fn render(ctx) { () }
"#,
);
let reg =
PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
let errors = reg.load_errors();
assert!(reg.is_empty());
assert!(matches!(errors[0], PluginError::UnknownDataDep { .. }));
}
#[test]
fn malformed_data_deps_surfaces_malformed_error() {
let engine = build_engine();
let tmp = TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"mal.rhai",
r#"// @data_deps = ["usage"
const ID = "mal";
fn render(ctx) { () }
"#,
);
let reg =
PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
let errors = reg.load_errors();
assert!(reg.is_empty());
assert!(matches!(errors[0], PluginError::MalformedDataDeps { .. }));
}
#[test]
fn plugin_id_colliding_with_built_in_rejected() {
let engine = build_engine();
let tmp = TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"model.rhai",
r#"
const ID = "model";
fn render(ctx) { () }
"#,
);
let reg =
PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
let errors = reg.load_errors();
assert!(reg.is_empty());
let PluginError::IdCollision { winner, .. } = &errors[0] else {
panic!("expected IdCollision, got {:?}", errors[0]);
};
assert_eq!(*winner, CollisionWinner::BuiltIn);
}
#[test]
fn non_string_id_const_surfaces_typed_error() {
let engine = build_engine();
let tmp = TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"num_id.rhai",
r#"
const ID = 42;
fn render(ctx) { () }
"#,
);
let reg =
PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
let errors = reg.load_errors();
assert!(reg.is_empty());
let PluginError::Compile { message, .. } = &errors[0] else {
panic!("expected Compile, got {:?}", errors[0]);
};
assert!(
message.contains("must be a string"),
"error must distinguish wrong-type from missing: {message}"
);
}
#[test]
fn duplicate_plugin_id_first_wins_second_rejected() {
let engine = build_engine();
let tmp_a = TempDir::new().expect("tempdir");
let tmp_b = TempDir::new().expect("tempdir");
let winner = write_plugin(
tmp_a.path(),
"x.rhai",
r#"
const ID = "dup";
fn render(ctx) { () }
"#,
);
let loser = write_plugin(
tmp_b.path(),
"y.rhai",
r#"
const ID = "dup";
fn render(ctx) { () }
"#,
);
let reg = PluginRegistry::load_with_xdg(
&[tmp_a.path().to_path_buf(), tmp_b.path().to_path_buf()],
None,
&engine,
BUILTINS,
);
let errors = reg.load_errors();
assert_eq!(reg.len(), 1);
assert_eq!(reg.get("dup").expect("first wins").path, winner);
assert_eq!(errors.len(), 1);
let PluginError::IdCollision {
id,
winner: collision_winner,
loser_path,
} = &errors[0]
else {
panic!("expected IdCollision, got {:?}", errors[0]);
};
assert_eq!(id, "dup");
assert_eq!(*collision_winner, CollisionWinner::Plugin(winner.clone()));
assert_eq!(loser_path, &loser);
}
#[test]
fn mix_of_good_and_bad_plugins_registers_good_and_reports_bad() {
let engine = build_engine();
let tmp = TempDir::new().expect("tempdir");
write_plugin(
tmp.path(),
"a_good.rhai",
r#"
const ID = "good";
fn render(ctx) { () }
"#,
);
write_plugin(
tmp.path(),
"b_bad.rhai",
r#"
fn render(ctx) { () }
"#,
);
let reg =
PluginRegistry::load_with_xdg(&[tmp.path().to_path_buf()], None, &engine, BUILTINS);
let errors = reg.load_errors();
assert_eq!(reg.len(), 1);
assert!(reg.get("good").is_some());
assert_eq!(errors.len(), 1);
assert!(matches!(errors[0], PluginError::Compile { .. }));
}
}