use semver::Version;
use std::fs;
use std::path::{Path, PathBuf};
use toml::Value as TomlValue;
pub fn find_cargo_workspace_root(manifest_path: &Path) -> Option<PathBuf> {
let mut current = manifest_path.parent()?.to_path_buf();
while current.as_os_str().len() > 0 {
let candidate = current.join("Cargo.toml");
if candidate.is_file() {
if let Ok(content) = fs::read_to_string(&candidate) {
if let Ok(value) = toml::from_str::<TomlValue>(&content) {
if value.get("workspace").is_some() {
return Some(current);
}
}
}
}
if !current.pop() {
break;
}
}
None
}
pub fn resolve_cargo_package_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|error| format!("Failed to parse TOML: {error}"))?;
Ok(resolve_cargo_package_version_from_value(&value))
}
pub fn resolve_cargo_package_version_from_value(value: &TomlValue) -> Option<String> {
if let Some(version) = read_direct_package_version(value) {
return Some(version);
}
if package_version_uses_workspace(value) {
return None;
}
read_workspace_package_version(value)
}
pub fn resolve_cargo_package_version(manifest_path: &Path) -> Result<Option<String>, String> {
let content = fs::read_to_string(manifest_path)
.map_err(|error| format!("Failed to read {}: {error}", manifest_path.display()))?;
let value: TomlValue =
toml::from_str(&content).map_err(|error| format!("Failed to parse TOML: {error}"))?;
if let Some(version) = read_direct_package_version(&value) {
return Ok(Some(version));
}
if package_version_uses_workspace(&value) {
let workspace_root = find_cargo_workspace_root(manifest_path).ok_or_else(|| {
format!(
"Cargo manifest {} inherits package.version from the workspace, but no workspace root Cargo.toml was found.",
manifest_path.display()
)
})?;
let workspace_manifest = workspace_root.join("Cargo.toml");
let workspace_content = fs::read_to_string(&workspace_manifest).map_err(|error| {
format!(
"Failed to read workspace manifest {}: {error}",
workspace_manifest.display()
)
})?;
let workspace_value: TomlValue = toml::from_str(&workspace_content)
.map_err(|error| format!("Failed to parse workspace TOML: {error}"))?;
return read_workspace_package_version(&workspace_value).ok_or_else(|| {
format!(
"Workspace manifest {} is missing [workspace.package].version.",
workspace_manifest.display()
)
}).map(Some);
}
Ok(read_workspace_package_version(&value))
}
pub fn resolve_cargo_package_version_required(manifest_path: &Path) -> Result<String, String> {
resolve_cargo_package_version(manifest_path)?
.filter(|value| !value.trim().is_empty())
.ok_or_else(|| {
format!(
"Could not resolve package.version from {}.",
manifest_path.display()
)
})
}
pub fn write_cargo_package_version(manifest_path: &Path, version: &Version) -> Result<bool, String> {
let content = fs::read_to_string(manifest_path)
.map_err(|error| format!("Failed to read {}: {error}", manifest_path.display()))?;
let mut value: TomlValue =
toml::from_str(&content).map_err(|error| format!("Failed to parse TOML: {error}"))?;
if value
.get("package")
.and_then(TomlValue::as_table)
.and_then(|package| package.get("version"))
.and_then(TomlValue::as_str)
.is_some()
{
let Some(package) = value.get_mut("package").and_then(TomlValue::as_table_mut) else {
return Ok(false);
};
package.insert("version".to_string(), TomlValue::String(version.to_string()));
write_toml_manifest(manifest_path, &value)?;
return Ok(true);
}
let target_manifest = if package_version_uses_workspace(&value) {
let workspace_root = find_cargo_workspace_root(manifest_path).ok_or_else(|| {
format!(
"Cargo manifest {} inherits package.version from the workspace, but no workspace root Cargo.toml was found.",
manifest_path.display()
)
})?;
workspace_root.join("Cargo.toml")
} else if value.get("workspace").is_some() {
manifest_path.to_path_buf()
} else {
return Ok(false);
};
let target_content = fs::read_to_string(&target_manifest)
.map_err(|error| format!("Failed to read {}: {error}", target_manifest.display()))?;
let mut target_value: TomlValue = toml::from_str(&target_content)
.map_err(|error| format!("Failed to parse workspace TOML: {error}"))?;
let Some(workspace) = target_value
.get_mut("workspace")
.and_then(TomlValue::as_table_mut)
else {
return Err(format!(
"Workspace manifest {} is missing a [workspace] table.",
target_manifest.display()
));
};
let workspace_package = workspace
.entry("package".to_string())
.or_insert_with(|| TomlValue::Table(toml::map::Map::new()));
let Some(workspace_package_table) = workspace_package.as_table_mut() else {
return Err(format!(
"Workspace manifest {} has an invalid [workspace.package] table.",
target_manifest.display()
));
};
let previous = workspace_package_table
.get("version")
.and_then(TomlValue::as_str)
.map(str::to_string);
workspace_package_table.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
if previous.as_deref() == Some(version.to_string().as_str()) {
return Ok(false);
}
write_toml_manifest(&target_manifest, &target_value)?;
Ok(true)
}
fn write_toml_manifest(path: &Path, value: &TomlValue) -> Result<(), String> {
fs::write(
path,
toml::to_string_pretty(value).map_err(|error| format!("Failed to serialize TOML: {error}"))?,
)
.map_err(|error| format!("Failed to write {}: {error}", path.display()))
}
fn read_direct_package_version(value: &TomlValue) -> Option<String> {
value
.get("package")
.and_then(TomlValue::as_table)
.and_then(|package| package.get("version"))
.and_then(TomlValue::as_str)
.map(str::to_string)
}
fn package_version_uses_workspace(value: &TomlValue) -> bool {
value
.get("package")
.and_then(TomlValue::as_table)
.and_then(|package| package.get("version"))
.and_then(TomlValue::as_table)
.and_then(|table| table.get("workspace"))
.and_then(TomlValue::as_bool)
.unwrap_or(false)
}
fn read_workspace_package_version(value: &TomlValue) -> Option<String> {
value
.get("workspace")
.and_then(TomlValue::as_table)
.and_then(|workspace| workspace.get("package"))
.and_then(TomlValue::as_table)
.and_then(|package| package.get("version"))
.and_then(TomlValue::as_str)
.map(str::to_string)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn temp_dir(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("xbp-cargo-manifest-{name}-{}", std::process::id()));
let _ = fs::remove_dir_all(&dir);
fs::create_dir_all(&dir).expect("create temp dir");
dir
}
#[test]
fn resolves_workspace_package_version_from_virtual_workspace_root() {
let dir = temp_dir("virtual-root");
let manifest = dir.join("Cargo.toml");
fs::write(
&manifest,
r#"[workspace]
members = ["crates/demo"]
resolver = "2"
[workspace.package]
version = "0.1.0"
"#,
)
.expect("write manifest");
assert_eq!(
resolve_cargo_package_version(&manifest).expect("resolve"),
Some("0.1.0".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn resolves_workspace_inherited_member_version() {
let dir = temp_dir("member");
fs::write(
dir.join("Cargo.toml"),
r#"[workspace]
members = ["crates/demo"]
resolver = "2"
[workspace.package]
version = "0.2.5"
"#,
)
.expect("write workspace root");
fs::create_dir_all(dir.join("crates/demo")).expect("create member dir");
let member_manifest = dir.join("crates/demo/Cargo.toml");
fs::write(
&member_manifest,
r#"[package]
name = "demo"
version = { workspace = true }
"#,
)
.expect("write member manifest");
assert_eq!(
resolve_cargo_package_version(&member_manifest).expect("resolve"),
Some("0.2.5".to_string())
);
let _ = fs::remove_dir_all(dir);
}
#[test]
fn writes_workspace_package_version_for_inherited_member() {
let dir = temp_dir("write-member");
let root_manifest = dir.join("Cargo.toml");
fs::write(
&root_manifest,
r#"[workspace]
members = ["crates/demo"]
resolver = "2"
[workspace.package]
version = "0.1.0"
"#,
)
.expect("write workspace root");
fs::create_dir_all(dir.join("crates/demo")).expect("create member dir");
let member_manifest = dir.join("crates/demo/Cargo.toml");
fs::write(
&member_manifest,
r#"[package]
name = "demo"
version = { workspace = true }
"#,
)
.expect("write member manifest");
assert!(
write_cargo_package_version(&member_manifest, &Version::new(0, 3, 0)).expect("write")
);
assert_eq!(
resolve_cargo_package_version(&root_manifest).expect("read root"),
Some("0.3.0".to_string())
);
assert_eq!(
fs::read_to_string(&member_manifest).expect("read member"),
r#"[package]
name = "demo"
version = { workspace = true }
"#
);
let _ = fs::remove_dir_all(dir);
}
}