use super::*;
fn parse_version(version_str: &str) -> Option<Version> {
let parts: Vec<&str> = version_str.split('-').collect();
let version_part: &str = parts.first()?;
let prerelease: Option<String> = parts.get(1).map(|s: &&str| s.to_string());
let nums: Vec<&str> = version_part.split('.').collect();
if nums.len() != 3 {
return None;
}
let major: u64 = nums.first()?.parse().ok()?;
let minor: u64 = nums.get(1)?.parse().ok()?;
let patch: u64 = nums.get(2)?.parse().ok()?;
Some(Version {
major,
minor,
patch,
prerelease,
})
}
fn parse_prerelease(prerelease: &str) -> Option<(&str, u64)> {
let parts: Vec<&str> = prerelease.split('.').collect();
let pre_type: &str = parts.first()?;
let number: u64 = parts
.get(1)
.and_then(|s: &&str| s.parse().ok())
.unwrap_or(0);
Some((pre_type, number))
}
fn get_next_prerelease(current: Option<&String>, target_type: &str) -> String {
match current {
Some(pre) => {
if let Some((pre_type, number)) = parse_prerelease(pre)
&& pre_type == target_type
&& number > 0
{
return format!("{}.{}", target_type, number + 1);
}
format!("{target_type}.1")
}
None => target_type.to_string(),
}
}
fn version_to_string(version: &Version) -> String {
let base: String = format!("{}.{}.{}", version.major, version.minor, version.patch);
match &version.prerelease {
Some(pre) => format!("{base}-{pre}"),
None => base,
}
}
fn bump_version(version: &Version, bump_type: &BumpVersionType) -> Version {
match bump_type {
BumpVersionType::Patch => Version {
major: version.major,
minor: version.minor,
patch: version.patch + 1,
prerelease: None,
},
BumpVersionType::Minor => Version {
major: version.major,
minor: version.minor + 1,
patch: 0,
prerelease: None,
},
BumpVersionType::Major => Version {
major: version.major + 1,
minor: 0,
patch: 0,
prerelease: None,
},
BumpVersionType::Release => Version {
major: version.major,
minor: version.minor,
patch: version.patch,
prerelease: None,
},
BumpVersionType::Alpha => {
let prerelease: String = get_next_prerelease(version.prerelease.as_ref(), "alpha");
Version {
major: version.major,
minor: version.minor,
patch: version.patch,
prerelease: Some(prerelease),
}
}
BumpVersionType::Beta => {
let prerelease: String = get_next_prerelease(version.prerelease.as_ref(), "beta");
Version {
major: version.major,
minor: version.minor,
patch: version.patch,
prerelease: Some(prerelease),
}
}
BumpVersionType::Rc => {
let prerelease: String = get_next_prerelease(version.prerelease.as_ref(), "rc");
Version {
major: version.major,
minor: version.minor,
patch: version.patch,
prerelease: Some(prerelease),
}
}
}
}
fn bump_version_str(version_str: &str, bump_type: &BumpVersionType) -> Option<String> {
let version: Version = parse_version(version_str)?;
Some(version_to_string(&bump_version(&version, bump_type)))
}
fn expand_member_entry(root_dir: &Path, entry: &str) -> Vec<PathBuf> {
match entry.strip_suffix("/*") {
Some(prefix) => {
let mut dirs: Vec<PathBuf> = Vec::new();
if let Ok(entries) = std::fs::read_dir(root_dir.join(prefix)) {
for entry in entries.flatten() {
let path: PathBuf = entry.path();
if path.is_dir() && path.join("Cargo.toml").exists() {
dirs.push(path);
}
}
}
dirs.sort();
dirs
}
None => vec![root_dir.join(entry)],
}
}
fn realign_dep_versions(
deps: &mut dyn TableLike,
base_dir: &Path,
bumped: &[(PathBuf, String)],
) -> bool {
let mut changed: bool = false;
for (_alias, entry) in deps.iter_mut() {
let Some(dep_path) = entry
.get("path")
.and_then(|path_item: &Item| path_item.as_str())
else {
continue;
};
let Ok(canonical) = base_dir.join(dep_path).canonicalize() else {
continue;
};
let Some((_, new_version)) = bumped.iter().find(|(dir, _)| *dir == canonical) else {
continue;
};
if entry.get("version").is_none() {
continue;
}
let Some(version_slot) = entry.get_mut("version") else {
continue;
};
if version_slot.as_str() == Some(new_version.as_str()) {
continue;
}
set_item_string_preserving_decor(version_slot, new_version);
changed = true;
}
changed
}
async fn realign_member_manifest(
member_dir: &Path,
member_manifest_path: &Path,
bumped: &[(PathBuf, String)],
) -> Result<(), Box<dyn std::error::Error>> {
let member_content: String = read_to_string(member_manifest_path).await?;
let mut member_doc: DocumentMut = member_content.parse().map_err(|e: TomlError| {
format!("failed to parse {}: {}", member_manifest_path.display(), e)
})?;
let mut changed: bool = false;
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(deps) = member_doc
.get_mut(section)
.and_then(|deps_item: &mut Item| deps_item.as_table_like_mut())
{
changed |= realign_dep_versions(deps, member_dir, bumped);
}
}
if let Some(targets) = member_doc
.get_mut("target")
.and_then(|target_item: &mut Item| target_item.as_table_like_mut())
{
let target_keys: Vec<String> = targets.iter().map(|(key, _)| key.to_string()).collect();
for target_key in target_keys {
let Some(target_table) = targets
.get_mut(&target_key)
.and_then(|target_item: &mut Item| target_item.as_table_like_mut())
else {
continue;
};
for section in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(deps) = target_table
.get_mut(section)
.and_then(|deps_item: &mut Item| deps_item.as_table_like_mut())
{
changed |= realign_dep_versions(deps, member_dir, bumped);
}
}
}
}
if changed {
write(member_manifest_path, member_doc.to_string()).await?;
}
Ok(())
}
async fn bump_workspace_members(
root_path: &Path,
doc: &mut DocumentMut,
bump_type: &BumpVersionType,
) -> Result<String, Box<dyn std::error::Error>> {
let root_dir: &Path = root_path.parent().unwrap_or_else(|| Path::new("."));
let member_entries: Vec<String> = doc
.get("workspace")
.and_then(|workspace: &Item| workspace.get("members"))
.and_then(|members_item: &Item| members_item.as_array())
.map(|members: &toml_edit::Array| {
members
.iter()
.filter_map(|member: &TomlEditValue| member.as_str().map(|s: &str| s.to_string()))
.collect()
})
.unwrap_or_default();
let mut member_dirs: Vec<PathBuf> = Vec::new();
for entry in &member_entries {
member_dirs.extend(expand_member_entry(root_dir, entry));
}
member_dirs.sort();
member_dirs.dedup();
let mut bumped: Vec<(PathBuf, String)> = Vec::new();
for dir in &member_dirs {
let member_manifest_path: PathBuf = dir.join("Cargo.toml");
if !member_manifest_path.exists() {
return Err(format!(
"member manifest not found: {}",
member_manifest_path.display()
)
.into());
}
let member_content: String = read_to_string(&member_manifest_path).await?;
let mut member_doc: DocumentMut = member_content.parse().map_err(|e: TomlError| {
format!("failed to parse {}: {}", member_manifest_path.display(), e)
})?;
let version_slot: &mut Item = member_doc
.get_mut("package")
.and_then(|package: &mut Item| package.get_mut("version"))
.ok_or_else(|| -> Box<dyn std::error::Error> {
format!(
"package.version not found in {}",
member_manifest_path.display()
)
.into()
})?;
let Some(old_version) = version_slot.as_str().map(|s: &str| s.to_string()) else {
continue;
};
let new_version: String = bump_version_str(&old_version, bump_type).ok_or_else(
|| -> Box<dyn std::error::Error> {
format!("failed to parse version: {}", old_version).into()
},
)?;
set_item_string_preserving_decor(version_slot, &new_version);
write(&member_manifest_path, member_doc.to_string()).await?;
log::info!(
"bump: {} -> {}",
member_manifest_path.display(),
new_version
);
bumped.push((dir.canonicalize()?, new_version));
}
if bumped.is_empty() {
return Ok("0 workspace members".to_string());
}
let root_changed: bool = doc
.get_mut("workspace")
.and_then(|workspace: &mut Item| workspace.get_mut("dependencies"))
.and_then(|deps_item: &mut Item| deps_item.as_table_like_mut())
.is_some_and(|deps: &mut dyn TableLike| realign_dep_versions(deps, root_dir, &bumped));
if root_changed {
write(root_path, doc.to_string()).await?;
}
for dir in &member_dirs {
realign_member_manifest(dir, &dir.join("Cargo.toml"), &bumped).await?;
}
Ok(format!("{} workspace members", bumped.len()))
}
pub async fn execute_bump(
manifest_path: &str,
bump_type: &BumpVersionType,
) -> Result<String, Box<dyn std::error::Error>> {
let path: &Path = Path::new(manifest_path);
let content: String = read_to_string(path).await?;
let mut doc: DocumentMut = content
.parse()
.map_err(|e: TomlError| format!("failed to parse {}: {}", manifest_path, e))?;
let has_workspace_version: bool = doc
.get("workspace")
.and_then(|workspace: &Item| workspace.get("package"))
.and_then(|package: &Item| package.get("version"))
.is_some();
let has_root_package: bool = doc.get("package").is_some();
let has_members: bool = doc
.get("workspace")
.and_then(|workspace: &Item| workspace.get("members"))
.and_then(|members_item: &Item| members_item.as_array())
.is_some_and(|members: &toml_edit::Array| !members.is_empty());
if !has_workspace_version && !has_root_package && has_members {
return bump_workspace_members(path, &mut doc, bump_type).await;
}
let version_slot: &mut Item = if has_workspace_version {
doc.get_mut("workspace")
.and_then(|workspace: &mut Item| workspace.get_mut("package"))
.and_then(|package: &mut Item| package.get_mut("version"))
.ok_or_else(|| -> Box<dyn std::error::Error> {
"workspace.package.version not found".into()
})?
} else if has_root_package {
doc.get_mut("package")
.and_then(|package: &mut Item| package.get_mut("version"))
.ok_or_else(|| -> Box<dyn std::error::Error> { "package.version not found".into() })?
} else {
return Err("neither [package] nor [workspace.package] found in Cargo.toml".into());
};
let version_str: String = version_slot
.as_str()
.ok_or_else(|| -> Box<dyn std::error::Error> { "version field is not a string".into() })?
.to_string();
let version: Version =
parse_version(&version_str).ok_or_else(|| -> Box<dyn std::error::Error> {
format!("failed to parse version: {}", version_str).into()
})?;
let bumped: Version = bump_version(&version, bump_type);
let version_string: String = version_to_string(&bumped);
set_item_string_preserving_decor(version_slot, &version_string);
write(path, doc.to_string()).await?;
Ok(version_string)
}