1use 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#[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 #[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#[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
96pub struct PluginRegistry {
102 plugins: Vec<CompiledPlugin>,
103 errors: Vec<PluginError>,
104}
105
106impl PluginRegistry {
107 #[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 #[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 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 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 #[must_use]
182 pub fn load_errors(&self) -> &[PluginError] {
183 &self.errors
184 }
185
186 #[must_use]
188 pub fn get(&self, id: &str) -> Option<&CompiledPlugin> {
189 self.plugins.iter().find(|p| p.id == id)
190 }
191
192 pub fn iter(&self) -> impl Iterator<Item = &CompiledPlugin> {
194 self.plugins.iter()
195 }
196
197 #[must_use]
199 pub fn len(&self) -> usize {
200 self.plugins.len()
201 }
202
203 #[must_use]
205 pub fn is_empty(&self) -> bool {
206 self.plugins.is_empty()
207 }
208
209 #[must_use]
213 pub fn into_plugins(self) -> Vec<CompiledPlugin> {
214 self.plugins
215 }
216}
217
218fn 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 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 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 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 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 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 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}