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, BTreeSet};
9use std::io::IsTerminal;
10use std::path::{Path, PathBuf};
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
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(Subcommand)]
61pub enum BpCommands {
62 New {
64 battery_pack: String,
66
67 #[arg(long, short = 'n')]
69 name: Option<String>,
70
71 #[arg(long, short = 't')]
74 template: Option<String>,
75
76 #[arg(long)]
78 path: Option<String>,
79 },
80
81 Add {
87 battery_pack: Option<String>,
90
91 crates: Vec<String>,
93
94 #[arg(long = "features", short = 'F', value_delimiter = ',')]
98 features: Vec<String>,
99
100 #[arg(long)]
103 no_default_features: bool,
104
105 #[arg(long)]
108 all_features: bool,
109
110 #[arg(long)]
114 target: Option<AddTarget>,
115
116 #[arg(long)]
118 path: Option<String>,
119 },
120
121 Sync {
123 #[arg(long)]
126 path: Option<String>,
127 },
128
129 Enable {
131 feature_name: String,
133
134 #[arg(long)]
136 battery_pack: Option<String>,
137 },
138
139 #[command(visible_alias = "ls")]
141 List {
142 filter: Option<String>,
144
145 #[arg(long)]
147 non_interactive: bool,
148 },
149
150 #[command(visible_alias = "info")]
152 Show {
153 battery_pack: String,
155
156 #[arg(long)]
158 path: Option<String>,
159
160 #[arg(long)]
162 non_interactive: bool,
163 },
164
165 #[command(visible_alias = "stat")]
167 Status {
168 #[arg(long)]
171 path: Option<String>,
172 },
173
174 Validate {
176 #[arg(long)]
178 path: Option<String>,
179 },
180}
181
182#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
184pub enum AddTarget {
185 Workspace,
187 Package,
189 Default,
191}
192
193pub fn main() -> Result<()> {
195 let cli = Cli::parse();
196 let project_dir = std::env::current_dir().context("Failed to get current directory")?;
197 let interactive = std::io::stdout().is_terminal();
198
199 match cli.command {
200 Commands::Bp {
201 crate_source,
202 command,
203 } => {
204 let source = match crate_source {
205 Some(path) => CrateSource::Local(path),
206 None => CrateSource::Registry,
207 };
208 let Some(command) = command else {
210 if interactive {
211 return tui::run_add(source);
212 } else {
213 bail!(
214 "No subcommand specified. Use `cargo bp --help` or run interactively in a terminal."
215 );
216 }
217 };
218 match command {
219 BpCommands::New {
220 battery_pack,
221 name,
222 template,
223 path,
224 } => new_from_battery_pack(&battery_pack, name, template, path, &source),
225 BpCommands::Add {
226 battery_pack,
227 crates,
228 features,
229 no_default_features,
230 all_features,
231 target,
232 path,
233 } => match battery_pack {
234 Some(name) => add_battery_pack(
235 &name,
236 &features,
237 no_default_features,
238 all_features,
239 &crates,
240 target,
241 path.as_deref(),
242 &source,
243 &project_dir,
244 ),
245 None if interactive => tui::run_add(source),
246 None => {
247 bail!(
248 "No battery pack specified. Use `cargo bp add <name>` or run interactively in a terminal."
249 )
250 }
251 },
252 BpCommands::Sync { path } => {
253 sync_battery_packs(&project_dir, path.as_deref(), &source)
254 }
255 BpCommands::Enable {
256 feature_name,
257 battery_pack,
258 } => enable_feature(&feature_name, battery_pack.as_deref(), &project_dir),
259 BpCommands::List {
260 filter,
261 non_interactive,
262 } => {
263 if !non_interactive && interactive {
266 tui::run_list(source, filter)
267 } else {
268 print_battery_pack_list(&source, filter.as_deref())
271 }
272 }
273 BpCommands::Show {
274 battery_pack,
275 path,
276 non_interactive,
277 } => {
278 if !non_interactive && interactive {
281 tui::run_show(&battery_pack, path.as_deref(), source)
282 } else {
283 print_battery_pack_detail(&battery_pack, path.as_deref(), &source)
284 }
285 }
286 BpCommands::Status { path } => {
287 status_battery_packs(&project_dir, path.as_deref(), &source)
288 }
289 BpCommands::Validate { path } => validate_battery_pack_cmd(path.as_deref()),
290 }
291 }
292 }
293}
294
295#[derive(Deserialize)]
300struct CratesIoResponse {
301 versions: Vec<VersionInfo>,
302}
303
304#[derive(Deserialize)]
305struct VersionInfo {
306 num: String,
307 yanked: bool,
308}
309
310#[derive(Deserialize)]
311struct SearchResponse {
312 crates: Vec<SearchCrate>,
313}
314
315#[derive(Deserialize)]
316struct SearchCrate {
317 name: String,
318 max_version: String,
319 description: Option<String>,
320}
321
322pub type TemplateConfig = bphelper_manifest::TemplateSpec;
324
325#[derive(Deserialize)]
330struct OwnersResponse {
331 users: Vec<Owner>,
332}
333
334#[derive(Deserialize, Clone)]
335struct Owner {
336 login: String,
337 name: Option<String>,
338}
339
340#[derive(Deserialize)]
345struct GitHubTreeResponse {
346 tree: Vec<GitHubTreeEntry>,
347 #[serde(default)]
348 #[allow(dead_code)]
349 truncated: bool,
350}
351
352#[derive(Deserialize)]
353struct GitHubTreeEntry {
354 path: String,
355}
356
357#[derive(Clone)]
363pub struct BatteryPackSummary {
364 pub name: String,
365 pub short_name: String,
366 pub version: String,
367 pub description: String,
368}
369
370#[derive(Clone)]
372pub struct BatteryPackDetail {
373 pub name: String,
374 pub short_name: String,
375 pub version: String,
376 pub description: String,
377 pub repository: Option<String>,
378 pub owners: Vec<OwnerInfo>,
379 pub crates: Vec<String>,
380 pub extends: Vec<String>,
381 pub templates: Vec<TemplateInfo>,
382 pub examples: Vec<ExampleInfo>,
383}
384
385#[derive(Clone)]
386pub struct OwnerInfo {
387 pub login: String,
388 pub name: Option<String>,
389}
390
391impl From<Owner> for OwnerInfo {
392 fn from(o: Owner) -> Self {
393 Self {
394 login: o.login,
395 name: o.name,
396 }
397 }
398}
399
400#[derive(Clone)]
401pub struct TemplateInfo {
402 pub name: String,
403 pub path: String,
404 pub description: Option<String>,
405 pub repo_path: Option<String>,
408}
409
410#[derive(Clone)]
411pub struct ExampleInfo {
412 pub name: String,
413 pub description: Option<String>,
414 pub repo_path: Option<String>,
417}
418
419fn new_from_battery_pack(
429 battery_pack: &str,
430 name: Option<String>,
431 template: Option<String>,
432 path_override: Option<String>,
433 source: &CrateSource,
434) -> Result<()> {
435 if let Some(path) = path_override {
437 return generate_from_local(&path, name, template);
438 }
439
440 let crate_name = resolve_crate_name(battery_pack);
441
442 let crate_dir: PathBuf;
444 let _temp_dir: Option<tempfile::TempDir>; match source {
446 CrateSource::Registry => {
447 let crate_info = lookup_crate(&crate_name)?;
448 let temp = download_and_extract_crate(&crate_name, &crate_info.version)?;
449 crate_dir = temp
450 .path()
451 .join(format!("{}-{}", crate_name, crate_info.version));
452 _temp_dir = Some(temp);
453 }
454 CrateSource::Local(workspace_dir) => {
455 crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
456 _temp_dir = None;
457 }
458 }
459
460 let manifest_path = crate_dir.join("Cargo.toml");
462 let manifest_content = std::fs::read_to_string(&manifest_path)
463 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
464 let templates = parse_template_metadata(&manifest_content, &crate_name)?;
465
466 let template_path = resolve_template(&templates, template.as_deref())?;
468
469 generate_from_path(&crate_dir, &template_path, name)
471}
472
473pub enum ResolvedAdd {
475 Crates {
477 active_features: BTreeSet<String>,
478 crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
479 },
480 Interactive,
482}
483
484pub fn resolve_add_crates(
499 bp_spec: &bphelper_manifest::BatteryPackSpec,
500 bp_name: &str,
501 with_features: &[String],
502 no_default_features: bool,
503 all_features: bool,
504 specific_crates: &[String],
505) -> ResolvedAdd {
506 if !specific_crates.is_empty() {
507 let mut selected = BTreeMap::new();
509 for crate_name_arg in specific_crates {
510 if let Some(spec) = bp_spec.crates.get(crate_name_arg.as_str()) {
511 selected.insert(crate_name_arg.clone(), spec.clone());
512 } else {
513 eprintln!(
514 "error: crate '{}' not found in battery pack '{}'",
515 crate_name_arg, bp_name
516 );
517 }
518 }
519 return ResolvedAdd::Crates {
520 active_features: BTreeSet::new(),
521 crates: selected,
522 };
523 }
524
525 if all_features {
526 return ResolvedAdd::Crates {
528 active_features: BTreeSet::from(["all".to_string()]),
529 crates: bp_spec.resolve_all_visible(),
530 };
531 }
532
533 if !no_default_features && with_features.is_empty() && bp_spec.has_meaningful_choices() {
537 return ResolvedAdd::Interactive;
538 }
539
540 let mut features: BTreeSet<String> = if no_default_features {
541 BTreeSet::new()
542 } else {
543 BTreeSet::from(["default".to_string()])
544 };
545 features.extend(with_features.iter().cloned());
546
547 if features.is_empty() {
551 return ResolvedAdd::Crates {
552 active_features: features,
553 crates: BTreeMap::new(),
554 };
555 }
556
557 let str_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
558 let crates = bp_spec.resolve_crates(&str_features);
559 ResolvedAdd::Crates {
560 active_features: features,
561 crates,
562 }
563}
564
565#[allow(clippy::too_many_arguments)]
575pub fn add_battery_pack(
576 name: &str,
577 with_features: &[String],
578 no_default_features: bool,
579 all_features: bool,
580 specific_crates: &[String],
581 target: Option<AddTarget>,
582 path: Option<&str>,
583 source: &CrateSource,
584 project_dir: &Path,
585) -> Result<()> {
586 let crate_name = resolve_crate_name(name);
587
588 let (bp_version, bp_spec) = if let Some(local_path) = path {
594 let manifest_path = Path::new(local_path).join("Cargo.toml");
595 let manifest_content = std::fs::read_to_string(&manifest_path)
596 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
597 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
598 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e))?;
599 (None, spec)
600 } else {
601 fetch_bp_spec(source, name)?
602 };
603
604 let resolved = resolve_add_crates(
607 &bp_spec,
608 &crate_name,
609 with_features,
610 no_default_features,
611 all_features,
612 specific_crates,
613 );
614 let (active_features, crates_to_sync) = match resolved {
615 ResolvedAdd::Crates {
616 active_features,
617 crates,
618 } => (active_features, crates),
619 ResolvedAdd::Interactive if std::io::stdout().is_terminal() => {
620 match pick_crates_interactive(&bp_spec)? {
621 Some(result) => (result.active_features, result.crates),
622 None => {
623 println!("Cancelled.");
624 return Ok(());
625 }
626 }
627 }
628 ResolvedAdd::Interactive => {
629 let crates = bp_spec.resolve_crates(&["default"]);
631 (BTreeSet::from(["default".to_string()]), crates)
632 }
633 };
634
635 if crates_to_sync.is_empty() {
636 println!("No crates selected.");
637 return Ok(());
638 }
639
640 let user_manifest_path = find_user_manifest(project_dir)?;
642 let user_manifest_content =
643 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
644 let mut user_doc: toml_edit::DocumentMut = user_manifest_content
646 .parse()
647 .context("Failed to parse Cargo.toml")?;
648
649 let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
651
652 let build_deps =
654 user_doc["build-dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
655 if let Some(table) = build_deps.as_table_mut() {
656 if let Some(local_path) = path {
657 let mut dep = toml_edit::InlineTable::new();
658 dep.insert("path", toml_edit::Value::from(local_path));
659 table.insert(
660 &crate_name,
661 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
662 );
663 } else if workspace_manifest.is_some() {
664 let mut dep = toml_edit::InlineTable::new();
665 dep.insert("workspace", toml_edit::Value::from(true));
666 table.insert(
667 &crate_name,
668 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
669 );
670 } else {
671 let version = bp_version
672 .as_ref()
673 .context("battery pack version not available (--path without workspace)")?;
674 table.insert(&crate_name, toml_edit::value(version));
675 }
676 }
677
678 let mut ws_doc: Option<toml_edit::DocumentMut> = if let Some(ref ws_path) = workspace_manifest {
683 let ws_content =
684 std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
685 Some(
686 ws_content
687 .parse()
688 .context("Failed to parse workspace Cargo.toml")?,
689 )
690 } else {
691 None
692 };
693
694 if let Some(ref mut doc) = ws_doc {
695 let ws_deps = doc["workspace"]["dependencies"]
696 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
697 if let Some(ws_table) = ws_deps.as_table_mut() {
698 if let Some(local_path) = path {
700 let mut dep = toml_edit::InlineTable::new();
701 dep.insert("path", toml_edit::Value::from(local_path));
702 ws_table.insert(
703 &crate_name,
704 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
705 );
706 } else {
707 let version = bp_version
708 .as_ref()
709 .context("battery pack version not available (--path without workspace)")?;
710 ws_table.insert(&crate_name, toml_edit::value(version));
711 }
712 for (dep_name, dep_spec) in &crates_to_sync {
714 add_dep_to_table(ws_table, dep_name, dep_spec);
715 }
716 }
717
718 write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, false);
720 } else {
721 write_deps_by_kind(&mut user_doc, &crates_to_sync, false);
724 }
725
726 let use_workspace_metadata = match target {
732 Some(AddTarget::Workspace) => true,
733 Some(AddTarget::Package) => false,
734 Some(AddTarget::Default) | None => workspace_manifest.is_some(),
735 };
736
737 if use_workspace_metadata {
738 if let Some(ref mut doc) = ws_doc {
739 write_bp_features_to_doc(
740 doc,
741 &["workspace", "metadata"],
742 &crate_name,
743 &active_features,
744 );
745 } else {
746 bail!("--target=workspace requires a workspace, but none was found");
747 }
748 } else {
749 write_bp_features_to_doc(
750 &mut user_doc,
751 &["package", "metadata"],
752 &crate_name,
753 &active_features,
754 );
755 }
756
757 if let (Some(ws_path), Some(doc)) = (&workspace_manifest, &ws_doc) {
759 std::fs::write(ws_path, doc.to_string()).context("Failed to write workspace Cargo.toml")?;
761 }
762
763 std::fs::write(&user_manifest_path, user_doc.to_string())
766 .context("Failed to write Cargo.toml")?;
767
768 let build_rs_path = user_manifest_path
770 .parent()
771 .unwrap_or(Path::new("."))
772 .join("build.rs");
773 update_build_rs(&build_rs_path, &crate_name)?;
774
775 println!(
776 "Added {} with {} crate(s)",
777 crate_name,
778 crates_to_sync.len()
779 );
780 for dep_name in crates_to_sync.keys() {
781 println!(" + {}", dep_name);
782 }
783
784 Ok(())
785}
786
787fn sync_battery_packs(project_dir: &Path, path: Option<&str>, source: &CrateSource) -> Result<()> {
793 let user_manifest_path = find_user_manifest(project_dir)?;
794 let user_manifest_content =
795 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
796
797 let bp_names = find_installed_bp_names(&user_manifest_content)?;
798
799 if bp_names.is_empty() {
800 println!("No battery packs installed.");
801 return Ok(());
802 }
803
804 let mut user_doc: toml_edit::DocumentMut = user_manifest_content
806 .parse()
807 .context("Failed to parse Cargo.toml")?;
808
809 let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
810 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
811 let mut total_changes = 0;
812
813 for bp_name in &bp_names {
814 let bp_spec = load_installed_bp_spec(bp_name, path, source)?;
816
817 let active_features =
819 read_active_features_from(&metadata_location, &user_manifest_content, bp_name);
820
821 let expected = bp_spec.resolve_for_features(&active_features);
823
824 if let Some(ref ws_path) = workspace_manifest {
827 let ws_content =
828 std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
829 let mut ws_doc: toml_edit::DocumentMut = ws_content
831 .parse()
832 .context("Failed to parse workspace Cargo.toml")?;
833
834 let ws_deps = ws_doc["workspace"]["dependencies"]
835 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
836 if let Some(ws_table) = ws_deps.as_table_mut() {
837 for (dep_name, dep_spec) in &expected {
838 if sync_dep_in_table(ws_table, dep_name, dep_spec) {
839 total_changes += 1;
840 println!(" ~ {} (updated in workspace)", dep_name);
841 }
842 }
843 }
844 std::fs::write(ws_path, ws_doc.to_string())
846 .context("Failed to write workspace Cargo.toml")?;
847
848 let refs_added = write_workspace_refs_by_kind(&mut user_doc, &expected, true);
851 total_changes += refs_added;
852 } else {
853 for (dep_name, dep_spec) in &expected {
856 let section = dep_kind_section(dep_spec.dep_kind);
857 let table =
858 user_doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
859 if let Some(table) = table.as_table_mut() {
860 if !table.contains_key(dep_name) {
861 add_dep_to_table(table, dep_name, dep_spec);
862 total_changes += 1;
863 println!(" + {}", dep_name);
864 } else if sync_dep_in_table(table, dep_name, dep_spec) {
865 total_changes += 1;
866 println!(" ~ {}", dep_name);
867 }
868 }
869 }
870 }
871 }
872
873 std::fs::write(&user_manifest_path, user_doc.to_string())
875 .context("Failed to write Cargo.toml")?;
876
877 if total_changes == 0 {
878 println!("All dependencies are up to date.");
879 } else {
880 println!("Synced {} change(s).", total_changes);
881 }
882
883 Ok(())
884}
885
886fn enable_feature(
887 feature_name: &str,
888 battery_pack: Option<&str>,
889 project_dir: &Path,
890) -> Result<()> {
891 let user_manifest_path = find_user_manifest(project_dir)?;
892 let user_manifest_content =
893 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
894
895 let bp_name = if let Some(name) = battery_pack {
897 resolve_crate_name(name)
898 } else {
899 let bp_names = find_installed_bp_names(&user_manifest_content)?;
901
902 let mut found = None;
903 for name in &bp_names {
904 let spec = fetch_battery_pack_spec(name)?;
905 if spec.features.contains_key(feature_name) {
906 found = Some(name.clone());
907 break;
908 }
909 }
910 found.ok_or_else(|| {
911 anyhow::anyhow!(
912 "No installed battery pack defines feature '{}'",
913 feature_name
914 )
915 })?
916 };
917
918 let bp_spec = fetch_battery_pack_spec(&bp_name)?;
919
920 if !bp_spec.features.contains_key(feature_name) {
921 let available: Vec<_> = bp_spec.features.keys().collect();
922 bail!(
923 "Battery pack '{}' has no feature '{}'. Available: {:?}",
924 bp_name,
925 feature_name,
926 available
927 );
928 }
929
930 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
932 let mut active_features =
933 read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
934 if active_features.contains(feature_name) {
935 println!(
936 "Feature '{}' is already active for {}.",
937 feature_name, bp_name
938 );
939 return Ok(());
940 }
941 active_features.insert(feature_name.to_string());
942
943 let str_features: Vec<&str> = active_features.iter().map(|s| s.as_str()).collect();
945 let crates_to_sync = bp_spec.resolve_crates(&str_features);
946
947 let mut user_doc: toml_edit::DocumentMut = user_manifest_content
949 .parse()
950 .context("Failed to parse Cargo.toml")?;
951
952 let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
953
954 if let Some(ref ws_path) = workspace_manifest {
956 let ws_content =
957 std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
958 let mut ws_doc: toml_edit::DocumentMut = ws_content
959 .parse()
960 .context("Failed to parse workspace Cargo.toml")?;
961
962 let ws_deps = ws_doc["workspace"]["dependencies"]
963 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
964 if let Some(ws_table) = ws_deps.as_table_mut() {
965 for (dep_name, dep_spec) in &crates_to_sync {
966 add_dep_to_table(ws_table, dep_name, dep_spec);
967 }
968 }
969
970 if matches!(metadata_location, MetadataLocation::Workspace { .. }) {
972 write_bp_features_to_doc(
973 &mut ws_doc,
974 &["workspace", "metadata"],
975 &bp_name,
976 &active_features,
977 );
978 }
979
980 std::fs::write(ws_path, ws_doc.to_string())
981 .context("Failed to write workspace Cargo.toml")?;
982
983 write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, true);
985 } else {
986 write_deps_by_kind(&mut user_doc, &crates_to_sync, true);
988 }
989
990 if matches!(metadata_location, MetadataLocation::Package) {
992 write_bp_features_to_doc(
993 &mut user_doc,
994 &["package", "metadata"],
995 &bp_name,
996 &active_features,
997 );
998 }
999
1000 std::fs::write(&user_manifest_path, user_doc.to_string())
1001 .context("Failed to write Cargo.toml")?;
1002
1003 println!("Enabled feature '{}' from {}", feature_name, bp_name);
1004 Ok(())
1005}
1006
1007struct PickerResult {
1013 crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
1015 active_features: BTreeSet<String>,
1017}
1018
1019fn pick_crates_interactive(
1024 bp_spec: &bphelper_manifest::BatteryPackSpec,
1025) -> Result<Option<PickerResult>> {
1026 use console::style;
1027 use dialoguer::MultiSelect;
1028
1029 let grouped = bp_spec.all_crates_with_grouping();
1030 if grouped.is_empty() {
1031 bail!("Battery pack has no crates to add");
1032 }
1033
1034 let mut labels = Vec::new();
1036 let mut defaults = Vec::new();
1037
1038 for (group, crate_name, dep, is_default) in &grouped {
1039 let version_info = if dep.features.is_empty() {
1040 format!("({})", dep.version)
1041 } else {
1042 format!(
1043 "({}, features: {})",
1044 dep.version,
1045 dep.features
1046 .iter()
1047 .map(|s| s.as_str())
1048 .collect::<Vec<_>>()
1049 .join(", ")
1050 )
1051 };
1052
1053 let group_label = if group == "default" {
1054 String::new()
1055 } else {
1056 format!(" [{}]", group)
1057 };
1058
1059 labels.push(format!(
1060 "{} {}{}",
1061 crate_name,
1062 style(&version_info).dim(),
1063 style(&group_label).cyan()
1064 ));
1065 defaults.push(*is_default);
1066 }
1067
1068 println!();
1070 println!(
1071 " {} v{}",
1072 style(&bp_spec.name).green().bold(),
1073 style(&bp_spec.version).dim()
1074 );
1075 println!();
1076
1077 let selections = MultiSelect::new()
1078 .with_prompt("Select crates to add")
1079 .items(&labels)
1080 .defaults(&defaults)
1081 .interact_opt()
1082 .context("Failed to show crate picker")?;
1083
1084 let Some(selected_indices) = selections else {
1085 return Ok(None); };
1087
1088 let mut crates = BTreeMap::new();
1090
1091 for idx in &selected_indices {
1092 let (_group, crate_name, dep, _) = &grouped[*idx];
1093 let merged = (*dep).clone();
1095
1096 crates.insert(crate_name.clone(), merged);
1097 }
1098
1099 let mut active_features = BTreeSet::from(["default".to_string()]);
1101 for (feature_name, feature_crates) in &bp_spec.features {
1102 if feature_name == "default" {
1103 continue;
1104 }
1105 let all_selected = feature_crates.iter().all(|c| crates.contains_key(c));
1106 if all_selected {
1107 active_features.insert(feature_name.clone());
1108 }
1109 }
1110
1111 Ok(Some(PickerResult {
1112 crates,
1113 active_features,
1114 }))
1115}
1116
1117fn find_user_manifest(project_dir: &Path) -> Result<std::path::PathBuf> {
1123 let path = project_dir.join("Cargo.toml");
1124 if path.exists() {
1125 Ok(path)
1126 } else {
1127 bail!("No Cargo.toml found in {}", project_dir.display());
1128 }
1129}
1130
1131pub fn find_installed_bp_names(manifest_content: &str) -> Result<Vec<String>> {
1136 let raw: toml::Value =
1137 toml::from_str(manifest_content).context("Failed to parse Cargo.toml")?;
1138
1139 let build_deps = raw
1140 .get("build-dependencies")
1141 .and_then(|bd| bd.as_table())
1142 .cloned()
1143 .unwrap_or_default();
1144
1145 Ok(build_deps
1146 .keys()
1147 .filter(|k| k.ends_with("-battery-pack") || *k == "battery-pack")
1148 .cloned()
1149 .collect())
1150}
1151
1152fn find_workspace_manifest(crate_manifest: &Path) -> Result<Option<std::path::PathBuf>> {
1157 let parent = crate_manifest.parent().unwrap_or(Path::new("."));
1158 let parent = if parent.as_os_str().is_empty() {
1159 Path::new(".")
1160 } else {
1161 parent
1162 };
1163 let crate_dir = parent
1164 .canonicalize()
1165 .context("Failed to resolve crate directory")?;
1166
1167 let mut dir = crate_dir.clone();
1169 loop {
1170 let candidate = dir.join("Cargo.toml");
1171 if candidate.exists() && candidate != crate_dir.join("Cargo.toml") {
1172 let content = std::fs::read_to_string(&candidate)?;
1173 if content.contains("[workspace]") {
1174 return Ok(Some(candidate));
1175 }
1176 }
1177 if !dir.pop() {
1178 break;
1179 }
1180 }
1181
1182 Ok(None)
1185}
1186
1187fn dep_kind_section(kind: bphelper_manifest::DepKind) -> &'static str {
1189 match kind {
1190 bphelper_manifest::DepKind::Normal => "dependencies",
1191 bphelper_manifest::DepKind::Dev => "dev-dependencies",
1192 bphelper_manifest::DepKind::Build => "build-dependencies",
1193 }
1194}
1195
1196fn write_deps_by_kind(
1202 doc: &mut toml_edit::DocumentMut,
1203 crates: &BTreeMap<String, bphelper_manifest::CrateSpec>,
1204 if_missing: bool,
1205) -> usize {
1206 let mut written = 0;
1207 for (dep_name, dep_spec) in crates {
1208 let section = dep_kind_section(dep_spec.dep_kind);
1209 let table = doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1210 if let Some(table) = table.as_table_mut()
1211 && (!if_missing || !table.contains_key(dep_name))
1212 {
1213 add_dep_to_table(table, dep_name, dep_spec);
1214 written += 1;
1215 }
1216 }
1217 written
1218}
1219
1220fn write_workspace_refs_by_kind(
1227 doc: &mut toml_edit::DocumentMut,
1228 crates: &BTreeMap<String, bphelper_manifest::CrateSpec>,
1229 if_missing: bool,
1230) -> usize {
1231 let mut written = 0;
1232 for (dep_name, dep_spec) in crates {
1233 let section = dep_kind_section(dep_spec.dep_kind);
1234 let table = doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1235 if let Some(table) = table.as_table_mut()
1236 && (!if_missing || !table.contains_key(dep_name))
1237 {
1238 let mut dep = toml_edit::InlineTable::new();
1239 dep.insert("workspace", toml_edit::Value::from(true));
1240 table.insert(
1241 dep_name,
1242 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
1243 );
1244 written += 1;
1245 }
1246 }
1247 written
1248}
1249
1250pub fn add_dep_to_table(
1256 table: &mut toml_edit::Table,
1257 name: &str,
1258 spec: &bphelper_manifest::CrateSpec,
1259) {
1260 if spec.features.is_empty() {
1261 table.insert(name, toml_edit::value(&spec.version));
1262 } else {
1263 let mut dep = toml_edit::InlineTable::new();
1264 dep.insert("version", toml_edit::Value::from(spec.version.as_str()));
1265 let mut features = toml_edit::Array::new();
1266 for feat in &spec.features {
1267 features.push(feat.as_str());
1268 }
1269 dep.insert("features", toml_edit::Value::Array(features));
1270 table.insert(
1271 name,
1272 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
1273 );
1274 }
1275}
1276
1277fn should_upgrade_version(current: &str, recommended: &str) -> bool {
1283 match (
1284 semver::Version::parse(current)
1285 .or_else(|_| semver::Version::parse(&format!("{}.0", current)))
1286 .or_else(|_| semver::Version::parse(&format!("{}.0.0", current))),
1287 semver::Version::parse(recommended)
1288 .or_else(|_| semver::Version::parse(&format!("{}.0", recommended)))
1289 .or_else(|_| semver::Version::parse(&format!("{}.0.0", recommended))),
1290 ) {
1291 (Ok(cur), Ok(rec)) => rec > cur,
1293 _ => current != recommended,
1295 }
1296}
1297
1298pub fn sync_dep_in_table(
1303 table: &mut toml_edit::Table,
1304 name: &str,
1305 spec: &bphelper_manifest::CrateSpec,
1306) -> bool {
1307 let Some(existing) = table.get_mut(name) else {
1308 add_dep_to_table(table, name, spec);
1310 return true;
1311 };
1312
1313 let mut changed = false;
1314
1315 match existing {
1316 toml_edit::Item::Value(toml_edit::Value::String(version_str)) => {
1317 let current = version_str.value().to_string();
1319 if !spec.version.is_empty() && should_upgrade_version(¤t, &spec.version) {
1321 *version_str = toml_edit::Formatted::new(spec.version.clone());
1322 changed = true;
1323 }
1324 if !spec.features.is_empty() {
1326 let keep_version = if !spec.version.is_empty()
1329 && should_upgrade_version(¤t, &spec.version)
1330 {
1331 spec.version.clone()
1332 } else {
1333 current.clone()
1334 };
1335 let patched = bphelper_manifest::CrateSpec {
1336 version: keep_version,
1337 features: spec.features.clone(),
1338 dep_kind: spec.dep_kind,
1339 optional: spec.optional,
1340 };
1341 add_dep_to_table(table, name, &patched);
1342 changed = true;
1343 }
1344 }
1345 toml_edit::Item::Value(toml_edit::Value::InlineTable(inline)) => {
1346 if let Some(toml_edit::Value::String(v)) = inline.get_mut("version")
1349 && !spec.version.is_empty()
1350 && should_upgrade_version(v.value(), &spec.version)
1351 {
1352 *v = toml_edit::Formatted::new(spec.version.clone());
1353 changed = true;
1354 }
1355 if !spec.features.is_empty() {
1358 let existing_features: Vec<String> = inline
1359 .get("features")
1360 .and_then(|f| f.as_array())
1361 .map(|arr| {
1362 arr.iter()
1363 .filter_map(|v| v.as_str().map(String::from))
1364 .collect()
1365 })
1366 .unwrap_or_default();
1367
1368 let mut needs_update = false;
1369 let existing_set: BTreeSet<&str> =
1370 existing_features.iter().map(|s| s.as_str()).collect();
1371 let mut all_features = existing_features.clone();
1372 for feat in &spec.features {
1373 if !existing_set.contains(feat.as_str()) {
1374 all_features.push(feat.clone());
1375 needs_update = true;
1376 }
1377 }
1378
1379 if needs_update {
1380 let mut arr = toml_edit::Array::new();
1381 for f in &all_features {
1382 arr.push(f.as_str());
1383 }
1384 inline.insert("features", toml_edit::Value::Array(arr));
1385 changed = true;
1386 }
1387 }
1388 }
1389 toml_edit::Item::Table(tbl) => {
1390 if let Some(toml_edit::Item::Value(toml_edit::Value::String(v))) =
1393 tbl.get_mut("version")
1394 && !spec.version.is_empty()
1395 && should_upgrade_version(v.value(), &spec.version)
1396 {
1397 *v = toml_edit::Formatted::new(spec.version.clone());
1398 changed = true;
1399 }
1400 if !spec.features.is_empty() {
1402 let existing_features: Vec<String> = tbl
1403 .get("features")
1404 .and_then(|f| f.as_value())
1405 .and_then(|v| v.as_array())
1406 .map(|arr| {
1407 arr.iter()
1408 .filter_map(|v| v.as_str().map(String::from))
1409 .collect()
1410 })
1411 .unwrap_or_default();
1412
1413 let existing_set: BTreeSet<&str> =
1414 existing_features.iter().map(|s| s.as_str()).collect();
1415 let mut all_features = existing_features.clone();
1416 let mut needs_update = false;
1417 for feat in &spec.features {
1418 if !existing_set.contains(feat.as_str()) {
1419 all_features.push(feat.clone());
1420 needs_update = true;
1421 }
1422 }
1423
1424 if needs_update {
1425 let mut arr = toml_edit::Array::new();
1426 for f in &all_features {
1427 arr.push(f.as_str());
1428 }
1429 tbl.insert(
1430 "features",
1431 toml_edit::Item::Value(toml_edit::Value::Array(arr)),
1432 );
1433 changed = true;
1434 }
1435 }
1436 }
1437 _ => {}
1438 }
1439
1440 changed
1441}
1442
1443fn read_features_at(raw: &toml::Value, prefix: &[&str], bp_name: &str) -> BTreeSet<String> {
1449 let mut node = Some(raw);
1450 for key in prefix {
1451 node = node.and_then(|n| n.get(key));
1452 }
1453 node.and_then(|m| m.get("battery-pack"))
1454 .and_then(|bp| bp.get(bp_name))
1455 .and_then(|entry| entry.get("features"))
1456 .and_then(|sets| sets.as_array())
1457 .map(|arr| {
1458 arr.iter()
1459 .filter_map(|v| v.as_str().map(String::from))
1460 .collect()
1461 })
1462 .unwrap_or_else(|| BTreeSet::from(["default".to_string()]))
1463}
1464
1465pub fn read_active_features(manifest_content: &str, bp_name: &str) -> BTreeSet<String> {
1467 let raw: toml::Value = match toml::from_str(manifest_content) {
1468 Ok(v) => v,
1469 Err(_) => return BTreeSet::from(["default".to_string()]),
1470 };
1471 read_features_at(&raw, &["package", "metadata"], bp_name)
1472}
1473
1474#[derive(Debug, Clone)]
1485enum MetadataLocation {
1486 Package,
1488 Workspace { ws_manifest_path: PathBuf },
1490}
1491
1492fn resolve_metadata_location(user_manifest_path: &Path) -> Result<MetadataLocation> {
1498 if let Some(ws_path) = find_workspace_manifest(user_manifest_path)? {
1499 let ws_content =
1500 std::fs::read_to_string(&ws_path).context("Failed to read workspace Cargo.toml")?;
1501 let raw: toml::Value =
1502 toml::from_str(&ws_content).context("Failed to parse workspace Cargo.toml")?;
1503 if raw
1504 .get("workspace")
1505 .and_then(|w| w.get("metadata"))
1506 .and_then(|m| m.get("battery-pack"))
1507 .is_some()
1508 {
1509 return Ok(MetadataLocation::Workspace {
1510 ws_manifest_path: ws_path,
1511 });
1512 }
1513 }
1514 Ok(MetadataLocation::Package)
1515}
1516
1517fn read_active_features_from(
1522 location: &MetadataLocation,
1523 user_manifest_content: &str,
1524 bp_name: &str,
1525) -> BTreeSet<String> {
1526 match location {
1527 MetadataLocation::Package => read_active_features(user_manifest_content, bp_name),
1528 MetadataLocation::Workspace { ws_manifest_path } => {
1529 let ws_content = match std::fs::read_to_string(ws_manifest_path) {
1530 Ok(c) => c,
1531 Err(_) => return BTreeSet::from(["default".to_string()]),
1532 };
1533 read_active_features_ws(&ws_content, bp_name)
1534 }
1535 }
1536}
1537
1538pub fn read_active_features_ws(ws_content: &str, bp_name: &str) -> BTreeSet<String> {
1540 let raw: toml::Value = match toml::from_str(ws_content) {
1541 Ok(v) => v,
1542 Err(_) => return BTreeSet::from(["default".to_string()]),
1543 };
1544 read_features_at(&raw, &["workspace", "metadata"], bp_name)
1545}
1546
1547fn write_bp_features_to_doc(
1552 doc: &mut toml_edit::DocumentMut,
1553 path_prefix: &[&str],
1554 bp_name: &str,
1555 active_features: &BTreeSet<String>,
1556) {
1557 let mut features_array = toml_edit::Array::new();
1558 for feature in active_features {
1559 features_array.push(feature.as_str());
1560 }
1561
1562 doc[path_prefix[0]].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1565 doc[path_prefix[0]][path_prefix[1]].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1566 doc[path_prefix[0]][path_prefix[1]]["battery-pack"]
1567 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1568
1569 let bp_meta = &mut doc[path_prefix[0]][path_prefix[1]]["battery-pack"][bp_name];
1570 *bp_meta = toml_edit::Item::Table(toml_edit::Table::new());
1571 bp_meta["features"] = toml_edit::value(features_array);
1572}
1573
1574fn resolve_battery_pack_manifest(bp_name: &str) -> Result<std::path::PathBuf> {
1579 let metadata = cargo_metadata::MetadataCommand::new()
1580 .exec()
1581 .context("Failed to run `cargo metadata`")?;
1582
1583 let package = metadata
1584 .packages
1585 .iter()
1586 .find(|p| p.name == bp_name)
1587 .ok_or_else(|| {
1588 anyhow::anyhow!(
1589 "Battery pack '{}' not found in dependency graph. Is it in [build-dependencies]?",
1590 bp_name
1591 )
1592 })?;
1593
1594 Ok(package.manifest_path.clone().into())
1595}
1596
1597fn load_installed_bp_spec(
1606 bp_name: &str,
1607 path: Option<&str>,
1608 source: &CrateSource,
1609) -> Result<bphelper_manifest::BatteryPackSpec> {
1610 if let Some(local_path) = path {
1611 let manifest_path = Path::new(local_path).join("Cargo.toml");
1612 let manifest_content = std::fs::read_to_string(&manifest_path)
1613 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1614 return bphelper_manifest::parse_battery_pack(&manifest_content)
1615 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", bp_name, e));
1616 }
1617 match source {
1618 CrateSource::Registry => fetch_battery_pack_spec(bp_name),
1619 CrateSource::Local(_) => {
1620 let (_version, spec) = fetch_bp_spec(source, bp_name)?;
1621 Ok(spec)
1622 }
1623 }
1624}
1625
1626fn fetch_battery_pack_spec(bp_name: &str) -> Result<bphelper_manifest::BatteryPackSpec> {
1628 let manifest_path = resolve_battery_pack_manifest(bp_name)?;
1629 let manifest_content = std::fs::read_to_string(&manifest_path)
1630 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1631
1632 bphelper_manifest::parse_battery_pack(&manifest_content)
1633 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", bp_name, e))
1634}
1635
1636pub(crate) fn fetch_bp_spec_from_registry(
1642 crate_name: &str,
1643) -> Result<(String, bphelper_manifest::BatteryPackSpec)> {
1644 let crate_info = lookup_crate(crate_name)?;
1645 let temp_dir = download_and_extract_crate(crate_name, &crate_info.version)?;
1646 let crate_dir = temp_dir
1647 .path()
1648 .join(format!("{}-{}", crate_name, crate_info.version));
1649
1650 let manifest_path = crate_dir.join("Cargo.toml");
1651 let manifest_content = std::fs::read_to_string(&manifest_path)
1652 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1653
1654 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
1655 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e))?;
1656
1657 Ok((crate_info.version, spec))
1658}
1659
1660fn update_build_rs(build_rs_path: &Path, crate_name: &str) -> Result<()> {
1666 let crate_ident = crate_name.replace('-', "_");
1667 let validate_call = format!("{}::validate();", crate_ident);
1668
1669 if build_rs_path.exists() {
1670 let content = std::fs::read_to_string(build_rs_path).context("Failed to read build.rs")?;
1671
1672 if content.contains(&validate_call) {
1674 return Ok(());
1675 }
1676
1677 let file: syn::File = syn::parse_str(&content).context("Failed to parse build.rs")?;
1679
1680 let has_main = file
1682 .items
1683 .iter()
1684 .any(|item| matches!(item, syn::Item::Fn(func) if func.sig.ident == "main"));
1685
1686 if has_main {
1687 let lines: Vec<&str> = content.lines().collect();
1689 let mut insert_line = None;
1690 let mut brace_depth: i32 = 0;
1691 let mut in_main = false;
1692
1693 for (i, line) in lines.iter().enumerate() {
1694 if line.contains("fn main") {
1695 in_main = true;
1696 brace_depth = 0;
1697 }
1698 if in_main {
1699 for ch in line.chars() {
1700 if ch == '{' {
1701 brace_depth += 1;
1702 } else if ch == '}' {
1703 brace_depth -= 1;
1704 if brace_depth == 0 {
1705 insert_line = Some(i);
1706 in_main = false;
1707 break;
1708 }
1709 }
1710 }
1711 }
1712 }
1713
1714 if let Some(line_idx) = insert_line {
1715 let mut new_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
1716 new_lines.insert(line_idx, format!(" {}", validate_call));
1717 std::fs::write(build_rs_path, new_lines.join("\n") + "\n")
1718 .context("Failed to write build.rs")?;
1719 return Ok(());
1720 }
1721 }
1722
1723 bail!(
1725 "Could not find fn main() in build.rs. Please add `{}` manually.",
1726 validate_call
1727 );
1728 } else {
1729 let content = format!("fn main() {{\n {}\n}}\n", validate_call);
1731 std::fs::write(build_rs_path, content).context("Failed to create build.rs")?;
1732 }
1733
1734 Ok(())
1735}
1736
1737fn generate_from_local(
1738 local_path: &str,
1739 name: Option<String>,
1740 template: Option<String>,
1741) -> Result<()> {
1742 let local_path = Path::new(local_path);
1743
1744 let manifest_path = local_path.join("Cargo.toml");
1746 let manifest_content = std::fs::read_to_string(&manifest_path)
1747 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1748
1749 let crate_name = local_path
1750 .file_name()
1751 .and_then(|s| s.to_str())
1752 .unwrap_or("unknown");
1753 let templates = parse_template_metadata(&manifest_content, crate_name)?;
1754 let template_path = resolve_template(&templates, template.as_deref())?;
1755
1756 generate_from_path(local_path, &template_path, name)
1757}
1758
1759fn ensure_battery_pack_suffix(name: Option<String>) -> Result<String> {
1762 let raw = match name {
1763 Some(n) => n,
1764 None => dialoguer::Input::<String>::new()
1765 .with_prompt("Project name")
1766 .interact_text()
1767 .context("Failed to read project name")?,
1768 };
1769 if raw.ends_with("-battery-pack") {
1770 Ok(raw)
1771 } else {
1772 let fixed = format!("{}-battery-pack", raw);
1773 println!("Renaming project to: {}", fixed);
1774 Ok(fixed)
1775 }
1776}
1777
1778fn generate_from_path(crate_path: &Path, template_path: &str, name: Option<String>) -> Result<()> {
1779 let name = Some(ensure_battery_pack_suffix(name)?);
1783
1784 let define = if !std::io::stdout().is_terminal() {
1786 vec!["description=A battery pack for ...".to_string()]
1787 } else {
1788 vec![]
1789 };
1790
1791 let args = GenerateArgs {
1792 template_path: TemplatePath {
1793 path: Some(crate_path.to_string_lossy().into_owned()),
1794 auto_path: Some(template_path.to_string()),
1795 ..Default::default()
1796 },
1797 name,
1798 vcs: Some(Vcs::Git),
1799 define,
1800 ..Default::default()
1801 };
1802
1803 cargo_generate::generate(args)?;
1804
1805 Ok(())
1806}
1807
1808struct CrateMetadata {
1810 version: String,
1811}
1812
1813fn lookup_crate(crate_name: &str) -> Result<CrateMetadata> {
1815 let client = http_client();
1816
1817 let url = format!("{}/{}", CRATES_IO_API, crate_name);
1818 let response = client
1819 .get(&url)
1820 .send()
1821 .with_context(|| format!("Failed to query crates.io for '{}'", crate_name))?;
1822
1823 if !response.status().is_success() {
1824 bail!(
1825 "Crate '{}' not found on crates.io (status: {})",
1826 crate_name,
1827 response.status()
1828 );
1829 }
1830
1831 let parsed: CratesIoResponse = response
1832 .json()
1833 .with_context(|| format!("Failed to parse crates.io response for '{}'", crate_name))?;
1834
1835 let version = parsed
1837 .versions
1838 .iter()
1839 .find(|v| !v.yanked)
1840 .map(|v| v.num.clone())
1841 .ok_or_else(|| anyhow::anyhow!("No non-yanked versions found for '{}'", crate_name))?;
1842
1843 Ok(CrateMetadata { version })
1844}
1845
1846fn download_and_extract_crate(crate_name: &str, version: &str) -> Result<tempfile::TempDir> {
1848 let client = http_client();
1849
1850 let url = format!(
1852 "{}/{}/{}-{}.crate",
1853 CRATES_IO_CDN, crate_name, crate_name, version
1854 );
1855
1856 let response = client
1857 .get(&url)
1858 .send()
1859 .with_context(|| format!("Failed to download crate from {}", url))?;
1860
1861 if !response.status().is_success() {
1862 bail!(
1863 "Failed to download '{}' version {} (status: {})",
1864 crate_name,
1865 version,
1866 response.status()
1867 );
1868 }
1869
1870 let bytes = response
1871 .bytes()
1872 .with_context(|| "Failed to read crate tarball")?;
1873
1874 let temp_dir = tempfile::tempdir().with_context(|| "Failed to create temp directory")?;
1876
1877 let decoder = GzDecoder::new(&bytes[..]);
1878 let mut archive = Archive::new(decoder);
1879 archive
1880 .unpack(temp_dir.path())
1881 .with_context(|| "Failed to extract crate tarball")?;
1882
1883 Ok(temp_dir)
1884}
1885
1886fn parse_template_metadata(
1887 manifest_content: &str,
1888 crate_name: &str,
1889) -> Result<BTreeMap<String, TemplateConfig>> {
1890 let spec = bphelper_manifest::parse_battery_pack(manifest_content)
1891 .map_err(|e| anyhow::anyhow!("Failed to parse Cargo.toml: {}", e))?;
1892
1893 if spec.templates.is_empty() {
1894 bail!(
1895 "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
1896 crate_name
1897 );
1898 }
1899
1900 Ok(spec.templates)
1901}
1902
1903pub fn resolve_template(
1906 templates: &BTreeMap<String, TemplateConfig>,
1907 requested: Option<&str>,
1908) -> Result<String> {
1909 match requested {
1910 Some(name) => {
1911 let config = templates.get(name).ok_or_else(|| {
1912 let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
1913 anyhow::anyhow!(
1914 "Template '{}' not found. Available templates: {}",
1915 name,
1916 available.join(", ")
1917 )
1918 })?;
1919 Ok(config.path.clone())
1920 }
1921 None => {
1922 if templates.len() == 1 {
1923 let (_, config) = templates.iter().next().unwrap();
1925 Ok(config.path.clone())
1926 } else if let Some(config) = templates.get("default") {
1927 Ok(config.path.clone())
1929 } else {
1930 prompt_for_template(templates)
1932 }
1933 }
1934 }
1935}
1936
1937fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
1938 use dialoguer::{Select, theme::ColorfulTheme};
1939
1940 let items: Vec<String> = templates
1942 .iter()
1943 .map(|(name, config)| {
1944 if let Some(desc) = &config.description {
1945 format!("{} - {}", name, desc)
1946 } else {
1947 name.clone()
1948 }
1949 })
1950 .collect();
1951
1952 if !std::io::stdout().is_terminal() {
1954 println!("Available templates:");
1956 for item in &items {
1957 println!(" {}", item);
1958 }
1959 bail!("Multiple templates available. Please specify one with --template <name>");
1960 }
1961
1962 let selection = Select::with_theme(&ColorfulTheme::default())
1964 .with_prompt("Select a template")
1965 .items(&items)
1966 .default(0)
1967 .interact()
1968 .context("Failed to select template")?;
1969
1970 let (_, config) = templates
1972 .iter()
1973 .nth(selection)
1974 .ok_or_else(|| anyhow::anyhow!("Invalid template selection"))?;
1975 Ok(config.path.clone())
1976}
1977
1978pub struct InstalledPack {
1980 pub name: String,
1981 pub short_name: String,
1982 pub version: String,
1983 pub spec: bphelper_manifest::BatteryPackSpec,
1984 pub active_features: BTreeSet<String>,
1985}
1986
1987pub fn load_installed_packs(project_dir: &Path) -> Result<Vec<InstalledPack>> {
1993 let user_manifest_path = find_user_manifest(project_dir)?;
1994 let user_manifest_content =
1995 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
1996
1997 let bp_names = find_installed_bp_names(&user_manifest_content)?;
1998 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
1999
2000 let mut packs = Vec::new();
2001 for bp_name in bp_names {
2002 let spec = fetch_battery_pack_spec(&bp_name)?;
2003 let active_features =
2004 read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
2005 packs.push(InstalledPack {
2006 short_name: short_name(&bp_name).to_string(),
2007 version: spec.version.clone(),
2008 spec,
2009 name: bp_name,
2010 active_features,
2011 });
2012 }
2013
2014 Ok(packs)
2015}
2016
2017pub fn fetch_battery_pack_list(
2019 source: &CrateSource,
2020 filter: Option<&str>,
2021) -> Result<Vec<BatteryPackSummary>> {
2022 match source {
2023 CrateSource::Registry => fetch_battery_pack_list_from_registry(filter),
2024 CrateSource::Local(path) => discover_local_battery_packs(path, filter),
2025 }
2026}
2027
2028fn fetch_battery_pack_list_from_registry(filter: Option<&str>) -> Result<Vec<BatteryPackSummary>> {
2029 let client = http_client();
2030
2031 let url = match filter {
2033 Some(q) => format!(
2034 "{CRATES_IO_API}?q={}&keyword=battery-pack&per_page=50",
2035 urlencoding::encode(q)
2036 ),
2037 None => format!("{CRATES_IO_API}?keyword=battery-pack&per_page=50"),
2038 };
2039
2040 let response = client
2041 .get(&url)
2042 .send()
2043 .context("Failed to query crates.io")?;
2044
2045 if !response.status().is_success() {
2046 bail!(
2047 "Failed to list battery packs (status: {})",
2048 response.status()
2049 );
2050 }
2051
2052 let parsed: SearchResponse = response.json().context("Failed to parse response")?;
2053
2054 let battery_packs = parsed
2056 .crates
2057 .into_iter()
2058 .filter(|c| c.name.ends_with("-battery-pack"))
2059 .map(|c| BatteryPackSummary {
2060 short_name: short_name(&c.name).to_string(),
2061 name: c.name,
2062 version: c.max_version,
2063 description: c.description.unwrap_or_default(),
2064 })
2065 .collect();
2066
2067 Ok(battery_packs)
2068}
2069
2070fn discover_local_battery_packs(
2072 workspace_dir: &Path,
2073 filter: Option<&str>,
2074) -> Result<Vec<BatteryPackSummary>> {
2075 let manifest_path = workspace_dir.join("Cargo.toml");
2076 let metadata = cargo_metadata::MetadataCommand::new()
2077 .manifest_path(&manifest_path)
2078 .no_deps()
2079 .exec()
2080 .with_context(|| format!("Failed to read workspace at {}", manifest_path.display()))?;
2081
2082 let mut battery_packs: Vec<BatteryPackSummary> = metadata
2083 .packages
2084 .iter()
2085 .filter(|pkg| pkg.name.ends_with("-battery-pack"))
2086 .filter(|pkg| {
2087 if let Some(q) = filter {
2088 short_name(&pkg.name).contains(q)
2089 } else {
2090 true
2091 }
2092 })
2093 .map(|pkg| BatteryPackSummary {
2094 short_name: short_name(&pkg.name).to_string(),
2095 name: pkg.name.to_string(),
2096 version: pkg.version.to_string(),
2097 description: pkg.description.clone().unwrap_or_default(),
2098 })
2099 .collect();
2100
2101 battery_packs.sort_by(|a, b| a.name.cmp(&b.name));
2102 Ok(battery_packs)
2103}
2104
2105fn find_local_battery_pack_dir(workspace_dir: &Path, crate_name: &str) -> Result<PathBuf> {
2107 let manifest_path = workspace_dir.join("Cargo.toml");
2108 let metadata = cargo_metadata::MetadataCommand::new()
2109 .manifest_path(&manifest_path)
2110 .no_deps()
2111 .exec()
2112 .with_context(|| format!("Failed to read workspace at {}", manifest_path.display()))?;
2113
2114 let package = metadata
2115 .packages
2116 .iter()
2117 .find(|p| p.name == crate_name)
2118 .ok_or_else(|| {
2119 anyhow::anyhow!(
2120 "Battery pack '{}' not found in workspace at {}",
2121 crate_name,
2122 workspace_dir.display()
2123 )
2124 })?;
2125
2126 Ok(package
2127 .manifest_path
2128 .parent()
2129 .expect("manifest path should have a parent")
2130 .into())
2131}
2132
2133pub(crate) fn fetch_bp_spec(
2138 source: &CrateSource,
2139 name: &str,
2140) -> Result<(Option<String>, bphelper_manifest::BatteryPackSpec)> {
2141 let crate_name = resolve_crate_name(name);
2142 match source {
2143 CrateSource::Registry => {
2144 let (version, spec) = fetch_bp_spec_from_registry(&crate_name)?;
2145 Ok((Some(version), spec))
2146 }
2147 CrateSource::Local(workspace_dir) => {
2148 let crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
2149 let manifest_path = crate_dir.join("Cargo.toml");
2150 let manifest_content = std::fs::read_to_string(&manifest_path)
2151 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
2152 let spec = bphelper_manifest::parse_battery_pack(&manifest_content).map_err(|e| {
2153 anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e)
2154 })?;
2155 Ok((None, spec))
2156 }
2157 }
2158}
2159
2160pub(crate) fn fetch_battery_pack_detail_from_source(
2163 source: &CrateSource,
2164 name: &str,
2165) -> Result<BatteryPackDetail> {
2166 match source {
2167 CrateSource::Registry => fetch_battery_pack_detail(name, None),
2168 CrateSource::Local(workspace_dir) => {
2169 let crate_name = resolve_crate_name(name);
2170 let crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
2171 fetch_battery_pack_detail_from_path(&crate_dir.to_string_lossy())
2172 }
2173 }
2174}
2175
2176fn print_battery_pack_list(source: &CrateSource, filter: Option<&str>) -> Result<()> {
2177 use console::style;
2178
2179 let battery_packs = fetch_battery_pack_list(source, filter)?;
2180
2181 if battery_packs.is_empty() {
2182 match filter {
2183 Some(q) => println!("No battery packs found matching '{}'", q),
2184 None => println!("No battery packs found"),
2185 }
2186 return Ok(());
2187 }
2188
2189 let max_name_len = battery_packs
2191 .iter()
2192 .map(|c| c.short_name.len())
2193 .max()
2194 .unwrap_or(0);
2195
2196 let max_version_len = battery_packs
2197 .iter()
2198 .map(|c| c.version.len())
2199 .max()
2200 .unwrap_or(0);
2201
2202 println!();
2203 for bp in &battery_packs {
2204 let desc = bp.description.lines().next().unwrap_or("");
2205
2206 let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
2208 let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
2209
2210 println!(
2211 " {} {} {}",
2212 style(name_padded).green().bold(),
2213 style(ver_padded).dim(),
2214 desc,
2215 );
2216 }
2217 println!();
2218
2219 println!(
2220 "{}",
2221 style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
2222 );
2223
2224 Ok(())
2225}
2226
2227fn short_name(crate_name: &str) -> &str {
2229 crate_name
2230 .strip_suffix("-battery-pack")
2231 .unwrap_or(crate_name)
2232}
2233
2234fn resolve_crate_name(name: &str) -> String {
2239 if name == "battery-pack" || name.ends_with("-battery-pack") {
2240 name.to_string()
2241 } else {
2242 format!("{}-battery-pack", name)
2243 }
2244}
2245
2246pub fn fetch_battery_pack_detail(name: &str, path: Option<&str>) -> Result<BatteryPackDetail> {
2248 if let Some(local_path) = path {
2250 return fetch_battery_pack_detail_from_path(local_path);
2251 }
2252
2253 let crate_name = resolve_crate_name(name);
2254
2255 let crate_info = lookup_crate(&crate_name)?;
2257 let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
2258 let crate_dir = temp_dir
2259 .path()
2260 .join(format!("{}-{}", crate_name, crate_info.version));
2261
2262 let manifest_path = crate_dir.join("Cargo.toml");
2264 let manifest_content = std::fs::read_to_string(&manifest_path)
2265 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
2266 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
2267 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack: {}", e))?;
2268
2269 let owners = fetch_owners(&crate_name)?;
2271
2272 build_battery_pack_detail(&crate_dir, &spec, owners)
2273}
2274
2275fn fetch_battery_pack_detail_from_path(path: &str) -> Result<BatteryPackDetail> {
2277 let crate_dir = std::path::Path::new(path);
2278 let manifest_path = crate_dir.join("Cargo.toml");
2279 let manifest_content = std::fs::read_to_string(&manifest_path)
2280 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
2281
2282 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
2283 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack: {}", e))?;
2284
2285 build_battery_pack_detail(crate_dir, &spec, Vec::new())
2286}
2287
2288fn build_battery_pack_detail(
2293 crate_dir: &Path,
2294 spec: &bphelper_manifest::BatteryPackSpec,
2295 owners: Vec<Owner>,
2296) -> Result<BatteryPackDetail> {
2297 let (extends_raw, crates_raw): (Vec<_>, Vec<_>) = spec
2300 .visible_crates()
2301 .into_keys()
2302 .partition(|d| d.ends_with("-battery-pack"));
2303
2304 let extends: Vec<String> = extends_raw
2305 .into_iter()
2306 .map(|d| short_name(d).to_string())
2307 .collect();
2308 let crates: Vec<String> = crates_raw.into_iter().map(|s| s.to_string()).collect();
2309
2310 let repo_tree = spec.repository.as_ref().and_then(|r| fetch_github_tree(r));
2312
2313 let templates = spec
2315 .templates
2316 .iter()
2317 .map(|(name, tmpl)| {
2318 let repo_path = repo_tree
2319 .as_ref()
2320 .and_then(|tree| find_template_path(tree, &tmpl.path));
2321 TemplateInfo {
2322 name: name.clone(),
2323 path: tmpl.path.clone(),
2324 description: tmpl.description.clone(),
2325 repo_path,
2326 }
2327 })
2328 .collect();
2329
2330 let examples = scan_examples(crate_dir, repo_tree.as_deref());
2332
2333 Ok(BatteryPackDetail {
2334 short_name: short_name(&spec.name).to_string(),
2335 name: spec.name.clone(),
2336 version: spec.version.clone(),
2337 description: spec.description.clone(),
2338 repository: spec.repository.clone(),
2339 owners: owners.into_iter().map(OwnerInfo::from).collect(),
2340 crates,
2341 extends,
2342 templates,
2343 examples,
2344 })
2345}
2346
2347fn print_battery_pack_detail(name: &str, path: Option<&str>, source: &CrateSource) -> Result<()> {
2351 use console::style;
2352
2353 let detail = if path.is_some() {
2355 fetch_battery_pack_detail(name, path)?
2356 } else {
2357 fetch_battery_pack_detail_from_source(source, name)?
2358 };
2359
2360 println!();
2362 println!(
2363 "{} {}",
2364 style(&detail.name).green().bold(),
2365 style(&detail.version).dim()
2366 );
2367 if !detail.description.is_empty() {
2368 println!("{}", detail.description);
2369 }
2370
2371 if !detail.owners.is_empty() {
2373 println!();
2374 println!("{}", style("Authors:").bold());
2375 for owner in &detail.owners {
2376 if let Some(name) = &owner.name {
2377 println!(" {} ({})", name, owner.login);
2378 } else {
2379 println!(" {}", owner.login);
2380 }
2381 }
2382 }
2383
2384 if !detail.crates.is_empty() {
2386 println!();
2387 println!("{}", style("Crates:").bold());
2388 for dep in &detail.crates {
2389 println!(" {}", dep);
2390 }
2391 }
2392
2393 if !detail.extends.is_empty() {
2395 println!();
2396 println!("{}", style("Extends:").bold());
2397 for dep in &detail.extends {
2398 println!(" {}", dep);
2399 }
2400 }
2401
2402 if !detail.templates.is_empty() {
2404 println!();
2405 println!("{}", style("Templates:").bold());
2406 let max_name_len = detail
2407 .templates
2408 .iter()
2409 .map(|t| t.name.len())
2410 .max()
2411 .unwrap_or(0);
2412 for tmpl in &detail.templates {
2413 let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
2414 if let Some(desc) = &tmpl.description {
2415 println!(" {} {}", style(name_padded).cyan(), desc);
2416 } else {
2417 println!(" {}", style(name_padded).cyan());
2418 }
2419 }
2420 }
2421
2422 if !detail.examples.is_empty() {
2425 println!();
2426 println!("{}", style("Examples:").bold());
2427 let max_name_len = detail
2428 .examples
2429 .iter()
2430 .map(|e| e.name.len())
2431 .max()
2432 .unwrap_or(0);
2433 for example in &detail.examples {
2434 let name_padded = format!("{:<width$}", example.name, width = max_name_len);
2435 if let Some(desc) = &example.description {
2436 println!(" {} {}", style(name_padded).magenta(), desc);
2437 } else {
2438 println!(" {}", style(name_padded).magenta());
2439 }
2440 }
2441 }
2442
2443 println!();
2445 println!("{}", style("Install:").bold());
2446 println!(" cargo bp add {}", detail.short_name);
2447 println!(" cargo bp new {}", detail.short_name);
2448 println!();
2449
2450 Ok(())
2451}
2452
2453fn fetch_owners(crate_name: &str) -> Result<Vec<Owner>> {
2454 let client = http_client();
2455
2456 let url = format!("{}/{}/owners", CRATES_IO_API, crate_name);
2457 let response = client
2458 .get(&url)
2459 .send()
2460 .with_context(|| format!("Failed to fetch owners for '{}'", crate_name))?;
2461
2462 if !response.status().is_success() {
2463 return Ok(Vec::new());
2465 }
2466
2467 let parsed: OwnersResponse = response
2468 .json()
2469 .with_context(|| "Failed to parse owners response")?;
2470
2471 Ok(parsed.users)
2472}
2473
2474fn scan_examples(crate_dir: &std::path::Path, repo_tree: Option<&[String]>) -> Vec<ExampleInfo> {
2478 let examples_dir = crate_dir.join("examples");
2479 if !examples_dir.exists() {
2480 return Vec::new();
2481 }
2482
2483 let mut examples = Vec::new();
2484
2485 if let Ok(entries) = std::fs::read_dir(&examples_dir) {
2486 for entry in entries.flatten() {
2487 let path = entry.path();
2488 if path.extension().is_some_and(|ext| ext == "rs")
2489 && let Some(name) = path.file_stem().and_then(|s| s.to_str())
2490 {
2491 let description = extract_example_description(&path);
2492 let repo_path = repo_tree.and_then(|tree| find_example_path(tree, name));
2493 examples.push(ExampleInfo {
2494 name: name.to_string(),
2495 description,
2496 repo_path,
2497 });
2498 }
2499 }
2500 }
2501
2502 examples.sort_by(|a, b| a.name.cmp(&b.name));
2504 examples
2505}
2506
2507fn extract_example_description(path: &std::path::Path) -> Option<String> {
2509 let content = std::fs::read_to_string(path).ok()?;
2510
2511 for line in content.lines() {
2513 let trimmed = line.trim();
2514 if trimmed.starts_with("//!") {
2515 let desc = trimmed.strip_prefix("//!").unwrap_or("").trim();
2516 if !desc.is_empty() {
2517 return Some(desc.to_string());
2518 }
2519 } else if !trimmed.is_empty() && !trimmed.starts_with("//") {
2520 break;
2522 }
2523 }
2524 None
2525}
2526
2527fn fetch_github_tree(repository: &str) -> Option<Vec<String>> {
2530 let gh_path = repository
2532 .strip_prefix("https://github.com/")
2533 .or_else(|| repository.strip_prefix("http://github.com/"))?;
2534 let gh_path = gh_path.strip_suffix(".git").unwrap_or(gh_path);
2535 let gh_path = gh_path.trim_end_matches('/');
2536
2537 let client = http_client();
2538
2539 let url = format!(
2541 "https://api.github.com/repos/{}/git/trees/main?recursive=1",
2542 gh_path
2543 );
2544
2545 let response = client.get(&url).send().ok()?;
2546 if !response.status().is_success() {
2547 return None;
2548 }
2549
2550 let tree_response: GitHubTreeResponse = response.json().ok()?;
2551
2552 Some(tree_response.tree.into_iter().map(|e| e.path).collect())
2554}
2555
2556fn find_example_path(tree: &[String], example_name: &str) -> Option<String> {
2559 let suffix = format!("examples/{}.rs", example_name);
2560 tree.iter().find(|path| path.ends_with(&suffix)).cloned()
2561}
2562
2563fn find_template_path(tree: &[String], template_path: &str) -> Option<String> {
2566 tree.iter()
2568 .find(|path| path.ends_with(template_path))
2569 .cloned()
2570}
2571
2572fn status_battery_packs(
2582 project_dir: &Path,
2583 path: Option<&str>,
2584 source: &CrateSource,
2585) -> Result<()> {
2586 use console::style;
2587
2588 let user_manifest_path =
2590 find_user_manifest(project_dir).context("are you inside a Rust project?")?;
2591 let user_manifest_content =
2592 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
2593
2594 let bp_names = find_installed_bp_names(&user_manifest_content)?;
2596 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
2597 let packs: Vec<InstalledPack> = bp_names
2598 .into_iter()
2599 .map(|bp_name| {
2600 let spec = load_installed_bp_spec(&bp_name, path, source)?;
2601 let active_features =
2602 read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
2603 Ok(InstalledPack {
2604 short_name: short_name(&bp_name).to_string(),
2605 version: spec.version.clone(),
2606 spec,
2607 name: bp_name,
2608 active_features,
2609 })
2610 })
2611 .collect::<Result<_>>()?;
2612
2613 if packs.is_empty() {
2614 println!("No battery packs installed.");
2615 return Ok(());
2616 }
2617
2618 let user_versions = collect_user_dep_versions(&user_manifest_path, &user_manifest_content)?;
2620
2621 let mut any_warnings = false;
2622
2623 for pack in &packs {
2624 println!(
2626 "{} ({})",
2627 style(&pack.short_name).bold(),
2628 style(&pack.version).dim(),
2629 );
2630
2631 let expected = pack.spec.resolve_for_features(&pack.active_features);
2633
2634 let mut pack_warnings = Vec::new();
2635 for (dep_name, dep_spec) in &expected {
2636 if dep_spec.version.is_empty() {
2637 continue;
2638 }
2639 if let Some(user_version) = user_versions.get(dep_name.as_str()) {
2640 if should_upgrade_version(user_version, &dep_spec.version) {
2642 pack_warnings.push((
2643 dep_name.as_str(),
2644 user_version.as_str(),
2645 dep_spec.version.as_str(),
2646 ));
2647 }
2648 }
2649 }
2650
2651 if pack_warnings.is_empty() {
2652 println!(" {} all dependencies up to date", style("✓").green());
2653 } else {
2654 any_warnings = true;
2655 for (dep, current, recommended) in &pack_warnings {
2656 println!(
2657 " {} {}: {} → {} recommended",
2658 style("⚠").yellow(),
2659 dep,
2660 style(current).red(),
2661 style(recommended).green(),
2662 );
2663 }
2664 }
2665 }
2666
2667 if any_warnings {
2668 println!();
2669 println!("Run {} to update.", style("cargo bp sync").bold());
2670 }
2671
2672 Ok(())
2673}
2674
2675pub fn collect_user_dep_versions(
2679 user_manifest_path: &Path,
2680 user_manifest_content: &str,
2681) -> Result<BTreeMap<String, String>> {
2682 let raw: toml::Value =
2683 toml::from_str(user_manifest_content).context("Failed to parse Cargo.toml")?;
2684
2685 let mut versions = BTreeMap::new();
2686
2687 let ws_versions = if let Some(ws_path) = find_workspace_manifest(user_manifest_path)? {
2689 let ws_content =
2690 std::fs::read_to_string(&ws_path).context("Failed to read workspace Cargo.toml")?;
2691 let ws_raw: toml::Value =
2692 toml::from_str(&ws_content).context("Failed to parse workspace Cargo.toml")?;
2693 extract_versions_from_table(
2694 ws_raw
2695 .get("workspace")
2696 .and_then(|w| w.get("dependencies"))
2697 .and_then(|d| d.as_table()),
2698 )
2699 } else {
2700 BTreeMap::new()
2701 };
2702
2703 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
2705 let table = raw.get(section).and_then(|d| d.as_table());
2706 let Some(table) = table else { continue };
2707 for (name, value) in table {
2708 if versions.contains_key(name) {
2709 continue; }
2711 if let Some(version) = extract_version_from_dep(value) {
2712 versions.insert(name.clone(), version);
2713 } else if is_workspace_ref(value) {
2714 if let Some(ws_ver) = ws_versions.get(name) {
2716 versions.insert(name.clone(), ws_ver.clone());
2717 }
2718 }
2719 }
2720 }
2721
2722 Ok(versions)
2723}
2724
2725fn extract_versions_from_table(
2727 table: Option<&toml::map::Map<String, toml::Value>>,
2728) -> BTreeMap<String, String> {
2729 let Some(table) = table else {
2730 return BTreeMap::new();
2731 };
2732 let mut versions = BTreeMap::new();
2733 for (name, value) in table {
2734 if let Some(version) = extract_version_from_dep(value) {
2735 versions.insert(name.clone(), version);
2736 }
2737 }
2738 versions
2739}
2740
2741fn extract_version_from_dep(value: &toml::Value) -> Option<String> {
2745 match value {
2746 toml::Value::String(s) => Some(s.clone()),
2747 toml::Value::Table(t) => t
2748 .get("version")
2749 .and_then(|v| v.as_str())
2750 .map(|s| s.to_string()),
2751 _ => None,
2752 }
2753}
2754
2755fn is_workspace_ref(value: &toml::Value) -> bool {
2757 match value {
2758 toml::Value::Table(t) => t
2759 .get("workspace")
2760 .and_then(|v| v.as_bool())
2761 .unwrap_or(false),
2762 _ => false,
2763 }
2764}
2765
2766pub fn validate_battery_pack_cmd(path: Option<&str>) -> Result<()> {
2773 let crate_root = match path {
2774 Some(p) => std::path::PathBuf::from(p),
2775 None => std::env::current_dir().context("failed to get current directory")?,
2776 };
2777
2778 let cargo_toml = crate_root.join("Cargo.toml");
2779 let content = std::fs::read_to_string(&cargo_toml)
2780 .with_context(|| format!("failed to read {}", cargo_toml.display()))?;
2781
2782 let raw: toml::Value = toml::from_str(&content)
2784 .with_context(|| format!("failed to parse {}", cargo_toml.display()))?;
2785 if raw.get("package").is_none() {
2786 if raw.get("workspace").is_some() {
2787 bail!(
2789 "{} is a workspace manifest, not a battery pack crate.\n\
2790 Run this from a battery pack crate directory, or use --path to point to one.",
2791 cargo_toml.display()
2792 );
2793 } else {
2794 bail!(
2796 "{} has no [package] section — is this a battery pack crate?",
2797 cargo_toml.display()
2798 );
2799 }
2800 }
2801
2802 let spec = bphelper_manifest::parse_battery_pack(&content)
2803 .with_context(|| format!("failed to parse {}", cargo_toml.display()))?;
2804
2805 let mut report = spec.validate_spec();
2807 report.merge(bphelper_manifest::validate_on_disk(&spec, &crate_root));
2808
2809 if report.is_clean() {
2811 println!("{} is valid", spec.name);
2812 return Ok(());
2813 }
2814
2815 let mut errors = 0;
2818 let mut warnings = 0;
2819 for diag in &report.diagnostics {
2820 match diag.severity {
2821 bphelper_manifest::Severity::Error => {
2822 eprintln!("error[{}]: {}", diag.rule, diag.message);
2823 errors += 1;
2824 }
2825 bphelper_manifest::Severity::Warning => {
2826 eprintln!("warning[{}]: {}", diag.rule, diag.message);
2827 warnings += 1;
2828 }
2829 }
2830 }
2831
2832 if errors > 0 {
2834 bail!(
2835 "validation failed: {} error(s), {} warning(s)",
2836 errors,
2837 warnings
2838 );
2839 }
2840
2841 println!("{} is valid ({} warning(s))", spec.name, warnings);
2844 Ok(())
2845}