1use super::variables::VersionVariables;
6use anyhow::{Context, Result};
7use quick_xml::events::{BytesText, Event};
8use quick_xml::reader::Reader;
9use quick_xml::writer::Writer;
10use regex::Regex;
11use rust_i18n::t;
12use std::io::Cursor;
13use std::path::{Path, PathBuf};
14
15const ASSEMBLY_HEADER: &str = "\
17//------------------------------------------------------------------------------
18// <auto-generated>
19// This code was generated by GitVersion.
20//
21// You can modify this code as we will not overwrite it when re-executing GitVersion
22// </auto-generated>
23//------------------------------------------------------------------------------
24";
25
26fn find_recursive(root: &Path, matches: impl Fn(&Path) -> bool) -> Vec<PathBuf> {
28 let mut out = Vec::new();
29 let mut stack = vec![root.to_path_buf()];
30 while let Some(dir) = stack.pop() {
31 let Ok(entries) = std::fs::read_dir(&dir) else {
32 continue;
33 };
34 for entry in entries.flatten() {
35 let path = entry.path();
36 if path.is_dir() {
37 if path
39 .file_name()
40 .map(|n| n.to_string_lossy().starts_with('.'))
41 .unwrap_or(false)
42 {
43 continue;
44 }
45 stack.push(path);
46 } else if matches(&path) {
47 out.push(path);
48 }
49 }
50 }
51 out.sort();
52 out
53}
54
55pub fn update_assembly_info(
59 vars: &VersionVariables,
60 work_dir: &Path,
61 files: &[String],
62 ensure: bool,
63) -> Result<Vec<PathBuf>> {
64 let targets: Vec<PathBuf> = if files.is_empty() {
65 find_recursive(work_dir, |p| {
66 let name = p
67 .file_name()
68 .map(|n| n.to_string_lossy().to_lowercase())
69 .unwrap_or_default();
70 matches!(
71 name.as_str(),
72 "assemblyinfo.cs" | "assemblyinfo.vb" | "assemblyinfo.fs"
73 )
74 })
75 } else {
76 files.iter().map(|f| work_dir.join(f)).collect()
77 };
78
79 let mut updated = Vec::new();
80 for path in targets {
81 if path.exists() {
82 let content = std::fs::read_to_string(&path)
83 .with_context(|| t!("file.read_failed", path = path.display()).to_string())?;
84 let new = replace_assembly_attributes(&content, vars);
85 std::fs::write(&path, new)?;
86 updated.push(path);
87 } else if ensure {
88 let content = create_assembly_info(&path, vars);
89 if let Some(parent) = path.parent() {
90 std::fs::create_dir_all(parent).ok();
91 }
92 std::fs::write(&path, content)?;
93 updated.push(path);
94 }
95 }
96 Ok(updated)
97}
98
99fn replace_assembly_attributes(content: &str, vars: &VersionVariables) -> String {
101 let replace_attr = |text: &str, attr: &str, value: &str| -> String {
102 let re = Regex::new(&format!(r#"({attr}\s*\(\s*")[^"]*("\s*\))"#)).unwrap();
104 re.replace_all(text, format!("${{1}}{value}${{2}}").as_str())
105 .into_owned()
106 };
107 let mut out = content.to_string();
108 out = replace_attr(&out, "AssemblyFileVersion", &vars.assembly_sem_file_ver);
109 out = replace_attr(
110 &out,
111 "AssemblyInformationalVersion",
112 &vars.informational_version,
113 );
114 out = replace_attr(&out, "AssemblyVersion", &vars.assembly_sem_ver);
115 out
116}
117
118fn create_assembly_info(path: &Path, vars: &VersionVariables) -> String {
120 let ext = path
121 .extension()
122 .map(|e| e.to_string_lossy().to_lowercase())
123 .unwrap_or_default();
124 let (fv, av, iv) = (
125 &vars.assembly_sem_file_ver,
126 &vars.assembly_sem_ver,
127 &vars.informational_version,
128 );
129 match ext.as_str() {
130 "vb" => format!(
131 "{ASSEMBLY_HEADER}\nImports System.Reflection\n\n\
132 <Assembly: AssemblyFileVersion(\"{fv}\")>\n\
133 <Assembly: AssemblyVersion(\"{av}\")>\n\
134 <Assembly: AssemblyInformationalVersion(\"{iv}\")>\n"
135 ),
136 "fs" => format!(
137 "{ASSEMBLY_HEADER}\nnamespace AssemblyInfo\n\nopen System.Reflection\n\n\
138 [<assembly: AssemblyFileVersion(\"{fv}\")>]\n\
139 [<assembly: AssemblyVersion(\"{av}\")>]\n\
140 [<assembly: AssemblyInformationalVersion(\"{iv}\")>]\n\
141 do ()\n"
142 ),
143 _ => format!(
144 "{ASSEMBLY_HEADER}\nusing System.Reflection;\n\n\
145 [assembly: AssemblyFileVersion(\"{fv}\")]\n\
146 [assembly: AssemblyVersion(\"{av}\")]\n\
147 [assembly: AssemblyInformationalVersion(\"{iv}\")]\n"
148 ),
149 }
150}
151
152pub fn update_project_files(
154 vars: &VersionVariables,
155 work_dir: &Path,
156 files: &[String],
157) -> Result<Vec<PathBuf>> {
158 let targets: Vec<PathBuf> = if files.is_empty() {
159 find_recursive(work_dir, |p| {
160 let ext = p
161 .extension()
162 .map(|e| e.to_string_lossy().to_lowercase())
163 .unwrap_or_default();
164 matches!(ext.as_str(), "csproj" | "vbproj" | "fsproj")
165 })
166 } else {
167 files.iter().map(|f| work_dir.join(f)).collect()
168 };
169
170 let mut updated = Vec::new();
171 for path in targets {
172 if !path.exists() {
173 continue;
174 }
175 let content = std::fs::read_to_string(&path)
176 .with_context(|| t!("file.read_failed", path = path.display()).to_string())?;
177 let new = replace_project_elements(&content, vars)
178 .with_context(|| t!("file.xml_update_failed", path = path.display()).to_string())?;
179 std::fs::write(&path, new)?;
180 updated.push(path);
181 }
182 Ok(updated)
183}
184
185const PROJECT_ELEMENTS: [&str; 4] = [
187 "AssemblyVersion",
188 "FileVersion",
189 "InformationalVersion",
190 "Version",
191];
192
193fn replace_project_elements(content: &str, vars: &VersionVariables) -> Result<String> {
199 let value_of = |elem: &str| -> &str {
200 match elem {
201 "AssemblyVersion" => &vars.assembly_sem_ver,
202 "FileVersion" => &vars.assembly_sem_file_ver,
203 "InformationalVersion" => &vars.informational_version,
204 _ => &vars.sem_ver,
205 }
206 };
207
208 let mut reader = Reader::from_str(content);
210 reader.config_mut().trim_text(false);
211 let mut events: Vec<Event<'static>> = Vec::new();
212 loop {
213 match reader.read_event() {
214 Ok(Event::Eof) => break,
215 Ok(ev) => events.push(ev.into_owned()),
216 Err(e) => return Err(anyhow::anyhow!("{}", t!("file.xml_parse_error", error = e))),
217 }
218 }
219
220 let name_of =
221 |e: &quick_xml::events::BytesStart| String::from_utf8_lossy(e.name().as_ref()).into_owned();
222 let end_name_of =
223 |e: &quick_xml::events::BytesEnd| String::from_utf8_lossy(e.name().as_ref()).into_owned();
224
225 let mut existing: std::collections::HashSet<String> = std::collections::HashSet::new();
227 let mut current: Option<String> = None;
228 let mut replaced = false;
229 let mut first_pg_start: Option<usize> = None;
230 let mut first_pg_end: Option<usize> = None;
231 let mut child_indent: Option<String> = None;
232 let mut pg_depth = 0i32;
233
234 for i in 0..events.len() {
235 match &events[i] {
236 Event::Start(e) => {
237 let name = name_of(e);
238 if name == "PropertyGroup" {
239 pg_depth += 1;
240 if first_pg_start.is_none() {
241 first_pg_start = Some(i);
242 }
243 }
244 if PROJECT_ELEMENTS.contains(&name.as_str()) {
245 existing.insert(name.clone());
246 current = Some(name);
247 replaced = false;
248 }
249 }
250 Event::Text(_) => {
251 if first_pg_start.is_some() && first_pg_end.is_none() && child_indent.is_none() {
253 if let (Event::Text(t), Some(Event::Start(_))) = (&events[i], events.get(i + 1))
254 {
255 let s = String::from_utf8_lossy(t.as_ref()).into_owned();
256 if s.contains('\n') {
257 child_indent = Some(s);
258 }
259 }
260 }
261 if let Some(name) = current.clone() {
262 if !replaced {
263 events[i] = Event::Text(BytesText::new(value_of(&name)).into_owned());
264 replaced = true;
265 }
266 }
267 }
268 Event::End(e) => {
269 let name = end_name_of(e);
270 if current.as_deref() == Some(name.as_str()) {
271 current = None;
272 }
273 if name == "PropertyGroup" {
274 pg_depth -= 1;
275 if first_pg_end.is_none() && first_pg_start.is_some() && pg_depth == 0 {
276 first_pg_end = Some(i);
277 }
278 }
279 }
280 _ => {}
281 }
282 }
283
284 let missing: Vec<&str> = PROJECT_ELEMENTS
286 .iter()
287 .filter(|e| !existing.contains(**e))
288 .copied()
289 .collect();
290 if let (Some(end_idx), false) = (first_pg_end, missing.is_empty()) {
291 let indent = child_indent.unwrap_or_else(|| "\n ".into());
292 let insert_at = if end_idx > 0 && matches!(&events[end_idx - 1], Event::Text(_)) {
294 end_idx - 1
295 } else {
296 end_idx
297 };
298 let mut new_events: Vec<Event<'static>> = Vec::new();
299 for elem in &missing {
300 new_events.push(Event::Text(BytesText::new(&indent).into_owned()));
301 new_events.push(Event::Start(
302 quick_xml::events::BytesStart::new(*elem).into_owned(),
303 ));
304 new_events.push(Event::Text(BytesText::new(value_of(elem)).into_owned()));
305 new_events.push(Event::End(
306 quick_xml::events::BytesEnd::new(*elem).into_owned(),
307 ));
308 }
309 events.splice(insert_at..insert_at, new_events);
310 }
311
312 let mut writer = Writer::new(Cursor::new(Vec::new()));
314 for ev in events {
315 writer.write_event(ev)?;
316 }
317 Ok(String::from_utf8(writer.into_inner().into_inner())?)
318}
319
320pub fn update_package_files(
328 vars: &VersionVariables,
329 work_dir: &Path,
330 files: &[String],
331) -> Result<Vec<PathBuf>> {
332 let targets: Vec<PathBuf> = if files.is_empty() {
333 find_recursive(work_dir, |p| {
334 let name = p
335 .file_name()
336 .map(|n| n.to_string_lossy().to_lowercase())
337 .unwrap_or_default();
338 let in_vendor = p.components().any(|c| {
340 let s = c.as_os_str().to_string_lossy();
341 s == "node_modules" || s == "vendor" || s == "target"
342 });
343 !in_vendor
344 && matches!(
345 name.as_str(),
346 "package.json" | "cargo.toml" | "pyproject.toml"
347 )
348 })
349 } else {
350 files.iter().map(|f| work_dir.join(f)).collect()
351 };
352
353 let mut updated = Vec::new();
354 for path in targets {
355 if !path.exists() {
356 continue;
357 }
358 let name = path
359 .file_name()
360 .map(|n| n.to_string_lossy().to_lowercase())
361 .unwrap_or_default();
362 let content = std::fs::read_to_string(&path)
363 .with_context(|| t!("file.read_failed", path = path.display()).to_string())?;
364 let version = &vars.sem_ver;
366 let new = match name.as_str() {
367 "package.json" => update_package_json(&content, version)?,
368 "cargo.toml" => update_cargo_toml(&content, version)?,
369 "pyproject.toml" => update_pyproject_toml(&content, version)?,
370 _ => continue,
371 };
372 if let Some(new) = new {
373 std::fs::write(&path, new)?;
374 updated.push(path);
375 }
376 }
377 Ok(updated)
378}
379
380fn update_package_json(content: &str, version: &str) -> Result<Option<String>> {
382 let mut value: serde_json::Value =
383 serde_json::from_str(content).with_context(|| t!("file.json_parse_failed").to_string())?;
384 let serde_json::Value::Object(map) = &mut value else {
385 return Ok(None);
386 };
387 if !map.contains_key("version") {
388 return Ok(None);
389 }
390 map.insert(
391 "version".into(),
392 serde_json::Value::String(version.to_string()),
393 );
394 let mut out = serde_json::to_string_pretty(&value)?;
395 out.push('\n'); Ok(Some(out))
397}
398
399fn update_cargo_toml(content: &str, version: &str) -> Result<Option<String>> {
401 let mut doc = content
402 .parse::<toml_edit::DocumentMut>()
403 .with_context(|| t!("file.cargo_parse_failed").to_string())?;
404 let Some(pkg) = doc.get_mut("package").and_then(|p| p.as_table_mut()) else {
405 return Ok(None);
406 };
407 if !pkg.contains_key("version") {
408 return Ok(None);
409 }
410 pkg["version"] = toml_edit::value(version);
411 Ok(Some(doc.to_string()))
412}
413
414fn update_pyproject_toml(content: &str, version: &str) -> Result<Option<String>> {
416 let mut doc = content
417 .parse::<toml_edit::DocumentMut>()
418 .with_context(|| t!("file.pyproject_parse_failed").to_string())?;
419 let mut changed = false;
420 if let Some(project) = doc.get_mut("project").and_then(|p| p.as_table_mut()) {
422 if project.contains_key("version") {
423 project["version"] = toml_edit::value(version);
424 changed = true;
425 }
426 }
427 if let Some(poetry) = doc
429 .get_mut("tool")
430 .and_then(|t| t.as_table_mut())
431 .and_then(|t| t.get_mut("poetry"))
432 .and_then(|p| p.as_table_mut())
433 {
434 if poetry.contains_key("version") {
435 poetry["version"] = toml_edit::value(version);
436 changed = true;
437 }
438 }
439 Ok(if changed { Some(doc.to_string()) } else { None })
440}
441
442pub fn write_wix(vars: &VersionVariables, work_dir: &Path) -> Result<PathBuf> {
444 let mut s = String::new();
445 s.push('\u{feff}'); s.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
447 s.push_str("<Include xmlns=\"http://schemas.microsoft.com/wix/2006/wi\">\n");
448 for (key, value) in vars.to_map() {
449 s.push_str(&format!(" <?define {key}=\"{value}\"?>\n"));
450 }
451 s.push_str("</Include>");
452 let path = work_dir.join("GitVersion_WixVersion.wxi");
453 std::fs::write(&path, s)?;
454 Ok(path)
455}
456
457#[cfg(test)]
458mod tests {
459 use super::*;
460
461 fn vars() -> VersionVariables {
462 VersionVariables {
463 assembly_sem_ver: "1.0.1.0".into(),
464 assembly_sem_file_ver: "1.0.1.0".into(),
465 informational_version: "1.0.1-1+Branch.main".into(),
466 sem_ver: "1.0.1-1".into(),
467 ..Default::default()
468 }
469 }
470
471 #[test]
472 fn assembly_attribute_replacement() {
473 let src = "[assembly: AssemblyVersion(\"0.0.0.0\")]\n\
474 [assembly: AssemblyFileVersion(\"0.0.0.0\")]\n\
475 [assembly: AssemblyInformationalVersion(\"0.0.0.0\")]\n";
476 let out = replace_assembly_attributes(src, &vars());
477 assert!(out.contains("AssemblyVersion(\"1.0.1.0\")"));
478 assert!(out.contains("AssemblyFileVersion(\"1.0.1.0\")"));
479 assert!(out.contains("AssemblyInformationalVersion(\"1.0.1-1+Branch.main\")"));
480 }
481
482 #[test]
483 fn project_element_replacement_preserves_structure() {
484 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>";
485 let out = replace_project_elements(src, &vars()).unwrap();
486 assert!(out.contains("<Version>1.0.1-1</Version>"));
487 assert!(out.contains("<AssemblyVersion>1.0.1.0</AssemblyVersion>"));
488 assert!(out.contains("<!-- 주석 유지 -->"));
490 assert!(out.contains("Sdk=\"Microsoft.NET.Sdk\""));
491 }
492
493 #[test]
494 fn project_does_not_touch_unrelated_text() {
495 let src = "<Project><PropertyGroup><Other>0.0.0</Other></PropertyGroup></Project>";
497 let out = replace_project_elements(src, &vars()).unwrap();
498 assert!(out.contains("<Other>0.0.0</Other>"));
499 }
500
501 #[test]
502 fn package_json_version_update() {
503 let src = "{\n \"name\": \"x\",\n \"version\": \"0.0.0\",\n \"private\": true\n}";
504 let out = update_package_json(src, "1.0.1-1").unwrap().unwrap();
505 assert!(out.contains("\"version\": \"1.0.1-1\""));
506 assert!(out.find("\"name\"").unwrap() < out.find("\"version\"").unwrap());
508 assert!(out.contains("\"private\""));
509 }
510
511 #[test]
512 fn cargo_toml_version_update_preserves_comments() {
513 let src = "# comment\n[package]\nname = \"x\" # inline\nversion = \"0.0.0\"\n";
514 let out = update_cargo_toml(src, "1.0.1-1").unwrap().unwrap();
515 assert!(out.contains("version = \"1.0.1-1\""));
516 assert!(out.contains("# comment"));
517 assert!(out.contains("# inline"));
518 }
519
520 #[test]
521 fn pyproject_pep621_and_poetry() {
522 let pep621 = "[project]\nname = \"x\"\nversion = \"0.0.0\"\n";
523 let out = update_pyproject_toml(pep621, "1.0.1-1").unwrap().unwrap();
524 assert!(out.contains("version = \"1.0.1-1\""));
525
526 let poetry = "[tool.poetry]\nname = \"x\"\nversion = \"0.0.0\"\n";
527 let out = update_pyproject_toml(poetry, "2.0.0").unwrap().unwrap();
528 assert!(out.contains("version = \"2.0.0\""));
529 }
530
531 #[test]
532 fn create_cs_assembly_info() {
533 let out = create_assembly_info(Path::new("AssemblyInfo.cs"), &vars());
534 assert!(out.contains("using System.Reflection;"));
535 assert!(out.contains("[assembly: AssemblyFileVersion(\"1.0.1.0\")]"));
536 assert!(out.starts_with("//---"));
537 }
538}