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/// Sync the `version` of internal path dependencies in a dependency table to `version`.
400///
401/// An entry is treated as an internal (sibling) crate when it is a table with both a
402/// `path` and a string `version` — exactly the form crates.io validates on publish, e.g.
403/// `dep = { path = "crates/dep", version = "0.0.1" }`. External deps (no `path`) and
404/// inherited deps (`dep.workspace = true`, no string `version`) are left untouched.
405/// The value's surrounding formatting is preserved. Returns true if anything changed.
406fn sync_path_dep_versions(deps: &mut dyn toml_edit::TableLike, version: &str) -> bool {
407    let mut changed = false;
408    for (_key, item) in deps.iter_mut() {
409        let Some(dep) = item.as_table_like_mut() else {
410            continue;
411        };
412        if dep.get("path").is_none() {
413            continue;
414        }
415        if let Some(val) = dep.get_mut("version").and_then(|i| i.as_value_mut()) {
416            if val.is_str() {
417                let decor = val.decor().clone();
418                *val = toml_edit::Value::from(version);
419                *val.decor_mut() = decor;
420                changed = true;
421            }
422        }
423    }
424    changed
425}
426
427/// Update the version in Cargo.toml (format-preserving).
428///
429/// Handles both plain packages and Cargo workspaces:
430/// - `[package]` with a string `version` is updated.
431/// - `[workspace.package]` with a string `version` (the inherited version source) is updated.
432/// - A member that inherits via `version.workspace = true` is left untouched (its `version`
433///   is not a string), so workspace inheritance is preserved rather than overwritten.
434/// - Internal path dependencies (`{ path = "...", version = "..." }`) in `[workspace.dependencies]`
435///   and `[dependencies]`/`[dev-dependencies]`/`[build-dependencies]` have their version
436///   requirement bumped in lockstep, so sibling crates in a monorepo still publish.
437///
438/// Returns `None` when nothing needed updating.
439fn update_cargo_toml(content: &str, version: &str) -> Result<Option<String>> {
440    let mut doc = content
441        .parse::<toml_edit::DocumentMut>()
442        .with_context(|| t!("file.cargo_parse_failed").to_string())?;
443
444    // Update `version` in `table` only when it is currently a plain string. This skips
445    // `version.workspace = true` (an inline table), preserving inheritance.
446    let update_string_version = |table: &mut toml_edit::Table| -> bool {
447        if table.get("version").and_then(|v| v.as_str()).is_some() {
448            table["version"] = toml_edit::value(version);
449            true
450        } else {
451            false
452        }
453    };
454
455    let mut changed = false;
456    // [package] version = "..."
457    if let Some(pkg) = doc.get_mut("package").and_then(|p| p.as_table_mut()) {
458        changed |= update_string_version(pkg);
459    }
460    // [workspace.package] version = "..." (the source of truth for inheriting members).
461    if let Some(ws_pkg) = doc
462        .get_mut("workspace")
463        .and_then(|w| w.as_table_mut())
464        .and_then(|w| w.get_mut("package"))
465        .and_then(|p| p.as_table_mut())
466    {
467        changed |= update_string_version(ws_pkg);
468    }
469
470    // [workspace.dependencies] — internal path deps share the workspace version.
471    if let Some(ws_deps) = doc
472        .get_mut("workspace")
473        .and_then(|w| w.as_table_mut())
474        .and_then(|w| w.get_mut("dependencies"))
475        .and_then(|d| d.as_table_like_mut())
476    {
477        changed |= sync_path_dep_versions(ws_deps, version);
478    }
479    // Per-crate dependency tables (members declaring sibling path deps directly).
480    for table_name in ["dependencies", "dev-dependencies", "build-dependencies"] {
481        if let Some(deps) = doc.get_mut(table_name).and_then(|d| d.as_table_like_mut()) {
482            changed |= sync_path_dep_versions(deps, version);
483        }
484    }
485
486    Ok(if changed { Some(doc.to_string()) } else { None })
487}
488
489/// Update the `[project]` or `[tool.poetry]` version in pyproject.toml (format-preserving).
490fn update_pyproject_toml(content: &str, version: &str) -> Result<Option<String>> {
491    let mut doc = content
492        .parse::<toml_edit::DocumentMut>()
493        .with_context(|| t!("file.pyproject_parse_failed").to_string())?;
494    let mut changed = false;
495    // PEP 621: [project] version
496    if let Some(project) = doc.get_mut("project").and_then(|p| p.as_table_mut()) {
497        if project.contains_key("version") {
498            project["version"] = toml_edit::value(version);
499            changed = true;
500        }
501    }
502    // Poetry: [tool.poetry] version
503    if let Some(poetry) = doc
504        .get_mut("tool")
505        .and_then(|t| t.as_table_mut())
506        .and_then(|t| t.get_mut("poetry"))
507        .and_then(|p| p.as_table_mut())
508    {
509        if poetry.contains_key("version") {
510            poetry["version"] = toml_edit::value(version);
511            changed = true;
512        }
513    }
514    Ok(if changed { Some(doc.to_string()) } else { None })
515}
516
517/// Generate the WiX version file (`GitVersion_WixVersion.wxi`).
518pub fn write_wix(vars: &VersionVariables, work_dir: &Path) -> Result<PathBuf> {
519    let mut s = String::new();
520    s.push('\u{feff}'); // UTF-8 BOM
521    s.push_str("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n");
522    s.push_str("<Include xmlns=\"http://schemas.microsoft.com/wix/2006/wi\">\n");
523    for (key, value) in vars.to_map() {
524        s.push_str(&format!("  <?define {key}=\"{value}\"?>\n"));
525    }
526    s.push_str("</Include>");
527    let path = work_dir.join("GitVersion_WixVersion.wxi");
528    std::fs::write(&path, s)?;
529    Ok(path)
530}
531
532#[cfg(test)]
533mod tests {
534    use super::*;
535
536    fn vars() -> VersionVariables {
537        VersionVariables {
538            assembly_sem_ver: "1.0.1.0".into(),
539            assembly_sem_file_ver: "1.0.1.0".into(),
540            informational_version: "1.0.1-1+Branch.main".into(),
541            sem_ver: "1.0.1-1".into(),
542            ..Default::default()
543        }
544    }
545
546    #[test]
547    fn assembly_attribute_replacement() {
548        let src = "[assembly: AssemblyVersion(\"0.0.0.0\")]\n\
549                   [assembly: AssemblyFileVersion(\"0.0.0.0\")]\n\
550                   [assembly: AssemblyInformationalVersion(\"0.0.0.0\")]\n";
551        let out = replace_assembly_attributes(src, &vars());
552        assert!(out.contains("AssemblyVersion(\"1.0.1.0\")"));
553        assert!(out.contains("AssemblyFileVersion(\"1.0.1.0\")"));
554        assert!(out.contains("AssemblyInformationalVersion(\"1.0.1-1+Branch.main\")"));
555    }
556
557    #[test]
558    fn project_element_replacement_preserves_structure() {
559        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>";
560        let out = replace_project_elements(src, &vars()).unwrap();
561        assert!(out.contains("<Version>1.0.1-1</Version>"));
562        assert!(out.contains("<AssemblyVersion>1.0.1.0</AssemblyVersion>"));
563        // Comments and attributes are preserved.
564        assert!(out.contains("<!-- 주석 유지 -->"));
565        assert!(out.contains("Sdk=\"Microsoft.NET.Sdk\""));
566    }
567
568    #[test]
569    fn project_does_not_touch_unrelated_text() {
570        // Elements and text not in the target list must not be modified.
571        let src = "<Project><PropertyGroup><Other>0.0.0</Other></PropertyGroup></Project>";
572        let out = replace_project_elements(src, &vars()).unwrap();
573        assert!(out.contains("<Other>0.0.0</Other>"));
574    }
575
576    #[test]
577    fn package_json_version_update() {
578        let src = "{\n  \"name\": \"x\",\n  \"version\": \"0.0.0\",\n  \"private\": true\n}";
579        let out = update_package_json(src, "1.0.1-1").unwrap().unwrap();
580        assert!(out.contains("\"version\": \"1.0.1-1\""));
581        // Key order is preserved ("name" comes before "version").
582        assert!(out.find("\"name\"").unwrap() < out.find("\"version\"").unwrap());
583        assert!(out.contains("\"private\""));
584    }
585
586    #[test]
587    fn cargo_toml_version_update_preserves_comments() {
588        let src = "# comment\n[package]\nname = \"x\"  # inline\nversion = \"0.0.0\"\n";
589        let out = update_cargo_toml(src, "1.0.1-1").unwrap().unwrap();
590        assert!(out.contains("version = \"1.0.1-1\""));
591        assert!(out.contains("# comment"));
592        assert!(out.contains("# inline"));
593    }
594
595    #[test]
596    fn package_json_without_version_is_skipped() {
597        // No top-level "version" → nothing to update (e.g. an npm workspace root).
598        let src =
599            "{\n  \"name\": \"root\",\n  \"private\": true,\n  \"workspaces\": [\"packages/*\"]\n}";
600        assert!(update_package_json(src, "1.2.3").unwrap().is_none());
601    }
602
603    #[test]
604    fn package_json_preserves_other_fields_and_format() {
605        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}";
606        let out = update_package_json(src, "2.5.0").unwrap().unwrap();
607        assert!(out.contains("\"version\": \"2.5.0\""));
608        // Nested objects and their values are preserved.
609        assert!(out.contains("\"build\": \"tsc\""));
610        assert!(out.contains("\"left-pad\": \"^1.0.0\""));
611        // npm conventions: 2-space indent and a trailing newline.
612        assert!(out.contains("\n  \"name\""));
613        assert!(out.ends_with("}\n"));
614    }
615
616    #[test]
617    fn pyproject_both_sections_updated() {
618        // Both PEP 621 [project] and [tool.poetry] present → both bumped.
619        let src =
620            "[project]\nname = \"x\"\nversion = \"0.0.0\"\n\n[tool.poetry]\nversion = \"0.0.0\"\n";
621        let out = update_pyproject_toml(src, "1.2.3").unwrap().unwrap();
622        assert_eq!(out.matches("version = \"1.2.3\"").count(), 2);
623    }
624
625    #[test]
626    fn pyproject_without_version_is_skipped() {
627        // Only build-system metadata, no version anywhere → None.
628        let src =
629            "[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n";
630        assert!(update_pyproject_toml(src, "1.2.3").unwrap().is_none());
631    }
632
633    #[test]
634    fn pyproject_dynamic_version_is_skipped() {
635        // PEP 621 dynamic version (computed by the build backend) has no static `version`
636        // key, so it must be left untouched.
637        let src = "[project]\nname = \"x\"\ndynamic = [\"version\"]\n";
638        assert!(update_pyproject_toml(src, "1.2.3").unwrap().is_none());
639    }
640
641    #[test]
642    fn pyproject_preserves_comments() {
643        let src = "# project metadata\n[project]\nname = \"x\"  # the name\nversion = \"0.0.0\"\n";
644        let out = update_pyproject_toml(src, "9.0.1").unwrap().unwrap();
645        assert!(out.contains("version = \"9.0.1\""));
646        assert!(out.contains("# project metadata"));
647        assert!(out.contains("# the name"));
648    }
649
650    #[test]
651    fn cargo_toml_workspace_root_updates_workspace_package() {
652        // Workspace root: the inherited version lives under [workspace.package].
653        let src = "[workspace]\nmembers = [\"crates/*\"]\n\n[workspace.package]\nversion = \"0.0.1\"\nedition = \"2021\"\n";
654        let out = update_cargo_toml(src, "1.2.3").unwrap().unwrap();
655        assert!(out.contains("version = \"1.2.3\""));
656        // Unrelated keys are preserved.
657        assert!(out.contains("edition = \"2021\""));
658        assert!(out.contains("members = [\"crates/*\"]"));
659    }
660
661    #[test]
662    fn cargo_toml_inheriting_member_is_untouched() {
663        // A member that inherits via `version.workspace = true` must NOT be rewritten.
664        let src = "[package]\nname = \"member\"\nversion.workspace = true\n";
665        assert!(update_cargo_toml(src, "1.2.3").unwrap().is_none());
666    }
667
668    #[test]
669    fn cargo_toml_workspace_syncs_internal_path_dep_versions() {
670        let src = "[workspace.package]\nversion = \"0.0.1\"\n\n\
671                   [workspace.dependencies]\n\
672                   git-warden-core = { path = \"crates/git-warden-core\", version = \"0.0.1\" }\n\
673                   serde = \"1\"\n\
674                   regex = { version = \"1\" }\n";
675        let out = update_cargo_toml(src, "0.1.0").unwrap().unwrap();
676        // Workspace version + the internal path dep are both bumped (formatting preserved).
677        assert!(out.contains("[workspace.package]\nversion = \"0.1.0\""));
678        assert!(out.contains(
679            "git-warden-core = { path = \"crates/git-warden-core\", version = \"0.1.0\" }"
680        ));
681        // External deps (no `path`) are untouched.
682        assert!(out.contains("serde = \"1\""));
683        assert!(out.contains("regex = { version = \"1\" }"));
684    }
685
686    #[test]
687    fn cargo_toml_member_syncs_sibling_path_dep() {
688        let src = "[package]\nname = \"app\"\nversion = \"0.0.1\"\n\n\
689                   [dependencies]\n\
690                   core = { path = \"../core\", version = \"0.0.1\" }\n";
691        let out = update_cargo_toml(src, "0.1.0").unwrap().unwrap();
692        assert!(out.contains("core = { path = \"../core\", version = \"0.1.0\" }"));
693    }
694
695    #[test]
696    fn cargo_toml_path_dep_without_version_is_untouched() {
697        // A purely local path dep (no version) must not gain a version field.
698        let src = "[dependencies]\nlocal = { path = \"../local\" }\n";
699        assert!(update_cargo_toml(src, "1.0.0").unwrap().is_none());
700    }
701
702    #[test]
703    fn cargo_toml_syncs_path_dep_in_full_table_form() {
704        let src = "[workspace.package]\nversion = \"0.0.1\"\n\n\
705                   [workspace.dependencies.core]\n\
706                   path = \"crates/core\"\n\
707                   version = \"0.0.1\"\n";
708        let out = update_cargo_toml(src, "2.0.0").unwrap().unwrap();
709        // Both the workspace version and the path dep's version become 2.0.0.
710        assert_eq!(out.matches("\"2.0.0\"").count(), 2);
711    }
712
713    #[test]
714    fn cargo_toml_root_package_and_workspace_both_updated() {
715        // A root crate that is both a package and a workspace: update both string versions.
716        let src = "[package]\nname = \"root\"\nversion = \"0.0.1\"\n\n[workspace.package]\nversion = \"0.0.1\"\n";
717        let out = update_cargo_toml(src, "2.0.0").unwrap().unwrap();
718        assert_eq!(out.matches("version = \"2.0.0\"").count(), 2);
719    }
720
721    #[test]
722    fn pyproject_pep621_and_poetry() {
723        let pep621 = "[project]\nname = \"x\"\nversion = \"0.0.0\"\n";
724        let out = update_pyproject_toml(pep621, "1.0.1-1").unwrap().unwrap();
725        assert!(out.contains("version = \"1.0.1-1\""));
726
727        let poetry = "[tool.poetry]\nname = \"x\"\nversion = \"0.0.0\"\n";
728        let out = update_pyproject_toml(poetry, "2.0.0").unwrap().unwrap();
729        assert!(out.contains("version = \"2.0.0\""));
730    }
731
732    #[test]
733    fn create_cs_assembly_info() {
734        let out = create_assembly_info(Path::new("AssemblyInfo.cs"), &vars());
735        assert!(out.contains("using System.Reflection;"));
736        assert!(out.contains("[assembly: AssemblyFileVersion(\"1.0.1.0\")]"));
737        assert!(out.starts_with("//---"));
738    }
739}