Skip to main content

camel_cli/commands/
plugin.rs

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