1use anyhow::{Context, Result, bail};
7use clap::{Parser, Subcommand};
8use std::collections::{BTreeMap, BTreeSet};
9use std::io::IsTerminal;
10use std::path::{Path, PathBuf};
11
12use crate::manifest::{
13 MetadataLocation, add_dep_to_table, dep_kind_section, find_installed_bp_names,
14 find_user_manifest, find_workspace_manifest, read_active_features_from,
15 resolve_metadata_location, should_upgrade_version, sync_dep_in_table, write_bp_features_to_doc,
16 write_deps_by_kind, write_workspace_refs_by_kind,
17};
18use crate::registry::{
19 CrateSource, InstalledPack, TemplateConfig, download_and_extract_crate,
20 fetch_battery_pack_detail, fetch_battery_pack_detail_from_source, fetch_battery_pack_list,
21 fetch_battery_pack_spec, fetch_bp_spec, find_local_battery_pack_dir, load_installed_bp_spec,
22 lookup_crate, resolve_crate_name, short_name,
23};
24
25#[derive(Parser)]
27#[command(name = "cargo-bp")]
28#[command(bin_name = "cargo")]
29#[command(version, about = "Create and manage battery packs", long_about = None)]
30pub(crate) struct Cli {
31 #[command(subcommand)]
32 pub command: Commands,
33}
34
35#[derive(Subcommand)]
36pub(crate) enum Commands {
37 Bp {
39 #[arg(long)]
42 crate_source: Option<PathBuf>,
43
44 #[command(subcommand)]
45 command: Option<BpCommands>,
46 },
47}
48
49#[derive(Subcommand)]
50pub(crate) enum BpCommands {
51 New {
53 battery_pack: String,
55
56 #[arg(long, short = 'n')]
58 name: Option<String>,
59
60 #[arg(long, short = 't')]
63 template: Option<String>,
64
65 #[arg(long)]
67 path: Option<String>,
68
69 #[arg(long = "define", short = 'd', value_parser = parse_define)]
71 define: Vec<(String, String)>,
72 },
73
74 Add {
80 battery_pack: Option<String>,
83
84 crates: Vec<String>,
86
87 #[arg(long = "features", short = 'F', value_delimiter = ',')]
91 features: Vec<String>,
92
93 #[arg(long)]
96 no_default_features: bool,
97
98 #[arg(long)]
101 all_features: bool,
102
103 #[arg(long)]
107 target: Option<AddTarget>,
108
109 #[arg(long)]
111 path: Option<String>,
112 },
113
114 Sync {
116 #[arg(long)]
119 path: Option<String>,
120 },
121
122 Enable {
124 feature_name: String,
126
127 #[arg(long)]
129 battery_pack: Option<String>,
130 },
131
132 #[command(visible_alias = "ls")]
134 List {
135 filter: Option<String>,
137
138 #[arg(long)]
140 non_interactive: bool,
141 },
142
143 #[command(visible_alias = "info")]
145 Show {
146 battery_pack: String,
148
149 #[arg(long)]
151 path: Option<String>,
152
153 #[arg(long)]
155 non_interactive: bool,
156 },
157
158 #[command(visible_alias = "stat")]
160 Status {
161 #[arg(long)]
164 path: Option<String>,
165 },
166
167 Validate {
169 #[arg(long)]
171 path: Option<String>,
172 },
173}
174
175#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
177pub(crate) enum AddTarget {
178 Workspace,
180 Package,
182 Default,
184}
185
186pub fn main() -> Result<()> {
187 let cli = Cli::parse();
188 let project_dir = std::env::current_dir().context("Failed to get current directory")?;
189 let interactive = std::io::stdout().is_terminal();
190
191 match cli.command {
192 Commands::Bp {
193 crate_source,
194 command,
195 } => {
196 let source = match crate_source {
197 Some(path) => CrateSource::Local(path),
198 None => CrateSource::Registry,
199 };
200 let Some(command) = command else {
202 if interactive {
203 return crate::tui::run_add(source);
204 } else {
205 bail!(
206 "No subcommand specified. Use `cargo bp --help` or run interactively in a terminal."
207 );
208 }
209 };
210 match command {
211 BpCommands::New {
212 battery_pack,
213 name,
214 template,
215 path,
216 define,
217 } => new_from_battery_pack(&battery_pack, name, template, path, &source, &define),
218 BpCommands::Add {
219 battery_pack,
220 crates,
221 features,
222 no_default_features,
223 all_features,
224 target,
225 path,
226 } => match battery_pack {
227 Some(name) => add_battery_pack(
228 &name,
229 &features,
230 no_default_features,
231 all_features,
232 &crates,
233 target,
234 path.as_deref(),
235 &source,
236 &project_dir,
237 ),
238 None if interactive => crate::tui::run_add(source),
239 None => {
240 bail!(
241 "No battery pack specified. Use `cargo bp add <name>` or run interactively in a terminal."
242 )
243 }
244 },
245 BpCommands::Sync { path } => {
246 sync_battery_packs(&project_dir, path.as_deref(), &source)
247 }
248 BpCommands::Enable {
249 feature_name,
250 battery_pack,
251 } => enable_feature(&feature_name, battery_pack.as_deref(), &project_dir),
252 BpCommands::List {
253 filter,
254 non_interactive,
255 } => {
256 if !non_interactive && interactive {
259 crate::tui::run_list(source, filter)
260 } else {
261 print_battery_pack_list(&source, filter.as_deref())
264 }
265 }
266 BpCommands::Show {
267 battery_pack,
268 path,
269 non_interactive,
270 } => {
271 if !non_interactive && interactive {
274 crate::tui::run_show(&battery_pack, path.as_deref(), source)
275 } else {
276 print_battery_pack_detail(&battery_pack, path.as_deref(), &source)
277 }
278 }
279 BpCommands::Status { path } => {
280 status_battery_packs(&project_dir, path.as_deref(), &source)
281 }
282 BpCommands::Validate { path } => {
283 crate::validate::validate_battery_pack_cmd(path.as_deref())
284 }
285 }
286 }
287 }
288}
289
290fn new_from_battery_pack(
300 battery_pack: &str,
301 name: Option<String>,
302 template: Option<String>,
303 path_override: Option<String>,
304 source: &CrateSource,
305 define: &[(String, String)],
306) -> Result<()> {
307 let defines: std::collections::BTreeMap<String, String> = define.iter().cloned().collect();
308
309 if let Some(path) = path_override {
311 return generate_from_local(battery_pack, &path, name, template, defines);
312 }
313
314 let crate_name = resolve_crate_name(battery_pack);
315
316 let crate_dir: PathBuf;
318 let _temp_dir: Option<tempfile::TempDir>; match source {
320 CrateSource::Registry => {
321 let crate_info = lookup_crate(&crate_name)?;
322 let temp = download_and_extract_crate(&crate_name, &crate_info.version)?;
323 crate_dir = temp
324 .path()
325 .join(format!("{}-{}", crate_name, crate_info.version));
326 _temp_dir = Some(temp);
327 }
328 CrateSource::Local(workspace_dir) => {
329 crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
330 _temp_dir = None;
331 }
332 }
333
334 let manifest_path = crate_dir.join("Cargo.toml");
336 let manifest_content = std::fs::read_to_string(&manifest_path)
337 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
338 let templates = parse_template_metadata(&manifest_content, &crate_name)?;
339
340 let template_path = resolve_template(&templates, template.as_deref())?;
342
343 generate_from_path(battery_pack, &crate_dir, &template_path, name, defines)
345}
346
347pub(crate) enum ResolvedAdd {
349 Crates {
351 active_features: BTreeSet<String>,
352 crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
353 },
354 Interactive,
356}
357
358pub(crate) fn resolve_add_crates(
373 bp_spec: &bphelper_manifest::BatteryPackSpec,
374 bp_name: &str,
375 with_features: &[String],
376 no_default_features: bool,
377 all_features: bool,
378 specific_crates: &[String],
379) -> ResolvedAdd {
380 if !specific_crates.is_empty() {
381 let mut selected = BTreeMap::new();
383 for crate_name_arg in specific_crates {
384 if let Some(spec) = bp_spec.crates.get(crate_name_arg.as_str()) {
385 selected.insert(crate_name_arg.clone(), spec.clone());
386 } else {
387 eprintln!(
388 "error: crate '{}' not found in battery pack '{}'",
389 crate_name_arg, bp_name
390 );
391 }
392 }
393 return ResolvedAdd::Crates {
394 active_features: BTreeSet::new(),
395 crates: selected,
396 };
397 }
398
399 if all_features {
400 return ResolvedAdd::Crates {
402 active_features: BTreeSet::from(["all".to_string()]),
403 crates: bp_spec.resolve_all_visible(),
404 };
405 }
406
407 if !no_default_features && with_features.is_empty() && bp_spec.has_meaningful_choices() {
411 return ResolvedAdd::Interactive;
412 }
413
414 let mut features: BTreeSet<String> = if no_default_features {
415 BTreeSet::new()
416 } else {
417 BTreeSet::from(["default".to_string()])
418 };
419 features.extend(with_features.iter().cloned());
420
421 if features.is_empty() {
425 return ResolvedAdd::Crates {
426 active_features: features,
427 crates: BTreeMap::new(),
428 };
429 }
430
431 let str_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
432 let crates = bp_spec.resolve_crates(&str_features);
433 ResolvedAdd::Crates {
434 active_features: features,
435 crates,
436 }
437}
438
439#[allow(clippy::too_many_arguments)]
449pub(crate) fn add_battery_pack(
450 name: &str,
451 with_features: &[String],
452 no_default_features: bool,
453 all_features: bool,
454 specific_crates: &[String],
455 target: Option<AddTarget>,
456 path: Option<&str>,
457 source: &CrateSource,
458 project_dir: &Path,
459) -> Result<()> {
460 let crate_name = resolve_crate_name(name);
461
462 let (bp_version, bp_spec) = if let Some(local_path) = path {
468 let manifest_path = Path::new(local_path).join("Cargo.toml");
469 let manifest_content = std::fs::read_to_string(&manifest_path)
470 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
471 let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
472 .map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e))?;
473 (None, spec)
474 } else {
475 fetch_bp_spec(source, name)?
476 };
477
478 let resolved = resolve_add_crates(
481 &bp_spec,
482 &crate_name,
483 with_features,
484 no_default_features,
485 all_features,
486 specific_crates,
487 );
488 let (active_features, crates_to_sync) = match resolved {
489 ResolvedAdd::Crates {
490 active_features,
491 crates,
492 } => (active_features, crates),
493 ResolvedAdd::Interactive if std::io::stdout().is_terminal() => {
494 match pick_crates_interactive(&bp_spec)? {
495 Some(result) => (result.active_features, result.crates),
496 None => {
497 println!("Cancelled.");
498 return Ok(());
499 }
500 }
501 }
502 ResolvedAdd::Interactive => {
503 let crates = bp_spec.resolve_crates(&["default"]);
505 (BTreeSet::from(["default".to_string()]), crates)
506 }
507 };
508
509 if crates_to_sync.is_empty() {
510 println!("No crates selected.");
511 return Ok(());
512 }
513
514 let user_manifest_path = find_user_manifest(project_dir)?;
516 let user_manifest_content =
517 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
518 let mut user_doc: toml_edit::DocumentMut = user_manifest_content
520 .parse()
521 .context("Failed to parse Cargo.toml")?;
522
523 let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
525
526 let build_deps =
528 user_doc["build-dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
529 if let Some(table) = build_deps.as_table_mut() {
530 if let Some(local_path) = path {
531 let mut dep = toml_edit::InlineTable::new();
532 dep.insert("path", toml_edit::Value::from(local_path));
533 table.insert(
534 &crate_name,
535 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
536 );
537 } else if workspace_manifest.is_some() {
538 let mut dep = toml_edit::InlineTable::new();
539 dep.insert("workspace", toml_edit::Value::from(true));
540 table.insert(
541 &crate_name,
542 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
543 );
544 } else {
545 let version = bp_version
546 .as_ref()
547 .context("battery pack version not available (--path without workspace)")?;
548 table.insert(&crate_name, toml_edit::value(version));
549 }
550 }
551
552 let mut ws_doc: Option<toml_edit::DocumentMut> = if let Some(ref ws_path) = workspace_manifest {
557 let ws_content =
558 std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
559 Some(
560 ws_content
561 .parse()
562 .context("Failed to parse workspace Cargo.toml")?,
563 )
564 } else {
565 None
566 };
567
568 if let Some(ref mut doc) = ws_doc {
569 let ws_deps = doc["workspace"]["dependencies"]
570 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
571 if let Some(ws_table) = ws_deps.as_table_mut() {
572 if let Some(local_path) = path {
574 let mut dep = toml_edit::InlineTable::new();
575 dep.insert("path", toml_edit::Value::from(local_path));
576 ws_table.insert(
577 &crate_name,
578 toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
579 );
580 } else {
581 let version = bp_version
582 .as_ref()
583 .context("battery pack version not available (--path without workspace)")?;
584 ws_table.insert(&crate_name, toml_edit::value(version));
585 }
586 for (dep_name, dep_spec) in &crates_to_sync {
588 add_dep_to_table(ws_table, dep_name, dep_spec);
589 }
590 }
591
592 write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, false);
594 } else {
595 write_deps_by_kind(&mut user_doc, &crates_to_sync, false);
598 }
599
600 let use_workspace_metadata = match target {
606 Some(AddTarget::Workspace) => true,
607 Some(AddTarget::Package) => false,
608 Some(AddTarget::Default) | None => workspace_manifest.is_some(),
609 };
610
611 if use_workspace_metadata {
612 if let Some(ref mut doc) = ws_doc {
613 write_bp_features_to_doc(
614 doc,
615 &["workspace", "metadata"],
616 &crate_name,
617 &active_features,
618 );
619 } else {
620 bail!("--target=workspace requires a workspace, but none was found");
621 }
622 } else {
623 write_bp_features_to_doc(
624 &mut user_doc,
625 &["package", "metadata"],
626 &crate_name,
627 &active_features,
628 );
629 }
630
631 if let (Some(ws_path), Some(doc)) = (&workspace_manifest, &ws_doc) {
633 std::fs::write(ws_path, doc.to_string()).context("Failed to write workspace Cargo.toml")?;
635 }
636
637 std::fs::write(&user_manifest_path, user_doc.to_string())
640 .context("Failed to write Cargo.toml")?;
641
642 let build_rs_path = user_manifest_path
644 .parent()
645 .unwrap_or(Path::new("."))
646 .join("build.rs");
647 update_build_rs(&build_rs_path, &crate_name)?;
648
649 println!(
650 "Added {} with {} crate(s)",
651 crate_name,
652 crates_to_sync.len()
653 );
654 for dep_name in crates_to_sync.keys() {
655 println!(" + {}", dep_name);
656 }
657
658 Ok(())
659}
660
661fn sync_battery_packs(project_dir: &Path, path: Option<&str>, source: &CrateSource) -> Result<()> {
667 let user_manifest_path = find_user_manifest(project_dir)?;
668 let user_manifest_content =
669 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
670
671 let bp_names = find_installed_bp_names(&user_manifest_content)?;
672
673 if bp_names.is_empty() {
674 println!("No battery packs installed.");
675 return Ok(());
676 }
677
678 let mut user_doc: toml_edit::DocumentMut = user_manifest_content
680 .parse()
681 .context("Failed to parse Cargo.toml")?;
682
683 let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
684 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
685 let mut total_changes = 0;
686
687 for bp_name in &bp_names {
688 let bp_spec = load_installed_bp_spec(bp_name, path, source)?;
690
691 let active_features =
693 read_active_features_from(&metadata_location, &user_manifest_content, bp_name);
694
695 let expected = bp_spec.resolve_for_features(&active_features);
697
698 if let Some(ref ws_path) = workspace_manifest {
701 let ws_content =
702 std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
703 let mut ws_doc: toml_edit::DocumentMut = ws_content
705 .parse()
706 .context("Failed to parse workspace Cargo.toml")?;
707
708 let ws_deps = ws_doc["workspace"]["dependencies"]
709 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
710 if let Some(ws_table) = ws_deps.as_table_mut() {
711 for (dep_name, dep_spec) in &expected {
712 if sync_dep_in_table(ws_table, dep_name, dep_spec) {
713 total_changes += 1;
714 println!(" ~ {} (updated in workspace)", dep_name);
715 }
716 }
717 }
718 std::fs::write(ws_path, ws_doc.to_string())
720 .context("Failed to write workspace Cargo.toml")?;
721
722 let refs_added = write_workspace_refs_by_kind(&mut user_doc, &expected, true);
725 total_changes += refs_added;
726 } else {
727 for (dep_name, dep_spec) in &expected {
730 let section = dep_kind_section(dep_spec.dep_kind);
731 let table =
732 user_doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
733 if let Some(table) = table.as_table_mut() {
734 if !table.contains_key(dep_name) {
735 add_dep_to_table(table, dep_name, dep_spec);
736 total_changes += 1;
737 println!(" + {}", dep_name);
738 } else if sync_dep_in_table(table, dep_name, dep_spec) {
739 total_changes += 1;
740 println!(" ~ {}", dep_name);
741 }
742 }
743 }
744 }
745 }
746
747 std::fs::write(&user_manifest_path, user_doc.to_string())
749 .context("Failed to write Cargo.toml")?;
750
751 if total_changes == 0 {
752 println!("All dependencies are up to date.");
753 } else {
754 println!("Synced {} change(s).", total_changes);
755 }
756
757 Ok(())
758}
759
760fn enable_feature(
761 feature_name: &str,
762 battery_pack: Option<&str>,
763 project_dir: &Path,
764) -> Result<()> {
765 let user_manifest_path = find_user_manifest(project_dir)?;
766 let user_manifest_content =
767 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
768
769 let bp_name = if let Some(name) = battery_pack {
771 resolve_crate_name(name)
772 } else {
773 let bp_names = find_installed_bp_names(&user_manifest_content)?;
775
776 let mut found = None;
777 for name in &bp_names {
778 let spec = fetch_battery_pack_spec(name)?;
779 if spec.features.contains_key(feature_name) {
780 found = Some(name.clone());
781 break;
782 }
783 }
784 found.ok_or_else(|| {
785 anyhow::anyhow!(
786 "No installed battery pack defines feature '{}'",
787 feature_name
788 )
789 })?
790 };
791
792 let bp_spec = fetch_battery_pack_spec(&bp_name)?;
793
794 if !bp_spec.features.contains_key(feature_name) {
795 let available: Vec<_> = bp_spec.features.keys().collect();
796 bail!(
797 "Battery pack '{}' has no feature '{}'. Available: {:?}",
798 bp_name,
799 feature_name,
800 available
801 );
802 }
803
804 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
806 let mut active_features =
807 read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
808 if active_features.contains(feature_name) {
809 println!(
810 "Feature '{}' is already active for {}.",
811 feature_name, bp_name
812 );
813 return Ok(());
814 }
815 active_features.insert(feature_name.to_string());
816
817 let str_features: Vec<&str> = active_features.iter().map(|s| s.as_str()).collect();
819 let crates_to_sync = bp_spec.resolve_crates(&str_features);
820
821 let mut user_doc: toml_edit::DocumentMut = user_manifest_content
823 .parse()
824 .context("Failed to parse Cargo.toml")?;
825
826 let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
827
828 if let Some(ref ws_path) = workspace_manifest {
830 let ws_content =
831 std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
832 let mut ws_doc: toml_edit::DocumentMut = ws_content
833 .parse()
834 .context("Failed to parse workspace Cargo.toml")?;
835
836 let ws_deps = ws_doc["workspace"]["dependencies"]
837 .or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
838 if let Some(ws_table) = ws_deps.as_table_mut() {
839 for (dep_name, dep_spec) in &crates_to_sync {
840 add_dep_to_table(ws_table, dep_name, dep_spec);
841 }
842 }
843
844 if matches!(metadata_location, MetadataLocation::Workspace { .. }) {
846 write_bp_features_to_doc(
847 &mut ws_doc,
848 &["workspace", "metadata"],
849 &bp_name,
850 &active_features,
851 );
852 }
853
854 std::fs::write(ws_path, ws_doc.to_string())
855 .context("Failed to write workspace Cargo.toml")?;
856
857 write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, true);
859 } else {
860 write_deps_by_kind(&mut user_doc, &crates_to_sync, true);
862 }
863
864 if matches!(metadata_location, MetadataLocation::Package) {
866 write_bp_features_to_doc(
867 &mut user_doc,
868 &["package", "metadata"],
869 &bp_name,
870 &active_features,
871 );
872 }
873
874 std::fs::write(&user_manifest_path, user_doc.to_string())
875 .context("Failed to write Cargo.toml")?;
876
877 println!("Enabled feature '{}' from {}", feature_name, bp_name);
878 Ok(())
879}
880
881struct PickerResult {
887 crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
889 active_features: BTreeSet<String>,
891}
892
893fn pick_crates_interactive(
898 bp_spec: &bphelper_manifest::BatteryPackSpec,
899) -> Result<Option<PickerResult>> {
900 use console::style;
901 use dialoguer::MultiSelect;
902
903 let grouped = bp_spec.all_crates_with_grouping();
904 if grouped.is_empty() {
905 bail!("Battery pack has no crates to add");
906 }
907
908 let mut labels = Vec::new();
910 let mut defaults = Vec::new();
911
912 for (group, crate_name, dep, is_default) in &grouped {
913 let version_info = if dep.features.is_empty() {
914 format!("({})", dep.version)
915 } else {
916 format!(
917 "({}, features: {})",
918 dep.version,
919 dep.features
920 .iter()
921 .map(|s| s.as_str())
922 .collect::<Vec<_>>()
923 .join(", ")
924 )
925 };
926
927 let group_label = if group == "default" {
928 String::new()
929 } else {
930 format!(" [{}]", group)
931 };
932
933 labels.push(format!(
934 "{} {}{}",
935 crate_name,
936 style(&version_info).dim(),
937 style(&group_label).cyan()
938 ));
939 defaults.push(*is_default);
940 }
941
942 println!();
944 println!(
945 " {} v{}",
946 style(&bp_spec.name).green().bold(),
947 style(&bp_spec.version).dim()
948 );
949 println!();
950
951 let selections = MultiSelect::new()
952 .with_prompt("Select crates to add")
953 .items(&labels)
954 .defaults(&defaults)
955 .interact_opt()
956 .context("Failed to show crate picker")?;
957
958 let Some(selected_indices) = selections else {
959 return Ok(None); };
961
962 let mut crates = BTreeMap::new();
964
965 for idx in &selected_indices {
966 let (_group, crate_name, dep, _) = &grouped[*idx];
967 let merged = (*dep).clone();
969
970 crates.insert(crate_name.clone(), merged);
971 }
972
973 let mut active_features = BTreeSet::from(["default".to_string()]);
975 for (feature_name, feature_crates) in &bp_spec.features {
976 if feature_name == "default" {
977 continue;
978 }
979 let all_selected = feature_crates.iter().all(|c| crates.contains_key(c));
980 if all_selected {
981 active_features.insert(feature_name.clone());
982 }
983 }
984
985 Ok(Some(PickerResult {
986 crates,
987 active_features,
988 }))
989}
990
991fn update_build_rs(build_rs_path: &Path, crate_name: &str) -> Result<()> {
997 let crate_ident = crate_name.replace('-', "_");
998 let validate_call = format!("{}::validate();", crate_ident);
999
1000 if build_rs_path.exists() {
1001 let content = std::fs::read_to_string(build_rs_path).context("Failed to read build.rs")?;
1002
1003 if content.contains(&validate_call) {
1005 return Ok(());
1006 }
1007
1008 let file: syn::File = syn::parse_str(&content).context("Failed to parse build.rs")?;
1010
1011 let has_main = file
1013 .items
1014 .iter()
1015 .any(|item| matches!(item, syn::Item::Fn(func) if func.sig.ident == "main"));
1016
1017 if has_main {
1018 let lines: Vec<&str> = content.lines().collect();
1020 let mut insert_line = None;
1021 let mut brace_depth: i32 = 0;
1022 let mut in_main = false;
1023
1024 for (i, line) in lines.iter().enumerate() {
1025 if line.contains("fn main") {
1026 in_main = true;
1027 brace_depth = 0;
1028 }
1029 if in_main {
1030 for ch in line.chars() {
1031 if ch == '{' {
1032 brace_depth += 1;
1033 } else if ch == '}' {
1034 brace_depth -= 1;
1035 if brace_depth == 0 {
1036 insert_line = Some(i);
1037 in_main = false;
1038 break;
1039 }
1040 }
1041 }
1042 }
1043 }
1044
1045 if let Some(line_idx) = insert_line {
1046 let mut new_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
1047 new_lines.insert(line_idx, format!(" {}", validate_call));
1048 std::fs::write(build_rs_path, new_lines.join("\n") + "\n")
1049 .context("Failed to write build.rs")?;
1050 return Ok(());
1051 }
1052 }
1053
1054 bail!(
1056 "Could not find fn main() in build.rs. Please add `{}` manually.",
1057 validate_call
1058 );
1059 } else {
1060 let content = format!("fn main() {{\n {}\n}}\n", validate_call);
1062 std::fs::write(build_rs_path, content).context("Failed to create build.rs")?;
1063 }
1064
1065 Ok(())
1066}
1067
1068fn generate_from_local(
1069 battery_pack: &str,
1070 local_path: &str,
1071 name: Option<String>,
1072 template: Option<String>,
1073 defines: std::collections::BTreeMap<String, String>,
1074) -> Result<()> {
1075 let local_path = Path::new(local_path);
1076
1077 let manifest_path = local_path.join("Cargo.toml");
1079 let manifest_content = std::fs::read_to_string(&manifest_path)
1080 .with_context(|| format!("Failed to read {}", manifest_path.display()))?;
1081
1082 let crate_name = local_path
1083 .file_name()
1084 .and_then(|s| s.to_str())
1085 .unwrap_or("unknown");
1086 let templates = parse_template_metadata(&manifest_content, crate_name)?;
1087 let template_path = resolve_template(&templates, template.as_deref())?;
1088
1089 generate_from_path(battery_pack, local_path, &template_path, name, defines)
1090}
1091
1092fn prompt_project_name(name: Option<String>) -> Result<String> {
1094 match name {
1095 Some(n) => Ok(n),
1096 None => dialoguer::Input::<String>::new()
1097 .with_prompt("Project name")
1098 .interact_text()
1099 .context("Failed to read project name"),
1100 }
1101}
1102
1103fn ensure_battery_pack_suffix(name: String) -> String {
1105 if name.ends_with("-battery-pack") {
1106 name
1107 } else {
1108 let fixed = format!("{}-battery-pack", name);
1109 println!("Renaming project to: {}", fixed);
1110 fixed
1111 }
1112}
1113
1114fn generate_from_path(
1115 battery_pack: &str,
1116 crate_path: &Path,
1117 template_path: &str,
1118 name: Option<String>,
1119 defines: std::collections::BTreeMap<String, String>,
1120) -> Result<()> {
1121 let raw = prompt_project_name(name)?;
1122 let project_name = if battery_pack == "battery-pack" {
1123 ensure_battery_pack_suffix(raw)
1124 } else {
1125 raw
1126 };
1127
1128 let opts = crate::template_engine::GenerateOpts {
1129 render: crate::template_engine::RenderOpts {
1130 crate_root: crate_path.to_path_buf(),
1131 template_path: template_path.to_string(),
1132 project_name,
1133 defines,
1134 },
1135 destination: None,
1136 git_init: true,
1137 };
1138
1139 crate::template_engine::generate(opts)?;
1140
1141 Ok(())
1142}
1143
1144fn parse_define(s: &str) -> Result<(String, String), String> {
1146 let (key, value) = s
1147 .split_once('=')
1148 .ok_or_else(|| format!("invalid define '{s}': expected key=value"))?;
1149 Ok((key.to_string(), value.to_string()))
1150}
1151
1152fn parse_template_metadata(
1153 manifest_content: &str,
1154 crate_name: &str,
1155) -> Result<BTreeMap<String, TemplateConfig>> {
1156 let spec = bphelper_manifest::parse_battery_pack(manifest_content)
1157 .map_err(|e| anyhow::anyhow!("Failed to parse Cargo.toml: {}", e))?;
1158
1159 if spec.templates.is_empty() {
1160 bail!(
1161 "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
1162 crate_name
1163 );
1164 }
1165
1166 Ok(spec.templates)
1167}
1168
1169pub(crate) fn resolve_template(
1172 templates: &BTreeMap<String, TemplateConfig>,
1173 requested: Option<&str>,
1174) -> Result<String> {
1175 match requested {
1176 Some(name) => {
1177 let config = templates.get(name).ok_or_else(|| {
1178 let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
1179 anyhow::anyhow!(
1180 "Template '{}' not found. Available templates: {}",
1181 name,
1182 available.join(", ")
1183 )
1184 })?;
1185 Ok(config.path.clone())
1186 }
1187 None => {
1188 if templates.len() == 1 {
1189 let (_, config) = templates.iter().next().unwrap();
1191 Ok(config.path.clone())
1192 } else if let Some(config) = templates.get("default") {
1193 Ok(config.path.clone())
1195 } else {
1196 prompt_for_template(templates)
1198 }
1199 }
1200 }
1201}
1202
1203fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
1204 use dialoguer::{Select, theme::ColorfulTheme};
1205
1206 let items: Vec<String> = templates
1208 .iter()
1209 .map(|(name, config)| {
1210 if let Some(desc) = &config.description {
1211 format!("{} - {}", name, desc)
1212 } else {
1213 name.clone()
1214 }
1215 })
1216 .collect();
1217
1218 if !std::io::stdout().is_terminal() {
1220 println!("Available templates:");
1222 for item in &items {
1223 println!(" {}", item);
1224 }
1225 bail!("Multiple templates available. Please specify one with --template <name>");
1226 }
1227
1228 let selection = Select::with_theme(&ColorfulTheme::default())
1230 .with_prompt("Select a template")
1231 .items(&items)
1232 .default(0)
1233 .interact()
1234 .context("Failed to select template")?;
1235
1236 let (_, config) = templates
1238 .iter()
1239 .nth(selection)
1240 .ok_or_else(|| anyhow::anyhow!("Invalid template selection"))?;
1241 Ok(config.path.clone())
1242}
1243
1244fn print_battery_pack_list(source: &CrateSource, filter: Option<&str>) -> Result<()> {
1245 use console::style;
1246
1247 let battery_packs = fetch_battery_pack_list(source, filter)?;
1248
1249 if battery_packs.is_empty() {
1250 match filter {
1251 Some(q) => println!("No battery packs found matching '{}'", q),
1252 None => println!("No battery packs found"),
1253 }
1254 return Ok(());
1255 }
1256
1257 let max_name_len = battery_packs
1259 .iter()
1260 .map(|c| c.short_name.len())
1261 .max()
1262 .unwrap_or(0);
1263
1264 let max_version_len = battery_packs
1265 .iter()
1266 .map(|c| c.version.len())
1267 .max()
1268 .unwrap_or(0);
1269
1270 println!();
1271 for bp in &battery_packs {
1272 let desc = bp.description.lines().next().unwrap_or("");
1273
1274 let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
1276 let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
1277
1278 println!(
1279 " {} {} {}",
1280 style(name_padded).green().bold(),
1281 style(ver_padded).dim(),
1282 desc,
1283 );
1284 }
1285 println!();
1286
1287 println!(
1288 "{}",
1289 style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
1290 );
1291
1292 Ok(())
1293}
1294
1295fn print_battery_pack_detail(name: &str, path: Option<&str>, source: &CrateSource) -> Result<()> {
1296 use console::style;
1297
1298 let detail = if path.is_some() {
1300 fetch_battery_pack_detail(name, path)?
1301 } else {
1302 fetch_battery_pack_detail_from_source(source, name)?
1303 };
1304
1305 println!();
1307 println!(
1308 "{} {}",
1309 style(&detail.name).green().bold(),
1310 style(&detail.version).dim()
1311 );
1312 if !detail.description.is_empty() {
1313 println!("{}", detail.description);
1314 }
1315
1316 if !detail.owners.is_empty() {
1318 println!();
1319 println!("{}", style("Authors:").bold());
1320 for owner in &detail.owners {
1321 if let Some(name) = &owner.name {
1322 println!(" {} ({})", name, owner.login);
1323 } else {
1324 println!(" {}", owner.login);
1325 }
1326 }
1327 }
1328
1329 if !detail.crates.is_empty() {
1331 println!();
1332 println!("{}", style("Crates:").bold());
1333 for dep in &detail.crates {
1334 println!(" {}", dep);
1335 }
1336 }
1337
1338 if !detail.extends.is_empty() {
1340 println!();
1341 println!("{}", style("Extends:").bold());
1342 for dep in &detail.extends {
1343 println!(" {}", dep);
1344 }
1345 }
1346
1347 if !detail.templates.is_empty() {
1349 println!();
1350 println!("{}", style("Templates:").bold());
1351 let max_name_len = detail
1352 .templates
1353 .iter()
1354 .map(|t| t.name.len())
1355 .max()
1356 .unwrap_or(0);
1357 for tmpl in &detail.templates {
1358 let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
1359 if let Some(desc) = &tmpl.description {
1360 println!(" {} {}", style(name_padded).cyan(), desc);
1361 } else {
1362 println!(" {}", style(name_padded).cyan());
1363 }
1364 }
1365 }
1366
1367 if !detail.examples.is_empty() {
1370 println!();
1371 println!("{}", style("Examples:").bold());
1372 let max_name_len = detail
1373 .examples
1374 .iter()
1375 .map(|e| e.name.len())
1376 .max()
1377 .unwrap_or(0);
1378 for example in &detail.examples {
1379 let name_padded = format!("{:<width$}", example.name, width = max_name_len);
1380 if let Some(desc) = &example.description {
1381 println!(" {} {}", style(name_padded).magenta(), desc);
1382 } else {
1383 println!(" {}", style(name_padded).magenta());
1384 }
1385 }
1386 }
1387
1388 println!();
1390 println!("{}", style("Install:").bold());
1391 println!(" cargo bp add {}", detail.short_name);
1392 println!(" cargo bp new {}", detail.short_name);
1393 println!();
1394
1395 Ok(())
1396}
1397
1398fn status_battery_packs(
1408 project_dir: &Path,
1409 path: Option<&str>,
1410 source: &CrateSource,
1411) -> Result<()> {
1412 use console::style;
1413
1414 let user_manifest_path =
1416 find_user_manifest(project_dir).context("are you inside a Rust project?")?;
1417 let user_manifest_content =
1418 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
1419
1420 let bp_names = find_installed_bp_names(&user_manifest_content)?;
1422 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
1423 let packs: Vec<InstalledPack> = bp_names
1424 .into_iter()
1425 .map(|bp_name| {
1426 let spec = load_installed_bp_spec(&bp_name, path, source)?;
1427 let active_features =
1428 read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
1429 Ok(InstalledPack {
1430 short_name: short_name(&bp_name).to_string(),
1431 version: spec.version.clone(),
1432 spec,
1433 name: bp_name,
1434 active_features,
1435 })
1436 })
1437 .collect::<Result<_>>()?;
1438
1439 if packs.is_empty() {
1440 println!("No battery packs installed.");
1441 return Ok(());
1442 }
1443
1444 let user_versions = collect_user_dep_versions(&user_manifest_path, &user_manifest_content)?;
1446
1447 let mut any_warnings = false;
1448
1449 for pack in &packs {
1450 println!(
1452 "{} ({})",
1453 style(&pack.short_name).bold(),
1454 style(&pack.version).dim(),
1455 );
1456
1457 let expected = pack.spec.resolve_for_features(&pack.active_features);
1459
1460 let mut pack_warnings = Vec::new();
1461 for (dep_name, dep_spec) in &expected {
1462 if dep_spec.version.is_empty() {
1463 continue;
1464 }
1465 if let Some(user_version) = user_versions.get(dep_name.as_str()) {
1466 if should_upgrade_version(user_version, &dep_spec.version) {
1468 pack_warnings.push((
1469 dep_name.as_str(),
1470 user_version.as_str(),
1471 dep_spec.version.as_str(),
1472 ));
1473 }
1474 }
1475 }
1476
1477 if pack_warnings.is_empty() {
1478 println!(" {} all dependencies up to date", style("✓").green());
1479 } else {
1480 any_warnings = true;
1481 for (dep, current, recommended) in &pack_warnings {
1482 println!(
1483 " {} {}: {} → {} recommended",
1484 style("⚠").yellow(),
1485 dep,
1486 style(current).red(),
1487 style(recommended).green(),
1488 );
1489 }
1490 }
1491 }
1492
1493 if any_warnings {
1494 println!();
1495 println!("Run {} to update.", style("cargo bp sync").bold());
1496 }
1497
1498 Ok(())
1499}
1500
1501pub(crate) fn collect_user_dep_versions(
1505 user_manifest_path: &Path,
1506 user_manifest_content: &str,
1507) -> Result<BTreeMap<String, String>> {
1508 let raw: toml::Value =
1509 toml::from_str(user_manifest_content).context("Failed to parse Cargo.toml")?;
1510
1511 let mut versions = BTreeMap::new();
1512
1513 let ws_versions = if let Some(ws_path) = find_workspace_manifest(user_manifest_path)? {
1515 let ws_content =
1516 std::fs::read_to_string(&ws_path).context("Failed to read workspace Cargo.toml")?;
1517 let ws_raw: toml::Value =
1518 toml::from_str(&ws_content).context("Failed to parse workspace Cargo.toml")?;
1519 extract_versions_from_table(
1520 ws_raw
1521 .get("workspace")
1522 .and_then(|w| w.get("dependencies"))
1523 .and_then(|d| d.as_table()),
1524 )
1525 } else {
1526 BTreeMap::new()
1527 };
1528
1529 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
1531 let table = raw.get(section).and_then(|d| d.as_table());
1532 let Some(table) = table else { continue };
1533 for (name, value) in table {
1534 if versions.contains_key(name) {
1535 continue; }
1537 if let Some(version) = extract_version_from_dep(value) {
1538 versions.insert(name.clone(), version);
1539 } else if is_workspace_ref(value) {
1540 if let Some(ws_ver) = ws_versions.get(name) {
1542 versions.insert(name.clone(), ws_ver.clone());
1543 }
1544 }
1545 }
1546 }
1547
1548 Ok(versions)
1549}
1550
1551fn extract_versions_from_table(
1553 table: Option<&toml::map::Map<String, toml::Value>>,
1554) -> BTreeMap<String, String> {
1555 let Some(table) = table else {
1556 return BTreeMap::new();
1557 };
1558 let mut versions = BTreeMap::new();
1559 for (name, value) in table {
1560 if let Some(version) = extract_version_from_dep(value) {
1561 versions.insert(name.clone(), version);
1562 }
1563 }
1564 versions
1565}
1566
1567fn extract_version_from_dep(value: &toml::Value) -> Option<String> {
1571 match value {
1572 toml::Value::String(s) => Some(s.clone()),
1573 toml::Value::Table(t) => t
1574 .get("version")
1575 .and_then(|v| v.as_str())
1576 .map(|s| s.to_string()),
1577 _ => None,
1578 }
1579}
1580
1581fn is_workspace_ref(value: &toml::Value) -> bool {
1583 match value {
1584 toml::Value::Table(t) => t
1585 .get("workspace")
1586 .and_then(|v| v.as_bool())
1587 .unwrap_or(false),
1588 _ => false,
1589 }
1590}
1591
1592#[cfg(test)]
1593mod tests;