1use anyhow::{Context, Result, bail};
4use clap::{Parser, Subcommand};
5use flate2::read::GzDecoder;
6use serde::Deserialize;
7use std::collections::{BTreeMap, BTreeSet};
8use std::io::IsTerminal;
9use std::path::{Path, PathBuf};
10use tar::Archive;
11
12pub(crate) mod template_engine;
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
18fn http_client() -> &'static reqwest::blocking::Client {
19 static CLIENT: std::sync::OnceLock<reqwest::blocking::Client> = std::sync::OnceLock::new();
20 CLIENT.get_or_init(|| {
21 reqwest::blocking::Client::builder()
22 .user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
23 .build()
24 .expect("failed to build HTTP client")
25 })
26}
27
28#[derive(Debug, Clone)]
31pub enum CrateSource {
32 Registry,
33 Local(PathBuf),
34}
35
36#[derive(Parser)]
38#[command(name = "cargo-bp")]
39#[command(bin_name = "cargo")]
40#[command(version, about = "Create and manage battery packs", long_about = None)]
41pub struct Cli {
42 #[command(subcommand)]
43 pub command: Commands,
44}
45
46#[derive(Subcommand)]
47pub enum Commands {
48 Bp {
50 #[arg(long)]
53 crate_source: Option<PathBuf>,
54
55 #[command(subcommand)]
56 command: Option<BpCommands>,
57 },
58}
59
60#[derive(Debug, clap::Args)]
61pub struct NewArgs {
62 pub battery_pack: String,
64
65 #[arg(long, short = 'n')]
67 pub name: Option<String>,
68
69 #[arg(long, short = 't')]
72 pub template: Option<String>,
73
74 #[arg(long)]
76 pub path: Option<String>,
77
78 #[arg(long = "define", short = 'd', value_parser = parse_define)]
80 pub define: Vec<(String, String)>,
81
82 #[arg(long)]
84 pub preview: bool,
85}
86
87#[derive(Debug, clap::Args)]
88pub struct AddArgs {
89 pub battery_pack: Option<String>,
92
93 pub crates: Vec<String>,
95
96 #[arg(long = "features", short = 'F', value_delimiter = ',')]
100 pub features: Vec<String>,
101
102 #[arg(long)]
105 pub no_default_features: bool,
106
107 #[arg(long)]
110 pub all_features: bool,
111
112 #[arg(long)]
116 pub target: Option<AddTarget>,
117
118 #[arg(long)]
120 pub path: Option<String>,
121}
122
123#[derive(Subcommand)]
124pub enum BpCommands {
125 New(NewArgs),
127
128 Add(AddArgs),
134
135 Sync {
137 #[arg(long)]
140 path: Option<String>,
141 },
142
143 Enable {
145 feature_name: String,
147
148 #[arg(long)]
150 battery_pack: Option<String>,
151 },
152
153 #[command(visible_alias = "ls")]
155 List {
156 filter: Option<String>,
158
159 #[arg(long)]
161 non_interactive: bool,
162 },
163
164 #[command(visible_alias = "info")]
166 Show {
167 battery_pack: String,
169
170 #[arg(long)]
172 path: Option<String>,
173
174 #[arg(long)]
176 non_interactive: bool,
177 },
178
179 #[command(visible_alias = "stat")]
181 Status {
182 #[arg(long)]
185 path: Option<String>,
186 },
187
188 Validate {
190 #[arg(long)]
192 path: Option<String>,
193 },
194}
195
196#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
198pub enum AddTarget {
199 Workspace,
201 Package,
203 Default,
205}
206
207pub fn main() -> Result<()> {
209 let cli = Cli::parse();
210 let project_dir = std::env::current_dir().context("Failed to get current directory")?;
211 let interactive = std::io::stdout().is_terminal();
212
213 match cli.command {
214 Commands::Bp {
215 crate_source,
216 command,
217 } => {
218 let source = match crate_source {
219 Some(path) => CrateSource::Local(path),
220 None => CrateSource::Registry,
221 };
222 let Some(command) = command else {
224 if interactive {
225 return tui::run_add(source);
226 } else {
227 bail!(
228 "No subcommand specified. Use `cargo bp --help` or run interactively in a terminal."
229 );
230 }
231 };
232 match command {
233 BpCommands::New(args) => new_from_battery_pack(args, &source),
234 BpCommands::Add(args) => match args.battery_pack {
235 Some(_) => add_battery_pack(args, &source, &project_dir),
236 None if interactive => tui::run_add(source),
237 None => {
238 bail!(
239 "No battery pack specified. Use `cargo bp add <name>` or run interactively in a terminal."
240 )
241 }
242 },
243 BpCommands::Sync { path } => {
244 sync_battery_packs(&project_dir, path.as_deref(), &source)
245 }
246 BpCommands::Enable {
247 feature_name,
248 battery_pack,
249 } => enable_feature(&feature_name, battery_pack.as_deref(), &project_dir),
250 BpCommands::List {
251 filter,
252 non_interactive,
253 } => {
254 if !non_interactive && interactive {
257 tui::run_list(source, filter)
258 } else {
259 print_battery_pack_list(&source, filter.as_deref())
262 }
263 }
264 BpCommands::Show {
265 battery_pack,
266 path,
267 non_interactive,
268 } => {
269 if !non_interactive && interactive {
272 tui::run_show(&battery_pack, path.as_deref(), source)
273 } else {
274 print_battery_pack_detail(&battery_pack, path.as_deref(), &source)
275 }
276 }
277 BpCommands::Status { path } => {
278 status_battery_packs(&project_dir, path.as_deref(), &source)
279 }
280 BpCommands::Validate { path } => validate_battery_pack_cmd(path.as_deref()),
281 }
282 }
283 }
284}
285
286#[derive(Deserialize)]
291struct CratesIoResponse {
292 versions: Vec<VersionInfo>,
293}
294
295#[derive(Deserialize)]
296struct VersionInfo {
297 num: String,
298 yanked: bool,
299}
300
301#[derive(Deserialize)]
302struct SearchResponse {
303 crates: Vec<SearchCrate>,
304}
305
306#[derive(Deserialize)]
307struct SearchCrate {
308 name: String,
309 max_version: String,
310 description: Option<String>,
311}
312
313pub type TemplateConfig = bphelper_manifest::TemplateSpec;
315
316#[derive(Deserialize)]
321struct OwnersResponse {
322 users: Vec<Owner>,
323}
324
325#[derive(Deserialize, Clone)]
326struct Owner {
327 login: String,
328 name: Option<String>,
329}
330
331#[derive(Deserialize)]
336struct GitHubTreeResponse {
337 tree: Vec<GitHubTreeEntry>,
338 #[serde(default)]
339 #[allow(dead_code)]
340 truncated: bool,
341}
342
343#[derive(Deserialize)]
344struct GitHubTreeEntry {
345 path: String,
346}
347
348#[derive(Clone)]
354pub struct BatteryPackSummary {
355 pub name: String,
356 pub short_name: String,
357 pub version: String,
358 pub description: String,
359}
360
361#[derive(Clone)]
363pub struct BatteryPackDetail {
364 pub name: String,
365 pub short_name: String,
366 pub version: String,
367 pub description: String,
368 pub repository: Option<String>,
369 pub owners: Vec<OwnerInfo>,
370 pub crates: Vec<String>,
371 pub extends: Vec<String>,
372 pub templates: Vec<TemplateInfo>,
373 pub examples: Vec<ExampleInfo>,
374}
375
376#[derive(Clone)]
377pub struct OwnerInfo {
378 pub login: String,
379 pub name: Option<String>,
380}
381
382impl From<Owner> for OwnerInfo {
383 fn from(o: Owner) -> Self {
384 Self {
385 login: o.login,
386 name: o.name,
387 }
388 }
389}
390
391#[derive(Clone)]
392pub struct TemplateInfo {
393 pub name: String,
394 pub path: String,
395 pub description: Option<String>,
396 pub repo_path: Option<String>,
399}
400
401#[derive(Clone)]
402pub struct ExampleInfo {
403 pub name: String,
404 pub description: Option<String>,
405 pub repo_path: Option<String>,
408}
409
410fn new_from_battery_pack(args: NewArgs, source: &CrateSource) -> Result<()> {
420 let defines: std::collections::BTreeMap<String, String> = args.define.into_iter().collect();
421
422 let _temp_dir: Option<tempfile::TempDir>;
423 let (crate_dir, template_path) = if let Some(path) = args.path {
424 _temp_dir = None;
425 resolve_local_template(&path, args.template.as_deref())?
426 } else {
427 let (dir, tp, temp) =
428 resolve_remote_template(&args.battery_pack, args.template.as_deref(), source)?;
429 _temp_dir = temp;
430 (dir, tp)
431 };
432
433 let render = template_engine::RenderOpts {
434 crate_root: crate_dir,
435 template_path,
436 project_name: if args.preview {
437 args.name.unwrap_or_else(|| "my-project".to_string())
438 } else {
439 let raw = prompt_project_name(args.name)?;
440 if args.battery_pack == "battery-pack" {
446 ensure_battery_pack_suffix(raw)
447 } else {
448 raw
449 }
450 },
451 defines,
452 };
453
454 if args.preview {
455 let files = template_engine::preview(render)?;
456 for file in &files {
457 println!("── {} ──", file.path);
458 println!("{}", file.content);
459 println!();
460 }
461 return Ok(());
462 }
463
464 template_engine::generate(template_engine::GenerateOpts {
465 render,
466 destination: None,
467 git_init: true,
468 })?;
469 Ok(())
470}
471
472fn resolve_remote_template(
474 battery_pack: &str,
475 template: Option<&str>,
476 source: &CrateSource,
477) -> Result<(PathBuf, String, Option<tempfile::TempDir>)> {
478 let crate_name = resolve_crate_name(battery_pack);
479 let crate_dir: PathBuf;
480 let temp_dir: Option<tempfile::TempDir>;
481 match source {
482 CrateSource::Registry => {
483 let crate_info = lookup_crate(&crate_name)?;
484 let temp = download_and_extract_crate(&crate_name, &crate_info.version)?;
485 crate_dir = temp
486 .path()
487 .join(format!("{}-{}", crate_name, crate_info.version));
488 temp_dir = Some(temp);
489 }
490 CrateSource::Local(workspace_dir) => {
491 crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
492 temp_dir = None;
493 }
494 }
495 let manifest_path = crate_dir.join("Cargo.toml");
496 let manifest_content = std::fs::read_to_string(&manifest_path)
497 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
498 let templates = parse_template_metadata(&manifest_content, &crate_name)?;
499 let template_path = resolve_template(&templates, template)?;
500 Ok((crate_dir, template_path, temp_dir))
501}
502
503pub enum ResolvedAdd {
505 Crates {
507 active_features: BTreeSet<String>,
508 crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
509 },
510 Interactive,
512}
513
514pub fn resolve_add_crates(
529 bp_spec: &bphelper_manifest::BatteryPackSpec,
530 bp_name: &str,
531 with_features: &[String],
532 no_default_features: bool,
533 all_features: bool,
534 specific_crates: &[String],
535) -> ResolvedAdd {
536 if !specific_crates.is_empty() {
537 let mut selected = BTreeMap::new();
539 for crate_name_arg in specific_crates {
540 if let Some(spec) = bp_spec.crates.get(crate_name_arg.as_str()) {
541 selected.insert(crate_name_arg.clone(), spec.clone());
542 } else {
543 eprintln!(
544 "error: crate '{}' not found in battery pack '{}'",
545 crate_name_arg, bp_name
546 );
547 }
548 }
549 return ResolvedAdd::Crates {
550 active_features: BTreeSet::new(),
551 crates: selected,
552 };
553 }
554
555 if all_features {
556 return ResolvedAdd::Crates {
558 active_features: BTreeSet::from(["all".to_string()]),
559 crates: bp_spec.resolve_all_visible(),
560 };
561 }
562
563 if !no_default_features && with_features.is_empty() && bp_spec.has_meaningful_choices() {
567 return ResolvedAdd::Interactive;
568 }
569
570 let mut features: BTreeSet<String> = if no_default_features {
571 BTreeSet::new()
572 } else {
573 BTreeSet::from(["default".to_string()])
574 };
575 features.extend(with_features.iter().cloned());
576
577 if features.is_empty() {
581 return ResolvedAdd::Crates {
582 active_features: features,
583 crates: BTreeMap::new(),
584 };
585 }
586
587 let str_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
588 let crates = bp_spec.resolve_crates(&str_features);
589 ResolvedAdd::Crates {
590 active_features: features,
591 crates,
592 }
593}
594
595pub fn add_battery_pack(args: AddArgs, source: &CrateSource, project_dir: &Path) -> Result<()> {
605 let name = args
606 .battery_pack
607 .as_deref()
608 .context("battery pack name required")?;
609 let crate_name = resolve_crate_name(name);
610
611 let (bp_version, bp_spec) = if let Some(ref local_path) = args.path {
617 let manifest_path = Path::new(local_path).join("Cargo.toml");
618 let manifest_content = std::fs::read_to_string(&manifest_path)
619 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
620 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
621 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e))?;
622 (None, spec)
623 } else {
624 fetch_bp_spec(source, name)?
625 };
626
627 let resolved = resolve_add_crates(
630 &bp_spec,
631 &crate_name,
632 &args.features,
633 args.no_default_features,
634 args.all_features,
635 &args.crates,
636 );
637 let (active_features, crates_to_sync) = match resolved {
638 ResolvedAdd::Crates {
639 active_features,
640 crates,
641 } => (active_features, crates),
642 ResolvedAdd::Interactive if std::io::stdout().is_terminal() => {
643 match pick_crates_interactive(&bp_spec)? {
644 Some(result) => (result.active_features, result.crates),
645 None => {
646 println!("Cancelled.");
647 return Ok(());
648 }
649 }
650 }
651 ResolvedAdd::Interactive => {
652 let crates = bp_spec.resolve_crates(&["default"]);
654 (BTreeSet::from(["default".to_string()]), crates)
655 }
656 };
657
658 if crates_to_sync.is_empty() {
659 println!("No crates selected.");
660 return Ok(());
661 }
662
663 let user_manifest_path = find_user_manifest(project_dir)?;
665 let user_manifest_content =
666 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
667 let mut user_doc: toml_edit::DocumentMut = user_manifest_content
669 .parse()
670 .context("Failed to parse Cargo.toml")?;
671
672 let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
674
675 let build_deps =
677 user_doc["build-dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
678 if let Some(table) = build_deps.as_table_mut() {
679 if let Some(ref local_path) = args.path {
680 let mut dep = toml_edit::InlineTable::new();
681 dep.insert("path", toml_edit::Value::from(local_path.as_str()));
682 table.insert(
683 &crate_name,
684 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
685 );
686 } else if workspace_manifest.is_some() {
687 let mut dep = toml_edit::InlineTable::new();
688 dep.insert("workspace", toml_edit::Value::from(true));
689 table.insert(
690 &crate_name,
691 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
692 );
693 } else {
694 let version = bp_version
695 .as_ref()
696 .context("battery pack version not available (--path without workspace)")?;
697 table.insert(&crate_name, toml_edit::value(version));
698 }
699 }
700
701 let mut ws_doc: Option<toml_edit::DocumentMut> = if let Some(ref ws_path) = workspace_manifest {
706 let ws_content =
707 std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
708 Some(
709 ws_content
710 .parse()
711 .context("Failed to parse workspace Cargo.toml")?,
712 )
713 } else {
714 None
715 };
716
717 if let Some(ref mut doc) = ws_doc {
718 let ws_deps = doc["workspace"]["dependencies"]
719 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
720 if let Some(ws_table) = ws_deps.as_table_mut() {
721 if let Some(ref local_path) = args.path {
723 let mut dep = toml_edit::InlineTable::new();
724 dep.insert("path", toml_edit::Value::from(local_path.as_str()));
725 ws_table.insert(
726 &crate_name,
727 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
728 );
729 } else {
730 let version = bp_version
731 .as_ref()
732 .context("battery pack version not available (--path without workspace)")?;
733 ws_table.insert(&crate_name, toml_edit::value(version));
734 }
735 for (dep_name, dep_spec) in &crates_to_sync {
737 add_dep_to_table(ws_table, dep_name, dep_spec);
738 }
739 }
740
741 write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, false);
743 } else {
744 write_deps_by_kind(&mut user_doc, &crates_to_sync, false);
747 }
748
749 let use_workspace_metadata = match args.target {
755 Some(AddTarget::Workspace) => true,
756 Some(AddTarget::Package) => false,
757 Some(AddTarget::Default) | None => workspace_manifest.is_some(),
758 };
759
760 if use_workspace_metadata {
761 if let Some(ref mut doc) = ws_doc {
762 write_bp_features_to_doc(
763 doc,
764 &["workspace", "metadata"],
765 &crate_name,
766 &active_features,
767 );
768 } else {
769 bail!("--target=workspace requires a workspace, but none was found");
770 }
771 } else {
772 write_bp_features_to_doc(
773 &mut user_doc,
774 &["package", "metadata"],
775 &crate_name,
776 &active_features,
777 );
778 }
779
780 if let (Some(ws_path), Some(doc)) = (&workspace_manifest, &ws_doc) {
782 std::fs::write(ws_path, doc.to_string()).context("Failed to write workspace Cargo.toml")?;
784 }
785
786 std::fs::write(&user_manifest_path, user_doc.to_string())
789 .context("Failed to write Cargo.toml")?;
790
791 let build_rs_path = user_manifest_path
793 .parent()
794 .unwrap_or(Path::new("."))
795 .join("build.rs");
796 update_build_rs(&build_rs_path, &crate_name)?;
797
798 println!(
799 "Added {} with {} crate(s)",
800 crate_name,
801 crates_to_sync.len()
802 );
803 for dep_name in crates_to_sync.keys() {
804 println!(" + {}", dep_name);
805 }
806
807 Ok(())
808}
809
810fn sync_battery_packs(project_dir: &Path, path: Option<&str>, source: &CrateSource) -> Result<()> {
816 let user_manifest_path = find_user_manifest(project_dir)?;
817 let user_manifest_content =
818 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
819
820 let bp_names = find_installed_bp_names(&user_manifest_content)?;
821
822 if bp_names.is_empty() {
823 println!("No battery packs installed.");
824 return Ok(());
825 }
826
827 let mut user_doc: toml_edit::DocumentMut = user_manifest_content
829 .parse()
830 .context("Failed to parse Cargo.toml")?;
831
832 let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
833 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
834 let mut total_changes = 0;
835
836 for bp_name in &bp_names {
837 let bp_spec = load_installed_bp_spec(bp_name, path, source)?;
839
840 let active_features =
842 read_active_features_from(&metadata_location, &user_manifest_content, bp_name);
843
844 let expected = bp_spec.resolve_for_features(&active_features);
846
847 if let Some(ref ws_path) = workspace_manifest {
850 let ws_content =
851 std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
852 let mut ws_doc: toml_edit::DocumentMut = ws_content
854 .parse()
855 .context("Failed to parse workspace Cargo.toml")?;
856
857 let ws_deps = ws_doc["workspace"]["dependencies"]
858 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
859 if let Some(ws_table) = ws_deps.as_table_mut() {
860 for (dep_name, dep_spec) in &expected {
861 if sync_dep_in_table(ws_table, dep_name, dep_spec) {
862 total_changes += 1;
863 println!(" ~ {} (updated in workspace)", dep_name);
864 }
865 }
866 }
867 std::fs::write(ws_path, ws_doc.to_string())
869 .context("Failed to write workspace Cargo.toml")?;
870
871 let refs_added = write_workspace_refs_by_kind(&mut user_doc, &expected, true);
874 total_changes += refs_added;
875 } else {
876 for (dep_name, dep_spec) in &expected {
879 let section = dep_kind_section(dep_spec.dep_kind);
880 let table =
881 user_doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
882 if let Some(table) = table.as_table_mut() {
883 if !table.contains_key(dep_name) {
884 add_dep_to_table(table, dep_name, dep_spec);
885 total_changes += 1;
886 println!(" + {}", dep_name);
887 } else if sync_dep_in_table(table, dep_name, dep_spec) {
888 total_changes += 1;
889 println!(" ~ {}", dep_name);
890 }
891 }
892 }
893 }
894 }
895
896 std::fs::write(&user_manifest_path, user_doc.to_string())
898 .context("Failed to write Cargo.toml")?;
899
900 if total_changes == 0 {
901 println!("All dependencies are up to date.");
902 } else {
903 println!("Synced {} change(s).", total_changes);
904 }
905
906 Ok(())
907}
908
909fn enable_feature(
910 feature_name: &str,
911 battery_pack: Option<&str>,
912 project_dir: &Path,
913) -> Result<()> {
914 let user_manifest_path = find_user_manifest(project_dir)?;
915 let user_manifest_content =
916 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
917
918 let bp_name = if let Some(name) = battery_pack {
920 resolve_crate_name(name)
921 } else {
922 let bp_names = find_installed_bp_names(&user_manifest_content)?;
924
925 let mut found = None;
926 for name in &bp_names {
927 let spec = fetch_battery_pack_spec(name)?;
928 if spec.features.contains_key(feature_name) {
929 found = Some(name.clone());
930 break;
931 }
932 }
933 found.ok_or_else(|| {
934 anyhow::anyhow!(
935 "No installed battery pack defines feature '{}'",
936 feature_name
937 )
938 })?
939 };
940
941 let bp_spec = fetch_battery_pack_spec(&bp_name)?;
942
943 if !bp_spec.features.contains_key(feature_name) {
944 let available: Vec<_> = bp_spec.features.keys().collect();
945 bail!(
946 "Battery pack '{}' has no feature '{}'. Available: {:?}",
947 bp_name,
948 feature_name,
949 available
950 );
951 }
952
953 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
955 let mut active_features =
956 read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
957 if active_features.contains(feature_name) {
958 println!(
959 "Feature '{}' is already active for {}.",
960 feature_name, bp_name
961 );
962 return Ok(());
963 }
964 active_features.insert(feature_name.to_string());
965
966 let str_features: Vec<&str> = active_features.iter().map(|s| s.as_str()).collect();
968 let crates_to_sync = bp_spec.resolve_crates(&str_features);
969
970 let mut user_doc: toml_edit::DocumentMut = user_manifest_content
972 .parse()
973 .context("Failed to parse Cargo.toml")?;
974
975 let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
976
977 if let Some(ref ws_path) = workspace_manifest {
979 let ws_content =
980 std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
981 let mut ws_doc: toml_edit::DocumentMut = ws_content
982 .parse()
983 .context("Failed to parse workspace Cargo.toml")?;
984
985 let ws_deps = ws_doc["workspace"]["dependencies"]
986 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
987 if let Some(ws_table) = ws_deps.as_table_mut() {
988 for (dep_name, dep_spec) in &crates_to_sync {
989 add_dep_to_table(ws_table, dep_name, dep_spec);
990 }
991 }
992
993 if matches!(metadata_location, MetadataLocation::Workspace { .. }) {
995 write_bp_features_to_doc(
996 &mut ws_doc,
997 &["workspace", "metadata"],
998 &bp_name,
999 &active_features,
1000 );
1001 }
1002
1003 std::fs::write(ws_path, ws_doc.to_string())
1004 .context("Failed to write workspace Cargo.toml")?;
1005
1006 write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, true);
1008 } else {
1009 write_deps_by_kind(&mut user_doc, &crates_to_sync, true);
1011 }
1012
1013 if matches!(metadata_location, MetadataLocation::Package) {
1015 write_bp_features_to_doc(
1016 &mut user_doc,
1017 &["package", "metadata"],
1018 &bp_name,
1019 &active_features,
1020 );
1021 }
1022
1023 std::fs::write(&user_manifest_path, user_doc.to_string())
1024 .context("Failed to write Cargo.toml")?;
1025
1026 println!("Enabled feature '{}' from {}", feature_name, bp_name);
1027 Ok(())
1028}
1029
1030struct PickerResult {
1036 crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
1038 active_features: BTreeSet<String>,
1040}
1041
1042fn pick_crates_interactive(
1047 bp_spec: &bphelper_manifest::BatteryPackSpec,
1048) -> Result<Option<PickerResult>> {
1049 use console::style;
1050 use dialoguer::MultiSelect;
1051
1052 let grouped = bp_spec.all_crates_with_grouping();
1053 if grouped.is_empty() {
1054 bail!("Battery pack has no crates to add");
1055 }
1056
1057 let mut labels = Vec::new();
1059 let mut defaults = Vec::new();
1060
1061 for (group, crate_name, dep, is_default) in &grouped {
1062 let version_info = if dep.features.is_empty() {
1063 format!("({})", dep.version)
1064 } else {
1065 format!(
1066 "({}, features: {})",
1067 dep.version,
1068 dep.features
1069 .iter()
1070 .map(|s| s.as_str())
1071 .collect::<Vec<_>>()
1072 .join(", ")
1073 )
1074 };
1075
1076 let group_label = if group == "default" {
1077 String::new()
1078 } else {
1079 format!(" [{}]", group)
1080 };
1081
1082 labels.push(format!(
1083 "{} {}{}",
1084 crate_name,
1085 style(&version_info).dim(),
1086 style(&group_label).cyan()
1087 ));
1088 defaults.push(*is_default);
1089 }
1090
1091 println!();
1093 println!(
1094 " {} v{}",
1095 style(&bp_spec.name).green().bold(),
1096 style(&bp_spec.version).dim()
1097 );
1098 println!();
1099
1100 let selections = MultiSelect::new()
1101 .with_prompt("Select crates to add")
1102 .items(&labels)
1103 .defaults(&defaults)
1104 .interact_opt()
1105 .context("Failed to show crate picker")?;
1106
1107 let Some(selected_indices) = selections else {
1108 return Ok(None); };
1110
1111 let mut crates = BTreeMap::new();
1113
1114 for idx in &selected_indices {
1115 let (_group, crate_name, dep, _) = &grouped[*idx];
1116 let merged = (*dep).clone();
1118
1119 crates.insert(crate_name.clone(), merged);
1120 }
1121
1122 let mut active_features = BTreeSet::from(["default".to_string()]);
1124 for (feature_name, feature_crates) in &bp_spec.features {
1125 if feature_name == "default" {
1126 continue;
1127 }
1128 let all_selected = feature_crates.iter().all(|c| crates.contains_key(c));
1129 if all_selected {
1130 active_features.insert(feature_name.clone());
1131 }
1132 }
1133
1134 Ok(Some(PickerResult {
1135 crates,
1136 active_features,
1137 }))
1138}
1139
1140fn find_user_manifest(project_dir: &Path) -> Result<std::path::PathBuf> {
1146 let path = project_dir.join("Cargo.toml");
1147 if path.exists() {
1148 Ok(path)
1149 } else {
1150 bail!("No Cargo.toml found in {}", project_dir.display());
1151 }
1152}
1153
1154pub fn find_installed_bp_names(manifest_content: &str) -> Result<Vec<String>> {
1159 let raw: toml::Value =
1160 toml::from_str(manifest_content).context("Failed to parse Cargo.toml")?;
1161
1162 let build_deps = raw
1163 .get("build-dependencies")
1164 .and_then(|bd| bd.as_table())
1165 .cloned()
1166 .unwrap_or_default();
1167
1168 Ok(build_deps
1169 .keys()
1170 .filter(|k| k.ends_with("-battery-pack") || *k == "battery-pack")
1171 .cloned()
1172 .collect())
1173}
1174
1175fn find_workspace_manifest(crate_manifest: &Path) -> Result<Option<std::path::PathBuf>> {
1180 let parent = crate_manifest.parent().unwrap_or(Path::new("."));
1181 let parent = if parent.as_os_str().is_empty() {
1182 Path::new(".")
1183 } else {
1184 parent
1185 };
1186 let crate_dir = parent
1187 .canonicalize()
1188 .context("Failed to resolve crate directory")?;
1189
1190 let mut dir = crate_dir.clone();
1192 loop {
1193 let candidate = dir.join("Cargo.toml");
1194 if candidate.exists() && candidate != crate_dir.join("Cargo.toml") {
1195 let content = std::fs::read_to_string(&candidate)?;
1196 if content.contains("[workspace]") {
1197 return Ok(Some(candidate));
1198 }
1199 }
1200 if !dir.pop() {
1201 break;
1202 }
1203 }
1204
1205 Ok(None)
1208}
1209
1210fn dep_kind_section(kind: bphelper_manifest::DepKind) -> &'static str {
1212 match kind {
1213 bphelper_manifest::DepKind::Normal => "dependencies",
1214 bphelper_manifest::DepKind::Dev => "dev-dependencies",
1215 bphelper_manifest::DepKind::Build => "build-dependencies",
1216 }
1217}
1218
1219fn write_deps_by_kind(
1225 doc: &mut toml_edit::DocumentMut,
1226 crates: &BTreeMap<String, bphelper_manifest::CrateSpec>,
1227 if_missing: bool,
1228) -> usize {
1229 let mut written = 0;
1230 for (dep_name, dep_spec) in crates {
1231 let section = dep_kind_section(dep_spec.dep_kind);
1232 let table = doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1233 if let Some(table) = table.as_table_mut()
1234 && (!if_missing || !table.contains_key(dep_name))
1235 {
1236 add_dep_to_table(table, dep_name, dep_spec);
1237 written += 1;
1238 }
1239 }
1240 written
1241}
1242
1243fn write_workspace_refs_by_kind(
1250 doc: &mut toml_edit::DocumentMut,
1251 crates: &BTreeMap<String, bphelper_manifest::CrateSpec>,
1252 if_missing: bool,
1253) -> usize {
1254 let mut written = 0;
1255 for (dep_name, dep_spec) in crates {
1256 let section = dep_kind_section(dep_spec.dep_kind);
1257 let table = doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1258 if let Some(table) = table.as_table_mut()
1259 && (!if_missing || !table.contains_key(dep_name))
1260 {
1261 let mut dep = toml_edit::InlineTable::new();
1262 dep.insert("workspace", toml_edit::Value::from(true));
1263 table.insert(
1264 dep_name,
1265 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
1266 );
1267 written += 1;
1268 }
1269 }
1270 written
1271}
1272
1273pub fn add_dep_to_table(
1279 table: &mut toml_edit::Table,
1280 name: &str,
1281 spec: &bphelper_manifest::CrateSpec,
1282) {
1283 if spec.features.is_empty() {
1284 table.insert(name, toml_edit::value(&spec.version));
1285 } else {
1286 let mut dep = toml_edit::InlineTable::new();
1287 dep.insert("version", toml_edit::Value::from(spec.version.as_str()));
1288 let mut features = toml_edit::Array::new();
1289 for feat in &spec.features {
1290 features.push(feat.as_str());
1291 }
1292 dep.insert("features", toml_edit::Value::Array(features));
1293 table.insert(
1294 name,
1295 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
1296 );
1297 }
1298}
1299
1300fn should_upgrade_version(current: &str, recommended: &str) -> bool {
1306 match (
1307 semver::Version::parse(current)
1308 .or_else(|_| semver::Version::parse(&format!("{}.0", current)))
1309 .or_else(|_| semver::Version::parse(&format!("{}.0.0", current))),
1310 semver::Version::parse(recommended)
1311 .or_else(|_| semver::Version::parse(&format!("{}.0", recommended)))
1312 .or_else(|_| semver::Version::parse(&format!("{}.0.0", recommended))),
1313 ) {
1314 (Ok(cur), Ok(rec)) => rec > cur,
1316 _ => current != recommended,
1318 }
1319}
1320
1321pub fn sync_dep_in_table(
1326 table: &mut toml_edit::Table,
1327 name: &str,
1328 spec: &bphelper_manifest::CrateSpec,
1329) -> bool {
1330 let Some(existing) = table.get_mut(name) else {
1331 add_dep_to_table(table, name, spec);
1333 return true;
1334 };
1335
1336 let mut changed = false;
1337
1338 match existing {
1339 toml_edit::Item::Value(toml_edit::Value::String(version_str)) => {
1340 let current = version_str.value().to_string();
1342 if !spec.version.is_empty() && should_upgrade_version(¤t, &spec.version) {
1344 *version_str = toml_edit::Formatted::new(spec.version.clone());
1345 changed = true;
1346 }
1347 if !spec.features.is_empty() {
1349 let keep_version = if !spec.version.is_empty()
1352 && should_upgrade_version(¤t, &spec.version)
1353 {
1354 spec.version.clone()
1355 } else {
1356 current.clone()
1357 };
1358 let patched = bphelper_manifest::CrateSpec {
1359 version: keep_version,
1360 features: spec.features.clone(),
1361 dep_kind: spec.dep_kind,
1362 optional: spec.optional,
1363 };
1364 add_dep_to_table(table, name, &patched);
1365 changed = true;
1366 }
1367 }
1368 toml_edit::Item::Value(toml_edit::Value::InlineTable(inline)) => {
1369 if let Some(toml_edit::Value::String(v)) = inline.get_mut("version")
1372 && !spec.version.is_empty()
1373 && should_upgrade_version(v.value(), &spec.version)
1374 {
1375 *v = toml_edit::Formatted::new(spec.version.clone());
1376 changed = true;
1377 }
1378 if !spec.features.is_empty() {
1381 let existing_features: Vec<String> = inline
1382 .get("features")
1383 .and_then(|f| f.as_array())
1384 .map(|arr| {
1385 arr.iter()
1386 .filter_map(|v| v.as_str().map(String::from))
1387 .collect()
1388 })
1389 .unwrap_or_default();
1390
1391 let mut needs_update = false;
1392 let existing_set: BTreeSet<&str> =
1393 existing_features.iter().map(|s| s.as_str()).collect();
1394 let mut all_features = existing_features.clone();
1395 for feat in &spec.features {
1396 if !existing_set.contains(feat.as_str()) {
1397 all_features.push(feat.clone());
1398 needs_update = true;
1399 }
1400 }
1401
1402 if needs_update {
1403 let mut arr = toml_edit::Array::new();
1404 for f in &all_features {
1405 arr.push(f.as_str());
1406 }
1407 inline.insert("features", toml_edit::Value::Array(arr));
1408 changed = true;
1409 }
1410 }
1411 }
1412 toml_edit::Item::Table(tbl) => {
1413 if let Some(toml_edit::Item::Value(toml_edit::Value::String(v))) =
1416 tbl.get_mut("version")
1417 && !spec.version.is_empty()
1418 && should_upgrade_version(v.value(), &spec.version)
1419 {
1420 *v = toml_edit::Formatted::new(spec.version.clone());
1421 changed = true;
1422 }
1423 if !spec.features.is_empty() {
1425 let existing_features: Vec<String> = tbl
1426 .get("features")
1427 .and_then(|f| f.as_value())
1428 .and_then(|v| v.as_array())
1429 .map(|arr| {
1430 arr.iter()
1431 .filter_map(|v| v.as_str().map(String::from))
1432 .collect()
1433 })
1434 .unwrap_or_default();
1435
1436 let existing_set: BTreeSet<&str> =
1437 existing_features.iter().map(|s| s.as_str()).collect();
1438 let mut all_features = existing_features.clone();
1439 let mut needs_update = false;
1440 for feat in &spec.features {
1441 if !existing_set.contains(feat.as_str()) {
1442 all_features.push(feat.clone());
1443 needs_update = true;
1444 }
1445 }
1446
1447 if needs_update {
1448 let mut arr = toml_edit::Array::new();
1449 for f in &all_features {
1450 arr.push(f.as_str());
1451 }
1452 tbl.insert(
1453 "features",
1454 toml_edit::Item::Value(toml_edit::Value::Array(arr)),
1455 );
1456 changed = true;
1457 }
1458 }
1459 }
1460 _ => {}
1461 }
1462
1463 changed
1464}
1465
1466fn read_features_at(raw: &toml::Value, prefix: &[&str], bp_name: &str) -> BTreeSet<String> {
1472 let mut node = Some(raw);
1473 for key in prefix {
1474 node = node.and_then(|n| n.get(key));
1475 }
1476 node.and_then(|m| m.get("battery-pack"))
1477 .and_then(|bp| bp.get(bp_name))
1478 .and_then(|entry| entry.get("features"))
1479 .and_then(|sets| sets.as_array())
1480 .map(|arr| {
1481 arr.iter()
1482 .filter_map(|v| v.as_str().map(String::from))
1483 .collect()
1484 })
1485 .unwrap_or_else(|| BTreeSet::from(["default".to_string()]))
1486}
1487
1488pub fn read_active_features(manifest_content: &str, bp_name: &str) -> BTreeSet<String> {
1490 let raw: toml::Value = match toml::from_str(manifest_content) {
1491 Ok(v) => v,
1492 Err(_) => return BTreeSet::from(["default".to_string()]),
1493 };
1494 read_features_at(&raw, &["package", "metadata"], bp_name)
1495}
1496
1497#[derive(Debug, Clone)]
1508enum MetadataLocation {
1509 Package,
1511 Workspace { ws_manifest_path: PathBuf },
1513}
1514
1515fn resolve_metadata_location(user_manifest_path: &Path) -> Result<MetadataLocation> {
1521 if let Some(ws_path) = find_workspace_manifest(user_manifest_path)? {
1522 let ws_content =
1523 std::fs::read_to_string(&ws_path).context("Failed to read workspace Cargo.toml")?;
1524 let raw: toml::Value =
1525 toml::from_str(&ws_content).context("Failed to parse workspace Cargo.toml")?;
1526 if raw
1527 .get("workspace")
1528 .and_then(|w| w.get("metadata"))
1529 .and_then(|m| m.get("battery-pack"))
1530 .is_some()
1531 {
1532 return Ok(MetadataLocation::Workspace {
1533 ws_manifest_path: ws_path,
1534 });
1535 }
1536 }
1537 Ok(MetadataLocation::Package)
1538}
1539
1540fn read_active_features_from(
1545 location: &MetadataLocation,
1546 user_manifest_content: &str,
1547 bp_name: &str,
1548) -> BTreeSet<String> {
1549 match location {
1550 MetadataLocation::Package => read_active_features(user_manifest_content, bp_name),
1551 MetadataLocation::Workspace { ws_manifest_path } => {
1552 let ws_content = match std::fs::read_to_string(ws_manifest_path) {
1553 Ok(c) => c,
1554 Err(_) => return BTreeSet::from(["default".to_string()]),
1555 };
1556 read_active_features_ws(&ws_content, bp_name)
1557 }
1558 }
1559}
1560
1561pub fn read_active_features_ws(ws_content: &str, bp_name: &str) -> BTreeSet<String> {
1563 let raw: toml::Value = match toml::from_str(ws_content) {
1564 Ok(v) => v,
1565 Err(_) => return BTreeSet::from(["default".to_string()]),
1566 };
1567 read_features_at(&raw, &["workspace", "metadata"], bp_name)
1568}
1569
1570fn write_bp_features_to_doc(
1575 doc: &mut toml_edit::DocumentMut,
1576 path_prefix: &[&str],
1577 bp_name: &str,
1578 active_features: &BTreeSet<String>,
1579) {
1580 let mut features_array = toml_edit::Array::new();
1581 for feature in active_features {
1582 features_array.push(feature.as_str());
1583 }
1584
1585 doc[path_prefix[0]].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1588 doc[path_prefix[0]][path_prefix[1]].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1589 doc[path_prefix[0]][path_prefix[1]]["battery-pack"]
1590 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1591
1592 let bp_meta = &mut doc[path_prefix[0]][path_prefix[1]]["battery-pack"][bp_name];
1593 *bp_meta = toml_edit::Item::Table(toml_edit::Table::new());
1594 bp_meta["features"] = toml_edit::value(features_array);
1595}
1596
1597fn resolve_battery_pack_manifest(bp_name: &str) -> Result<std::path::PathBuf> {
1602 let metadata = cargo_metadata::MetadataCommand::new()
1603 .exec()
1604 .context("Failed to run `cargo metadata`")?;
1605
1606 let package = metadata
1607 .packages
1608 .iter()
1609 .find(|p| p.name == bp_name)
1610 .ok_or_else(|| {
1611 anyhow::anyhow!(
1612 "Battery pack '{}' not found in dependency graph. Is it in [build-dependencies]?",
1613 bp_name
1614 )
1615 })?;
1616
1617 Ok(package.manifest_path.clone().into())
1618}
1619
1620fn load_installed_bp_spec(
1629 bp_name: &str,
1630 path: Option<&str>,
1631 source: &CrateSource,
1632) -> Result<bphelper_manifest::BatteryPackSpec> {
1633 if let Some(local_path) = path {
1634 let manifest_path = Path::new(local_path).join("Cargo.toml");
1635 let manifest_content = std::fs::read_to_string(&manifest_path)
1636 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1637 return bphelper_manifest::parse_battery_pack(&manifest_content)
1638 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", bp_name, e));
1639 }
1640 match source {
1641 CrateSource::Registry => fetch_battery_pack_spec(bp_name),
1642 CrateSource::Local(_) => {
1643 let (_version, spec) = fetch_bp_spec(source, bp_name)?;
1644 Ok(spec)
1645 }
1646 }
1647}
1648
1649fn fetch_battery_pack_spec(bp_name: &str) -> Result<bphelper_manifest::BatteryPackSpec> {
1651 let manifest_path = resolve_battery_pack_manifest(bp_name)?;
1652 let manifest_content = std::fs::read_to_string(&manifest_path)
1653 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1654
1655 bphelper_manifest::parse_battery_pack(&manifest_content)
1656 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", bp_name, e))
1657}
1658
1659pub(crate) fn fetch_bp_spec_from_registry(
1665 crate_name: &str,
1666) -> Result<(String, bphelper_manifest::BatteryPackSpec)> {
1667 let crate_info = lookup_crate(crate_name)?;
1668 let temp_dir = download_and_extract_crate(crate_name, &crate_info.version)?;
1669 let crate_dir = temp_dir
1670 .path()
1671 .join(format!("{}-{}", crate_name, crate_info.version));
1672
1673 let manifest_path = crate_dir.join("Cargo.toml");
1674 let manifest_content = std::fs::read_to_string(&manifest_path)
1675 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1676
1677 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
1678 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e))?;
1679
1680 Ok((crate_info.version, spec))
1681}
1682
1683fn update_build_rs(build_rs_path: &Path, crate_name: &str) -> Result<()> {
1689 let crate_ident = crate_name.replace('-', "_");
1690 let validate_call = format!("{}::validate();", crate_ident);
1691
1692 if build_rs_path.exists() {
1693 let content = std::fs::read_to_string(build_rs_path).context("Failed to read build.rs")?;
1694
1695 if content.contains(&validate_call) {
1697 return Ok(());
1698 }
1699
1700 let file: syn::File = syn::parse_str(&content).context("Failed to parse build.rs")?;
1702
1703 let has_main = file
1705 .items
1706 .iter()
1707 .any(|item| matches!(item, syn::Item::Fn(func) if func.sig.ident == "main"));
1708
1709 if has_main {
1710 let lines: Vec<&str> = content.lines().collect();
1712 let mut insert_line = None;
1713 let mut brace_depth: i32 = 0;
1714 let mut in_main = false;
1715
1716 for (i, line) in lines.iter().enumerate() {
1717 if line.contains("fn main") {
1718 in_main = true;
1719 brace_depth = 0;
1720 }
1721 if in_main {
1722 for ch in line.chars() {
1723 if ch == '{' {
1724 brace_depth += 1;
1725 } else if ch == '}' {
1726 brace_depth -= 1;
1727 if brace_depth == 0 {
1728 insert_line = Some(i);
1729 in_main = false;
1730 break;
1731 }
1732 }
1733 }
1734 }
1735 }
1736
1737 if let Some(line_idx) = insert_line {
1738 let mut new_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
1739 new_lines.insert(line_idx, format!(" {}", validate_call));
1740 std::fs::write(build_rs_path, new_lines.join("\n") + "\n")
1741 .context("Failed to write build.rs")?;
1742 return Ok(());
1743 }
1744 }
1745
1746 bail!(
1748 "Could not find fn main() in build.rs. Please add `{}` manually.",
1749 validate_call
1750 );
1751 } else {
1752 let content = format!("fn main() {{\n {}\n}}\n", validate_call);
1754 std::fs::write(build_rs_path, content).context("Failed to create build.rs")?;
1755 }
1756
1757 Ok(())
1758}
1759
1760fn resolve_local_template(local_path: &str, template: Option<&str>) -> Result<(PathBuf, String)> {
1761 let local_path = Path::new(local_path);
1762 let manifest_path = local_path.join("Cargo.toml");
1763 let manifest_content = std::fs::read_to_string(&manifest_path)
1764 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1765
1766 let crate_name = local_path
1767 .file_name()
1768 .and_then(|s| s.to_str())
1769 .unwrap_or("unknown");
1770 let templates = parse_template_metadata(&manifest_content, crate_name)?;
1771 let template_path = resolve_template(&templates, template)?;
1772
1773 Ok((local_path.to_path_buf(), template_path))
1774}
1775
1776fn prompt_project_name(name: Option<String>) -> Result<String> {
1778 match name {
1779 Some(n) => Ok(n),
1780 None => dialoguer::Input::<String>::new()
1781 .with_prompt("Project name")
1782 .interact_text()
1783 .context("Failed to read project name"),
1784 }
1785}
1786
1787fn ensure_battery_pack_suffix(name: String) -> String {
1789 if name.ends_with("-battery-pack") {
1790 name
1791 } else {
1792 let fixed = format!("{}-battery-pack", name);
1793 println!("Renaming project to: {}", fixed);
1794 fixed
1795 }
1796}
1797
1798fn parse_define(s: &str) -> Result<(String, String), String> {
1800 let (key, value) = s
1801 .split_once('=')
1802 .ok_or_else(|| format!("invalid define '{s}': expected key=value"))?;
1803 Ok((key.to_string(), value.to_string()))
1804}
1805
1806struct CrateMetadata {
1808 version: String,
1809}
1810
1811fn lookup_crate(crate_name: &str) -> Result<CrateMetadata> {
1813 let client = http_client();
1814
1815 let url = format!("{}/{}", CRATES_IO_API, crate_name);
1816 let response = client
1817 .get(&url)
1818 .send()
1819 .with_context(|| format!("Failed to query crates.io for '{}'", crate_name))?;
1820
1821 if !response.status().is_success() {
1822 bail!(
1823 "Crate '{}' not found on crates.io (status: {})",
1824 crate_name,
1825 response.status()
1826 );
1827 }
1828
1829 let parsed: CratesIoResponse = response
1830 .json()
1831 .with_context(|| format!("Failed to parse crates.io response for '{}'", crate_name))?;
1832
1833 let version = parsed
1835 .versions
1836 .iter()
1837 .find(|v| !v.yanked)
1838 .map(|v| v.num.clone())
1839 .ok_or_else(|| anyhow::anyhow!("No non-yanked versions found for '{}'", crate_name))?;
1840
1841 Ok(CrateMetadata { version })
1842}
1843
1844fn download_and_extract_crate(crate_name: &str, version: &str) -> Result<tempfile::TempDir> {
1846 let client = http_client();
1847
1848 let url = format!(
1850 "{}/{}/{}-{}.crate",
1851 CRATES_IO_CDN, crate_name, crate_name, version
1852 );
1853
1854 let response = client
1855 .get(&url)
1856 .send()
1857 .with_context(|| format!("Failed to download crate from {}", url))?;
1858
1859 if !response.status().is_success() {
1860 bail!(
1861 "Failed to download '{}' version {} (status: {})",
1862 crate_name,
1863 version,
1864 response.status()
1865 );
1866 }
1867
1868 let bytes = response
1869 .bytes()
1870 .with_context(|| "Failed to read crate tarball")?;
1871
1872 let temp_dir = tempfile::tempdir().with_context(|| "Failed to create temp directory")?;
1874
1875 let decoder = GzDecoder::new(&bytes[..]);
1876 let mut archive = Archive::new(decoder);
1877 archive
1878 .unpack(temp_dir.path())
1879 .with_context(|| "Failed to extract crate tarball")?;
1880
1881 Ok(temp_dir)
1882}
1883
1884fn parse_template_metadata(
1885 manifest_content: &str,
1886 crate_name: &str,
1887) -> Result<BTreeMap<String, TemplateConfig>> {
1888 let spec = bphelper_manifest::parse_battery_pack(manifest_content)
1889 .map_err(|e| anyhow::anyhow!("Failed to parse Cargo.toml: {}", e))?;
1890
1891 if spec.templates.is_empty() {
1892 bail!(
1893 "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
1894 crate_name
1895 );
1896 }
1897
1898 Ok(spec.templates)
1899}
1900
1901pub fn resolve_template(
1904 templates: &BTreeMap<String, TemplateConfig>,
1905 requested: Option<&str>,
1906) -> Result<String> {
1907 match requested {
1908 Some(name) => {
1909 let config = templates.get(name).ok_or_else(|| {
1910 let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
1911 anyhow::anyhow!(
1912 "Template '{}' not found. Available templates: {}",
1913 name,
1914 available.join(", ")
1915 )
1916 })?;
1917 Ok(config.path.clone())
1918 }
1919 None => {
1920 if templates.len() == 1 {
1921 let (_, config) = templates.iter().next().unwrap();
1923 Ok(config.path.clone())
1924 } else if let Some(config) = templates.get("default") {
1925 Ok(config.path.clone())
1927 } else {
1928 prompt_for_template(templates)
1930 }
1931 }
1932 }
1933}
1934
1935fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
1936 use dialoguer::{Select, theme::ColorfulTheme};
1937
1938 let items: Vec<String> = templates
1940 .iter()
1941 .map(|(name, config)| {
1942 if let Some(desc) = &config.description {
1943 format!("{} - {}", name, desc)
1944 } else {
1945 name.clone()
1946 }
1947 })
1948 .collect();
1949
1950 if !std::io::stdout().is_terminal() {
1952 println!("Available templates:");
1954 for item in &items {
1955 println!(" {}", item);
1956 }
1957 bail!("Multiple templates available. Please specify one with --template <name>");
1958 }
1959
1960 let selection = Select::with_theme(&ColorfulTheme::default())
1962 .with_prompt("Select a template")
1963 .items(&items)
1964 .default(0)
1965 .interact()
1966 .context("Failed to select template")?;
1967
1968 let (_, config) = templates
1970 .iter()
1971 .nth(selection)
1972 .ok_or_else(|| anyhow::anyhow!("Invalid template selection"))?;
1973 Ok(config.path.clone())
1974}
1975
1976pub struct InstalledPack {
1978 pub name: String,
1979 pub short_name: String,
1980 pub version: String,
1981 pub spec: bphelper_manifest::BatteryPackSpec,
1982 pub active_features: BTreeSet<String>,
1983}
1984
1985pub fn load_installed_packs(project_dir: &Path) -> Result<Vec<InstalledPack>> {
1991 let user_manifest_path = find_user_manifest(project_dir)?;
1992 let user_manifest_content =
1993 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
1994
1995 let bp_names = find_installed_bp_names(&user_manifest_content)?;
1996 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
1997
1998 let mut packs = Vec::new();
1999 for bp_name in bp_names {
2000 let spec = fetch_battery_pack_spec(&bp_name)?;
2001 let active_features =
2002 read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
2003 packs.push(InstalledPack {
2004 short_name: short_name(&bp_name).to_string(),
2005 version: spec.version.clone(),
2006 spec,
2007 name: bp_name,
2008 active_features,
2009 });
2010 }
2011
2012 Ok(packs)
2013}
2014
2015pub fn fetch_battery_pack_list(
2017 source: &CrateSource,
2018 filter: Option<&str>,
2019) -> Result<Vec<BatteryPackSummary>> {
2020 match source {
2021 CrateSource::Registry => fetch_battery_pack_list_from_registry(filter),
2022 CrateSource::Local(path) => discover_local_battery_packs(path, filter),
2023 }
2024}
2025
2026fn fetch_battery_pack_list_from_registry(filter: Option<&str>) -> Result<Vec<BatteryPackSummary>> {
2027 let client = http_client();
2028
2029 let url = match filter {
2031 Some(q) => format!(
2032 "{CRATES_IO_API}?q={}&keyword=battery-pack&per_page=50",
2033 urlencoding::encode(q)
2034 ),
2035 None => format!("{CRATES_IO_API}?keyword=battery-pack&per_page=50"),
2036 };
2037
2038 let response = client
2039 .get(&url)
2040 .send()
2041 .context("Failed to query crates.io")?;
2042
2043 if !response.status().is_success() {
2044 bail!(
2045 "Failed to list battery packs (status: {})",
2046 response.status()
2047 );
2048 }
2049
2050 let parsed: SearchResponse = response.json().context("Failed to parse response")?;
2051
2052 let battery_packs = parsed
2054 .crates
2055 .into_iter()
2056 .filter(|c| c.name.ends_with("-battery-pack"))
2057 .map(|c| BatteryPackSummary {
2058 short_name: short_name(&c.name).to_string(),
2059 name: c.name,
2060 version: c.max_version,
2061 description: c.description.unwrap_or_default(),
2062 })
2063 .collect();
2064
2065 Ok(battery_packs)
2066}
2067
2068fn discover_local_battery_packs(
2070 workspace_dir: &Path,
2071 filter: Option<&str>,
2072) -> Result<Vec<BatteryPackSummary>> {
2073 let manifest_path = workspace_dir.join("Cargo.toml");
2074 let metadata = cargo_metadata::MetadataCommand::new()
2075 .manifest_path(&manifest_path)
2076 .no_deps()
2077 .exec()
2078 .with_context(|| format!("Failed to read workspace at {}", manifest_path.display()))?;
2079
2080 let mut battery_packs: Vec<BatteryPackSummary> = metadata
2081 .packages
2082 .iter()
2083 .filter(|pkg| pkg.name.ends_with("-battery-pack"))
2084 .filter(|pkg| {
2085 if let Some(q) = filter {
2086 short_name(&pkg.name).contains(q)
2087 } else {
2088 true
2089 }
2090 })
2091 .map(|pkg| BatteryPackSummary {
2092 short_name: short_name(&pkg.name).to_string(),
2093 name: pkg.name.to_string(),
2094 version: pkg.version.to_string(),
2095 description: pkg.description.clone().unwrap_or_default(),
2096 })
2097 .collect();
2098
2099 battery_packs.sort_by(|a, b| a.name.cmp(&b.name));
2100 Ok(battery_packs)
2101}
2102
2103fn find_local_battery_pack_dir(workspace_dir: &Path, crate_name: &str) -> Result<PathBuf> {
2105 let manifest_path = workspace_dir.join("Cargo.toml");
2106 let metadata = cargo_metadata::MetadataCommand::new()
2107 .manifest_path(&manifest_path)
2108 .no_deps()
2109 .exec()
2110 .with_context(|| format!("Failed to read workspace at {}", manifest_path.display()))?;
2111
2112 let package = metadata
2113 .packages
2114 .iter()
2115 .find(|p| p.name == crate_name)
2116 .ok_or_else(|| {
2117 anyhow::anyhow!(
2118 "Battery pack '{}' not found in workspace at {}",
2119 crate_name,
2120 workspace_dir.display()
2121 )
2122 })?;
2123
2124 Ok(package
2125 .manifest_path
2126 .parent()
2127 .expect("manifest path should have a parent")
2128 .into())
2129}
2130
2131pub(crate) fn fetch_bp_spec(
2136 source: &CrateSource,
2137 name: &str,
2138) -> Result<(Option<String>, bphelper_manifest::BatteryPackSpec)> {
2139 let crate_name = resolve_crate_name(name);
2140 match source {
2141 CrateSource::Registry => {
2142 let (version, spec) = fetch_bp_spec_from_registry(&crate_name)?;
2143 Ok((Some(version), spec))
2144 }
2145 CrateSource::Local(workspace_dir) => {
2146 let crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
2147 let manifest_path = crate_dir.join("Cargo.toml");
2148 let manifest_content = std::fs::read_to_string(&manifest_path)
2149 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
2150 let spec = bphelper_manifest::parse_battery_pack(&manifest_content).map_err(|e| {
2151 anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e)
2152 })?;
2153 Ok((None, spec))
2154 }
2155 }
2156}
2157
2158pub(crate) fn fetch_battery_pack_detail_from_source(
2161 source: &CrateSource,
2162 name: &str,
2163) -> Result<BatteryPackDetail> {
2164 match source {
2165 CrateSource::Registry => fetch_battery_pack_detail(name, None),
2166 CrateSource::Local(workspace_dir) => {
2167 let crate_name = resolve_crate_name(name);
2168 let crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
2169 fetch_battery_pack_detail_from_path(&crate_dir.to_string_lossy())
2170 }
2171 }
2172}
2173
2174fn print_battery_pack_list(source: &CrateSource, filter: Option<&str>) -> Result<()> {
2175 use console::style;
2176
2177 let battery_packs = fetch_battery_pack_list(source, filter)?;
2178
2179 if battery_packs.is_empty() {
2180 match filter {
2181 Some(q) => println!("No battery packs found matching '{}'", q),
2182 None => println!("No battery packs found"),
2183 }
2184 return Ok(());
2185 }
2186
2187 let max_name_len = battery_packs
2189 .iter()
2190 .map(|c| c.short_name.len())
2191 .max()
2192 .unwrap_or(0);
2193
2194 let max_version_len = battery_packs
2195 .iter()
2196 .map(|c| c.version.len())
2197 .max()
2198 .unwrap_or(0);
2199
2200 println!();
2201 for bp in &battery_packs {
2202 let desc = bp.description.lines().next().unwrap_or("");
2203
2204 let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
2206 let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
2207
2208 println!(
2209 " {} {} {}",
2210 style(name_padded).green().bold(),
2211 style(ver_padded).dim(),
2212 desc,
2213 );
2214 }
2215 println!();
2216
2217 println!(
2218 "{}",
2219 style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
2220 );
2221
2222 Ok(())
2223}
2224
2225fn short_name(crate_name: &str) -> &str {
2227 crate_name
2228 .strip_suffix("-battery-pack")
2229 .unwrap_or(crate_name)
2230}
2231
2232fn resolve_crate_name(name: &str) -> String {
2237 if name == "battery-pack" || name.ends_with("-battery-pack") {
2238 name.to_string()
2239 } else {
2240 format!("{}-battery-pack", name)
2241 }
2242}
2243
2244pub fn fetch_battery_pack_detail(name: &str, path: Option<&str>) -> Result<BatteryPackDetail> {
2246 if let Some(local_path) = path {
2248 return fetch_battery_pack_detail_from_path(local_path);
2249 }
2250
2251 let crate_name = resolve_crate_name(name);
2252
2253 let crate_info = lookup_crate(&crate_name)?;
2255 let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
2256 let crate_dir = temp_dir
2257 .path()
2258 .join(format!("{}-{}", crate_name, crate_info.version));
2259
2260 let manifest_path = crate_dir.join("Cargo.toml");
2262 let manifest_content = std::fs::read_to_string(&manifest_path)
2263 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
2264 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
2265 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack: {}", e))?;
2266
2267 let owners = fetch_owners(&crate_name)?;
2269
2270 build_battery_pack_detail(&crate_dir, &spec, owners)
2271}
2272
2273fn fetch_battery_pack_detail_from_path(path: &str) -> Result<BatteryPackDetail> {
2275 let crate_dir = std::path::Path::new(path);
2276 let manifest_path = crate_dir.join("Cargo.toml");
2277 let manifest_content = std::fs::read_to_string(&manifest_path)
2278 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
2279
2280 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
2281 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack: {}", e))?;
2282
2283 build_battery_pack_detail(crate_dir, &spec, Vec::new())
2284}
2285
2286fn build_battery_pack_detail(
2291 crate_dir: &Path,
2292 spec: &bphelper_manifest::BatteryPackSpec,
2293 owners: Vec<Owner>,
2294) -> Result<BatteryPackDetail> {
2295 let (extends_raw, crates_raw): (Vec<_>, Vec<_>) = spec
2298 .visible_crates()
2299 .into_keys()
2300 .partition(|d| d.ends_with("-battery-pack"));
2301
2302 let extends: Vec<String> = extends_raw
2303 .into_iter()
2304 .map(|d| short_name(d).to_string())
2305 .collect();
2306 let crates: Vec<String> = crates_raw.into_iter().map(|s| s.to_string()).collect();
2307
2308 let repo_tree = spec.repository.as_ref().and_then(|r| fetch_github_tree(r));
2310
2311 let templates = spec
2313 .templates
2314 .iter()
2315 .map(|(name, tmpl)| {
2316 let repo_path = repo_tree
2317 .as_ref()
2318 .and_then(|tree| find_template_path(tree, &tmpl.path));
2319 TemplateInfo {
2320 name: name.clone(),
2321 path: tmpl.path.clone(),
2322 description: tmpl.description.clone(),
2323 repo_path,
2324 }
2325 })
2326 .collect();
2327
2328 let examples = scan_examples(crate_dir, repo_tree.as_deref());
2330
2331 Ok(BatteryPackDetail {
2332 short_name: short_name(&spec.name).to_string(),
2333 name: spec.name.clone(),
2334 version: spec.version.clone(),
2335 description: spec.description.clone(),
2336 repository: spec.repository.clone(),
2337 owners: owners.into_iter().map(OwnerInfo::from).collect(),
2338 crates,
2339 extends,
2340 templates,
2341 examples,
2342 })
2343}
2344
2345fn print_battery_pack_detail(name: &str, path: Option<&str>, source: &CrateSource) -> Result<()> {
2349 use console::style;
2350
2351 let detail = if path.is_some() {
2353 fetch_battery_pack_detail(name, path)?
2354 } else {
2355 fetch_battery_pack_detail_from_source(source, name)?
2356 };
2357
2358 println!();
2360 println!(
2361 "{} {}",
2362 style(&detail.name).green().bold(),
2363 style(&detail.version).dim()
2364 );
2365 if !detail.description.is_empty() {
2366 println!("{}", detail.description);
2367 }
2368
2369 if !detail.owners.is_empty() {
2371 println!();
2372 println!("{}", style("Authors:").bold());
2373 for owner in &detail.owners {
2374 if let Some(name) = &owner.name {
2375 println!(" {} ({})", name, owner.login);
2376 } else {
2377 println!(" {}", owner.login);
2378 }
2379 }
2380 }
2381
2382 if !detail.crates.is_empty() {
2384 println!();
2385 println!("{}", style("Crates:").bold());
2386 for dep in &detail.crates {
2387 println!(" {}", dep);
2388 }
2389 }
2390
2391 if !detail.extends.is_empty() {
2393 println!();
2394 println!("{}", style("Extends:").bold());
2395 for dep in &detail.extends {
2396 println!(" {}", dep);
2397 }
2398 }
2399
2400 if !detail.templates.is_empty() {
2402 println!();
2403 println!("{}", style("Templates:").bold());
2404 let max_name_len = detail
2405 .templates
2406 .iter()
2407 .map(|t| t.name.len())
2408 .max()
2409 .unwrap_or(0);
2410 for tmpl in &detail.templates {
2411 let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
2412 if let Some(desc) = &tmpl.description {
2413 println!(" {} {}", style(name_padded).cyan(), desc);
2414 } else {
2415 println!(" {}", style(name_padded).cyan());
2416 }
2417 }
2418 }
2419
2420 if !detail.examples.is_empty() {
2423 println!();
2424 println!("{}", style("Examples:").bold());
2425 let max_name_len = detail
2426 .examples
2427 .iter()
2428 .map(|e| e.name.len())
2429 .max()
2430 .unwrap_or(0);
2431 for example in &detail.examples {
2432 let name_padded = format!("{:<width$}", example.name, width = max_name_len);
2433 if let Some(desc) = &example.description {
2434 println!(" {} {}", style(name_padded).magenta(), desc);
2435 } else {
2436 println!(" {}", style(name_padded).magenta());
2437 }
2438 }
2439 }
2440
2441 println!();
2443 println!("{}", style("Install:").bold());
2444 println!(" cargo bp add {}", detail.short_name);
2445 println!(" cargo bp new {}", detail.short_name);
2446 println!();
2447
2448 Ok(())
2449}
2450
2451fn fetch_owners(crate_name: &str) -> Result<Vec<Owner>> {
2452 let client = http_client();
2453
2454 let url = format!("{}/{}/owners", CRATES_IO_API, crate_name);
2455 let response = client
2456 .get(&url)
2457 .send()
2458 .with_context(|| format!("Failed to fetch owners for '{}'", crate_name))?;
2459
2460 if !response.status().is_success() {
2461 return Ok(Vec::new());
2463 }
2464
2465 let parsed: OwnersResponse = response
2466 .json()
2467 .with_context(|| "Failed to parse owners response")?;
2468
2469 Ok(parsed.users)
2470}
2471
2472fn scan_examples(crate_dir: &std::path::Path, repo_tree: Option<&[String]>) -> Vec<ExampleInfo> {
2476 let examples_dir = crate_dir.join("examples");
2477 if !examples_dir.exists() {
2478 return Vec::new();
2479 }
2480
2481 let mut examples = Vec::new();
2482
2483 if let Ok(entries) = std::fs::read_dir(&examples_dir) {
2484 for entry in entries.flatten() {
2485 let path = entry.path();
2486 if path.extension().is_some_and(|ext| ext == "rs")
2487 && let Some(name) = path.file_stem().and_then(|s| s.to_str())
2488 {
2489 let description = extract_example_description(&path);
2490 let repo_path = repo_tree.and_then(|tree| find_example_path(tree, name));
2491 examples.push(ExampleInfo {
2492 name: name.to_string(),
2493 description,
2494 repo_path,
2495 });
2496 }
2497 }
2498 }
2499
2500 examples.sort_by(|a, b| a.name.cmp(&b.name));
2502 examples
2503}
2504
2505fn extract_example_description(path: &std::path::Path) -> Option<String> {
2507 let content = std::fs::read_to_string(path).ok()?;
2508
2509 for line in content.lines() {
2511 let trimmed = line.trim();
2512 if trimmed.starts_with("//!") {
2513 let desc = trimmed.strip_prefix("//!").unwrap_or("").trim();
2514 if !desc.is_empty() {
2515 return Some(desc.to_string());
2516 }
2517 } else if !trimmed.is_empty() && !trimmed.starts_with("//") {
2518 break;
2520 }
2521 }
2522 None
2523}
2524
2525fn fetch_github_tree(repository: &str) -> Option<Vec<String>> {
2528 let gh_path = repository
2530 .strip_prefix("https://github.com/")
2531 .or_else(|| repository.strip_prefix("http://github.com/"))?;
2532 let gh_path = gh_path.strip_suffix(".git").unwrap_or(gh_path);
2533 let gh_path = gh_path.trim_end_matches('/');
2534
2535 let client = http_client();
2536
2537 let url = format!(
2539 "https://api.github.com/repos/{}/git/trees/main?recursive=1",
2540 gh_path
2541 );
2542
2543 let response = client.get(&url).send().ok()?;
2544 if !response.status().is_success() {
2545 return None;
2546 }
2547
2548 let tree_response: GitHubTreeResponse = response.json().ok()?;
2549
2550 Some(tree_response.tree.into_iter().map(|e| e.path).collect())
2552}
2553
2554fn find_example_path(tree: &[String], example_name: &str) -> Option<String> {
2557 let suffix = format!("examples/{}.rs", example_name);
2558 tree.iter().find(|path| path.ends_with(&suffix)).cloned()
2559}
2560
2561fn find_template_path(tree: &[String], template_path: &str) -> Option<String> {
2564 tree.iter()
2566 .find(|path| path.ends_with(template_path))
2567 .cloned()
2568}
2569
2570fn status_battery_packs(
2580 project_dir: &Path,
2581 path: Option<&str>,
2582 source: &CrateSource,
2583) -> Result<()> {
2584 use console::style;
2585
2586 let user_manifest_path =
2588 find_user_manifest(project_dir).context("are you inside a Rust project?")?;
2589 let user_manifest_content =
2590 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
2591
2592 let bp_names = find_installed_bp_names(&user_manifest_content)?;
2594 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
2595 let packs: Vec<InstalledPack> = bp_names
2596 .into_iter()
2597 .map(|bp_name| {
2598 let spec = load_installed_bp_spec(&bp_name, path, source)?;
2599 let active_features =
2600 read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
2601 Ok(InstalledPack {
2602 short_name: short_name(&bp_name).to_string(),
2603 version: spec.version.clone(),
2604 spec,
2605 name: bp_name,
2606 active_features,
2607 })
2608 })
2609 .collect::<Result<_>>()?;
2610
2611 if packs.is_empty() {
2612 println!("No battery packs installed.");
2613 return Ok(());
2614 }
2615
2616 let user_versions = collect_user_dep_versions(&user_manifest_path, &user_manifest_content)?;
2618
2619 let mut any_warnings = false;
2620
2621 for pack in &packs {
2622 println!(
2624 "{} ({})",
2625 style(&pack.short_name).bold(),
2626 style(&pack.version).dim(),
2627 );
2628
2629 let expected = pack.spec.resolve_for_features(&pack.active_features);
2631
2632 let mut pack_warnings = Vec::new();
2633 for (dep_name, dep_spec) in &expected {
2634 if dep_spec.version.is_empty() {
2635 continue;
2636 }
2637 if let Some(user_version) = user_versions.get(dep_name.as_str()) {
2638 if should_upgrade_version(user_version, &dep_spec.version) {
2640 pack_warnings.push((
2641 dep_name.as_str(),
2642 user_version.as_str(),
2643 dep_spec.version.as_str(),
2644 ));
2645 }
2646 }
2647 }
2648
2649 if pack_warnings.is_empty() {
2650 println!(" {} all dependencies up to date", style("✓").green());
2651 } else {
2652 any_warnings = true;
2653 for (dep, current, recommended) in &pack_warnings {
2654 println!(
2655 " {} {}: {} → {} recommended",
2656 style("⚠").yellow(),
2657 dep,
2658 style(current).red(),
2659 style(recommended).green(),
2660 );
2661 }
2662 }
2663 }
2664
2665 if any_warnings {
2666 println!();
2667 println!("Run {} to update.", style("cargo bp sync").bold());
2668 }
2669
2670 Ok(())
2671}
2672
2673pub fn collect_user_dep_versions(
2677 user_manifest_path: &Path,
2678 user_manifest_content: &str,
2679) -> Result<BTreeMap<String, String>> {
2680 let raw: toml::Value =
2681 toml::from_str(user_manifest_content).context("Failed to parse Cargo.toml")?;
2682
2683 let mut versions = BTreeMap::new();
2684
2685 let ws_versions = if let Some(ws_path) = find_workspace_manifest(user_manifest_path)? {
2687 let ws_content =
2688 std::fs::read_to_string(&ws_path).context("Failed to read workspace Cargo.toml")?;
2689 let ws_raw: toml::Value =
2690 toml::from_str(&ws_content).context("Failed to parse workspace Cargo.toml")?;
2691 extract_versions_from_table(
2692 ws_raw
2693 .get("workspace")
2694 .and_then(|w| w.get("dependencies"))
2695 .and_then(|d| d.as_table()),
2696 )
2697 } else {
2698 BTreeMap::new()
2699 };
2700
2701 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
2703 let table = raw.get(section).and_then(|d| d.as_table());
2704 let Some(table) = table else { continue };
2705 for (name, value) in table {
2706 if versions.contains_key(name) {
2707 continue; }
2709 if let Some(version) = extract_version_from_dep(value) {
2710 versions.insert(name.clone(), version);
2711 } else if is_workspace_ref(value) {
2712 if let Some(ws_ver) = ws_versions.get(name) {
2714 versions.insert(name.clone(), ws_ver.clone());
2715 }
2716 }
2717 }
2718 }
2719
2720 Ok(versions)
2721}
2722
2723fn extract_versions_from_table(
2725 table: Option<&toml::map::Map<String, toml::Value>>,
2726) -> BTreeMap<String, String> {
2727 let Some(table) = table else {
2728 return BTreeMap::new();
2729 };
2730 let mut versions = BTreeMap::new();
2731 for (name, value) in table {
2732 if let Some(version) = extract_version_from_dep(value) {
2733 versions.insert(name.clone(), version);
2734 }
2735 }
2736 versions
2737}
2738
2739fn extract_version_from_dep(value: &toml::Value) -> Option<String> {
2743 match value {
2744 toml::Value::String(s) => Some(s.clone()),
2745 toml::Value::Table(t) => t
2746 .get("version")
2747 .and_then(|v| v.as_str())
2748 .map(|s| s.to_string()),
2749 _ => None,
2750 }
2751}
2752
2753fn is_workspace_ref(value: &toml::Value) -> bool {
2755 match value {
2756 toml::Value::Table(t) => t
2757 .get("workspace")
2758 .and_then(|v| v.as_bool())
2759 .unwrap_or(false),
2760 _ => false,
2761 }
2762}
2763
2764pub fn validate_battery_pack_cmd(path: Option<&str>) -> Result<()> {
2771 let crate_root = match path {
2772 Some(p) => std::path::PathBuf::from(p),
2773 None => std::env::current_dir().context("failed to get current directory")?,
2774 };
2775
2776 let cargo_toml = crate_root.join("Cargo.toml");
2777 let content = std::fs::read_to_string(&cargo_toml)
2778 .with_context(|| format!("failed to read {}", cargo_toml.display()))?;
2779
2780 let raw: toml::Value = toml::from_str(&content)
2782 .with_context(|| format!("failed to parse {}", cargo_toml.display()))?;
2783 if raw.get("package").is_none() {
2784 if raw.get("workspace").is_some() {
2785 bail!(
2787 "{} is a workspace manifest, not a battery pack crate.\n\
2788 Run this from a battery pack crate directory, or use --path to point to one.",
2789 cargo_toml.display()
2790 );
2791 } else {
2792 bail!(
2794 "{} has no [package] section — is this a battery pack crate?",
2795 cargo_toml.display()
2796 );
2797 }
2798 }
2799
2800 let spec = bphelper_manifest::parse_battery_pack(&content)
2801 .with_context(|| format!("failed to parse {}", cargo_toml.display()))?;
2802
2803 let mut report = spec.validate_spec();
2805 report.merge(bphelper_manifest::validate_on_disk(&spec, &crate_root));
2806
2807 if report.is_clean() {
2809 validate_templates(crate_root.to_str().unwrap_or("."))?;
2810 println!("{} is valid", spec.name);
2811 return Ok(());
2812 }
2813
2814 let mut errors = 0;
2817 let mut warnings = 0;
2818 for diag in &report.diagnostics {
2819 match diag.severity {
2820 bphelper_manifest::Severity::Error => {
2821 eprintln!("error[{}]: {}", diag.rule, diag.message);
2822 errors += 1;
2823 }
2824 bphelper_manifest::Severity::Warning => {
2825 eprintln!("warning[{}]: {}", diag.rule, diag.message);
2826 warnings += 1;
2827 }
2828 }
2829 }
2830
2831 if errors > 0 {
2833 bail!(
2834 "validation failed: {} error(s), {} warning(s)",
2835 errors,
2836 warnings
2837 );
2838 }
2839
2840 validate_templates(crate_root.to_str().unwrap_or("."))?;
2843 println!("{} is valid ({} warning(s))", spec.name, warnings);
2844 Ok(())
2845}
2846
2847pub fn validate_templates(manifest_dir: &str) -> Result<()> {
2860 let manifest_dir = Path::new(manifest_dir);
2861 let cargo_toml = manifest_dir.join("Cargo.toml");
2862 let content = std::fs::read_to_string(&cargo_toml)
2863 .with_context(|| format!("failed to read {}", cargo_toml.display()))?;
2864
2865 let crate_name = manifest_dir
2866 .file_name()
2867 .and_then(|s| s.to_str())
2868 .unwrap_or("unknown");
2869 let spec = bphelper_manifest::parse_battery_pack(&content)
2870 .map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", cargo_toml.display()))?;
2871
2872 if spec.templates.is_empty() {
2873 println!("no templates to validate");
2875 return Ok(());
2876 }
2877
2878 let metadata = cargo_metadata::MetadataCommand::new()
2880 .manifest_path(&cargo_toml)
2881 .no_deps()
2882 .exec()
2883 .context("failed to run cargo metadata")?;
2884 let shared_target_dir = metadata.target_directory.join("bp-validate");
2885
2886 for (name, template) in &spec.templates {
2887 println!("validating template '{name}'...");
2888
2889 let tmp = tempfile::tempdir().context("failed to create temp directory")?;
2890
2891 let project_name = format!("bp-validate-{name}");
2892
2893 let opts = template_engine::GenerateOpts {
2894 render: template_engine::RenderOpts {
2895 crate_root: manifest_dir.to_path_buf(),
2896 template_path: template.path.clone(),
2897 project_name,
2898 defines: std::collections::BTreeMap::new(),
2899 },
2900 destination: Some(tmp.path().to_path_buf()),
2901 git_init: false,
2902 };
2903
2904 let project_dir = template_engine::generate(opts)
2905 .with_context(|| format!("failed to generate template '{name}'"))?;
2906
2907 write_crates_io_patches(&project_dir, &metadata)?;
2912
2913 let output = std::process::Command::new("cargo")
2915 .args(["check"])
2916 .env("CARGO_TARGET_DIR", &*shared_target_dir)
2917 .current_dir(&project_dir)
2918 .output()
2919 .context("failed to run cargo check")?;
2920 anyhow::ensure!(
2921 output.status.success(),
2922 "cargo check failed for template '{name}':\n{}",
2923 String::from_utf8_lossy(&output.stderr)
2924 );
2925
2926 let output = std::process::Command::new("cargo")
2928 .args(["test"])
2929 .env("CARGO_TARGET_DIR", &*shared_target_dir)
2930 .current_dir(&project_dir)
2931 .output()
2932 .context("failed to run cargo test")?;
2933 anyhow::ensure!(
2934 output.status.success(),
2935 "cargo test failed for template '{name}':\n{}",
2936 String::from_utf8_lossy(&output.stderr)
2937 );
2938
2939 println!("template '{name}' ok");
2940 }
2941
2942 println!(
2943 "all {} template(s) for '{}' validated successfully",
2944 spec.templates.len(),
2945 crate_name
2946 );
2947 Ok(())
2948}
2949
2950fn write_crates_io_patches(project_dir: &Path, metadata: &cargo_metadata::Metadata) -> Result<()> {
2954 let mut patches = String::from("[patch.crates-io]\n");
2955 for pkg in &metadata.workspace_packages() {
2956 let path = pkg.manifest_path.parent().unwrap();
2957 patches.push_str(&format!("{} = {{ path = \"{}\" }}\n", pkg.name, path));
2958 }
2959
2960 let cargo_dir = project_dir.join(".cargo");
2961 std::fs::create_dir_all(&cargo_dir)
2962 .with_context(|| format!("failed to create {}", cargo_dir.display()))?;
2963 std::fs::write(cargo_dir.join("config.toml"), patches)
2964 .context("failed to write .cargo/config.toml")?;
2965 Ok(())
2966}