use std::fs;
use std::path::{Path, PathBuf};
use regex::Regex;
use crate::error::ReleaseError;
pub trait VersionFileHandler: Send + Sync {
fn name(&self) -> &str;
fn manifest_names(&self) -> &[&str];
fn lock_file_names(&self) -> &[&str];
fn detect(&self, dir: &Path) -> bool {
self.manifest_names()
.iter()
.any(|name| dir.join(name).exists())
}
fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError>;
}
struct CargoHandler;
impl VersionFileHandler for CargoHandler {
fn name(&self) -> &str {
"Cargo"
}
fn manifest_names(&self) -> &[&str] {
&["Cargo.toml"]
}
fn lock_file_names(&self) -> &[&str] {
&["Cargo.lock"]
}
fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
bump_cargo_toml(path, new_version)
}
}
struct NpmHandler;
impl VersionFileHandler for NpmHandler {
fn name(&self) -> &str {
"npm"
}
fn manifest_names(&self) -> &[&str] {
&["package.json"]
}
fn lock_file_names(&self) -> &[&str] {
&["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]
}
fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
bump_package_json(path, new_version)
}
}
struct PyprojectHandler;
impl VersionFileHandler for PyprojectHandler {
fn name(&self) -> &str {
"Python"
}
fn manifest_names(&self) -> &[&str] {
&["pyproject.toml"]
}
fn lock_file_names(&self) -> &[&str] {
&["uv.lock", "poetry.lock"]
}
fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
bump_pyproject_toml(path, new_version)
}
}
struct MavenHandler;
impl VersionFileHandler for MavenHandler {
fn name(&self) -> &str {
"Maven"
}
fn manifest_names(&self) -> &[&str] {
&["pom.xml"]
}
fn lock_file_names(&self) -> &[&str] {
&[]
}
fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
bump_pom_xml(path, new_version).map(|()| vec![])
}
}
struct GradleHandler;
impl VersionFileHandler for GradleHandler {
fn name(&self) -> &str {
"Gradle"
}
fn manifest_names(&self) -> &[&str] {
&["build.gradle", "build.gradle.kts"]
}
fn lock_file_names(&self) -> &[&str] {
&[]
}
fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
bump_gradle(path, new_version).map(|()| vec![])
}
}
struct GoHandler;
impl VersionFileHandler for GoHandler {
fn name(&self) -> &str {
"Go"
}
fn manifest_names(&self) -> &[&str] {
&[]
}
fn lock_file_names(&self) -> &[&str] {
&[]
}
fn detect(&self, dir: &Path) -> bool {
let Ok(entries) = fs::read_dir(dir) else {
return false;
};
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "go")
&& let Ok(contents) = fs::read_to_string(&path)
&& go_version_re().is_match(&contents)
{
return true;
}
}
false
}
fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
bump_go_version(path, new_version).map(|()| vec![])
}
}
pub fn all_handlers() -> Vec<Box<dyn VersionFileHandler>> {
vec![
Box::new(CargoHandler),
Box::new(NpmHandler),
Box::new(PyprojectHandler),
Box::new(MavenHandler),
Box::new(GradleHandler),
Box::new(GoHandler),
]
}
pub fn detect_version_files(dir: &Path) -> Vec<String> {
let mut files = Vec::new();
for handler in all_handlers() {
if !handler.detect(dir) {
continue;
}
if handler.manifest_names().is_empty() {
if let Ok(entries) = fs::read_dir(dir) {
let re = go_version_re();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().is_some_and(|e| e == "go")
&& let Ok(contents) = fs::read_to_string(&path)
&& re.is_match(&contents)
{
files.push(path.file_name().unwrap().to_string_lossy().into_owned());
}
}
}
} else {
for name in handler.manifest_names() {
if dir.join(name).exists() {
files.push((*name).to_string());
}
}
}
}
files
}
fn handler_for_file(filename: &str) -> Option<Box<dyn VersionFileHandler>> {
for handler in all_handlers() {
if handler.manifest_names().contains(&filename) {
return Some(handler);
}
}
if filename.ends_with(".go") {
return Some(Box::new(GoHandler));
}
None
}
pub fn bump_version_file(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
match handler_for_file(filename) {
Some(handler) => handler.bump(path, new_version),
None => Err(ReleaseError::VersionBump(format!(
"unsupported version file: {filename}"
))),
}
}
pub fn discover_lock_files(bumped_files: &[String]) -> Vec<PathBuf> {
let handlers = all_handlers();
let mut seen = std::collections::BTreeSet::new();
for file in bumped_files {
let path = Path::new(file);
let filename = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
let mut lock_names: Vec<&str> = Vec::new();
for handler in &handlers {
if handler.manifest_names().contains(&filename) {
lock_names.extend(handler.lock_file_names());
}
}
let mut dir = path.parent();
while let Some(d) = dir {
for lock_name in &lock_names {
let lock_path = d.join(lock_name);
if lock_path.exists() {
seen.insert(lock_path);
}
}
dir = d.parent();
if d.join(".git").exists() {
break;
}
}
}
seen.into_iter().collect()
}
pub fn is_supported_version_file(filename: &str) -> bool {
handler_for_file(filename).is_some()
}
fn go_version_re() -> Regex {
Regex::new(r#"(?:var|const)\s+Version\s*(?:string\s*)?=\s*""#).unwrap()
}
fn bump_cargo_toml(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
let contents = read_file(path)?;
let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
})?;
let is_workspace = doc
.get("workspace")
.and_then(|w| w.get("package"))
.and_then(|p| p.get("version"))
.is_some();
if doc.get("package").and_then(|p| p.get("version")).is_some() {
doc["package"]["version"] = toml_edit::value(new_version);
} else if is_workspace {
doc["workspace"]["package"]["version"] = toml_edit::value(new_version);
if let Some(deps) = doc
.get_mut("workspace")
.and_then(|w| w.get_mut("dependencies"))
.and_then(|d| d.as_table_like_mut())
{
for (_, dep) in deps.iter_mut() {
if let Some(tbl) = dep.as_table_like_mut()
&& tbl.get("path").is_some()
&& tbl.get("version").is_some()
{
tbl.insert("version", toml_edit::value(new_version));
}
}
}
} else {
return Err(ReleaseError::VersionBump(format!(
"no version field found in {}",
path.display()
)));
}
write_file(path, &doc.to_string())?;
let mut extra = Vec::new();
if is_workspace {
let members = extract_toml_string_array(&doc, &["workspace", "members"]);
let root_dir = path.parent().unwrap_or(Path::new("."));
for member_path in resolve_member_globs(root_dir, &members, "Cargo.toml") {
if member_path.as_path() == path {
continue;
}
match bump_cargo_member(&member_path, new_version) {
Ok(true) => extra.push(member_path),
Ok(false) => {}
Err(e) => eprintln!("warning: {e}"),
}
}
}
Ok(extra)
}
fn bump_cargo_member(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
let contents = read_file(path)?;
let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
})?;
let version_item = doc.get("package").and_then(|p| p.get("version"));
match version_item {
Some(item) if item.is_value() => {
doc["package"]["version"] = toml_edit::value(new_version);
write_file(path, &doc.to_string())?;
Ok(true)
}
_ => Ok(false), }
}
fn bump_package_json(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
let contents = read_file(path)?;
let mut value: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
})?;
let obj = value
.as_object_mut()
.ok_or_else(|| ReleaseError::VersionBump("package.json is not an object".into()))?;
let workspace_patterns: Vec<String> = obj
.get("workspaces")
.and_then(|w| w.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
obj.insert(
"version".into(),
serde_json::Value::String(new_version.into()),
);
let output = serde_json::to_string_pretty(&value).map_err(|e| {
ReleaseError::VersionBump(format!("failed to serialize {}: {e}", path.display()))
})?;
write_file(path, &format!("{output}\n"))?;
let mut extra = Vec::new();
if !workspace_patterns.is_empty() {
let root_dir = path.parent().unwrap_or(Path::new("."));
for member_path in resolve_member_globs(root_dir, &workspace_patterns, "package.json") {
if member_path == path {
continue;
}
match bump_json_version(&member_path, new_version) {
Ok(true) => extra.push(member_path),
Ok(false) => {}
Err(e) => eprintln!("warning: {e}"),
}
}
}
Ok(extra)
}
fn bump_json_version(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
let contents = read_file(path)?;
let mut value: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
})?;
let obj = match value.as_object_mut() {
Some(o) => o,
None => return Ok(false),
};
if obj.get("version").is_none() {
return Ok(false);
}
obj.insert(
"version".into(),
serde_json::Value::String(new_version.into()),
);
let output = serde_json::to_string_pretty(&value).map_err(|e| {
ReleaseError::VersionBump(format!("failed to serialize {}: {e}", path.display()))
})?;
write_file(path, &format!("{output}\n"))?;
Ok(true)
}
fn bump_pyproject_toml(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
let contents = read_file(path)?;
let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
})?;
if doc.get("project").and_then(|p| p.get("version")).is_some() {
doc["project"]["version"] = toml_edit::value(new_version);
} else if doc
.get("tool")
.and_then(|t| t.get("poetry"))
.and_then(|p| p.get("version"))
.is_some()
{
doc["tool"]["poetry"]["version"] = toml_edit::value(new_version);
} else {
return Err(ReleaseError::VersionBump(format!(
"no version field found in {}",
path.display()
)));
}
write_file(path, &doc.to_string())?;
let members = extract_toml_string_array(&doc, &["tool", "uv", "workspace", "members"]);
let mut extra = Vec::new();
if !members.is_empty() {
let root_dir = path.parent().unwrap_or(Path::new("."));
for member_path in resolve_member_globs(root_dir, &members, "pyproject.toml") {
if member_path.as_path() == path {
continue;
}
match bump_pyproject_member(&member_path, new_version) {
Ok(true) => extra.push(member_path),
Ok(false) => {}
Err(e) => eprintln!("warning: {e}"),
}
}
}
Ok(extra)
}
fn bump_pyproject_member(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
let contents = read_file(path)?;
let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
})?;
if doc.get("project").and_then(|p| p.get("version")).is_some() {
doc["project"]["version"] = toml_edit::value(new_version);
} else if doc
.get("tool")
.and_then(|t| t.get("poetry"))
.and_then(|p| p.get("version"))
.is_some()
{
doc["tool"]["poetry"]["version"] = toml_edit::value(new_version);
} else {
return Ok(false); }
write_file(path, &doc.to_string())?;
Ok(true)
}
fn bump_gradle(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
let contents = read_file(path)?;
let re = Regex::new(r#"(version\s*=\s*["'])([^"']*)(["'])"#).unwrap();
if !re.is_match(&contents) {
return Err(ReleaseError::VersionBump(format!(
"no version assignment found in {}",
path.display()
)));
}
let result = re.replacen(&contents, 1, format!("${{1}}{new_version}${{3}}"));
write_file(path, &result)
}
fn bump_pom_xml(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
let contents = read_file(path)?;
let search_start = if let Some(pos) = contents.find("</parent>") {
pos + "</parent>".len()
} else if let Some(pos) = contents.find("</modelVersion>") {
pos + "</modelVersion>".len()
} else {
0
};
let rest = &contents[search_start..];
let re = Regex::new(r"<version>[^<]*</version>").unwrap();
if let Some(m) = re.find(rest) {
let replacement = format!("<version>{new_version}</version>");
let mut result = String::with_capacity(contents.len());
result.push_str(&contents[..search_start + m.start()]);
result.push_str(&replacement);
result.push_str(&contents[search_start + m.end()..]);
write_file(path, &result)
} else {
Err(ReleaseError::VersionBump(format!(
"no <version> element found in {}",
path.display()
)))
}
}
fn bump_go_version(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
let contents = read_file(path)?;
let re = Regex::new(r#"((?:var|const)\s+Version\s*(?:string\s*)?=\s*")([^"]*)(")"#).unwrap();
if !re.is_match(&contents) {
return Err(ReleaseError::VersionBump(format!(
"no Version variable found in {}",
path.display()
)));
}
let result = re.replacen(&contents, 1, format!("${{1}}{new_version}${{3}}"));
write_file(path, &result)
}
fn extract_toml_string_array(doc: &toml_edit::DocumentMut, keys: &[&str]) -> Vec<String> {
let mut item: Option<&toml_edit::Item> = None;
for key in keys {
item = match item {
None => doc.get(key),
Some(parent) => parent.get(key),
};
if item.is_none() {
return vec![];
}
}
item.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
fn resolve_member_globs(root_dir: &Path, patterns: &[String], manifest_name: &str) -> Vec<PathBuf> {
let mut paths = Vec::new();
for pattern in patterns {
let full_pattern = root_dir.join(pattern).to_string_lossy().into_owned();
let Ok(entries) = glob::glob(&full_pattern) else {
continue;
};
for entry in entries.flatten() {
let manifest = if entry.is_dir() {
entry.join(manifest_name)
} else {
continue;
};
if manifest.exists() {
paths.push(manifest);
}
}
}
paths
}
fn read_file(path: &Path) -> Result<String, ReleaseError> {
fs::read_to_string(path)
.map_err(|e| ReleaseError::VersionBump(format!("failed to read {}: {e}", path.display())))
}
fn write_file(path: &Path, contents: &str) -> Result<(), ReleaseError> {
fs::write(path, contents)
.map_err(|e| ReleaseError::VersionBump(format!("failed to write {}: {e}", path.display())))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn bump_cargo_toml_package_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
fs::write(
&path,
r#"[package]
name = "my-crate"
version = "0.1.0"
edition = "2021"
[dependencies]
serde = "1"
"#,
)
.unwrap();
bump_version_file(&path, "1.2.3").unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("version = \"1.2.3\""));
assert!(contents.contains("name = \"my-crate\""));
assert!(contents.contains("serde = \"1\""));
}
#[test]
fn bump_cargo_toml_workspace_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
fs::write(
&path,
r#"[workspace]
members = ["crates/*"]
[workspace.package]
version = "0.0.1"
edition = "2021"
"#,
)
.unwrap();
bump_version_file(&path, "2.0.0").unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("version = \"2.0.0\""));
assert!(contents.contains("members = [\"crates/*\"]"));
}
#[test]
fn bump_package_json_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("package.json");
fs::write(
&path,
r#"{
"name": "my-pkg",
"version": "0.0.0",
"description": "test"
}"#,
)
.unwrap();
bump_version_file(&path, "3.1.0").unwrap();
let contents = fs::read_to_string(&path).unwrap();
let value: serde_json::Value = serde_json::from_str(&contents).unwrap();
assert_eq!(value["version"], "3.1.0");
assert_eq!(value["name"], "my-pkg");
assert_eq!(value["description"], "test");
assert!(contents.ends_with('\n'));
}
#[test]
fn bump_pyproject_toml_project_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pyproject.toml");
fs::write(
&path,
r#"[project]
name = "my-project"
version = "0.1.0"
description = "A test project"
"#,
)
.unwrap();
bump_version_file(&path, "1.0.0").unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("version = \"1.0.0\""));
assert!(contents.contains("name = \"my-project\""));
}
#[test]
fn bump_pyproject_toml_poetry_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pyproject.toml");
fs::write(
&path,
r#"[tool.poetry]
name = "my-poetry-project"
version = "0.2.0"
description = "A poetry project"
"#,
)
.unwrap();
bump_version_file(&path, "0.3.0").unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("version = \"0.3.0\""));
assert!(contents.contains("name = \"my-poetry-project\""));
}
#[test]
fn bump_unknown_file_returns_error() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("unknown.txt");
fs::write(&path, "version = 1").unwrap();
let err = bump_version_file(&path, "1.0.0").unwrap_err();
assert!(matches!(err, ReleaseError::VersionBump(_)));
assert!(err.to_string().contains("unsupported"));
}
#[test]
fn bump_build_gradle_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("build.gradle");
fs::write(
&path,
r#"plugins {
id 'java'
}
group = 'com.example'
version = '1.0.0'
dependencies {
implementation 'org.slf4j:slf4j-api:2.0.0'
}
"#,
)
.unwrap();
bump_version_file(&path, "2.0.0").unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("version = '2.0.0'"));
assert!(contents.contains("group = 'com.example'"));
assert!(contents.contains("slf4j-api:2.0.0"));
}
#[test]
fn bump_build_gradle_kts_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("build.gradle.kts");
fs::write(
&path,
r#"plugins {
kotlin("jvm") version "1.9.0"
}
group = "com.example"
version = "1.0.0"
dependencies {
implementation("org.slf4j:slf4j-api:2.0.0")
}
"#,
)
.unwrap();
bump_version_file(&path, "3.0.0").unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("version = \"3.0.0\""));
assert!(contents.contains("group = \"com.example\""));
}
#[test]
fn bump_pom_xml_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pom.xml");
fs::write(
&path,
r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
</project>
"#,
)
.unwrap();
bump_version_file(&path, "2.0.0").unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("<version>2.0.0</version>"));
assert!(contents.contains("<groupId>com.example</groupId>"));
}
#[test]
fn bump_pom_xml_with_parent_version() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("pom.xml");
fs::write(
&path,
r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.example</groupId>
<artifactId>parent</artifactId>
<version>5.0.0</version>
</parent>
<artifactId>my-app</artifactId>
<version>1.0.0</version>
</project>
"#,
)
.unwrap();
bump_version_file(&path, "2.0.0").unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains("<version>5.0.0</version>"));
assert!(contents.contains("<version>2.0.0</version>"));
let version_count: Vec<&str> = contents.matches("<version>").collect();
assert_eq!(version_count.len(), 2);
}
#[test]
fn bump_cargo_toml_workspace_dependencies_with_path() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
fs::write(
&path,
r#"[workspace]
members = ["crates/*"]
[workspace.package]
version = "0.1.0"
edition = "2021"
[workspace.dependencies]
# Internal crates
sr-core = { path = "crates/sr-core", version = "0.1.0" }
sr-git = { path = "crates/sr-git", version = "0.1.0" }
# External dep should not change
serde = { version = "1", features = ["derive"] }
"#,
)
.unwrap();
bump_version_file(&path, "2.0.0").unwrap();
let contents = fs::read_to_string(&path).unwrap();
let doc: toml_edit::DocumentMut = contents.parse().unwrap();
assert_eq!(
doc["workspace"]["package"]["version"].as_str().unwrap(),
"2.0.0"
);
assert_eq!(
doc["workspace"]["dependencies"]["sr-core"]["version"]
.as_str()
.unwrap(),
"2.0.0"
);
assert_eq!(
doc["workspace"]["dependencies"]["sr-git"]["version"]
.as_str()
.unwrap(),
"2.0.0"
);
assert_eq!(
doc["workspace"]["dependencies"]["serde"]["version"]
.as_str()
.unwrap(),
"1"
);
}
#[test]
fn bump_go_version_var() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("version.go");
fs::write(
&path,
r#"package main
var Version = "1.0.0"
func main() {}
"#,
)
.unwrap();
bump_version_file(&path, "2.0.0").unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains(r#"var Version = "2.0.0""#));
}
#[test]
fn bump_go_version_const() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("version.go");
fs::write(
&path,
r#"package main
const Version string = "0.5.0"
func main() {}
"#,
)
.unwrap();
bump_version_file(&path, "0.6.0").unwrap();
let contents = fs::read_to_string(&path).unwrap();
assert!(contents.contains(r#"const Version string = "0.6.0""#));
}
#[test]
fn bump_cargo_workspace_discovers_members() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("Cargo.toml");
fs::write(
&root,
r#"[workspace]
members = ["crates/*"]
[workspace.package]
version = "1.0.0"
edition = "2021"
[workspace.dependencies]
my-core = { path = "crates/core", version = "1.0.0" }
"#,
)
.unwrap();
fs::create_dir_all(dir.path().join("crates/core")).unwrap();
let member = dir.path().join("crates/core/Cargo.toml");
fs::write(
&member,
r#"[package]
name = "my-core"
version = "1.0.0"
edition = "2021"
"#,
)
.unwrap();
fs::create_dir_all(dir.path().join("crates/cli")).unwrap();
let inherited_member = dir.path().join("crates/cli/Cargo.toml");
fs::write(
&inherited_member,
r#"[package]
name = "my-cli"
version.workspace = true
edition.workspace = true
"#,
)
.unwrap();
let extra = bump_version_file(&root, "2.0.0").unwrap();
let root_contents = fs::read_to_string(&root).unwrap();
assert!(root_contents.contains("version = \"2.0.0\""));
let doc: toml_edit::DocumentMut = root_contents.parse().unwrap();
assert_eq!(
doc["workspace"]["dependencies"]["my-core"]["version"]
.as_str()
.unwrap(),
"2.0.0"
);
let member_contents = fs::read_to_string(&member).unwrap();
assert!(member_contents.contains("version = \"2.0.0\""));
let inherited_contents = fs::read_to_string(&inherited_member).unwrap();
assert!(inherited_contents.contains("version.workspace = true"));
assert_eq!(extra.len(), 1);
assert_eq!(extra[0], member);
}
#[test]
fn bump_npm_workspace_discovers_members() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("package.json");
fs::write(
&root,
r#"{
"name": "my-monorepo",
"version": "1.0.0",
"workspaces": ["packages/*"]
}"#,
)
.unwrap();
fs::create_dir_all(dir.path().join("packages/core")).unwrap();
let member = dir.path().join("packages/core/package.json");
fs::write(
&member,
r#"{
"name": "@my/core",
"version": "1.0.0"
}"#,
)
.unwrap();
fs::create_dir_all(dir.path().join("packages/utils")).unwrap();
let no_version_member = dir.path().join("packages/utils/package.json");
fs::write(
&no_version_member,
r#"{
"name": "@my/utils",
"private": true
}"#,
)
.unwrap();
let extra = bump_version_file(&root, "2.0.0").unwrap();
let root_contents: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&root).unwrap()).unwrap();
assert_eq!(root_contents["version"], "2.0.0");
let member_contents: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&member).unwrap()).unwrap();
assert_eq!(member_contents["version"], "2.0.0");
let utils_contents: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&no_version_member).unwrap()).unwrap();
assert!(utils_contents.get("version").is_none());
assert_eq!(extra.len(), 1);
assert_eq!(extra[0], member);
}
#[test]
fn bump_uv_workspace_discovers_members() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().join("pyproject.toml");
fs::write(
&root,
r#"[project]
name = "my-monorepo"
version = "1.0.0"
[tool.uv.workspace]
members = ["packages/*"]
"#,
)
.unwrap();
fs::create_dir_all(dir.path().join("packages/core")).unwrap();
let member = dir.path().join("packages/core/pyproject.toml");
fs::write(
&member,
r#"[project]
name = "my-core"
version = "1.0.0"
"#,
)
.unwrap();
let extra = bump_version_file(&root, "2.0.0").unwrap();
let root_contents = fs::read_to_string(&root).unwrap();
assert!(root_contents.contains("version = \"2.0.0\""));
let member_contents = fs::read_to_string(&member).unwrap();
assert!(member_contents.contains("version = \"2.0.0\""));
assert_eq!(extra.len(), 1);
assert_eq!(extra[0], member);
}
#[test]
fn bump_non_workspace_returns_empty_extra() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("Cargo.toml");
fs::write(
&path,
r#"[package]
name = "solo-crate"
version = "1.0.0"
"#,
)
.unwrap();
let extra = bump_version_file(&path, "2.0.0").unwrap();
assert!(extra.is_empty());
}
#[test]
fn detect_cargo_toml() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let detected = detect_version_files(dir.path());
assert_eq!(detected, vec!["Cargo.toml"]);
}
#[test]
fn detect_package_json() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"name": "x", "version": "1.0.0"}"#,
)
.unwrap();
let detected = detect_version_files(dir.path());
assert_eq!(detected, vec!["package.json"]);
}
#[test]
fn detect_pyproject_toml() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("pyproject.toml"),
"[project]\nname = \"x\"\nversion = \"0.1.0\"\n",
)
.unwrap();
let detected = detect_version_files(dir.path());
assert_eq!(detected, vec!["pyproject.toml"]);
}
#[test]
fn detect_multiple_ecosystems() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("Cargo.toml"),
"[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
)
.unwrap();
fs::write(
dir.path().join("package.json"),
r#"{"name": "x", "version": "1.0.0"}"#,
)
.unwrap();
let detected = detect_version_files(dir.path());
assert!(detected.contains(&"Cargo.toml".to_string()));
assert!(detected.contains(&"package.json".to_string()));
}
#[test]
fn detect_empty_directory() {
let dir = tempfile::tempdir().unwrap();
let detected = detect_version_files(dir.path());
assert!(detected.is_empty());
}
#[test]
fn detect_go_version_file() {
let dir = tempfile::tempdir().unwrap();
fs::write(
dir.path().join("version.go"),
"package main\n\nvar Version = \"1.0.0\"\n",
)
.unwrap();
let detected = detect_version_files(dir.path());
assert_eq!(detected, vec!["version.go"]);
}
#[test]
fn is_supported_recognizes_all_types() {
assert!(is_supported_version_file("Cargo.toml"));
assert!(is_supported_version_file("package.json"));
assert!(is_supported_version_file("pyproject.toml"));
assert!(is_supported_version_file("pom.xml"));
assert!(is_supported_version_file("build.gradle"));
assert!(is_supported_version_file("build.gradle.kts"));
assert!(is_supported_version_file("version.go"));
assert!(!is_supported_version_file("unknown.txt"));
}
}