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 let mut bumped_names: Vec<String> = Vec::new();
317 if let Some(name) = doc
318 .get("package")
319 .and_then(|p| p.get("name"))
320 .and_then(|n| n.as_str())
321 {
322 bumped_names.push(name.to_string());
323 }
324
325 if doc.get("package").and_then(|p| p.get("version")).is_some() {
326 doc["package"]["version"] = toml_edit::value(new_version);
327 } else if is_workspace {
328 doc["workspace"]["package"]["version"] = toml_edit::value(new_version);
329
330 if let Some(deps) = doc
332 .get_mut("workspace")
333 .and_then(|w| w.get_mut("dependencies"))
334 .and_then(|d| d.as_table_like_mut())
335 {
336 for (_, dep) in deps.iter_mut() {
337 if let Some(tbl) = dep.as_table_like_mut()
338 && tbl.get("path").is_some()
339 && tbl.get("version").is_some()
340 {
341 tbl.insert("version", toml_edit::value(new_version));
342 }
343 }
344 }
345 } else {
346 return Err(ReleaseError::VersionBump(format!(
347 "no version field found in {}",
348 path.display()
349 )));
350 }
351
352 write_file(path, &doc.to_string())?;
353
354 let mut extra = Vec::new();
356 if is_workspace {
357 let members = extract_toml_string_array(&doc, &["workspace", "members"]);
358 let root_dir = path.parent().unwrap_or(Path::new("."));
359 for member_path in resolve_member_globs(root_dir, &members, "Cargo.toml") {
360 if member_path.as_path() == path {
361 continue;
362 }
363 match bump_cargo_member(&member_path, new_version) {
364 Ok((modified, name)) => {
365 if modified {
366 extra.push(member_path);
367 }
368 if let Some(n) = name {
369 bumped_names.push(n);
370 }
371 }
372 Err(e) => eprintln!("warning: {e}"),
373 }
374 }
375 }
376
377 refresh_cargo_lock(path, new_version, &bumped_names)?;
382
383 Ok(extra)
384}
385
386fn bump_cargo_member(
391 path: &Path,
392 new_version: &str,
393) -> Result<(bool, Option<String>), ReleaseError> {
394 let contents = read_file(path)?;
395 let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
396 ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
397 })?;
398
399 let name = doc
400 .get("package")
401 .and_then(|p| p.get("name"))
402 .and_then(|n| n.as_str())
403 .map(|s| s.to_string());
404
405 let version_item = doc.get("package").and_then(|p| p.get("version"));
407 match version_item {
408 Some(item) if item.is_value() => {
409 doc["package"]["version"] = toml_edit::value(new_version);
410 write_file(path, &doc.to_string())?;
411 Ok((true, name))
412 }
413 _ => Ok((false, name)), }
415}
416
417fn refresh_cargo_lock(
421 manifest_path: &Path,
422 new_version: &str,
423 member_names: &[String],
424) -> Result<(), ReleaseError> {
425 if member_names.is_empty() {
426 return Ok(());
427 }
428
429 let mut dir = manifest_path.parent();
431 let lock_path = loop {
432 let Some(d) = dir else {
433 return Ok(());
434 };
435 let candidate = d.join("Cargo.lock");
436 if candidate.exists() {
437 break candidate;
438 }
439 if d.join(".git").exists() {
440 return Ok(());
441 }
442 dir = d.parent();
443 };
444
445 let contents = read_file(&lock_path)?;
446 let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
447 ReleaseError::VersionBump(format!("failed to parse {}: {e}", lock_path.display()))
448 })?;
449
450 let Some(packages) = doc
451 .get_mut("package")
452 .and_then(|p| p.as_array_of_tables_mut())
453 else {
454 return Ok(());
455 };
456
457 let mut changed = false;
458 for pkg in packages.iter_mut() {
459 let Some(name) = pkg.get("name").and_then(|n| n.as_str()) else {
460 continue;
461 };
462 if pkg.contains_key("source") {
463 continue; }
465 if member_names.iter().any(|m| m == name) {
466 pkg.insert("version", toml_edit::value(new_version));
467 changed = true;
468 }
469 }
470
471 if changed {
472 write_file(&lock_path, &doc.to_string())?;
473 }
474 Ok(())
475}
476
477fn bump_package_json(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
478 let contents = read_file(path)?;
479 let mut value: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
480 ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
481 })?;
482
483 let obj = value
484 .as_object_mut()
485 .ok_or_else(|| ReleaseError::VersionBump("package.json is not an object".into()))?;
486
487 let workspace_patterns: Vec<String> = obj
489 .get("workspaces")
490 .and_then(|w| w.as_array())
491 .map(|arr| {
492 arr.iter()
493 .filter_map(|v| v.as_str().map(String::from))
494 .collect()
495 })
496 .unwrap_or_default();
497
498 obj.insert(
499 "version".into(),
500 serde_json::Value::String(new_version.into()),
501 );
502
503 let output = serde_json::to_string_pretty(&value).map_err(|e| {
504 ReleaseError::VersionBump(format!("failed to serialize {}: {e}", path.display()))
505 })?;
506
507 write_file(path, &format!("{output}\n"))?;
508
509 let mut extra = Vec::new();
511 if !workspace_patterns.is_empty() {
512 let root_dir = path.parent().unwrap_or(Path::new("."));
513 for member_path in resolve_member_globs(root_dir, &workspace_patterns, "package.json") {
514 if member_path == path {
515 continue;
516 }
517 match bump_json_version(&member_path, new_version) {
518 Ok(true) => extra.push(member_path),
519 Ok(false) => {}
520 Err(e) => eprintln!("warning: {e}"),
521 }
522 }
523 }
524
525 Ok(extra)
526}
527
528fn bump_json_version(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
531 let contents = read_file(path)?;
532 let mut value: serde_json::Value = serde_json::from_str(&contents).map_err(|e| {
533 ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
534 })?;
535
536 let obj = match value.as_object_mut() {
537 Some(o) => o,
538 None => return Ok(false),
539 };
540
541 if obj.get("version").is_none() {
542 return Ok(false);
543 }
544
545 obj.insert(
546 "version".into(),
547 serde_json::Value::String(new_version.into()),
548 );
549
550 let output = serde_json::to_string_pretty(&value).map_err(|e| {
551 ReleaseError::VersionBump(format!("failed to serialize {}: {e}", path.display()))
552 })?;
553
554 write_file(path, &format!("{output}\n"))?;
555 Ok(true)
556}
557
558fn bump_pyproject_toml(path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError> {
559 let contents = read_file(path)?;
560 let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
561 ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
562 })?;
563
564 if doc.get("project").and_then(|p| p.get("version")).is_some() {
565 doc["project"]["version"] = toml_edit::value(new_version);
566 } else if doc
567 .get("tool")
568 .and_then(|t| t.get("poetry"))
569 .and_then(|p| p.get("version"))
570 .is_some()
571 {
572 doc["tool"]["poetry"]["version"] = toml_edit::value(new_version);
573 } else {
574 return Err(ReleaseError::VersionBump(format!(
575 "no version field found in {}",
576 path.display()
577 )));
578 }
579
580 write_file(path, &doc.to_string())?;
581
582 let members = extract_toml_string_array(&doc, &["tool", "uv", "workspace", "members"]);
584 let mut extra = Vec::new();
585 if !members.is_empty() {
586 let root_dir = path.parent().unwrap_or(Path::new("."));
587 for member_path in resolve_member_globs(root_dir, &members, "pyproject.toml") {
588 if member_path.as_path() == path {
589 continue;
590 }
591 match bump_pyproject_member(&member_path, new_version) {
592 Ok(true) => extra.push(member_path),
593 Ok(false) => {}
594 Err(e) => eprintln!("warning: {e}"),
595 }
596 }
597 }
598
599 Ok(extra)
600}
601
602fn bump_pyproject_member(path: &Path, new_version: &str) -> Result<bool, ReleaseError> {
605 let contents = read_file(path)?;
606 let mut doc: toml_edit::DocumentMut = contents.parse().map_err(|e| {
607 ReleaseError::VersionBump(format!("failed to parse {}: {e}", path.display()))
608 })?;
609
610 if doc.get("project").and_then(|p| p.get("version")).is_some() {
611 doc["project"]["version"] = toml_edit::value(new_version);
612 } else if doc
613 .get("tool")
614 .and_then(|t| t.get("poetry"))
615 .and_then(|p| p.get("version"))
616 .is_some()
617 {
618 doc["tool"]["poetry"]["version"] = toml_edit::value(new_version);
619 } else {
620 return Ok(false); }
622
623 write_file(path, &doc.to_string())?;
624 Ok(true)
625}
626
627fn bump_gradle(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
628 let contents = read_file(path)?;
629 let re = Regex::new(r#"(version\s*=\s*["'])([^"']*)(["'])"#).unwrap();
630 if !re.is_match(&contents) {
631 return Err(ReleaseError::VersionBump(format!(
632 "no version assignment found in {}",
633 path.display()
634 )));
635 }
636 let result = re.replacen(&contents, 1, format!("${{1}}{new_version}${{3}}"));
637 write_file(path, &result)
638}
639
640fn bump_pom_xml(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
641 let contents = read_file(path)?;
642
643 let search_start = if let Some(pos) = contents.find("</parent>") {
645 pos + "</parent>".len()
646 } else if let Some(pos) = contents.find("</modelVersion>") {
647 pos + "</modelVersion>".len()
648 } else {
649 0
650 };
651
652 let rest = &contents[search_start..];
653 let re = Regex::new(r"<version>[^<]*</version>").unwrap();
654 if let Some(m) = re.find(rest) {
655 let replacement = format!("<version>{new_version}</version>");
656 let mut result = String::with_capacity(contents.len());
657 result.push_str(&contents[..search_start + m.start()]);
658 result.push_str(&replacement);
659 result.push_str(&contents[search_start + m.end()..]);
660 write_file(path, &result)
661 } else {
662 Err(ReleaseError::VersionBump(format!(
663 "no <version> element found in {}",
664 path.display()
665 )))
666 }
667}
668
669fn bump_go_version(path: &Path, new_version: &str) -> Result<(), ReleaseError> {
670 let contents = read_file(path)?;
671 let re = Regex::new(r#"((?:var|const)\s+Version\s*(?:string\s*)?=\s*")([^"]*)(")"#).unwrap();
672 if !re.is_match(&contents) {
673 return Err(ReleaseError::VersionBump(format!(
674 "no Version variable found in {}",
675 path.display()
676 )));
677 }
678 let result = re.replacen(&contents, 1, format!("${{1}}{new_version}${{3}}"));
679 write_file(path, &result)
680}
681
682fn extract_toml_string_array(doc: &toml_edit::DocumentMut, keys: &[&str]) -> Vec<String> {
684 let mut item: Option<&toml_edit::Item> = None;
685 for key in keys {
686 item = match item {
687 None => doc.get(key),
688 Some(parent) => parent.get(key),
689 };
690 if item.is_none() {
691 return vec![];
692 }
693 }
694 item.and_then(|v| v.as_array())
695 .map(|arr| {
696 arr.iter()
697 .filter_map(|v| v.as_str().map(String::from))
698 .collect()
699 })
700 .unwrap_or_default()
701}
702
703fn resolve_member_globs(root_dir: &Path, patterns: &[String], manifest_name: &str) -> Vec<PathBuf> {
707 let mut paths = Vec::new();
708 for pattern in patterns {
709 let full_pattern = root_dir.join(pattern).to_string_lossy().into_owned();
710 let Ok(entries) = glob::glob(&full_pattern) else {
711 continue;
712 };
713 for entry in entries.flatten() {
714 let manifest = if entry.is_dir() {
715 entry.join(manifest_name)
716 } else {
717 continue;
718 };
719 if manifest.exists() {
720 paths.push(manifest);
721 }
722 }
723 }
724 paths
725}
726
727fn read_file(path: &Path) -> Result<String, ReleaseError> {
728 fs::read_to_string(path)
729 .map_err(|e| ReleaseError::VersionBump(format!("failed to read {}: {e}", path.display())))
730}
731
732fn write_file(path: &Path, contents: &str) -> Result<(), ReleaseError> {
733 fs::write(path, contents)
734 .map_err(|e| ReleaseError::VersionBump(format!("failed to write {}: {e}", path.display())))
735}
736
737#[cfg(test)]
738mod tests {
739 use super::*;
740
741 #[test]
742 fn bump_cargo_toml_package_version() {
743 let dir = tempfile::tempdir().unwrap();
744 let path = dir.path().join("Cargo.toml");
745 fs::write(
746 &path,
747 r#"[package]
748name = "my-crate"
749version = "0.1.0"
750edition = "2021"
751
752[dependencies]
753serde = "1"
754"#,
755 )
756 .unwrap();
757
758 bump_version_file(&path, "1.2.3").unwrap();
759
760 let contents = fs::read_to_string(&path).unwrap();
761 assert!(contents.contains("version = \"1.2.3\""));
762 assert!(contents.contains("name = \"my-crate\""));
763 assert!(contents.contains("serde = \"1\""));
764 }
765
766 #[test]
767 fn bump_cargo_toml_workspace_version() {
768 let dir = tempfile::tempdir().unwrap();
769 let path = dir.path().join("Cargo.toml");
770 fs::write(
771 &path,
772 r#"[workspace]
773members = ["crates/*"]
774
775[workspace.package]
776version = "0.0.1"
777edition = "2021"
778"#,
779 )
780 .unwrap();
781
782 bump_version_file(&path, "2.0.0").unwrap();
783
784 let contents = fs::read_to_string(&path).unwrap();
785 assert!(contents.contains("version = \"2.0.0\""));
786 assert!(contents.contains("members = [\"crates/*\"]"));
787 }
788
789 #[test]
790 fn bump_package_json_version() {
791 let dir = tempfile::tempdir().unwrap();
792 let path = dir.path().join("package.json");
793 fs::write(
794 &path,
795 r#"{
796 "name": "my-pkg",
797 "version": "0.0.0",
798 "description": "test"
799}"#,
800 )
801 .unwrap();
802
803 bump_version_file(&path, "3.1.0").unwrap();
804
805 let contents = fs::read_to_string(&path).unwrap();
806 let value: serde_json::Value = serde_json::from_str(&contents).unwrap();
807 assert_eq!(value["version"], "3.1.0");
808 assert_eq!(value["name"], "my-pkg");
809 assert_eq!(value["description"], "test");
810 assert!(contents.ends_with('\n'));
811 }
812
813 #[test]
814 fn bump_pyproject_toml_project_version() {
815 let dir = tempfile::tempdir().unwrap();
816 let path = dir.path().join("pyproject.toml");
817 fs::write(
818 &path,
819 r#"[project]
820name = "my-project"
821version = "0.1.0"
822description = "A test project"
823"#,
824 )
825 .unwrap();
826
827 bump_version_file(&path, "1.0.0").unwrap();
828
829 let contents = fs::read_to_string(&path).unwrap();
830 assert!(contents.contains("version = \"1.0.0\""));
831 assert!(contents.contains("name = \"my-project\""));
832 }
833
834 #[test]
835 fn bump_pyproject_toml_poetry_version() {
836 let dir = tempfile::tempdir().unwrap();
837 let path = dir.path().join("pyproject.toml");
838 fs::write(
839 &path,
840 r#"[tool.poetry]
841name = "my-poetry-project"
842version = "0.2.0"
843description = "A poetry project"
844"#,
845 )
846 .unwrap();
847
848 bump_version_file(&path, "0.3.0").unwrap();
849
850 let contents = fs::read_to_string(&path).unwrap();
851 assert!(contents.contains("version = \"0.3.0\""));
852 assert!(contents.contains("name = \"my-poetry-project\""));
853 }
854
855 #[test]
856 fn bump_unknown_file_returns_error() {
857 let dir = tempfile::tempdir().unwrap();
858 let path = dir.path().join("unknown.txt");
859 fs::write(&path, "version = 1").unwrap();
860
861 let err = bump_version_file(&path, "1.0.0").unwrap_err();
862 assert!(matches!(err, ReleaseError::VersionBump(_)));
863 assert!(err.to_string().contains("unsupported"));
864 }
865
866 #[test]
867 fn bump_build_gradle_version() {
868 let dir = tempfile::tempdir().unwrap();
869 let path = dir.path().join("build.gradle");
870 fs::write(
871 &path,
872 r#"plugins {
873 id 'java'
874}
875
876group = 'com.example'
877version = '1.0.0'
878
879dependencies {
880 implementation 'org.slf4j:slf4j-api:2.0.0'
881}
882"#,
883 )
884 .unwrap();
885
886 bump_version_file(&path, "2.0.0").unwrap();
887
888 let contents = fs::read_to_string(&path).unwrap();
889 assert!(contents.contains("version = '2.0.0'"));
890 assert!(contents.contains("group = 'com.example'"));
891 assert!(contents.contains("slf4j-api:2.0.0"));
893 }
894
895 #[test]
896 fn bump_build_gradle_kts_version() {
897 let dir = tempfile::tempdir().unwrap();
898 let path = dir.path().join("build.gradle.kts");
899 fs::write(
900 &path,
901 r#"plugins {
902 kotlin("jvm") version "1.9.0"
903}
904
905group = "com.example"
906version = "1.0.0"
907
908dependencies {
909 implementation("org.slf4j:slf4j-api:2.0.0")
910}
911"#,
912 )
913 .unwrap();
914
915 bump_version_file(&path, "3.0.0").unwrap();
916
917 let contents = fs::read_to_string(&path).unwrap();
918 assert!(contents.contains("version = \"3.0.0\""));
919 assert!(contents.contains("group = \"com.example\""));
920 }
921
922 #[test]
923 fn bump_pom_xml_version() {
924 let dir = tempfile::tempdir().unwrap();
925 let path = dir.path().join("pom.xml");
926 fs::write(
927 &path,
928 r#"<?xml version="1.0" encoding="UTF-8"?>
929<project>
930 <modelVersion>4.0.0</modelVersion>
931 <groupId>com.example</groupId>
932 <artifactId>my-app</artifactId>
933 <version>1.0.0</version>
934</project>
935"#,
936 )
937 .unwrap();
938
939 bump_version_file(&path, "2.0.0").unwrap();
940
941 let contents = fs::read_to_string(&path).unwrap();
942 assert!(contents.contains("<version>2.0.0</version>"));
943 assert!(contents.contains("<groupId>com.example</groupId>"));
944 }
945
946 #[test]
947 fn bump_pom_xml_with_parent_version() {
948 let dir = tempfile::tempdir().unwrap();
949 let path = dir.path().join("pom.xml");
950 fs::write(
951 &path,
952 r#"<?xml version="1.0" encoding="UTF-8"?>
953<project>
954 <modelVersion>4.0.0</modelVersion>
955 <parent>
956 <groupId>com.example</groupId>
957 <artifactId>parent</artifactId>
958 <version>5.0.0</version>
959 </parent>
960 <artifactId>my-app</artifactId>
961 <version>1.0.0</version>
962</project>
963"#,
964 )
965 .unwrap();
966
967 bump_version_file(&path, "2.0.0").unwrap();
968
969 let contents = fs::read_to_string(&path).unwrap();
970 assert!(contents.contains("<version>5.0.0</version>"));
972 assert!(contents.contains("<version>2.0.0</version>"));
974 let version_count: Vec<&str> = contents.matches("<version>").collect();
976 assert_eq!(version_count.len(), 2);
977 }
978
979 #[test]
980 fn bump_cargo_toml_workspace_dependencies_with_path() {
981 let dir = tempfile::tempdir().unwrap();
982 let path = dir.path().join("Cargo.toml");
983 fs::write(
984 &path,
985 r#"[workspace]
986members = ["crates/*"]
987
988[workspace.package]
989version = "0.1.0"
990edition = "2021"
991
992[workspace.dependencies]
993# Internal crates
994sr-core = { path = "crates/sr-core", version = "0.1.0" }
995sr-git = { path = "crates/sr-git", version = "0.1.0" }
996# External dep should not change
997serde = { version = "1", features = ["derive"] }
998"#,
999 )
1000 .unwrap();
1001
1002 bump_version_file(&path, "2.0.0").unwrap();
1003
1004 let contents = fs::read_to_string(&path).unwrap();
1005 let doc: toml_edit::DocumentMut = contents.parse().unwrap();
1006
1007 assert_eq!(
1009 doc["workspace"]["package"]["version"].as_str().unwrap(),
1010 "2.0.0"
1011 );
1012 assert_eq!(
1014 doc["workspace"]["dependencies"]["sr-core"]["version"]
1015 .as_str()
1016 .unwrap(),
1017 "2.0.0"
1018 );
1019 assert_eq!(
1020 doc["workspace"]["dependencies"]["sr-git"]["version"]
1021 .as_str()
1022 .unwrap(),
1023 "2.0.0"
1024 );
1025 assert_eq!(
1027 doc["workspace"]["dependencies"]["serde"]["version"]
1028 .as_str()
1029 .unwrap(),
1030 "1"
1031 );
1032 }
1033
1034 #[test]
1035 fn bump_go_version_var() {
1036 let dir = tempfile::tempdir().unwrap();
1037 let path = dir.path().join("version.go");
1038 fs::write(
1039 &path,
1040 r#"package main
1041
1042var Version = "1.0.0"
1043
1044func main() {}
1045"#,
1046 )
1047 .unwrap();
1048
1049 bump_version_file(&path, "2.0.0").unwrap();
1050
1051 let contents = fs::read_to_string(&path).unwrap();
1052 assert!(contents.contains(r#"var Version = "2.0.0""#));
1053 }
1054
1055 #[test]
1056 fn bump_go_version_const() {
1057 let dir = tempfile::tempdir().unwrap();
1058 let path = dir.path().join("version.go");
1059 fs::write(
1060 &path,
1061 r#"package main
1062
1063const Version string = "0.5.0"
1064
1065func main() {}
1066"#,
1067 )
1068 .unwrap();
1069
1070 bump_version_file(&path, "0.6.0").unwrap();
1071
1072 let contents = fs::read_to_string(&path).unwrap();
1073 assert!(contents.contains(r#"const Version string = "0.6.0""#));
1074 }
1075
1076 #[test]
1079 fn bump_cargo_workspace_discovers_members() {
1080 let dir = tempfile::tempdir().unwrap();
1081
1082 let root = dir.path().join("Cargo.toml");
1084 fs::write(
1085 &root,
1086 r#"[workspace]
1087members = ["crates/*"]
1088
1089[workspace.package]
1090version = "1.0.0"
1091edition = "2021"
1092
1093[workspace.dependencies]
1094my-core = { path = "crates/core", version = "1.0.0" }
1095"#,
1096 )
1097 .unwrap();
1098
1099 fs::create_dir_all(dir.path().join("crates/core")).unwrap();
1101 let member = dir.path().join("crates/core/Cargo.toml");
1102 fs::write(
1103 &member,
1104 r#"[package]
1105name = "my-core"
1106version = "1.0.0"
1107edition = "2021"
1108"#,
1109 )
1110 .unwrap();
1111
1112 fs::create_dir_all(dir.path().join("crates/cli")).unwrap();
1114 let inherited_member = dir.path().join("crates/cli/Cargo.toml");
1115 fs::write(
1116 &inherited_member,
1117 r#"[package]
1118name = "my-cli"
1119version.workspace = true
1120edition.workspace = true
1121"#,
1122 )
1123 .unwrap();
1124
1125 let extra = bump_version_file(&root, "2.0.0").unwrap();
1126
1127 let root_contents = fs::read_to_string(&root).unwrap();
1129 assert!(root_contents.contains("version = \"2.0.0\""));
1130
1131 let doc: toml_edit::DocumentMut = root_contents.parse().unwrap();
1133 assert_eq!(
1134 doc["workspace"]["dependencies"]["my-core"]["version"]
1135 .as_str()
1136 .unwrap(),
1137 "2.0.0"
1138 );
1139
1140 let member_contents = fs::read_to_string(&member).unwrap();
1142 assert!(member_contents.contains("version = \"2.0.0\""));
1143
1144 let inherited_contents = fs::read_to_string(&inherited_member).unwrap();
1146 assert!(inherited_contents.contains("version.workspace = true"));
1147
1148 assert_eq!(extra.len(), 1);
1150 assert_eq!(extra[0], member);
1151 }
1152
1153 #[test]
1154 fn bump_npm_workspace_discovers_members() {
1155 let dir = tempfile::tempdir().unwrap();
1156
1157 let root = dir.path().join("package.json");
1159 fs::write(
1160 &root,
1161 r#"{
1162 "name": "my-monorepo",
1163 "version": "1.0.0",
1164 "workspaces": ["packages/*"]
1165}"#,
1166 )
1167 .unwrap();
1168
1169 fs::create_dir_all(dir.path().join("packages/core")).unwrap();
1171 let member = dir.path().join("packages/core/package.json");
1172 fs::write(
1173 &member,
1174 r#"{
1175 "name": "@my/core",
1176 "version": "1.0.0"
1177}"#,
1178 )
1179 .unwrap();
1180
1181 fs::create_dir_all(dir.path().join("packages/utils")).unwrap();
1183 let no_version_member = dir.path().join("packages/utils/package.json");
1184 fs::write(
1185 &no_version_member,
1186 r#"{
1187 "name": "@my/utils",
1188 "private": true
1189}"#,
1190 )
1191 .unwrap();
1192
1193 let extra = bump_version_file(&root, "2.0.0").unwrap();
1194
1195 let root_contents: serde_json::Value =
1197 serde_json::from_str(&fs::read_to_string(&root).unwrap()).unwrap();
1198 assert_eq!(root_contents["version"], "2.0.0");
1199
1200 let member_contents: serde_json::Value =
1202 serde_json::from_str(&fs::read_to_string(&member).unwrap()).unwrap();
1203 assert_eq!(member_contents["version"], "2.0.0");
1204
1205 let utils_contents: serde_json::Value =
1207 serde_json::from_str(&fs::read_to_string(&no_version_member).unwrap()).unwrap();
1208 assert!(utils_contents.get("version").is_none());
1209
1210 assert_eq!(extra.len(), 1);
1211 assert_eq!(extra[0], member);
1212 }
1213
1214 #[test]
1215 fn bump_uv_workspace_discovers_members() {
1216 let dir = tempfile::tempdir().unwrap();
1217
1218 let root = dir.path().join("pyproject.toml");
1220 fs::write(
1221 &root,
1222 r#"[project]
1223name = "my-monorepo"
1224version = "1.0.0"
1225
1226[tool.uv.workspace]
1227members = ["packages/*"]
1228"#,
1229 )
1230 .unwrap();
1231
1232 fs::create_dir_all(dir.path().join("packages/core")).unwrap();
1234 let member = dir.path().join("packages/core/pyproject.toml");
1235 fs::write(
1236 &member,
1237 r#"[project]
1238name = "my-core"
1239version = "1.0.0"
1240"#,
1241 )
1242 .unwrap();
1243
1244 let extra = bump_version_file(&root, "2.0.0").unwrap();
1245
1246 let root_contents = fs::read_to_string(&root).unwrap();
1248 assert!(root_contents.contains("version = \"2.0.0\""));
1249
1250 let member_contents = fs::read_to_string(&member).unwrap();
1252 assert!(member_contents.contains("version = \"2.0.0\""));
1253
1254 assert_eq!(extra.len(), 1);
1255 assert_eq!(extra[0], member);
1256 }
1257
1258 #[test]
1259 fn bump_cargo_workspace_refreshes_lockfile() {
1260 let dir = tempfile::tempdir().unwrap();
1261
1262 let root = dir.path().join("Cargo.toml");
1264 fs::write(
1265 &root,
1266 r#"[workspace]
1267members = ["crates/*"]
1268
1269[workspace.package]
1270version = "1.0.0"
1271"#,
1272 )
1273 .unwrap();
1274
1275 fs::create_dir_all(dir.path().join("crates/my-core")).unwrap();
1277 fs::write(
1278 dir.path().join("crates/my-core/Cargo.toml"),
1279 r#"[package]
1280name = "my-core"
1281version = "1.0.0"
1282"#,
1283 )
1284 .unwrap();
1285
1286 let lock = dir.path().join("Cargo.lock");
1288 fs::write(
1289 &lock,
1290 r#"version = 3
1291
1292[[package]]
1293name = "my-core"
1294version = "1.0.0"
1295
1296[[package]]
1297name = "serde"
1298version = "1.0.0"
1299source = "registry+https://github.com/rust-lang/crates.io-index"
1300checksum = "abc123"
1301"#,
1302 )
1303 .unwrap();
1304
1305 bump_version_file(&root, "2.0.0").unwrap();
1306
1307 let lock_contents = fs::read_to_string(&lock).unwrap();
1308 let doc: toml_edit::DocumentMut = lock_contents.parse().unwrap();
1309 let packages = doc["package"].as_array_of_tables().unwrap();
1310
1311 let my_core = packages
1312 .iter()
1313 .find(|p| p.get("name").and_then(|n| n.as_str()) == Some("my-core"))
1314 .unwrap();
1315 assert_eq!(my_core["version"].as_str().unwrap(), "2.0.0");
1316
1317 let serde = packages
1319 .iter()
1320 .find(|p| p.get("name").and_then(|n| n.as_str()) == Some("serde"))
1321 .unwrap();
1322 assert_eq!(serde["version"].as_str().unwrap(), "1.0.0");
1323 assert!(serde.contains_key("source"));
1324 assert_eq!(serde["checksum"].as_str().unwrap(), "abc123");
1325 }
1326
1327 #[test]
1328 fn bump_non_workspace_returns_empty_extra() {
1329 let dir = tempfile::tempdir().unwrap();
1330 let path = dir.path().join("Cargo.toml");
1331 fs::write(
1332 &path,
1333 r#"[package]
1334name = "solo-crate"
1335version = "1.0.0"
1336"#,
1337 )
1338 .unwrap();
1339
1340 let extra = bump_version_file(&path, "2.0.0").unwrap();
1341 assert!(extra.is_empty());
1342 }
1343
1344 #[test]
1347 fn detect_cargo_toml() {
1348 let dir = tempfile::tempdir().unwrap();
1349 fs::write(
1350 dir.path().join("Cargo.toml"),
1351 "[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
1352 )
1353 .unwrap();
1354
1355 let detected = detect_version_files(dir.path());
1356 assert_eq!(detected, vec!["Cargo.toml"]);
1357 }
1358
1359 #[test]
1360 fn detect_package_json() {
1361 let dir = tempfile::tempdir().unwrap();
1362 fs::write(
1363 dir.path().join("package.json"),
1364 r#"{"name": "x", "version": "1.0.0"}"#,
1365 )
1366 .unwrap();
1367
1368 let detected = detect_version_files(dir.path());
1369 assert_eq!(detected, vec!["package.json"]);
1370 }
1371
1372 #[test]
1373 fn detect_pyproject_toml() {
1374 let dir = tempfile::tempdir().unwrap();
1375 fs::write(
1376 dir.path().join("pyproject.toml"),
1377 "[project]\nname = \"x\"\nversion = \"0.1.0\"\n",
1378 )
1379 .unwrap();
1380
1381 let detected = detect_version_files(dir.path());
1382 assert_eq!(detected, vec!["pyproject.toml"]);
1383 }
1384
1385 #[test]
1386 fn detect_multiple_ecosystems() {
1387 let dir = tempfile::tempdir().unwrap();
1388 fs::write(
1389 dir.path().join("Cargo.toml"),
1390 "[package]\nname = \"x\"\nversion = \"0.1.0\"\n",
1391 )
1392 .unwrap();
1393 fs::write(
1394 dir.path().join("package.json"),
1395 r#"{"name": "x", "version": "1.0.0"}"#,
1396 )
1397 .unwrap();
1398
1399 let detected = detect_version_files(dir.path());
1400 assert!(detected.contains(&"Cargo.toml".to_string()));
1401 assert!(detected.contains(&"package.json".to_string()));
1402 }
1403
1404 #[test]
1405 fn detect_empty_directory() {
1406 let dir = tempfile::tempdir().unwrap();
1407 let detected = detect_version_files(dir.path());
1408 assert!(detected.is_empty());
1409 }
1410
1411 #[test]
1412 fn detect_go_version_file() {
1413 let dir = tempfile::tempdir().unwrap();
1414 fs::write(
1415 dir.path().join("version.go"),
1416 "package main\n\nvar Version = \"1.0.0\"\n",
1417 )
1418 .unwrap();
1419
1420 let detected = detect_version_files(dir.path());
1421 assert_eq!(detected, vec!["version.go"]);
1422 }
1423
1424 #[test]
1425 fn is_supported_recognizes_all_types() {
1426 assert!(is_supported_version_file("Cargo.toml"));
1427 assert!(is_supported_version_file("package.json"));
1428 assert!(is_supported_version_file("pyproject.toml"));
1429 assert!(is_supported_version_file("pom.xml"));
1430 assert!(is_supported_version_file("build.gradle"));
1431 assert!(is_supported_version_file("build.gradle.kts"));
1432 assert!(is_supported_version_file("version.go"));
1433 assert!(!is_supported_version_file("unknown.txt"));
1434 }
1435}