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
374fn read_file(path: &Path) -> Result<String, ReleaseError> {
375    fs::read_to_string(path)
376        .map_err(|e| ReleaseError::VersionBump(format!("failed to read {}: {e}", path.display())))
377}
378
379fn write_file(path: &Path, contents: &str) -> Result<(), ReleaseError> {
380    fs::write(path, contents)
381        .map_err(|e| ReleaseError::VersionBump(format!("failed to write {}: {e}", path.display())))
382}
383
384#[cfg(test)]
385mod tests {
386    use super::*;
387
388    #[test]
389    fn bump_cargo_toml_package_version() {
390        let dir = tempfile::tempdir().unwrap();
391        let path = dir.path().join("Cargo.toml");
392        fs::write(
393            &path,
394            r#"[package]
395name = "my-crate"
396version = "0.1.0"
397edition = "2021"
398
399[dependencies]
400serde = "1"
401"#,
402        )
403        .unwrap();
404
405        bump_version_file(&path, "1.2.3").unwrap();
406
407        let contents = fs::read_to_string(&path).unwrap();
408        assert!(contents.contains("version = \"1.2.3\""));
409        assert!(contents.contains("name = \"my-crate\""));
410        assert!(contents.contains("serde = \"1\""));
411    }
412
413    #[test]
414    fn bump_cargo_toml_workspace_version() {
415        let dir = tempfile::tempdir().unwrap();
416        let path = dir.path().join("Cargo.toml");
417        fs::write(
418            &path,
419            r#"[workspace]
420members = ["crates/*"]
421
422[workspace.package]
423version = "0.0.1"
424edition = "2021"
425"#,
426        )
427        .unwrap();
428
429        bump_version_file(&path, "2.0.0").unwrap();
430
431        let contents = fs::read_to_string(&path).unwrap();
432        assert!(contents.contains("version = \"2.0.0\""));
433        assert!(contents.contains("members = [\"crates/*\"]"));
434    }
435
436    #[test]
437    fn bump_package_json_version() {
438        let dir = tempfile::tempdir().unwrap();
439        let path = dir.path().join("package.json");
440        fs::write(
441            &path,
442            r#"{
443  "name": "my-pkg",
444  "version": "0.0.0",
445  "description": "test"
446}"#,
447        )
448        .unwrap();
449
450        bump_version_file(&path, "3.1.0").unwrap();
451
452        let contents = fs::read_to_string(&path).unwrap();
453        let value: serde_json::Value = serde_json::from_str(&contents).unwrap();
454        assert_eq!(value["version"], "3.1.0");
455        assert_eq!(value["name"], "my-pkg");
456        assert_eq!(value["description"], "test");
457        assert!(contents.ends_with('\n'));
458    }
459
460    #[test]
461    fn bump_pyproject_toml_project_version() {
462        let dir = tempfile::tempdir().unwrap();
463        let path = dir.path().join("pyproject.toml");
464        fs::write(
465            &path,
466            r#"[project]
467name = "my-project"
468version = "0.1.0"
469description = "A test project"
470"#,
471        )
472        .unwrap();
473
474        bump_version_file(&path, "1.0.0").unwrap();
475
476        let contents = fs::read_to_string(&path).unwrap();
477        assert!(contents.contains("version = \"1.0.0\""));
478        assert!(contents.contains("name = \"my-project\""));
479    }
480
481    #[test]
482    fn bump_pyproject_toml_poetry_version() {
483        let dir = tempfile::tempdir().unwrap();
484        let path = dir.path().join("pyproject.toml");
485        fs::write(
486            &path,
487            r#"[tool.poetry]
488name = "my-poetry-project"
489version = "0.2.0"
490description = "A poetry project"
491"#,
492        )
493        .unwrap();
494
495        bump_version_file(&path, "0.3.0").unwrap();
496
497        let contents = fs::read_to_string(&path).unwrap();
498        assert!(contents.contains("version = \"0.3.0\""));
499        assert!(contents.contains("name = \"my-poetry-project\""));
500    }
501
502    #[test]
503    fn bump_unknown_file_returns_error() {
504        let dir = tempfile::tempdir().unwrap();
505        let path = dir.path().join("unknown.txt");
506        fs::write(&path, "version = 1").unwrap();
507
508        let err = bump_version_file(&path, "1.0.0").unwrap_err();
509        assert!(matches!(err, ReleaseError::VersionBump(_)));
510        assert!(err.to_string().contains("unsupported"));
511    }
512
513    #[test]
514    fn bump_build_gradle_version() {
515        let dir = tempfile::tempdir().unwrap();
516        let path = dir.path().join("build.gradle");
517        fs::write(
518            &path,
519            r#"plugins {
520    id 'java'
521}
522
523group = 'com.example'
524version = '1.0.0'
525
526dependencies {
527    implementation 'org.slf4j:slf4j-api:2.0.0'
528}
529"#,
530        )
531        .unwrap();
532
533        bump_version_file(&path, "2.0.0").unwrap();
534
535        let contents = fs::read_to_string(&path).unwrap();
536        assert!(contents.contains("version = '2.0.0'"));
537        assert!(contents.contains("group = 'com.example'"));
538        // dependency version must not change
539        assert!(contents.contains("slf4j-api:2.0.0"));
540    }
541
542    #[test]
543    fn bump_build_gradle_kts_version() {
544        let dir = tempfile::tempdir().unwrap();
545        let path = dir.path().join("build.gradle.kts");
546        fs::write(
547            &path,
548            r#"plugins {
549    kotlin("jvm") version "1.9.0"
550}
551
552group = "com.example"
553version = "1.0.0"
554
555dependencies {
556    implementation("org.slf4j:slf4j-api:2.0.0")
557}
558"#,
559        )
560        .unwrap();
561
562        bump_version_file(&path, "3.0.0").unwrap();
563
564        let contents = fs::read_to_string(&path).unwrap();
565        assert!(contents.contains("version = \"3.0.0\""));
566        assert!(contents.contains("group = \"com.example\""));
567    }
568
569    #[test]
570    fn bump_pom_xml_version() {
571        let dir = tempfile::tempdir().unwrap();
572        let path = dir.path().join("pom.xml");
573        fs::write(
574            &path,
575            r#"<?xml version="1.0" encoding="UTF-8"?>
576<project>
577    <modelVersion>4.0.0</modelVersion>
578    <groupId>com.example</groupId>
579    <artifactId>my-app</artifactId>
580    <version>1.0.0</version>
581</project>
582"#,
583        )
584        .unwrap();
585
586        bump_version_file(&path, "2.0.0").unwrap();
587
588        let contents = fs::read_to_string(&path).unwrap();
589        assert!(contents.contains("<version>2.0.0</version>"));
590        assert!(contents.contains("<groupId>com.example</groupId>"));
591    }
592
593    #[test]
594    fn bump_pom_xml_with_parent_version() {
595        let dir = tempfile::tempdir().unwrap();
596        let path = dir.path().join("pom.xml");
597        fs::write(
598            &path,
599            r#"<?xml version="1.0" encoding="UTF-8"?>
600<project>
601    <modelVersion>4.0.0</modelVersion>
602    <parent>
603        <groupId>com.example</groupId>
604        <artifactId>parent</artifactId>
605        <version>5.0.0</version>
606    </parent>
607    <artifactId>my-app</artifactId>
608    <version>1.0.0</version>
609</project>
610"#,
611        )
612        .unwrap();
613
614        bump_version_file(&path, "2.0.0").unwrap();
615
616        let contents = fs::read_to_string(&path).unwrap();
617        // Parent version must NOT be changed
618        assert!(contents.contains("<version>5.0.0</version>"));
619        // Project version must be changed
620        assert!(contents.contains("<version>2.0.0</version>"));
621        // Verify there are exactly two <version> tags with expected values
622        let version_count: Vec<&str> = contents.matches("<version>").collect();
623        assert_eq!(version_count.len(), 2);
624    }
625
626    #[test]
627    fn bump_cargo_toml_workspace_dependencies_with_path() {
628        let dir = tempfile::tempdir().unwrap();
629        let path = dir.path().join("Cargo.toml");
630        fs::write(
631            &path,
632            r#"[workspace]
633members = ["crates/*"]
634
635[workspace.package]
636version = "0.1.0"
637edition = "2021"
638
639[workspace.dependencies]
640# Internal crates
641sr-core = { path = "crates/sr-core", version = "0.1.0" }
642sr-git = { path = "crates/sr-git", version = "0.1.0" }
643# External dep should not change
644serde = { version = "1", features = ["derive"] }
645"#,
646        )
647        .unwrap();
648
649        bump_version_file(&path, "2.0.0").unwrap();
650
651        let contents = fs::read_to_string(&path).unwrap();
652        let doc: toml_edit::DocumentMut = contents.parse().unwrap();
653
654        // workspace.package.version should be bumped
655        assert_eq!(
656            doc["workspace"]["package"]["version"].as_str().unwrap(),
657            "2.0.0"
658        );
659        // Internal path deps should have their version bumped
660        assert_eq!(
661            doc["workspace"]["dependencies"]["sr-core"]["version"]
662                .as_str()
663                .unwrap(),
664            "2.0.0"
665        );
666        assert_eq!(
667            doc["workspace"]["dependencies"]["sr-git"]["version"]
668                .as_str()
669                .unwrap(),
670            "2.0.0"
671        );
672        // External dep version must NOT change
673        assert_eq!(
674            doc["workspace"]["dependencies"]["serde"]["version"]
675                .as_str()
676                .unwrap(),
677            "1"
678        );
679    }
680
681    #[test]
682    fn bump_go_version_var() {
683        let dir = tempfile::tempdir().unwrap();
684        let path = dir.path().join("version.go");
685        fs::write(
686            &path,
687            r#"package main
688
689var Version = "1.0.0"
690
691func main() {}
692"#,
693        )
694        .unwrap();
695
696        bump_version_file(&path, "2.0.0").unwrap();
697
698        let contents = fs::read_to_string(&path).unwrap();
699        assert!(contents.contains(r#"var Version = "2.0.0""#));
700    }
701
702    #[test]
703    fn bump_go_version_const() {
704        let dir = tempfile::tempdir().unwrap();
705        let path = dir.path().join("version.go");
706        fs::write(
707            &path,
708            r#"package main
709
710const Version string = "0.5.0"
711
712func main() {}
713"#,
714        )
715        .unwrap();
716
717        bump_version_file(&path, "0.6.0").unwrap();
718
719        let contents = fs::read_to_string(&path).unwrap();
720        assert!(contents.contains(r#"const Version string = "0.6.0""#));
721    }
722
723    // --- workspace auto-discovery tests ---
724
725    #[test]
726    fn bump_cargo_workspace_discovers_members() {
727        let dir = tempfile::tempdir().unwrap();
728
729        // Create workspace root
730        let root = dir.path().join("Cargo.toml");
731        fs::write(
732            &root,
733            r#"[workspace]
734members = ["crates/*"]
735
736[workspace.package]
737version = "1.0.0"
738edition = "2021"
739
740[workspace.dependencies]
741my-core = { path = "crates/core", version = "1.0.0" }
742"#,
743        )
744        .unwrap();
745
746        // Create member with hardcoded version
747        fs::create_dir_all(dir.path().join("crates/core")).unwrap();
748        let member = dir.path().join("crates/core/Cargo.toml");
749        fs::write(
750            &member,
751            r#"[package]
752name = "my-core"
753version = "1.0.0"
754edition = "2021"
755"#,
756        )
757        .unwrap();
758
759        // Create member that uses workspace inheritance (should be skipped)
760        fs::create_dir_all(dir.path().join("crates/cli")).unwrap();
761        let inherited_member = dir.path().join("crates/cli/Cargo.toml");
762        fs::write(
763            &inherited_member,
764            r#"[package]
765name = "my-cli"
766version.workspace = true
767edition.workspace = true
768"#,
769        )
770        .unwrap();
771
772        let extra = bump_version_file(&root, "2.0.0").unwrap();
773
774        // Root should be bumped
775        let root_contents = fs::read_to_string(&root).unwrap();
776        assert!(root_contents.contains("version = \"2.0.0\""));
777
778        // Workspace dep should be bumped
779        let doc: toml_edit::DocumentMut = root_contents.parse().unwrap();
780        assert_eq!(
781            doc["workspace"]["dependencies"]["my-core"]["version"]
782                .as_str()
783                .unwrap(),
784            "2.0.0"
785        );
786
787        // Member with hardcoded version should be bumped
788        let member_contents = fs::read_to_string(&member).unwrap();
789        assert!(member_contents.contains("version = \"2.0.0\""));
790
791        // Member with workspace inheritance should NOT be modified
792        let inherited_contents = fs::read_to_string(&inherited_member).unwrap();
793        assert!(inherited_contents.contains("version.workspace = true"));
794
795        // Only the hardcoded member should be in extra
796        assert_eq!(extra.len(), 1);
797        assert_eq!(extra[0], member);
798    }
799
800    #[test]
801    fn bump_npm_workspace_discovers_members() {
802        let dir = tempfile::tempdir().unwrap();
803
804        // Create root package.json with workspaces
805        let root = dir.path().join("package.json");
806        fs::write(
807            &root,
808            r#"{
809  "name": "my-monorepo",
810  "version": "1.0.0",
811  "workspaces": ["packages/*"]
812}"#,
813        )
814        .unwrap();
815
816        // Create member
817        fs::create_dir_all(dir.path().join("packages/core")).unwrap();
818        let member = dir.path().join("packages/core/package.json");
819        fs::write(
820            &member,
821            r#"{
822  "name": "@my/core",
823  "version": "1.0.0"
824}"#,
825        )
826        .unwrap();
827
828        // Create member without version (should be skipped)
829        fs::create_dir_all(dir.path().join("packages/utils")).unwrap();
830        let no_version_member = dir.path().join("packages/utils/package.json");
831        fs::write(
832            &no_version_member,
833            r#"{
834  "name": "@my/utils",
835  "private": true
836}"#,
837        )
838        .unwrap();
839
840        let extra = bump_version_file(&root, "2.0.0").unwrap();
841
842        // Root bumped
843        let root_contents: serde_json::Value =
844            serde_json::from_str(&fs::read_to_string(&root).unwrap()).unwrap();
845        assert_eq!(root_contents["version"], "2.0.0");
846
847        // Member with version bumped
848        let member_contents: serde_json::Value =
849            serde_json::from_str(&fs::read_to_string(&member).unwrap()).unwrap();
850        assert_eq!(member_contents["version"], "2.0.0");
851
852        // Member without version untouched
853        let utils_contents: serde_json::Value =
854            serde_json::from_str(&fs::read_to_string(&no_version_member).unwrap()).unwrap();
855        assert!(utils_contents.get("version").is_none());
856
857        assert_eq!(extra.len(), 1);
858        assert_eq!(extra[0], member);
859    }
860
861    #[test]
862    fn bump_uv_workspace_discovers_members() {
863        let dir = tempfile::tempdir().unwrap();
864
865        // Create root pyproject.toml with uv workspace
866        let root = dir.path().join("pyproject.toml");
867        fs::write(
868            &root,
869            r#"[project]
870name = "my-monorepo"
871version = "1.0.0"
872
873[tool.uv.workspace]
874members = ["packages/*"]
875"#,
876        )
877        .unwrap();
878
879        // Create member
880        fs::create_dir_all(dir.path().join("packages/core")).unwrap();
881        let member = dir.path().join("packages/core/pyproject.toml");
882        fs::write(
883            &member,
884            r#"[project]
885name = "my-core"
886version = "1.0.0"
887"#,
888        )
889        .unwrap();
890
891        let extra = bump_version_file(&root, "2.0.0").unwrap();
892
893        // Root bumped
894        let root_contents = fs::read_to_string(&root).unwrap();
895        assert!(root_contents.contains("version = \"2.0.0\""));
896
897        // Member bumped
898        let member_contents = fs::read_to_string(&member).unwrap();
899        assert!(member_contents.contains("version = \"2.0.0\""));
900
901        assert_eq!(extra.len(), 1);
902        assert_eq!(extra[0], member);
903    }
904
905    #[test]
906    fn bump_non_workspace_returns_empty_extra() {
907        let dir = tempfile::tempdir().unwrap();
908        let path = dir.path().join("Cargo.toml");
909        fs::write(
910            &path,
911            r#"[package]
912name = "solo-crate"
913version = "1.0.0"
914"#,
915        )
916        .unwrap();
917
918        let extra = bump_version_file(&path, "2.0.0").unwrap();
919        assert!(extra.is_empty());
920    }
921}