1use std::fs;
2use std::path::Path;
3
4use regex::Regex;
5
6use crate::error::ReleaseError;
7
8pub 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 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 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 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 assert!(contents.contains("<version>5.0.0</version>"));
424 assert!(contents.contains("<version>2.0.0</version>"));
426 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 assert_eq!(
461 doc["workspace"]["package"]["version"].as_str().unwrap(),
462 "2.0.0"
463 );
464 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 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}