xbp 10.30.3

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Cargo manifest helpers for workspace-aware version read/write.

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);
    }
}