broccoli_cli/commands/plugin/
new.rs1use std::path::{Path, PathBuf};
2
3use anyhow::{Result, bail};
4use clap::Args;
5use console::style;
6use dialoguer::Select;
7
8use crate::template::variables::{
9 TemplateVars, to_pascal_case, to_snake_case, validate_plugin_name,
10};
11use crate::template::write_template;
12
13const TMPL_PLUGIN_HEADER: &str = include_str!("../../../templates/plugin_header.toml.tmpl");
14const TMPL_PLUGIN_SERVER: &str = include_str!("../../../templates/plugin_server.toml.tmpl");
15const TMPL_PLUGIN_WEB: &str = include_str!("../../../templates/plugin_web.toml.tmpl");
16
17const TMPL_CARGO_TOML: &str = include_str!("../../../templates/backend/Cargo.toml.tmpl");
18const TMPL_RUST_TOOLCHAIN: &str =
19 include_str!("../../../templates/backend/rust-toolchain.toml.tmpl");
20const TMPL_CARGO_CONFIG: &str =
21 include_str!("../../../templates/backend/dot-cargo/config.toml.tmpl");
22const TMPL_LIB_RS: &str = include_str!("../../../templates/backend/src/lib.rs.tmpl");
23const TMPL_GITIGNORE: &str = include_str!("../../../templates/backend/.gitignore.tmpl");
24
25const TMPL_PACKAGE_JSON: &str = include_str!("../../../templates/frontend/package.json.tmpl");
26const TMPL_TSCONFIG: &str = include_str!("../../../templates/frontend/tsconfig.json.tmpl");
27const TMPL_INDEX_TSX: &str = include_str!("../../../templates/frontend/src/index.tsx.tmpl");
28
29#[derive(Args)]
30pub struct NewPluginArgs {
31 pub name: String,
33
34 #[arg(long, conflicts_with_all = ["frontend", "full"])]
36 pub backend: bool,
37
38 #[arg(long, conflicts_with_all = ["backend", "full"])]
40 pub frontend: bool,
41
42 #[arg(long, conflicts_with_all = ["backend", "frontend"])]
44 pub full: bool,
45
46 #[arg(long, short)]
48 pub output: Option<PathBuf>,
49
50 #[arg(long, default_value = r#"path = "../../server-sdk""#)]
52 pub server_sdk: String,
53
54 #[arg(long, default_value = "workspace:*")]
56 pub web_sdk: String,
57}
58
59#[derive(Debug, Clone, Copy, PartialEq, Eq)]
60pub enum ScaffoldKind {
61 Backend,
62 Frontend,
63 Full,
64}
65
66pub fn run(args: NewPluginArgs) -> Result<()> {
67 validate_plugin_name(&args.name)?;
68
69 let kind = determine_kind(&args)?;
70 let output_dir = args.output.unwrap_or_else(|| PathBuf::from(&args.name));
71
72 if output_dir.exists() {
73 bail!(
74 "Directory '{}' already exists. Remove it or choose a different name.",
75 output_dir.display()
76 );
77 }
78
79 let web_root = match kind {
80 ScaffoldKind::Frontend => "dist".to_string(),
81 ScaffoldKind::Full => "web/dist".to_string(),
82 ScaffoldKind::Backend => String::new(),
83 };
84
85 let vars = TemplateVars {
86 plugin_name: args.name.clone(),
87 plugin_name_snake: to_snake_case(&args.name),
88 plugin_name_pascal: to_pascal_case(&args.name),
89 server_sdk_dep: args.server_sdk,
90 web_sdk_dep: args.web_sdk,
91 web_root,
92 };
93
94 let mut created_files: Vec<String> = Vec::new();
95
96 write_plugin_toml(&output_dir, &vars, kind)?;
97 created_files.push("plugin.toml".into());
98
99 if matches!(kind, ScaffoldKind::Backend | ScaffoldKind::Full) {
101 write_backend_files(&output_dir, &vars, &mut created_files)?;
102 }
103
104 if matches!(kind, ScaffoldKind::Frontend | ScaffoldKind::Full) {
106 let fe_root = match kind {
107 ScaffoldKind::Full => output_dir.join("web"),
108 _ => output_dir.clone(),
109 };
110 let prefix = match kind {
111 ScaffoldKind::Full => "web/",
112 _ => "",
113 };
114 write_frontend_files(&fe_root, &vars, prefix, &mut created_files)?;
115 }
116
117 print_summary(&args.name, kind, &output_dir, &created_files);
118
119 Ok(())
120}
121
122fn determine_kind(args: &NewPluginArgs) -> Result<ScaffoldKind> {
123 if args.backend {
124 return Ok(ScaffoldKind::Backend);
125 }
126 if args.frontend {
127 return Ok(ScaffoldKind::Frontend);
128 }
129 if args.full {
130 return Ok(ScaffoldKind::Full);
131 }
132
133 let options = &[
134 "Backend (Rust/WASM)",
135 "Frontend (React/TypeScript)",
136 "Full (Backend + Frontend)",
137 ];
138 let selection = Select::new()
139 .with_prompt("What kind of plugin would you like to create?")
140 .items(options)
141 .default(2)
142 .interact_opt()?;
143
144 match selection {
145 Some(0) => Ok(ScaffoldKind::Backend),
146 Some(1) => Ok(ScaffoldKind::Frontend),
147 Some(_) => Ok(ScaffoldKind::Full),
148 None => bail!(
149 "No scaffold kind selected. Use --backend, --frontend, or --full in non-interactive mode."
150 ),
151 }
152}
153
154fn write_plugin_toml(dir: &Path, vars: &TemplateVars, kind: ScaffoldKind) -> Result<()> {
155 let mut content = crate::template::render(TMPL_PLUGIN_HEADER, vars);
156
157 if matches!(kind, ScaffoldKind::Backend | ScaffoldKind::Full) {
158 content.push_str(&crate::template::render(TMPL_PLUGIN_SERVER, vars));
159 }
160
161 if matches!(kind, ScaffoldKind::Frontend | ScaffoldKind::Full) {
162 content.push_str(&crate::template::render(TMPL_PLUGIN_WEB, vars));
163 }
164
165 let path = dir.join("plugin.toml");
166 if let Some(parent) = path.parent() {
167 std::fs::create_dir_all(parent)?;
168 }
169 std::fs::write(path, content)?;
170 Ok(())
171}
172
173fn write_backend_files(dir: &Path, vars: &TemplateVars, files: &mut Vec<String>) -> Result<()> {
174 let templates: &[(&str, &str)] = &[
175 ("Cargo.toml", TMPL_CARGO_TOML),
176 ("rust-toolchain.toml", TMPL_RUST_TOOLCHAIN),
177 (".cargo/config.toml", TMPL_CARGO_CONFIG),
178 ("src/lib.rs", TMPL_LIB_RS),
179 (".gitignore", TMPL_GITIGNORE),
180 ];
181
182 for (rel_path, template) in templates {
183 write_template(template, &dir.join(rel_path), vars)?;
184 files.push((*rel_path).to_string());
185 }
186
187 Ok(())
188}
189
190fn write_frontend_files(
191 dir: &Path,
192 vars: &TemplateVars,
193 prefix: &str,
194 files: &mut Vec<String>,
195) -> Result<()> {
196 let templates: &[(&str, &str)] = &[
197 ("package.json", TMPL_PACKAGE_JSON),
198 ("tsconfig.json", TMPL_TSCONFIG),
199 ("src/index.tsx", TMPL_INDEX_TSX),
200 ];
201
202 for (rel_path, template) in templates {
203 write_template(template, &dir.join(rel_path), vars)?;
204 files.push(format!("{prefix}{rel_path}"));
205 }
206
207 Ok(())
208}
209
210fn print_summary(name: &str, kind: ScaffoldKind, dir: &Path, files: &[String]) {
211 let kind_label = match kind {
212 ScaffoldKind::Backend => "backend",
213 ScaffoldKind::Frontend => "frontend",
214 ScaffoldKind::Full => "full",
215 };
216
217 println!(
218 "\n{} Created {} plugin {}",
219 style("✓").green().bold(),
220 kind_label,
221 style(name).cyan().bold()
222 );
223 println!(" {}\n", style(dir.display()).dim());
224
225 for (i, file) in files.iter().enumerate() {
226 let connector = if i == files.len() - 1 {
227 "└──"
228 } else {
229 "├──"
230 };
231 println!(" {connector} {file}");
232 }
233
234 println!();
235
236 match kind {
237 ScaffoldKind::Backend | ScaffoldKind::Full => {
238 println!(" Next steps:");
239 println!(
240 " cd {} && cargo build --target wasm32-wasip1 --release",
241 dir.display()
242 );
243 }
244 ScaffoldKind::Frontend => {
245 println!(" Next steps:");
246 println!(" cd {} && pnpm install && pnpm build", dir.display());
247 }
248 }
249
250 if kind == ScaffoldKind::Full {
251 println!(
252 " cd {}/web && pnpm install && pnpm build",
253 dir.display()
254 );
255 }
256
257 println!();
258}