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    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
281/// Validates a `plugins_dir` config value relative to `camel_root`.
282///
283/// Rejects empty strings, absolute paths, and any path containing `ParentDir` (`..`) components.
284/// Also verifies that the resolved path stays within `camel_root` (catches symlink escapes).
285pub 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    // Reject any ParentDir (..) component using Path::components()
300    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    // Containment check via canonicalization
307    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    // Try to canonicalize the candidate directly
314    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    // Candidate doesn't exist yet — walk up ancestors until we find one that does
322    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                // Prepend this component to the suffix
345                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
360/// Resolves the plugins directory from Camel.toml `[default.components.wasm].plugins_dir`.
361///
362/// Falls back to `"plugins"` when no Camel.toml or no `plugins_dir` key is present.
363pub 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    // Default: "plugins"
384    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    // validate_plugins_dir tests
636    #[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    // resolve_plugins_dir tests
699    #[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}