1use std::fs;
2use std::path::{Path, PathBuf};
3
4use regex::Regex;
5
6use crate::error::ReleaseError;
7
8pub trait VersionFileHandler: Send + Sync {
11 fn name(&self) -> &str;
13
14 fn manifest_names(&self) -> &[&str];
16
17 fn lock_file_names(&self) -> &[&str];
19
20 fn detect(&self, dir: &Path) -> bool {
22 self.manifest_names()
23 .iter()
24 .any(|name| dir.join(name).exists())
25 }
26
27 fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError>;
30}
31
32struct CargoHandler;
37
38impl VersionFileHandler for CargoHandler {
39 fn name(&self) -> &str {
40 "Cargo"
41 }
42 fn manifest_names(&self) -> &[&str] {
43 &["Cargo.toml"]
44 }
45 fn lock_file_names(&self) -> &[&str] {
46 &["Cargo.lock"]
47 }
48 fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
49 bump_cargo_toml(path, new_version)
50 }
51}
52
53struct NpmHandler;
54
55impl VersionFileHandler for NpmHandler {
56 fn name(&self) -> &str {
57 "npm"
58 }
59 fn manifest_names(&self) -> &[&str] {
60 &["package.json"]
61 }
62 fn lock_file_names(&self) -> &[&str] {
63 &["package-lock.json", "yarn.lock", "pnpm-lock.yaml"]
64 }
65 fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
66 bump_package_json(path, new_version)
67 }
68}
69
70struct PyprojectHandler;
71
72impl VersionFileHandler for PyprojectHandler {
73 fn name(&self) -> &str {
74 "Python"
75 }
76 fn manifest_names(&self) -> &[&str] {
77 &["pyproject.toml"]
78 }
79 fn lock_file_names(&self) -> &[&str] {
80 &["uv.lock", "poetry.lock"]
81 }
82 fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
83 bump_pyproject_toml(path, new_version)
84 }
85}
86
87struct MavenHandler;
88
89impl VersionFileHandler for MavenHandler {
90 fn name(&self) -> &str {
91 "Maven"
92 }
93 fn manifest_names(&self) -> &[&str] {
94 &["pom.xml"]
95 }
96 fn lock_file_names(&self) -> &[&str] {
97 &[]
98 }
99 fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
100 bump_pom_xml(path, new_version).map(|()| vec![])
101 }
102}
103
104struct GradleHandler;
105
106impl VersionFileHandler for GradleHandler {
107 fn name(&self) -> &str {
108 "Gradle"
109 }
110 fn manifest_names(&self) -> &[&str] {
111 &["build.gradle", "build.gradle.kts"]
112 }
113 fn lock_file_names(&self) -> &[&str] {
114 &[]
115 }
116 fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
117 bump_gradle(path, new_version).map(|()| vec![])
118 }
119}
120
121struct GoHandler;
122
123impl VersionFileHandler for GoHandler {
124 fn name(&self) -> &str {
125 "Go"
126 }
127 fn manifest_names(&self) -> &[&str] {
128 &[]
129 }
130 fn lock_file_names(&self) -> &[&str] {
131 &[]
132 }
133 fn detect(&self, dir: &Path) -> bool {
135 let Ok(entries) = fs::read_dir(dir) else {
136 return false;
137 };
138 for entry in entries.flatten() {
139 let path = entry.path();
140 if path.extension().is_some_and(|e| e == "go")
141 && let Ok(contents) = fs::read_to_string(&path)
142 && go_version_re().is_match(&contents)
143 {
144 return true;
145 }
146 }
147 false
148 }
149 fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
150 bump_go_version(path, new_version).map(|()| vec![])
151 }
152}
153
154pub fn all_handlers() -> Vec<Box<dyn VersionFileHandler>> {
160 vec![
161 Box::new(CargoHandler),
162 Box::new(NpmHandler),
163 Box::new(PyprojectHandler),
164 Box::new(MavenHandler),
165 Box::new(GradleHandler),
166 Box::new(GoHandler),
167 ]
168}
169
170pub fn detect_version_files(dir: &Path) -> Vec<String> {
176 let mut files = Vec::new();
177 for handler in all_handlers() {
178 if !handler.detect(dir) {
179 continue;
180 }
181 if handler.manifest_names().is_empty() {
182 if let Ok(entries) = fs::read_dir(dir) {
184 let re = go_version_re();
185 for entry in entries.flatten() {
186 let path = entry.path();
187 if path.extension().is_some_and(|e| e == "go")
188 && let Ok(contents) = fs::read_to_string(&path)
189 && re.is_match(&contents)
190 {
191 files.push(path.file_name().unwrap().to_string_lossy().into_owned());
192 }
193 }
194 }
195 } else {
196 for name in handler.manifest_names() {
197 if dir.join(name).exists() {
198 files.push((*name).to_string());
199 }
200 }
201 }
202 }
203 files
204}
205
206fn handler_for_file(filename: &str) -> Option<Box<dyn VersionFileHandler>> {
208 for handler in all_handlers() {
209 if handler.manifest_names().contains(&filename) {
210 return Some(handler);
211 }
212 }
213 if filename.ends_with(".go") {
215 return Some(Box::new(GoHandler));
216 }
217 None
218}
219
220pub fn bump_version_file(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
237 let filename = path
238 .file_name()
239 .and_then(|n| n.to_str())
240 .unwrap_or_default();
241
242 match handler_for_file(filename) {
243 Some(handler) => handler.bump(path, new_version),
244 None => Err(ReleaseError::VersionBump(format!(
245 "unsupported version file: {filename}"
246 ))),
247 }
248}
249
250pub fn discover_lock_files(bumped_files: &[String]) -> Vec<PathBuf> {
254 let handlers = all_handlers();
255 let mut seen = std::collections::BTreeSet::new();
256 for file in bumped_files {
257 let path = Path::new(file);
258 let filename = path
259 .file_name()
260 .and_then(|n| n.to_str())
261 .unwrap_or_default();
262
263 let mut lock_names: Vec<&str> = Vec::new();
265 for handler in &handlers {
266 if handler.manifest_names().contains(&filename) {
267 lock_names.extend(handler.lock_file_names());
268 }
269 }
270
271 let mut dir = path.parent();
273 while let Some(d) = dir {
274 for lock_name in &lock_names {
275 let lock_path = d.join(lock_name);
276 if lock_path.exists() {
277 seen.insert(lock_path);
278 }
279 }
280 dir = d.parent();
281 if d.join(".git").exists() {
283 break;
284 }
285 }
286 }
287 seen.into_iter().collect()
288}
289
290pub fn is_supported_version_file(filename: &str) -> bool {
292 handler_for_file(filename).is_some()
293}
294
295fn go_version_re() -> Regex {
297 Regex::new(r#"(?:var|const)\s+Version\s*(?:string\s*)?=\s*""#).unwrap()
298}
299
300fn bump_cargo_toml(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
305 let contents = read_file(path)?;
306 let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
307 ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
308 })?;
309
310 let is_workspace = doc
311 .get("workspace")
312 .and_then(|w| w.get("package"))
313 .and_then(|p| p.get("version"))
314 .is_some();
315
316 if doc.get("package").and_then(|p| p.get("version")).is_some() {
317 doc["package"]["version"] = toml_edit::value(new_version);
318 } else if is_workspace {
319 doc["workspace"]["package"]["version"] = toml_edit::value(new_version);
320
321 if let Some(deps) = doc
323 .get_mut("workspace")
324 .and_then(|w| w.get_mut("dependencies"))
325 .and_then(|d| d.as_table_like_mut())
326 {
327 for (_, dep) in deps.iter_mut() {
328 if let Some(tbl) = dep.as_table_like_mut()
329 && tbl.get("path").is_some()
330 && tbl.get("version").is_some()
331 {
332 tbl.insert("version", toml_edit::value(new_version));
333 }
334 }
335 }
336 } else {
337 return Err(ReleaseError::VersionBump(format!(
338 "no version field found in {}",
339 path.display()
340 )));
341 }
342
343 write_file(path, &doc.to_string())?;
344
345 let mut extra = Vec::new();
347 if is_workspace {
348 let members = extract_toml_string_array(&doc, &["workspace", "members"]);
349 let root_dir = path.parent().unwrap_or(Path::new("."));
350 for member_path in resolve_member_globs(root_dir, &members, "Cargo.toml") {
351 if member_path.as_path() == path {
352 continue;
353 }
354 match bump_cargo_member(&member_path, new_version) {
355 Ok(true) => extra.push(member_path),
356 Ok(false) => {}
357 Err(e) => eprintln!("warning: {e}"),
358 }
359 }
360 }
361
362 Ok(extra)
363}
364
365fn bump_cargo_member(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
368 let contents = read_file(path)?;
369 let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
370 ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
371 })?;
372
373 let version_item = doc.get("package").and_then(|p| p.get("version"));
375 match version_item {
376 Some(item) if item.is_value() => {
377 doc["package"]["version"] = toml_edit::value(new_version);
378 write_file(path, &doc.to_string())?;
379 Ok(true)
380 }
381 _ => Ok(false), }
383}
384
385fn bump_package_json(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
386 let contents = read_file(path)?;
387 let mut value: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
388 ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
389 })?;
390
391 let obj = value
392 .as_object_mut()
393 .ok_or_else(|| ReleaseError::VersionBump("package.json is not an object".into()))?;
394
395 let workspace_patterns: Vec<String> = obj
397 .get("workspaces")
398 .and_then(|w| w.as_array())
399 .map(|arr| {
400 arr.iter()
401 .filter_map(|v| v.as_str().map(String::from))
402 .collect()
403 })
404 .unwrap_or_default();
405
406 obj.insert(
407 "version".into(),
408 serde_json::Value::String(new_version.into()),
409 );
410
411 let output = serde_json::to_string_pretty(&value).map_err(|e| {
412 ReleaseError::VersionBump(format!("failed to serialize {}: {e}", path.display()))
413 })?;
414
415 write_file(path, &format!("{output}\n"))?;
416
417 let mut extra = Vec::new();
419 if !workspace_patterns.is_empty() {
420 let root_dir = path.parent().unwrap_or(Path::new("."));
421 for member_path in resolve_member_globs(root_dir, &workspace_patterns, "package.json") {
422 if member_path == path {
423 continue;
424 }
425 match bump_json_version(&member_path, new_version) {
426 Ok(true) => extra.push(member_path),
427 Ok(false) => {}
428 Err(e) => eprintln!("warning: {e}"),
429 }
430 }
431 }
432
433 Ok(extra)
434}
435
436fn bump_json_version(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
439 let contents = read_file(path)?;
440 let mut value: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
441 ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
442 })?;
443
444 let obj = match value.as_object_mut() {
445 Some(o) => o,
446 None => return Ok(false),
447 };
448
449 if obj.get("version").is_none() {
450 return Ok(false);
451 }
452
453 obj.insert(
454 "version".into(),
455 serde_json::Value::String(new_version.into()),
456 );
457
458 let output = serde_json::to_string_pretty(&value).map_err(|e| {
459 ReleaseError::VersionBump(format!("failed to serialize {}: {e}", path.display()))
460 })?;
461
462 write_file(path, &format!("{output}\n"))?;
463 Ok(true)
464}
465
466fn bump_pyproject_toml(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
467 let contents = read_file(path)?;
468 let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
469 ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
470 })?;
471
472 if doc.get("project").and_then(|p| p.get("version")).is_some() {
473 doc["project"]["version"] = toml_edit::value(new_version);
474 } else if doc
475 .get("tool")
476 .and_then(|t| t.get("poetry"))
477 .and_then(|p| p.get("version"))
478 .is_some()
479 {
480 doc["tool"]["poetry"]["version"] = toml_edit::value(new_version);
481 } else {
482 return Err(ReleaseError::VersionBump(format!(
483 "no version field found in {}",
484 path.display()
485 )));
486 }
487
488 write_file(path, &doc.to_string())?;
489
490 let members = extract_toml_string_array(&doc, &["tool", "uv", "workspace", "members"]);
492 let mut extra = Vec::new();
493 if !members.is_empty() {
494 let root_dir = path.parent().unwrap_or(Path::new("."));
495 for member_path in resolve_member_globs(root_dir, &members, "pyproject.toml") {
496 if member_path.as_path() == path {
497 continue;
498 }
499 match bump_pyproject_member(&member_path, new_version) {
500 Ok(true) => extra.push(member_path),
501 Ok(false) => {}
502 Err(e) => eprintln!("warning: {e}"),
503 }
504 }
505 }
506
507 Ok(extra)
508}
509
510fn bump_pyproject_member(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
513 let contents = read_file(path)?;
514 let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
515 ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
516 })?;
517
518 if doc.get("project").and_then(|p| p.get("version")).is_some() {
519 doc["project"]["version"] = toml_edit::value(new_version);
520 } else if doc
521 .get("tool")
522 .and_then(|t| t.get("poetry"))
523 .and_then(|p| p.get("version"))
524 .is_some()
525 {
526 doc["tool"]["poetry"]["version"] = toml_edit::value(new_version);
527 } else {
528 return Ok(false); }
530
531 write_file(path, &doc.to_string())?;
532 Ok(true)
533}
534
535fn bump_gradle(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
536 let contents = read_file(path)?;
537 let re = Regex::new(r#"(version\s*=\s*["'])([^"']*)(["'])"#).unwrap();
538 if !re.is_match(&contents) {
539 return Err(ReleaseError::VersionBump(format!(
540 "no version assignment found in {}",
541 path.display()
542 )));
543 }
544 let result = re.replacen(&contents, 1, format!("${{1}}{new_version}${{3}}"));
545 write_file(path, &result)
546}
547
548fn bump_pom_xml(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
549 let contents = read_file(path)?;
550
551 let search_start = if let Some(pos) = contents.find("</parent>") {
553 pos + "</parent>".len()
554 } else if let Some(pos) = contents.find("</modelVersion>") {
555 pos + "</modelVersion>".len()
556 } else {
557 0
558 };
559
560 let rest = &contents[search_start..];
561 let re = Regex::new(r"<version>[^<]*</version>").unwrap();
562 if let Some(m) = re.find(rest) {
563 let replacement = format!("<version>{new_version}</version>");
564 let mut result = String::with_capacity(contents.len());
565 result.push_str(&contents[..search_start + m.start()]);
566 result.push_str(&replacement);
567 result.push_str(&contents[search_start + m.end()..]);
568 write_file(path, &result)
569 } else {
570 Err(ReleaseError::VersionBump(format!(
571 "no <version> element found in {}",
572 path.display()
573 )))
574 }
575}
576
577fn bump_go_version(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
578 let contents = read_file(path)?;
579 let re = Regex::new(r#"((?:var|const)\s+Version\s*(?:string\s*)?=\s*")([^"]*)(")"#).unwrap();
580 if !re.is_match(&contents) {
581 return Err(ReleaseError::VersionBump(format!(
582 "no Version variable found in {}",
583 path.display()
584 )));
585 }
586 let result = re.replacen(&contents, 1, format!("${{1}}{new_version}${{3}}"));
587 write_file(path, &result)
588}
589
590fn extract_toml_string_array(doc: &toml_edit::DocumentMut, keys: &[&str]) -> Vec<String> {
592 let mut item: Option<&toml_edit::Item> = None;
593 for key in keys {
594 item = match item {
595 None => doc.get(key),
596 Some(parent) => parent.get(key),
597 };
598 if item.is_none() {
599 return vec![];
600 }
601 }
602 item.and_then(|v| v.as_array())
603 .map(|arr| {
604 arr.iter()
605 .filter_map(|v| v.as_str().map(String::from))
606 .collect()
607 })
608 .unwrap_or_default()
609}
610
611fn resolve_member_globs(root_dir: &Path, patterns: &[String], manifest_name: &str) -> Vec<PathBuf> {
615 let mut paths = Vec::new();
616 for pattern in patterns {
617 let full_pattern = root_dir.join(pattern).to_string_lossy().into_owned();
618 let Ok(entries) = glob::glob(&full_pattern) else {
619 continue;
620 };
621 for entry in entries.flatten() {
622 let manifest = if entry.is_dir() {
623 entry.join(manifest_name)
624 } else {
625 continue;
626 };
627 if manifest.exists() {
628 paths.push(manifest);
629 }
630 }
631 }
632 paths
633}
634
635fn read_file(path: &Path) -> Result<String, ReleaseError> {
636 fs::read_to_string(path)
637 .map_err(|e| ReleaseError::VersionBump(format!("failed to read {}: {e}", path.display())))
638}
639
640fn write_file(path: &Path, contents: &str) -> Result<(), ReleaseError> {
641 fs::write(path, contents)
642 .map_err(|e| ReleaseError::VersionBump(format!("failed to write {}: {e}", path.display())))
643}
644
645#[cfg(test)]
646mod tests {
647 use super::*;
648
649 #[test]
650 fn bump_cargo_toml_package_version() {
651 let dir = tempfile::tempdir().unwrap();
652 let path = dir.path().join("Cargo.toml");
653 fs::write(
654 &path,
655 r#"[package]
656name = "my-crate"
657version = "0.1.0"
658edition = "2021"
659
660[dependencies]
661serde = "1"
662"#,
663 )
664 .unwrap();
665
666 bump_version_file(&path, "1.2.3").unwrap();
667
668 let contents = fs::read_to_string(&path).unwrap();
669 assert!(contents.contains("version = \"1.2.3\""));
670 assert!(contents.contains("name = \"my-crate\""));
671 assert!(contents.contains("serde = \"1\""));
672 }
673
674 #[test]
675 fn bump_cargo_toml_workspace_version() {
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.0.1"
685edition = "2021"
686"#,
687 )
688 .unwrap();
689
690 bump_version_file(&path, "2.0.0").unwrap();
691
692 let contents = fs::read_to_string(&path).unwrap();
693 assert!(contents.contains("version = \"2.0.0\""));
694 assert!(contents.contains("members = [\"crates/*\"]"));
695 }
696
697 #[test]
698 fn bump_package_json_version() {
699 let dir = tempfile::tempdir().unwrap();
700 let path = dir.path().join("package.json");
701 fs::write(
702 &path,
703 r#"{
704 "name": "my-pkg",
705 "version": "0.0.0",
706 "description": "test"
707}"#,
708 )
709 .unwrap();
710
711 bump_version_file(&path, "3.1.0").unwrap();
712
713 let contents = fs::read_to_string(&path).unwrap();
714 let value: serde_json::Value = serde_json::from_str(&contents).unwrap();
715 assert_eq!(value["version"], "3.1.0");
716 assert_eq!(value["name"], "my-pkg");
717 assert_eq!(value["description"], "test");
718 assert!(contents.ends_with('\n'));
719 }
720
721 #[test]
722 fn bump_pyproject_toml_project_version() {
723 let dir = tempfile::tempdir().unwrap();
724 let path = dir.path().join("pyproject.toml");
725 fs::write(
726 &path,
727 r#"[project]
728name = "my-project"
729version = "0.1.0"
730description = "A test project"
731"#,
732 )
733 .unwrap();
734
735 bump_version_file(&path, "1.0.0").unwrap();
736
737 let contents = fs::read_to_string(&path).unwrap();
738 assert!(contents.contains("version = \"1.0.0\""));
739 assert!(contents.contains("name = \"my-project\""));
740 }
741
742 #[test]
743 fn bump_pyproject_toml_poetry_version() {
744 let dir = tempfile::tempdir().unwrap();
745 let path = dir.path().join("pyproject.toml");
746 fs::write(
747 &path,
748 r#"[tool.poetry]
749name = "my-poetry-project"
750version = "0.2.0"
751description = "A poetry project"
752"#,
753 )
754 .unwrap();
755
756 bump_version_file(&path, "0.3.0").unwrap();
757
758 let contents = fs::read_to_string(&path).unwrap();
759 assert!(contents.contains("version = \"0.3.0\""));
760 assert!(contents.contains("name = \"my-poetry-project\""));
761 }
762
763 #[test]
764 fn bump_unknown_file_returns_error() {
765 let dir = tempfile::tempdir().unwrap();
766 let path = dir.path().join("unknown.txt");
767 fs::write(&path, "version = 1").unwrap();
768
769 let err = bump_version_file(&path, "1.0.0").unwrap_err();
770 assert!(matches!(err, ReleaseError::VersionBump(_)));
771 assert!(err.to_string().contains("unsupported"));
772 }
773
774 #[test]
775 fn bump_build_gradle_version() {
776 let dir = tempfile::tempdir().unwrap();
777 let path = dir.path().join("build.gradle");
778 fs::write(
779 &path,
780 r#"plugins {
781 id 'java'
782}
783
784group = 'com.example'
785version = '1.0.0'
786
787dependencies {
788 implementation 'org.slf4j:slf4j-api:2.0.0'
789}
790"#,
791 )
792 .unwrap();
793
794 bump_version_file(&path, "2.0.0").unwrap();
795
796 let contents = fs::read_to_string(&path).unwrap();
797 assert!(contents.contains("version = '2.0.0'"));
798 assert!(contents.contains("group = 'com.example'"));
799 assert!(contents.contains("slf4j-api:2.0.0"));
801 }
802
803 #[test]
804 fn bump_build_gradle_kts_version() {
805 let dir = tempfile::tempdir().unwrap();
806 let path = dir.path().join("build.gradle.kts");
807 fs::write(
808 &path,
809 r#"plugins {
810 kotlin("jvm") version "1.9.0"
811}
812
813group = "com.example"
814version = "1.0.0"
815
816dependencies {
817 implementation("org.slf4j:slf4j-api:2.0.0")
818}
819"#,
820 )
821 .unwrap();
822
823 bump_version_file(&path, "3.0.0").unwrap();
824
825 let contents = fs::read_to_string(&path).unwrap();
826 assert!(contents.contains("version = \"3.0.0\""));
827 assert!(contents.contains("group = \"com.example\""));
828 }
829
830 #[test]
831 fn bump_pom_xml_version() {
832 let dir = tempfile::tempdir().unwrap();
833 let path = dir.path().join("pom.xml");
834 fs::write(
835 &path,
836 r#"<?xml version="1.0" encoding="UTF-8"?>
837<project>
838 <modelVersion>4.0.0</modelVersion>
839 <groupId>com.example</groupId>
840 <artifactId>my-app</artifactId>
841 <version>1.0.0</version>
842</project>
843"#,
844 )
845 .unwrap();
846
847 bump_version_file(&path, "2.0.0").unwrap();
848
849 let contents = fs::read_to_string(&path).unwrap();
850 assert!(contents.contains("<version>2.0.0</version>"));
851 assert!(contents.contains("<groupId>com.example</groupId>"));
852 }
853
854 #[test]
855 fn bump_pom_xml_with_parent_version() {
856 let dir = tempfile::tempdir().unwrap();
857 let path = dir.path().join("pom.xml");
858 fs::write(
859 &path,
860 r#"<?xml version="1.0" encoding="UTF-8"?>
861<project>
862 <modelVersion>4.0.0</modelVersion>
863 <parent>
864 <groupId>com.example</groupId>
865 <artifactId>parent</artifactId>
866 <version>5.0.0</version>
867 </parent>
868 <artifactId>my-app</artifactId>
869 <version>1.0.0</version>
870</project>
871"#,
872 )
873 .unwrap();
874
875 bump_version_file(&path, "2.0.0").unwrap();
876
877 let contents = fs::read_to_string(&path).unwrap();
878 assert!(contents.contains("<version>5.0.0</version>"));
880 assert!(contents.contains("<version>2.0.0</version>"));
882 let version_count: Vec<&str> = contents.matches("<version>").collect();
884 assert_eq!(version_count.len(), 2);
885 }
886
887 #[test]
888 fn bump_cargo_toml_workspace_dependencies_with_path() {
889 let dir = tempfile::tempdir().unwrap();
890 let path = dir.path().join("Cargo.toml");
891 fs::write(
892 &path,
893 r#"[workspace]
894members = ["crates/*"]
895
896[workspace.package]
897version = "0.1.0"
898edition = "2021"
899
900[workspace.dependencies]
901# Internal crates
902sr-core = { path = "crates/sr-core", version = "0.1.0" }
903sr-git = { path = "crates/sr-git", version = "0.1.0" }
904# External dep should not change
905serde = { version = "1", features = ["derive"] }
906"#,
907 )
908 .unwrap();
909
910 bump_version_file(&path, "2.0.0").unwrap();
911
912 let contents = fs::read_to_string(&path).unwrap();
913 let doc: toml_edit::DocumentMut = contents.parse().unwrap();
914
915 assert_eq!(
917 doc["workspace"]["package"]["version"].as_str().unwrap(),
918 "2.0.0"
919 );
920 assert_eq!(
922 doc["workspace"]["dependencies"]["sr-core"]["version"]
923 .as_str()
924 .unwrap(),
925 "2.0.0"
926 );
927 assert_eq!(
928 doc["workspace"]["dependencies"]["sr-git"]["version"]
929 .as_str()
930 .unwrap(),
931 "2.0.0"
932 );
933 assert_eq!(
935 doc["workspace"]["dependencies"]["serde"]["version"]
936 .as_str()
937 .unwrap(),
938 "1"
939 );
940 }
941
942 #[test]
943 fn bump_go_version_var() {
944 let dir = tempfile::tempdir().unwrap();
945 let path = dir.path().join("version.go");
946 fs::write(
947 &path,
948 r#"package main
949
950var Version = "1.0.0"
951
952func main() {}
953"#,
954 )
955 .unwrap();
956
957 bump_version_file(&path, "2.0.0").unwrap();
958
959 let contents = fs::read_to_string(&path).unwrap();
960 assert!(contents.contains(r#"var Version = "2.0.0""#));
961 }
962
963 #[test]
964 fn bump_go_version_const() {
965 let dir = tempfile::tempdir().unwrap();
966 let path = dir.path().join("version.go");
967 fs::write(
968 &path,
969 r#"package main
970
971const Version string = "0.5.0"
972
973func main() {}
974"#,
975 )
976 .unwrap();
977
978 bump_version_file(&path, "0.6.0").unwrap();
979
980 let contents = fs::read_to_string(&path).unwrap();
981 assert!(contents.contains(r#"const Version string = "0.6.0""#));
982 }
983
984 #[test]
987 fn bump_cargo_workspace_discovers_members() {
988 let dir = tempfile::tempdir().unwrap();
989
990 let root = dir.path().join("Cargo.toml");
992 fs::write(
993 &root,
994 r#"[workspace]
995members = ["crates/*"]
996
997[workspace.package]
998version = "1.0.0"
999edition = "2021"
1000
1001[workspace.dependencies]
1002my-core = { path = "crates/core", version = "1.0.0" }
1003"#,
1004 )
1005 .unwrap();
1006
1007 fs::create_dir_all(dir.path().join("crates/core")).unwrap();
1009 let member = dir.path().join("crates/core/Cargo.toml");
1010 fs::write(
1011 &member,
1012 r#"[package]
1013name = "my-core"
1014version = "1.0.0"
1015edition = "2021"
1016"#,
1017 )
1018 .unwrap();
1019
1020 fs::create_dir_all(dir.path().join("crates/cli")).unwrap();
1022 let inherited_member = dir.path().join("crates/cli/Cargo.toml");
1023 fs::write(
1024 &inherited_member,
1025 r#"[package]
1026name = "my-cli"
1027version.workspace = true
1028edition.workspace = true
1029"#,
1030 )
1031 .unwrap();
1032
1033 let extra = bump_version_file(&root, "2.0.0").unwrap();
1034
1035 let root_contents = fs::read_to_string(&root).unwrap();
1037 assert!(root_contents.contains("version = \"2.0.0\""));
1038
1039 let doc: toml_edit::DocumentMut = root_contents.parse().unwrap();
1041 assert_eq!(
1042 doc["workspace"]["dependencies"]["my-core"]["version"]
1043 .as_str()
1044 .unwrap(),
1045 "2.0.0"
1046 );
1047
1048 let member_contents = fs::read_to_string(&member).unwrap();
1050 assert!(member_contents.contains("version = \"2.0.0\""));
1051
1052 let inherited_contents = fs::read_to_string(&inherited_member).unwrap();
1054 assert!(inherited_contents.contains("version.workspace = true"));
1055
1056 assert_eq!(extra.len(), 1);
1058 assert_eq!(extra[0], member);
1059 }
1060
1061 #[test]
1062 fn bump_npm_workspace_discovers_members() {
1063 let dir = tempfile::tempdir().unwrap();
1064
1065 let root = dir.path().join("package.json");
1067 fs::write(
1068 &root,
1069 r#"{
1070 "name": "my-monorepo",
1071 "version": "1.0.0",
1072 "workspaces": ["packages/*"]
1073}"#,
1074 )
1075 .unwrap();
1076
1077 fs::create_dir_all(dir.path().join("packages/core")).unwrap();
1079 let member = dir.path().join("packages/core/package.json");
1080 fs::write(
1081 &member,
1082 r#"{
1083 "name": "@my/core",
1084 "version": "1.0.0"
1085}"#,
1086 )
1087 .unwrap();
1088
1089 fs::create_dir_all(dir.path().join("packages/utils")).unwrap();
1091 let no_version_member = dir.path().join("packages/utils/package.json");
1092 fs::write(
1093 &no_version_member,
1094 r#"{
1095 "name": "@my/utils",
1096 "private": true
1097}"#,
1098 )
1099 .unwrap();
1100
1101 let extra = bump_version_file(&root, "2.0.0").unwrap();
1102
1103 let root_contents: serde_json::Value =
1105 serde_json::from_str(&fs::read_to_string(&root).unwrap()).unwrap();
1106 assert_eq!(root_contents["version"], "2.0.0");
1107
1108 let member_contents: serde_json::Value =
1110 serde_json::from_str(&fs::read_to_string(&member).unwrap()).unwrap();
1111 assert_eq!(member_contents["version"], "2.0.0");
1112
1113 let utils_contents: serde_json::Value =
1115 serde_json::from_str(&fs::read_to_string(&no_version_member).unwrap()).unwrap();
1116 assert!(utils_contents.get("version").is_none());
1117
1118 assert_eq!(extra.len(), 1);
1119 assert_eq!(extra[0], member);
1120 }
1121
1122 #[test]
1123 fn bump_uv_workspace_discovers_members() {
1124 let dir = tempfile::tempdir().unwrap();
1125
1126 let root = dir.path().join("pyproject.toml");
1128 fs::write(
1129 &root,
1130 r#"[project]
1131name = "my-monorepo"
1132version = "1.0.0"
1133
1134[tool.uv.workspace]
1135members = ["packages/*"]
1136"#,
1137 )
1138 .unwrap();
1139
1140 fs::create_dir_all(dir.path().join("packages/core")).unwrap();
1142 let member = dir.path().join("packages/core/pyproject.toml");
1143 fs::write(
1144 &member,
1145 r#"[project]
1146name = "my-core"
1147version = "1.0.0"
1148"#,
1149 )
1150 .unwrap();
1151
1152 let extra = bump_version_file(&root, "2.0.0").unwrap();
1153
1154 let root_contents = fs::read_to_string(&root).unwrap();
1156 assert!(root_contents.contains("version = \"2.0.0\""));
1157
1158 let member_contents = fs::read_to_string(&member).unwrap();
1160 assert!(member_contents.contains("version = \"2.0.0\""));
1161
1162 assert_eq!(extra.len(), 1);
1163 assert_eq!(extra[0], member);
1164 }
1165
1166 #[test]
1167 fn bump_non_workspace_returns_empty_extra() {
1168 let dir = tempfile::tempdir().unwrap();
1169 let path = dir.path().join("Cargo.toml");
1170 fs::write(
1171 &path,
1172 r#"[package]
1173name = "solo-crate"
1174version = "1.0.0"
1175"#,
1176 )
1177 .unwrap();
1178
1179 let extra = bump_version_file(&path, "2.0.0").unwrap();
1180 assert!(extra.is_empty());
1181 }
1182
1183 #[test]
1186 fn detect_cargo_toml() {
1187 let dir = tempfile::tempdir().unwrap();
1188 fs::write(
1189 dir.path().join("Cargo.toml"),
1190 "[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
1191 )
1192 .unwrap();
1193
1194 let detected = detect_version_files(dir.path());
1195 assert_eq!(detected, vec!["Cargo.toml"]);
1196 }
1197
1198 #[test]
1199 fn detect_package_json() {
1200 let dir = tempfile::tempdir().unwrap();
1201 fs::write(
1202 dir.path().join("package.json"),
1203 r#"{"name": "x", "version": "1.0.0"}"#,
1204 )
1205 .unwrap();
1206
1207 let detected = detect_version_files(dir.path());
1208 assert_eq!(detected, vec!["package.json"]);
1209 }
1210
1211 #[test]
1212 fn detect_pyproject_toml() {
1213 let dir = tempfile::tempdir().unwrap();
1214 fs::write(
1215 dir.path().join("pyproject.toml"),
1216 "[project]\nname = \"x\"\nversion = \"0.1.0\"\n",
1217 )
1218 .unwrap();
1219
1220 let detected = detect_version_files(dir.path());
1221 assert_eq!(detected, vec!["pyproject.toml"]);
1222 }
1223
1224 #[test]
1225 fn detect_multiple_ecosystems() {
1226 let dir = tempfile::tempdir().unwrap();
1227 fs::write(
1228 dir.path().join("Cargo.toml"),
1229 "[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
1230 )
1231 .unwrap();
1232 fs::write(
1233 dir.path().join("package.json"),
1234 r#"{"name": "x", "version": "1.0.0"}"#,
1235 )
1236 .unwrap();
1237
1238 let detected = detect_version_files(dir.path());
1239 assert!(detected.contains(&"Cargo.toml".to_string()));
1240 assert!(detected.contains(&"package.json".to_string()));
1241 }
1242
1243 #[test]
1244 fn detect_empty_directory() {
1245 let dir = tempfile::tempdir().unwrap();
1246 let detected = detect_version_files(dir.path());
1247 assert!(detected.is_empty());
1248 }
1249
1250 #[test]
1251 fn detect_go_version_file() {
1252 let dir = tempfile::tempdir().unwrap();
1253 fs::write(
1254 dir.path().join("version.go"),
1255 "package main\n\nvar Version = \"1.0.0\"\n",
1256 )
1257 .unwrap();
1258
1259 let detected = detect_version_files(dir.path());
1260 assert_eq!(detected, vec!["version.go"]);
1261 }
1262
1263 #[test]
1264 fn is_supported_recognizes_all_types() {
1265 assert!(is_supported_version_file("Cargo.toml"));
1266 assert!(is_supported_version_file("package.json"));
1267 assert!(is_supported_version_file("pyproject.toml"));
1268 assert!(is_supported_version_file("pom.xml"));
1269 assert!(is_supported_version_file("build.gradle"));
1270 assert!(is_supported_version_file("build.gradle.kts"));
1271 assert!(is_supported_version_file("version.go"));
1272 assert!(!is_supported_version_file("unknown.txt"));
1273 }
1274}