1use clap::{Args, Subcommand};
2use std::fmt;
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6#[derive(Clone, Debug, clap::ValueEnum, PartialEq)]
7pub enum PluginType {
8 Processor,
9 Bean,
10 AuthorizationPolicy,
11}
12
13impl fmt::Display for PluginType {
14 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
15 match self {
16 PluginType::Processor => write!(f, "processor"),
17 PluginType::Bean => write!(f, "bean"),
18 PluginType::AuthorizationPolicy => write!(f, "authorization-policy"),
19 }
20 }
21}
22
23#[derive(Subcommand, Debug)]
24pub enum PluginAction {
25 New(PluginNewArgs),
26 Build(PluginBuildArgs),
27}
28
29#[derive(Args, Debug)]
30pub struct PluginNewArgs {
31 pub name: String,
32 #[arg(long, value_name = "TYPE", default_value_t = PluginType::Processor)]
33 pub r#type: PluginType,
34 #[arg(long)]
35 pub force: bool,
36}
37
38#[derive(Args, Debug)]
39pub struct PluginBuildArgs {
40 pub path: Option<String>,
41 #[arg(long)]
42 pub debug: bool,
43}
44
45pub fn run_plugin(action: PluginAction) {
46 match action {
47 PluginAction::New(args) => run_plugin_new(args),
48 PluginAction::Build(args) => run_plugin_build(args),
49 }
50}
51
52fn run_plugin_new(args: PluginNewArgs) {
53 let PluginNewArgs {
54 name,
55 force,
56 r#type: plugin_type,
57 } = args;
58
59 if !name
60 .chars()
61 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
62 {
63 eprintln!(
64 "Error: plugin name must contain only alphanumeric characters, hyphens, or underscores"
65 );
66 std::process::exit(1);
67 }
68
69 let files = match plugin_type {
70 PluginType::Bean => crate::template::bean::bean_files(&name),
71 PluginType::Processor => crate::template::processor::processor_files(&name),
72 PluginType::AuthorizationPolicy => {
73 crate::template::authorization_policy::authorization_policy_files(&name)
74 }
75 };
76 let target = Path::new(&name);
77
78 if target.exists() && !force {
79 let is_non_empty = target.read_dir().is_ok_and(|mut d| d.next().is_some());
80 if is_non_empty {
81 eprintln!(
82 "Directory '{}' already exists and is not empty. Use --force to overwrite.",
83 name
84 );
85 std::process::exit(1);
86 }
87 }
88
89 std::fs::create_dir_all(target).unwrap_or_else(|e| {
90 eprintln!("Failed to create directory '{}': {}", name, e);
91 std::process::exit(1);
92 });
93
94 for file in &files {
95 let file_path = target.join(&file.path);
96 if let Some(parent) = file_path.parent() {
97 std::fs::create_dir_all(parent).unwrap_or_else(|e| {
98 eprintln!("Failed to create directory '{}': {}", parent.display(), e);
99 std::process::exit(1);
100 });
101 }
102 std::fs::write(&file_path, &file.content).unwrap_or_else(|e| {
103 eprintln!("Failed to write '{}': {}", file_path.display(), e);
104 std::process::exit(1);
105 });
106 }
107
108 let type_label = match plugin_type {
109 PluginType::Bean => "bean",
110 PluginType::Processor => "processor",
111 PluginType::AuthorizationPolicy => "authorization-policy",
112 };
113 println!("Created camel {} plugin '{}'\n", type_label, name);
114 println!("Next steps:");
115 println!(" cd {}", name);
116 println!(" camel plugin build");
117}
118
119fn run_plugin_build(args: PluginBuildArgs) {
120 let plugin_dir = match args.path {
121 Some(ref p) => {
122 let canonical = std::path::Path::new(p).canonicalize().unwrap_or_else(|e| {
123 eprintln!("Error: cannot resolve path '{}': {e}", p);
124 std::process::exit(1);
125 });
126 if !canonical.join("Cargo.toml").exists() {
127 eprintln!(
128 "Error: '{}' does not contain a Cargo.toml",
129 canonical.display()
130 );
131 std::process::exit(1);
132 }
133 canonical
134 }
135 None => {
136 let cwd = std::env::current_dir().unwrap_or_else(|e| {
137 eprintln!("Error: failed to get current directory: {e}");
138 std::process::exit(1);
139 });
140 let canonical = cwd.canonicalize().unwrap_or_else(|e| {
141 eprintln!("Error: cannot resolve current directory: {e}");
142 std::process::exit(1);
143 });
144 if !canonical.join("Cargo.toml").exists() {
145 eprintln!(
146 "Error: current directory '{}' does not contain a Cargo.toml",
147 canonical.display()
148 );
149 std::process::exit(1);
150 }
151 canonical
152 }
153 };
154
155 let cargo_toml_path = plugin_dir.join("Cargo.toml");
156 let cargo_toml = std::fs::read_to_string(&cargo_toml_path).unwrap_or_else(|e| {
157 eprintln!(
158 "Error: failed to read '{}': {}",
159 cargo_toml_path.display(),
160 e
161 );
162 std::process::exit(1);
163 });
164
165 let parsed: toml::Value = toml::from_str(&cargo_toml).unwrap_or_else(|e| {
166 eprintln!(
167 "Error: failed to parse '{}': {}",
168 cargo_toml_path.display(),
169 e
170 );
171 std::process::exit(1);
172 });
173
174 let plugin_name = parsed
175 .get("package")
176 .and_then(|pkg| pkg.get("name"))
177 .and_then(toml::Value::as_str)
178 .map(str::to_string)
179 .unwrap_or_else(|| {
180 eprintln!(
181 "Error: missing [package].name in '{}'",
182 cargo_toml_path.display()
183 );
184 std::process::exit(1);
185 });
186
187 let mut cmd = Command::new("cargo");
188 cmd.arg("build")
189 .arg("--target")
190 .arg("wasm32-wasip2")
191 .current_dir(&plugin_dir);
192
193 if !args.debug {
194 cmd.arg("--release");
195 }
196
197 let status = cmd.status().unwrap_or_else(|e| {
198 eprintln!("Error: failed to execute build command: {e}");
199 std::process::exit(1);
200 });
201
202 if !status.success() {
203 eprintln!("Error: build failed");
204 std::process::exit(1);
205 }
206
207 let built_wasm = build_output_path(&plugin_dir, &plugin_name, args.debug);
208 if !built_wasm.exists() {
209 eprintln!("Error: built wasm not found at '{}'", built_wasm.display());
210 std::process::exit(1);
211 }
212
213 let camel_root = find_camel_root(&plugin_dir).unwrap_or_else(|e| {
214 eprintln!("Error: {e}");
215 std::process::exit(1);
216 });
217
218 let plugins_dir_relative = resolve_plugins_dir(&camel_root).unwrap_or_else(|e| {
219 eprintln!("Error: {e}");
220 std::process::exit(1);
221 });
222 let plugins_dir = camel_root.join(&plugins_dir_relative);
223
224 std::fs::create_dir_all(&plugins_dir).unwrap_or_else(|e| {
225 eprintln!(
226 "Error: failed to create plugins directory '{}': {}",
227 plugins_dir.display(),
228 e
229 );
230 std::process::exit(1);
231 });
232
233 let installed_wasm = plugins_dir.join(format!("{plugin_name}.wasm"));
234 std::fs::copy(&built_wasm, &installed_wasm).unwrap_or_else(|e| {
235 eprintln!(
236 "Error: failed to copy '{}' to '{}': {}",
237 built_wasm.display(),
238 installed_wasm.display(),
239 e
240 );
241 std::process::exit(1);
242 });
243
244 println!("Built and installed plugin '{}'", plugin_name);
245 println!(" source: {}", built_wasm.display());
246 println!(" installed: {}", installed_wasm.display());
247}
248
249pub fn find_camel_root(start: &Path) -> Result<PathBuf, String> {
250 for dir in start.ancestors() {
251 if dir.join("Camel.toml").exists() {
252 return Ok(dir.to_path_buf());
253 }
254 let workspace_cargo = dir.join("Cargo.toml");
255 if workspace_cargo.exists() {
256 let contents = std::fs::read_to_string(&workspace_cargo)
257 .map_err(|e| format!("failed to read '{}': {}", workspace_cargo.display(), e))?;
258 let parsed: toml::Value = toml::from_str(&contents)
259 .map_err(|e| format!("failed to parse '{}': {}", workspace_cargo.display(), e))?;
260 if parsed.get("workspace").is_some() {
261 return Ok(dir.to_path_buf());
262 }
263 }
264 }
265
266 Err(format!(
267 "could not find Camel.toml or workspace Cargo.toml from '{}'",
268 start.display()
269 ))
270}
271
272pub fn build_output_path(dir: &Path, plugin_name: &str, debug: bool) -> PathBuf {
273 let profile = if debug { "debug" } else { "release" };
274 let wasm_name = plugin_name.replace('-', "_");
275 dir.join("target")
276 .join("wasm32-wasip2")
277 .join(profile)
278 .join(format!("{wasm_name}.wasm"))
279}
280
281pub fn validate_plugins_dir(camel_root: &Path, dir: &str) -> Result<(), String> {
286 let trimmed = dir.trim();
287 if trimmed.is_empty() {
288 return Err("plugins_dir must not be empty".to_string());
289 }
290
291 let path = Path::new(trimmed);
292 if path.is_absolute() {
293 return Err(format!(
294 "plugins_dir must be a relative path, got '{}'",
295 dir
296 ));
297 }
298
299 for component in path.components() {
301 if matches!(component, std::path::Component::ParentDir) {
302 return Err(format!("plugins_dir must not contain '..', got '{}'", dir));
303 }
304 }
305
306 let canonical_root = camel_root
308 .canonicalize()
309 .map_err(|e| format!("failed to canonicalize project root: {e}"))?;
310
311 let candidate = camel_root.join(trimmed);
312
313 if let Ok(canonical_candidate) = candidate.canonicalize() {
315 if !canonical_candidate.starts_with(&canonical_root) {
316 return Err("plugins_dir resolves outside project root".to_string());
317 }
318 return Ok(());
319 }
320
321 let mut ancestor = candidate.as_path();
323 let mut suffix = PathBuf::new();
324 loop {
325 if ancestor.exists() {
326 match ancestor.canonicalize() {
327 Ok(canonical_ancestor) => {
328 let resolved = canonical_ancestor.join(&suffix);
329 if !resolved.starts_with(&canonical_root) {
330 return Err("plugins_dir resolves outside project root".to_string());
331 }
332 return Ok(());
333 }
334 Err(e) => {
335 return Err(format!(
336 "failed to canonicalize ancestor '{}': {e}",
337 ancestor.display()
338 ));
339 }
340 }
341 }
342 if let Some(parent) = ancestor.parent() {
343 if let Some(file_name) = ancestor.file_name() {
344 let mut new_suffix = PathBuf::from(file_name);
346 if !suffix.as_os_str().is_empty() {
347 new_suffix.push(&suffix);
348 }
349 suffix = new_suffix;
350 ancestor = parent;
351 } else {
352 return Err("no existing ancestor found for plugins_dir".to_string());
353 }
354 } else {
355 return Err("no existing ancestor found for plugins_dir".to_string());
356 }
357 }
358}
359
360pub fn resolve_plugins_dir(camel_root: &Path) -> Result<PathBuf, String> {
364 let toml_path = camel_root.join("Camel.toml");
365 if toml_path.exists() {
366 let contents = std::fs::read_to_string(&toml_path)
367 .map_err(|e| format!("failed to read '{}': {e}", toml_path.display()))?;
368 let parsed: toml::Value = toml::from_str(&contents)
369 .map_err(|e| format!("failed to parse '{}': {e}", toml_path.display()))?;
370
371 if let Some(plugins_dir) = parsed
372 .get("default")
373 .and_then(|d| d.get("components"))
374 .and_then(|c| c.get("wasm"))
375 .and_then(|w| w.get("plugins_dir"))
376 .and_then(toml::Value::as_str)
377 {
378 validate_plugins_dir(camel_root, plugins_dir)?;
379 return Ok(PathBuf::from(plugins_dir));
380 }
381 }
382
383 validate_plugins_dir(camel_root, "plugins")?;
385 Ok(PathBuf::from("plugins"))
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391 use clap::Parser;
392 use tempfile::tempdir;
393
394 #[derive(Parser)]
395 struct TestCli {
396 #[command(subcommand)]
397 action: PluginAction,
398 }
399
400 #[test]
401 fn plugin_action_parses_new_with_force() {
402 let cli = TestCli::try_parse_from(["test", "new", "my-plugin", "--force"])
403 .expect("expected parse success");
404 match cli.action {
405 PluginAction::New(args) => {
406 assert_eq!(args.name, "my-plugin");
407 assert!(args.force);
408 assert_eq!(args.r#type, PluginType::Processor);
409 }
410 _ => panic!("expected PluginAction::New"),
411 }
412 }
413
414 #[test]
415 fn plugin_action_parses_new_bean_type() {
416 let cli = TestCli::try_parse_from(["test", "new", "my-bean", "--type", "bean"])
417 .expect("expected parse success");
418 match cli.action {
419 PluginAction::New(args) => {
420 assert_eq!(args.name, "my-bean");
421 assert_eq!(args.r#type, PluginType::Bean);
422 }
423 _ => panic!("expected PluginAction::New"),
424 }
425 }
426
427 #[test]
428 fn plugin_action_default_type_is_processor() {
429 let cli =
430 TestCli::try_parse_from(["test", "new", "my-proc"]).expect("expected parse success");
431 match cli.action {
432 PluginAction::New(args) => {
433 assert_eq!(args.name, "my-proc");
434 assert_eq!(args.r#type, PluginType::Processor);
435 }
436 _ => panic!("expected PluginAction::New"),
437 }
438 }
439
440 #[test]
441 fn plugin_action_parses_build_debug() {
442 let cli =
443 TestCli::try_parse_from(["test", "build", "--debug"]).expect("expected parse success");
444 match cli.action {
445 PluginAction::Build(args) => {
446 assert!(args.debug);
447 }
448 _ => panic!("expected PluginAction::Build"),
449 }
450 }
451
452 #[test]
453 fn plugin_build_accepts_optional_path() {
454 let cli = TestCli::try_parse_from(["test", "build", "my-plugin/"])
455 .expect("expected parse success");
456 match cli.action {
457 PluginAction::Build(args) => {
458 assert_eq!(args.path.as_deref(), Some("my-plugin/"));
459 }
460 _ => panic!("expected PluginAction::Build"),
461 }
462 }
463
464 #[test]
465 fn plugin_build_defaults_path_to_none() {
466 let cli = TestCli::try_parse_from(["test", "build"]).expect("expected parse success");
467 match cli.action {
468 PluginAction::Build(args) => {
469 assert!(args.path.is_none());
470 }
471 _ => panic!("expected PluginAction::Build"),
472 }
473 }
474
475 #[test]
476 fn plugin_action_rejects_missing_name() {
477 let result = TestCli::try_parse_from(["test", "new"]);
478 assert!(result.is_err());
479 }
480
481 #[test]
482 fn plugin_type_display_values() {
483 assert_eq!(PluginType::Processor.to_string(), "processor");
484 assert_eq!(PluginType::Bean.to_string(), "bean");
485 assert_eq!(
486 PluginType::AuthorizationPolicy.to_string(),
487 "authorization-policy"
488 );
489 }
490
491 #[test]
492 fn plugin_type_authorization_policy_display() {
493 assert_eq!(
494 PluginType::AuthorizationPolicy.to_string(),
495 "authorization-policy"
496 );
497 }
498
499 #[test]
500 fn plugin_action_rejects_invalid_type() {
501 let result = TestCli::try_parse_from(["test", "new", "my-plugin", "--type", "unknown"]);
502 assert!(result.is_err());
503 }
504
505 #[test]
506 fn find_camel_root_finds_camel_toml() {
507 let root = tempdir().expect("tempdir");
508 std::fs::write(root.path().join("Camel.toml"), "name = \"x\"\n").expect("write");
509 let nested = root.path().join("a").join("b");
510 std::fs::create_dir_all(&nested).expect("mkdir");
511
512 let found = find_camel_root(&nested).expect("find root");
513 assert_eq!(found, root.path());
514 }
515
516 #[test]
517 fn find_camel_root_finds_workspace_cargo_toml() {
518 let root = tempdir().expect("tempdir");
519 std::fs::write(
520 root.path().join("Cargo.toml"),
521 "[workspace]\nmembers = []\n",
522 )
523 .expect("write");
524 let nested = root.path().join("x").join("y");
525 std::fs::create_dir_all(&nested).expect("mkdir");
526
527 let found = find_camel_root(&nested).expect("find root");
528 assert_eq!(found, root.path());
529 }
530
531 #[test]
532 fn find_camel_root_errors_without_markers() {
533 let root = tempdir().expect("tempdir");
534 let nested = root.path().join("one").join("two");
535 std::fs::create_dir_all(&nested).expect("mkdir");
536
537 let err = find_camel_root(&nested).expect_err("expected error");
538 assert!(err.contains("could not find Camel.toml or workspace Cargo.toml"));
539 }
540
541 #[test]
542 fn find_camel_root_prefers_nearest_ancestor_marker() {
543 let root = tempdir().expect("tempdir");
544 std::fs::write(root.path().join("Camel.toml"), "name = \"x\"\n").expect("write");
545 let mid = root.path().join("mid");
546 std::fs::create_dir_all(&mid).expect("mkdir");
547 std::fs::write(mid.join("Cargo.toml"), "[workspace]\nmembers = []\n").expect("write");
548 let nested = mid.join("deep");
549 std::fs::create_dir_all(&nested).expect("mkdir");
550
551 let found = find_camel_root(&nested).expect("find root");
552 assert_eq!(found, mid);
553 }
554
555 #[test]
556 fn find_camel_root_returns_parse_error_for_invalid_workspace_toml() {
557 let root = tempdir().expect("tempdir");
558 std::fs::write(root.path().join("Cargo.toml"), "[workspace\ninvalid").expect("write");
559 let nested = root.path().join("x").join("y");
560 std::fs::create_dir_all(&nested).expect("mkdir");
561
562 let err = find_camel_root(&nested).expect_err("expected error");
563 assert!(err.contains("failed to parse"));
564 assert!(err.contains("Cargo.toml"));
565 }
566
567 #[test]
568 fn find_camel_root_ignores_non_workspace_cargo_toml() {
569 let root = tempdir().expect("tempdir");
570 std::fs::write(root.path().join("Cargo.toml"), "[package]\nname = \"x\"\n").expect("write");
571 let nested = root.path().join("a").join("b");
572 std::fs::create_dir_all(&nested).expect("mkdir");
573
574 let err = find_camel_root(&nested).expect_err("expected error");
575 assert!(err.contains("could not find Camel.toml or workspace Cargo.toml"));
576 }
577
578 #[test]
579 fn find_camel_root_returns_read_error_for_unreadable_workspace_marker() {
580 let root = tempdir().expect("tempdir");
581 let cargo_as_dir = root.path().join("Cargo.toml");
582 std::fs::create_dir_all(&cargo_as_dir).expect("mkdir");
583 let nested = root.path().join("x").join("y");
584 std::fs::create_dir_all(&nested).expect("mkdir");
585
586 let err = find_camel_root(&nested).expect_err("expected read error");
587 assert!(err.contains("failed to read"), "got: {err}");
588 assert!(err.contains("Cargo.toml"), "got: {err}");
589 }
590
591 #[test]
592 fn build_output_path_release() {
593 let dir = Path::new("/tmp/project");
594 let path = build_output_path(dir, "my-plugin", false);
595 assert!(
596 path.ends_with(Path::new("target/wasm32-wasip2/release/my_plugin.wasm")),
597 "got: {}",
598 path.display()
599 );
600 }
601
602 #[test]
603 fn build_output_path_debug() {
604 let dir = Path::new("/tmp/project");
605 let path = build_output_path(dir, "my-plugin", true);
606 assert!(
607 path.ends_with(Path::new("target/wasm32-wasip2/debug/my_plugin.wasm")),
608 "got: {}",
609 path.display()
610 );
611 }
612
613 #[test]
614 fn build_output_path_keeps_existing_underscores() {
615 let dir = Path::new("/tmp/project");
616 let path = build_output_path(dir, "my_plugin", false);
617 assert!(
618 path.ends_with(Path::new("target/wasm32-wasip2/release/my_plugin.wasm")),
619 "got: {}",
620 path.display()
621 );
622 }
623
624 #[test]
625 fn build_output_path_replaces_all_hyphens() {
626 let dir = Path::new("/tmp/project");
627 let path = build_output_path(dir, "my-super-plugin", true);
628 assert!(
629 path.ends_with(Path::new("target/wasm32-wasip2/debug/my_super_plugin.wasm")),
630 "got: {}",
631 path.display()
632 );
633 }
634
635 #[test]
637 fn validate_plugins_dir_rejects_absolute_path() {
638 let root = tempfile::tempdir().expect("tempdir");
639 let err = super::validate_plugins_dir(root.path(), "/tmp/plugins").unwrap_err();
640 assert!(err.contains("relative path"), "got: {err}");
641 }
642
643 #[test]
644 fn validate_plugins_dir_rejects_parentdir_component() {
645 let root = tempfile::tempdir().expect("tempdir");
646 let err = super::validate_plugins_dir(root.path(), "../other").unwrap_err();
647 assert!(err.contains("'..'"), "got: {err}");
648 }
649
650 #[test]
651 fn validate_plugins_dir_rejects_parentdir_mid_path() {
652 let root = tempfile::tempdir().expect("tempdir");
653 let err = super::validate_plugins_dir(root.path(), "foo/../bar").unwrap_err();
654 assert!(err.contains("'..'"), "got: {err}");
655 }
656
657 #[test]
658 fn validate_plugins_dir_rejects_empty_string() {
659 let root = tempfile::tempdir().expect("tempdir");
660 let err = super::validate_plugins_dir(root.path(), "").unwrap_err();
661 assert!(err.contains("empty"), "got: {err}");
662 }
663
664 #[test]
665 fn validate_plugins_dir_accepts_simple_relative() {
666 let root = tempfile::tempdir().expect("tempdir");
667 super::validate_plugins_dir(root.path(), "plugins").expect("should accept");
668 }
669
670 #[test]
671 fn validate_plugins_dir_accepts_nested_relative() {
672 let root = tempfile::tempdir().expect("tempdir");
673 super::validate_plugins_dir(root.path(), ".camel/plugins").expect("should accept");
674 }
675
676 #[cfg(unix)]
677 #[test]
678 fn validate_plugins_dir_rejects_symlink_escape() {
679 let root = tempfile::tempdir().expect("tempdir");
680 let outside = tempfile::tempdir().expect("tempdir outside");
681 let link = root.path().join("link");
682 std::os::unix::fs::symlink(outside.path(), &link).expect("symlink");
683 let err = super::validate_plugins_dir(root.path(), "link/escape").unwrap_err();
684 assert!(err.contains("outside project root"), "got: {err}");
685 }
686
687 #[cfg(unix)]
688 #[test]
689 fn validate_plugins_dir_rejects_symlink_escape_missing_target() {
690 let root = tempfile::tempdir().expect("tempdir");
691 let outside = tempfile::tempdir().expect("tempdir outside");
692 let link = root.path().join("link");
693 std::os::unix::fs::symlink(outside.path(), &link).expect("symlink");
694 let err = super::validate_plugins_dir(root.path(), "link/sub/deep").unwrap_err();
695 assert!(err.contains("outside project root"), "got: {err}");
696 }
697
698 #[test]
700 fn resolve_plugins_dir_defaults_to_plugins_when_no_camel_toml() {
701 let root = tempfile::tempdir().expect("tempdir");
702 let dir = super::resolve_plugins_dir(root.path()).expect("should resolve");
703 assert_eq!(dir, std::path::PathBuf::from("plugins"));
704 }
705
706 #[test]
707 fn resolve_plugins_dir_reads_default_components_wasm_plugins_dir() {
708 let root = tempfile::tempdir().expect("tempdir");
709 std::fs::write(
710 root.path().join("Camel.toml"),
711 "[default.components.wasm]\nplugins_dir = \".camel/plugins\"\n",
712 )
713 .expect("write config");
714 let dir = super::resolve_plugins_dir(root.path()).expect("should resolve");
715 assert_eq!(dir, std::path::PathBuf::from(".camel/plugins"));
716 }
717
718 #[test]
719 fn resolve_plugins_dir_returns_error_on_invalid_toml() {
720 let root = tempfile::tempdir().expect("tempdir");
721 std::fs::write(root.path().join("Camel.toml"), "[invalid\n").expect("write config");
722 let err = super::resolve_plugins_dir(root.path()).unwrap_err();
723 assert!(err.contains("failed to parse"), "got: {err}");
724 }
725
726 #[test]
727 fn resolve_plugins_dir_rejects_invalid_plugins_dir_from_config() {
728 let root = tempfile::tempdir().expect("tempdir");
729 std::fs::write(
730 root.path().join("Camel.toml"),
731 "[default.components.wasm]\nplugins_dir = \"/absolute/path\"\n",
732 )
733 .expect("write config");
734 let err = super::resolve_plugins_dir(root.path()).unwrap_err();
735 assert!(err.contains("relative path"), "got: {err}");
736 }
737}