use crate::strategies::deployment_config::XbpConfig;
use crate::strategies::normalize_config_paths_for_persistence;
use crate::utils::{resolve_cargo_package_version, write_cargo_package_version};
use regex::Regex;
use semver::Version;
use serde_json::Value as JsonValue;
use serde_yaml::{Mapping as YamlMapping, Value as YamlValue};
use std::fs;
use std::path::{Path, PathBuf};
use toml::Value as TomlValue;
use super::{resolve_registry_paths, ResolvedRegistryPath, VersionScope};
fn is_openapi_variant_file_name(file_name: &str) -> bool {
let Some((stem, extension)) = file_name.rsplit_once('.') else {
return false;
};
if !matches!(extension, "yaml" | "yml" | "json") {
return false;
}
stem.starts_with("openapi-") || stem.starts_with("swagger-")
}
pub(crate) fn read_version_from_path(path: &Path) -> Result<Option<String>, String> {
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if is_openapi_variant_file_name(file_name) {
return match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => read_json_openapi_version(path),
Some("yaml") | Some("yml") => read_openapi_version(path),
_ => Ok(None),
};
}
match file_name {
"README.md" => read_readme_version(path),
"openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
read_openapi_version(path)
}
"openapi.json" | "swagger.json" => read_json_openapi_version(path),
"package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
| "xbp.json" => read_json_root_version(path),
"deno.json" => read_json_root_version(path),
"deno.jsonc" => read_regex_version(path, r#""version"\s*:\s*"([^"]+)""#),
"Cargo.toml" => read_cargo_toml_version(path),
"Cargo.lock" => read_cargo_lock_version(path),
"pyproject.toml" => read_pyproject_version(path),
"Chart.yaml" => read_yaml_root_version(path, "version"),
"xbp.yaml" | "xbp.yml" => read_yaml_root_version(path, "version"),
"pom.xml" => read_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>"),
"build.gradle" | "build.gradle.kts" => {
read_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#)
}
"mix.exs" => read_regex_version(path, r#"version:\s*"([^"]+)""#),
_ => match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => read_json_root_version(path),
Some("yaml") | Some("yml") => read_yaml_root_version(path, "version"),
Some("toml") => read_toml_root_version(path),
Some("md") => read_readme_version(path),
_ => Ok(None),
},
}
}
pub(crate) fn read_version_from_resolved_path(entry: &ResolvedRegistryPath) -> Result<Option<String>, String> {
let path = &entry.absolute;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if file_name == "Cargo.lock" {
if let Some(package_name) = entry.cargo_package_override.as_deref() {
return read_cargo_lock_version_for_package(path, package_name);
}
}
read_version_from_path(path)
}
pub(crate) fn write_version_to_path(path: &Path, version: &Version) -> Result<bool, String> {
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
match file_name {
"README.md" => write_readme_version(path, version).map(|_| true),
"openapi.yaml" | "openapi.yml" | "swagger.yaml" | "swagger.yml" => {
write_openapi_version(path, version).map(|_| true)
}
"openapi.json" | "swagger.json" => write_json_openapi_version(path, version).map(|_| true),
"package.json" | "package-lock.json" | "composer.json" | "app.json" | "manifest.json"
| "xbp.json" => write_json_root_version(path, version).map(|_| true),
"deno.json" => write_json_root_version(path, version).map(|_| true),
"deno.jsonc" => {
write_regex_version(path, r#""version"\s*:\s*"([^"]+)""#, version).map(|_| true)
}
"Cargo.toml" => write_cargo_toml_version(path, version),
"Cargo.lock" => write_cargo_lock_version(path, version),
"pyproject.toml" => write_pyproject_version(path, version).map(|_| true),
"Chart.yaml" => write_chart_version(path, version).map(|_| true),
"xbp.yaml" | "xbp.yml" => write_yaml_root_version(path, "version", version).map(|_| true),
"pom.xml" => {
write_regex_version(path, r"<version>\s*([^<\s]+)\s*</version>", version).map(|_| true)
}
"build.gradle" | "build.gradle.kts" => {
write_regex_version(path, r#"(?m)^\s*version\s*=\s*['"]([^'"]+)['"]"#, version)
.map(|_| true)
}
"mix.exs" => write_regex_version(path, r#"version:\s*"([^"]+)""#, version).map(|_| true),
_ => match path.extension().and_then(|ext| ext.to_str()) {
Some("json") => write_json_root_version(path, version).map(|_| true),
Some("yaml") | Some("yml") => {
write_yaml_root_version(path, "version", version).map(|_| true)
}
Some("toml") => write_toml_root_version(path, version).map(|_| true),
Some("md") => write_readme_version(path, version).map(|_| true),
_ => Err("Unsupported version file type".to_string()),
},
}
}
pub(crate) fn write_version_to_resolved_path(
entry: &ResolvedRegistryPath,
version: &Version,
) -> Result<bool, String> {
let path = &entry.absolute;
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if file_name == "Cargo.lock" {
if let Some(package_name) = entry.cargo_package_override.as_deref() {
return write_cargo_lock_version_for_package(path, Some(package_name), version);
}
}
write_version_to_path(path, version)
}
pub(crate) fn read_json_root_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: JsonValue =
serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
Ok(value
.get("version")
.and_then(JsonValue::as_str)
.map(|value| value.to_string()))
}
pub(crate) fn read_json_root_version(path: &Path) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_json_root_version_from_content(&content)
}
pub(crate) fn write_json_root_version(path: &Path, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: JsonValue =
serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
let object = value
.as_object_mut()
.ok_or_else(|| "Expected a JSON object".to_string())?;
object.insert(
"version".to_string(),
JsonValue::String(version.to_string()),
);
fs::write(
path,
serde_json::to_string_pretty(&value)
.map_err(|e| format!("Failed to serialize JSON: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
pub(crate) fn read_yaml_root_version_from_content(content: &str, key: &str) -> Result<Option<String>, String> {
let value: YamlValue =
serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
Ok(yaml_get_string(&value, key))
}
pub(crate) fn read_yaml_root_version(path: &Path, key: &str) -> Result<Option<String>, String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_yaml_root_version_from_content(&content, key)
}
pub(crate) fn write_yaml_root_version(path: &Path, key: &str, version: &Version) -> Result<(), String> {
let content = fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or_default();
if matches!(file_name, "xbp.yaml" | "xbp.yml") && key == "version" {
if let Ok(mut config) = serde_yaml::from_str::<XbpConfig>(&content) {
config.version = version.to_string();
if let Some(project_root) = path.parent().and_then(|parent| {
if parent
.file_name()
.and_then(|name| name.to_str())
.is_some_and(|name| name.eq_ignore_ascii_case(".xbp"))
{
parent.parent()
} else {
Some(parent)
}
}) {
normalize_config_paths_for_persistence(&mut config, project_root);
}
let yaml = serde_yaml::to_string(&config)
.map_err(|e| format!("Failed to serialize XBP config: {}", e))?;
return fs::write(path, yaml).map_err(|e| format!("Failed to write file: {}", e));
}
}
let mut value: YamlValue =
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
let mapping = yaml_root_mapping_mut(&mut value)?;
mapping.insert(
YamlValue::String(key.to_string()),
YamlValue::String(version.to_string()),
);
fs::write(
path,
serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
pub(crate) fn read_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: YamlValue =
serde_yaml::from_str(content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
let info = yaml_get_mapping(&value, "info");
Ok(info.and_then(|mapping| {
mapping
.get(YamlValue::String("version".to_string()))
.and_then(YamlValue::as_str)
.map(|value| value.to_string())
}))
}
pub(crate) fn read_openapi_version(path: &Path) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_openapi_version_from_content(&content)
}
pub(crate) fn read_json_openapi_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: JsonValue =
serde_json::from_str(content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
Ok(value
.get("info")
.and_then(JsonValue::as_object)
.and_then(|info| info.get("version"))
.and_then(JsonValue::as_str)
.map(|value| value.to_string()))
}
pub(crate) fn read_json_openapi_version(path: &Path) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_json_openapi_version_from_content(&content)
}
pub(crate) fn write_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: YamlValue =
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
let root: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
let info_key: YamlValue = YamlValue::String("info".to_string());
if !matches!(root.get(&info_key), Some(YamlValue::Mapping(_))) {
root.insert(info_key.clone(), YamlValue::Mapping(YamlMapping::new()));
}
let info: &mut YamlMapping = root
.get_mut(&info_key)
.and_then(YamlValue::as_mapping_mut)
.ok_or_else(|| "Expected `info` to be a YAML mapping".to_string())?;
info.insert(
YamlValue::String("version".to_string()),
YamlValue::String(version.to_string()),
);
fs::write(
path,
serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
pub(crate) fn write_json_openapi_version(path: &Path, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: JsonValue =
serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
let root = value
.as_object_mut()
.ok_or_else(|| "Expected a JSON object".to_string())?;
let info = root
.entry("info".to_string())
.or_insert_with(|| JsonValue::Object(serde_json::Map::new()));
let info_object = info
.as_object_mut()
.ok_or_else(|| "Expected `info` to be a JSON object".to_string())?;
info_object.insert(
"version".to_string(),
JsonValue::String(version.to_string()),
);
fs::write(
path,
serde_json::to_string_pretty(&value)
.map_err(|e| format!("Failed to serialize JSON: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
pub(crate) fn read_toml_root_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
Ok(value
.get("version")
.and_then(TomlValue::as_str)
.map(|value| value.to_string()))
}
pub(crate) fn read_toml_root_version(path: &Path) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_toml_root_version_from_content(&content)
}
pub(crate) fn write_toml_root_version(path: &Path, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: TomlValue =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let table = value
.as_table_mut()
.ok_or_else(|| "Expected a TOML table".to_string())?;
table.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
fs::write(
path,
toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
pub(crate) fn read_cargo_toml_version_from_content(content: &str) -> Result<Option<String>, String> {
crate::utils::cargo_manifest::resolve_cargo_package_version_from_content(content)
}
pub(crate) fn read_cargo_toml_version(path: &Path) -> Result<Option<String>, String> {
resolve_cargo_package_version(path)
}
pub(crate) fn write_cargo_toml_version(path: &Path, version: &Version) -> Result<bool, String> {
write_cargo_package_version(path, version)
}
pub(crate) fn read_pyproject_version_from_content(content: &str) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let project_version = value
.get("project")
.and_then(TomlValue::as_table)
.and_then(|project| project.get("version"))
.and_then(TomlValue::as_str);
let poetry_version = value
.get("tool")
.and_then(TomlValue::as_table)
.and_then(|tool| tool.get("poetry"))
.and_then(TomlValue::as_table)
.and_then(|poetry| poetry.get("version"))
.and_then(TomlValue::as_str);
Ok(project_version
.or(poetry_version)
.map(|value| value.to_string()))
}
pub(crate) fn read_pyproject_version(path: &Path) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_pyproject_version_from_content(&content)
}
pub(crate) fn write_pyproject_version(path: &Path, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: TomlValue =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
if let Some(project) = value.get_mut("project").and_then(TomlValue::as_table_mut) {
project.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
} else if let Some(poetry) = value
.get_mut("tool")
.and_then(TomlValue::as_table_mut)
.and_then(|tool| tool.get_mut("poetry"))
.and_then(TomlValue::as_table_mut)
{
poetry.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
} else {
let table: &mut toml::map::Map<String, TomlValue> = value
.as_table_mut()
.ok_or_else(|| "Expected a TOML table".to_string())?;
table.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
}
fs::write(
path,
toml::to_string_pretty(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
pub(crate) fn read_cargo_lock_version_from_content(
content: &str,
cargo_toml_content: Option<&str>,
) -> Result<Option<String>, String> {
read_cargo_lock_version_from_content_with_package(content, cargo_toml_content, None)
}
pub(crate) fn read_cargo_lock_version_from_content_with_package(
content: &str,
cargo_toml_content: Option<&str>,
package_name_override: Option<&str>,
) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let package_name = if let Some(package_name_override) = package_name_override {
package_name_override.trim().to_string()
} else {
let cargo_toml_content = cargo_toml_content
.ok_or_else(|| "Missing Cargo.toml content for Cargo.lock".to_string())?;
cargo_package_name_from_content(cargo_toml_content)?
};
Ok(value
.get("package")
.and_then(TomlValue::as_array)
.and_then(|packages| {
packages.iter().find_map(|package| {
let table = package.as_table()?;
if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
table
.get("version")
.and_then(TomlValue::as_str)
.map(|value| value.to_string())
} else {
None
}
})
}))
}
pub(crate) fn read_cargo_lock_version(path: &Path) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let cargo_toml: String = fs::read_to_string(
path.parent()
.unwrap_or_else(|| Path::new("."))
.join("Cargo.toml"),
)
.map_err(|e| format!("Failed to read file: {}", e))?;
read_cargo_lock_version_from_content(&content, Some(&cargo_toml))
}
pub(crate) fn read_cargo_lock_version_for_package(
path: &Path,
package_name: &str,
) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_cargo_lock_version_from_content_with_package(&content, None, Some(package_name))
}
pub(crate) fn write_cargo_lock_version(path: &Path, version: &Version) -> Result<bool, String> {
write_cargo_lock_version_for_package(path, None, version)
}
pub(crate) fn write_cargo_lock_version_for_package(
path: &Path,
package_name_override: Option<&str>,
version: &Version,
) -> Result<bool, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: TomlValue =
toml::from_str(&content).map_err(|e| format!("Failed to parse TOML: {}", e))?;
let package_name = if let Some(package_name_override) = package_name_override {
package_name_override.trim().to_string()
} else {
let Some(package_name) = cargo_package_name(path)? else {
return Ok(false);
};
package_name
};
let packages: &mut Vec<TomlValue> = value
.get_mut("package")
.and_then(TomlValue::as_array_mut)
.ok_or_else(|| "Expected `package` entries in Cargo.lock".to_string())?;
let mut updated = false;
for package in packages {
if let Some(table) = package.as_table_mut() {
if table.get("name").and_then(TomlValue::as_str) == Some(package_name.as_str()) {
table.insert(
"version".to_string(),
TomlValue::String(version.to_string()),
);
updated = true;
}
}
}
if !updated {
return Err(format!(
"Could not find package `{}` in Cargo.lock",
package_name
));
}
fs::write(
path,
toml::to_string(&value).map_err(|e| format!("Failed to serialize TOML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))?;
Ok(true)
}
pub(crate) fn cargo_package_name(path: &Path) -> Result<Option<String>, String> {
let cargo_toml: PathBuf = path
.parent()
.unwrap_or_else(|| Path::new("."))
.join("Cargo.toml");
let content: String = fs::read_to_string(&cargo_toml)
.map_err(|e| format!("Failed to read {}: {}", cargo_toml.display(), e))?;
cargo_package_name_from_content_optional(&content)
}
pub(crate) fn cargo_package_name_from_content(content: &str) -> Result<String, String> {
cargo_package_name_from_content_optional(content)?
.ok_or_else(|| "Could not determine Cargo package name".to_string())
}
pub(crate) fn cargo_package_name_from_content_optional(content: &str) -> Result<Option<String>, String> {
let value: TomlValue =
toml::from_str(content).map_err(|e| format!("Failed to parse Cargo.toml: {}", e))?;
Ok(value
.get("package")
.and_then(TomlValue::as_table)
.and_then(|package| package.get("name"))
.and_then(TomlValue::as_str)
.map(|value| value.to_string()))
}
pub(crate) fn read_readme_version_from_content(content: &str) -> Result<Option<String>, String> {
read_regex_version_from_content(content, r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
}
pub(crate) fn read_readme_version(path: &Path) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_readme_version_from_content(&content)
}
pub(crate) fn write_readme_version(path: &Path, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let marker: String = format!("current version: `{}`", version);
let regex: Regex = Regex::new(r#"(?im)^current version:\s*`?([^`\s]+)`?\s*$"#)
.map_err(|e| format!("Failed to build README regex: {}", e))?;
let updated: String = if regex.is_match(&content) {
regex.replace(&content, marker.as_str()).to_string()
} else if let Some(first_break) = content.find('\n') {
let mut next = String::new();
next.push_str(&content[..=first_break]);
next.push('\n');
next.push_str(&marker);
next.push('\n');
next.push_str(&content[first_break + 1..]);
next
} else {
format!("{}\n\n{}\n", content, marker)
};
fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
}
pub(crate) fn read_regex_version(path: &Path, pattern: &str) -> Result<Option<String>, String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
read_regex_version_from_content(&content, pattern)
}
pub(crate) fn read_regex_version_from_content(content: &str, pattern: &str) -> Result<Option<String>, String> {
let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
Ok(regex
.captures(content)
.and_then(|captures| captures.get(1))
.map(|matched| matched.as_str().trim().to_string()))
}
pub(crate) fn write_regex_version(path: &Path, pattern: &str, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let regex: Regex = Regex::new(pattern).map_err(|e| format!("Invalid regex: {}", e))?;
if !regex.is_match(&content) {
return Err("No version pattern found".to_string());
}
let updated: String = regex
.replace(&content, |caps: ®ex::Captures<'_>| {
caps[0].replace(&caps[1], &version.to_string())
})
.to_string();
fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))
}
#[cfg(test)]
pub(crate) fn write_package_version_to_configured_files(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
package_name: &str,
version: &Version,
) -> Result<usize, String> {
write_package_version_to_configured_files_with_paths(
project_root,
invocation_dir,
registry,
version_scope,
package_name,
version,
)
.map(|paths| paths.len())
}
pub(crate) fn write_package_version_to_configured_files_with_paths(
project_root: &Path,
invocation_dir: &Path,
registry: &[String],
version_scope: &VersionScope,
package_name: &str,
version: &Version,
) -> Result<Vec<PathBuf>, String> {
let mut updated: usize = 0usize;
let mut updated_paths = Vec::new();
let mut errors: Vec<String> = Vec::new();
for entry in resolve_registry_paths(project_root, invocation_dir, registry, version_scope) {
let path: &PathBuf = &entry.absolute;
if !path.exists() {
continue;
}
match write_package_version_to_path(path, package_name, version) {
Ok(true) => {
updated += 1;
updated_paths.push(path.clone());
}
Ok(false) => {}
Err(err) => errors.push(format!("{}: {}", path.display(), err)),
}
}
if updated == 0 && errors.is_empty() {
return Err(format!(
"No configured TOML files contained package assignment `{}`.",
package_name
));
}
if !errors.is_empty() {
return Err(format!(
"Updated {} file(s), but some package version targets failed:\n{}",
updated,
errors.join("\n")
));
}
Ok(updated_paths)
}
pub(crate) fn write_package_version_to_path(
path: &Path,
package_name: &str,
version: &Version,
) -> Result<bool, String> {
let is_toml: bool = matches!(path.extension().and_then(|ext| ext.to_str()), Some("toml"));
if !is_toml {
return Ok(false);
}
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let (updated, changed) =
rewrite_toml_package_assignment_versions(&content, package_name, version)?;
if changed {
fs::write(path, updated).map_err(|e| format!("Failed to write file: {}", e))?;
}
Ok(changed)
}
pub(crate) fn rewrite_toml_package_assignment_versions(
content: &str,
package_name: &str,
version: &Version,
) -> Result<(String, bool), String> {
let escaped_name: String = regex::escape(package_name);
let inline_pattern: String = format!(
r#"(?m)^(\s*{}\s*=\s*\{{[^\n]*?\bversion\s*=\s*")([^"]+)(")"#,
escaped_name
);
let string_pattern: String = format!(r#"(?m)^(\s*{}\s*=\s*")([^"]+)(".*)$"#, escaped_name);
let inline_regex: Regex =
Regex::new(&inline_pattern).map_err(|e| format!("Invalid inline-table regex: {}", e))?;
let string_regex: Regex =
Regex::new(&string_pattern).map_err(|e| format!("Invalid string regex: {}", e))?;
let replacement: String = version.to_string();
let after_inline: String = inline_regex
.replace_all(content, |caps: ®ex::Captures<'_>| {
format!("{}{}{}", &caps[1], replacement, &caps[3])
})
.to_string();
let after_string: String = string_regex
.replace_all(&after_inline, |caps: ®ex::Captures<'_>| {
format!("{}{}{}", &caps[1], replacement, &caps[3])
})
.to_string();
Ok((after_string.clone(), after_string != content))
}
pub(crate) fn write_chart_version(path: &Path, version: &Version) -> Result<(), String> {
let content: String =
fs::read_to_string(path).map_err(|e| format!("Failed to read file: {}", e))?;
let mut value: YamlValue =
serde_yaml::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
let mapping: &mut YamlMapping = yaml_root_mapping_mut(&mut value)?;
mapping.insert(
YamlValue::String("version".to_string()),
YamlValue::String(version.to_string()),
);
if mapping.contains_key(YamlValue::String("appVersion".to_string())) {
mapping.insert(
YamlValue::String("appVersion".to_string()),
YamlValue::String(version.to_string()),
);
}
fs::write(
path,
serde_yaml::to_string(&value).map_err(|e| format!("Failed to serialize YAML: {}", e))?,
)
.map_err(|e| format!("Failed to write file: {}", e))
}
pub(crate) fn yaml_root_mapping_mut(value: &mut YamlValue) -> Result<&mut YamlMapping, String> {
value
.as_mapping_mut()
.ok_or_else(|| "Expected a YAML mapping".to_string())
}
pub(crate) fn yaml_get_mapping<'a>(value: &'a YamlValue, key: &str) -> Option<&'a YamlMapping> {
value
.as_mapping()
.and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
.and_then(YamlValue::as_mapping)
}
pub(crate) fn yaml_get_string(value: &YamlValue, key: &str) -> Option<String> {
value
.as_mapping()
.and_then(|mapping| mapping.get(YamlValue::String(key.to_string())))
.and_then(YamlValue::as_str)
.map(|value| value.to_string())
}
pub(crate) fn json_lookup_string(value: &JsonValue, key_parts: &[&str]) -> Option<String> {
let mut current: &JsonValue = value;
for part in key_parts {
current = current.get(*part)?;
}
current.as_str().map(|value| value.to_string())
}
pub(crate) fn yaml_lookup_string(value: &YamlValue, key_parts: &[&str]) -> Option<String> {
let mut current = value;
for part in key_parts {
let mapping = current.as_mapping()?;
current = mapping.get(YamlValue::String((*part).to_string()))?;
}
current.as_str().map(|value| value.to_string())
}
pub(crate) fn toml_lookup_string(value: &TomlValue, key_parts: &[&str]) -> Option<String> {
let mut current = value;
for part in key_parts {
current = current.get(*part)?;
}
current.as_str().map(|value| value.to_string())
}
pub(crate) fn parse_version(input: &str) -> Result<Version, String> {
let trimmed: &str = input.trim();
let normalized: &str = trimmed.strip_prefix('v').unwrap_or(trimmed);
Version::parse(normalized).map_err(|e| format!("Invalid semantic version `{}`: {}", input, e))
}
pub(crate) fn parse_release_version_target(input: &str) -> Result<(Version, String), String> {
let trimmed = input.trim();
if trimmed.is_empty() {
return Err("Release version cannot be empty.".to_string());
}
if let Ok(version) = parse_version(trimmed) {
return Ok((version.clone(), format!("v{}", version)));
}
let prefixed = Regex::new(
r"^(?P<prefix>[A-Za-z][A-Za-z0-9._-]*-)(?P<semver>\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?)$",
)
.map_err(|e| format!("Failed to build release target parser: {}", e))?;
if let Some(captures) = prefixed.captures(trimmed) {
let semver = captures
.name("semver")
.map(|m| m.as_str())
.ok_or_else(|| format!("Invalid release target `{}`.", input))?;
let version = Version::parse(semver)
.map_err(|e| format!("Invalid semantic version `{}`: {}", semver, e))?;
return Ok((version, trimmed.to_string()));
}
Err(format!(
"Invalid release version target `{}`. Use semantic version like `1.2.3`/`1.2.3-alpha` or prefixed form like `studio-1.2.3-alpha`.",
input
))
}
pub(crate) fn normalize_release_flag(flag: &str) -> Result<String, String> {
let normalized = flag.trim().to_ascii_lowercase();
match normalized.as_str() {
"dev" | "stable" | "beta" | "alpha" | "nightly" | "exp" => Ok(normalized),
_ => Err(format!(
"Invalid release flag `{}`. Use one of: dev, stable, beta, alpha, nightly, exp.",
flag
)),
}
}
pub(crate) fn apply_release_flag_to_version(
mut version: Version,
flag: Option<&str>,
) -> Result<Version, String> {
let Some(flag) = flag else {
return Ok(version);
};
let flag = normalize_release_flag(flag)?;
if version.build.is_empty() {
version.build = semver::BuildMetadata::new(&flag)
.map_err(|error| format!("Invalid release flag `{}`: {}", flag, error))?;
return Ok(version);
}
if version.build.as_str() == flag {
return Ok(version);
}
Err(format!(
"Release version `{}` already has build metadata `+{}`; remove `--flag {}` or pass a matching flag.",
version,
version.build,
flag
))
}
pub(crate) fn parse_package_version_target(input: &str) -> Result<Option<(String, Version)>, String> {
let Some((raw_package, raw_version)) = input.split_once('=') else {
return Ok(None);
};
let package_name = raw_package.trim();
if package_name.is_empty() {
return Ok(None);
}
let package_name_regex: Regex = Regex::new(r"^[A-Za-z0-9._-]+$")
.map_err(|e| format!("Failed to build package-name validator: {}", e))?;
if !package_name_regex.is_match(package_name) {
return Err(format!(
"Invalid package target `{}`. Use `package=1.2.3` with only letters, digits, `-`, `_`, or `.` in the package name.",
input
));
}
let version = parse_version(raw_version.trim())?;
Ok(Some((package_name.to_string(), version)))
}
pub(crate) fn bump_version(current: &Version, kind: &str) -> Version {
let mut next = current.clone();
match kind {
"major" => {
next.major += 1;
next.minor = 0;
next.patch = 0;
next.pre = semver::Prerelease::EMPTY;
next.build = semver::BuildMetadata::EMPTY;
}
"minor" => {
next.minor += 1;
next.patch = 0;
next.pre = semver::Prerelease::EMPTY;
next.build = semver::BuildMetadata::EMPTY;
}
_ => {
next.patch += 1;
next.pre = semver::Prerelease::EMPTY;
next.build = semver::BuildMetadata::EMPTY;
}
}
next
}
pub(crate) fn default_version() -> Version {
Version::new(0, 1, 0)
}