Skip to main content

gitversion_rs/output/
files.rs

1//! File output: AssemblyInfo, project files, Wix.
2//!
3//! Corresponds to the original `GitVersion.Output/AssemblyInfo/*` and `WixUpdater/*`.
4
5use 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
15/// Template header for generated AssemblyInfo files (matches the original).
16const 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
26/// Recursively find files matching the given predicate under `root`.
27fn 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                // Skip hidden directories such as .git.
38                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
55/// Update AssemblyInfo files (creates them when `ensure` is true and they are missing).
56///
57/// When `files` is empty, searches the working directory recursively for AssemblyInfo.{cs,vb,fs}.
58pub 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
99/// Replace the three assembly attribute values in an existing AssemblyInfo file using regex.
100fn replace_assembly_attributes(content: &str, vars: &VersionVariables) -> String {
101    let replace_attr = |text: &str, attr: &str, value: &str| -> String {
102        // Matches both `AssemblyVersion("...")` and `<Assembly: AssemblyVersion("...")>`.
103        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
118/// Generate the content for a new AssemblyInfo file (syntax varies by extension).
119fn 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
152/// Update version elements in .csproj / .vbproj / .fsproj files.
153pub 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
185/// Target version elements in fixed order (matching the original `ProjectFileUpdater` processing order).
186const PROJECT_ELEMENTS: [&str; 4] = [
187    "AssemblyVersion",
188    "FileVersion",
189    "InformationalVersion",
190    "Version",
191];
192
193/// Update version elements in a project file using real XML parsing.
194///
195/// Uses quick-xml events rather than regex, so comments, attributes, and indentation are preserved.
196/// Like the original `ProjectFileUpdater`, existing elements have their values updated, and
197/// missing elements are appended to the first `<PropertyGroup>`.
198fn 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    // 1) Collect all events in owned form.
209    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    // 2) Update text in existing target elements and record which exist plus the first PropertyGroup position.
226    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                // Capture the indentation of the first child inside the first PropertyGroup.
252                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    // 3) Insert missing elements before the closing tag of the first PropertyGroup.
285    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        // Insert before the closing-indent text node (immediately before the End event).
293        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    // 4) Re-serialise.
313    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
320/// Update the version field in package manifests for various languages.
321///
322/// Uses a format-preserving parser for each format (not regex):
323/// - `package.json` (Node.js): serde_json (preserves key order)
324/// - `Cargo.toml` (Rust), `pyproject.toml` (Python): toml_edit (preserves comments and formatting)
325///
326/// When `files` is empty, searches the working directory recursively for known manifests.
327pub 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            // Exclude manifests inside node_modules / vendor directories.
339            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        // Package manifests use SemVer without build metadata.
365        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
380/// Update the top-level `"version"` field in package.json (key order preserved, 2-space indent).
381fn 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'); // npm convention: trailing newline.
396    Ok(Some(out))
397}
398
399/// Update the `[package]` version in Cargo.toml (format-preserving).
400fn 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
414/// Update the `[project]` or `[tool.poetry]` version in pyproject.toml (format-preserving).
415fn 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    // PEP 621: [project] version
421    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    // Poetry: [tool.poetry] version
428    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
442/// Generate the WiX version file (`GitVersion_WixVersion.wxi`).
443pub fn write_wix(vars: &VersionVariables, work_dir: &Path) -> Result<PathBuf> {
444    let mut s = String::new();
445    s.push('\u{feff}'); // UTF-8 BOM
446    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        // Comments and attributes are preserved.
489        assert!(out.contains("<!-- 주석 유지 -->"));
490        assert!(out.contains("Sdk=\"Microsoft.NET.Sdk\""));
491    }
492
493    #[test]
494    fn project_does_not_touch_unrelated_text() {
495        // Elements and text not in the target list must not be modified.
496        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        // Key order is preserved ("name" comes before "version").
507        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}