Skip to main content

sr_core/
version_files.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use regex::Regex;
5
6use crate::error::ReleaseError;
7
8/// Bump the `version` field in the given manifest file.
9///
10/// Returns a list of additional files that were auto-discovered and bumped
11/// (e.g. workspace member manifests). The caller should stage these files.
12///
13/// The file format is auto-detected from the filename:
14/// - `Cargo.toml`          → TOML (`package.version` or `workspace.package.version`)
15/// - `package.json`        → JSON (`.version`)
16/// - `pyproject.toml`      → TOML (`project.version` or `tool.poetry.version`)
17/// - `build.gradle`        → Gradle Groovy DSL (`version = '...'` or `version = "..."`)
18/// - `build.gradle.kts`    → Gradle Kotlin DSL (`version = "..."`)
19/// - `pom.xml`             → Maven (`<version>...</version>`, skipping `<parent>` block)
20/// - `*.go`                → Go (`var/const Version = "..."`)
21///
22/// For workspace roots (Cargo, npm, uv), member manifests are auto-discovered
23/// and bumped without needing to list them in `version_files`.
24pub fn bump_version_file(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
25    let filename = path
26        .file_name()
27        .and_then(|n| n.to_str())
28        .unwrap_or_default();
29
30    match filename {
31        "Cargo.toml" => bump_cargo_toml(path, new_version),
32        "package.json" => bump_package_json(path, new_version),
33        "pyproject.toml" => bump_pyproject_toml(path, new_version),
34        "pom.xml" => bump_pom_xml(path, new_version).map(|()| vec![]),
35        "build.gradle" | "build.gradle.kts" => bump_gradle(path, new_version).map(|()| vec![]),
36        _ if filename.ends_with(".go") => bump_go_version(path, new_version).map(|()| vec![]),
37        other => Err(ReleaseError::VersionBump(format!(
38            "unsupported version file: {other}"
39        ))),
40    }
41}
42
43fn bump_cargo_toml(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
44    let contents = read_file(path)?;
45    let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
46        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
47    })?;
48
49    let is_workspace = doc
50        .get("workspace")
51        .and_then(|w| w.get("package"))
52        .and_then(|p| p.get("version"))
53        .is_some();
54
55    if doc.get("package").and_then(|p| p.get("version")).is_some() {
56        doc["package"]["version"] = toml_edit::value(new_version);
57    } else if is_workspace {
58        doc["workspace"]["package"]["version"] = toml_edit::value(new_version);
59
60        // Also update [workspace.dependencies] entries that are internal path deps
61        if let Some(deps) = doc
62            .get_mut("workspace")
63            .and_then(|w| w.get_mut("dependencies"))
64            .and_then(|d| d.as_table_like_mut())
65        {
66            for (_, dep) in deps.iter_mut() {
67                if let Some(tbl) = dep.as_table_like_mut()
68                    && tbl.get("path").is_some()
69                    && tbl.get("version").is_some()
70                {
71                    tbl.insert("version", toml_edit::value(new_version));
72                }
73            }
74        }
75    } else {
76        return Err(ReleaseError::VersionBump(format!(
77            "no version field found in {}",
78            path.display()
79        )));
80    }
81
82    write_file(path, &doc.to_string())?;
83
84    // Auto-discover and bump workspace member Cargo.toml files
85    let mut extra = Vec::new();
86    if is_workspace {
87        let members = extract_toml_string_array(&doc, &["workspace", "members"]);
88        let root_dir = path.parent().unwrap_or(Path::new("."));
89        for member_path in resolve_member_globs(root_dir, &members, "Cargo.toml") {
90            if member_path.as_path() == path {
91                continue;
92            }
93            match bump_cargo_member(&member_path, new_version) {
94                Ok(true) => extra.push(member_path),
95                Ok(false) => {}
96                Err(e) => eprintln!("warning: {e}"),
97            }
98        }
99    }
100
101    Ok(extra)
102}
103
104/// Bump `package.version` in a workspace member Cargo.toml (skip if using `version.workspace = true`).
105/// Returns `true` if the file was actually modified.
106fn bump_cargo_member(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
107    let contents = read_file(path)?;
108    let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
109        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
110    })?;
111
112    // Skip members that inherit version from workspace
113    let version_item = doc.get("package").and_then(|p| p.get("version"));
114    match version_item {
115        Some(item) if item.is_value() => {
116            doc["package"]["version"] = toml_edit::value(new_version);
117            write_file(path, &doc.to_string())?;
118            Ok(true)
119        }
120        _ => Ok(false), // No version or uses workspace inheritance — skip
121    }
122}
123
124fn bump_package_json(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
125    let contents = read_file(path)?;
126    let mut value: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
127        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
128    })?;
129
130    let obj = value
131        .as_object_mut()
132        .ok_or_else(|| ReleaseError::VersionBump("package.json is not an object".into()))?;
133
134    // Extract workspace patterns before mutating
135    let workspace_patterns: Vec<String> = obj
136        .get("workspaces")
137        .and_then(|w| w.as_array())
138        .map(|arr| {
139            arr.iter()
140                .filter_map(|v| v.as_str().map(String::from))
141                .collect()
142        })
143        .unwrap_or_default();
144
145    obj.insert(
146        "version".into(),
147        serde_json::Value::String(new_version.into()),
148    );
149
150    let output = serde_json::to_string_pretty(&value).map_err(|e| {
151        ReleaseError::VersionBump(format!("failed to serialize {}: {e}", path.display()))
152    })?;
153
154    write_file(path, &format!("{output}\n"))?;
155
156    // Auto-discover and bump workspace member package.json files
157    let mut extra = Vec::new();
158    if !workspace_patterns.is_empty() {
159        let root_dir = path.parent().unwrap_or(Path::new("."));
160        for member_path in resolve_member_globs(root_dir, &workspace_patterns, "package.json") {
161            if member_path == path {
162                continue;
163            }
164            match bump_json_version(&member_path, new_version) {
165                Ok(true) => extra.push(member_path),
166                Ok(false) => {}
167                Err(e) => eprintln!("warning: {e}"),
168            }
169        }
170    }
171
172    Ok(extra)
173}
174
175/// Bump `version` in a member package.json (skip if no version field).
176/// Returns `true` if the file was actually modified.
177fn bump_json_version(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
178    let contents = read_file(path)?;
179    let mut value: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
180        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
181    })?;
182
183    let obj = match value.as_object_mut() {
184        Some(o) => o,
185        None => return Ok(false),
186    };
187
188    if obj.get("version").is_none() {
189        return Ok(false);
190    }
191
192    obj.insert(
193        "version".into(),
194        serde_json::Value::String(new_version.into()),
195    );
196
197    let output = serde_json::to_string_pretty(&value).map_err(|e| {
198        ReleaseError::VersionBump(format!("failed to serialize {}: {e}", path.display()))
199    })?;
200
201    write_file(path, &format!("{output}\n"))?;
202    Ok(true)
203}
204
205fn bump_pyproject_toml(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
206    let contents = read_file(path)?;
207    let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
208        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
209    })?;
210
211    if doc.get("project").and_then(|p| p.get("version")).is_some() {
212        doc["project"]["version"] = toml_edit::value(new_version);
213    } else if doc
214        .get("tool")
215        .and_then(|t| t.get("poetry"))
216        .and_then(|p| p.get("version"))
217        .is_some()
218    {
219        doc["tool"]["poetry"]["version"] = toml_edit::value(new_version);
220    } else {
221        return Err(ReleaseError::VersionBump(format!(
222            "no version field found in {}",
223            path.display()
224        )));
225    }
226
227    write_file(path, &doc.to_string())?;
228
229    // Auto-discover uv workspace members
230    let members = extract_toml_string_array(&doc, &["tool", "uv", "workspace", "members"]);
231    let mut extra = Vec::new();
232    if !members.is_empty() {
233        let root_dir = path.parent().unwrap_or(Path::new("."));
234        for member_path in resolve_member_globs(root_dir, &members, "pyproject.toml") {
235            if member_path.as_path() == path {
236                continue;
237            }
238            match bump_pyproject_member(&member_path, new_version) {
239                Ok(true) => extra.push(member_path),
240                Ok(false) => {}
241                Err(e) => eprintln!("warning: {e}"),
242            }
243        }
244    }
245
246    Ok(extra)
247}
248
249/// Bump version in a uv workspace member pyproject.toml (skip if no version field).
250/// Returns `true` if the file was actually modified.
251fn bump_pyproject_member(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
252    let contents = read_file(path)?;
253    let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
254        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
255    })?;
256
257    if doc.get("project").and_then(|p| p.get("version")).is_some() {
258        doc["project"]["version"] = toml_edit::value(new_version);
259    } else if doc
260        .get("tool")
261        .and_then(|t| t.get("poetry"))
262        .and_then(|p| p.get("version"))
263        .is_some()
264    {
265        doc["tool"]["poetry"]["version"] = toml_edit::value(new_version);
266    } else {
267        return Ok(false); // No version field — skip
268    }
269
270    write_file(path, &doc.to_string())?;
271    Ok(true)
272}
273
274fn bump_gradle(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
275    let contents = read_file(path)?;
276    let re = Regex::new(r#"(version\s*=\s*["'])([^"']*)(["'])"#).unwrap();
277    if !re.is_match(&contents) {
278        return Err(ReleaseError::VersionBump(format!(
279            "no version assignment found in {}",
280            path.display()
281        )));
282    }
283    let result = re.replacen(&contents, 1, format!("${{1}}{new_version}${{3}}"));
284    write_file(path, &result)
285}
286
287fn bump_pom_xml(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
288    let contents = read_file(path)?;
289
290    // Determine search start: skip past </parent> if present, else after </modelVersion>
291    let search_start = if let Some(pos) = contents.find("</parent>") {
292        pos + "</parent>".len()
293    } else if let Some(pos) = contents.find("</modelVersion>") {
294        pos + "</modelVersion>".len()
295    } else {
296        0
297    };
298
299    let rest = &contents[search_start..];
300    let re = Regex::new(r"<version>[^<]*</version>").unwrap();
301    if let Some(m) = re.find(rest) {
302        let replacement = format!("<version>{new_version}</version>");
303        let mut result = String::with_capacity(contents.len());
304        result.push_str(&contents[..search_start + m.start()]);
305        result.push_str(&replacement);
306        result.push_str(&contents[search_start + m.end()..]);
307        write_file(path, &result)
308    } else {
309        Err(ReleaseError::VersionBump(format!(
310            "no <version> element found in {}",
311            path.display()
312        )))
313    }
314}
315
316fn bump_go_version(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
317    let contents = read_file(path)?;
318    let re = Regex::new(r#"((?:var|const)\s+Version\s*(?:string\s*)?=\s*")([^"]*)(")"#).unwrap();
319    if !re.is_match(&contents) {
320        return Err(ReleaseError::VersionBump(format!(
321            "no Version variable found in {}",
322            path.display()
323        )));
324    }
325    let result = re.replacen(&contents, 1, format!("${{1}}{new_version}${{3}}"));
326    write_file(path, &result)
327}
328
329/// Extract a string array from a nested TOML path (e.g. `["workspace", "members"]`).
330fn extract_toml_string_array(doc: &toml_edit::DocumentMut, keys: &[&str]) -> Vec<String> {
331    let mut item: Option<&toml_edit::Item> = None;
332    for key in keys {
333        item = match item {
334            None => doc.get(key),
335            Some(parent) => parent.get(key),
336        };
337        if item.is_none() {
338            return vec![];
339        }
340    }
341    item.and_then(|v| v.as_array())
342        .map(|arr| {
343            arr.iter()
344                .filter_map(|v| v.as_str().map(String::from))
345                .collect()
346        })
347        .unwrap_or_default()
348}
349
350/// Resolve workspace member glob patterns into manifest file paths.
351/// Each glob is resolved relative to `root_dir`, and `manifest_name` is appended
352/// to each matched directory (e.g. "Cargo.toml", "package.json", "pyproject.toml").
353fn resolve_member_globs(root_dir: &Path, patterns: &[String], manifest_name: &str) -> Vec<PathBuf> {
354    let mut paths = Vec::new();
355    for pattern in patterns {
356        let full_pattern = root_dir.join(pattern).to_string_lossy().into_owned();
357        let Ok(entries) = glob::glob(&full_pattern) else {
358            continue;
359        };
360        for entry in entries.flatten() {
361            let manifest = if entry.is_dir() {
362                entry.join(manifest_name)
363            } else {
364                continue;
365            };
366            if manifest.exists() {
367                paths.push(manifest);
368            }
369        }
370    }
371    paths
372}
373
374/// Known manifest → lock file mappings. Lock files are searched in the same
375/// directory as the manifest, then in ancestor directories up to the repo root.
376const LOCK_FILE_MAPPINGS: &[(&str, &[&str])] = &[
377    ("Cargo.toml", &["Cargo.lock"]),
378    (
379        "package.json",
380        &["package-lock.json", "yarn.lock", "pnpm-lock.yaml"],
381    ),
382    ("pyproject.toml", &["uv.lock", "poetry.lock"]),
383];
384
385/// Given a list of bumped manifest paths, discover associated lock files that exist on disk.
386/// Searches the manifest's directory and ancestors (for monorepo roots).
387/// Returns deduplicated paths.
388pub fn discover_lock_files(bumped_files: &[String]) -> Vec<PathBuf> {
389    let mut seen = std::collections::BTreeSet::new();
390    for file in bumped_files {
391        let path = Path::new(file);
392        let filename = path
393            .file_name()
394            .and_then(|n| n.to_str())
395            .unwrap_or_default();
396
397        let lock_names: &[&str] = LOCK_FILE_MAPPINGS
398            .iter()
399            .find(|(manifest, _)| *manifest == filename)
400            .map(|(_, locks)| *locks)
401            .unwrap_or(&[]);
402
403        // Search the manifest's directory and ancestors
404        let mut dir = path.parent();
405        while let Some(d) = dir {
406            for lock_name in lock_names {
407                let lock_path = d.join(lock_name);
408                if lock_path.exists() {
409                    seen.insert(lock_path);
410                }
411            }
412            dir = d.parent();
413            // Stop at repo root (don't traverse beyond .git)
414            if d.join(".git").exists() {
415                break;
416            }
417        }
418    }
419    seen.into_iter().collect()
420}
421
422fn read_file(path: &Path) -> Result<String, ReleaseError> {
423    fs::read_to_string(path)
424        .map_err(|e| ReleaseError::VersionBump(format!("failed to read {}: {e}", path.display())))
425}
426
427fn write_file(path: &Path, contents: &str) -> Result<(), ReleaseError> {
428    fs::write(path, contents)
429        .map_err(|e| ReleaseError::VersionBump(format!("failed to write {}: {e}", path.display())))
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    #[test]
437    fn bump_cargo_toml_package_version() {
438        let dir = tempfile::tempdir().unwrap();
439        let path = dir.path().join("Cargo.toml");
440        fs::write(
441            &path,
442            r#"[package]
443name = "my-crate"
444version = "0.1.0"
445edition = "2021"
446
447[dependencies]
448serde = "1"
449"#,
450        )
451        .unwrap();
452
453        bump_version_file(&path, "1.2.3").unwrap();
454
455        let contents = fs::read_to_string(&path).unwrap();
456        assert!(contents.contains("version = \"1.2.3\""));
457        assert!(contents.contains("name = \"my-crate\""));
458        assert!(contents.contains("serde = \"1\""));
459    }
460
461    #[test]
462    fn bump_cargo_toml_workspace_version() {
463        let dir = tempfile::tempdir().unwrap();
464        let path = dir.path().join("Cargo.toml");
465        fs::write(
466            &path,
467            r#"[workspace]
468members = ["crates/*"]
469
470[workspace.package]
471version = "0.0.1"
472edition = "2021"
473"#,
474        )
475        .unwrap();
476
477        bump_version_file(&path, "2.0.0").unwrap();
478
479        let contents = fs::read_to_string(&path).unwrap();
480        assert!(contents.contains("version = \"2.0.0\""));
481        assert!(contents.contains("members = [\"crates/*\"]"));
482    }
483
484    #[test]
485    fn bump_package_json_version() {
486        let dir = tempfile::tempdir().unwrap();
487        let path = dir.path().join("package.json");
488        fs::write(
489            &path,
490            r#"{
491  "name": "my-pkg",
492  "version": "0.0.0",
493  "description": "test"
494}"#,
495        )
496        .unwrap();
497
498        bump_version_file(&path, "3.1.0").unwrap();
499
500        let contents = fs::read_to_string(&path).unwrap();
501        let value: serde_json::Value = serde_json::from_str(&contents).unwrap();
502        assert_eq!(value["version"], "3.1.0");
503        assert_eq!(value["name"], "my-pkg");
504        assert_eq!(value["description"], "test");
505        assert!(contents.ends_with('\n'));
506    }
507
508    #[test]
509    fn bump_pyproject_toml_project_version() {
510        let dir = tempfile::tempdir().unwrap();
511        let path = dir.path().join("pyproject.toml");
512        fs::write(
513            &path,
514            r#"[project]
515name = "my-project"
516version = "0.1.0"
517description = "A test project"
518"#,
519        )
520        .unwrap();
521
522        bump_version_file(&path, "1.0.0").unwrap();
523
524        let contents = fs::read_to_string(&path).unwrap();
525        assert!(contents.contains("version = \"1.0.0\""));
526        assert!(contents.contains("name = \"my-project\""));
527    }
528
529    #[test]
530    fn bump_pyproject_toml_poetry_version() {
531        let dir = tempfile::tempdir().unwrap();
532        let path = dir.path().join("pyproject.toml");
533        fs::write(
534            &path,
535            r#"[tool.poetry]
536name = "my-poetry-project"
537version = "0.2.0"
538description = "A poetry project"
539"#,
540        )
541        .unwrap();
542
543        bump_version_file(&path, "0.3.0").unwrap();
544
545        let contents = fs::read_to_string(&path).unwrap();
546        assert!(contents.contains("version = \"0.3.0\""));
547        assert!(contents.contains("name = \"my-poetry-project\""));
548    }
549
550    #[test]
551    fn bump_unknown_file_returns_error() {
552        let dir = tempfile::tempdir().unwrap();
553        let path = dir.path().join("unknown.txt");
554        fs::write(&path, "version = 1").unwrap();
555
556        let err = bump_version_file(&path, "1.0.0").unwrap_err();
557        assert!(matches!(err, ReleaseError::VersionBump(_)));
558        assert!(err.to_string().contains("unsupported"));
559    }
560
561    #[test]
562    fn bump_build_gradle_version() {
563        let dir = tempfile::tempdir().unwrap();
564        let path = dir.path().join("build.gradle");
565        fs::write(
566            &path,
567            r#"plugins {
568    id 'java'
569}
570
571group = 'com.example'
572version = '1.0.0'
573
574dependencies {
575    implementation 'org.slf4j:slf4j-api:2.0.0'
576}
577"#,
578        )
579        .unwrap();
580
581        bump_version_file(&path, "2.0.0").unwrap();
582
583        let contents = fs::read_to_string(&path).unwrap();
584        assert!(contents.contains("version = '2.0.0'"));
585        assert!(contents.contains("group = 'com.example'"));
586        // dependency version must not change
587        assert!(contents.contains("slf4j-api:2.0.0"));
588    }
589
590    #[test]
591    fn bump_build_gradle_kts_version() {
592        let dir = tempfile::tempdir().unwrap();
593        let path = dir.path().join("build.gradle.kts");
594        fs::write(
595            &path,
596            r#"plugins {
597    kotlin("jvm") version "1.9.0"
598}
599
600group = "com.example"
601version = "1.0.0"
602
603dependencies {
604    implementation("org.slf4j:slf4j-api:2.0.0")
605}
606"#,
607        )
608        .unwrap();
609
610        bump_version_file(&path, "3.0.0").unwrap();
611
612        let contents = fs::read_to_string(&path).unwrap();
613        assert!(contents.contains("version = \"3.0.0\""));
614        assert!(contents.contains("group = \"com.example\""));
615    }
616
617    #[test]
618    fn bump_pom_xml_version() {
619        let dir = tempfile::tempdir().unwrap();
620        let path = dir.path().join("pom.xml");
621        fs::write(
622            &path,
623            r#"<?xml version="1.0" encoding="UTF-8"?>
624<project>
625    <modelVersion>4.0.0</modelVersion>
626    <groupId>com.example</groupId>
627    <artifactId>my-app</artifactId>
628    <version>1.0.0</version>
629</project>
630"#,
631        )
632        .unwrap();
633
634        bump_version_file(&path, "2.0.0").unwrap();
635
636        let contents = fs::read_to_string(&path).unwrap();
637        assert!(contents.contains("<version>2.0.0</version>"));
638        assert!(contents.contains("<groupId>com.example</groupId>"));
639    }
640
641    #[test]
642    fn bump_pom_xml_with_parent_version() {
643        let dir = tempfile::tempdir().unwrap();
644        let path = dir.path().join("pom.xml");
645        fs::write(
646            &path,
647            r#"<?xml version="1.0" encoding="UTF-8"?>
648<project>
649    <modelVersion>4.0.0</modelVersion>
650    <parent>
651        <groupId>com.example</groupId>
652        <artifactId>parent</artifactId>
653        <version>5.0.0</version>
654    </parent>
655    <artifactId>my-app</artifactId>
656    <version>1.0.0</version>
657</project>
658"#,
659        )
660        .unwrap();
661
662        bump_version_file(&path, "2.0.0").unwrap();
663
664        let contents = fs::read_to_string(&path).unwrap();
665        // Parent version must NOT be changed
666        assert!(contents.contains("<version>5.0.0</version>"));
667        // Project version must be changed
668        assert!(contents.contains("<version>2.0.0</version>"));
669        // Verify there are exactly two <version> tags with expected values
670        let version_count: Vec<&str> = contents.matches("<version>").collect();
671        assert_eq!(version_count.len(), 2);
672    }
673
674    #[test]
675    fn bump_cargo_toml_workspace_dependencies_with_path() {
676        let dir = tempfile::tempdir().unwrap();
677        let path = dir.path().join("Cargo.toml");
678        fs::write(
679            &path,
680            r#"[workspace]
681members = ["crates/*"]
682
683[workspace.package]
684version = "0.1.0"
685edition = "2021"
686
687[workspace.dependencies]
688# Internal crates
689sr-core = { path = "crates/sr-core", version = "0.1.0" }
690sr-git = { path = "crates/sr-git", version = "0.1.0" }
691# External dep should not change
692serde = { version = "1", features = ["derive"] }
693"#,
694        )
695        .unwrap();
696
697        bump_version_file(&path, "2.0.0").unwrap();
698
699        let contents = fs::read_to_string(&path).unwrap();
700        let doc: toml_edit::DocumentMut = contents.parse().unwrap();
701
702        // workspace.package.version should be bumped
703        assert_eq!(
704            doc["workspace"]["package"]["version"].as_str().unwrap(),
705            "2.0.0"
706        );
707        // Internal path deps should have their version bumped
708        assert_eq!(
709            doc["workspace"]["dependencies"]["sr-core"]["version"]
710                .as_str()
711                .unwrap(),
712            "2.0.0"
713        );
714        assert_eq!(
715            doc["workspace"]["dependencies"]["sr-git"]["version"]
716                .as_str()
717                .unwrap(),
718            "2.0.0"
719        );
720        // External dep version must NOT change
721        assert_eq!(
722            doc["workspace"]["dependencies"]["serde"]["version"]
723                .as_str()
724                .unwrap(),
725            "1"
726        );
727    }
728
729    #[test]
730    fn bump_go_version_var() {
731        let dir = tempfile::tempdir().unwrap();
732        let path = dir.path().join("version.go");
733        fs::write(
734            &path,
735            r#"package main
736
737var Version = "1.0.0"
738
739func main() {}
740"#,
741        )
742        .unwrap();
743
744        bump_version_file(&path, "2.0.0").unwrap();
745
746        let contents = fs::read_to_string(&path).unwrap();
747        assert!(contents.contains(r#"var Version = "2.0.0""#));
748    }
749
750    #[test]
751    fn bump_go_version_const() {
752        let dir = tempfile::tempdir().unwrap();
753        let path = dir.path().join("version.go");
754        fs::write(
755            &path,
756            r#"package main
757
758const Version string = "0.5.0"
759
760func main() {}
761"#,
762        )
763        .unwrap();
764
765        bump_version_file(&path, "0.6.0").unwrap();
766
767        let contents = fs::read_to_string(&path).unwrap();
768        assert!(contents.contains(r#"const Version string = "0.6.0""#));
769    }
770
771    // --- workspace auto-discovery tests ---
772
773    #[test]
774    fn bump_cargo_workspace_discovers_members() {
775        let dir = tempfile::tempdir().unwrap();
776
777        // Create workspace root
778        let root = dir.path().join("Cargo.toml");
779        fs::write(
780            &root,
781            r#"[workspace]
782members = ["crates/*"]
783
784[workspace.package]
785version = "1.0.0"
786edition = "2021"
787
788[workspace.dependencies]
789my-core = { path = "crates/core", version = "1.0.0" }
790"#,
791        )
792        .unwrap();
793
794        // Create member with hardcoded version
795        fs::create_dir_all(dir.path().join("crates/core")).unwrap();
796        let member = dir.path().join("crates/core/Cargo.toml");
797        fs::write(
798            &member,
799            r#"[package]
800name = "my-core"
801version = "1.0.0"
802edition = "2021"
803"#,
804        )
805        .unwrap();
806
807        // Create member that uses workspace inheritance (should be skipped)
808        fs::create_dir_all(dir.path().join("crates/cli")).unwrap();
809        let inherited_member = dir.path().join("crates/cli/Cargo.toml");
810        fs::write(
811            &inherited_member,
812            r#"[package]
813name = "my-cli"
814version.workspace = true
815edition.workspace = true
816"#,
817        )
818        .unwrap();
819
820        let extra = bump_version_file(&root, "2.0.0").unwrap();
821
822        // Root should be bumped
823        let root_contents = fs::read_to_string(&root).unwrap();
824        assert!(root_contents.contains("version = \"2.0.0\""));
825
826        // Workspace dep should be bumped
827        let doc: toml_edit::DocumentMut = root_contents.parse().unwrap();
828        assert_eq!(
829            doc["workspace"]["dependencies"]["my-core"]["version"]
830                .as_str()
831                .unwrap(),
832            "2.0.0"
833        );
834
835        // Member with hardcoded version should be bumped
836        let member_contents = fs::read_to_string(&member).unwrap();
837        assert!(member_contents.contains("version = \"2.0.0\""));
838
839        // Member with workspace inheritance should NOT be modified
840        let inherited_contents = fs::read_to_string(&inherited_member).unwrap();
841        assert!(inherited_contents.contains("version.workspace = true"));
842
843        // Only the hardcoded member should be in extra
844        assert_eq!(extra.len(), 1);
845        assert_eq!(extra[0], member);
846    }
847
848    #[test]
849    fn bump_npm_workspace_discovers_members() {
850        let dir = tempfile::tempdir().unwrap();
851
852        // Create root package.json with workspaces
853        let root = dir.path().join("package.json");
854        fs::write(
855            &root,
856            r#"{
857  "name": "my-monorepo",
858  "version": "1.0.0",
859  "workspaces": ["packages/*"]
860}"#,
861        )
862        .unwrap();
863
864        // Create member
865        fs::create_dir_all(dir.path().join("packages/core")).unwrap();
866        let member = dir.path().join("packages/core/package.json");
867        fs::write(
868            &member,
869            r#"{
870  "name": "@my/core",
871  "version": "1.0.0"
872}"#,
873        )
874        .unwrap();
875
876        // Create member without version (should be skipped)
877        fs::create_dir_all(dir.path().join("packages/utils")).unwrap();
878        let no_version_member = dir.path().join("packages/utils/package.json");
879        fs::write(
880            &no_version_member,
881            r#"{
882  "name": "@my/utils",
883  "private": true
884}"#,
885        )
886        .unwrap();
887
888        let extra = bump_version_file(&root, "2.0.0").unwrap();
889
890        // Root bumped
891        let root_contents: serde_json::Value =
892            serde_json::from_str(&fs::read_to_string(&root).unwrap()).unwrap();
893        assert_eq!(root_contents["version"], "2.0.0");
894
895        // Member with version bumped
896        let member_contents: serde_json::Value =
897            serde_json::from_str(&fs::read_to_string(&member).unwrap()).unwrap();
898        assert_eq!(member_contents["version"], "2.0.0");
899
900        // Member without version untouched
901        let utils_contents: serde_json::Value =
902            serde_json::from_str(&fs::read_to_string(&no_version_member).unwrap()).unwrap();
903        assert!(utils_contents.get("version").is_none());
904
905        assert_eq!(extra.len(), 1);
906        assert_eq!(extra[0], member);
907    }
908
909    #[test]
910    fn bump_uv_workspace_discovers_members() {
911        let dir = tempfile::tempdir().unwrap();
912
913        // Create root pyproject.toml with uv workspace
914        let root = dir.path().join("pyproject.toml");
915        fs::write(
916            &root,
917            r#"[project]
918name = "my-monorepo"
919version = "1.0.0"
920
921[tool.uv.workspace]
922members = ["packages/*"]
923"#,
924        )
925        .unwrap();
926
927        // Create member
928        fs::create_dir_all(dir.path().join("packages/core")).unwrap();
929        let member = dir.path().join("packages/core/pyproject.toml");
930        fs::write(
931            &member,
932            r#"[project]
933name = "my-core"
934version = "1.0.0"
935"#,
936        )
937        .unwrap();
938
939        let extra = bump_version_file(&root, "2.0.0").unwrap();
940
941        // Root bumped
942        let root_contents = fs::read_to_string(&root).unwrap();
943        assert!(root_contents.contains("version = \"2.0.0\""));
944
945        // Member bumped
946        let member_contents = fs::read_to_string(&member).unwrap();
947        assert!(member_contents.contains("version = \"2.0.0\""));
948
949        assert_eq!(extra.len(), 1);
950        assert_eq!(extra[0], member);
951    }
952
953    #[test]
954    fn bump_non_workspace_returns_empty_extra() {
955        let dir = tempfile::tempdir().unwrap();
956        let path = dir.path().join("Cargo.toml");
957        fs::write(
958            &path,
959            r#"[package]
960name = "solo-crate"
961version = "1.0.0"
962"#,
963        )
964        .unwrap();
965
966        let extra = bump_version_file(&path, "2.0.0").unwrap();
967        assert!(extra.is_empty());
968    }
969}