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
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 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 assert!(contents.contains("<version>5.0.0</version>"));
619 assert!(contents.contains("<version>2.0.0</version>"));
621 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 assert_eq!(
656 doc["workspace"]["package"]["version"].as_str().unwrap(),
657 "2.0.0"
658 );
659 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 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 #[test]
726 fn bump_cargo_workspace_discovers_members() {
727 let dir = tempfile::tempdir().unwrap();
728
729 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 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 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 let root_contents = fs::read_to_string(&root).unwrap();
776 assert!(root_contents.contains("version = \"2.0.0\""));
777
778 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 let member_contents = fs::read_to_string(&member).unwrap();
789 assert!(member_contents.contains("version = \"2.0.0\""));
790
791 let inherited_contents = fs::read_to_string(&inherited_member).unwrap();
793 assert!(inherited_contents.contains("version.workspace = true"));
794
795 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 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 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 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 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 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 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 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 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 let root_contents = fs::read_to_string(&root).unwrap();
895 assert!(root_contents.contains("version = \"2.0.0\""));
896
897 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}