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}