1use anyhow::{bail, Context, Result};
4use cargo_generate::{GenerateArgs, TemplatePath, Vcs};
5use clap::{Parser, Subcommand};
6use serde::Deserialize;
7use std::collections::BTreeMap;
8
9const CRATES_IO_API: &str = "https://crates.io/api/v1/crates";
10
11#[derive(Parser)]
12#[command(name = "cargo-bp")]
13#[command(bin_name = "cargo")]
14#[command(version, about = "Create and manage battery packs", long_about = None)]
15pub struct Cli {
16 #[command(subcommand)]
17 pub command: Commands,
18}
19
20#[derive(Subcommand)]
21pub enum Commands {
22 Bp {
24 #[command(subcommand)]
25 command: BpCommands,
26 },
27}
28
29#[derive(Subcommand)]
30pub enum BpCommands {
31 New {
33 battery_pack: String,
35
36 #[arg(long, short = 'n')]
38 name: Option<String>,
39
40 #[arg(long, short = 't')]
42 template: Option<String>,
43
44 #[arg(long, hide = true)]
46 git: Option<String>,
47
48 #[arg(long, hide = true)]
50 path: Option<String>,
51 },
52
53 Add {
55 battery_pack: String,
57
58 #[arg(long, short = 'F')]
60 features: Vec<String>,
61 },
62}
63
64pub fn main() -> Result<()> {
66 let cli = Cli::parse();
67
68 match cli.command {
69 Commands::Bp { command } => match command {
70 BpCommands::New {
71 battery_pack,
72 name,
73 template,
74 git,
75 path,
76 } => new_from_battery_pack(&battery_pack, name, template, git, path),
77 BpCommands::Add {
78 battery_pack,
79 features,
80 } => add_battery_pack(&battery_pack, &features),
81 },
82 }
83}
84
85#[derive(Deserialize)]
90struct CratesIoResponse {
91 #[serde(rename = "crate")]
92 krate: CrateInfo,
93}
94
95#[derive(Deserialize)]
96struct CrateInfo {
97 repository: Option<String>,
98}
99
100#[derive(Deserialize, Default)]
105struct CargoManifest {
106 package: Option<PackageSection>,
107}
108
109#[derive(Deserialize, Default)]
110struct PackageSection {
111 metadata: Option<PackageMetadata>,
112}
113
114#[derive(Deserialize, Default)]
115struct PackageMetadata {
116 battery: Option<BatteryMetadata>,
117}
118
119#[derive(Deserialize, Default)]
120struct BatteryMetadata {
121 #[serde(default)]
122 templates: BTreeMap<String, TemplateConfig>,
123}
124
125#[derive(Deserialize)]
126struct TemplateConfig {
127 path: String,
128 #[serde(default)]
129 description: Option<String>,
130}
131
132fn new_from_battery_pack(
137 battery_pack: &str,
138 name: Option<String>,
139 template: Option<String>,
140 git_override: Option<String>,
141 path_override: Option<String>,
142) -> Result<()> {
143 let repo_url = if let Some(path) = path_override {
145 return generate_from_local(&path, battery_pack, name, template);
146 } else if let Some(git) = git_override {
147 git
148 } else {
149 resolve_battery_pack(battery_pack)?.repository
150 };
151
152 let templates = fetch_template_metadata(&repo_url, battery_pack)?;
154
155 let template_path = resolve_template(&templates, template.as_deref())?;
157
158 let args = GenerateArgs {
160 template_path: TemplatePath {
161 git: Some(repo_url),
162 auto_path: Some(template_path),
163 ..Default::default()
164 },
165 name,
166 vcs: Some(Vcs::Git),
167 ..Default::default()
168 };
169
170 cargo_generate::generate(args)?;
171
172 Ok(())
173}
174
175fn add_battery_pack(name: &str, features: &[String]) -> Result<()> {
176 let resolved = resolve_battery_pack(name)?;
177
178 let mut cmd = std::process::Command::new("cargo");
180 cmd.arg("add").arg(&resolved.crate_name);
181
182 if resolved.crate_name != name {
184 cmd.arg("--rename").arg(name);
185 }
186
187 for feature in features {
189 cmd.arg("--features").arg(feature);
190 }
191
192 let status = cmd.status().context("Failed to run cargo add")?;
193
194 if !status.success() {
195 bail!("cargo add failed");
196 }
197
198 Ok(())
199}
200
201fn generate_from_local(
202 local_path: &str,
203 battery_pack: &str,
204 name: Option<String>,
205 template: Option<String>,
206) -> Result<()> {
207 let manifest_path = format!("{}/Cargo.toml", local_path);
209 let manifest_content = std::fs::read_to_string(&manifest_path)
210 .with_context(|| format!("Failed to read {}", manifest_path))?;
211
212 let templates = parse_template_metadata(&manifest_content, battery_pack)?;
213 let template_path = resolve_template(&templates, template.as_deref())?;
214
215 let args = GenerateArgs {
216 template_path: TemplatePath {
217 path: Some(local_path.to_string()),
218 auto_path: Some(template_path),
219 ..Default::default()
220 },
221 name,
222 vcs: Some(Vcs::Git),
223 ..Default::default()
224 };
225
226 cargo_generate::generate(args)?;
227
228 Ok(())
229}
230
231struct ResolvedBatteryPack {
233 crate_name: String,
235 repository: String,
237}
238
239fn resolve_battery_pack(name: &str) -> Result<ResolvedBatteryPack> {
240 let client = reqwest::blocking::Client::builder()
241 .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
242 .build()?;
243
244 let candidates = [name.to_string(), format!("{}-battery-pack", name)];
246
247 for candidate in &candidates {
248 let url = format!("{}/{}", CRATES_IO_API, candidate);
249
250 let response = client.get(&url).send();
251
252 if let Ok(resp) = response {
253 if resp.status().is_success() {
254 if let Ok(parsed) = resp.json::<CratesIoResponse>() {
255 if let Some(repo) = parsed.krate.repository {
256 return Ok(ResolvedBatteryPack {
257 crate_name: candidate.clone(),
258 repository: repo,
259 });
260 }
261 }
262 }
263 }
264 }
265
266 bail!(
267 "Could not find battery pack '{}' or '{}-battery-pack' on crates.io",
268 name,
269 name
270 )
271}
272
273fn fetch_template_metadata(
274 repo_url: &str,
275 crate_name: &str,
276) -> Result<BTreeMap<String, TemplateConfig>> {
277 let raw_url = if repo_url.contains("github.com") {
279 repo_url
280 .replace("github.com", "raw.githubusercontent.com")
281 .trim_end_matches(".git")
282 .to_string()
283 + "/HEAD/Cargo.toml"
284 } else {
285 bail!(
286 "Unsupported repository host. Currently only GitHub is supported: {}",
287 repo_url
288 );
289 };
290
291 let client = reqwest::blocking::Client::builder()
292 .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
293 .build()?;
294
295 let manifest_content = client
296 .get(&raw_url)
297 .send()
298 .with_context(|| format!("Failed to fetch Cargo.toml from {}", raw_url))?
299 .text()
300 .with_context(|| "Failed to read Cargo.toml content")?;
301
302 parse_template_metadata(&manifest_content, crate_name)
303}
304
305fn parse_template_metadata(
306 manifest_content: &str,
307 crate_name: &str,
308) -> Result<BTreeMap<String, TemplateConfig>> {
309 let manifest: CargoManifest =
310 toml::from_str(manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
311
312 let templates = manifest
313 .package
314 .and_then(|p| p.metadata)
315 .and_then(|m| m.battery)
316 .map(|b| b.templates)
317 .unwrap_or_default();
318
319 if templates.is_empty() {
320 bail!(
321 "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
322 crate_name
323 );
324 }
325
326 Ok(templates)
327}
328
329fn resolve_template(
330 templates: &BTreeMap<String, TemplateConfig>,
331 requested: Option<&str>,
332) -> Result<String> {
333 match requested {
334 Some(name) => {
335 let config = templates.get(name).ok_or_else(|| {
336 let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
337 anyhow::anyhow!(
338 "Template '{}' not found. Available templates: {}",
339 name,
340 available.join(", ")
341 )
342 })?;
343 Ok(config.path.clone())
344 }
345 None => {
346 if templates.len() == 1 {
347 let (_, config) = templates.iter().next().unwrap();
349 Ok(config.path.clone())
350 } else if let Some(config) = templates.get("default") {
351 Ok(config.path.clone())
353 } else {
354 println!("Available templates:");
356 for (name, config) in templates {
357 if let Some(desc) = &config.description {
358 println!(" {} - {}", name, desc);
359 } else {
360 println!(" {}", name);
361 }
362 }
363 bail!("Multiple templates available. Please specify one with --template <name>");
364 }
365 }
366 }
367}