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_LIB_RS: &str = include_str!("../../../templates/backend/src/lib.rs.tmpl");
21const TMPL_GITIGNORE: &str = include_str!("../../../templates/backend/.gitignore.tmpl");
22
23const TMPL_PACKAGE_JSON: &str = include_str!("../../../templates/frontend/package.json.tmpl");
24const TMPL_TSCONFIG: &str = include_str!("../../../templates/frontend/tsconfig.json.tmpl");
25const TMPL_INDEX_TSX: &str = include_str!("../../../templates/frontend/src/index.tsx.tmpl");
26const STATIC_STYLES_CSS: &str = include_str!("../../../templates/frontend/src/styles.css");
27const STATIC_TAILWIND_CONFIG: &str = include_str!("../../../templates/frontend/tailwind.config.js");
28const STATIC_POSTCSS_CONFIG: &str = include_str!("../../../templates/frontend/postcss.config.js");
29
30#[derive(Args)]
31pub struct NewPluginArgs {
32 pub name: String,
34
35 #[arg(long, conflicts_with_all = ["frontend", "full"])]
37 pub backend: bool,
38
39 #[arg(long, conflicts_with_all = ["backend", "full"])]
41 pub frontend: bool,
42
43 #[arg(long, conflicts_with_all = ["backend", "frontend"])]
45 pub full: bool,
46
47 #[arg(long, short)]
49 pub output: Option<PathBuf>,
50
51 #[arg(long, default_value = r#"path = "../../server-sdk""#)]
53 pub server_sdk: String,
54
55 #[arg(long, default_value = "workspace:*")]
57 pub web_sdk: String,
58}
59
60#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub enum ScaffoldKind {
62 Backend,
63 Frontend,
64 Full,
65}
66
67pub fn run(args: NewPluginArgs) -> Result<()> {
68 validate_plugin_name(&args.name)?;
69
70 let kind = determine_kind(&args)?;
71 let output_dir = args.output.unwrap_or_else(|| PathBuf::from(&args.name));
72
73 if output_dir.exists() {
74 bail!(
75 "Directory '{}' already exists. Remove it or choose a different name.",
76 output_dir.display()
77 );
78 }
79
80 let web_root = match kind {
81 ScaffoldKind::Frontend => "dist".to_string(),
82 ScaffoldKind::Full => "web/dist".to_string(),
83 ScaffoldKind::Backend => String::new(),
84 };
85
86 let vars = TemplateVars {
87 plugin_name: args.name.clone(),
88 plugin_name_snake: to_snake_case(&args.name),
89 plugin_name_pascal: to_pascal_case(&args.name),
90 server_sdk_dep: args.server_sdk,
91 web_sdk_dep: args.web_sdk,
92 web_root,
93 };
94
95 let mut created_files: Vec<String> = Vec::new();
96
97 write_plugin_toml(&output_dir, &vars, kind)?;
98 created_files.push("plugin.toml".into());
99
100 if matches!(kind, ScaffoldKind::Backend | ScaffoldKind::Full) {
102 write_backend_files(&output_dir, &vars, &mut created_files)?;
103 }
104
105 if matches!(kind, ScaffoldKind::Frontend | ScaffoldKind::Full) {
107 let fe_root = match kind {
108 ScaffoldKind::Full => output_dir.join("web"),
109 _ => output_dir.clone(),
110 };
111 let prefix = match kind {
112 ScaffoldKind::Full => "web/",
113 _ => "",
114 };
115 write_frontend_files(&fe_root, &vars, prefix, &mut created_files)?;
116 }
117
118 print_summary(&args.name, kind, &output_dir, &created_files);
119
120 Ok(())
121}
122
123fn determine_kind(args: &NewPluginArgs) -> Result<ScaffoldKind> {
124 if args.backend {
125 return Ok(ScaffoldKind::Backend);
126 }
127 if args.frontend {
128 return Ok(ScaffoldKind::Frontend);
129 }
130 if args.full {
131 return Ok(ScaffoldKind::Full);
132 }
133
134 let options = &[
135 "Backend (Rust/WASM)",
136 "Frontend (React/TypeScript)",
137 "Full (Backend + Frontend)",
138 ];
139 let selection = Select::new()
140 .with_prompt("What kind of plugin would you like to create?")
141 .items(options)
142 .default(2)
143 .interact_opt()?;
144
145 match selection {
146 Some(0) => Ok(ScaffoldKind::Backend),
147 Some(1) => Ok(ScaffoldKind::Frontend),
148 Some(_) => Ok(ScaffoldKind::Full),
149 None => bail!(
150 "No scaffold kind selected. Use --backend, --frontend, or --full in non-interactive mode."
151 ),
152 }
153}
154
155fn write_plugin_toml(dir: &Path, vars: &TemplateVars, kind: ScaffoldKind) -> Result<()> {
156 let mut content = crate::template::render(TMPL_PLUGIN_HEADER, vars);
157
158 if matches!(kind, ScaffoldKind::Backend | ScaffoldKind::Full) {
159 content.push_str(&crate::template::render(TMPL_PLUGIN_SERVER, vars));
160 }
161
162 if matches!(kind, ScaffoldKind::Frontend | ScaffoldKind::Full) {
163 content.push_str(&crate::template::render(TMPL_PLUGIN_WEB, vars));
164 }
165
166 let path = dir.join("plugin.toml");
167 if let Some(parent) = path.parent() {
168 std::fs::create_dir_all(parent)?;
169 }
170 std::fs::write(path, content)?;
171 Ok(())
172}
173
174fn write_backend_files(dir: &Path, vars: &TemplateVars, files: &mut Vec<String>) -> Result<()> {
175 let templates: &[(&str, &str)] = &[
176 ("Cargo.toml", TMPL_CARGO_TOML),
177 ("rust-toolchain.toml", TMPL_RUST_TOOLCHAIN),
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 let static_files: &[(&str, &str)] = &[
209 ("src/styles.css", STATIC_STYLES_CSS),
210 ("tailwind.config.js", STATIC_TAILWIND_CONFIG),
211 ("postcss.config.js", STATIC_POSTCSS_CONFIG),
212 ];
213
214 for (rel_path, content) in static_files {
215 let path = dir.join(rel_path);
216 if let Some(parent) = path.parent() {
217 std::fs::create_dir_all(parent)?;
218 }
219 std::fs::write(&path, content)?;
220 files.push(format!("{prefix}{rel_path}"));
221 }
222
223 Ok(())
224}
225
226fn print_summary(name: &str, kind: ScaffoldKind, dir: &Path, files: &[String]) {
227 let kind_label = match kind {
228 ScaffoldKind::Backend => "backend",
229 ScaffoldKind::Frontend => "frontend",
230 ScaffoldKind::Full => "full",
231 };
232
233 println!(
234 "\n{} Created {} plugin {}",
235 style("✓").green().bold(),
236 kind_label,
237 style(name).cyan().bold()
238 );
239 println!(" {}\n", style(dir.display()).dim());
240
241 for (i, file) in files.iter().enumerate() {
242 let connector = if i == files.len() - 1 {
243 "└──"
244 } else {
245 "├──"
246 };
247 println!(" {connector} {file}");
248 }
249
250 println!();
251
252 match kind {
253 ScaffoldKind::Backend | ScaffoldKind::Full => {
254 println!(" Next steps:");
255 println!(
256 " cd {} && cargo build --target wasm32-wasip1 --release",
257 dir.display()
258 );
259 }
260 ScaffoldKind::Frontend => {
261 println!(" Next steps:");
262 println!(" cd {} && pnpm install && pnpm build", dir.display());
263 }
264 }
265
266 if kind == ScaffoldKind::Full {
267 println!(
268 " cd {}/web && pnpm install && pnpm build",
269 dir.display()
270 );
271 }
272
273 println!();
274}