1use 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#[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
68pub struct PluginRegistry {
74 plugins: Vec<CompiledPlugin>,
75 errors: Vec<PluginError>,
76}
77
78impl PluginRegistry {
79 #[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 #[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 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 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 #[must_use]
154 pub fn load_errors(&self) -> &[PluginError] {
155 &self.errors
156 }
157
158 #[must_use]
160 pub fn get(&self, id: &str) -> Option<&CompiledPlugin> {
161 self.plugins.iter().find(|p| p.id == id)
162 }
163
164 pub fn iter(&self) -> impl Iterator<Item = &CompiledPlugin> {
166 self.plugins.iter()
167 }
168
169 #[must_use]
171 pub fn len(&self) -> usize {
172 self.plugins.len()
173 }
174
175 #[must_use]
177 pub fn is_empty(&self) -> bool {
178 self.plugins.is_empty()
179 }
180
181 #[must_use]
187 pub fn into_plugins(self) -> Vec<CompiledPlugin> {
188 self.plugins
189 }
190}
191
192fn 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 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 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 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 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 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 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}