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(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 #[arg(long = "define", short = 'd', value_parser = parse_define)]
82 define: Vec<(String, String)>,
83 },
84
85 Add {
91 battery_pack: Option<String>,
94
95 crates: Vec<String>,
97
98 #[arg(long = "features", short = 'F', value_delimiter = ',')]
102 features: Vec<String>,
103
104 #[arg(long)]
107 no_default_features: bool,
108
109 #[arg(long)]
112 all_features: bool,
113
114 #[arg(long)]
118 target: Option<AddTarget>,
119
120 #[arg(long)]
122 path: Option<String>,
123 },
124
125 Sync {
127 #[arg(long)]
130 path: Option<String>,
131 },
132
133 Enable {
135 feature_name: String,
137
138 #[arg(long)]
140 battery_pack: Option<String>,
141 },
142
143 #[command(visible_alias = "ls")]
145 List {
146 filter: Option<String>,
148
149 #[arg(long)]
151 non_interactive: bool,
152 },
153
154 #[command(visible_alias = "info")]
156 Show {
157 battery_pack: String,
159
160 #[arg(long)]
162 path: Option<String>,
163
164 #[arg(long)]
166 non_interactive: bool,
167 },
168
169 #[command(visible_alias = "stat")]
171 Status {
172 #[arg(long)]
175 path: Option<String>,
176 },
177
178 Validate {
180 #[arg(long)]
182 path: Option<String>,
183 },
184}
185
186#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
188pub enum AddTarget {
189 Workspace,
191 Package,
193 Default,
195}
196
197pub fn main() -> Result<()> {
199 let cli = Cli::parse();
200 let project_dir = std::env::current_dir().context("Failed to get current directory")?;
201 let interactive = std::io::stdout().is_terminal();
202
203 match cli.command {
204 Commands::Bp {
205 crate_source,
206 command,
207 } => {
208 let source = match crate_source {
209 Some(path) => CrateSource::Local(path),
210 None => CrateSource::Registry,
211 };
212 let Some(command) = command else {
214 if interactive {
215 return tui::run_add(source);
216 } else {
217 bail!(
218 "No subcommand specified. Use `cargo bp --help` or run interactively in a terminal."
219 );
220 }
221 };
222 match command {
223 BpCommands::New {
224 battery_pack,
225 name,
226 template,
227 path,
228 define,
229 } => new_from_battery_pack(&battery_pack, name, template, path, &source, &define),
230 BpCommands::Add {
231 battery_pack,
232 crates,
233 features,
234 no_default_features,
235 all_features,
236 target,
237 path,
238 } => match battery_pack {
239 Some(name) => add_battery_pack(
240 &name,
241 &features,
242 no_default_features,
243 all_features,
244 &crates,
245 target,
246 path.as_deref(),
247 &source,
248 &project_dir,
249 ),
250 None if interactive => tui::run_add(source),
251 None => {
252 bail!(
253 "No battery pack specified. Use `cargo bp add <name>` or run interactively in a terminal."
254 )
255 }
256 },
257 BpCommands::Sync { path } => {
258 sync_battery_packs(&project_dir, path.as_deref(), &source)
259 }
260 BpCommands::Enable {
261 feature_name,
262 battery_pack,
263 } => enable_feature(&feature_name, battery_pack.as_deref(), &project_dir),
264 BpCommands::List {
265 filter,
266 non_interactive,
267 } => {
268 if !non_interactive && interactive {
271 tui::run_list(source, filter)
272 } else {
273 print_battery_pack_list(&source, filter.as_deref())
276 }
277 }
278 BpCommands::Show {
279 battery_pack,
280 path,
281 non_interactive,
282 } => {
283 if !non_interactive && interactive {
286 tui::run_show(&battery_pack, path.as_deref(), source)
287 } else {
288 print_battery_pack_detail(&battery_pack, path.as_deref(), &source)
289 }
290 }
291 BpCommands::Status { path } => {
292 status_battery_packs(&project_dir, path.as_deref(), &source)
293 }
294 BpCommands::Validate { path } => validate_battery_pack_cmd(path.as_deref()),
295 }
296 }
297 }
298}
299
300#[derive(Deserialize)]
305struct CratesIoResponse {
306 versions: Vec<VersionInfo>,
307}
308
309#[derive(Deserialize)]
310struct VersionInfo {
311 num: String,
312 yanked: bool,
313}
314
315#[derive(Deserialize)]
316struct SearchResponse {
317 crates: Vec<SearchCrate>,
318}
319
320#[derive(Deserialize)]
321struct SearchCrate {
322 name: String,
323 max_version: String,
324 description: Option<String>,
325}
326
327pub type TemplateConfig = bphelper_manifest::TemplateSpec;
329
330#[derive(Deserialize)]
335struct OwnersResponse {
336 users: Vec<Owner>,
337}
338
339#[derive(Deserialize, Clone)]
340struct Owner {
341 login: String,
342 name: Option<String>,
343}
344
345#[derive(Deserialize)]
350struct GitHubTreeResponse {
351 tree: Vec<GitHubTreeEntry>,
352 #[serde(default)]
353 #[allow(dead_code)]
354 truncated: bool,
355}
356
357#[derive(Deserialize)]
358struct GitHubTreeEntry {
359 path: String,
360}
361
362#[derive(Clone)]
368pub struct BatteryPackSummary {
369 pub name: String,
370 pub short_name: String,
371 pub version: String,
372 pub description: String,
373}
374
375#[derive(Clone)]
377pub struct BatteryPackDetail {
378 pub name: String,
379 pub short_name: String,
380 pub version: String,
381 pub description: String,
382 pub repository: Option<String>,
383 pub owners: Vec<OwnerInfo>,
384 pub crates: Vec<String>,
385 pub extends: Vec<String>,
386 pub templates: Vec<TemplateInfo>,
387 pub examples: Vec<ExampleInfo>,
388}
389
390#[derive(Clone)]
391pub struct OwnerInfo {
392 pub login: String,
393 pub name: Option<String>,
394}
395
396impl From<Owner> for OwnerInfo {
397 fn from(o: Owner) -> Self {
398 Self {
399 login: o.login,
400 name: o.name,
401 }
402 }
403}
404
405#[derive(Clone)]
406pub struct TemplateInfo {
407 pub name: String,
408 pub path: String,
409 pub description: Option<String>,
410 pub repo_path: Option<String>,
413}
414
415#[derive(Clone)]
416pub struct ExampleInfo {
417 pub name: String,
418 pub description: Option<String>,
419 pub repo_path: Option<String>,
422}
423
424fn new_from_battery_pack(
434 battery_pack: &str,
435 name: Option<String>,
436 template: Option<String>,
437 path_override: Option<String>,
438 source: &CrateSource,
439 define: &[(String, String)],
440) -> Result<()> {
441 let defines: std::collections::BTreeMap<String, String> = define.iter().cloned().collect();
442
443 if let Some(path) = path_override {
445 return generate_from_local(&path, name, template, defines);
446 }
447
448 let crate_name = resolve_crate_name(battery_pack);
449
450 let crate_dir: PathBuf;
452 let _temp_dir: Option<tempfile::TempDir>; match source {
454 CrateSource::Registry => {
455 let crate_info = lookup_crate(&crate_name)?;
456 let temp = download_and_extract_crate(&crate_name, &crate_info.version)?;
457 crate_dir = temp
458 .path()
459 .join(format!("{}-{}", crate_name, crate_info.version));
460 _temp_dir = Some(temp);
461 }
462 CrateSource::Local(workspace_dir) => {
463 crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
464 _temp_dir = None;
465 }
466 }
467
468 let manifest_path = crate_dir.join("Cargo.toml");
470 let manifest_content = std::fs::read_to_string(&manifest_path)
471 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
472 let templates = parse_template_metadata(&manifest_content, &crate_name)?;
473
474 let template_path = resolve_template(&templates, template.as_deref())?;
476
477 generate_from_path(&crate_dir, &template_path, name, defines)
479}
480
481pub enum ResolvedAdd {
483 Crates {
485 active_features: BTreeSet<String>,
486 crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
487 },
488 Interactive,
490}
491
492pub fn resolve_add_crates(
507 bp_spec: &bphelper_manifest::BatteryPackSpec,
508 bp_name: &str,
509 with_features: &[String],
510 no_default_features: bool,
511 all_features: bool,
512 specific_crates: &[String],
513) -> ResolvedAdd {
514 if !specific_crates.is_empty() {
515 let mut selected = BTreeMap::new();
517 for crate_name_arg in specific_crates {
518 if let Some(spec) = bp_spec.crates.get(crate_name_arg.as_str()) {
519 selected.insert(crate_name_arg.clone(), spec.clone());
520 } else {
521 eprintln!(
522 "error: crate '{}' not found in battery pack '{}'",
523 crate_name_arg, bp_name
524 );
525 }
526 }
527 return ResolvedAdd::Crates {
528 active_features: BTreeSet::new(),
529 crates: selected,
530 };
531 }
532
533 if all_features {
534 return ResolvedAdd::Crates {
536 active_features: BTreeSet::from(["all".to_string()]),
537 crates: bp_spec.resolve_all_visible(),
538 };
539 }
540
541 if !no_default_features && with_features.is_empty() && bp_spec.has_meaningful_choices() {
545 return ResolvedAdd::Interactive;
546 }
547
548 let mut features: BTreeSet<String> = if no_default_features {
549 BTreeSet::new()
550 } else {
551 BTreeSet::from(["default".to_string()])
552 };
553 features.extend(with_features.iter().cloned());
554
555 if features.is_empty() {
559 return ResolvedAdd::Crates {
560 active_features: features,
561 crates: BTreeMap::new(),
562 };
563 }
564
565 let str_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
566 let crates = bp_spec.resolve_crates(&str_features);
567 ResolvedAdd::Crates {
568 active_features: features,
569 crates,
570 }
571}
572
573#[allow(clippy::too_many_arguments)]
583pub fn add_battery_pack(
584 name: &str,
585 with_features: &[String],
586 no_default_features: bool,
587 all_features: bool,
588 specific_crates: &[String],
589 target: Option<AddTarget>,
590 path: Option<&str>,
591 source: &CrateSource,
592 project_dir: &Path,
593) -> Result<()> {
594 let crate_name = resolve_crate_name(name);
595
596 let (bp_version, bp_spec) = if let Some(local_path) = path {
602 let manifest_path = Path::new(local_path).join("Cargo.toml");
603 let manifest_content = std::fs::read_to_string(&manifest_path)
604 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
605 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
606 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e))?;
607 (None, spec)
608 } else {
609 fetch_bp_spec(source, name)?
610 };
611
612 let resolved = resolve_add_crates(
615 &bp_spec,
616 &crate_name,
617 with_features,
618 no_default_features,
619 all_features,
620 specific_crates,
621 );
622 let (active_features, crates_to_sync) = match resolved {
623 ResolvedAdd::Crates {
624 active_features,
625 crates,
626 } => (active_features, crates),
627 ResolvedAdd::Interactive if std::io::stdout().is_terminal() => {
628 match pick_crates_interactive(&bp_spec)? {
629 Some(result) => (result.active_features, result.crates),
630 None => {
631 println!("Cancelled.");
632 return Ok(());
633 }
634 }
635 }
636 ResolvedAdd::Interactive => {
637 let crates = bp_spec.resolve_crates(&["default"]);
639 (BTreeSet::from(["default".to_string()]), crates)
640 }
641 };
642
643 if crates_to_sync.is_empty() {
644 println!("No crates selected.");
645 return Ok(());
646 }
647
648 let user_manifest_path = find_user_manifest(project_dir)?;
650 let user_manifest_content =
651 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
652 let mut user_doc: toml_edit::DocumentMut = user_manifest_content
654 .parse()
655 .context("Failed to parse Cargo.toml")?;
656
657 let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
659
660 let build_deps =
662 user_doc["build-dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
663 if let Some(table) = build_deps.as_table_mut() {
664 if let Some(local_path) = path {
665 let mut dep = toml_edit::InlineTable::new();
666 dep.insert("path", toml_edit::Value::from(local_path));
667 table.insert(
668 &crate_name,
669 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
670 );
671 } else if workspace_manifest.is_some() {
672 let mut dep = toml_edit::InlineTable::new();
673 dep.insert("workspace", toml_edit::Value::from(true));
674 table.insert(
675 &crate_name,
676 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
677 );
678 } else {
679 let version = bp_version
680 .as_ref()
681 .context("battery pack version not available (--path without workspace)")?;
682 table.insert(&crate_name, toml_edit::value(version));
683 }
684 }
685
686 let mut ws_doc: Option<toml_edit::DocumentMut> = if let Some(ref ws_path) = workspace_manifest {
691 let ws_content =
692 std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
693 Some(
694 ws_content
695 .parse()
696 .context("Failed to parse workspace Cargo.toml")?,
697 )
698 } else {
699 None
700 };
701
702 if let Some(ref mut doc) = ws_doc {
703 let ws_deps = doc["workspace"]["dependencies"]
704 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
705 if let Some(ws_table) = ws_deps.as_table_mut() {
706 if let Some(local_path) = path {
708 let mut dep = toml_edit::InlineTable::new();
709 dep.insert("path", toml_edit::Value::from(local_path));
710 ws_table.insert(
711 &crate_name,
712 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
713 );
714 } else {
715 let version = bp_version
716 .as_ref()
717 .context("battery pack version not available (--path without workspace)")?;
718 ws_table.insert(&crate_name, toml_edit::value(version));
719 }
720 for (dep_name, dep_spec) in &crates_to_sync {
722 add_dep_to_table(ws_table, dep_name, dep_spec);
723 }
724 }
725
726 write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, false);
728 } else {
729 write_deps_by_kind(&mut user_doc, &crates_to_sync, false);
732 }
733
734 let use_workspace_metadata = match target {
740 Some(AddTarget::Workspace) => true,
741 Some(AddTarget::Package) => false,
742 Some(AddTarget::Default) | None => workspace_manifest.is_some(),
743 };
744
745 if use_workspace_metadata {
746 if let Some(ref mut doc) = ws_doc {
747 write_bp_features_to_doc(
748 doc,
749 &["workspace", "metadata"],
750 &crate_name,
751 &active_features,
752 );
753 } else {
754 bail!("--target=workspace requires a workspace, but none was found");
755 }
756 } else {
757 write_bp_features_to_doc(
758 &mut user_doc,
759 &["package", "metadata"],
760 &crate_name,
761 &active_features,
762 );
763 }
764
765 if let (Some(ws_path), Some(doc)) = (&workspace_manifest, &ws_doc) {
767 std::fs::write(ws_path, doc.to_string()).context("Failed to write workspace Cargo.toml")?;
769 }
770
771 std::fs::write(&user_manifest_path, user_doc.to_string())
774 .context("Failed to write Cargo.toml")?;
775
776 let build_rs_path = user_manifest_path
778 .parent()
779 .unwrap_or(Path::new("."))
780 .join("build.rs");
781 update_build_rs(&build_rs_path, &crate_name)?;
782
783 println!(
784 "Added {} with {} crate(s)",
785 crate_name,
786 crates_to_sync.len()
787 );
788 for dep_name in crates_to_sync.keys() {
789 println!(" + {}", dep_name);
790 }
791
792 Ok(())
793}
794
795fn sync_battery_packs(project_dir: &Path, path: Option<&str>, source: &CrateSource) -> Result<()> {
801 let user_manifest_path = find_user_manifest(project_dir)?;
802 let user_manifest_content =
803 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
804
805 let bp_names = find_installed_bp_names(&user_manifest_content)?;
806
807 if bp_names.is_empty() {
808 println!("No battery packs installed.");
809 return Ok(());
810 }
811
812 let mut user_doc: toml_edit::DocumentMut = user_manifest_content
814 .parse()
815 .context("Failed to parse Cargo.toml")?;
816
817 let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
818 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
819 let mut total_changes = 0;
820
821 for bp_name in &bp_names {
822 let bp_spec = load_installed_bp_spec(bp_name, path, source)?;
824
825 let active_features =
827 read_active_features_from(&metadata_location, &user_manifest_content, bp_name);
828
829 let expected = bp_spec.resolve_for_features(&active_features);
831
832 if let Some(ref ws_path) = workspace_manifest {
835 let ws_content =
836 std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
837 let mut ws_doc: toml_edit::DocumentMut = ws_content
839 .parse()
840 .context("Failed to parse workspace Cargo.toml")?;
841
842 let ws_deps = ws_doc["workspace"]["dependencies"]
843 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
844 if let Some(ws_table) = ws_deps.as_table_mut() {
845 for (dep_name, dep_spec) in &expected {
846 if sync_dep_in_table(ws_table, dep_name, dep_spec) {
847 total_changes += 1;
848 println!(" ~ {} (updated in workspace)", dep_name);
849 }
850 }
851 }
852 std::fs::write(ws_path, ws_doc.to_string())
854 .context("Failed to write workspace Cargo.toml")?;
855
856 let refs_added = write_workspace_refs_by_kind(&mut user_doc, &expected, true);
859 total_changes += refs_added;
860 } else {
861 for (dep_name, dep_spec) in &expected {
864 let section = dep_kind_section(dep_spec.dep_kind);
865 let table =
866 user_doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
867 if let Some(table) = table.as_table_mut() {
868 if !table.contains_key(dep_name) {
869 add_dep_to_table(table, dep_name, dep_spec);
870 total_changes += 1;
871 println!(" + {}", dep_name);
872 } else if sync_dep_in_table(table, dep_name, dep_spec) {
873 total_changes += 1;
874 println!(" ~ {}", dep_name);
875 }
876 }
877 }
878 }
879 }
880
881 std::fs::write(&user_manifest_path, user_doc.to_string())
883 .context("Failed to write Cargo.toml")?;
884
885 if total_changes == 0 {
886 println!("All dependencies are up to date.");
887 } else {
888 println!("Synced {} change(s).", total_changes);
889 }
890
891 Ok(())
892}
893
894fn enable_feature(
895 feature_name: &str,
896 battery_pack: Option<&str>,
897 project_dir: &Path,
898) -> Result<()> {
899 let user_manifest_path = find_user_manifest(project_dir)?;
900 let user_manifest_content =
901 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
902
903 let bp_name = if let Some(name) = battery_pack {
905 resolve_crate_name(name)
906 } else {
907 let bp_names = find_installed_bp_names(&user_manifest_content)?;
909
910 let mut found = None;
911 for name in &bp_names {
912 let spec = fetch_battery_pack_spec(name)?;
913 if spec.features.contains_key(feature_name) {
914 found = Some(name.clone());
915 break;
916 }
917 }
918 found.ok_or_else(|| {
919 anyhow::anyhow!(
920 "No installed battery pack defines feature '{}'",
921 feature_name
922 )
923 })?
924 };
925
926 let bp_spec = fetch_battery_pack_spec(&bp_name)?;
927
928 if !bp_spec.features.contains_key(feature_name) {
929 let available: Vec<_> = bp_spec.features.keys().collect();
930 bail!(
931 "Battery pack '{}' has no feature '{}'. Available: {:?}",
932 bp_name,
933 feature_name,
934 available
935 );
936 }
937
938 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
940 let mut active_features =
941 read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
942 if active_features.contains(feature_name) {
943 println!(
944 "Feature '{}' is already active for {}.",
945 feature_name, bp_name
946 );
947 return Ok(());
948 }
949 active_features.insert(feature_name.to_string());
950
951 let str_features: Vec<&str> = active_features.iter().map(|s| s.as_str()).collect();
953 let crates_to_sync = bp_spec.resolve_crates(&str_features);
954
955 let mut user_doc: toml_edit::DocumentMut = user_manifest_content
957 .parse()
958 .context("Failed to parse Cargo.toml")?;
959
960 let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
961
962 if let Some(ref ws_path) = workspace_manifest {
964 let ws_content =
965 std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
966 let mut ws_doc: toml_edit::DocumentMut = ws_content
967 .parse()
968 .context("Failed to parse workspace Cargo.toml")?;
969
970 let ws_deps = ws_doc["workspace"]["dependencies"]
971 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
972 if let Some(ws_table) = ws_deps.as_table_mut() {
973 for (dep_name, dep_spec) in &crates_to_sync {
974 add_dep_to_table(ws_table, dep_name, dep_spec);
975 }
976 }
977
978 if matches!(metadata_location, MetadataLocation::Workspace { .. }) {
980 write_bp_features_to_doc(
981 &mut ws_doc,
982 &["workspace", "metadata"],
983 &bp_name,
984 &active_features,
985 );
986 }
987
988 std::fs::write(ws_path, ws_doc.to_string())
989 .context("Failed to write workspace Cargo.toml")?;
990
991 write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, true);
993 } else {
994 write_deps_by_kind(&mut user_doc, &crates_to_sync, true);
996 }
997
998 if matches!(metadata_location, MetadataLocation::Package) {
1000 write_bp_features_to_doc(
1001 &mut user_doc,
1002 &["package", "metadata"],
1003 &bp_name,
1004 &active_features,
1005 );
1006 }
1007
1008 std::fs::write(&user_manifest_path, user_doc.to_string())
1009 .context("Failed to write Cargo.toml")?;
1010
1011 println!("Enabled feature '{}' from {}", feature_name, bp_name);
1012 Ok(())
1013}
1014
1015struct PickerResult {
1021 crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
1023 active_features: BTreeSet<String>,
1025}
1026
1027fn pick_crates_interactive(
1032 bp_spec: &bphelper_manifest::BatteryPackSpec,
1033) -> Result<Option<PickerResult>> {
1034 use console::style;
1035 use dialoguer::MultiSelect;
1036
1037 let grouped = bp_spec.all_crates_with_grouping();
1038 if grouped.is_empty() {
1039 bail!("Battery pack has no crates to add");
1040 }
1041
1042 let mut labels = Vec::new();
1044 let mut defaults = Vec::new();
1045
1046 for (group, crate_name, dep, is_default) in &grouped {
1047 let version_info = if dep.features.is_empty() {
1048 format!("({})", dep.version)
1049 } else {
1050 format!(
1051 "({}, features: {})",
1052 dep.version,
1053 dep.features
1054 .iter()
1055 .map(|s| s.as_str())
1056 .collect::<Vec<_>>()
1057 .join(", ")
1058 )
1059 };
1060
1061 let group_label = if group == "default" {
1062 String::new()
1063 } else {
1064 format!(" [{}]", group)
1065 };
1066
1067 labels.push(format!(
1068 "{} {}{}",
1069 crate_name,
1070 style(&version_info).dim(),
1071 style(&group_label).cyan()
1072 ));
1073 defaults.push(*is_default);
1074 }
1075
1076 println!();
1078 println!(
1079 " {} v{}",
1080 style(&bp_spec.name).green().bold(),
1081 style(&bp_spec.version).dim()
1082 );
1083 println!();
1084
1085 let selections = MultiSelect::new()
1086 .with_prompt("Select crates to add")
1087 .items(&labels)
1088 .defaults(&defaults)
1089 .interact_opt()
1090 .context("Failed to show crate picker")?;
1091
1092 let Some(selected_indices) = selections else {
1093 return Ok(None); };
1095
1096 let mut crates = BTreeMap::new();
1098
1099 for idx in &selected_indices {
1100 let (_group, crate_name, dep, _) = &grouped[*idx];
1101 let merged = (*dep).clone();
1103
1104 crates.insert(crate_name.clone(), merged);
1105 }
1106
1107 let mut active_features = BTreeSet::from(["default".to_string()]);
1109 for (feature_name, feature_crates) in &bp_spec.features {
1110 if feature_name == "default" {
1111 continue;
1112 }
1113 let all_selected = feature_crates.iter().all(|c| crates.contains_key(c));
1114 if all_selected {
1115 active_features.insert(feature_name.clone());
1116 }
1117 }
1118
1119 Ok(Some(PickerResult {
1120 crates,
1121 active_features,
1122 }))
1123}
1124
1125fn find_user_manifest(project_dir: &Path) -> Result<std::path::PathBuf> {
1131 let path = project_dir.join("Cargo.toml");
1132 if path.exists() {
1133 Ok(path)
1134 } else {
1135 bail!("No Cargo.toml found in {}", project_dir.display());
1136 }
1137}
1138
1139pub fn find_installed_bp_names(manifest_content: &str) -> Result<Vec<String>> {
1144 let raw: toml::Value =
1145 toml::from_str(manifest_content).context("Failed to parse Cargo.toml")?;
1146
1147 let build_deps = raw
1148 .get("build-dependencies")
1149 .and_then(|bd| bd.as_table())
1150 .cloned()
1151 .unwrap_or_default();
1152
1153 Ok(build_deps
1154 .keys()
1155 .filter(|k| k.ends_with("-battery-pack") || *k == "battery-pack")
1156 .cloned()
1157 .collect())
1158}
1159
1160fn find_workspace_manifest(crate_manifest: &Path) -> Result<Option<std::path::PathBuf>> {
1165 let parent = crate_manifest.parent().unwrap_or(Path::new("."));
1166 let parent = if parent.as_os_str().is_empty() {
1167 Path::new(".")
1168 } else {
1169 parent
1170 };
1171 let crate_dir = parent
1172 .canonicalize()
1173 .context("Failed to resolve crate directory")?;
1174
1175 let mut dir = crate_dir.clone();
1177 loop {
1178 let candidate = dir.join("Cargo.toml");
1179 if candidate.exists() && candidate != crate_dir.join("Cargo.toml") {
1180 let content = std::fs::read_to_string(&candidate)?;
1181 if content.contains("[workspace]") {
1182 return Ok(Some(candidate));
1183 }
1184 }
1185 if !dir.pop() {
1186 break;
1187 }
1188 }
1189
1190 Ok(None)
1193}
1194
1195fn dep_kind_section(kind: bphelper_manifest::DepKind) -> &'static str {
1197 match kind {
1198 bphelper_manifest::DepKind::Normal => "dependencies",
1199 bphelper_manifest::DepKind::Dev => "dev-dependencies",
1200 bphelper_manifest::DepKind::Build => "build-dependencies",
1201 }
1202}
1203
1204fn write_deps_by_kind(
1210 doc: &mut toml_edit::DocumentMut,
1211 crates: &BTreeMap<String, bphelper_manifest::CrateSpec>,
1212 if_missing: bool,
1213) -> usize {
1214 let mut written = 0;
1215 for (dep_name, dep_spec) in crates {
1216 let section = dep_kind_section(dep_spec.dep_kind);
1217 let table = doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1218 if let Some(table) = table.as_table_mut()
1219 && (!if_missing || !table.contains_key(dep_name))
1220 {
1221 add_dep_to_table(table, dep_name, dep_spec);
1222 written += 1;
1223 }
1224 }
1225 written
1226}
1227
1228fn write_workspace_refs_by_kind(
1235 doc: &mut toml_edit::DocumentMut,
1236 crates: &BTreeMap<String, bphelper_manifest::CrateSpec>,
1237 if_missing: bool,
1238) -> usize {
1239 let mut written = 0;
1240 for (dep_name, dep_spec) in crates {
1241 let section = dep_kind_section(dep_spec.dep_kind);
1242 let table = doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1243 if let Some(table) = table.as_table_mut()
1244 && (!if_missing || !table.contains_key(dep_name))
1245 {
1246 let mut dep = toml_edit::InlineTable::new();
1247 dep.insert("workspace", toml_edit::Value::from(true));
1248 table.insert(
1249 dep_name,
1250 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
1251 );
1252 written += 1;
1253 }
1254 }
1255 written
1256}
1257
1258pub fn add_dep_to_table(
1264 table: &mut toml_edit::Table,
1265 name: &str,
1266 spec: &bphelper_manifest::CrateSpec,
1267) {
1268 if spec.features.is_empty() {
1269 table.insert(name, toml_edit::value(&spec.version));
1270 } else {
1271 let mut dep = toml_edit::InlineTable::new();
1272 dep.insert("version", toml_edit::Value::from(spec.version.as_str()));
1273 let mut features = toml_edit::Array::new();
1274 for feat in &spec.features {
1275 features.push(feat.as_str());
1276 }
1277 dep.insert("features", toml_edit::Value::Array(features));
1278 table.insert(
1279 name,
1280 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
1281 );
1282 }
1283}
1284
1285fn should_upgrade_version(current: &str, recommended: &str) -> bool {
1291 match (
1292 semver::Version::parse(current)
1293 .or_else(|_| semver::Version::parse(&format!("{}.0", current)))
1294 .or_else(|_| semver::Version::parse(&format!("{}.0.0", current))),
1295 semver::Version::parse(recommended)
1296 .or_else(|_| semver::Version::parse(&format!("{}.0", recommended)))
1297 .or_else(|_| semver::Version::parse(&format!("{}.0.0", recommended))),
1298 ) {
1299 (Ok(cur), Ok(rec)) => rec > cur,
1301 _ => current != recommended,
1303 }
1304}
1305
1306pub fn sync_dep_in_table(
1311 table: &mut toml_edit::Table,
1312 name: &str,
1313 spec: &bphelper_manifest::CrateSpec,
1314) -> bool {
1315 let Some(existing) = table.get_mut(name) else {
1316 add_dep_to_table(table, name, spec);
1318 return true;
1319 };
1320
1321 let mut changed = false;
1322
1323 match existing {
1324 toml_edit::Item::Value(toml_edit::Value::String(version_str)) => {
1325 let current = version_str.value().to_string();
1327 if !spec.version.is_empty() && should_upgrade_version(¤t, &spec.version) {
1329 *version_str = toml_edit::Formatted::new(spec.version.clone());
1330 changed = true;
1331 }
1332 if !spec.features.is_empty() {
1334 let keep_version = if !spec.version.is_empty()
1337 && should_upgrade_version(¤t, &spec.version)
1338 {
1339 spec.version.clone()
1340 } else {
1341 current.clone()
1342 };
1343 let patched = bphelper_manifest::CrateSpec {
1344 version: keep_version,
1345 features: spec.features.clone(),
1346 dep_kind: spec.dep_kind,
1347 optional: spec.optional,
1348 };
1349 add_dep_to_table(table, name, &patched);
1350 changed = true;
1351 }
1352 }
1353 toml_edit::Item::Value(toml_edit::Value::InlineTable(inline)) => {
1354 if let Some(toml_edit::Value::String(v)) = inline.get_mut("version")
1357 && !spec.version.is_empty()
1358 && should_upgrade_version(v.value(), &spec.version)
1359 {
1360 *v = toml_edit::Formatted::new(spec.version.clone());
1361 changed = true;
1362 }
1363 if !spec.features.is_empty() {
1366 let existing_features: Vec<String> = inline
1367 .get("features")
1368 .and_then(|f| f.as_array())
1369 .map(|arr| {
1370 arr.iter()
1371 .filter_map(|v| v.as_str().map(String::from))
1372 .collect()
1373 })
1374 .unwrap_or_default();
1375
1376 let mut needs_update = false;
1377 let existing_set: BTreeSet<&str> =
1378 existing_features.iter().map(|s| s.as_str()).collect();
1379 let mut all_features = existing_features.clone();
1380 for feat in &spec.features {
1381 if !existing_set.contains(feat.as_str()) {
1382 all_features.push(feat.clone());
1383 needs_update = true;
1384 }
1385 }
1386
1387 if needs_update {
1388 let mut arr = toml_edit::Array::new();
1389 for f in &all_features {
1390 arr.push(f.as_str());
1391 }
1392 inline.insert("features", toml_edit::Value::Array(arr));
1393 changed = true;
1394 }
1395 }
1396 }
1397 toml_edit::Item::Table(tbl) => {
1398 if let Some(toml_edit::Item::Value(toml_edit::Value::String(v))) =
1401 tbl.get_mut("version")
1402 && !spec.version.is_empty()
1403 && should_upgrade_version(v.value(), &spec.version)
1404 {
1405 *v = toml_edit::Formatted::new(spec.version.clone());
1406 changed = true;
1407 }
1408 if !spec.features.is_empty() {
1410 let existing_features: Vec<String> = tbl
1411 .get("features")
1412 .and_then(|f| f.as_value())
1413 .and_then(|v| v.as_array())
1414 .map(|arr| {
1415 arr.iter()
1416 .filter_map(|v| v.as_str().map(String::from))
1417 .collect()
1418 })
1419 .unwrap_or_default();
1420
1421 let existing_set: BTreeSet<&str> =
1422 existing_features.iter().map(|s| s.as_str()).collect();
1423 let mut all_features = existing_features.clone();
1424 let mut needs_update = false;
1425 for feat in &spec.features {
1426 if !existing_set.contains(feat.as_str()) {
1427 all_features.push(feat.clone());
1428 needs_update = true;
1429 }
1430 }
1431
1432 if needs_update {
1433 let mut arr = toml_edit::Array::new();
1434 for f in &all_features {
1435 arr.push(f.as_str());
1436 }
1437 tbl.insert(
1438 "features",
1439 toml_edit::Item::Value(toml_edit::Value::Array(arr)),
1440 );
1441 changed = true;
1442 }
1443 }
1444 }
1445 _ => {}
1446 }
1447
1448 changed
1449}
1450
1451fn read_features_at(raw: &toml::Value, prefix: &[&str], bp_name: &str) -> BTreeSet<String> {
1457 let mut node = Some(raw);
1458 for key in prefix {
1459 node = node.and_then(|n| n.get(key));
1460 }
1461 node.and_then(|m| m.get("battery-pack"))
1462 .and_then(|bp| bp.get(bp_name))
1463 .and_then(|entry| entry.get("features"))
1464 .and_then(|sets| sets.as_array())
1465 .map(|arr| {
1466 arr.iter()
1467 .filter_map(|v| v.as_str().map(String::from))
1468 .collect()
1469 })
1470 .unwrap_or_else(|| BTreeSet::from(["default".to_string()]))
1471}
1472
1473pub fn read_active_features(manifest_content: &str, bp_name: &str) -> BTreeSet<String> {
1475 let raw: toml::Value = match toml::from_str(manifest_content) {
1476 Ok(v) => v,
1477 Err(_) => return BTreeSet::from(["default".to_string()]),
1478 };
1479 read_features_at(&raw, &["package", "metadata"], bp_name)
1480}
1481
1482#[derive(Debug, Clone)]
1493enum MetadataLocation {
1494 Package,
1496 Workspace { ws_manifest_path: PathBuf },
1498}
1499
1500fn resolve_metadata_location(user_manifest_path: &Path) -> Result<MetadataLocation> {
1506 if let Some(ws_path) = find_workspace_manifest(user_manifest_path)? {
1507 let ws_content =
1508 std::fs::read_to_string(&ws_path).context("Failed to read workspace Cargo.toml")?;
1509 let raw: toml::Value =
1510 toml::from_str(&ws_content).context("Failed to parse workspace Cargo.toml")?;
1511 if raw
1512 .get("workspace")
1513 .and_then(|w| w.get("metadata"))
1514 .and_then(|m| m.get("battery-pack"))
1515 .is_some()
1516 {
1517 return Ok(MetadataLocation::Workspace {
1518 ws_manifest_path: ws_path,
1519 });
1520 }
1521 }
1522 Ok(MetadataLocation::Package)
1523}
1524
1525fn read_active_features_from(
1530 location: &MetadataLocation,
1531 user_manifest_content: &str,
1532 bp_name: &str,
1533) -> BTreeSet<String> {
1534 match location {
1535 MetadataLocation::Package => read_active_features(user_manifest_content, bp_name),
1536 MetadataLocation::Workspace { ws_manifest_path } => {
1537 let ws_content = match std::fs::read_to_string(ws_manifest_path) {
1538 Ok(c) => c,
1539 Err(_) => return BTreeSet::from(["default".to_string()]),
1540 };
1541 read_active_features_ws(&ws_content, bp_name)
1542 }
1543 }
1544}
1545
1546pub fn read_active_features_ws(ws_content: &str, bp_name: &str) -> BTreeSet<String> {
1548 let raw: toml::Value = match toml::from_str(ws_content) {
1549 Ok(v) => v,
1550 Err(_) => return BTreeSet::from(["default".to_string()]),
1551 };
1552 read_features_at(&raw, &["workspace", "metadata"], bp_name)
1553}
1554
1555fn write_bp_features_to_doc(
1560 doc: &mut toml_edit::DocumentMut,
1561 path_prefix: &[&str],
1562 bp_name: &str,
1563 active_features: &BTreeSet<String>,
1564) {
1565 let mut features_array = toml_edit::Array::new();
1566 for feature in active_features {
1567 features_array.push(feature.as_str());
1568 }
1569
1570 doc[path_prefix[0]].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1573 doc[path_prefix[0]][path_prefix[1]].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1574 doc[path_prefix[0]][path_prefix[1]]["battery-pack"]
1575 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
1576
1577 let bp_meta = &mut doc[path_prefix[0]][path_prefix[1]]["battery-pack"][bp_name];
1578 *bp_meta = toml_edit::Item::Table(toml_edit::Table::new());
1579 bp_meta["features"] = toml_edit::value(features_array);
1580}
1581
1582fn resolve_battery_pack_manifest(bp_name: &str) -> Result<std::path::PathBuf> {
1587 let metadata = cargo_metadata::MetadataCommand::new()
1588 .exec()
1589 .context("Failed to run `cargo metadata`")?;
1590
1591 let package = metadata
1592 .packages
1593 .iter()
1594 .find(|p| p.name == bp_name)
1595 .ok_or_else(|| {
1596 anyhow::anyhow!(
1597 "Battery pack '{}' not found in dependency graph. Is it in [build-dependencies]?",
1598 bp_name
1599 )
1600 })?;
1601
1602 Ok(package.manifest_path.clone().into())
1603}
1604
1605fn load_installed_bp_spec(
1614 bp_name: &str,
1615 path: Option<&str>,
1616 source: &CrateSource,
1617) -> Result<bphelper_manifest::BatteryPackSpec> {
1618 if let Some(local_path) = path {
1619 let manifest_path = Path::new(local_path).join("Cargo.toml");
1620 let manifest_content = std::fs::read_to_string(&manifest_path)
1621 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1622 return bphelper_manifest::parse_battery_pack(&manifest_content)
1623 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", bp_name, e));
1624 }
1625 match source {
1626 CrateSource::Registry => fetch_battery_pack_spec(bp_name),
1627 CrateSource::Local(_) => {
1628 let (_version, spec) = fetch_bp_spec(source, bp_name)?;
1629 Ok(spec)
1630 }
1631 }
1632}
1633
1634fn fetch_battery_pack_spec(bp_name: &str) -> Result<bphelper_manifest::BatteryPackSpec> {
1636 let manifest_path = resolve_battery_pack_manifest(bp_name)?;
1637 let manifest_content = std::fs::read_to_string(&manifest_path)
1638 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1639
1640 bphelper_manifest::parse_battery_pack(&manifest_content)
1641 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", bp_name, e))
1642}
1643
1644pub(crate) fn fetch_bp_spec_from_registry(
1650 crate_name: &str,
1651) -> Result<(String, bphelper_manifest::BatteryPackSpec)> {
1652 let crate_info = lookup_crate(crate_name)?;
1653 let temp_dir = download_and_extract_crate(crate_name, &crate_info.version)?;
1654 let crate_dir = temp_dir
1655 .path()
1656 .join(format!("{}-{}", crate_name, crate_info.version));
1657
1658 let manifest_path = crate_dir.join("Cargo.toml");
1659 let manifest_content = std::fs::read_to_string(&manifest_path)
1660 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1661
1662 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
1663 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e))?;
1664
1665 Ok((crate_info.version, spec))
1666}
1667
1668fn update_build_rs(build_rs_path: &Path, crate_name: &str) -> Result<()> {
1674 let crate_ident = crate_name.replace('-', "_");
1675 let validate_call = format!("{}::validate();", crate_ident);
1676
1677 if build_rs_path.exists() {
1678 let content = std::fs::read_to_string(build_rs_path).context("Failed to read build.rs")?;
1679
1680 if content.contains(&validate_call) {
1682 return Ok(());
1683 }
1684
1685 let file: syn::File = syn::parse_str(&content).context("Failed to parse build.rs")?;
1687
1688 let has_main = file
1690 .items
1691 .iter()
1692 .any(|item| matches!(item, syn::Item::Fn(func) if func.sig.ident == "main"));
1693
1694 if has_main {
1695 let lines: Vec<&str> = content.lines().collect();
1697 let mut insert_line = None;
1698 let mut brace_depth: i32 = 0;
1699 let mut in_main = false;
1700
1701 for (i, line) in lines.iter().enumerate() {
1702 if line.contains("fn main") {
1703 in_main = true;
1704 brace_depth = 0;
1705 }
1706 if in_main {
1707 for ch in line.chars() {
1708 if ch == '{' {
1709 brace_depth += 1;
1710 } else if ch == '}' {
1711 brace_depth -= 1;
1712 if brace_depth == 0 {
1713 insert_line = Some(i);
1714 in_main = false;
1715 break;
1716 }
1717 }
1718 }
1719 }
1720 }
1721
1722 if let Some(line_idx) = insert_line {
1723 let mut new_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
1724 new_lines.insert(line_idx, format!(" {}", validate_call));
1725 std::fs::write(build_rs_path, new_lines.join("\n") + "\n")
1726 .context("Failed to write build.rs")?;
1727 return Ok(());
1728 }
1729 }
1730
1731 bail!(
1733 "Could not find fn main() in build.rs. Please add `{}` manually.",
1734 validate_call
1735 );
1736 } else {
1737 let content = format!("fn main() {{\n {}\n}}\n", validate_call);
1739 std::fs::write(build_rs_path, content).context("Failed to create build.rs")?;
1740 }
1741
1742 Ok(())
1743}
1744
1745fn generate_from_local(
1746 local_path: &str,
1747 name: Option<String>,
1748 template: Option<String>,
1749 defines: std::collections::BTreeMap<String, String>,
1750) -> Result<()> {
1751 let local_path = Path::new(local_path);
1752
1753 let manifest_path = local_path.join("Cargo.toml");
1755 let manifest_content = std::fs::read_to_string(&manifest_path)
1756 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1757
1758 let crate_name = local_path
1759 .file_name()
1760 .and_then(|s| s.to_str())
1761 .unwrap_or("unknown");
1762 let templates = parse_template_metadata(&manifest_content, crate_name)?;
1763 let template_path = resolve_template(&templates, template.as_deref())?;
1764
1765 generate_from_path(local_path, &template_path, name, defines)
1766}
1767
1768fn ensure_battery_pack_suffix(name: Option<String>) -> Result<String> {
1771 let raw = match name {
1772 Some(n) => n,
1773 None => dialoguer::Input::<String>::new()
1774 .with_prompt("Project name")
1775 .interact_text()
1776 .context("Failed to read project name")?,
1777 };
1778 if raw.ends_with("-battery-pack") {
1779 Ok(raw)
1780 } else {
1781 let fixed = format!("{}-battery-pack", raw);
1782 println!("Renaming project to: {}", fixed);
1783 Ok(fixed)
1784 }
1785}
1786
1787fn generate_from_path(
1788 crate_path: &Path,
1789 template_path: &str,
1790 name: Option<String>,
1791 defines: std::collections::BTreeMap<String, String>,
1792) -> Result<()> {
1793 let project_name = ensure_battery_pack_suffix(name)?;
1797
1798 let opts = template_engine::GenerateOpts {
1799 crate_root: crate_path.to_path_buf(),
1800 template_path: template_path.to_string(),
1801 project_name,
1802 destination: None,
1803 defines,
1804 git_init: true,
1805 };
1806
1807 template_engine::generate(opts)?;
1808
1809 Ok(())
1810}
1811
1812fn parse_define(s: &str) -> Result<(String, String), String> {
1814 let (key, value) = s
1815 .split_once('=')
1816 .ok_or_else(|| format!("invalid define '{s}': expected key=value"))?;
1817 Ok((key.to_string(), value.to_string()))
1818}
1819
1820struct CrateMetadata {
1822 version: String,
1823}
1824
1825fn lookup_crate(crate_name: &str) -> Result<CrateMetadata> {
1827 let client = http_client();
1828
1829 let url = format!("{}/{}", CRATES_IO_API, crate_name);
1830 let response = client
1831 .get(&url)
1832 .send()
1833 .with_context(|| format!("Failed to query crates.io for '{}'", crate_name))?;
1834
1835 if !response.status().is_success() {
1836 bail!(
1837 "Crate '{}' not found on crates.io (status: {})",
1838 crate_name,
1839 response.status()
1840 );
1841 }
1842
1843 let parsed: CratesIoResponse = response
1844 .json()
1845 .with_context(|| format!("Failed to parse crates.io response for '{}'", crate_name))?;
1846
1847 let version = parsed
1849 .versions
1850 .iter()
1851 .find(|v| !v.yanked)
1852 .map(|v| v.num.clone())
1853 .ok_or_else(|| anyhow::anyhow!("No non-yanked versions found for '{}'", crate_name))?;
1854
1855 Ok(CrateMetadata { version })
1856}
1857
1858fn download_and_extract_crate(crate_name: &str, version: &str) -> Result<tempfile::TempDir> {
1860 let client = http_client();
1861
1862 let url = format!(
1864 "{}/{}/{}-{}.crate",
1865 CRATES_IO_CDN, crate_name, crate_name, version
1866 );
1867
1868 let response = client
1869 .get(&url)
1870 .send()
1871 .with_context(|| format!("Failed to download crate from {}", url))?;
1872
1873 if !response.status().is_success() {
1874 bail!(
1875 "Failed to download '{}' version {} (status: {})",
1876 crate_name,
1877 version,
1878 response.status()
1879 );
1880 }
1881
1882 let bytes = response
1883 .bytes()
1884 .with_context(|| "Failed to read crate tarball")?;
1885
1886 let temp_dir = tempfile::tempdir().with_context(|| "Failed to create temp directory")?;
1888
1889 let decoder = GzDecoder::new(&bytes[..]);
1890 let mut archive = Archive::new(decoder);
1891 archive
1892 .unpack(temp_dir.path())
1893 .with_context(|| "Failed to extract crate tarball")?;
1894
1895 Ok(temp_dir)
1896}
1897
1898fn parse_template_metadata(
1899 manifest_content: &str,
1900 crate_name: &str,
1901) -> Result<BTreeMap<String, TemplateConfig>> {
1902 let spec = bphelper_manifest::parse_battery_pack(manifest_content)
1903 .map_err(|e| anyhow::anyhow!("Failed to parse Cargo.toml: {}", e))?;
1904
1905 if spec.templates.is_empty() {
1906 bail!(
1907 "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
1908 crate_name
1909 );
1910 }
1911
1912 Ok(spec.templates)
1913}
1914
1915pub fn resolve_template(
1918 templates: &BTreeMap<String, TemplateConfig>,
1919 requested: Option<&str>,
1920) -> Result<String> {
1921 match requested {
1922 Some(name) => {
1923 let config = templates.get(name).ok_or_else(|| {
1924 let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
1925 anyhow::anyhow!(
1926 "Template '{}' not found. Available templates: {}",
1927 name,
1928 available.join(", ")
1929 )
1930 })?;
1931 Ok(config.path.clone())
1932 }
1933 None => {
1934 if templates.len() == 1 {
1935 let (_, config) = templates.iter().next().unwrap();
1937 Ok(config.path.clone())
1938 } else if let Some(config) = templates.get("default") {
1939 Ok(config.path.clone())
1941 } else {
1942 prompt_for_template(templates)
1944 }
1945 }
1946 }
1947}
1948
1949fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
1950 use dialoguer::{Select, theme::ColorfulTheme};
1951
1952 let items: Vec<String> = templates
1954 .iter()
1955 .map(|(name, config)| {
1956 if let Some(desc) = &config.description {
1957 format!("{} - {}", name, desc)
1958 } else {
1959 name.clone()
1960 }
1961 })
1962 .collect();
1963
1964 if !std::io::stdout().is_terminal() {
1966 println!("Available templates:");
1968 for item in &items {
1969 println!(" {}", item);
1970 }
1971 bail!("Multiple templates available. Please specify one with --template <name>");
1972 }
1973
1974 let selection = Select::with_theme(&ColorfulTheme::default())
1976 .with_prompt("Select a template")
1977 .items(&items)
1978 .default(0)
1979 .interact()
1980 .context("Failed to select template")?;
1981
1982 let (_, config) = templates
1984 .iter()
1985 .nth(selection)
1986 .ok_or_else(|| anyhow::anyhow!("Invalid template selection"))?;
1987 Ok(config.path.clone())
1988}
1989
1990pub struct InstalledPack {
1992 pub name: String,
1993 pub short_name: String,
1994 pub version: String,
1995 pub spec: bphelper_manifest::BatteryPackSpec,
1996 pub active_features: BTreeSet<String>,
1997}
1998
1999pub fn load_installed_packs(project_dir: &Path) -> Result<Vec<InstalledPack>> {
2005 let user_manifest_path = find_user_manifest(project_dir)?;
2006 let user_manifest_content =
2007 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
2008
2009 let bp_names = find_installed_bp_names(&user_manifest_content)?;
2010 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
2011
2012 let mut packs = Vec::new();
2013 for bp_name in bp_names {
2014 let spec = fetch_battery_pack_spec(&bp_name)?;
2015 let active_features =
2016 read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
2017 packs.push(InstalledPack {
2018 short_name: short_name(&bp_name).to_string(),
2019 version: spec.version.clone(),
2020 spec,
2021 name: bp_name,
2022 active_features,
2023 });
2024 }
2025
2026 Ok(packs)
2027}
2028
2029pub fn fetch_battery_pack_list(
2031 source: &CrateSource,
2032 filter: Option<&str>,
2033) -> Result<Vec<BatteryPackSummary>> {
2034 match source {
2035 CrateSource::Registry => fetch_battery_pack_list_from_registry(filter),
2036 CrateSource::Local(path) => discover_local_battery_packs(path, filter),
2037 }
2038}
2039
2040fn fetch_battery_pack_list_from_registry(filter: Option<&str>) -> Result<Vec<BatteryPackSummary>> {
2041 let client = http_client();
2042
2043 let url = match filter {
2045 Some(q) => format!(
2046 "{CRATES_IO_API}?q={}&keyword=battery-pack&per_page=50",
2047 urlencoding::encode(q)
2048 ),
2049 None => format!("{CRATES_IO_API}?keyword=battery-pack&per_page=50"),
2050 };
2051
2052 let response = client
2053 .get(&url)
2054 .send()
2055 .context("Failed to query crates.io")?;
2056
2057 if !response.status().is_success() {
2058 bail!(
2059 "Failed to list battery packs (status: {})",
2060 response.status()
2061 );
2062 }
2063
2064 let parsed: SearchResponse = response.json().context("Failed to parse response")?;
2065
2066 let battery_packs = parsed
2068 .crates
2069 .into_iter()
2070 .filter(|c| c.name.ends_with("-battery-pack"))
2071 .map(|c| BatteryPackSummary {
2072 short_name: short_name(&c.name).to_string(),
2073 name: c.name,
2074 version: c.max_version,
2075 description: c.description.unwrap_or_default(),
2076 })
2077 .collect();
2078
2079 Ok(battery_packs)
2080}
2081
2082fn discover_local_battery_packs(
2084 workspace_dir: &Path,
2085 filter: Option<&str>,
2086) -> Result<Vec<BatteryPackSummary>> {
2087 let manifest_path = workspace_dir.join("Cargo.toml");
2088 let metadata = cargo_metadata::MetadataCommand::new()
2089 .manifest_path(&manifest_path)
2090 .no_deps()
2091 .exec()
2092 .with_context(|| format!("Failed to read workspace at {}", manifest_path.display()))?;
2093
2094 let mut battery_packs: Vec<BatteryPackSummary> = metadata
2095 .packages
2096 .iter()
2097 .filter(|pkg| pkg.name.ends_with("-battery-pack"))
2098 .filter(|pkg| {
2099 if let Some(q) = filter {
2100 short_name(&pkg.name).contains(q)
2101 } else {
2102 true
2103 }
2104 })
2105 .map(|pkg| BatteryPackSummary {
2106 short_name: short_name(&pkg.name).to_string(),
2107 name: pkg.name.to_string(),
2108 version: pkg.version.to_string(),
2109 description: pkg.description.clone().unwrap_or_default(),
2110 })
2111 .collect();
2112
2113 battery_packs.sort_by(|a, b| a.name.cmp(&b.name));
2114 Ok(battery_packs)
2115}
2116
2117fn find_local_battery_pack_dir(workspace_dir: &Path, crate_name: &str) -> Result<PathBuf> {
2119 let manifest_path = workspace_dir.join("Cargo.toml");
2120 let metadata = cargo_metadata::MetadataCommand::new()
2121 .manifest_path(&manifest_path)
2122 .no_deps()
2123 .exec()
2124 .with_context(|| format!("Failed to read workspace at {}", manifest_path.display()))?;
2125
2126 let package = metadata
2127 .packages
2128 .iter()
2129 .find(|p| p.name == crate_name)
2130 .ok_or_else(|| {
2131 anyhow::anyhow!(
2132 "Battery pack '{}' not found in workspace at {}",
2133 crate_name,
2134 workspace_dir.display()
2135 )
2136 })?;
2137
2138 Ok(package
2139 .manifest_path
2140 .parent()
2141 .expect("manifest path should have a parent")
2142 .into())
2143}
2144
2145pub(crate) fn fetch_bp_spec(
2150 source: &CrateSource,
2151 name: &str,
2152) -> Result<(Option<String>, bphelper_manifest::BatteryPackSpec)> {
2153 let crate_name = resolve_crate_name(name);
2154 match source {
2155 CrateSource::Registry => {
2156 let (version, spec) = fetch_bp_spec_from_registry(&crate_name)?;
2157 Ok((Some(version), spec))
2158 }
2159 CrateSource::Local(workspace_dir) => {
2160 let crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
2161 let manifest_path = crate_dir.join("Cargo.toml");
2162 let manifest_content = std::fs::read_to_string(&manifest_path)
2163 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
2164 let spec = bphelper_manifest::parse_battery_pack(&manifest_content).map_err(|e| {
2165 anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e)
2166 })?;
2167 Ok((None, spec))
2168 }
2169 }
2170}
2171
2172pub(crate) fn fetch_battery_pack_detail_from_source(
2175 source: &CrateSource,
2176 name: &str,
2177) -> Result<BatteryPackDetail> {
2178 match source {
2179 CrateSource::Registry => fetch_battery_pack_detail(name, None),
2180 CrateSource::Local(workspace_dir) => {
2181 let crate_name = resolve_crate_name(name);
2182 let crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
2183 fetch_battery_pack_detail_from_path(&crate_dir.to_string_lossy())
2184 }
2185 }
2186}
2187
2188fn print_battery_pack_list(source: &CrateSource, filter: Option<&str>) -> Result<()> {
2189 use console::style;
2190
2191 let battery_packs = fetch_battery_pack_list(source, filter)?;
2192
2193 if battery_packs.is_empty() {
2194 match filter {
2195 Some(q) => println!("No battery packs found matching '{}'", q),
2196 None => println!("No battery packs found"),
2197 }
2198 return Ok(());
2199 }
2200
2201 let max_name_len = battery_packs
2203 .iter()
2204 .map(|c| c.short_name.len())
2205 .max()
2206 .unwrap_or(0);
2207
2208 let max_version_len = battery_packs
2209 .iter()
2210 .map(|c| c.version.len())
2211 .max()
2212 .unwrap_or(0);
2213
2214 println!();
2215 for bp in &battery_packs {
2216 let desc = bp.description.lines().next().unwrap_or("");
2217
2218 let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
2220 let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
2221
2222 println!(
2223 " {} {} {}",
2224 style(name_padded).green().bold(),
2225 style(ver_padded).dim(),
2226 desc,
2227 );
2228 }
2229 println!();
2230
2231 println!(
2232 "{}",
2233 style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
2234 );
2235
2236 Ok(())
2237}
2238
2239fn short_name(crate_name: &str) -> &str {
2241 crate_name
2242 .strip_suffix("-battery-pack")
2243 .unwrap_or(crate_name)
2244}
2245
2246fn resolve_crate_name(name: &str) -> String {
2251 if name == "battery-pack" || name.ends_with("-battery-pack") {
2252 name.to_string()
2253 } else {
2254 format!("{}-battery-pack", name)
2255 }
2256}
2257
2258pub fn fetch_battery_pack_detail(name: &str, path: Option<&str>) -> Result<BatteryPackDetail> {
2260 if let Some(local_path) = path {
2262 return fetch_battery_pack_detail_from_path(local_path);
2263 }
2264
2265 let crate_name = resolve_crate_name(name);
2266
2267 let crate_info = lookup_crate(&crate_name)?;
2269 let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
2270 let crate_dir = temp_dir
2271 .path()
2272 .join(format!("{}-{}", crate_name, crate_info.version));
2273
2274 let manifest_path = crate_dir.join("Cargo.toml");
2276 let manifest_content = std::fs::read_to_string(&manifest_path)
2277 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
2278 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
2279 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack: {}", e))?;
2280
2281 let owners = fetch_owners(&crate_name)?;
2283
2284 build_battery_pack_detail(&crate_dir, &spec, owners)
2285}
2286
2287fn fetch_battery_pack_detail_from_path(path: &str) -> Result<BatteryPackDetail> {
2289 let crate_dir = std::path::Path::new(path);
2290 let manifest_path = crate_dir.join("Cargo.toml");
2291 let manifest_content = std::fs::read_to_string(&manifest_path)
2292 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
2293
2294 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
2295 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack: {}", e))?;
2296
2297 build_battery_pack_detail(crate_dir, &spec, Vec::new())
2298}
2299
2300fn build_battery_pack_detail(
2305 crate_dir: &Path,
2306 spec: &bphelper_manifest::BatteryPackSpec,
2307 owners: Vec<Owner>,
2308) -> Result<BatteryPackDetail> {
2309 let (extends_raw, crates_raw): (Vec<_>, Vec<_>) = spec
2312 .visible_crates()
2313 .into_keys()
2314 .partition(|d| d.ends_with("-battery-pack"));
2315
2316 let extends: Vec<String> = extends_raw
2317 .into_iter()
2318 .map(|d| short_name(d).to_string())
2319 .collect();
2320 let crates: Vec<String> = crates_raw.into_iter().map(|s| s.to_string()).collect();
2321
2322 let repo_tree = spec.repository.as_ref().and_then(|r| fetch_github_tree(r));
2324
2325 let templates = spec
2327 .templates
2328 .iter()
2329 .map(|(name, tmpl)| {
2330 let repo_path = repo_tree
2331 .as_ref()
2332 .and_then(|tree| find_template_path(tree, &tmpl.path));
2333 TemplateInfo {
2334 name: name.clone(),
2335 path: tmpl.path.clone(),
2336 description: tmpl.description.clone(),
2337 repo_path,
2338 }
2339 })
2340 .collect();
2341
2342 let examples = scan_examples(crate_dir, repo_tree.as_deref());
2344
2345 Ok(BatteryPackDetail {
2346 short_name: short_name(&spec.name).to_string(),
2347 name: spec.name.clone(),
2348 version: spec.version.clone(),
2349 description: spec.description.clone(),
2350 repository: spec.repository.clone(),
2351 owners: owners.into_iter().map(OwnerInfo::from).collect(),
2352 crates,
2353 extends,
2354 templates,
2355 examples,
2356 })
2357}
2358
2359fn print_battery_pack_detail(name: &str, path: Option<&str>, source: &CrateSource) -> Result<()> {
2363 use console::style;
2364
2365 let detail = if path.is_some() {
2367 fetch_battery_pack_detail(name, path)?
2368 } else {
2369 fetch_battery_pack_detail_from_source(source, name)?
2370 };
2371
2372 println!();
2374 println!(
2375 "{} {}",
2376 style(&detail.name).green().bold(),
2377 style(&detail.version).dim()
2378 );
2379 if !detail.description.is_empty() {
2380 println!("{}", detail.description);
2381 }
2382
2383 if !detail.owners.is_empty() {
2385 println!();
2386 println!("{}", style("Authors:").bold());
2387 for owner in &detail.owners {
2388 if let Some(name) = &owner.name {
2389 println!(" {} ({})", name, owner.login);
2390 } else {
2391 println!(" {}", owner.login);
2392 }
2393 }
2394 }
2395
2396 if !detail.crates.is_empty() {
2398 println!();
2399 println!("{}", style("Crates:").bold());
2400 for dep in &detail.crates {
2401 println!(" {}", dep);
2402 }
2403 }
2404
2405 if !detail.extends.is_empty() {
2407 println!();
2408 println!("{}", style("Extends:").bold());
2409 for dep in &detail.extends {
2410 println!(" {}", dep);
2411 }
2412 }
2413
2414 if !detail.templates.is_empty() {
2416 println!();
2417 println!("{}", style("Templates:").bold());
2418 let max_name_len = detail
2419 .templates
2420 .iter()
2421 .map(|t| t.name.len())
2422 .max()
2423 .unwrap_or(0);
2424 for tmpl in &detail.templates {
2425 let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
2426 if let Some(desc) = &tmpl.description {
2427 println!(" {} {}", style(name_padded).cyan(), desc);
2428 } else {
2429 println!(" {}", style(name_padded).cyan());
2430 }
2431 }
2432 }
2433
2434 if !detail.examples.is_empty() {
2437 println!();
2438 println!("{}", style("Examples:").bold());
2439 let max_name_len = detail
2440 .examples
2441 .iter()
2442 .map(|e| e.name.len())
2443 .max()
2444 .unwrap_or(0);
2445 for example in &detail.examples {
2446 let name_padded = format!("{:<width$}", example.name, width = max_name_len);
2447 if let Some(desc) = &example.description {
2448 println!(" {} {}", style(name_padded).magenta(), desc);
2449 } else {
2450 println!(" {}", style(name_padded).magenta());
2451 }
2452 }
2453 }
2454
2455 println!();
2457 println!("{}", style("Install:").bold());
2458 println!(" cargo bp add {}", detail.short_name);
2459 println!(" cargo bp new {}", detail.short_name);
2460 println!();
2461
2462 Ok(())
2463}
2464
2465fn fetch_owners(crate_name: &str) -> Result<Vec<Owner>> {
2466 let client = http_client();
2467
2468 let url = format!("{}/{}/owners", CRATES_IO_API, crate_name);
2469 let response = client
2470 .get(&url)
2471 .send()
2472 .with_context(|| format!("Failed to fetch owners for '{}'", crate_name))?;
2473
2474 if !response.status().is_success() {
2475 return Ok(Vec::new());
2477 }
2478
2479 let parsed: OwnersResponse = response
2480 .json()
2481 .with_context(|| "Failed to parse owners response")?;
2482
2483 Ok(parsed.users)
2484}
2485
2486fn scan_examples(crate_dir: &std::path::Path, repo_tree: Option<&[String]>) -> Vec<ExampleInfo> {
2490 let examples_dir = crate_dir.join("examples");
2491 if !examples_dir.exists() {
2492 return Vec::new();
2493 }
2494
2495 let mut examples = Vec::new();
2496
2497 if let Ok(entries) = std::fs::read_dir(&examples_dir) {
2498 for entry in entries.flatten() {
2499 let path = entry.path();
2500 if path.extension().is_some_and(|ext| ext == "rs")
2501 && let Some(name) = path.file_stem().and_then(|s| s.to_str())
2502 {
2503 let description = extract_example_description(&path);
2504 let repo_path = repo_tree.and_then(|tree| find_example_path(tree, name));
2505 examples.push(ExampleInfo {
2506 name: name.to_string(),
2507 description,
2508 repo_path,
2509 });
2510 }
2511 }
2512 }
2513
2514 examples.sort_by(|a, b| a.name.cmp(&b.name));
2516 examples
2517}
2518
2519fn extract_example_description(path: &std::path::Path) -> Option<String> {
2521 let content = std::fs::read_to_string(path).ok()?;
2522
2523 for line in content.lines() {
2525 let trimmed = line.trim();
2526 if trimmed.starts_with("//!") {
2527 let desc = trimmed.strip_prefix("//!").unwrap_or("").trim();
2528 if !desc.is_empty() {
2529 return Some(desc.to_string());
2530 }
2531 } else if !trimmed.is_empty() && !trimmed.starts_with("//") {
2532 break;
2534 }
2535 }
2536 None
2537}
2538
2539fn fetch_github_tree(repository: &str) -> Option<Vec<String>> {
2542 let gh_path = repository
2544 .strip_prefix("https://github.com/")
2545 .or_else(|| repository.strip_prefix("http://github.com/"))?;
2546 let gh_path = gh_path.strip_suffix(".git").unwrap_or(gh_path);
2547 let gh_path = gh_path.trim_end_matches('/');
2548
2549 let client = http_client();
2550
2551 let url = format!(
2553 "https://api.github.com/repos/{}/git/trees/main?recursive=1",
2554 gh_path
2555 );
2556
2557 let response = client.get(&url).send().ok()?;
2558 if !response.status().is_success() {
2559 return None;
2560 }
2561
2562 let tree_response: GitHubTreeResponse = response.json().ok()?;
2563
2564 Some(tree_response.tree.into_iter().map(|e| e.path).collect())
2566}
2567
2568fn find_example_path(tree: &[String], example_name: &str) -> Option<String> {
2571 let suffix = format!("examples/{}.rs", example_name);
2572 tree.iter().find(|path| path.ends_with(&suffix)).cloned()
2573}
2574
2575fn find_template_path(tree: &[String], template_path: &str) -> Option<String> {
2578 tree.iter()
2580 .find(|path| path.ends_with(template_path))
2581 .cloned()
2582}
2583
2584fn status_battery_packs(
2594 project_dir: &Path,
2595 path: Option<&str>,
2596 source: &CrateSource,
2597) -> Result<()> {
2598 use console::style;
2599
2600 let user_manifest_path =
2602 find_user_manifest(project_dir).context("are you inside a Rust project?")?;
2603 let user_manifest_content =
2604 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
2605
2606 let bp_names = find_installed_bp_names(&user_manifest_content)?;
2608 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
2609 let packs: Vec<InstalledPack> = bp_names
2610 .into_iter()
2611 .map(|bp_name| {
2612 let spec = load_installed_bp_spec(&bp_name, path, source)?;
2613 let active_features =
2614 read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
2615 Ok(InstalledPack {
2616 short_name: short_name(&bp_name).to_string(),
2617 version: spec.version.clone(),
2618 spec,
2619 name: bp_name,
2620 active_features,
2621 })
2622 })
2623 .collect::<Result<_>>()?;
2624
2625 if packs.is_empty() {
2626 println!("No battery packs installed.");
2627 return Ok(());
2628 }
2629
2630 let user_versions = collect_user_dep_versions(&user_manifest_path, &user_manifest_content)?;
2632
2633 let mut any_warnings = false;
2634
2635 for pack in &packs {
2636 println!(
2638 "{} ({})",
2639 style(&pack.short_name).bold(),
2640 style(&pack.version).dim(),
2641 );
2642
2643 let expected = pack.spec.resolve_for_features(&pack.active_features);
2645
2646 let mut pack_warnings = Vec::new();
2647 for (dep_name, dep_spec) in &expected {
2648 if dep_spec.version.is_empty() {
2649 continue;
2650 }
2651 if let Some(user_version) = user_versions.get(dep_name.as_str()) {
2652 if should_upgrade_version(user_version, &dep_spec.version) {
2654 pack_warnings.push((
2655 dep_name.as_str(),
2656 user_version.as_str(),
2657 dep_spec.version.as_str(),
2658 ));
2659 }
2660 }
2661 }
2662
2663 if pack_warnings.is_empty() {
2664 println!(" {} all dependencies up to date", style("✓").green());
2665 } else {
2666 any_warnings = true;
2667 for (dep, current, recommended) in &pack_warnings {
2668 println!(
2669 " {} {}: {} → {} recommended",
2670 style("⚠").yellow(),
2671 dep,
2672 style(current).red(),
2673 style(recommended).green(),
2674 );
2675 }
2676 }
2677 }
2678
2679 if any_warnings {
2680 println!();
2681 println!("Run {} to update.", style("cargo bp sync").bold());
2682 }
2683
2684 Ok(())
2685}
2686
2687pub fn collect_user_dep_versions(
2691 user_manifest_path: &Path,
2692 user_manifest_content: &str,
2693) -> Result<BTreeMap<String, String>> {
2694 let raw: toml::Value =
2695 toml::from_str(user_manifest_content).context("Failed to parse Cargo.toml")?;
2696
2697 let mut versions = BTreeMap::new();
2698
2699 let ws_versions = if let Some(ws_path) = find_workspace_manifest(user_manifest_path)? {
2701 let ws_content =
2702 std::fs::read_to_string(&ws_path).context("Failed to read workspace Cargo.toml")?;
2703 let ws_raw: toml::Value =
2704 toml::from_str(&ws_content).context("Failed to parse workspace Cargo.toml")?;
2705 extract_versions_from_table(
2706 ws_raw
2707 .get("workspace")
2708 .and_then(|w| w.get("dependencies"))
2709 .and_then(|d| d.as_table()),
2710 )
2711 } else {
2712 BTreeMap::new()
2713 };
2714
2715 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
2717 let table = raw.get(section).and_then(|d| d.as_table());
2718 let Some(table) = table else { continue };
2719 for (name, value) in table {
2720 if versions.contains_key(name) {
2721 continue; }
2723 if let Some(version) = extract_version_from_dep(value) {
2724 versions.insert(name.clone(), version);
2725 } else if is_workspace_ref(value) {
2726 if let Some(ws_ver) = ws_versions.get(name) {
2728 versions.insert(name.clone(), ws_ver.clone());
2729 }
2730 }
2731 }
2732 }
2733
2734 Ok(versions)
2735}
2736
2737fn extract_versions_from_table(
2739 table: Option<&toml::map::Map<String, toml::Value>>,
2740) -> BTreeMap<String, String> {
2741 let Some(table) = table else {
2742 return BTreeMap::new();
2743 };
2744 let mut versions = BTreeMap::new();
2745 for (name, value) in table {
2746 if let Some(version) = extract_version_from_dep(value) {
2747 versions.insert(name.clone(), version);
2748 }
2749 }
2750 versions
2751}
2752
2753fn extract_version_from_dep(value: &toml::Value) -> Option<String> {
2757 match value {
2758 toml::Value::String(s) => Some(s.clone()),
2759 toml::Value::Table(t) => t
2760 .get("version")
2761 .and_then(|v| v.as_str())
2762 .map(|s| s.to_string()),
2763 _ => None,
2764 }
2765}
2766
2767fn is_workspace_ref(value: &toml::Value) -> bool {
2769 match value {
2770 toml::Value::Table(t) => t
2771 .get("workspace")
2772 .and_then(|v| v.as_bool())
2773 .unwrap_or(false),
2774 _ => false,
2775 }
2776}
2777
2778pub fn validate_battery_pack_cmd(path: Option<&str>) -> Result<()> {
2785 let crate_root = match path {
2786 Some(p) => std::path::PathBuf::from(p),
2787 None => std::env::current_dir().context("failed to get current directory")?,
2788 };
2789
2790 let cargo_toml = crate_root.join("Cargo.toml");
2791 let content = std::fs::read_to_string(&cargo_toml)
2792 .with_context(|| format!("failed to read {}", cargo_toml.display()))?;
2793
2794 let raw: toml::Value = toml::from_str(&content)
2796 .with_context(|| format!("failed to parse {}", cargo_toml.display()))?;
2797 if raw.get("package").is_none() {
2798 if raw.get("workspace").is_some() {
2799 bail!(
2801 "{} is a workspace manifest, not a battery pack crate.\n\
2802 Run this from a battery pack crate directory, or use --path to point to one.",
2803 cargo_toml.display()
2804 );
2805 } else {
2806 bail!(
2808 "{} has no [package] section — is this a battery pack crate?",
2809 cargo_toml.display()
2810 );
2811 }
2812 }
2813
2814 let spec = bphelper_manifest::parse_battery_pack(&content)
2815 .with_context(|| format!("failed to parse {}", cargo_toml.display()))?;
2816
2817 let mut report = spec.validate_spec();
2819 report.merge(bphelper_manifest::validate_on_disk(&spec, &crate_root));
2820
2821 if report.is_clean() {
2823 validate_templates(crate_root.to_str().unwrap_or("."))?;
2824 println!("{} is valid", spec.name);
2825 return Ok(());
2826 }
2827
2828 let mut errors = 0;
2831 let mut warnings = 0;
2832 for diag in &report.diagnostics {
2833 match diag.severity {
2834 bphelper_manifest::Severity::Error => {
2835 eprintln!("error[{}]: {}", diag.rule, diag.message);
2836 errors += 1;
2837 }
2838 bphelper_manifest::Severity::Warning => {
2839 eprintln!("warning[{}]: {}", diag.rule, diag.message);
2840 warnings += 1;
2841 }
2842 }
2843 }
2844
2845 if errors > 0 {
2847 bail!(
2848 "validation failed: {} error(s), {} warning(s)",
2849 errors,
2850 warnings
2851 );
2852 }
2853
2854 validate_templates(crate_root.to_str().unwrap_or("."))?;
2857 println!("{} is valid ({} warning(s))", spec.name, warnings);
2858 Ok(())
2859}
2860
2861pub fn validate_templates(manifest_dir: &str) -> Result<()> {
2874 let manifest_dir = Path::new(manifest_dir);
2875 let cargo_toml = manifest_dir.join("Cargo.toml");
2876 let content = std::fs::read_to_string(&cargo_toml)
2877 .with_context(|| format!("failed to read {}", cargo_toml.display()))?;
2878
2879 let crate_name = manifest_dir
2880 .file_name()
2881 .and_then(|s| s.to_str())
2882 .unwrap_or("unknown");
2883 let spec = bphelper_manifest::parse_battery_pack(&content)
2884 .map_err(|e| anyhow::anyhow!("failed to parse {}: {e}", cargo_toml.display()))?;
2885
2886 if spec.templates.is_empty() {
2887 println!("no templates to validate");
2889 return Ok(());
2890 }
2891
2892 let metadata = cargo_metadata::MetadataCommand::new()
2894 .manifest_path(&cargo_toml)
2895 .no_deps()
2896 .exec()
2897 .context("failed to run cargo metadata")?;
2898 let shared_target_dir = metadata.target_directory.join("bp-validate");
2899
2900 for (name, template) in &spec.templates {
2901 println!("validating template '{name}'...");
2902
2903 let tmp = tempfile::tempdir().context("failed to create temp directory")?;
2904
2905 let project_name = format!("bp-validate-{name}");
2906
2907 let opts = template_engine::GenerateOpts {
2908 crate_root: manifest_dir.to_path_buf(),
2909 template_path: template.path.clone(),
2910 project_name,
2911 destination: Some(tmp.path().to_path_buf()),
2912 defines: std::collections::BTreeMap::new(),
2913 git_init: false,
2914 };
2915
2916 let project_dir = template_engine::generate(opts)
2917 .with_context(|| format!("failed to generate template '{name}'"))?;
2918
2919 write_crates_io_patches(&project_dir, &metadata)?;
2924
2925 let output = std::process::Command::new("cargo")
2927 .args(["check"])
2928 .env("CARGO_TARGET_DIR", &*shared_target_dir)
2929 .current_dir(&project_dir)
2930 .output()
2931 .context("failed to run cargo check")?;
2932 anyhow::ensure!(
2933 output.status.success(),
2934 "cargo check failed for template '{name}':\n{}",
2935 String::from_utf8_lossy(&output.stderr)
2936 );
2937
2938 let output = std::process::Command::new("cargo")
2940 .args(["test"])
2941 .env("CARGO_TARGET_DIR", &*shared_target_dir)
2942 .current_dir(&project_dir)
2943 .output()
2944 .context("failed to run cargo test")?;
2945 anyhow::ensure!(
2946 output.status.success(),
2947 "cargo test failed for template '{name}':\n{}",
2948 String::from_utf8_lossy(&output.stderr)
2949 );
2950
2951 println!("template '{name}' ok");
2952 }
2953
2954 println!(
2955 "all {} template(s) for '{}' validated successfully",
2956 spec.templates.len(),
2957 crate_name
2958 );
2959 Ok(())
2960}
2961
2962fn write_crates_io_patches(project_dir: &Path, metadata: &cargo_metadata::Metadata) -> Result<()> {
2966 let mut patches = String::from("[patch.crates-io]\n");
2967 for pkg in &metadata.workspace_packages() {
2968 let path = pkg.manifest_path.parent().unwrap();
2969 patches.push_str(&format!("{} = {{ path = \"{}\" }}\n", pkg.name, path));
2970 }
2971
2972 let cargo_dir = project_dir.join(".cargo");
2973 std::fs::create_dir_all(&cargo_dir)
2974 .with_context(|| format!("failed to create {}", cargo_dir.display()))?;
2975 std::fs::write(cargo_dir.join("config.toml"), patches)
2976 .context("failed to write .cargo/config.toml")?;
2977 Ok(())
2978}