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 interactive_override: None,
1135 },
1136 destination: None,
1137 git_init: true,
1138 };
1139
1140 crate::template_engine::generate(opts)?;
1141
1142 Ok(())
1143}
1144
1145fn parse_define(s: &str) -> Result<(String, String), String> {
1147 let (key, value) = s
1148 .split_once('=')
1149 .ok_or_else(|| format!("invalid define '{s}': expected key=value"))?;
1150 Ok((key.to_string(), value.to_string()))
1151}
1152
1153fn parse_template_metadata(
1154 manifest_content: &str,
1155 crate_name: &str,
1156) -> Result<BTreeMap<String, TemplateConfig>> {
1157 let spec = bphelper_manifest::parse_battery_pack(manifest_content)
1158 .map_err(|e| anyhow::anyhow!("Failed to parse Cargo.toml: {}", e))?;
1159
1160 if spec.templates.is_empty() {
1161 bail!(
1162 "Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
1163 crate_name
1164 );
1165 }
1166
1167 Ok(spec.templates)
1168}
1169
1170pub(crate) fn resolve_template(
1173 templates: &BTreeMap<String, TemplateConfig>,
1174 requested: Option<&str>,
1175) -> Result<String> {
1176 match requested {
1177 Some(name) => {
1178 let config = templates.get(name).ok_or_else(|| {
1179 let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
1180 anyhow::anyhow!(
1181 "Template '{}' not found. Available templates: {}",
1182 name,
1183 available.join(", ")
1184 )
1185 })?;
1186 Ok(config.path.clone())
1187 }
1188 None => {
1189 if templates.len() == 1 {
1190 let (_, config) = templates.iter().next().unwrap();
1192 Ok(config.path.clone())
1193 } else if let Some(config) = templates.get("default") {
1194 Ok(config.path.clone())
1196 } else {
1197 prompt_for_template(templates)
1199 }
1200 }
1201 }
1202}
1203
1204fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
1205 use dialoguer::{Select, theme::ColorfulTheme};
1206
1207 let items: Vec<String> = templates
1209 .iter()
1210 .map(|(name, config)| {
1211 if let Some(desc) = &config.description {
1212 format!("{} - {}", name, desc)
1213 } else {
1214 name.clone()
1215 }
1216 })
1217 .collect();
1218
1219 if !std::io::stdout().is_terminal() {
1221 println!("Available templates:");
1223 for item in &items {
1224 println!(" {}", item);
1225 }
1226 bail!("Multiple templates available. Please specify one with --template <name>");
1227 }
1228
1229 let selection = Select::with_theme(&ColorfulTheme::default())
1231 .with_prompt("Select a template")
1232 .items(&items)
1233 .default(0)
1234 .interact()
1235 .context("Failed to select template")?;
1236
1237 let (_, config) = templates
1239 .iter()
1240 .nth(selection)
1241 .ok_or_else(|| anyhow::anyhow!("Invalid template selection"))?;
1242 Ok(config.path.clone())
1243}
1244
1245fn print_battery_pack_list(source: &CrateSource, filter: Option<&str>) -> Result<()> {
1246 use console::style;
1247
1248 let battery_packs = fetch_battery_pack_list(source, filter)?;
1249
1250 if battery_packs.is_empty() {
1251 match filter {
1252 Some(q) => println!("No battery packs found matching '{}'", q),
1253 None => println!("No battery packs found"),
1254 }
1255 return Ok(());
1256 }
1257
1258 let max_name_len = battery_packs
1260 .iter()
1261 .map(|c| c.short_name.len())
1262 .max()
1263 .unwrap_or(0);
1264
1265 let max_version_len = battery_packs
1266 .iter()
1267 .map(|c| c.version.len())
1268 .max()
1269 .unwrap_or(0);
1270
1271 println!();
1272 for bp in &battery_packs {
1273 let desc = bp.description.lines().next().unwrap_or("");
1274
1275 let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
1277 let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
1278
1279 println!(
1280 " {} {} {}",
1281 style(name_padded).green().bold(),
1282 style(ver_padded).dim(),
1283 desc,
1284 );
1285 }
1286 println!();
1287
1288 println!(
1289 "{}",
1290 style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
1291 );
1292
1293 Ok(())
1294}
1295
1296fn print_battery_pack_detail(name: &str, path: Option<&str>, source: &CrateSource) -> Result<()> {
1297 use console::style;
1298
1299 let detail = if path.is_some() {
1301 fetch_battery_pack_detail(name, path)?
1302 } else {
1303 fetch_battery_pack_detail_from_source(source, name)?
1304 };
1305
1306 println!();
1308 println!(
1309 "{} {}",
1310 style(&detail.name).green().bold(),
1311 style(&detail.version).dim()
1312 );
1313 if !detail.description.is_empty() {
1314 println!("{}", detail.description);
1315 }
1316
1317 if !detail.owners.is_empty() {
1319 println!();
1320 println!("{}", style("Authors:").bold());
1321 for owner in &detail.owners {
1322 if let Some(name) = &owner.name {
1323 println!(" {} ({})", name, owner.login);
1324 } else {
1325 println!(" {}", owner.login);
1326 }
1327 }
1328 }
1329
1330 if !detail.crates.is_empty() {
1332 println!();
1333 println!("{}", style("Crates:").bold());
1334 for dep in &detail.crates {
1335 println!(" {}", dep);
1336 }
1337 }
1338
1339 if !detail.extends.is_empty() {
1341 println!();
1342 println!("{}", style("Extends:").bold());
1343 for dep in &detail.extends {
1344 println!(" {}", dep);
1345 }
1346 }
1347
1348 if !detail.templates.is_empty() {
1350 println!();
1351 println!("{}", style("Templates:").bold());
1352 let max_name_len = detail
1353 .templates
1354 .iter()
1355 .map(|t| t.name.len())
1356 .max()
1357 .unwrap_or(0);
1358 for tmpl in &detail.templates {
1359 let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
1360 if let Some(desc) = &tmpl.description {
1361 println!(" {} {}", style(name_padded).cyan(), desc);
1362 } else {
1363 println!(" {}", style(name_padded).cyan());
1364 }
1365 }
1366 }
1367
1368 if !detail.examples.is_empty() {
1371 println!();
1372 println!("{}", style("Examples:").bold());
1373 let max_name_len = detail
1374 .examples
1375 .iter()
1376 .map(|e| e.name.len())
1377 .max()
1378 .unwrap_or(0);
1379 for example in &detail.examples {
1380 let name_padded = format!("{:<width$}", example.name, width = max_name_len);
1381 if let Some(desc) = &example.description {
1382 println!(" {} {}", style(name_padded).magenta(), desc);
1383 } else {
1384 println!(" {}", style(name_padded).magenta());
1385 }
1386 }
1387 }
1388
1389 println!();
1391 println!("{}", style("Install:").bold());
1392 println!(" cargo bp add {}", detail.short_name);
1393 println!(" cargo bp new {}", detail.short_name);
1394 println!();
1395
1396 Ok(())
1397}
1398
1399fn status_battery_packs(
1409 project_dir: &Path,
1410 path: Option<&str>,
1411 source: &CrateSource,
1412) -> Result<()> {
1413 use console::style;
1414
1415 let user_manifest_path =
1417 find_user_manifest(project_dir).context("are you inside a Rust project?")?;
1418 let user_manifest_content =
1419 std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
1420
1421 let bp_names = find_installed_bp_names(&user_manifest_content)?;
1423 let metadata_location = resolve_metadata_location(&user_manifest_path)?;
1424 let packs: Vec<InstalledPack> = bp_names
1425 .into_iter()
1426 .map(|bp_name| {
1427 let spec = load_installed_bp_spec(&bp_name, path, source)?;
1428 let active_features =
1429 read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
1430 Ok(InstalledPack {
1431 short_name: short_name(&bp_name).to_string(),
1432 version: spec.version.clone(),
1433 spec,
1434 name: bp_name,
1435 active_features,
1436 })
1437 })
1438 .collect::<Result<_>>()?;
1439
1440 if packs.is_empty() {
1441 println!("No battery packs installed.");
1442 return Ok(());
1443 }
1444
1445 let user_versions = collect_user_dep_versions(&user_manifest_path, &user_manifest_content)?;
1447
1448 let mut any_warnings = false;
1449
1450 for pack in &packs {
1451 println!(
1453 "{} ({})",
1454 style(&pack.short_name).bold(),
1455 style(&pack.version).dim(),
1456 );
1457
1458 let expected = pack.spec.resolve_for_features(&pack.active_features);
1460
1461 let mut pack_warnings = Vec::new();
1462 for (dep_name, dep_spec) in &expected {
1463 if dep_spec.version.is_empty() {
1464 continue;
1465 }
1466 if let Some(user_version) = user_versions.get(dep_name.as_str()) {
1467 if should_upgrade_version(user_version, &dep_spec.version) {
1469 pack_warnings.push((
1470 dep_name.as_str(),
1471 user_version.as_str(),
1472 dep_spec.version.as_str(),
1473 ));
1474 }
1475 }
1476 }
1477
1478 if pack_warnings.is_empty() {
1479 println!(" {} all dependencies up to date", style("✓").green());
1480 } else {
1481 any_warnings = true;
1482 for (dep, current, recommended) in &pack_warnings {
1483 println!(
1484 " {} {}: {} → {} recommended",
1485 style("⚠").yellow(),
1486 dep,
1487 style(current).red(),
1488 style(recommended).green(),
1489 );
1490 }
1491 }
1492 }
1493
1494 if any_warnings {
1495 println!();
1496 println!("Run {} to update.", style("cargo bp sync").bold());
1497 }
1498
1499 Ok(())
1500}
1501
1502pub(crate) fn collect_user_dep_versions(
1506 user_manifest_path: &Path,
1507 user_manifest_content: &str,
1508) -> Result<BTreeMap<String, String>> {
1509 let raw: toml::Value =
1510 toml::from_str(user_manifest_content).context("Failed to parse Cargo.toml")?;
1511
1512 let mut versions = BTreeMap::new();
1513
1514 let ws_versions = if let Some(ws_path) = find_workspace_manifest(user_manifest_path)? {
1516 let ws_content =
1517 std::fs::read_to_string(&ws_path).context("Failed to read workspace Cargo.toml")?;
1518 let ws_raw: toml::Value =
1519 toml::from_str(&ws_content).context("Failed to parse workspace Cargo.toml")?;
1520 extract_versions_from_table(
1521 ws_raw
1522 .get("workspace")
1523 .and_then(|w| w.get("dependencies"))
1524 .and_then(|d| d.as_table()),
1525 )
1526 } else {
1527 BTreeMap::new()
1528 };
1529
1530 for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
1532 let table = raw.get(section).and_then(|d| d.as_table());
1533 let Some(table) = table else { continue };
1534 for (name, value) in table {
1535 if versions.contains_key(name) {
1536 continue; }
1538 if let Some(version) = extract_version_from_dep(value) {
1539 versions.insert(name.clone(), version);
1540 } else if is_workspace_ref(value) {
1541 if let Some(ws_ver) = ws_versions.get(name) {
1543 versions.insert(name.clone(), ws_ver.clone());
1544 }
1545 }
1546 }
1547 }
1548
1549 Ok(versions)
1550}
1551
1552fn extract_versions_from_table(
1554 table: Option<&toml::map::Map<String, toml::Value>>,
1555) -> BTreeMap<String, String> {
1556 let Some(table) = table else {
1557 return BTreeMap::new();
1558 };
1559 let mut versions = BTreeMap::new();
1560 for (name, value) in table {
1561 if let Some(version) = extract_version_from_dep(value) {
1562 versions.insert(name.clone(), version);
1563 }
1564 }
1565 versions
1566}
1567
1568fn extract_version_from_dep(value: &toml::Value) -> Option<String> {
1572 match value {
1573 toml::Value::String(s) => Some(s.clone()),
1574 toml::Value::Table(t) => t
1575 .get("version")
1576 .and_then(|v| v.as_str())
1577 .map(|s| s.to_string()),
1578 _ => None,
1579 }
1580}
1581
1582fn is_workspace_ref(value: &toml::Value) -> bool {
1584 match value {
1585 toml::Value::Table(t) => t
1586 .get("workspace")
1587 .and_then(|v| v.as_bool())
1588 .unwrap_or(false),
1589 _ => false,
1590 }
1591}
1592
1593#[cfg(test)]
1594mod tests;