bphelper_cli/
lib.rs

1//! CLI for battery-pack: create and manage battery packs.
2
3use 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    /// Battery pack commands
23    Bp {
24        #[command(subcommand)]
25        command: BpCommands,
26    },
27}
28
29#[derive(Subcommand)]
30pub enum BpCommands {
31    /// Create a new project from a battery pack template
32    New {
33        /// Name of the battery pack to use as template source
34        battery_pack: String,
35
36        /// Name for the new project (prompted interactively if not provided)
37        #[arg(long, short = 'n')]
38        name: Option<String>,
39
40        /// Which template to use (defaults to 'default', or prompts if multiple available)
41        #[arg(long, short = 't')]
42        template: Option<String>,
43
44        /// Override the git repository (for development/testing)
45        #[arg(long, hide = true)]
46        git: Option<String>,
47
48        /// Override with a local path (for development/testing)
49        #[arg(long, hide = true)]
50        path: Option<String>,
51    },
52
53    /// Add a battery pack as a dependency
54    Add {
55        /// Name of the battery pack (e.g., "cli" resolves to "cli-battery-pack")
56        battery_pack: String,
57
58        /// Features to enable
59        #[arg(long, short = 'F')]
60        features: Vec<String>,
61    },
62}
63
64/// Main entry point for the CLI.
65pub 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// ============================================================================
86// crates.io API types
87// ============================================================================
88
89#[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// ============================================================================
101// Battery pack metadata types
102// ============================================================================
103
104#[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
132// ============================================================================
133// Implementation
134// ============================================================================
135
136fn 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    // Get the repository URL
144    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    // Fetch the Cargo.toml from the repo to get template metadata
153    let templates = fetch_template_metadata(&repo_url, battery_pack)?;
154
155    // Resolve which template to use
156    let template_path = resolve_template(&templates, template.as_deref())?;
157
158    // Generate the project
159    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    // Build cargo add command: cargo add cli-battery-pack --rename cli
179    let mut cmd = std::process::Command::new("cargo");
180    cmd.arg("add").arg(&resolved.crate_name);
181
182    // Rename to the short name (e.g., cli-battery-pack -> cli)
183    if resolved.crate_name != name {
184        cmd.arg("--rename").arg(name);
185    }
186
187    // Add features if specified
188    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    // Read local Cargo.toml
208    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
231/// Resolved battery pack info from crates.io
232struct ResolvedBatteryPack {
233    /// The actual crate name on crates.io (e.g., "cli-battery-pack")
234    crate_name: String,
235    /// The repository URL
236    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    // Try the crate name as-is first, then with -battery-pack suffix
245    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    // Convert GitHub repo URL to raw Cargo.toml URL
278    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                // Only one template, use it
348                let (_, config) = templates.iter().next().unwrap();
349                Ok(config.path.clone())
350            } else if let Some(config) = templates.get("default") {
351                // Multiple templates, but there's a 'default'
352                Ok(config.path.clone())
353            } else {
354                // Multiple templates, no default - list them
355                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}