use anyhow::{Context, Result, bail};
use flate2::read::GzDecoder;
use serde::Deserialize;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use tar::Archive;
use crate::manifest::{
find_installed_bp_names, find_user_manifest, read_active_features_from,
resolve_battery_pack_manifest, resolve_metadata_location,
};
const CRATES_IO_API: &str = "https://crates.io/api/v1/crates";
const CRATES_IO_CDN: &str = "https://static.crates.io/crates";
fn http_client() -> &'static reqwest::blocking::Client {
static CLIENT: std::sync::OnceLock<reqwest::blocking::Client> = std::sync::OnceLock::new();
CLIENT.get_or_init(|| {
reqwest::blocking::Client::builder()
.user_agent("cargo-bp (https://github.com/battery-pack-rs/battery-pack)")
.build()
.expect("failed to build HTTP client")
})
}
#[derive(Debug, Clone)]
pub(crate) enum CrateSource {
Registry,
Local(PathBuf),
}
#[derive(Deserialize)]
struct CratesIoResponse {
versions: Vec<VersionInfo>,
}
#[derive(Deserialize)]
struct VersionInfo {
num: String,
yanked: bool,
}
#[derive(Deserialize)]
struct SearchResponse {
crates: Vec<SearchCrate>,
}
#[derive(Deserialize)]
struct SearchCrate {
name: String,
max_version: String,
description: Option<String>,
}
pub(crate) type TemplateConfig = bphelper_manifest::TemplateSpec;
#[derive(Deserialize)]
struct OwnersResponse {
users: Vec<Owner>,
}
#[derive(Deserialize, Clone)]
pub(crate) struct Owner {
login: String,
name: Option<String>,
}
#[derive(Deserialize)]
struct GitHubTreeResponse {
tree: Vec<GitHubTreeEntry>,
#[serde(default)]
#[allow(dead_code)]
truncated: bool,
}
#[derive(Deserialize)]
struct GitHubTreeEntry {
path: String,
}
#[derive(Clone)]
pub(crate) struct BatteryPackSummary {
pub name: String,
pub short_name: String,
pub version: String,
pub description: String,
}
#[derive(Clone)]
pub(crate) struct BatteryPackDetail {
pub name: String,
pub short_name: String,
pub version: String,
pub description: String,
pub repository: Option<String>,
pub owners: Vec<OwnerInfo>,
pub crates: Vec<String>,
pub extends: Vec<String>,
pub templates: Vec<TemplateInfo>,
pub examples: Vec<ExampleInfo>,
}
#[derive(Clone)]
pub(crate) struct OwnerInfo {
pub login: String,
pub name: Option<String>,
}
impl From<Owner> for OwnerInfo {
fn from(o: Owner) -> Self {
Self {
login: o.login,
name: o.name,
}
}
}
#[derive(Clone)]
pub(crate) struct TemplateInfo {
pub name: String,
pub path: String,
pub description: Option<String>,
pub repo_path: Option<String>,
}
#[derive(Clone)]
pub(crate) struct ExampleInfo {
pub name: String,
pub description: Option<String>,
pub repo_path: Option<String>,
}
pub(crate) struct CrateMetadata {
pub(crate) version: String,
}
pub(crate) fn lookup_crate(crate_name: &str) -> Result<CrateMetadata> {
let client = http_client();
let url = format!("{}/{}", CRATES_IO_API, crate_name);
let response = client
.get(&url)
.send()
.with_context(|| format!("Failed to query crates.io for '{}'", crate_name))?;
if !response.status().is_success() {
bail!(
"Crate '{}' not found on crates.io (status: {})",
crate_name,
response.status()
);
}
let parsed: CratesIoResponse = response
.json()
.with_context(|| format!("Failed to parse crates.io response for '{}'", crate_name))?;
let version = parsed
.versions
.iter()
.find(|v| !v.yanked)
.map(|v| v.num.clone())
.ok_or_else(|| anyhow::anyhow!("No non-yanked versions found for '{}'", crate_name))?;
Ok(CrateMetadata { version })
}
pub(crate) fn download_and_extract_crate(
crate_name: &str,
version: &str,
) -> Result<tempfile::TempDir> {
let client = http_client();
let url = format!(
"{}/{}/{}-{}.crate",
CRATES_IO_CDN, crate_name, crate_name, version
);
let response = client
.get(&url)
.send()
.with_context(|| format!("Failed to download crate from {}", url))?;
if !response.status().is_success() {
bail!(
"Failed to download '{}' version {} (status: {})",
crate_name,
version,
response.status()
);
}
let bytes = response
.bytes()
.with_context(|| "Failed to read crate tarball")?;
let temp_dir = tempfile::tempdir().with_context(|| "Failed to create temp directory")?;
let decoder = GzDecoder::new(&bytes[..]);
let mut archive = Archive::new(decoder);
archive
.unpack(temp_dir.path())
.with_context(|| "Failed to extract crate tarball")?;
Ok(temp_dir)
}
pub(crate) fn fetch_bp_spec_from_registry(
crate_name: &str,
) -> Result<(String, bphelper_manifest::BatteryPackSpec)> {
let crate_info = lookup_crate(crate_name)?;
let temp_dir = download_and_extract_crate(crate_name, &crate_info.version)?;
let crate_dir = temp_dir
.path()
.join(format!("{}-{}", crate_name, crate_info.version));
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 spec = bphelper_manifest::parse_battery_pack(&manifest_content)
.map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e))?;
Ok((crate_info.version, spec))
}
pub fn resolve_bp_managed_content(content: &str, bp_crate_root: &Path) -> Result<String> {
let mut doc: toml_edit::DocumentMut = content.parse().context("failed to parse Cargo.toml")?;
let sections = ["dependencies", "dev-dependencies", "build-dependencies"];
let mut has_managed = false;
for section in §ions {
if let Some(table) = doc.get(section).and_then(|v| v.as_table()) {
for (name, value) in table.iter() {
if is_bp_managed(value) {
has_managed = true;
let extra = extra_keys_on_bp_managed(value);
if !extra.is_empty() {
bail!(
"dependency '{}' in [{}] has `bp-managed = true` with conflicting keys: {}",
name,
section,
extra.join(", ")
);
}
}
}
}
}
if !has_managed {
return Ok(content.to_string());
}
let raw: toml::Value = toml::from_str(content).context("failed to parse Cargo.toml")?;
let all_specs = bphelper_manifest::discover_from_crate_root(bp_crate_root)?;
let mut resolved: std::collections::BTreeMap<String, bphelper_manifest::CrateSpec> =
std::collections::BTreeMap::new();
let mut bp_versions: std::collections::BTreeMap<String, String> =
std::collections::BTreeMap::new();
let bp_metadata = raw
.get("package")
.and_then(|p| p.get("metadata"))
.and_then(|m| m.get("battery-pack"))
.and_then(|bp| bp.as_table());
if let Some(bp_table) = bp_metadata {
for (bp_name, _entry) in bp_table {
let active_features =
crate::manifest::read_features_at(&raw, &["package", "metadata"], bp_name);
let spec = if let Some(s) = all_specs.iter().find(|s| s.name == *bp_name) {
s.clone()
} else {
let (_version, s) = fetch_bp_spec_from_registry(bp_name).with_context(|| {
format!("battery pack '{bp_name}' not found locally or on crates.io")
})?;
s
};
bp_versions.insert(bp_name.clone(), spec.version.clone());
let crates = spec.resolve_for_features(&active_features);
for (crate_name, crate_spec) in crates {
resolved.insert(crate_name, crate_spec);
}
}
}
for section in §ions {
let Some(table) = doc.get_mut(section).and_then(|v| v.as_table_mut()) else {
continue;
};
let managed_names: Vec<String> = table
.iter()
.filter(|(_, v)| is_bp_managed_item(v))
.map(|(k, _)| k.to_string())
.collect();
for name in managed_names {
if let Some(crate_spec) = resolved.get(&name) {
crate::manifest::add_dep_to_table(table, &name, crate_spec);
} else if let Some(bp_version) = bp_versions.get(&name) {
table.insert(&name, toml_edit::value(bp_version));
} else {
bail!(
"dependency '{}' in [{}] has `bp-managed = true` but no battery pack provides it",
name,
section
);
}
}
}
Ok(doc.to_string())
}
fn is_bp_managed_item(item: &toml_edit::Item) -> bool {
match item {
toml_edit::Item::Value(toml_edit::Value::InlineTable(t)) => t
.get("bp-managed")
.and_then(|v| v.as_bool())
.unwrap_or(false),
toml_edit::Item::Table(t) => t
.get("bp-managed")
.and_then(|v| v.as_value())
.and_then(|v| v.as_bool())
.unwrap_or(false),
_ => false,
}
}
fn is_bp_managed(value: &toml_edit::Item) -> bool {
is_bp_managed_item(value)
}
fn extra_keys_on_bp_managed(value: &toml_edit::Item) -> Vec<String> {
let keys: Box<dyn Iterator<Item = &str>> = match value {
toml_edit::Item::Value(toml_edit::Value::InlineTable(t)) => {
Box::new(t.iter().map(|(k, _)| k))
}
toml_edit::Item::Table(t) => Box::new(t.iter().map(|(k, _)| k)),
_ => return vec![],
};
keys.filter(|k| *k != "bp-managed")
.map(String::from)
.collect()
}
pub(crate) fn fetch_battery_pack_spec(bp_name: &str) -> Result<bphelper_manifest::BatteryPackSpec> {
let manifest_path = resolve_battery_pack_manifest(bp_name)?;
let manifest_content = std::fs::read_to_string(&manifest_path)
.with_context(|| format!("Failed to read {}", manifest_path.display()))?;
bphelper_manifest::parse_battery_pack(&manifest_content)
.map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", bp_name, e))
}
pub(crate) fn load_installed_bp_spec(
bp_name: &str,
path: Option<&str>,
source: &CrateSource,
) -> Result<bphelper_manifest::BatteryPackSpec> {
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()))?;
return bphelper_manifest::parse_battery_pack(&manifest_content)
.map_err(|e| anyhow::anyhow!("Failed to parse battery pack '{}': {}", bp_name, e));
}
match source {
CrateSource::Registry => fetch_battery_pack_spec(bp_name),
CrateSource::Local(_) => {
let (_version, spec) = fetch_bp_spec(source, bp_name)?;
Ok(spec)
}
}
}
pub(crate) struct InstalledPack {
pub name: String,
pub short_name: String,
pub version: String,
pub spec: bphelper_manifest::BatteryPackSpec,
pub active_features: BTreeSet<String>,
}
pub(crate) fn load_installed_packs(project_dir: &Path) -> Result<Vec<InstalledPack>> {
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)?;
let metadata_location = resolve_metadata_location(&user_manifest_path)?;
let mut packs = Vec::new();
for bp_name in bp_names {
let spec = fetch_battery_pack_spec(&bp_name)?;
let active_features =
read_active_features_from(&metadata_location, &user_manifest_content, &bp_name);
packs.push(InstalledPack {
short_name: short_name(&bp_name).to_string(),
version: spec.version.clone(),
spec,
name: bp_name,
active_features,
});
}
Ok(packs)
}
pub(crate) fn fetch_battery_pack_list(
source: &CrateSource,
filter: Option<&str>,
) -> Result<Vec<BatteryPackSummary>> {
match source {
CrateSource::Registry => fetch_battery_pack_list_from_registry(filter),
CrateSource::Local(path) => discover_local_battery_packs(path, filter),
}
}
fn fetch_battery_pack_list_from_registry(filter: Option<&str>) -> Result<Vec<BatteryPackSummary>> {
let client = http_client();
let url = match filter {
Some(q) => format!(
"{CRATES_IO_API}?q={}&keyword=battery-pack&per_page=50",
urlencoding::encode(q)
),
None => format!("{CRATES_IO_API}?keyword=battery-pack&per_page=50"),
};
let response = client
.get(&url)
.send()
.context("Failed to query crates.io")?;
if !response.status().is_success() {
bail!(
"Failed to list battery packs (status: {})",
response.status()
);
}
let parsed: SearchResponse = response.json().context("Failed to parse response")?;
let battery_packs = parsed
.crates
.into_iter()
.filter(|c| c.name.ends_with("-battery-pack"))
.map(|c| BatteryPackSummary {
short_name: short_name(&c.name).to_string(),
name: c.name,
version: c.max_version,
description: c.description.unwrap_or_default(),
})
.collect();
Ok(battery_packs)
}
pub(crate) fn discover_local_battery_packs(
workspace_dir: &Path,
filter: Option<&str>,
) -> Result<Vec<BatteryPackSummary>> {
let manifest_path = workspace_dir.join("Cargo.toml");
let metadata = cargo_metadata::MetadataCommand::new()
.manifest_path(&manifest_path)
.no_deps()
.exec()
.with_context(|| format!("Failed to read workspace at {}", manifest_path.display()))?;
let mut battery_packs: Vec<BatteryPackSummary> = metadata
.packages
.iter()
.filter(|pkg| pkg.name.ends_with("-battery-pack"))
.filter(|pkg| {
if let Some(q) = filter {
short_name(&pkg.name).contains(q)
} else {
true
}
})
.map(|pkg| BatteryPackSummary {
short_name: short_name(&pkg.name).to_string(),
name: pkg.name.to_string(),
version: pkg.version.to_string(),
description: pkg.description.clone().unwrap_or_default(),
})
.collect();
battery_packs.sort_by(|a, b| a.name.cmp(&b.name));
Ok(battery_packs)
}
pub(crate) fn find_local_battery_pack_dir(
workspace_dir: &Path,
crate_name: &str,
) -> Result<PathBuf> {
let manifest_path = workspace_dir.join("Cargo.toml");
let metadata = cargo_metadata::MetadataCommand::new()
.manifest_path(&manifest_path)
.no_deps()
.exec()
.with_context(|| format!("Failed to read workspace at {}", manifest_path.display()))?;
let package = metadata
.packages
.iter()
.find(|p| p.name == crate_name)
.ok_or_else(|| {
anyhow::anyhow!(
"Battery pack '{}' not found in workspace at {}",
crate_name,
workspace_dir.display()
)
})?;
Ok(package
.manifest_path
.parent()
.expect("manifest path should have a parent")
.into())
}
pub(crate) fn fetch_bp_spec(
source: &CrateSource,
name: &str,
) -> Result<(Option<String>, bphelper_manifest::BatteryPackSpec)> {
let crate_name = resolve_crate_name(name);
match source {
CrateSource::Registry => {
let (version, spec) = fetch_bp_spec_from_registry(&crate_name)?;
Ok((Some(version), spec))
}
CrateSource::Local(workspace_dir) => {
let crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
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 spec = bphelper_manifest::parse_battery_pack(&manifest_content).map_err(|e| {
anyhow::anyhow!("Failed to parse battery pack '{}': {}", crate_name, e)
})?;
Ok((None, spec))
}
}
}
pub(crate) fn fetch_battery_pack_detail_from_source(
source: &CrateSource,
name: &str,
) -> Result<BatteryPackDetail> {
match source {
CrateSource::Registry => fetch_battery_pack_detail(name, None),
CrateSource::Local(workspace_dir) => {
let crate_name = resolve_crate_name(name);
let crate_dir = find_local_battery_pack_dir(workspace_dir, &crate_name)?;
fetch_battery_pack_detail_from_path(&crate_dir.to_string_lossy())
}
}
}
pub(crate) fn short_name(crate_name: &str) -> &str {
crate_name
.strip_suffix("-battery-pack")
.unwrap_or(crate_name)
}
pub(crate) fn resolve_crate_name(name: &str) -> String {
if name == "battery-pack" || name.ends_with("-battery-pack") {
name.to_string()
} else {
format!("{}-battery-pack", name)
}
}
pub(crate) fn fetch_battery_pack_detail(
name: &str,
path: Option<&str>,
) -> Result<BatteryPackDetail> {
if let Some(local_path) = path {
return fetch_battery_pack_detail_from_path(local_path);
}
let crate_name = resolve_crate_name(name);
let crate_info = lookup_crate(&crate_name)?;
let temp_dir = download_and_extract_crate(&crate_name, &crate_info.version)?;
let crate_dir = temp_dir
.path()
.join(format!("{}-{}", crate_name, crate_info.version));
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 spec = bphelper_manifest::parse_battery_pack(&manifest_content)
.map_err(|e| anyhow::anyhow!("Failed to parse battery pack: {}", e))?;
let owners = fetch_owners(&crate_name)?;
build_battery_pack_detail(&crate_dir, &spec, owners)
}
fn fetch_battery_pack_detail_from_path(path: &str) -> Result<BatteryPackDetail> {
let crate_dir = std::path::Path::new(path);
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 spec = bphelper_manifest::parse_battery_pack(&manifest_content)
.map_err(|e| anyhow::anyhow!("Failed to parse battery pack: {}", e))?;
build_battery_pack_detail(crate_dir, &spec, Vec::new())
}
pub(crate) fn build_battery_pack_detail(
crate_dir: &Path,
spec: &bphelper_manifest::BatteryPackSpec,
owners: Vec<Owner>,
) -> Result<BatteryPackDetail> {
let (extends_raw, crates_raw): (Vec<_>, Vec<_>) = spec
.visible_crates()
.into_keys()
.partition(|d| d.ends_with("-battery-pack"));
let extends: Vec<String> = extends_raw
.into_iter()
.map(|d| short_name(d).to_string())
.collect();
let crates: Vec<String> = crates_raw.into_iter().map(|s| s.to_string()).collect();
let repo_tree = spec.repository.as_ref().and_then(|r| fetch_github_tree(r));
let templates = spec
.templates
.iter()
.map(|(name, tmpl)| {
let repo_path = repo_tree
.as_ref()
.and_then(|tree| find_template_path(tree, &tmpl.path));
TemplateInfo {
name: name.clone(),
path: tmpl.path.clone(),
description: tmpl.description.clone(),
repo_path,
}
})
.collect();
let examples = scan_examples(crate_dir, repo_tree.as_deref());
Ok(BatteryPackDetail {
short_name: short_name(&spec.name).to_string(),
name: spec.name.clone(),
version: spec.version.clone(),
description: spec.description.clone(),
repository: spec.repository.clone(),
owners: owners.into_iter().map(OwnerInfo::from).collect(),
crates,
extends,
templates,
examples,
})
}
fn fetch_owners(crate_name: &str) -> Result<Vec<Owner>> {
let client = http_client();
let url = format!("{}/{}/owners", CRATES_IO_API, crate_name);
let response = client
.get(&url)
.send()
.with_context(|| format!("Failed to fetch owners for '{}'", crate_name))?;
if !response.status().is_success() {
return Ok(Vec::new());
}
let parsed: OwnersResponse = response
.json()
.with_context(|| "Failed to parse owners response")?;
Ok(parsed.users)
}
fn scan_examples(crate_dir: &std::path::Path, repo_tree: Option<&[String]>) -> Vec<ExampleInfo> {
let examples_dir = crate_dir.join("examples");
if !examples_dir.exists() {
return Vec::new();
}
let mut examples = Vec::new();
if let Ok(entries) = std::fs::read_dir(&examples_dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|ext| ext == "rs")
&& let Some(name) = path.file_stem().and_then(|s| s.to_str())
{
let description = extract_example_description(&path);
let repo_path = repo_tree.and_then(|tree| find_example_path(tree, name));
examples.push(ExampleInfo {
name: name.to_string(),
description,
repo_path,
});
}
}
}
examples.sort_by(|a, b| a.name.cmp(&b.name));
examples
}
fn extract_example_description(path: &std::path::Path) -> Option<String> {
let content = std::fs::read_to_string(path).ok()?;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("//!") {
let desc = trimmed.strip_prefix("//!").unwrap_or("").trim();
if !desc.is_empty() {
return Some(desc.to_string());
}
} else if !trimmed.is_empty() && !trimmed.starts_with("//") {
break;
}
}
None
}
fn fetch_github_tree(repository: &str) -> Option<Vec<String>> {
let gh_path = repository
.strip_prefix("https://github.com/")
.or_else(|| repository.strip_prefix("http://github.com/"))?;
let gh_path = gh_path.strip_suffix(".git").unwrap_or(gh_path);
let gh_path = gh_path.trim_end_matches('/');
let client = http_client();
let url = format!(
"https://api.github.com/repos/{}/git/trees/main?recursive=1",
gh_path
);
let response = client.get(&url).send().ok()?;
if !response.status().is_success() {
return None;
}
let tree_response: GitHubTreeResponse = response.json().ok()?;
Some(tree_response.tree.into_iter().map(|e| e.path).collect())
}
pub(crate) fn find_example_path(tree: &[String], example_name: &str) -> Option<String> {
let suffix = format!("examples/{}.rs", example_name);
tree.iter().find(|path| path.ends_with(&suffix)).cloned()
}
pub(crate) fn find_template_path(tree: &[String], template_path: &str) -> Option<String> {
tree.iter()
.find(|path| path.ends_with(template_path))
.cloned()
}
#[cfg(test)]
mod tests;