use crate::config::Language;
use crate::error::{ApiForgeError, Result};
use crate::integrations::git::GitRepo;
use crate::steps::{Step, StepContext, StepOutput};
use crate::utils::version::read_version;
use crate::utils::{bump_version, BumpType};
use async_trait::async_trait;
use std::fs;
use std::path::PathBuf;
use std::sync::RwLock;
pub struct VersionBumpStep {
bump_type: BumpType,
original_content: RwLock<Option<(PathBuf, String)>>,
}
impl VersionBumpStep {
pub fn new(bump_type: BumpType) -> Self {
Self {
bump_type,
original_content: RwLock::new(None),
}
}
fn write_rust_version(path: &PathBuf, new_version: &str) -> Result<()> {
let content = fs::read_to_string(path)?;
let mut doc: toml_edit::DocumentMut = content
.parse()
.map_err(|e| ApiForgeError::Config(format!("Failed to parse Cargo.toml: {}", e)))?;
doc["package"]["version"] = toml_edit::value(new_version);
fs::write(path, doc.to_string())?;
Ok(())
}
fn write_node_version(path: &PathBuf, new_version: &str) -> Result<()> {
let content = fs::read_to_string(path)?;
let mut json: serde_json::Value = serde_json::from_str(&content)
.map_err(|e| ApiForgeError::Config(format!("Failed to parse package.json: {}", e)))?;
json["version"] = serde_json::Value::String(new_version.to_string());
let pretty = serde_json::to_string_pretty(&json).map_err(|e| {
ApiForgeError::Serialization(format!("Failed to serialize package.json: {}", e))
})?;
fs::write(path, format!("{}\n", pretty))?;
Ok(())
}
fn write_python_version(path: &PathBuf, new_version: &str) -> Result<()> {
let content = fs::read_to_string(path)?;
let mut doc: toml_edit::DocumentMut = content
.parse()
.map_err(|e| ApiForgeError::Config(format!("Failed to parse pyproject.toml: {}", e)))?;
if doc.get("tool").and_then(|t| t.get("poetry")).is_some() {
doc["tool"]["poetry"]["version"] = toml_edit::value(new_version);
} else if doc.get("project").is_some() {
doc["project"]["version"] = toml_edit::value(new_version);
} else {
return Err(ApiForgeError::Config(
"Could not find version location in pyproject.toml (expected tool.poetry or project section)".to_string()
));
}
fs::write(path, doc.to_string())?;
Ok(())
}
fn write_go_version(path: &PathBuf, new_version: &str) -> Result<()> {
let version_file = path
.parent()
.map(|p| p.join("version.go"))
.filter(|p| p.exists());
if let Some(vf) = version_file {
let content = fs::read_to_string(&vf)?;
let mut new_content = String::new();
let mut found = false;
for line in content.lines() {
if line.contains("Version") && line.contains('=') && !found {
if let Some(quote_start) = line.find('"') {
if let Some(quote_end) = line[quote_start + 1..].find('"') {
let new_line = format!(
"{}\"{}\"{}",
&line[..quote_start + 1],
new_version,
&line[quote_start + 1 + quote_end..]
);
new_content.push_str(&new_line);
new_content.push('\n');
found = true;
continue;
}
}
}
new_content.push_str(line);
new_content.push('\n');
}
if found {
fs::write(&vf, new_content)?;
return Ok(());
}
}
let content = fs::read_to_string(path)?;
let new_content = format!("{}\n// {}\n", content.trim_end(), new_version);
fs::write(path, new_content)?;
tracing::warn!("Wrote version to go.mod comment. Consider creating a version.go file for better version management.");
Ok(())
}
fn write_java_version(path: &PathBuf, new_version: &str) -> Result<()> {
let content = fs::read_to_string(path)?;
let mut new_content = String::new();
let mut found = false;
let mut in_project = false;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.contains("<project") && !trimmed.contains("</project>") {
in_project = true;
}
if trimmed == "</project>" {
in_project = false;
}
if in_project
&& !found
&& trimmed.starts_with("<version>")
&& trimmed.ends_with("</version>")
{
let start = trimmed.find("<version>").unwrap() + "<version>".len();
let end = trimmed.find("</version>").unwrap();
let current = &trimmed[start..end];
if !current.starts_with("${") {
let indent = line.len() - line.trim_start().len();
let spaces = " ".repeat(indent);
new_content
.push_str(&format!("{}<version>{}</version>\n", spaces, new_version));
found = true;
continue;
}
}
new_content.push_str(line);
new_content.push('\n');
}
if !found {
return Err(ApiForgeError::Config(
"Could not find project version to update in pom.xml".to_string(),
));
}
fs::write(path, new_content)?;
Ok(())
}
fn get_version_file_path(&self, ctx: &StepContext) -> Result<PathBuf> {
let repo = GitRepo::open()?;
let root = repo.root_path();
let version_file = ctx.config.project.language.version_file();
Ok(root.join(version_file))
}
fn write_version(&self, ctx: &StepContext, path: &PathBuf, version: &str) -> Result<()> {
match ctx.config.project.language {
Language::Rust => Self::write_rust_version(path, version),
Language::Node => Self::write_node_version(path, version),
Language::Python => Self::write_python_version(path, version),
Language::Go => Self::write_go_version(path, version),
Language::Java => Self::write_java_version(path, version),
}
}
}
#[async_trait]
impl Step for VersionBumpStep {
fn name(&self) -> &str {
"version-bump"
}
fn description(&self) -> &str {
"Bump project version"
}
async fn validate(&self, ctx: &StepContext) -> Result<()> {
let path = self.get_version_file_path(ctx)?;
if !path.exists() {
return Err(ApiForgeError::Config(format!(
"Version file not found: {}",
path.display()
)));
}
read_version(ctx.config.project.language, &path)?;
Ok(())
}
async fn execute(&self, ctx: &StepContext) -> Result<StepOutput> {
let path = self.get_version_file_path(ctx)?;
let original = fs::read_to_string(&path)?;
{
let mut guard = self
.original_content
.write()
.map_err(|_| ApiForgeError::StepFailed("Failed to acquire lock".to_string()))?;
*guard = Some((path.clone(), original));
}
let current = read_version(ctx.config.project.language, &path)?;
let new_version = bump_version(¤t, self.bump_type)?;
self.write_version(ctx, &path, &new_version.to_string())?;
Ok(StepOutput::ok(format!(
"Bumped version from {} to {}",
current, new_version
)))
}
async fn dry_run(&self, ctx: &StepContext) -> Result<StepOutput> {
let path = self.get_version_file_path(ctx)?;
let current = read_version(ctx.config.project.language, &path)?;
let new_version = bump_version(¤t, self.bump_type)?;
let new_version_str = new_version.to_string();
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("version file");
let change_preview = match ctx.config.project.language {
Language::Rust => {
format!(
" - {}: \"{}\" → \"{}\"",
file_name, current, new_version_str
)
}
Language::Node => {
format!(
" - {}: \"{}\" → \"{}\"",
file_name, current, new_version_str
)
}
Language::Python => {
format!(
" - {}: \"{}\" → \"{}\"",
file_name, current, new_version_str
)
}
_ => format!(" - {}: {} → {}", file_name, current, new_version_str),
};
let file_change = crate::steps::FileChange {
path: path.display().to_string(),
operation: crate::steps::FileOperation::Modify,
diff: Some(change_preview),
};
let details = crate::steps::DryRunDetails {
file_changes: vec![file_change],
docker_preview: None,
notes: vec![format!(
"Version bump from {} to {} ({} bump)",
current, new_version_str, self.bump_type
)],
};
Ok(StepOutput::ok(format!(
"Would bump version from {} to {}",
current, new_version
))
.with_dry_run_details(details))
}
async fn rollback(&self, _ctx: &StepContext) -> Result<()> {
let restored = {
let guard = self
.original_content
.read()
.map_err(|_| ApiForgeError::StepFailed("Failed to acquire lock".to_string()))?;
if let Some((ref path, ref content)) = *guard {
fs::write(path, content)?;
tracing::info!("Restored {} from saved original content", path.display());
true
} else {
false
}
};
if !restored {
tracing::warn!(
"No original content saved for version file rollback. \
The file may need to be manually restored if there were uncommitted changes."
);
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::version::{read_go_version, read_java_version, read_python_version};
use std::io::Write;
use tempfile::NamedTempFile;
#[test]
fn test_read_python_version_poetry() {
let content = r#"
[tool.poetry]
name = "my-app"
version = "1.2.3"
description = "Test app"
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let result = read_python_version(file.path());
assert_eq!(result.unwrap(), "1.2.3");
}
#[test]
fn test_read_python_version_pep621() {
let content = r#"
[project]
name = "my-app"
version = "2.0.0"
description = "Test app"
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let result = read_python_version(file.path());
assert_eq!(result.unwrap(), "2.0.0");
}
#[test]
fn test_write_python_version_poetry() {
let content = r#"[tool.poetry]
name = "my-app"
version = "1.0.0"
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
VersionBumpStep::write_python_version(&file.path().to_path_buf(), "1.1.0").unwrap();
let new_content = fs::read_to_string(file.path()).unwrap();
assert!(new_content.contains("version = \"1.1.0\""));
}
#[test]
fn test_read_java_version() {
let content = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>3.4.5</version>
</project>
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let result = read_java_version(file.path());
assert_eq!(result.unwrap(), "3.4.5");
}
#[test]
fn test_write_java_version() {
let content = r#"<?xml version="1.0" encoding="UTF-8"?>
<project>
<version>1.0.0</version>
</project>
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
VersionBumpStep::write_java_version(&file.path().to_path_buf(), "2.0.0").unwrap();
let new_content = fs::read_to_string(file.path()).unwrap();
assert!(new_content.contains("<version>2.0.0</version>"));
}
#[test]
fn test_read_go_version_from_mod() {
let content = r#"
module github.com/example/app
go 1.21
// v1.2.3
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
let result = read_go_version(file.path());
assert_eq!(result.unwrap(), "v1.2.3");
}
#[test]
fn test_write_go_version_to_mod() {
let content = r#"module github.com/example/app
go 1.21
"#;
let mut file = NamedTempFile::new().unwrap();
file.write_all(content.as_bytes()).unwrap();
VersionBumpStep::write_go_version(&file.path().to_path_buf(), "1.2.4").unwrap();
let new_content = fs::read_to_string(file.path()).unwrap();
assert!(new_content.contains("// 1.2.4"));
}
}