Skip to main content

broccoli_cli/commands/plugin/
new.rs

1use 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    /// Plugin name (kebab-case, e.g. "my-plugin")
33    pub name: String,
34
35    /// Create backend (Rust/WASM) plugin only
36    #[arg(long, conflicts_with_all = ["frontend", "full"])]
37    pub backend: bool,
38
39    /// Create frontend (React/TypeScript) plugin only
40    #[arg(long, conflicts_with_all = ["backend", "full"])]
41    pub frontend: bool,
42
43    /// Create full plugin with both backend and frontend
44    #[arg(long, conflicts_with_all = ["backend", "frontend"])]
45    pub full: bool,
46
47    /// Output directory (defaults to ./<name>)
48    #[arg(long, short)]
49    pub output: Option<PathBuf>,
50
51    /// Server SDK dependency (e.g. path or version)
52    #[arg(long, default_value = r#"path = "../../server-sdk""#)]
53    pub server_sdk: String,
54
55    /// Frontend SDK dependency version
56    #[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    // Backend files
101    if matches!(kind, ScaffoldKind::Backend | ScaffoldKind::Full) {
102        write_backend_files(&output_dir, &vars, &mut created_files)?;
103    }
104
105    // Frontend files
106    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    // Static files (no template variables, written as-is)
208    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}