use anyhow::{Context, Result, bail};
use clap::{Parser, Subcommand};
use std::collections::{BTreeMap, BTreeSet};
use std::io::IsTerminal;
use std::path::{Path, PathBuf};
use crate::manifest::{
MetadataLocation, add_dep_to_table, dep_kind_section, find_installed_bp_names,
find_user_manifest, find_workspace_manifest, read_active_features_from,
resolve_metadata_location, should_upgrade_version, sync_dep_in_table, write_bp_features_to_doc,
write_deps_by_kind, write_workspace_refs_by_kind,
};
use crate::registry::{
CrateSource, InstalledPack, TemplateConfig, download_and_extract_crate,
fetch_battery_pack_detail, fetch_battery_pack_detail_from_source, fetch_battery_pack_list,
fetch_battery_pack_spec, fetch_bp_spec, find_local_battery_pack_dir, load_installed_bp_spec,
lookup_crate, resolve_crate_name, short_name,
};
#[derive(Parser)]
#[command(name = "cargo-bp")]
#[command(bin_name = "cargo")]
#[command(version, about = "Create and manage battery packs", long_about = None)]
pub(crate) struct Cli {
#[command(subcommand)]
pub command: Commands,
}
#[derive(Subcommand)]
pub(crate) enum Commands {
Bp {
#[arg(long)]
crate_source: Option<PathBuf>,
#[command(subcommand)]
command: Option<BpCommands>,
},
}
#[derive(Subcommand)]
pub(crate) enum BpCommands {
New {
battery_pack: String,
#[arg(long, short = 'n')]
name: Option<String>,
#[arg(long, short = 't')]
template: Option<String>,
#[arg(long)]
path: Option<String>,
#[arg(long = "define", short = 'd', value_parser = parse_define)]
define: Vec<(String, String)>,
},
Add {
battery_pack: Option<String>,
crates: Vec<String>,
#[arg(long = "features", short = 'F', value_delimiter = ',')]
features: Vec<String>,
#[arg(long)]
no_default_features: bool,
#[arg(long)]
all_features: bool,
#[arg(long)]
target: Option<AddTarget>,
#[arg(long)]
path: Option<String>,
},
Sync {
#[arg(long)]
path: Option<String>,
},
Enable {
feature_name: String,
#[arg(long)]
battery_pack: Option<String>,
},
#[command(visible_alias = "ls")]
List {
filter: Option<String>,
#[arg(long)]
non_interactive: bool,
},
#[command(visible_alias = "info")]
Show {
battery_pack: String,
#[arg(long)]
path: Option<String>,
#[arg(long)]
non_interactive: bool,
},
#[command(visible_alias = "stat")]
Status {
#[arg(long)]
path: Option<String>,
},
Validate {
#[arg(long)]
path: Option<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum AddTarget {
Workspace,
Package,
Default,
}
pub fn main() -> Result<()> {
let cli = Cli::parse();
let project_dir = std::env::current_dir().context("Failed to get current directory")?;
let interactive = std::io::stdout().is_terminal();
match cli.command {
Commands::Bp {
crate_source,
command,
} => {
let source = match crate_source {
Some(path) => CrateSource::Local(path),
None => CrateSource::Registry,
};
let Some(command) = command else {
if interactive {
return crate::tui::run_add(source);
} else {
bail!(
"No subcommand specified. Use `cargo bp --help` or run interactively in a terminal."
);
}
};
match command {
BpCommands::New {
battery_pack,
name,
template,
path,
define,
} => new_from_battery_pack(&battery_pack, name, template, path, &source, &define),
BpCommands::Add {
battery_pack,
crates,
features,
no_default_features,
all_features,
target,
path,
} => match battery_pack {
Some(name) => add_battery_pack(
&name,
&features,
no_default_features,
all_features,
&crates,
target,
path.as_deref(),
&source,
&project_dir,
),
None if interactive => crate::tui::run_add(source),
None => {
bail!(
"No battery pack specified. Use `cargo bp add <name>` or run interactively in a terminal."
)
}
},
BpCommands::Sync { path } => {
sync_battery_packs(&project_dir, path.as_deref(), &source)
}
BpCommands::Enable {
feature_name,
battery_pack,
} => enable_feature(&feature_name, battery_pack.as_deref(), &project_dir),
BpCommands::List {
filter,
non_interactive,
} => {
if !non_interactive && interactive {
crate::tui::run_list(source, filter)
} else {
print_battery_pack_list(&source, filter.as_deref())
}
}
BpCommands::Show {
battery_pack,
path,
non_interactive,
} => {
if !non_interactive && interactive {
crate::tui::run_show(&battery_pack, path.as_deref(), source)
} else {
print_battery_pack_detail(&battery_pack, path.as_deref(), &source)
}
}
BpCommands::Status { path } => {
status_battery_packs(&project_dir, path.as_deref(), &source)
}
BpCommands::Validate { path } => {
crate::validate::validate_battery_pack_cmd(path.as_deref())
}
}
}
}
}
fn new_from_battery_pack(
battery_pack: &str,
name: Option<String>,
template: Option<String>,
path_override: Option<String>,
source: &CrateSource,
define: &[(String, String)],
) -> Result<()> {
let defines: std::collections::BTreeMap<String, String> = define.iter().cloned().collect();
if let Some(path) = path_override {
return generate_from_local(battery_pack, &path, name, template, defines);
}
let crate_name = resolve_crate_name(battery_pack);
let crate_dir: PathBuf;
let _temp_dir: Option<tempfile::TempDir>; match source {
CrateSource::Registry => {
let crate_info = lookup_crate(&crate_name)?;
let temp = download_and_extract_crate(&crate_name, &crate_info.version)?;
crate_dir = temp
.path()
.join(format!("{}-{}", crate_name, crate_info.version));
_temp_dir = Some(temp);
}
CrateSource::Local(workspace_dir) => {
crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
_temp_dir = None;
}
}
let manifest_path = crate_dir.join("Cargo.toml");
let manifest_content = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("Failed to read {}", manifest_path.display()))?;
let templates = parse_template_metadata(&manifest_content, &crate_name)?;
let template_path = resolve_template(&templates, template.as_deref())?;
generate_from_path(battery_pack, &crate_dir, &template_path, name, defines)
}
pub(crate) enum ResolvedAdd {
Crates {
active_features: BTreeSet<String>,
crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
},
Interactive,
}
pub(crate) fn resolve_add_crates(
bp_spec: &bphelper_manifest::BatteryPackSpec,
bp_name: &str,
with_features: &[String],
no_default_features: bool,
all_features: bool,
specific_crates: &[String],
) -> ResolvedAdd {
if !specific_crates.is_empty() {
let mut selected = BTreeMap::new();
for crate_name_arg in specific_crates {
if let Some(spec) = bp_spec.crates.get(crate_name_arg.as_str()) {
selected.insert(crate_name_arg.clone(), spec.clone());
} else {
eprintln!(
"error: crate '{}' not found in battery pack '{}'",
crate_name_arg, bp_name
);
}
}
return ResolvedAdd::Crates {
active_features: BTreeSet::new(),
crates: selected,
};
}
if all_features {
return ResolvedAdd::Crates {
active_features: BTreeSet::from(["all".to_string()]),
crates: bp_spec.resolve_all_visible(),
};
}
if !no_default_features && with_features.is_empty() && bp_spec.has_meaningful_choices() {
return ResolvedAdd::Interactive;
}
let mut features: BTreeSet<String> = if no_default_features {
BTreeSet::new()
} else {
BTreeSet::from(["default".to_string()])
};
features.extend(with_features.iter().cloned());
if features.is_empty() {
return ResolvedAdd::Crates {
active_features: features,
crates: BTreeMap::new(),
};
}
let str_features: Vec<&str> = features.iter().map(|s| s.as_str()).collect();
let crates = bp_spec.resolve_crates(&str_features);
ResolvedAdd::Crates {
active_features: features,
crates,
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn add_battery_pack(
name: &str,
with_features: &[String],
no_default_features: bool,
all_features: bool,
specific_crates: &[String],
target: Option<AddTarget>,
path: Option<&str>,
source: &CrateSource,
project_dir: &Path,
) -> Result<()> {
let crate_name = resolve_crate_name(name);
let (bp_version, bp_spec) = if let Some(local_path) = path {
let manifest_path = Path::new(local_path).join("Cargo.toml");
let manifest_content = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("Failed to read {}", manifest_path.display()))?;
let spec = bphelper_manifest::parse_battery_pack(&manifest_content)
.map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e))?;
(None, spec)
} else {
fetch_bp_spec(source, name)?
};
let resolved = resolve_add_crates(
&bp_spec,
&crate_name,
with_features,
no_default_features,
all_features,
specific_crates,
);
let (active_features, crates_to_sync) = match resolved {
ResolvedAdd::Crates {
active_features,
crates,
} => (active_features, crates),
ResolvedAdd::Interactive if std::io::stdout().is_terminal() => {
match pick_crates_interactive(&bp_spec)? {
Some(result) => (result.active_features, result.crates),
None => {
println!("Cancelled.");
return Ok(());
}
}
}
ResolvedAdd::Interactive => {
let crates = bp_spec.resolve_crates(&["default"]);
(BTreeSet::from(["default".to_string()]), crates)
}
};
if crates_to_sync.is_empty() {
println!("No crates selected.");
return Ok(());
}
let user_manifest_path = find_user_manifest(project_dir)?;
let user_manifest_content =
std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
let mut user_doc: toml_edit::DocumentMut = user_manifest_content
.parse()
.context("Failed to parse Cargo.toml")?;
let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
let build_deps =
user_doc["build-dependencies"].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
if let Some(table) = build_deps.as_table_mut() {
if let Some(local_path) = path {
let mut dep = toml_edit::InlineTable::new();
dep.insert("path", toml_edit::Value::from(local_path));
table.insert(
&crate_name,
toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
);
} else if workspace_manifest.is_some() {
let mut dep = toml_edit::InlineTable::new();
dep.insert("workspace", toml_edit::Value::from(true));
table.insert(
&crate_name,
toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
);
} else {
let version = bp_version
.as_ref()
.context("battery pack version not available (--path without workspace)")?;
table.insert(&crate_name, toml_edit::value(version));
}
}
let mut ws_doc: Option<toml_edit::DocumentMut> = if let Some(ref ws_path) = workspace_manifest {
let ws_content =
std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
Some(
ws_content
.parse()
.context("Failed to parse workspace Cargo.toml")?,
)
} else {
None
};
if let Some(ref mut doc) = ws_doc {
let ws_deps = doc["workspace"]["dependencies"]
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
if let Some(ws_table) = ws_deps.as_table_mut() {
if let Some(local_path) = path {
let mut dep = toml_edit::InlineTable::new();
dep.insert("path", toml_edit::Value::from(local_path));
ws_table.insert(
&crate_name,
toml_edit::Item::Value(toml_edit::Value::InlineTable(dep)),
);
} else {
let version = bp_version
.as_ref()
.context("battery pack version not available (--path without workspace)")?;
ws_table.insert(&crate_name, toml_edit::value(version));
}
for (dep_name, dep_spec) in &crates_to_sync {
add_dep_to_table(ws_table, dep_name, dep_spec);
}
}
write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, false);
} else {
write_deps_by_kind(&mut user_doc, &crates_to_sync, false);
}
let use_workspace_metadata = match target {
Some(AddTarget::Workspace) => true,
Some(AddTarget::Package) => false,
Some(AddTarget::Default) | None => workspace_manifest.is_some(),
};
if use_workspace_metadata {
if let Some(ref mut doc) = ws_doc {
write_bp_features_to_doc(
doc,
&["workspace", "metadata"],
&crate_name,
&active_features,
);
} else {
bail!("--target=workspace requires a workspace, but none was found");
}
} else {
write_bp_features_to_doc(
&mut user_doc,
&["package", "metadata"],
&crate_name,
&active_features,
);
}
if let (Some(ws_path), Some(doc)) = (&workspace_manifest, &ws_doc) {
std::fs::write(ws_path, doc.to_string()).context("Failed to write workspace Cargo.toml")?;
}
std::fs::write(&user_manifest_path, user_doc.to_string())
.context("Failed to write Cargo.toml")?;
let build_rs_path = user_manifest_path
.parent()
.unwrap_or(Path::new("."))
.join("build.rs");
update_build_rs(&build_rs_path, &crate_name)?;
println!(
"Added {} with {} crate(s)",
crate_name,
crates_to_sync.len()
);
for dep_name in crates_to_sync.keys() {
println!(" + {}", dep_name);
}
Ok(())
}
fn sync_battery_packs(project_dir: &Path, path: Option<&str>, source: &CrateSource) -> Result<()> {
let user_manifest_path = find_user_manifest(project_dir)?;
let user_manifest_content =
std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
let bp_names = find_installed_bp_names(&user_manifest_content)?;
if bp_names.is_empty() {
println!("No battery packs installed.");
return Ok(());
}
let mut user_doc: toml_edit::DocumentMut = user_manifest_content
.parse()
.context("Failed to parse Cargo.toml")?;
let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
let metadata_location = resolve_metadata_location(&user_manifest_path)?;
let mut total_changes = 0;
for bp_name in &bp_names {
let bp_spec = load_installed_bp_spec(bp_name, path, source)?;
let active_features =
read_active_features_from(&metadata_location, &user_manifest_content, bp_name);
let expected = bp_spec.resolve_for_features(&active_features);
if let Some(ref ws_path) = workspace_manifest {
let ws_content =
std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
let mut ws_doc: toml_edit::DocumentMut = ws_content
.parse()
.context("Failed to parse workspace Cargo.toml")?;
let ws_deps = ws_doc["workspace"]["dependencies"]
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
if let Some(ws_table) = ws_deps.as_table_mut() {
for (dep_name, dep_spec) in &expected {
if sync_dep_in_table(ws_table, dep_name, dep_spec) {
total_changes += 1;
println!(" ~ {} (updated in workspace)", dep_name);
}
}
}
std::fs::write(ws_path, ws_doc.to_string())
.context("Failed to write workspace Cargo.toml")?;
let refs_added = write_workspace_refs_by_kind(&mut user_doc, &expected, true);
total_changes += refs_added;
} else {
for (dep_name, dep_spec) in &expected {
let section = dep_kind_section(dep_spec.dep_kind);
let table =
user_doc[section].or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
if let Some(table) = table.as_table_mut() {
if !table.contains_key(dep_name) {
add_dep_to_table(table, dep_name, dep_spec);
total_changes += 1;
println!(" + {}", dep_name);
} else if sync_dep_in_table(table, dep_name, dep_spec) {
total_changes += 1;
println!(" ~ {}", dep_name);
}
}
}
}
}
std::fs::write(&user_manifest_path, user_doc.to_string())
.context("Failed to write Cargo.toml")?;
if total_changes == 0 {
println!("All dependencies are up to date.");
} else {
println!("Synced {} change(s).", total_changes);
}
Ok(())
}
fn enable_feature(
feature_name: &str,
battery_pack: Option<&str>,
project_dir: &Path,
) -> Result<()> {
let user_manifest_path = find_user_manifest(project_dir)?;
let user_manifest_content =
std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
let bp_name = if let Some(name) = battery_pack {
resolve_crate_name(name)
} else {
let bp_names = find_installed_bp_names(&user_manifest_content)?;
let mut found = None;
for name in &bp_names {
let spec = fetch_battery_pack_spec(name)?;
if spec.features.contains_key(feature_name) {
found = Some(name.clone());
break;
}
}
found.ok_or_else(|| {
anyhow::anyhow!(
"No installed battery pack defines feature '{}'",
feature_name
)
})?
};
let bp_spec = fetch_battery_pack_spec(&bp_name)?;
if !bp_spec.features.contains_key(feature_name) {
let available: Vec<_> = bp_spec.features.keys().collect();
bail!(
"Battery pack '{}' has no feature '{}'. Available: {:?}",
bp_name,
feature_name,
available
);
}
let metadata_location = resolve_metadata_location(&user_manifest_path)?;
let mut active_features =
read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
if active_features.contains(feature_name) {
println!(
"Feature '{}' is already active for {}.",
feature_name, bp_name
);
return Ok(());
}
active_features.insert(feature_name.to_string());
let str_features: Vec<&str> = active_features.iter().map(|s| s.as_str()).collect();
let crates_to_sync = bp_spec.resolve_crates(&str_features);
let mut user_doc: toml_edit::DocumentMut = user_manifest_content
.parse()
.context("Failed to parse Cargo.toml")?;
let workspace_manifest = find_workspace_manifest(&user_manifest_path)?;
if let Some(ref ws_path) = workspace_manifest {
let ws_content =
std::fs::read_to_string(ws_path).context("Failed to read workspace Cargo.toml")?;
let mut ws_doc: toml_edit::DocumentMut = ws_content
.parse()
.context("Failed to parse workspace Cargo.toml")?;
let ws_deps = ws_doc["workspace"]["dependencies"]
.or_insert(toml_edit::Item::Table(toml_edit::Table::new()));
if let Some(ws_table) = ws_deps.as_table_mut() {
for (dep_name, dep_spec) in &crates_to_sync {
add_dep_to_table(ws_table, dep_name, dep_spec);
}
}
if matches!(metadata_location, MetadataLocation::Workspace { .. }) {
write_bp_features_to_doc(
&mut ws_doc,
&["workspace", "metadata"],
&bp_name,
&active_features,
);
}
std::fs::write(ws_path, ws_doc.to_string())
.context("Failed to write workspace Cargo.toml")?;
write_workspace_refs_by_kind(&mut user_doc, &crates_to_sync, true);
} else {
write_deps_by_kind(&mut user_doc, &crates_to_sync, true);
}
if matches!(metadata_location, MetadataLocation::Package) {
write_bp_features_to_doc(
&mut user_doc,
&["package", "metadata"],
&bp_name,
&active_features,
);
}
std::fs::write(&user_manifest_path, user_doc.to_string())
.context("Failed to write Cargo.toml")?;
println!("Enabled feature '{}' from {}", feature_name, bp_name);
Ok(())
}
struct PickerResult {
crates: BTreeMap<String, bphelper_manifest::CrateSpec>,
active_features: BTreeSet<String>,
}
fn pick_crates_interactive(
bp_spec: &bphelper_manifest::BatteryPackSpec,
) -> Result<Option<PickerResult>> {
use console::style;
use dialoguer::MultiSelect;
let grouped = bp_spec.all_crates_with_grouping();
if grouped.is_empty() {
bail!("Battery pack has no crates to add");
}
let mut labels = Vec::new();
let mut defaults = Vec::new();
for (group, crate_name, dep, is_default) in &grouped {
let version_info = if dep.features.is_empty() {
format!("({})", dep.version)
} else {
format!(
"({}, features: {})",
dep.version,
dep.features
.iter()
.map(|s| s.as_str())
.collect::<Vec<_>>()
.join(", ")
)
};
let group_label = if group == "default" {
String::new()
} else {
format!(" [{}]", group)
};
labels.push(format!(
"{} {}{}",
crate_name,
style(&version_info).dim(),
style(&group_label).cyan()
));
defaults.push(*is_default);
}
println!();
println!(
" {} v{}",
style(&bp_spec.name).green().bold(),
style(&bp_spec.version).dim()
);
println!();
let selections = MultiSelect::new()
.with_prompt("Select crates to add")
.items(&labels)
.defaults(&defaults)
.interact_opt()
.context("Failed to show crate picker")?;
let Some(selected_indices) = selections else {
return Ok(None); };
let mut crates = BTreeMap::new();
for idx in &selected_indices {
let (_group, crate_name, dep, _) = &grouped[*idx];
let merged = (*dep).clone();
crates.insert(crate_name.clone(), merged);
}
let mut active_features = BTreeSet::from(["default".to_string()]);
for (feature_name, feature_crates) in &bp_spec.features {
if feature_name == "default" {
continue;
}
let all_selected = feature_crates.iter().all(|c| crates.contains_key(c));
if all_selected {
active_features.insert(feature_name.clone());
}
}
Ok(Some(PickerResult {
crates,
active_features,
}))
}
fn update_build_rs(build_rs_path: &Path, crate_name: &str) -> Result<()> {
let crate_ident = crate_name.replace('-', "_");
let validate_call = format!("{}::validate();", crate_ident);
if build_rs_path.exists() {
let content = std::fs::read_to_string(build_rs_path).context("Failed to read build.rs")?;
if content.contains(&validate_call) {
return Ok(());
}
let file: syn::File = syn::parse_str(&content).context("Failed to parse build.rs")?;
let has_main = file
.items
.iter()
.any(|item| matches!(item, syn::Item::Fn(func) if func.sig.ident == "main"));
if has_main {
let lines: Vec<&str> = content.lines().collect();
let mut insert_line = None;
let mut brace_depth: i32 = 0;
let mut in_main = false;
for (i, line) in lines.iter().enumerate() {
if line.contains("fn main") {
in_main = true;
brace_depth = 0;
}
if in_main {
for ch in line.chars() {
if ch == '{' {
brace_depth += 1;
} else if ch == '}' {
brace_depth -= 1;
if brace_depth == 0 {
insert_line = Some(i);
in_main = false;
break;
}
}
}
}
}
if let Some(line_idx) = insert_line {
let mut new_lines: Vec<String> = lines.iter().map(|l| l.to_string()).collect();
new_lines.insert(line_idx, format!(" {}", validate_call));
std::fs::write(build_rs_path, new_lines.join("\n") + "\n")
.context("Failed to write build.rs")?;
return Ok(());
}
}
bail!(
"Could not find fn main() in build.rs. Please add `{}` manually.",
validate_call
);
} else {
let content = format!("fn main() {{\n {}\n}}\n", validate_call);
std::fs::write(build_rs_path, content).context("Failed to create build.rs")?;
}
Ok(())
}
fn generate_from_local(
battery_pack: &str,
local_path: &str,
name: Option<String>,
template: Option<String>,
defines: std::collections::BTreeMap<String, String>,
) -> Result<()> {
let local_path = Path::new(local_path);
let manifest_path = local_path.join("Cargo.toml");
let manifest_content = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("Failed to read {}", manifest_path.display()))?;
let crate_name = local_path
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("unknown");
let templates = parse_template_metadata(&manifest_content, crate_name)?;
let template_path = resolve_template(&templates, template.as_deref())?;
generate_from_path(battery_pack, local_path, &template_path, name, defines)
}
fn prompt_project_name(name: Option<String>) -> Result<String> {
match name {
Some(n) => Ok(n),
None => dialoguer::Input::<String>::new()
.with_prompt("Project name")
.interact_text()
.context("Failed to read project name"),
}
}
fn ensure_battery_pack_suffix(name: String) -> String {
if name.ends_with("-battery-pack") {
name
} else {
let fixed = format!("{}-battery-pack", name);
println!("Renaming project to: {}", fixed);
fixed
}
}
fn generate_from_path(
battery_pack: &str,
crate_path: &Path,
template_path: &str,
name: Option<String>,
defines: std::collections::BTreeMap<String, String>,
) -> Result<()> {
let raw = prompt_project_name(name)?;
let project_name = if battery_pack == "battery-pack" {
ensure_battery_pack_suffix(raw)
} else {
raw
};
let opts = crate::template_engine::GenerateOpts {
render: crate::template_engine::RenderOpts {
crate_root: crate_path.to_path_buf(),
template_path: template_path.to_string(),
project_name,
defines,
interactive_override: None,
},
destination: None,
git_init: true,
};
crate::template_engine::generate(opts)?;
Ok(())
}
fn parse_define(s: &str) -> Result<(String, String), String> {
let (key, value) = s
.split_once('=')
.ok_or_else(|| format!("invalid define '{s}': expected key=value"))?;
Ok((key.to_string(), value.to_string()))
}
fn parse_template_metadata(
manifest_content: &str,
crate_name: &str,
) -> Result<BTreeMap<String, TemplateConfig>> {
let spec = bphelper_manifest::parse_battery_pack(manifest_content)
.map_err(|e| anyhow::anyhow!("Failed to parse Cargo.toml: {}", e))?;
if spec.templates.is_empty() {
bail!(
"Battery pack '{}' has no templates defined in [package.metadata.battery.templates]",
crate_name
);
}
Ok(spec.templates)
}
pub(crate) fn resolve_template(
templates: &BTreeMap<String, TemplateConfig>,
requested: Option<&str>,
) -> Result<String> {
match requested {
Some(name) => {
let config = templates.get(name).ok_or_else(|| {
let available: Vec<_> = templates.keys().map(|s| s.as_str()).collect();
anyhow::anyhow!(
"Template '{}' not found. Available templates: {}",
name,
available.join(", ")
)
})?;
Ok(config.path.clone())
}
None => {
if templates.len() == 1 {
let (_, config) = templates.iter().next().unwrap();
Ok(config.path.clone())
} else if let Some(config) = templates.get("default") {
Ok(config.path.clone())
} else {
prompt_for_template(templates)
}
}
}
}
fn prompt_for_template(templates: &BTreeMap<String, TemplateConfig>) -> Result<String> {
use dialoguer::{Select, theme::ColorfulTheme};
let items: Vec<String> = templates
.iter()
.map(|(name, config)| {
if let Some(desc) = &config.description {
format!("{} - {}", name, desc)
} else {
name.clone()
}
})
.collect();
if !std::io::stdout().is_terminal() {
println!("Available templates:");
for item in &items {
println!(" {}", item);
}
bail!("Multiple templates available. Please specify one with --template <name>");
}
let selection = Select::with_theme(&ColorfulTheme::default())
.with_prompt("Select a template")
.items(&items)
.default(0)
.interact()
.context("Failed to select template")?;
let (_, config) = templates
.iter()
.nth(selection)
.ok_or_else(|| anyhow::anyhow!("Invalid template selection"))?;
Ok(config.path.clone())
}
fn print_battery_pack_list(source: &CrateSource, filter: Option<&str>) -> Result<()> {
use console::style;
let battery_packs = fetch_battery_pack_list(source, filter)?;
if battery_packs.is_empty() {
match filter {
Some(q) => println!("No battery packs found matching '{}'", q),
None => println!("No battery packs found"),
}
return Ok(());
}
let max_name_len = battery_packs
.iter()
.map(|c| c.short_name.len())
.max()
.unwrap_or(0);
let max_version_len = battery_packs
.iter()
.map(|c| c.version.len())
.max()
.unwrap_or(0);
println!();
for bp in &battery_packs {
let desc = bp.description.lines().next().unwrap_or("");
let name_padded = format!("{:<width$}", bp.short_name, width = max_name_len);
let ver_padded = format!("{:<width$}", bp.version, width = max_version_len);
println!(
" {} {} {}",
style(name_padded).green().bold(),
style(ver_padded).dim(),
desc,
);
}
println!();
println!(
"{}",
style(format!("Found {} battery pack(s)", battery_packs.len())).dim()
);
Ok(())
}
fn print_battery_pack_detail(name: &str, path: Option<&str>, source: &CrateSource) -> Result<()> {
use console::style;
let detail = if path.is_some() {
fetch_battery_pack_detail(name, path)?
} else {
fetch_battery_pack_detail_from_source(source, name)?
};
println!();
println!(
"{} {}",
style(&detail.name).green().bold(),
style(&detail.version).dim()
);
if !detail.description.is_empty() {
println!("{}", detail.description);
}
if !detail.owners.is_empty() {
println!();
println!("{}", style("Authors:").bold());
for owner in &detail.owners {
if let Some(name) = &owner.name {
println!(" {} ({})", name, owner.login);
} else {
println!(" {}", owner.login);
}
}
}
if !detail.crates.is_empty() {
println!();
println!("{}", style("Crates:").bold());
for dep in &detail.crates {
println!(" {}", dep);
}
}
if !detail.extends.is_empty() {
println!();
println!("{}", style("Extends:").bold());
for dep in &detail.extends {
println!(" {}", dep);
}
}
if !detail.templates.is_empty() {
println!();
println!("{}", style("Templates:").bold());
let max_name_len = detail
.templates
.iter()
.map(|t| t.name.len())
.max()
.unwrap_or(0);
for tmpl in &detail.templates {
let name_padded = format!("{:<width$}", tmpl.name, width = max_name_len);
if let Some(desc) = &tmpl.description {
println!(" {} {}", style(name_padded).cyan(), desc);
} else {
println!(" {}", style(name_padded).cyan());
}
}
}
if !detail.examples.is_empty() {
println!();
println!("{}", style("Examples:").bold());
let max_name_len = detail
.examples
.iter()
.map(|e| e.name.len())
.max()
.unwrap_or(0);
for example in &detail.examples {
let name_padded = format!("{:<width$}", example.name, width = max_name_len);
if let Some(desc) = &example.description {
println!(" {} {}", style(name_padded).magenta(), desc);
} else {
println!(" {}", style(name_padded).magenta());
}
}
}
println!();
println!("{}", style("Install:").bold());
println!(" cargo bp add {}", detail.short_name);
println!(" cargo bp new {}", detail.short_name);
println!();
Ok(())
}
fn status_battery_packs(
project_dir: &Path,
path: Option<&str>,
source: &CrateSource,
) -> Result<()> {
use console::style;
let user_manifest_path =
find_user_manifest(project_dir).context("are you inside a Rust project?")?;
let user_manifest_content =
std::fs::read_to_string(&user_manifest_path).context("Failed to read Cargo.toml")?;
let bp_names = find_installed_bp_names(&user_manifest_content)?;
let metadata_location = resolve_metadata_location(&user_manifest_path)?;
let packs: Vec<InstalledPack> = bp_names
.into_iter()
.map(|bp_name| {
let spec = load_installed_bp_spec(&bp_name, path, source)?;
let active_features =
read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
Ok(InstalledPack {
short_name: short_name(&bp_name).to_string(),
version: spec.version.clone(),
spec,
name: bp_name,
active_features,
})
})
.collect::<Result<_>>()?;
if packs.is_empty() {
println!("No battery packs installed.");
return Ok(());
}
let user_versions = collect_user_dep_versions(&user_manifest_path, &user_manifest_content)?;
let mut any_warnings = false;
for pack in &packs {
println!(
"{} ({})",
style(&pack.short_name).bold(),
style(&pack.version).dim(),
);
let expected = pack.spec.resolve_for_features(&pack.active_features);
let mut pack_warnings = Vec::new();
for (dep_name, dep_spec) in &expected {
if dep_spec.version.is_empty() {
continue;
}
if let Some(user_version) = user_versions.get(dep_name.as_str()) {
if should_upgrade_version(user_version, &dep_spec.version) {
pack_warnings.push((
dep_name.as_str(),
user_version.as_str(),
dep_spec.version.as_str(),
));
}
}
}
if pack_warnings.is_empty() {
println!(" {} all dependencies up to date", style("✓").green());
} else {
any_warnings = true;
for (dep, current, recommended) in &pack_warnings {
println!(
" {} {}: {} → {} recommended",
style("⚠").yellow(),
dep,
style(current).red(),
style(recommended).green(),
);
}
}
}
if any_warnings {
println!();
println!("Run {} to update.", style("cargo bp sync").bold());
}
Ok(())
}
pub(crate) fn collect_user_dep_versions(
user_manifest_path: &Path,
user_manifest_content: &str,
) -> Result<BTreeMap<String, String>> {
let raw: toml::Value =
toml::from_str(user_manifest_content).context("Failed to parse Cargo.toml")?;
let mut versions = BTreeMap::new();
let ws_versions = if let Some(ws_path) = find_workspace_manifest(user_manifest_path)? {
let ws_content =
std::fs::read_to_string(&ws_path).context("Failed to read workspace Cargo.toml")?;
let ws_raw: toml::Value =
toml::from_str(&ws_content).context("Failed to parse workspace Cargo.toml")?;
extract_versions_from_table(
ws_raw
.get("workspace")
.and_then(|w| w.get("dependencies"))
.and_then(|d| d.as_table()),
)
} else {
BTreeMap::new()
};
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
let table = raw.get(section).and_then(|d| d.as_table());
let Some(table) = table else { continue };
for (name, value) in table {
if versions.contains_key(name) {
continue; }
if let Some(version) = extract_version_from_dep(value) {
versions.insert(name.clone(), version);
} else if is_workspace_ref(value) {
if let Some(ws_ver) = ws_versions.get(name) {
versions.insert(name.clone(), ws_ver.clone());
}
}
}
}
Ok(versions)
}
fn extract_versions_from_table(
table: Option<&toml::map::Map<String, toml::Value>>,
) -> BTreeMap<String, String> {
let Some(table) = table else {
return BTreeMap::new();
};
let mut versions = BTreeMap::new();
for (name, value) in table {
if let Some(version) = extract_version_from_dep(value) {
versions.insert(name.clone(), version);
}
}
versions
}
fn extract_version_from_dep(value: &toml::Value) -> Option<String> {
match value {
toml::Value::String(s) => Some(s.clone()),
toml::Value::Table(t) => t
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string()),
_ => None,
}
}
fn is_workspace_ref(value: &toml::Value) -> bool {
match value {
toml::Value::Table(t) => t
.get("workspace")
.and_then(|v| v.as_bool())
.unwrap_or(false),
_ => false,
}
}
#[cfg(test)]
mod tests;