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    #[arg(long)]
39    pub debug: bool,
40}
41
42pub fn run_plugin(action: PluginAction) {
43    match action {
44        PluginAction::New(args) => run_plugin_new(args),
45        PluginAction::Build(args) => run_plugin_build(args),
46    }
47}
48
49fn run_plugin_new(args: PluginNewArgs) {
50    let PluginNewArgs {
51        name,
52        force,
53        r#type: plugin_type,
54    } = args;
55
56    if !name
57        .chars()
58        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
59    {
60        eprintln!(
61            "Error: plugin name must contain only alphanumeric characters, hyphens, or underscores"
62        );
63        std::process::exit(1);
64    }
65
66    let files = match plugin_type {
67        PluginType::Bean => crate::template::bean::bean_files(&name),
68        PluginType::Processor => crate::template::processor::processor_files(&name),
69    };
70    let target = Path::new(&name);
71
72    if target.exists() && !force {
73        let is_non_empty = target.read_dir().is_ok_and(|mut d| d.next().is_some());
74        if is_non_empty {
75            eprintln!(
76                "Directory '{}' already exists and is not empty. Use --force to overwrite.",
77                name
78            );
79            std::process::exit(1);
80        }
81    }
82
83    std::fs::create_dir_all(target).unwrap_or_else(|e| {
84        eprintln!("Failed to create directory '{}': {}", name, e);
85        std::process::exit(1);
86    });
87
88    for file in &files {
89        let file_path = target.join(&file.path);
90        if let Some(parent) = file_path.parent() {
91            std::fs::create_dir_all(parent).unwrap_or_else(|e| {
92                eprintln!("Failed to create directory '{}': {}", parent.display(), e);
93                std::process::exit(1);
94            });
95        }
96        std::fs::write(&file_path, &file.content).unwrap_or_else(|e| {
97            eprintln!("Failed to write '{}': {}", file_path.display(), e);
98            std::process::exit(1);
99        });
100    }
101
102    let type_label = match plugin_type {
103        PluginType::Bean => "bean",
104        PluginType::Processor => "processor",
105    };
106    println!("Created camel {} plugin '{}'\n", type_label, name);
107    println!("Next steps:");
108    println!("  cd {}", name);
109    println!("  camel plugin build");
110}
111
112fn run_plugin_build(args: PluginBuildArgs) {
113    let cwd = std::env::current_dir().unwrap_or_else(|e| {
114        eprintln!("Error: failed to get current directory: {e}");
115        std::process::exit(1);
116    });
117
118    let cargo_toml_path = cwd.join("Cargo.toml");
119    let cargo_toml = std::fs::read_to_string(&cargo_toml_path).unwrap_or_else(|e| {
120        eprintln!(
121            "Error: failed to read '{}': {}",
122            cargo_toml_path.display(),
123            e
124        );
125        std::process::exit(1);
126    });
127
128    let parsed: toml::Value = toml::from_str(&cargo_toml).unwrap_or_else(|e| {
129        eprintln!(
130            "Error: failed to parse '{}': {}",
131            cargo_toml_path.display(),
132            e
133        );
134        std::process::exit(1);
135    });
136
137    let plugin_name = parsed
138        .get("package")
139        .and_then(|pkg| pkg.get("name"))
140        .and_then(toml::Value::as_str)
141        .map(str::to_string)
142        .unwrap_or_else(|| {
143            eprintln!(
144                "Error: missing [package].name in '{}'",
145                cargo_toml_path.display()
146            );
147            std::process::exit(1);
148        });
149
150    let mut cmd = Command::new("cargo");
151    cmd.arg("build").arg("--target").arg("wasm32-wasip2");
152
153    if !args.debug {
154        cmd.arg("--release");
155    }
156
157    let status = cmd.status().unwrap_or_else(|e| {
158        eprintln!("Error: failed to execute build command: {e}");
159        std::process::exit(1);
160    });
161
162    if !status.success() {
163        eprintln!("Error: build failed");
164        std::process::exit(1);
165    }
166
167    let built_wasm = build_output_path(&cwd, &plugin_name, args.debug);
168    if !built_wasm.exists() {
169        eprintln!("Error: built wasm not found at '{}'", built_wasm.display());
170        std::process::exit(1);
171    }
172
173    let camel_root = find_camel_root(&cwd).unwrap_or_else(|e| {
174        eprintln!("Error: {e}");
175        std::process::exit(1);
176    });
177
178    let plugins_dir = camel_root.join(".camel").join("plugins");
179    std::fs::create_dir_all(&plugins_dir).unwrap_or_else(|e| {
180        eprintln!(
181            "Error: failed to create plugins directory '{}': {}",
182            plugins_dir.display(),
183            e
184        );
185        std::process::exit(1);
186    });
187
188    let installed_wasm = plugins_dir.join(format!("{plugin_name}.wasm"));
189    std::fs::copy(&built_wasm, &installed_wasm).unwrap_or_else(|e| {
190        eprintln!(
191            "Error: failed to copy '{}' to '{}': {}",
192            built_wasm.display(),
193            installed_wasm.display(),
194            e
195        );
196        std::process::exit(1);
197    });
198
199    println!("Built and installed plugin '{}'", plugin_name);
200    println!("  source: {}", built_wasm.display());
201    println!("  installed: {}", installed_wasm.display());
202}
203
204pub fn find_camel_root(start: &Path) -> Result<PathBuf, String> {
205    for dir in start.ancestors() {
206        if dir.join("Camel.toml").exists() {
207            return Ok(dir.to_path_buf());
208        }
209        let workspace_cargo = dir.join("Cargo.toml");
210        if workspace_cargo.exists() {
211            let contents = std::fs::read_to_string(&workspace_cargo)
212                .map_err(|e| format!("failed to read '{}': {}", workspace_cargo.display(), e))?;
213            let parsed: toml::Value = toml::from_str(&contents)
214                .map_err(|e| format!("failed to parse '{}': {}", workspace_cargo.display(), e))?;
215            if parsed.get("workspace").is_some() {
216                return Ok(dir.to_path_buf());
217            }
218        }
219    }
220
221    Err(format!(
222        "could not find Camel.toml or workspace Cargo.toml from '{}'",
223        start.display()
224    ))
225}
226
227pub fn build_output_path(dir: &Path, plugin_name: &str, debug: bool) -> PathBuf {
228    let profile = if debug { "debug" } else { "release" };
229    let wasm_name = plugin_name.replace('-', "_");
230    dir.join("target")
231        .join("wasm32-wasip2")
232        .join(profile)
233        .join(format!("{wasm_name}.wasm"))
234}
235
236#[cfg(test)]
237mod tests {
238    use super::*;
239    use clap::Parser;
240    use tempfile::tempdir;
241
242    #[derive(Parser)]
243    struct TestCli {
244        #[command(subcommand)]
245        action: PluginAction,
246    }
247
248    #[test]
249    fn plugin_action_parses_new_with_force() {
250        let cli = TestCli::try_parse_from(["test", "new", "my-plugin", "--force"])
251            .expect("expected parse success");
252        match cli.action {
253            PluginAction::New(args) => {
254                assert_eq!(args.name, "my-plugin");
255                assert!(args.force);
256                assert_eq!(args.r#type, PluginType::Processor);
257            }
258            _ => panic!("expected PluginAction::New"),
259        }
260    }
261
262    #[test]
263    fn plugin_action_parses_new_bean_type() {
264        let cli = TestCli::try_parse_from(["test", "new", "my-bean", "--type", "bean"])
265            .expect("expected parse success");
266        match cli.action {
267            PluginAction::New(args) => {
268                assert_eq!(args.name, "my-bean");
269                assert_eq!(args.r#type, PluginType::Bean);
270            }
271            _ => panic!("expected PluginAction::New"),
272        }
273    }
274
275    #[test]
276    fn plugin_action_default_type_is_processor() {
277        let cli =
278            TestCli::try_parse_from(["test", "new", "my-proc"]).expect("expected parse success");
279        match cli.action {
280            PluginAction::New(args) => {
281                assert_eq!(args.name, "my-proc");
282                assert_eq!(args.r#type, PluginType::Processor);
283            }
284            _ => panic!("expected PluginAction::New"),
285        }
286    }
287
288    #[test]
289    fn plugin_action_parses_build_debug() {
290        let cli =
291            TestCli::try_parse_from(["test", "build", "--debug"]).expect("expected parse success");
292        match cli.action {
293            PluginAction::Build(args) => {
294                assert!(args.debug);
295            }
296            _ => panic!("expected PluginAction::Build"),
297        }
298    }
299
300    #[test]
301    fn plugin_action_rejects_missing_name() {
302        let result = TestCli::try_parse_from(["test", "new"]);
303        assert!(result.is_err());
304    }
305
306    #[test]
307    fn find_camel_root_finds_camel_toml() {
308        let root = tempdir().expect("tempdir");
309        std::fs::write(root.path().join("Camel.toml"), "name = \"x\"\n").expect("write");
310        let nested = root.path().join("a").join("b");
311        std::fs::create_dir_all(&nested).expect("mkdir");
312
313        let found = find_camel_root(&nested).expect("find root");
314        assert_eq!(found, root.path());
315    }
316
317    #[test]
318    fn find_camel_root_finds_workspace_cargo_toml() {
319        let root = tempdir().expect("tempdir");
320        std::fs::write(
321            root.path().join("Cargo.toml"),
322            "[workspace]\nmembers = []\n",
323        )
324        .expect("write");
325        let nested = root.path().join("x").join("y");
326        std::fs::create_dir_all(&nested).expect("mkdir");
327
328        let found = find_camel_root(&nested).expect("find root");
329        assert_eq!(found, root.path());
330    }
331
332    #[test]
333    fn find_camel_root_errors_without_markers() {
334        let root = tempdir().expect("tempdir");
335        let nested = root.path().join("one").join("two");
336        std::fs::create_dir_all(&nested).expect("mkdir");
337
338        let err = find_camel_root(&nested).expect_err("expected error");
339        assert!(err.contains("could not find Camel.toml or workspace Cargo.toml"));
340    }
341
342    #[test]
343    fn build_output_path_release() {
344        let dir = Path::new("/tmp/project");
345        let path = build_output_path(dir, "my-plugin", false);
346        assert!(
347            path.ends_with(Path::new("target/wasm32-wasip2/release/my_plugin.wasm")),
348            "got: {}",
349            path.display()
350        );
351    }
352
353    #[test]
354    fn build_output_path_debug() {
355        let dir = Path::new("/tmp/project");
356        let path = build_output_path(dir, "my-plugin", true);
357        assert!(
358            path.ends_with(Path::new("target/wasm32-wasip2/debug/my_plugin.wasm")),
359            "got: {}",
360            path.display()
361        );
362    }
363}