Skip to main content

sr_core/
version_files.rs

1use std::fs;
2use std::path::Path;
3
4use regex::Regex;
5
6use crate::error::ReleaseError;
7
8/// Bump the `version` field in the given manifest file.
9///
10/// The file format is auto-detected from the filename:
11/// - `Cargo.toml`          → TOML (`package.version` or `workspace.package.version`)
12/// - `package.json`        → JSON (`.version`)
13/// - `pyproject.toml`      → TOML (`project.version` or `tool.poetry.version`)
14/// - `build.gradle`        → Gradle Groovy DSL (`version = '...'` or `version = "..."`)
15/// - `build.gradle.kts`    → Gradle Kotlin DSL (`version = "..."`)
16/// - `pom.xml`             → Maven (`<version>...</version>`, skipping `<parent>` block)
17/// - `*.go`                → Go (`var/const Version = "..."`)
18pub fn bump_version_file(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
19    let filename = path
20        .file_name()
21        .and_then(|n| n.to_str())
22        .unwrap_or_default();
23
24    match filename {
25        "Cargo.toml" => bump_cargo_toml(path, new_version),
26        "package.json" => bump_package_json(path, new_version),
27        "pyproject.toml" => bump_pyproject_toml(path, new_version),
28        "pom.xml" => bump_pom_xml(path, new_version),
29        "build.gradle" | "build.gradle.kts" => bump_gradle(path, new_version),
30        _ if filename.ends_with(".go") => bump_go_version(path, new_version),
31        other => Err(ReleaseError::VersionBump(format!(
32            "unsupported version file: {other}"
33        ))),
34    }
35}
36
37fn bump_cargo_toml(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
38    let contents = read_file(path)?;
39    let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
40        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
41    })?;
42
43    if doc.get("package").and_then(|p| p.get("version")).is_some() {
44        doc["package"]["version"] = toml_edit::value(new_version);
45    } else if doc
46        .get("workspace")
47        .and_then(|w| w.get("package"))
48        .and_then(|p| p.get("version"))
49        .is_some()
50    {
51        doc["workspace"]["package"]["version"] = toml_edit::value(new_version);
52
53        // Also update [workspace.dependencies] entries that are internal path deps
54        if let Some(deps) = doc
55            .get_mut("workspace")
56            .and_then(|w| w.get_mut("dependencies"))
57            .and_then(|d| d.as_table_like_mut())
58        {
59            for (_, dep) in deps.iter_mut() {
60                if let Some(tbl) = dep.as_table_like_mut()
61                    && tbl.get("path").is_some()
62                    && tbl.get("version").is_some()
63                {
64                    tbl.insert("version", toml_edit::value(new_version));
65                }
66            }
67        }
68    } else {
69        return Err(ReleaseError::VersionBump(format!(
70            "no version field found in {}",
71            path.display()
72        )));
73    }
74
75    write_file(path, &doc.to_string())
76}
77
78fn bump_package_json(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
79    let contents = read_file(path)?;
80    let mut value: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
81        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
82    })?;
83
84    value
85        .as_object_mut()
86        .ok_or_else(|| ReleaseError::VersionBump("package.json is not an object".into()))?
87        .insert(
88            "version".into(),
89            serde_json::Value::String(new_version.into()),
90        );
91
92    let output = serde_json::to_string_pretty(&value).map_err(|e| {
93        ReleaseError::VersionBump(format!("failed to serialize {}: {e}", path.display()))
94    })?;
95
96    write_file(path, &format!("{output}\n"))
97}
98
99fn bump_pyproject_toml(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
100    let contents = read_file(path)?;
101    let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
102        ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
103    })?;
104
105    if doc.get("project").and_then(|p| p.get("version")).is_some() {
106        doc["project"]["version"] = toml_edit::value(new_version);
107    } else if doc
108        .get("tool")
109        .and_then(|t| t.get("poetry"))
110        .and_then(|p| p.get("version"))
111        .is_some()
112    {
113        doc["tool"]["poetry"]["version"] = toml_edit::value(new_version);
114    } else {
115        return Err(ReleaseError::VersionBump(format!(
116            "no version field found in {}",
117            path.display()
118        )));
119    }
120
121    write_file(path, &doc.to_string())
122}
123
124fn bump_gradle(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
125    let contents = read_file(path)?;
126    let re = Regex::new(r#"(version\s*=\s*["'])([^"']*)(["'])"#).unwrap();
127    if !re.is_match(&contents) {
128        return Err(ReleaseError::VersionBump(format!(
129            "no version assignment found in {}",
130            path.display()
131        )));
132    }
133    let result = re.replacen(&contents, 1, format!("${{1}}{new_version}${{3}}"));
134    write_file(path, &result)
135}
136
137fn bump_pom_xml(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
138    let contents = read_file(path)?;
139
140    // Determine search start: skip past </parent> if present, else after </modelVersion>
141    let search_start = if let Some(pos) = contents.find("</parent>") {
142        pos + "</parent>".len()
143    } else if let Some(pos) = contents.find("</modelVersion>") {
144        pos + "</modelVersion>".len()
145    } else {
146        0
147    };
148
149    let rest = &contents[search_start..];
150    let re = Regex::new(r"<version>[^<]*</version>").unwrap();
151    if let Some(m) = re.find(rest) {
152        let replacement = format!("<version>{new_version}</version>");
153        let mut result = String::with_capacity(contents.len());
154        result.push_str(&contents[..search_start + m.start()]);
155        result.push_str(&replacement);
156        result.push_str(&contents[search_start + m.end()..]);
157        write_file(path, &result)
158    } else {
159        Err(ReleaseError::VersionBump(format!(
160            "no <version> element found in {}",
161            path.display()
162        )))
163    }
164}
165
166fn bump_go_version(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
167    let contents = read_file(path)?;
168    let re = Regex::new(r#"((?:var|const)\s+Version\s*(?:string\s*)?=\s*")([^"]*)(")"#).unwrap();
169    if !re.is_match(&contents) {
170        return Err(ReleaseError::VersionBump(format!(
171            "no Version variable found in {}",
172            path.display()
173        )));
174    }
175    let result = re.replacen(&contents, 1, format!("${{1}}{new_version}${{3}}"));
176    write_file(path, &result)
177}
178
179fn read_file(path: &Path) -> Result<String, ReleaseError> {
180    fs::read_to_string(path)
181        .map_err(|e| ReleaseError::VersionBump(format!("failed to read {}: {e}", path.display())))
182}
183
184fn write_file(path: &Path, contents: &str) -> Result<(), ReleaseError> {
185    fs::write(path, contents)
186        .map_err(|e| ReleaseError::VersionBump(format!("failed to write {}: {e}", path.display())))
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192
193    #[test]
194    fn bump_cargo_toml_package_version() {
195        let dir = tempfile::tempdir().unwrap();
196        let path = dir.path().join("Cargo.toml");
197        fs::write(
198            &path,
199            r#"[package]
200name = "my-crate"
201version = "0.1.0"
202edition = "2021"
203
204[dependencies]
205serde = "1"
206"#,
207        )
208        .unwrap();
209
210        bump_version_file(&path, "1.2.3").unwrap();
211
212        let contents = fs::read_to_string(&path).unwrap();
213        assert!(contents.contains("version = \"1.2.3\""));
214        assert!(contents.contains("name = \"my-crate\""));
215        assert!(contents.contains("serde = \"1\""));
216    }
217
218    #[test]
219    fn bump_cargo_toml_workspace_version() {
220        let dir = tempfile::tempdir().unwrap();
221        let path = dir.path().join("Cargo.toml");
222        fs::write(
223            &path,
224            r#"[workspace]
225members = ["crates/*"]
226
227[workspace.package]
228version = "0.0.1"
229edition = "2021"
230"#,
231        )
232        .unwrap();
233
234        bump_version_file(&path, "2.0.0").unwrap();
235
236        let contents = fs::read_to_string(&path).unwrap();
237        assert!(contents.contains("version = \"2.0.0\""));
238        assert!(contents.contains("members = [\"crates/*\"]"));
239    }
240
241    #[test]
242    fn bump_package_json_version() {
243        let dir = tempfile::tempdir().unwrap();
244        let path = dir.path().join("package.json");
245        fs::write(
246            &path,
247            r#"{
248  "name": "my-pkg",
249  "version": "0.0.0",
250  "description": "test"
251}"#,
252        )
253        .unwrap();
254
255        bump_version_file(&path, "3.1.0").unwrap();
256
257        let contents = fs::read_to_string(&path).unwrap();
258        let value: serde_json::Value = serde_json::from_str(&contents).unwrap();
259        assert_eq!(value["version"], "3.1.0");
260        assert_eq!(value["name"], "my-pkg");
261        assert_eq!(value["description"], "test");
262        assert!(contents.ends_with('\n'));
263    }
264
265    #[test]
266    fn bump_pyproject_toml_project_version() {
267        let dir = tempfile::tempdir().unwrap();
268        let path = dir.path().join("pyproject.toml");
269        fs::write(
270            &path,
271            r#"[project]
272name = "my-project"
273version = "0.1.0"
274description = "A test project"
275"#,
276        )
277        .unwrap();
278
279        bump_version_file(&path, "1.0.0").unwrap();
280
281        let contents = fs::read_to_string(&path).unwrap();
282        assert!(contents.contains("version = \"1.0.0\""));
283        assert!(contents.contains("name = \"my-project\""));
284    }
285
286    #[test]
287    fn bump_pyproject_toml_poetry_version() {
288        let dir = tempfile::tempdir().unwrap();
289        let path = dir.path().join("pyproject.toml");
290        fs::write(
291            &path,
292            r#"[tool.poetry]
293name = "my-poetry-project"
294version = "0.2.0"
295description = "A poetry project"
296"#,
297        )
298        .unwrap();
299
300        bump_version_file(&path, "0.3.0").unwrap();
301
302        let contents = fs::read_to_string(&path).unwrap();
303        assert!(contents.contains("version = \"0.3.0\""));
304        assert!(contents.contains("name = \"my-poetry-project\""));
305    }
306
307    #[test]
308    fn bump_unknown_file_returns_error() {
309        let dir = tempfile::tempdir().unwrap();
310        let path = dir.path().join("unknown.txt");
311        fs::write(&path, "version = 1").unwrap();
312
313        let err = bump_version_file(&path, "1.0.0").unwrap_err();
314        assert!(matches!(err, ReleaseError::VersionBump(_)));
315        assert!(err.to_string().contains("unsupported"));
316    }
317
318    #[test]
319    fn bump_build_gradle_version() {
320        let dir = tempfile::tempdir().unwrap();
321        let path = dir.path().join("build.gradle");
322        fs::write(
323            &path,
324            r#"plugins {
325    id 'java'
326}
327
328group = 'com.example'
329version = '1.0.0'
330
331dependencies {
332    implementation 'org.slf4j:slf4j-api:2.0.0'
333}
334"#,
335        )
336        .unwrap();
337
338        bump_version_file(&path, "2.0.0").unwrap();
339
340        let contents = fs::read_to_string(&path).unwrap();
341        assert!(contents.contains("version = '2.0.0'"));
342        assert!(contents.contains("group = 'com.example'"));
343        // dependency version must not change
344        assert!(contents.contains("slf4j-api:2.0.0"));
345    }
346
347    #[test]
348    fn bump_build_gradle_kts_version() {
349        let dir = tempfile::tempdir().unwrap();
350        let path = dir.path().join("build.gradle.kts");
351        fs::write(
352            &path,
353            r#"plugins {
354    kotlin("jvm") version "1.9.0"
355}
356
357group = "com.example"
358version = "1.0.0"
359
360dependencies {
361    implementation("org.slf4j:slf4j-api:2.0.0")
362}
363"#,
364        )
365        .unwrap();
366
367        bump_version_file(&path, "3.0.0").unwrap();
368
369        let contents = fs::read_to_string(&path).unwrap();
370        assert!(contents.contains("version = \"3.0.0\""));
371        assert!(contents.contains("group = \"com.example\""));
372    }
373
374    #[test]
375    fn bump_pom_xml_version() {
376        let dir = tempfile::tempdir().unwrap();
377        let path = dir.path().join("pom.xml");
378        fs::write(
379            &path,
380            r#"<?xml version="1.0" encoding="UTF-8"?>
381<project>
382    <modelVersion>4.0.0</modelVersion>
383    <groupId>com.example</groupId>
384    <artifactId>my-app</artifactId>
385    <version>1.0.0</version>
386</project>
387"#,
388        )
389        .unwrap();
390
391        bump_version_file(&path, "2.0.0").unwrap();
392
393        let contents = fs::read_to_string(&path).unwrap();
394        assert!(contents.contains("<version>2.0.0</version>"));
395        assert!(contents.contains("<groupId>com.example</groupId>"));
396    }
397
398    #[test]
399    fn bump_pom_xml_with_parent_version() {
400        let dir = tempfile::tempdir().unwrap();
401        let path = dir.path().join("pom.xml");
402        fs::write(
403            &path,
404            r#"<?xml version="1.0" encoding="UTF-8"?>
405<project>
406    <modelVersion>4.0.0</modelVersion>
407    <parent>
408        <groupId>com.example</groupId>
409        <artifactId>parent</artifactId>
410        <version>5.0.0</version>
411    </parent>
412    <artifactId>my-app</artifactId>
413    <version>1.0.0</version>
414</project>
415"#,
416        )
417        .unwrap();
418
419        bump_version_file(&path, "2.0.0").unwrap();
420
421        let contents = fs::read_to_string(&path).unwrap();
422        // Parent version must NOT be changed
423        assert!(contents.contains("<version>5.0.0</version>"));
424        // Project version must be changed
425        assert!(contents.contains("<version>2.0.0</version>"));
426        // Verify there are exactly two <version> tags with expected values
427        let version_count: Vec<&str> = contents.matches("<version>").collect();
428        assert_eq!(version_count.len(), 2);
429    }
430
431    #[test]
432    fn bump_cargo_toml_workspace_dependencies_with_path() {
433        let dir = tempfile::tempdir().unwrap();
434        let path = dir.path().join("Cargo.toml");
435        fs::write(
436            &path,
437            r#"[workspace]
438members = ["crates/*"]
439
440[workspace.package]
441version = "0.1.0"
442edition = "2021"
443
444[workspace.dependencies]
445# Internal crates
446sr-core = { path = "crates/sr-core", version = "0.1.0" }
447sr-git = { path = "crates/sr-git", version = "0.1.0" }
448# External dep should not change
449serde = { version = "1", features = ["derive"] }
450"#,
451        )
452        .unwrap();
453
454        bump_version_file(&path, "2.0.0").unwrap();
455
456        let contents = fs::read_to_string(&path).unwrap();
457        let doc: toml_edit::DocumentMut = contents.parse().unwrap();
458
459        // workspace.package.version should be bumped
460        assert_eq!(
461            doc["workspace"]["package"]["version"].as_str().unwrap(),
462            "2.0.0"
463        );
464        // Internal path deps should have their version bumped
465        assert_eq!(
466            doc["workspace"]["dependencies"]["sr-core"]["version"]
467                .as_str()
468                .unwrap(),
469            "2.0.0"
470        );
471        assert_eq!(
472            doc["workspace"]["dependencies"]["sr-git"]["version"]
473                .as_str()
474                .unwrap(),
475            "2.0.0"
476        );
477        // External dep version must NOT change
478        assert_eq!(
479            doc["workspace"]["dependencies"]["serde"]["version"]
480                .as_str()
481                .unwrap(),
482            "1"
483        );
484    }
485
486    #[test]
487    fn bump_go_version_var() {
488        let dir = tempfile::tempdir().unwrap();
489        let path = dir.path().join("version.go");
490        fs::write(
491            &path,
492            r#"package main
493
494var Version = "1.0.0"
495
496func main() {}
497"#,
498        )
499        .unwrap();
500
501        bump_version_file(&path, "2.0.0").unwrap();
502
503        let contents = fs::read_to_string(&path).unwrap();
504        assert!(contents.contains(r#"var Version = "2.0.0""#));
505    }
506
507    #[test]
508    fn bump_go_version_const() {
509        let dir = tempfile::tempdir().unwrap();
510        let path = dir.path().join("version.go");
511        fs::write(
512            &path,
513            r#"package main
514
515const Version string = "0.5.0"
516
517func main() {}
518"#,
519        )
520        .unwrap();
521
522        bump_version_file(&path, "0.6.0").unwrap();
523
524        let contents = fs::read_to_string(&path).unwrap();
525        assert!(contents.contains(r#"const Version string = "0.6.0""#));
526    }
527}