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(Deserialize)]
217struct GitHubTreeResponse {
218 tree: Vec<GitHubTreeEntry>,
219 #[serde(default)]
220 #[allow(dead_code)]
221 truncated: bool,
222}
223
224#[derive(Deserialize)]
225struct GitHubTreeEntry {
226 path: String,
227}
228
229#[derive(Clone)]
235pub struct BatteryPackSummary {
236 pub name: String,
237 pub short_name: String,
238 pub version: String,
239 pub description: String,
240}
241
242#[derive(Clone)]
244pub struct BatteryPackDetail {
245 pub name: String,
246 pub short_name: String,
247 pub version: String,
248 pub description: String,
249 pub repository: Option<String>,
250 pub owners: Vec<OwnerInfo>,
251 pub crates: Vec<String>,
252 pub extends: Vec<String>,
253 pub templates: Vec<TemplateInfo>,
254 pub examples: Vec<ExampleInfo>,
255}
256
257#[derive(Clone)]
258pub struct OwnerInfo {
259 pub login: String,
260 pub name: Option<String>,
261}
262
263impl From<Owner> for OwnerInfo {
264 fn from(o: Owner) -> Self {
265 Self {
266 login: o.login,
267 name: o.name,
268 }
269 }
270}
271
272#[derive(Clone)]
273pub struct TemplateInfo {
274 pub name: String,
275 pub path: String,
276 pub description: Option<String>,
277 pub repo_path: Option<String>,
280}
281
282#[derive(Clone)]
283pub struct ExampleInfo {
284 pub name: String,
285 pub description: Option<String>,
286 pub repo_path: Option<String>,
289}
290
291fn new_from_battery_pack(
296 battery_pack: &str,
297 name: Option<String>,
298 template: Option<String>,
299 path_override: Option<String>,
300) -> Result<()> {
301 if let Some(path) = path_override {
303 return generate_from_local(&path, name, template);
304 }
305
306 let crate_name = resolve_crate_name(battery_pack);
308
309 let crate_info = lookup_crate(&crate_name)?;
311
312 let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
314 let crate_dir = temp_dir
315 .path()
316 .join(format!("{}-{}", crate_name, crate_info.version));
317
318 let manifest_path = crate_dir.join("Cargo.toml");
320 let manifest_content = std::fs::read_to_string(&manifest_path)
321 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
322 let templates = parse_template_metadata(&manifest_content, &crate_name)?;
323
324 let template_path = resolve_template(&templates, template.as_deref())?;
326
327 generate_from_path(&crate_dir, &template_path, name)
329}
330
331fn add_battery_pack(name: &str, features: &[String]) -> Result<()> {
332 let crate_name = resolve_crate_name(name);
333 let short = short_name(&crate_name);
334
335 lookup_crate(&crate_name)?;
337
338 let mut cmd = std::process::Command::new("cargo");
340 cmd.arg("add").arg(&crate_name);
341
342 cmd.arg("--rename").arg(short);
344
345 for feature in features {
347 cmd.arg("--features").arg(feature);
348 }
349
350 let status = cmd.status().context("Failed to run cargo add")?;
351
352 if !status.success() {
353 bail!("cargo add failed");
354 }
355
356 Ok(())
357}
358
359fn generate_from_local(
360 local_path: &str,
361 name: Option<String>,
362 template: Option<String>,
363) -> Result<()> {
364 let local_path = Path::new(local_path);
365
366 let manifest_path = local_path.join("Cargo.toml");
368 let manifest_content = std::fs::read_to_string(&manifest_path)
369 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
370
371 let crate_name = local_path
372 .file_name()
373 .and_then(|s| s.to_str())
374 .unwrap_or("unknown");
375 let templates = parse_template_metadata(&manifest_content, crate_name)?;
376 let template_path = resolve_template(&templates, template.as_deref())?;
377
378 generate_from_path(local_path, &template_path, name)
379}
380
381fn generate_from_path(crate_path: &Path, template_path: &str, name: Option<String>) -> Result<()> {
382 let args = GenerateArgs {
383 template_path: TemplatePath {
384 path: Some(crate_path.to_string_lossy().into_owned()),
385 auto_path: Some(template_path.to_string()),
386 ..Default::default()
387 },
388 name,
389 vcs: Some(Vcs::Git),
390 ..Default::default()
391 };
392
393 cargo_generate::generate(args)?;
394
395 Ok(())
396}
397
398struct CrateMetadata {
400 version: String,
401}
402
403fn lookup_crate(crate_name: &str) -> Result<CrateMetadata> {
405 let client = reqwest::blocking::Client::builder()
406 .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
407 .build()?;
408
409 let url = format!("{}/{}", CRATES_IO_API, crate_name);
410 let response = client
411 .get(&url)
412 .send()
413 .with_context(|| format!("Failed to query crates.io for '{}'", crate_name))?;
414
415 if !response.status().is_success() {
416 bail!(
417 "Crate '{}' not found on crates.io (status: {})",
418 crate_name,
419 response.status()
420 );
421 }
422
423 let parsed: CratesIoResponse = response
424 .json()
425 .with_context(|| format!("Failed to parse crates.io response for '{}'", crate_name))?;
426
427 let version = parsed
429 .versions
430 .iter()
431 .find(|v| !v.yanked)
432 .map(|v| v.num.clone())
433 .ok_or_else(|| anyhow::anyhow!("No non-yanked versions found for '{}'", crate_name))?;
434
435 Ok(CrateMetadata { version })
436}
437
438fn download_and_extract_crate(crate_name: &str, version: &str) -> Result<tempfile::TempDir> {
440 let client = reqwest::blocking::Client::builder()
441 .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
442 .build()?;
443
444 let url = format!(
446 "{}/{}/{}-{}.crate",
447 CRATES_IO_CDN, crate_name, crate_name, version
448 );
449
450 let response = client
451 .get(&url)
452 .send()
453 .with_context(|| format!("Failed to download crate from {}", url))?;
454
455 if !response.status().is_success() {
456 bail!(
457 "Failed to download '{}' version {} (status: {})",
458 crate_name,
459 version,
460 response.status()
461 );
462 }
463
464 let bytes = response
465 .bytes()
466 .with_context(|| "Failed to read crate tarball")?;
467
468 let temp_dir = tempfile::tempdir().with_context(|| "Failed to create temp directory")?;
470
471 let decoder = GzDecoder::new(&bytes[..]);
472 let mut archive = Archive::new(decoder);
473 archive
474 .unpack(temp_dir.path())
475 .with_context(|| "Failed to extract crate tarball")?;
476
477 Ok(temp_dir)
478}
479
480fn parse_template_metadata(
481 manifest_content: &str,
482 crate_name: &str,
483) -> Result<BTreeMap<String, TemplateConfig>> {
484 let manifest: CargoManifest =
485 toml::from_str(manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
486
487 let templates = manifest
488 .package
489 .and_then(|p| p.metadata)
490 .and_then(|m| m.battery)
491 .map(|b| b.templates)
492 .unwrap_or_default();
493
494 if templates.is_empty() {
495 bail!(
496 "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
497 crate_name
498 );
499 }
500
501 Ok(templates)
502}
503
504fn resolve_template(
505 templates: &BTreeMap<String, TemplateConfig>,
506 requested: Option<&str>,
507) -> Result<String> {
508 match requested {
509 Some(name) => {
510 let config = templates.get(name).ok_or_else(|| {
511 let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
512 anyhow::anyhow!(
513 "Template '{}' not found. Available templates: {}",
514 name,
515 available.join(", ")
516 )
517 })?;
518 Ok(config.path.clone())
519 }
520 None => {
521 if templates.len() == 1 {
522 let (_, config) = templates.iter().next().unwrap();
524 Ok(config.path.clone())
525 } else if let Some(config) = templates.get("default") {
526 Ok(config.path.clone())
528 } else {
529 prompt_for_template(templates)
531 }
532 }
533 }
534}
535
536fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
537 use dialoguer::{Select, theme::ColorfulTheme};
538
539 let items: Vec<String> = templates
541 .iter()
542 .map(|(name, config)| {
543 if let Some(desc) = &config.description {
544 format!("{} - {}", name, desc)
545 } else {
546 name.clone()
547 }
548 })
549 .collect();
550
551 if !std::io::stdout().is_terminal() {
553 println!("Available templates:");
555 for item in &items {
556 println!(" {}", item);
557 }
558 bail!("Multiple templates available. Please specify one with --template <name>");
559 }
560
561 let selection = Select::with_theme(&ColorfulTheme::default())
563 .with_prompt("Select a template")
564 .items(&items)
565 .default(0)
566 .interact()
567 .context("Failed to select template")?;
568
569 let (_, config) = templates
571 .iter()
572 .nth(selection)
573 .ok_or_else(|| anyhow::anyhow!("Invalid template selection"))?;
574 Ok(config.path.clone())
575}
576
577pub fn fetch_battery_pack_list(filter: Option<&str>) -> Result<Vec<BatteryPackSummary>> {
579 let client = reqwest::blocking::Client::builder()
580 .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
581 .build()?;
582
583 let url = match filter {
585 Some(q) => format!(
586 "{CRATES_IO_API}?q={}&keyword=battery-pack&per_page=50",
587 urlencoding::encode(q)
588 ),
589 None => format!("{CRATES_IO_API}?keyword=battery-pack&per_page=50"),
590 };
591
592 let response = client
593 .get(&url)
594 .send()
595 .context("Failed to query crates.io")?;
596
597 if !response.status().is_success() {
598 bail!(
599 "Failed to list battery packs (status: {})",
600 response.status()
601 );
602 }
603
604 let parsed: SearchResponse = response.json().context("Failed to parse response")?;
605
606 let battery_packs = parsed
608 .crates
609 .into_iter()
610 .filter(|c| c.name.ends_with("-battery-pack"))
611 .map(|c| BatteryPackSummary {
612 short_name: short_name(&c.name).to_string(),
613 name: c.name,
614 version: c.max_version,
615 description: c.description.unwrap_or_default(),
616 })
617 .collect();
618
619 Ok(battery_packs)
620}
621
622fn print_battery_pack_list(filter: Option<&str>) -> Result<()> {
623 use console::style;
624
625 let battery_packs = fetch_battery_pack_list(filter)?;
626
627 if battery_packs.is_empty() {
628 match filter {
629 Some(q) => println!("No battery packs found matching '{}'", q),
630 None => println!("No battery packs found"),
631 }
632 return Ok(());
633 }
634
635 let max_name_len = battery_packs
637 .iter()
638 .map(|c| c.short_name.len())
639 .max()
640 .unwrap_or(0);
641
642 let max_version_len = battery_packs
643 .iter()
644 .map(|c| c.version.len())
645 .max()
646 .unwrap_or(0);
647
648 println!();
649 for bp in &battery_packs {
650 let desc = bp.description.lines().next().unwrap_or("");
651
652 let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
654 let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
655
656 println!(
657 " {} {} {}",
658 style(name_padded).green().bold(),
659 style(ver_padded).dim(),
660 desc,
661 );
662 }
663 println!();
664
665 println!(
666 "{}",
667 style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
668 );
669
670 Ok(())
671}
672
673fn short_name(crate_name: &str) -> &str {
675 crate_name
676 .strip_suffix("-battery-pack")
677 .unwrap_or(crate_name)
678}
679
680fn resolve_crate_name(name: &str) -> String {
682 if name.ends_with("-battery-pack") {
683 name.to_string()
684 } else {
685 format!("{}-battery-pack", name)
686 }
687}
688
689pub fn fetch_battery_pack_detail(name: &str, path: Option<&str>) -> Result<BatteryPackDetail> {
691 if let Some(local_path) = path {
693 return fetch_battery_pack_detail_from_path(local_path);
694 }
695
696 let crate_name = resolve_crate_name(name);
697
698 let crate_info = lookup_crate(&crate_name)?;
700 let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
701 let crate_dir = temp_dir
702 .path()
703 .join(format!("{}-{}", crate_name, crate_info.version));
704
705 let owners = fetch_owners(&crate_name)?;
707
708 build_battery_pack_detail(
709 &crate_dir,
710 crate_name,
711 crate_info.version,
712 owners,
713 )
714}
715
716fn fetch_battery_pack_detail_from_path(path: &str) -> Result<BatteryPackDetail> {
718 let crate_dir = std::path::Path::new(path);
719
720 let manifest_path = crate_dir.join("Cargo.toml");
722 let manifest_content = std::fs::read_to_string(&manifest_path)
723 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
724 let manifest: CargoManifest =
725 toml::from_str(&manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
726
727 let package = manifest.package.unwrap_or_default();
728 let crate_name = package
729 .name
730 .clone()
731 .unwrap_or_else(|| "unknown".to_string());
732 let version = package
733 .version
734 .clone()
735 .unwrap_or_else(|| "0.0.0".to_string());
736
737 build_battery_pack_detail(
738 crate_dir,
739 crate_name,
740 version,
741 Vec::new(), )
743}
744
745fn build_battery_pack_detail(
748 crate_dir: &Path,
749 crate_name: String,
750 version: String,
751 owners: Vec<Owner>,
752) -> Result<BatteryPackDetail> {
753 let manifest_path = crate_dir.join("Cargo.toml");
755 let manifest_content = std::fs::read_to_string(&manifest_path)
756 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
757 let manifest: CargoManifest =
758 toml::from_str(&manifest_content).with_context(|| "Failed to parse Cargo.toml")?;
759
760 let package = manifest.package.unwrap_or_default();
762 let description = package.description.clone().unwrap_or_default();
763 let repository = package.repository.clone();
764 let battery = package.metadata.and_then(|m| m.battery).unwrap_or_default();
765
766 let (extends_raw, crates_raw): (Vec<_>, Vec<_>) = manifest
768 .dependencies
769 .keys()
770 .filter(|d| *d != "battery-pack")
771 .partition(|d| d.ends_with("-battery-pack"));
772
773 let extends: Vec<String> = extends_raw
774 .into_iter()
775 .map(|d| short_name(d).to_string())
776 .collect();
777 let crates: Vec<String> = crates_raw.into_iter().cloned().collect();
778
779 let repo_tree = repository.as_ref().and_then(|r| fetch_github_tree(r));
781
782 let templates = battery
784 .templates
785 .into_iter()
786 .map(|(name, config)| {
787 let repo_path = repo_tree
788 .as_ref()
789 .and_then(|tree| find_template_path(tree, &config.path));
790 TemplateInfo {
791 name,
792 path: config.path,
793 description: config.description,
794 repo_path,
795 }
796 })
797 .collect();
798
799 let examples = scan_examples(crate_dir, repo_tree.as_deref());
801
802 Ok(BatteryPackDetail {
803 short_name: short_name(&crate_name).to_string(),
804 name: crate_name,
805 version,
806 description,
807 repository,
808 owners: owners.into_iter().map(OwnerInfo::from).collect(),
809 crates,
810 extends,
811 templates,
812 examples,
813 })
814}
815
816fn print_battery_pack_detail(name: &str, path: Option<&str>) -> Result<()> {
817 use console::style;
818
819 let detail = fetch_battery_pack_detail(name, path)?;
820
821 println!();
823 println!(
824 "{} {}",
825 style(&detail.name).green().bold(),
826 style(&detail.version).dim()
827 );
828 if !detail.description.is_empty() {
829 println!("{}", detail.description);
830 }
831
832 if !detail.owners.is_empty() {
834 println!();
835 println!("{}", style("Authors:").bold());
836 for owner in &detail.owners {
837 if let Some(name) = &owner.name {
838 println!(" {} ({})", name, owner.login);
839 } else {
840 println!(" {}", owner.login);
841 }
842 }
843 }
844
845 if !detail.crates.is_empty() {
847 println!();
848 println!("{}", style("Crates:").bold());
849 for dep in &detail.crates {
850 println!(" {}", dep);
851 }
852 }
853
854 if !detail.extends.is_empty() {
856 println!();
857 println!("{}", style("Extends:").bold());
858 for dep in &detail.extends {
859 println!(" {}", dep);
860 }
861 }
862
863 if !detail.templates.is_empty() {
865 println!();
866 println!("{}", style("Templates:").bold());
867 let max_name_len = detail
868 .templates
869 .iter()
870 .map(|t| t.name.len())
871 .max()
872 .unwrap_or(0);
873 for tmpl in &detail.templates {
874 let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
875 if let Some(desc) = &tmpl.description {
876 println!(" {} {}", style(name_padded).cyan(), desc);
877 } else {
878 println!(" {}", style(name_padded).cyan());
879 }
880 }
881 }
882
883 if !detail.examples.is_empty() {
885 println!();
886 println!("{}", style("Examples:").bold());
887 let max_name_len = detail
888 .examples
889 .iter()
890 .map(|e| e.name.len())
891 .max()
892 .unwrap_or(0);
893 for example in &detail.examples {
894 let name_padded = format!("{:<width$}", example.name, width = max_name_len);
895 if let Some(desc) = &example.description {
896 println!(" {} {}", style(name_padded).magenta(), desc);
897 } else {
898 println!(" {}", style(name_padded).magenta());
899 }
900 }
901 }
902
903 println!();
905 println!("{}", style("Install:").bold());
906 println!(" cargo bp add {}", detail.short_name);
907 println!(" cargo bp new {}", detail.short_name);
908 println!();
909
910 Ok(())
911}
912
913fn fetch_owners(crate_name: &str) -> Result<Vec<Owner>> {
914 let client = reqwest::blocking::Client::builder()
915 .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
916 .build()?;
917
918 let url = format!("{}/{}/owners", CRATES_IO_API, crate_name);
919 let response = client
920 .get(&url)
921 .send()
922 .with_context(|| format!("Failed to fetch owners for '{}'", crate_name))?;
923
924 if !response.status().is_success() {
925 return Ok(Vec::new());
927 }
928
929 let parsed: OwnersResponse = response
930 .json()
931 .with_context(|| "Failed to parse owners response")?;
932
933 Ok(parsed.users)
934}
935
936fn scan_examples(crate_dir: &std::path::Path, repo_tree: Option<&[String]>) -> Vec<ExampleInfo> {
939 let examples_dir = crate_dir.join("examples");
940 if !examples_dir.exists() {
941 return Vec::new();
942 }
943
944 let mut examples = Vec::new();
945
946 if let Ok(entries) = std::fs::read_dir(&examples_dir) {
947 for entry in entries.flatten() {
948 let path = entry.path();
949 if path.extension().is_some_and(|ext| ext == "rs") {
950 if let Some(name) = path.file_stem().and_then(|s| s.to_str()) {
951 let description = extract_example_description(&path);
952 let repo_path = repo_tree.and_then(|tree| find_example_path(tree, name));
953 examples.push(ExampleInfo {
954 name: name.to_string(),
955 description,
956 repo_path,
957 });
958 }
959 }
960 }
961 }
962
963 examples.sort_by(|a, b| a.name.cmp(&b.name));
965 examples
966}
967
968fn extract_example_description(path: &std::path::Path) -> Option<String> {
970 let content = std::fs::read_to_string(path).ok()?;
971
972 for line in content.lines() {
974 let trimmed = line.trim();
975 if trimmed.starts_with("//!") {
976 let desc = trimmed.strip_prefix("//!").unwrap_or("").trim();
977 if !desc.is_empty() {
978 return Some(desc.to_string());
979 }
980 } else if !trimmed.is_empty() && !trimmed.starts_with("//") {
981 break;
983 }
984 }
985 None
986}
987
988fn fetch_github_tree(repository: &str) -> Option<Vec<String>> {
991 let gh_path = repository
993 .strip_prefix("https://github.com/")
994 .or_else(|| repository.strip_prefix("http://github.com/"))?;
995 let gh_path = gh_path.strip_suffix(".git").unwrap_or(gh_path);
996 let gh_path = gh_path.trim_end_matches('/');
997
998 let client = reqwest::blocking::Client::builder()
999 .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
1000 .build()
1001 .ok()?;
1002
1003 let url = format!(
1005 "https://api.github.com/repos/{}/git/trees/main?recursive=1",
1006 gh_path
1007 );
1008
1009 let response = client.get(&url).send().ok()?;
1010 if !response.status().is_success() {
1011 return None;
1012 }
1013
1014 let tree_response: GitHubTreeResponse = response.json().ok()?;
1015
1016 Some(
1018 tree_response
1019 .tree
1020 .into_iter()
1021 .map(|e| e.path)
1022 .collect(),
1023 )
1024}
1025
1026fn find_example_path(tree: &[String], example_name: &str) -> Option<String> {
1029 let suffix = format!("examples/{}.rs", example_name);
1030 tree.iter()
1031 .find(|path| path.ends_with(&suffix))
1032 .cloned()
1033}
1034
1035fn find_template_path(tree: &[String], template_path: &str) -> Option<String> {
1038 tree.iter()
1040 .find(|path| path.ends_with(template_path))
1041 .cloned()
1042}