1use anyhow::{Context, Result, bail};
4use cargo_generate::{GenerateArgs, TemplatePath, Vcs};
5use clap::{Parser, Subcommand};
6use flate2::read::GzDecoder;
7use serde::Deserialize;
8use std::collections::BTreeMap;
9use std::io::IsTerminal;
10use std::path::Path;
11use tar::Archive;
12
13mod tui;
14
15const CRATES_IO_API: &str = "https://crates.io/api/v1/crates";
16const CRATES_IO_CDN: &str = "https://static.crates.io/crates";
17
18#[derive(Parser)]
19#[command(name = "cargo-bp")]
20#[command(bin_name = "cargo")]
21#[command(version, about = "Create and manage battery packs", long_about = None)]
22pub struct Cli {
23 #[command(subcommand)]
24 pub command: Commands,
25}
26
27#[derive(Subcommand)]
28pub enum Commands {
29 Bp {
31 #[command(subcommand)]
32 command: BpCommands,
33 },
34}
35
36#[derive(Subcommand)]
37pub enum BpCommands {
38 New {
40 battery_pack: String,
42
43 #[arg(long, short = 'n')]
45 name: Option<String>,
46
47 #[arg(long, short = 't')]
49 template: Option<String>,
50
51 #[arg(long)]
53 path: Option<String>,
54 },
55
56 Add {
58 battery_pack: String,
60
61 #[arg(long, short = 'F')]
63 features: Vec<String>,
64 },
65
66 List {
68 filter: Option<String>,
70
71 #[arg(long)]
73 non_interactive: bool,
74 },
75
76 Show {
78 battery_pack: String,
80
81 #[arg(long)]
83 path: Option<String>,
84
85 #[arg(long)]
87 non_interactive: bool,
88 },
89}
90
91pub fn main() -> Result<()> {
93 let cli = Cli::parse();
94
95 match cli.command {
96 Commands::Bp { command } => match command {
97 BpCommands::New {
98 battery_pack,
99 name,
100 template,
101 path,
102 } => new_from_battery_pack(&battery_pack, name, template, path),
103 BpCommands::Add {
104 battery_pack,
105 features,
106 } => add_battery_pack(&battery_pack, &features),
107 BpCommands::List {
108 filter,
109 non_interactive,
110 } => {
111 if !non_interactive && std::io::stdout().is_terminal() {
112 tui::run_list(filter)
113 } else {
114 print_battery_pack_list(filter.as_deref())
115 }
116 }
117 BpCommands::Show {
118 battery_pack,
119 path,
120 non_interactive,
121 } => {
122 if !non_interactive && std::io::stdout().is_terminal() {
123 tui::run_show(&battery_pack, path.as_deref())
124 } else {
125 print_battery_pack_detail(&battery_pack, path.as_deref())
126 }
127 }
128 },
129 }
130}
131
132#[derive(Deserialize)]
137struct CratesIoResponse {
138 versions: Vec<VersionInfo>,
139}
140
141#[derive(Deserialize)]
142struct VersionInfo {
143 num: String,
144 yanked: bool,
145}
146
147#[derive(Deserialize)]
148struct SearchResponse {
149 crates: Vec<SearchCrate>,
150}
151
152#[derive(Deserialize)]
153struct SearchCrate {
154 name: String,
155 max_version: String,
156 description: Option<String>,
157}
158
159#[derive(Deserialize, Default)]
164struct CargoManifest {
165 package: Option<PackageSection>,
166 #[serde(default)]
167 dependencies: BTreeMap<String, toml::Value>,
168}
169
170#[derive(Deserialize, Default)]
171struct PackageSection {
172 name: Option<String>,
173 version: Option<String>,
174 description: Option<String>,
175 repository: Option<String>,
176 metadata: Option<PackageMetadata>,
177}
178
179#[derive(Deserialize, Default)]
180struct PackageMetadata {
181 battery: Option<BatteryMetadata>,
182}
183
184#[derive(Deserialize, Default)]
185struct BatteryMetadata {
186 #[serde(default)]
187 templates: BTreeMap<String, TemplateConfig>,
188}
189
190#[derive(Deserialize)]
191struct TemplateConfig {
192 path: String,
193 #[serde(default)]
194 description: Option<String>,
195}
196
197#[derive(Deserialize)]
202struct OwnersResponse {
203 users: Vec<Owner>,
204}
205
206#[derive(Deserialize, Clone)]
207struct Owner {
208 login: String,
209 name: Option<String>,
210}
211
212#[derive(Clone)]
218pub struct BatteryPackSummary {
219 pub name: String,
220 pub short_name: String,
221 pub version: String,
222 pub description: String,
223}
224
225#[derive(Clone)]
227pub struct BatteryPackDetail {
228 pub name: String,
229 pub short_name: String,
230 pub version: String,
231 pub description: String,
232 pub repository: Option<String>,
233 pub owners: Vec<OwnerInfo>,
234 pub crates: Vec<String>,
235 pub extends: Vec<String>,
236 pub templates: Vec<TemplateInfo>,
237 pub examples: Vec<ExampleInfo>,
238}
239
240#[derive(Clone)]
241pub struct OwnerInfo {
242 pub login: String,
243 pub name: Option<String>,
244}
245
246impl From<Owner> for OwnerInfo {
247 fn from(o: Owner) -> Self {
248 Self {
249 login: o.login,
250 name: o.name,
251 }
252 }
253}
254
255#[derive(Clone)]
256pub struct TemplateInfo {
257 pub name: String,
258 pub path: String,
259 pub description: Option<String>,
260}
261
262#[derive(Clone)]
263pub struct ExampleInfo {
264 pub name: String,
265 pub description: Option<String>,
266}
267
268fn new_from_battery_pack(
273 battery_pack: &str,
274 name: Option<String>,
275 template: Option<String>,
276 path_override: Option<String>,
277) -> Result<()> {
278 if let Some(path) = path_override {
280 return generate_from_local(&path, name, template);
281 }
282
283 let crate_name = resolve_crate_name(battery_pack);
285
286 let crate_info = lookup_crate(&crate_name)?;
288
289 let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
291 let crate_dir = temp_dir
292 .path()
293 .join(format!("{}-{}", crate_name, crate_info.version));
294
295 let manifest_path = crate_dir.join("Cargo.toml");
297 let manifest_content = std::fs::read_to_string(&manifest_path)
298 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
299 let templates = parse_template_metadata(&manifest_content, &crate_name)?;
300
301 let template_path = resolve_template(&templates, template.as_deref())?;
303
304 generate_from_path(&crate_dir, &template_path, name)
306}
307
308fn add_battery_pack(name: &str, features: &[String]) -> Result<()> {
309 let crate_name = resolve_crate_name(name);
310 let short = short_name(&crate_name);
311
312 lookup_crate(&crate_name)?;
314
315 let mut cmd = std::process::Command::new("cargo");
317 cmd.arg("add").arg(&crate_name);
318
319 cmd.arg("--rename").arg(short);
321
322 for feature in features {
324 cmd.arg("--features").arg(feature);
325 }
326
327 let status = cmd.status().context("Failed to run cargo add")?;
328
329 if !status.success() {
330 bail!("cargo add failed");
331 }
332
333 Ok(())
334}
335
336fn generate_from_local(
337 local_path: &str,
338 name: Option<String>,
339 template: Option<String>,
340) -> Result<()> {
341 let local_path = Path::new(local_path);
342
343 let manifest_path = local_path.join("Cargo.toml");
345 let manifest_content = std::fs::read_to_string(&manifest_path)
346 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
347
348 let crate_name = local_path
349 .file_name()
350 .and_then(|s| s.to_str())
351 .unwrap_or("unknown");
352 let templates = parse_template_metadata(&manifest_content, crate_name)?;
353 let template_path = resolve_template(&templates, template.as_deref())?;
354
355 generate_from_path(local_path, &template_path, name)
356}
357
358fn generate_from_path(crate_path: &Path, template_path: &str, name: Option<String>) -> Result<()> {
359 let args = GenerateArgs {
360 template_path: TemplatePath {
361 path: Some(crate_path.to_string_lossy().into_owned()),
362 auto_path: Some(template_path.to_string()),
363 ..Default::default()
364 },
365 name,
366 vcs: Some(Vcs::Git),
367 ..Default::default()
368 };
369
370 cargo_generate::generate(args)?;
371
372 Ok(())
373}
374
375struct CrateMetadata {
377 version: String,
378}
379
380fn lookup_crate(crate_name: &str) -> Result<CrateMetadata> {
382 let client = reqwest::blocking::Client::builder()
383 .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
384 .build()?;
385
386 let url = format!("{}/{}", CRATES_IO_API, crate_name);
387 let response = client
388 .get(&url)
389 .send()
390 .with_context(|| format!("Failed to query crates.io for '{}'", crate_name))?;
391
392 if !response.status().is_success() {
393 bail!(
394 "Crate '{}' not found on crates.io (status: {})",
395 crate_name,
396 response.status()
397 );
398 }
399
400 let parsed: CratesIoResponse = response
401 .json()
402 .with_context(|| format!("Failed to parse crates.io response for '{}'", crate_name))?;
403
404 let version = parsed
406 .versions
407 .iter()
408 .find(|v| !v.yanked)
409 .map(|v| v.num.clone())
410 .ok_or_else(|| anyhow::anyhow!("No non-yanked versions found for '{}'", crate_name))?;
411
412 Ok(CrateMetadata { version })
413}
414
415fn download_and_extract_crate(crate_name: &str, version: &str) -> Result<tempfile::TempDir> {
417 let client = reqwest::blocking::Client::builder()
418 .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
419 .build()?;
420
421 let url = format!(
423 "{}/{}/{}-{}.crate",
424 CRATES_IO_CDN, crate_name, crate_name, version
425 );
426
427 let response = client
428 .get(&url)
429 .send()
430 .with_context(|| format!("Failed to download crate from {}", url))?;
431
432 if !response.status().is_success() {
433 bail!(
434 "Failed to download '{}' version {} (status: {})",
435 crate_name,
436 version,
437 response.status()
438 );
439 }
440
441 let bytes = response
442 .bytes()
443 .with_context(|| "Failed to read crate tarball")?;
444
445 let temp_dir = tempfile::tempdir().with_context(|| "Failed to create temp directory")?;
447
448 let decoder = GzDecoder::new(&bytes[..]);
449 let mut archive = Archive::new(decoder);
450 archive
451 .unpack(temp_dir.path())
452 .with_context(|| "Failed to extract crate tarball")?;
453
454 Ok(temp_dir)
455}
456
457fn parse_template_metadata(
458 manifest_content: &str,
459 crate_name: &str,
460) -> Result<BTreeMap<String, TemplateConfig>> {
461 let manifest: CargoManifest =
462 toml::from_str(manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
463
464 let templates = manifest
465 .package
466 .and_then(|p| p.metadata)
467 .and_then(|m| m.battery)
468 .map(|b| b.templates)
469 .unwrap_or_default();
470
471 if templates.is_empty() {
472 bail!(
473 "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
474 crate_name
475 );
476 }
477
478 Ok(templates)
479}
480
481fn resolve_template(
482 templates: &BTreeMap<String, TemplateConfig>,
483 requested: Option<&str>,
484) -> Result<String> {
485 match requested {
486 Some(name) => {
487 let config = templates.get(name).ok_or_else(|| {
488 let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
489 anyhow::anyhow!(
490 "Template '{}' not found. Available templates: {}",
491 name,
492 available.join(", ")
493 )
494 })?;
495 Ok(config.path.clone())
496 }
497 None => {
498 if templates.len() == 1 {
499 let (_, config) = templates.iter().next().unwrap();
501 Ok(config.path.clone())
502 } else if let Some(config) = templates.get("default") {
503 Ok(config.path.clone())
505 } else {
506 prompt_for_template(templates)
508 }
509 }
510 }
511}
512
513fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
514 use dialoguer::{Select, theme::ColorfulTheme};
515
516 let items: Vec<String> = templates
518 .iter()
519 .map(|(name, config)| {
520 if let Some(desc) = &config.description {
521 format!("{} - {}", name, desc)
522 } else {
523 name.clone()
524 }
525 })
526 .collect();
527
528 if !std::io::stdout().is_terminal() {
530 println!("Available templates:");
532 for item in &items {
533 println!(" {}", item);
534 }
535 bail!("Multiple templates available. Please specify one with --template <name>");
536 }
537
538 let selection = Select::with_theme(&ColorfulTheme::default())
540 .with_prompt("Select a template")
541 .items(&items)
542 .default(0)
543 .interact()
544 .context("Failed to select template")?;
545
546 let (_, config) = templates.iter().nth(selection).unwrap();
548 Ok(config.path.clone())
549}
550
551pub fn fetch_battery_pack_list(filter: Option<&str>) -> Result<Vec<BatteryPackSummary>> {
553 let client = reqwest::blocking::Client::builder()
554 .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
555 .build()?;
556
557 let url = match filter {
559 Some(q) => format!(
560 "{CRATES_IO_API}?q={}&keyword=battery-pack&per_page=50",
561 urlencoding::encode(q)
562 ),
563 None => format!("{CRATES_IO_API}?keyword=battery-pack&per_page=50"),
564 };
565
566 let response = client
567 .get(&url)
568 .send()
569 .context("Failed to query crates.io")?;
570
571 if !response.status().is_success() {
572 bail!(
573 "Failed to list battery packs (status: {})",
574 response.status()
575 );
576 }
577
578 let parsed: SearchResponse = response.json().context("Failed to parse response")?;
579
580 let battery_packs = parsed
582 .crates
583 .into_iter()
584 .filter(|c| c.name.ends_with("-battery-pack"))
585 .map(|c| BatteryPackSummary {
586 short_name: short_name(&c.name).to_string(),
587 name: c.name,
588 version: c.max_version,
589 description: c.description.unwrap_or_default(),
590 })
591 .collect();
592
593 Ok(battery_packs)
594}
595
596fn print_battery_pack_list(filter: Option<&str>) -> Result<()> {
597 use console::style;
598
599 let battery_packs = fetch_battery_pack_list(filter)?;
600
601 if battery_packs.is_empty() {
602 match filter {
603 Some(q) => println!("No battery packs found matching '{}'", q),
604 None => println!("No battery packs found"),
605 }
606 return Ok(());
607 }
608
609 let max_name_len = battery_packs
611 .iter()
612 .map(|c| c.short_name.len())
613 .max()
614 .unwrap_or(0);
615
616 let max_version_len = battery_packs
617 .iter()
618 .map(|c| c.version.len())
619 .max()
620 .unwrap_or(0);
621
622 println!();
623 for bp in &battery_packs {
624 let desc = bp.description.lines().next().unwrap_or("");
625
626 let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
628 let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
629
630 println!(
631 " {} {} {}",
632 style(name_padded).green().bold(),
633 style(ver_padded).dim(),
634 desc,
635 );
636 }
637 println!();
638
639 println!(
640 "{}",
641 style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
642 );
643
644 Ok(())
645}
646
647fn short_name(crate_name: &str) -> &str {
649 crate_name
650 .strip_suffix("-battery-pack")
651 .unwrap_or(crate_name)
652}
653
654fn resolve_crate_name(name: &str) -> String {
656 if name.ends_with("-battery-pack") {
657 name.to_string()
658 } else {
659 format!("{}-battery-pack", name)
660 }
661}
662
663pub fn fetch_battery_pack_detail(name: &str, path: Option<&str>) -> Result<BatteryPackDetail> {
665 if let Some(local_path) = path {
667 return fetch_battery_pack_detail_from_path(local_path);
668 }
669
670 let crate_name = resolve_crate_name(name);
671
672 let crate_info = lookup_crate(&crate_name)?;
674 let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
675 let crate_dir = temp_dir
676 .path()
677 .join(format!("{}-{}", crate_name, crate_info.version));
678
679 let manifest_path = crate_dir.join("Cargo.toml");
681 let manifest_content = std::fs::read_to_string(&manifest_path)
682 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
683 let manifest: CargoManifest =
684 toml::from_str(&manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
685
686 let owners = fetch_owners(&crate_name)?;
688
689 let package = manifest.package.unwrap_or_default();
691 let description = package.description.clone().unwrap_or_default();
692 let repository = package.repository.clone();
693 let battery = package.metadata.and_then(|m| m.battery).unwrap_or_default();
694
695 let mut extends = Vec::new();
697 let mut crates = Vec::new();
698
699 for dep_name in manifest.dependencies.keys() {
700 if dep_name.ends_with("-battery-pack") {
701 extends.push(short_name(dep_name).to_string());
702 } else if dep_name != "battery-pack" {
703 crates.push(dep_name.clone());
704 }
705 }
706
707 let templates = battery
709 .templates
710 .into_iter()
711 .map(|(name, config)| TemplateInfo {
712 name,
713 path: config.path,
714 description: config.description,
715 })
716 .collect();
717
718 let examples = scan_examples(&crate_dir);
720
721 Ok(BatteryPackDetail {
722 short_name: short_name(&crate_name).to_string(),
723 name: crate_name,
724 version: crate_info.version,
725 description,
726 repository,
727 owners: owners.into_iter().map(OwnerInfo::from).collect(),
728 crates,
729 extends,
730 templates,
731 examples,
732 })
733}
734
735fn fetch_battery_pack_detail_from_path(path: &str) -> Result<BatteryPackDetail> {
737 let crate_dir = std::path::Path::new(path);
738
739 let manifest_path = crate_dir.join("Cargo.toml");
741 let manifest_content = std::fs::read_to_string(&manifest_path)
742 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
743 let manifest: CargoManifest =
744 toml::from_str(&manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
745
746 let package = manifest.package.unwrap_or_default();
748 let crate_name = package
749 .name
750 .clone()
751 .unwrap_or_else(|| "unknown".to_string());
752 let version = package
753 .version
754 .clone()
755 .unwrap_or_else(|| "0.0.0".to_string());
756 let description = package.description.clone().unwrap_or_default();
757 let repository = package.repository.clone();
758 let battery = package.metadata.and_then(|m| m.battery).unwrap_or_default();
759
760 let mut extends = Vec::new();
762 let mut crates = Vec::new();
763
764 for dep_name in manifest.dependencies.keys() {
765 if dep_name.ends_with("-battery-pack") {
766 extends.push(short_name(dep_name).to_string());
767 } else if dep_name != "battery-pack" {
768 crates.push(dep_name.clone());
769 }
770 }
771
772 let templates = battery
774 .templates
775 .into_iter()
776 .map(|(name, config)| TemplateInfo {
777 name,
778 path: config.path,
779 description: config.description,
780 })
781 .collect();
782
783 let examples = scan_examples(crate_dir);
785
786 Ok(BatteryPackDetail {
787 short_name: short_name(&crate_name).to_string(),
788 name: crate_name,
789 version,
790 description,
791 repository,
792 owners: Vec::new(), crates,
794 extends,
795 templates,
796 examples,
797 })
798}
799
800fn print_battery_pack_detail(name: &str, path: Option<&str>) -> Result<()> {
801 use console::style;
802
803 let detail = fetch_battery_pack_detail(name, path)?;
804
805 println!();
807 println!(
808 "{} {}",
809 style(&detail.name).green().bold(),
810 style(&detail.version).dim()
811 );
812 if !detail.description.is_empty() {
813 println!("{}", detail.description);
814 }
815
816 if !detail.owners.is_empty() {
818 println!();
819 println!("{}", style("Authors:").bold());
820 for owner in &detail.owners {
821 if let Some(name) = &owner.name {
822 println!(" {} ({})", name, owner.login);
823 } else {
824 println!(" {}", owner.login);
825 }
826 }
827 }
828
829 if !detail.crates.is_empty() {
831 println!();
832 println!("{}", style("Crates:").bold());
833 for dep in &detail.crates {
834 println!(" {}", dep);
835 }
836 }
837
838 if !detail.extends.is_empty() {
840 println!();
841 println!("{}", style("Extends:").bold());
842 for dep in &detail.extends {
843 println!(" {}", dep);
844 }
845 }
846
847 if !detail.templates.is_empty() {
849 println!();
850 println!("{}", style("Templates:").bold());
851 let max_name_len = detail
852 .templates
853 .iter()
854 .map(|t| t.name.len())
855 .max()
856 .unwrap_or(0);
857 for tmpl in &detail.templates {
858 let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
859 if let Some(desc) = &tmpl.description {
860 println!(" {} {}", style(name_padded).cyan(), desc);
861 } else {
862 println!(" {}", style(name_padded).cyan());
863 }
864 }
865 }
866
867 if !detail.examples.is_empty() {
869 println!();
870 println!("{}", style("Examples:").bold());
871 let max_name_len = detail
872 .examples
873 .iter()
874 .map(|e| e.name.len())
875 .max()
876 .unwrap_or(0);
877 for example in &detail.examples {
878 let name_padded = format!("{:<width$}", example.name, width = max_name_len);
879 if let Some(desc) = &example.description {
880 println!(" {} {}", style(name_padded).magenta(), desc);
881 } else {
882 println!(" {}", style(name_padded).magenta());
883 }
884 }
885 }
886
887 println!();
889 println!("{}", style("Install:").bold());
890 println!(" cargo bp add {}", detail.short_name);
891 println!(" cargo bp new {}", detail.short_name);
892 println!();
893
894 Ok(())
895}
896
897fn fetch_owners(crate_name: &str) -> Result<Vec<Owner>> {
898 let client = reqwest::blocking::Client::builder()
899 .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
900 .build()?;
901
902 let url = format!("{}/{}/owners", CRATES_IO_API, crate_name);
903 let response = client
904 .get(&url)
905 .send()
906 .with_context(|| format!("Failed to fetch owners for '{}'", crate_name))?;
907
908 if !response.status().is_success() {
909 return Ok(Vec::new());
911 }
912
913 let parsed: OwnersResponse = response
914 .json()
915 .with_context(|| "Failed to parse owners response")?;
916
917 Ok(parsed.users)
918}
919
920fn scan_examples(crate_dir: &std::path::Path) -> Vec<ExampleInfo> {
922 let examples_dir = crate_dir.join("examples");
923 if !examples_dir.exists() {
924 return Vec::new();
925 }
926
927 let mut examples = Vec::new();
928
929 if let Ok(entries) = std::fs::read_dir(&examples_dir) {
930 for entry in entries.flatten() {
931 let path = entry.path();
932 if path.extension().is_some_and(|ext| ext == "rs") {
933 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
934 let description = extract_example_description(&path);
935 examples.push(ExampleInfo {
936 name: name.to_string(),
937 description,
938 });
939 }
940 }
941 }
942 }
943
944 examples.sort_by(|a, b| a.name.cmp(&b.name));
946 examples
947}
948
949fn extract_example_description(path: &std::path::Path) -> Option<String> {
951 let content = std::fs::read_to_string(path).ok()?;
952
953 for line in content.lines() {
955 let trimmed = line.trim();
956 if trimmed.starts_with("//!") {
957 let desc = trimmed.strip_prefix("//!").unwrap_or("").trim();
958 if !desc.is_empty() {
959 return Some(desc.to_string());
960 }
961 } else if !trimmed.is_empty() && !trimmed.starts_with("//") {
962 break;
964 }
965 }
966 None
967}