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