1use std::fs;
2use std::path::{Path, PathBuf};
3
4use regex::Regex;
5
6use crate::error::ReleaseError;
7
8pub 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 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 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
104fn 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 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), }
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 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 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
175fn 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 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
249fn 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); }
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 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
329fn 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
350fn 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
374const 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
385pub 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 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 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 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 assert!(contents.contains("<version>5.0.0</version>"));
667 assert!(contents.contains("<version>2.0.0</version>"));
669 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 assert_eq!(
704 doc["workspace"]["package"]["version"].as_str().unwrap(),
705 "2.0.0"
706 );
707 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 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 #[test]
774 fn bump_cargo_workspace_discovers_members() {
775 let dir = tempfile::tempdir().unwrap();
776
777 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 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 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 let root_contents = fs::read_to_string(&root).unwrap();
824 assert!(root_contents.contains("version = \"2.0.0\""));
825
826 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 let member_contents = fs::read_to_string(&member).unwrap();
837 assert!(member_contents.contains("version = \"2.0.0\""));
838
839 let inherited_contents = fs::read_to_string(&inherited_member).unwrap();
841 assert!(inherited_contents.contains("version.workspace = true"));
842
843 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 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 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 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 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 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 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 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 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 let root_contents = fs::read_to_string(&root).unwrap();
943 assert!(root_contents.contains("version = \"2.0.0\""));
944
945 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}