use super::variables::VersionVariables;
use anyhow::{Context, Result};
use quick_xml::events::{BytesText, Event};
use quick_xml::reader::Reader;
use quick_xml::writer::Writer;
use regex::Regex;
use rust_i18n::t;
use std::io::Cursor;
use std::path::{Path, PathBuf};
const ASSEMBLY_HEADER: &str = "\
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by GitVersion.
//
// You can modify this code as we will not overwrite it when re-executing GitVersion
// </auto-generated>
//------------------------------------------------------------------------------
";
fn find_recursive(root: &Path, matches: impl Fn(&Path) -> bool) -> Vec<PathBuf> {
let mut out = Vec::new();
let mut stack = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
if path
.file_name()
.map(|n| n.to_string_lossy().starts_with('.'))
.unwrap_or(false)
{
continue;
}
stack.push(path);
} else if matches(&path) {
out.push(path);
}
}
}
out.sort();
out
}
pub fn update_assembly_info(
vars: &VersionVariables,
work_dir: &Path,
files: &[String],
ensure: bool,
) -> Result<Vec<PathBuf>> {
let targets: Vec<PathBuf> = if files.is_empty() {
find_recursive(work_dir, |p| {
let name = p
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
matches!(
name.as_str(),
"assemblyinfo.cs" | "assemblyinfo.vb" | "assemblyinfo.fs"
)
})
} else {
files.iter().map(|f| work_dir.join(f)).collect()
};
let mut updated = Vec::new();
for path in targets {
if path.exists() {
let content = std::fs::read_to_string(&path)
.with_context(|| t!("file.read_failed", path = path.display()).to_string())?;
let new = replace_assembly_attributes(&content, vars);
std::fs::write(&path, new)?;
updated.push(path);
} else if ensure {
let content = create_assembly_info(&path, vars);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).ok();
}
std::fs::write(&path, content)?;
updated.push(path);
}
}
Ok(updated)
}
fn replace_assembly_attributes(content: &str, vars: &VersionVariables) -> String {
let replace_attr = |text: &str, attr: &str, value: &str| -> String {
let re = Regex::new(&format!(r#"({attr}\s*\(\s*")[^"]*("\s*\))"#)).unwrap();
re.replace_all(text, format!("${{1}}{value}${{2}}").as_str())
.into_owned()
};
let mut out = content.to_string();
out = replace_attr(&out, "AssemblyFileVersion", &vars.assembly_sem_file_ver);
out = replace_attr(
&out,
"AssemblyInformationalVersion",
&vars.informational_version,
);
out = replace_attr(&out, "AssemblyVersion", &vars.assembly_sem_ver);
out
}
fn create_assembly_info(path: &Path, vars: &VersionVariables) -> String {
let ext = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
let (fv, av, iv) = (
&vars.assembly_sem_file_ver,
&vars.assembly_sem_ver,
&vars.informational_version,
);
match ext.as_str() {
"vb" => format!(
"{ASSEMBLY_HEADER}\nImports System.Reflection\n\n\
<Assembly: AssemblyFileVersion(\"{fv}\")>\n\
<Assembly: AssemblyVersion(\"{av}\")>\n\
<Assembly: AssemblyInformationalVersion(\"{iv}\")>\n"
),
"fs" => format!(
"{ASSEMBLY_HEADER}\nnamespace AssemblyInfo\n\nopen System.Reflection\n\n\
[<assembly: AssemblyFileVersion(\"{fv}\")>]\n\
[<assembly: AssemblyVersion(\"{av}\")>]\n\
[<assembly: AssemblyInformationalVersion(\"{iv}\")>]\n\
do ()\n"
),
_ => format!(
"{ASSEMBLY_HEADER}\nusing System.Reflection;\n\n\
[assembly: AssemblyFileVersion(\"{fv}\")]\n\
[assembly: AssemblyVersion(\"{av}\")]\n\
[assembly: AssemblyInformationalVersion(\"{iv}\")]\n"
),
}
}
pub fn update_project_files(
vars: &VersionVariables,
work_dir: &Path,
files: &[String],
) -> Result<Vec<PathBuf>> {
let targets: Vec<PathBuf> = if files.is_empty() {
find_recursive(work_dir, |p| {
let ext = p
.extension()
.map(|e| e.to_string_lossy().to_lowercase())
.unwrap_or_default();
matches!(ext.as_str(), "csproj" | "vbproj" | "fsproj")
})
} else {
files.iter().map(|f| work_dir.join(f)).collect()
};
let mut updated = Vec::new();
for path in targets {
if !path.exists() {
continue;
}
let content = std::fs::read_to_string(&path)
.with_context(|| t!("file.read_failed", path = path.display()).to_string())?;
let new = replace_project_elements(&content, vars)
.with_context(|| t!("file.xml_update_failed", path = path.display()).to_string())?;
std::fs::write(&path, new)?;
updated.push(path);
}
Ok(updated)
}
const PROJECT_ELEMENTS: [&str; 4] = [
"AssemblyVersion",
"FileVersion",
"InformationalVersion",
"Version",
];
fn replace_project_elements(content: &str, vars: &VersionVariables) -> Result<String> {
let value_of = |elem: &str| -> &str {
match elem {
"AssemblyVersion" => &vars.assembly_sem_ver,
"FileVersion" => &vars.assembly_sem_file_ver,
"InformationalVersion" => &vars.informational_version,
_ => &vars.sem_ver,
}
};
let mut reader = Reader::from_str(content);
reader.config_mut().trim_text(false);
let mut events: Vec<Event<'static>> = Vec::new();
loop {
match reader.read_event() {
Ok(Event::Eof) => break,
Ok(ev) => events.push(ev.into_owned()),
Err(e) => return Err(anyhow::anyhow!("{}", t!("file.xml_parse_error", error = e))),
}
}
let name_of =
|e: &quick_xml::events::BytesStart| String::from_utf8_lossy(e.name().as_ref()).into_owned();
let end_name_of =
|e: &quick_xml::events::BytesEnd| String::from_utf8_lossy(e.name().as_ref()).into_owned();
let mut existing: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut current: Option<String> = None;
let mut replaced = false;
let mut first_pg_start: Option<usize> = None;
let mut first_pg_end: Option<usize> = None;
let mut child_indent: Option<String> = None;
let mut pg_depth = 0i32;
for i in 0..events.len() {
match &events[i] {
Event::Start(e) => {
let name = name_of(e);
if name == "PropertyGroup" {
pg_depth += 1;
if first_pg_start.is_none() {
first_pg_start = Some(i);
}
}
if PROJECT_ELEMENTS.contains(&name.as_str()) {
existing.insert(name.clone());
current = Some(name);
replaced = false;
}
}
Event::Text(_) => {
if first_pg_start.is_some() && first_pg_end.is_none() && child_indent.is_none() {
if let (Event::Text(t), Some(Event::Start(_))) = (&events[i], events.get(i + 1))
{
let s = String::from_utf8_lossy(t.as_ref()).into_owned();
if s.contains('\n') {
child_indent = Some(s);
}
}
}
if let Some(name) = current.clone() {
if !replaced {
events[i] = Event::Text(BytesText::new(value_of(&name)).into_owned());
replaced = true;
}
}
}
Event::End(e) => {
let name = end_name_of(e);
if current.as_deref() == Some(name.as_str()) {
current = None;
}
if name == "PropertyGroup" {
pg_depth -= 1;
if first_pg_end.is_none() && first_pg_start.is_some() && pg_depth == 0 {
first_pg_end = Some(i);
}
}
}
_ => {}
}
}
let missing: Vec<&str> = PROJECT_ELEMENTS
.iter()
.filter(|e| !existing.contains(**e))
.copied()
.collect();
if let (Some(end_idx), false) = (first_pg_end, missing.is_empty()) {
let indent = child_indent.unwrap_or_else(|| "\n ".into());
let insert_at = if end_idx > 0 && matches!(&events[end_idx - 1], Event::Text(_)) {
end_idx - 1
} else {
end_idx
};
let mut new_events: Vec<Event<'static>> = Vec::new();
for elem in &missing {
new_events.push(Event::Text(BytesText::new(&indent).into_owned()));
new_events.push(Event::Start(
quick_xml::events::BytesStart::new(*elem).into_owned(),
));
new_events.push(Event::Text(BytesText::new(value_of(elem)).into_owned()));
new_events.push(Event::End(
quick_xml::events::BytesEnd::new(*elem).into_owned(),
));
}
events.splice(insert_at..insert_at, new_events);
}
let mut writer = Writer::new(Cursor::new(Vec::new()));
for ev in events {
writer.write_event(ev)?;
}
Ok(String::from_utf8(writer.into_inner().into_inner())?)
}
pub fn update_package_files(
vars: &VersionVariables,
work_dir: &Path,
files: &[String],
) -> Result<Vec<PathBuf>> {
let targets: Vec<PathBuf> = if files.is_empty() {
find_recursive(work_dir, |p| {
let name = p
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
let in_vendor = p.components().any(|c| {
let s = c.as_os_str().to_string_lossy();
s == "node_modules" || s == "vendor" || s == "target"
});
!in_vendor
&& matches!(
name.as_str(),
"package.json" | "cargo.toml" | "pyproject.toml"
)
})
} else {
files.iter().map(|f| work_dir.join(f)).collect()
};
let mut updated = Vec::new();
for path in targets {
if !path.exists() {
continue;
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_lowercase())
.unwrap_or_default();
let content = std::fs::read_to_string(&path)
.with_context(|| t!("file.read_failed", path = path.display()).to_string())?;
let version = &vars.sem_ver;
let new = match name.as_str() {
"package.json" => update_package_json(&content, version)?,
"cargo.toml" => update_cargo_toml(&content, version)?,
"pyproject.toml" => update_pyproject_toml(&content, version)?,
_ => continue,
};
if let Some(new) = new {
std::fs::write(&path, new)?;
updated.push(path);
}
}
Ok(updated)
}
fn update_package_json(content: &str, version: &str) -> Result<Option<String>> {
let mut value: serde_json::Value =
serde_json::from_str(content).with_context(|| t!("file.json_parse_failed").to_string())?;
let serde_json::Value::Object(map) = &mut value else {
return Ok(None);
};
if !map.contains_key("version") {
return Ok(None);
}
map.insert(
"version".into(),
serde_json::Value::String(version.to_string()),
);
let mut out = serde_json::to_string_pretty(&value)?;
out.push('\n'); Ok(Some(out))
}
fn sync_path_dep_versions(deps: &mut dyn toml_edit::TableLike, version: &str) -> bool {
let mut changed = false;
for (_key, item) in deps.iter_mut() {
let Some(dep) = item.as_table_like_mut() else {
continue;
};
if dep.get("path").is_none() {
continue;
}
if let Some(val) = dep.get_mut("version").and_then(|i| i.as_value_mut()) {
if val.is_str() {
let decor = val.decor().clone();
*val = toml_edit::Value::from(version);
*val.decor_mut() = decor;
changed = true;
}
}
}
changed
}
fn update_cargo_toml(content: &str, version: &str) -> Result<Option<String>> {
let mut doc = content
.parse::<toml_edit::DocumentMut>()
.with_context(|| t!("file.cargo_parse_failed").to_string())?;
let update_string_version = |table: &mut toml_edit::Table| -> bool {
if table.get("version").and_then(|v| v.as_str()).is_some() {
table["version"] = toml_edit::value(version);
true
} else {
false
}
};
let mut changed = false;
if let Some(pkg) = doc.get_mut("package").and_then(|p| p.as_table_mut()) {
changed |= update_string_version(pkg);
}
if let Some(ws_pkg) = doc
.get_mut("workspace")
.and_then(|w| w.as_table_mut())
.and_then(|w| w.get_mut("package"))
.and_then(|p| p.as_table_mut())
{
changed |= update_string_version(ws_pkg);
}
if let Some(ws_deps) = doc
.get_mut("workspace")
.and_then(|w| w.as_table_mut())
.and_then(|w| w.get_mut("dependencies"))
.and_then(|d| d.as_table_like_mut())
{
changed |= sync_path_dep_versions(ws_deps, version);
}
for table_name in ["dependencies", "dev-dependencies", "build-dependencies"] {
if let Some(deps) = doc.get_mut(table_name).and_then(|d| d.as_table_like_mut()) {
changed |= sync_path_dep_versions(deps, version);
}
}
Ok(if changed { Some(doc.to_string()) } else { None })
}
fn update_pyproject_toml(content: &str, version: &str) -> Result<Option<String>> {
let mut doc = content
.parse::<toml_edit::DocumentMut>()
.with_context(|| t!("file.pyproject_parse_failed").to_string())?;
let mut changed = false;
if let Some(project) = doc.get_mut("project").and_then(|p| p.as_table_mut()) {
if project.contains_key("version") {
project["version"] = toml_edit::value(version);
changed = true;
}
}
if let Some(poetry) = doc
.get_mut("tool")
.and_then(|t| t.as_table_mut())
.and_then(|t| t.get_mut("poetry"))
.and_then(|p| p.as_table_mut())
{
if poetry.contains_key("version") {
poetry["version"] = toml_edit::value(version);
changed = true;
}
}
Ok(if changed { Some(doc.to_string()) } else { None })
}
pub fn write_wix(vars: &VersionVariables, work_dir: &Path) -> Result<PathBuf> {
let mut s = String::new();
s.push('\u{feff}'); s.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
s.push_str("<Include xmlns=\"http://schemas.microsoft.com/wix/2006/wi\">\n");
for (key, value) in vars.to_map() {
s.push_str(&format!(" <?define {key}=\"{value}\"?>\n"));
}
s.push_str("</Include>");
let path = work_dir.join("GitVersion_WixVersion.wxi");
std::fs::write(&path, s)?;
Ok(path)
}
#[cfg(test)]
mod tests {
use super::*;
fn vars() -> VersionVariables {
VersionVariables {
assembly_sem_ver: "1.0.1.0".into(),
assembly_sem_file_ver: "1.0.1.0".into(),
informational_version: "1.0.1-1+Branch.main".into(),
sem_ver: "1.0.1-1".into(),
..Default::default()
}
}
#[test]
fn assembly_attribute_replacement() {
let src = "[assembly: AssemblyVersion(\"0.0.0.0\")]\n\
[assembly: AssemblyFileVersion(\"0.0.0.0\")]\n\
[assembly: AssemblyInformationalVersion(\"0.0.0.0\")]\n";
let out = replace_assembly_attributes(src, &vars());
assert!(out.contains("AssemblyVersion(\"1.0.1.0\")"));
assert!(out.contains("AssemblyFileVersion(\"1.0.1.0\")"));
assert!(out.contains("AssemblyInformationalVersion(\"1.0.1-1+Branch.main\")"));
}
#[test]
fn project_element_replacement_preserves_structure() {
let src = "<Project Sdk=\"Microsoft.NET.Sdk\">\n <!-- 주석 유지 -->\n <PropertyGroup>\n <Version>0.0.0</Version>\n <AssemblyVersion>0.0.0.0</AssemblyVersion>\n </PropertyGroup>\n</Project>";
let out = replace_project_elements(src, &vars()).unwrap();
assert!(out.contains("<Version>1.0.1-1</Version>"));
assert!(out.contains("<AssemblyVersion>1.0.1.0</AssemblyVersion>"));
assert!(out.contains("<!-- 주석 유지 -->"));
assert!(out.contains("Sdk=\"Microsoft.NET.Sdk\""));
}
#[test]
fn project_does_not_touch_unrelated_text() {
let src = "<Project><PropertyGroup><Other>0.0.0</Other></PropertyGroup></Project>";
let out = replace_project_elements(src, &vars()).unwrap();
assert!(out.contains("<Other>0.0.0</Other>"));
}
#[test]
fn package_json_version_update() {
let src = "{\n \"name\": \"x\",\n \"version\": \"0.0.0\",\n \"private\": true\n}";
let out = update_package_json(src, "1.0.1-1").unwrap().unwrap();
assert!(out.contains("\"version\": \"1.0.1-1\""));
assert!(out.find("\"name\"").unwrap() < out.find("\"version\"").unwrap());
assert!(out.contains("\"private\""));
}
#[test]
fn cargo_toml_version_update_preserves_comments() {
let src = "# comment\n[package]\nname = \"x\" # inline\nversion = \"0.0.0\"\n";
let out = update_cargo_toml(src, "1.0.1-1").unwrap().unwrap();
assert!(out.contains("version = \"1.0.1-1\""));
assert!(out.contains("# comment"));
assert!(out.contains("# inline"));
}
#[test]
fn package_json_without_version_is_skipped() {
let src =
"{\n \"name\": \"root\",\n \"private\": true,\n \"workspaces\": [\"packages/*\"]\n}";
assert!(update_package_json(src, "1.2.3").unwrap().is_none());
}
#[test]
fn package_json_preserves_other_fields_and_format() {
let src = "{\n \"name\": \"x\",\n \"version\": \"0.0.0\",\n \"scripts\": {\n \"build\": \"tsc\"\n },\n \"dependencies\": {\n \"left-pad\": \"^1.0.0\"\n }\n}";
let out = update_package_json(src, "2.5.0").unwrap().unwrap();
assert!(out.contains("\"version\": \"2.5.0\""));
assert!(out.contains("\"build\": \"tsc\""));
assert!(out.contains("\"left-pad\": \"^1.0.0\""));
assert!(out.contains("\n \"name\""));
assert!(out.ends_with("}\n"));
}
#[test]
fn pyproject_both_sections_updated() {
let src =
"[project]\nname = \"x\"\nversion = \"0.0.0\"\n\n[tool.poetry]\nversion = \"0.0.0\"\n";
let out = update_pyproject_toml(src, "1.2.3").unwrap().unwrap();
assert_eq!(out.matches("version = \"1.2.3\"").count(), 2);
}
#[test]
fn pyproject_without_version_is_skipped() {
let src =
"[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n";
assert!(update_pyproject_toml(src, "1.2.3").unwrap().is_none());
}
#[test]
fn pyproject_dynamic_version_is_skipped() {
let src = "[project]\nname = \"x\"\ndynamic = [\"version\"]\n";
assert!(update_pyproject_toml(src, "1.2.3").unwrap().is_none());
}
#[test]
fn pyproject_preserves_comments() {
let src = "# project metadata\n[project]\nname = \"x\" # the name\nversion = \"0.0.0\"\n";
let out = update_pyproject_toml(src, "9.0.1").unwrap().unwrap();
assert!(out.contains("version = \"9.0.1\""));
assert!(out.contains("# project metadata"));
assert!(out.contains("# the name"));
}
#[test]
fn cargo_toml_workspace_root_updates_workspace_package() {
let src = "[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.package]\nversion = \"0.0.1\"\nedition = \"2021\"\n";
let out = update_cargo_toml(src, "1.2.3").unwrap().unwrap();
assert!(out.contains("version = \"1.2.3\""));
assert!(out.contains("edition = \"2021\""));
assert!(out.contains("members = [\"crates/*\"]"));
}
#[test]
fn cargo_toml_inheriting_member_is_untouched() {
let src = "[package]\nname = \"member\"\nversion.workspace = true\n";
assert!(update_cargo_toml(src, "1.2.3").unwrap().is_none());
}
#[test]
fn cargo_toml_workspace_syncs_internal_path_dep_versions() {
let src = "[workspace.package]\nversion = \"0.0.1\"\n\n\
[workspace.dependencies]\n\
git-warden-core = { path = \"crates/git-warden-core\", version = \"0.0.1\" }\n\
serde = \"1\"\n\
regex = { version = \"1\" }\n";
let out = update_cargo_toml(src, "0.1.0").unwrap().unwrap();
assert!(out.contains("[workspace.package]\nversion = \"0.1.0\""));
assert!(out.contains(
"git-warden-core = { path = \"crates/git-warden-core\", version = \"0.1.0\" }"
));
assert!(out.contains("serde = \"1\""));
assert!(out.contains("regex = { version = \"1\" }"));
}
#[test]
fn cargo_toml_member_syncs_sibling_path_dep() {
let src = "[package]\nname = \"app\"\nversion = \"0.0.1\"\n\n\
[dependencies]\n\
core = { path = \"../core\", version = \"0.0.1\" }\n";
let out = update_cargo_toml(src, "0.1.0").unwrap().unwrap();
assert!(out.contains("core = { path = \"../core\", version = \"0.1.0\" }"));
}
#[test]
fn cargo_toml_path_dep_without_version_is_untouched() {
let src = "[dependencies]\nlocal = { path = \"../local\" }\n";
assert!(update_cargo_toml(src, "1.0.0").unwrap().is_none());
}
#[test]
fn cargo_toml_syncs_path_dep_in_full_table_form() {
let src = "[workspace.package]\nversion = \"0.0.1\"\n\n\
[workspace.dependencies.core]\n\
path = \"crates/core\"\n\
version = \"0.0.1\"\n";
let out = update_cargo_toml(src, "2.0.0").unwrap().unwrap();
assert_eq!(out.matches("\"2.0.0\"").count(), 2);
}
#[test]
fn cargo_toml_root_package_and_workspace_both_updated() {
let src = "[package]\nname = \"root\"\nversion = \"0.0.1\"\n\n[workspace.package]\nversion = \"0.0.1\"\n";
let out = update_cargo_toml(src, "2.0.0").unwrap().unwrap();
assert_eq!(out.matches("version = \"2.0.0\"").count(), 2);
}
#[test]
fn pyproject_pep621_and_poetry() {
let pep621 = "[project]\nname = \"x\"\nversion = \"0.0.0\"\n";
let out = update_pyproject_toml(pep621, "1.0.1-1").unwrap().unwrap();
assert!(out.contains("version = \"1.0.1-1\""));
let poetry = "[tool.poetry]\nname = \"x\"\nversion = \"0.0.0\"\n";
let out = update_pyproject_toml(poetry, "2.0.0").unwrap().unwrap();
assert!(out.contains("version = \"2.0.0\""));
}
#[test]
fn create_cs_assembly_info() {
let out = create_assembly_info(Path::new("AssemblyInfo.cs"), &vars());
assert!(out.contains("using System.Reflection;"));
assert!(out.contains("[assembly: AssemblyFileVersion(\"1.0.1.0\")]"));
assert!(out.starts_with("//---"));
}
}