1use anyhow::{anyhow, Context, Result};
2use log::{debug, warn};
3use semver::Version;
4use std::fs;
5use std::path::{Path, PathBuf};
6
7pub trait Project {
8 fn get_version(&self) -> Result<Version>;
9
10 fn update_version(&self, version: &Version) -> Result<()>;
12
13 fn dry_run_update(&self, version: &Version) -> Result<String>;
15
16 fn get_file_path(&self) -> &Path;
18
19 fn get_files_to_commit(&self) -> Vec<PathBuf>;
21
22 fn get_package_manager_update_command(&self) -> Option<String> {
24 None
25 }
26}
27
28pub fn detect_project(dir: &str) -> Result<Box<dyn Project>> {
29 let dir_path = Path::new(dir);
30
31 let package_json_path = dir_path.join("package.json");
33 if package_json_path.exists() {
34 debug!("Detected Node.js project (package.json)");
35 return Ok(Box::new(NodeProject::new(package_json_path)));
36 }
37
38 let pyproject_path = dir_path.join("pyproject.toml");
40 if pyproject_path.exists() {
41 debug!("Detected Python project (pyproject.toml)");
42 return Ok(Box::new(PythonProject::new(pyproject_path)));
43 }
44
45 let cargo_path = dir_path.join("Cargo.toml");
47 if cargo_path.exists() {
48 debug!("Detected Rust project (Cargo.toml)");
49 return Ok(Box::new(RustProject::new(cargo_path)));
50 }
51
52 let go_mod_path = dir_path.join("go.mod");
54 if go_mod_path.exists() {
55 debug!("Detected Go project (go.mod)");
56 return Ok(Box::new(GoProject::new(go_mod_path)));
57 }
58
59 let gemfile_path = dir_path.join("Gemfile");
61 if gemfile_path.exists() {
62 debug!("Detected Ruby project (Gemfile)");
63 return Ok(Box::new(RubyProject::new(gemfile_path)));
64 }
65
66 Err(anyhow!("No supported project files found in {}", dir))
67}
68
69pub struct NodeProject {
71 path: PathBuf,
72}
73
74impl NodeProject {
75 pub fn new(path: PathBuf) -> Self {
76 Self { path }
77 }
78
79 fn update_version_internal(&self, version: &Version, dry_run: bool) -> Result<String> {
81 let content = fs::read_to_string(&self.path).context("Failed to read package.json")?;
83
84 let old_version = self.get_version()?;
85
86 let re = regex::Regex::new(r#"("version"\s*:\s*")([^"]*)(")"#).unwrap();
92 let new_content = re.replace(&content, |caps: ®ex::Captures| {
93 format!("{}{}{}", &caps[1], version, &caps[3])
94 });
95
96 let diff = format!(
97 "{}:\n version: {} → {}",
98 if dry_run {
99 "Would update package.json"
100 } else {
101 "Updated package.json"
102 },
103 old_version,
104 version
105 );
106
107 if !dry_run {
108 fs::write(&self.path, new_content.as_bytes())
109 .context("Failed to write updated package.json")?;
110 }
111
112 Ok(diff)
113 }
114}
115
116impl Project for NodeProject {
117 fn get_version(&self) -> Result<Version> {
118 let content = fs::read_to_string(&self.path).context("Failed to read package.json")?;
119
120 let package: serde_json::Value =
121 serde_json::from_str(&content).context("Failed to parse package.json")?;
122
123 let version_str = package["version"]
124 .as_str()
125 .ok_or_else(|| anyhow!("No version field found in package.json"))?;
126
127 Version::parse(version_str).context("Failed to parse version from package.json")
128 }
129
130 fn update_version(&self, version: &Version) -> Result<()> {
131 self.update_version_internal(version, false)?;
132 Ok(())
133 }
134
135 fn dry_run_update(&self, version: &Version) -> Result<String> {
136 self.update_version_internal(version, true)
137 }
138
139 fn get_file_path(&self) -> &Path {
140 &self.path
141 }
142
143 fn get_files_to_commit(&self) -> Vec<PathBuf> {
144 vec![self.path.clone()]
145 }
146
147 fn get_package_manager_update_command(&self) -> Option<String> {
148 let dir = self.path.parent().unwrap_or(Path::new("."));
149
150 if dir.join("bun.lockb").exists() {
152 return Some("bun install".to_string());
153 } else if dir.join("yarn.lock").exists() {
154 return Some("yarn".to_string());
155 } else if dir.join("pnpm-lock.yaml").exists() {
156 return Some("pnpm install".to_string());
157 } else if dir.join("package-lock.json").exists() {
158 return Some("npm install".to_string());
159 }
160
161 Some("npm install".to_string())
163 }
164}
165
166pub struct PythonProject {
168 path: PathBuf,
169}
170
171impl PythonProject {
172 pub fn new(path: PathBuf) -> Self {
173 Self { path }
174 }
175
176 fn find_version_locations(&self, content: &str) -> Result<Vec<(String, String)>> {
178 let toml_value: toml::Value = content.parse().context("Failed to parse pyproject.toml")?;
179
180 let mut locations = Vec::new();
181
182 if let Some(project) = toml_value.get("project").and_then(|p| p.as_table()) {
184 if let Some(version) = project.get("version").and_then(|v| v.as_str()) {
185 locations.push(("project.version".to_string(), version.to_string()));
186 }
187 }
188
189 if let Some(tool) = toml_value.get("tool").and_then(|t| t.as_table()) {
191 if let Some(poetry) = tool.get("poetry").and_then(|p| p.as_table()) {
192 if let Some(version) = poetry.get("version").and_then(|v| v.as_str()) {
193 locations.push(("tool.poetry.version".to_string(), version.to_string()));
194 }
195 }
196 }
197
198 if let Some(tool) = toml_value.get("tool").and_then(|t| t.as_table()) {
200 if let Some(setuptools) = tool.get("setuptools").and_then(|s| s.as_table()) {
201 if let Some(version) = setuptools.get("version").and_then(|v| v.as_str()) {
202 locations.push(("tool.setuptools.version".to_string(), version.to_string()));
203 }
204 }
205 }
206
207 if locations.is_empty() {
208 return Err(anyhow!("No version field found in pyproject.toml"));
209 }
210
211 Ok(locations)
212 }
213}
214
215impl PythonProject {
216 fn update_version_internal(&self, version: &Version, dry_run: bool) -> Result<String> {
217 let content = fs::read_to_string(&self.path).context("Failed to read pyproject.toml")?;
219
220 let locations = self.find_version_locations(&content)?;
222
223 let prefix = if dry_run { "Would update" } else { "Updated" };
224 let mut diff = format!("{} pyproject.toml:", prefix);
225
226 for (location, old_version) in &locations {
227 diff.push_str(&format!("\n {}: {} → {}", location, old_version, version));
228 }
229
230 if !dry_run {
231 let mut doc = match content.parse::<toml_edit::DocumentMut>() {
233 Ok(doc) => doc,
234 Err(e) => return Err(anyhow!("Failed to parse pyproject.toml: {}", e)),
235 };
236
237 let version_str = version.to_string();
239
240 if let Some(project) = doc.get_mut("project") {
242 if let Some(project_table) = project.as_table_mut() {
243 if project_table.contains_key("version") {
244 project_table["version"] = toml_edit::value(version_str.clone());
245 }
246 }
247 }
248
249 if let Some(tool) = doc.get_mut("tool") {
251 if let Some(tool_table) = tool.as_table_mut() {
252 if let Some(poetry) = tool_table.get_mut("poetry") {
253 if let Some(poetry_table) = poetry.as_table_mut() {
254 if poetry_table.contains_key("version") {
255 poetry_table["version"] = toml_edit::value(version_str.clone());
256 }
257 }
258 }
259
260 if let Some(setuptools) = tool_table.get_mut("setuptools") {
262 if let Some(setuptools_table) = setuptools.as_table_mut() {
263 if setuptools_table.contains_key("version") {
264 setuptools_table["version"] = toml_edit::value(version_str.clone());
265 }
266 }
267 }
268 }
269 }
270
271 let new_content = doc.to_string();
273 if new_content == content {
274 warn!("No version patterns matched in pyproject.toml");
275 return Err(anyhow!("Failed to update version in pyproject.toml"));
276 }
277
278 fs::write(&self.path, new_content).context("Failed to write updated pyproject.toml")?;
279 }
280
281 Ok(diff)
282 }
283}
284
285impl Project for PythonProject {
286 fn get_version(&self) -> Result<Version> {
287 let content = fs::read_to_string(&self.path).context("Failed to read pyproject.toml")?;
288
289 let locations = self.find_version_locations(&content)?;
291
292 let version_str = &locations[0].1;
294
295 Version::parse(version_str).context("Failed to parse version from pyproject.toml")
296 }
297
298 fn update_version(&self, version: &Version) -> Result<()> {
299 self.update_version_internal(version, false)?;
300 Ok(())
301 }
302
303 fn dry_run_update(&self, version: &Version) -> Result<String> {
304 self.update_version_internal(version, true)
305 }
306
307 fn get_file_path(&self) -> &Path {
308 &self.path
309 }
310
311 fn get_files_to_commit(&self) -> Vec<PathBuf> {
312 vec![self.path.clone()]
313 }
314
315 fn get_package_manager_update_command(&self) -> Option<String> {
316 let dir = self.path.parent().unwrap_or(Path::new("."));
317
318 if dir.join("poetry.lock").exists() {
320 return Some("poetry update".to_string());
321 } else if dir.join("Pipfile.lock").exists() {
322 return Some("pipenv update".to_string());
323 } else if dir.join("pdm.lock").exists() {
324 return Some("pdm update".to_string());
325 }
326
327 if dir.join("uv.lock").exists() || dir.join(".uv").exists() {
329 return Some("uv sync".to_string());
330 }
331
332 if let Ok(content) = fs::read_to_string(&self.path) {
334 if content.contains("[tool.poetry]") {
335 return Some("poetry update".to_string());
336 } else if content.contains("[tool.pdm]") {
337 return Some("pdm update".to_string());
338 } else if content.contains("[tool.hatch") {
339 return Some("hatch env update".to_string());
340 }
341 }
342
343 if dir.join("requirements.txt").exists() {
345 return Some("pip install -r requirements.txt".to_string());
346 }
347
348 None
350 }
351}
352
353pub struct RustProject {
355 path: PathBuf,
356}
357
358impl RustProject {
359 pub fn new(path: PathBuf) -> Self {
360 Self { path }
361 }
362}
363
364impl RustProject {
365 fn update_version_internal(&self, version: &Version, dry_run: bool) -> Result<String> {
366 let content = fs::read_to_string(&self.path).context("Failed to read Cargo.toml")?;
367
368 let old_version = self.get_version()?;
369
370 let prefix = if dry_run { "Would update" } else { "Updated" };
371 let diff = format!(
372 "{} Cargo.toml:\n version: {} → {}",
373 prefix, old_version, version
374 );
375
376 if !dry_run {
377 let mut doc = match content.parse::<toml_edit::DocumentMut>() {
379 Ok(doc) => doc,
380 Err(e) => return Err(anyhow!("Failed to parse Cargo.toml: {}", e)),
381 };
382
383 if let Some(package) = doc.get_mut("package") {
385 if let Some(package_table) = package.as_table_mut() {
386 if package_table.contains_key("version") {
387 package_table["version"] = toml_edit::value(version.to_string());
388 } else {
389 return Err(anyhow!(
390 "No version field found in Cargo.toml package table"
391 ));
392 }
393 }
394 } else {
395 return Err(anyhow!("No package table found in Cargo.toml"));
396 }
397
398 let new_content = doc.to_string();
400 fs::write(&self.path, new_content).context("Failed to write updated Cargo.toml")?;
401 }
402
403 Ok(diff)
404 }
405}
406
407impl Project for RustProject {
408 fn get_version(&self) -> Result<Version> {
409 let content = fs::read_to_string(&self.path).context("Failed to read Cargo.toml")?;
410
411 let toml_value: toml::Value = content.parse().context("Failed to parse Cargo.toml")?;
412
413 let version_str = toml_value
414 .get("package")
415 .and_then(|package| package.get("version"))
416 .and_then(|v| v.as_str())
417 .ok_or_else(|| anyhow!("No version field found in Cargo.toml"))?;
418
419 Version::parse(version_str).context("Failed to parse version from Cargo.toml")
420 }
421
422 fn update_version(&self, version: &Version) -> Result<()> {
423 self.update_version_internal(version, false)?;
424 Ok(())
425 }
426
427 fn dry_run_update(&self, version: &Version) -> Result<String> {
428 self.update_version_internal(version, true)
429 }
430
431 fn get_file_path(&self) -> &Path {
432 &self.path
433 }
434
435 fn get_files_to_commit(&self) -> Vec<PathBuf> {
436 vec![self.path.clone()]
437 }
438
439 fn get_package_manager_update_command(&self) -> Option<String> {
440 Some("cargo update".to_string())
442 }
443}
444
445pub struct GoProject {
447 path: PathBuf,
448}
449
450impl GoProject {
451 pub fn new(path: PathBuf) -> Self {
452 Self { path }
453 }
454}
455
456impl GoProject {
457 fn get_version_files(&self) -> Vec<PathBuf> {
458 let dir = self.path.parent().unwrap_or(Path::new("."));
459 let candidates = [
460 dir.join("version.go"),
461 dir.join("internal/version/version.go"),
462 dir.join("pkg/version/version.go"),
463 ];
464
465 candidates
466 .into_iter()
467 .filter(|path| path.exists())
468 .collect()
469 }
470
471 fn update_version_internal(&self, version: &Version, dry_run: bool) -> Result<String> {
472 let version_files = self.get_version_files();
473 let mut updated_files = Vec::new();
474 let mut diff = String::new();
475
476 let old_version = match self.get_version() {
477 Ok(v) => v,
478 Err(_) => Version::new(0, 1, 0),
479 };
480
481 diff.push_str(&format!(
482 "{} Go project version from {} to {}:\n",
483 if dry_run { "Would update" } else { "Updated" },
484 old_version,
485 version
486 ));
487
488 if version_files.is_empty() {
489 diff.push_str(" No version files found. You may need to manually create/update version information.");
490 return Ok(diff);
491 }
492
493 let version_regex = regex::Regex::new(
494 r#"((?:Version|VERSION)\s*=\s*["'])v?([0-9]+\.[0-9]+\.[0-9]+)(["'])"#,
495 )
496 .unwrap();
497
498 for file_path in &version_files {
499 let file_content =
500 fs::read_to_string(file_path).context("Failed to read version file")?;
501
502 if version_regex.is_match(&file_content) {
503 diff.push_str(&format!(" File: {}\n", file_path.display()));
504
505 let new_content = version_regex.replace(&file_content, |caps: ®ex::Captures| {
506 format!("{}v{}{}", &caps[1], version, &caps[3])
507 });
508
509 if !dry_run && new_content != file_content {
510 fs::write(file_path, new_content.as_bytes())
511 .context("Failed to write updated version file")?;
512 updated_files.push(file_path);
513 }
514 }
515 }
516
517 if !dry_run && updated_files.is_empty() {
518 warn!("No version file was updated for Go project.");
519 diff.push_str(" No version patterns were matched in any files.");
520 }
521
522 Ok(diff)
523 }
524}
525
526impl Project for GoProject {
527 fn get_version(&self) -> Result<Version> {
528 let version_files = self.get_version_files();
529
530 let version_regex =
531 regex::Regex::new(r#"(?:Version|VERSION)\s*=\s*["']v?([0-9]+\.[0-9]+\.[0-9]+)["']"#)
532 .unwrap();
533
534 for file_path in &version_files {
535 let version_content =
536 fs::read_to_string(file_path).context("Failed to read version file")?;
537
538 if let Some(captures) = version_regex.captures(&version_content) {
539 if let Some(version_match) = captures.get(1) {
540 return Version::parse(version_match.as_str())
541 .context("Failed to parse version from version.go file");
542 }
543 }
544 }
545
546 warn!("Could not find version information in Go files, you may need to manually tag this version");
548
549 Ok(Version::new(0, 1, 0))
551 }
552
553 fn update_version(&self, version: &Version) -> Result<()> {
554 self.update_version_internal(version, false)?;
555 Ok(())
556 }
557
558 fn dry_run_update(&self, version: &Version) -> Result<String> {
559 self.update_version_internal(version, true)
560 }
561
562 fn get_file_path(&self) -> &Path {
563 &self.path
564 }
565
566 fn get_files_to_commit(&self) -> Vec<PathBuf> {
567 let mut files = vec![self.path.clone()];
568 files.extend(self.get_version_files());
569 files
570 }
571
572 fn get_package_manager_update_command(&self) -> Option<String> {
573 Some("go mod tidy".to_string())
575 }
576}
577
578pub struct RubyProject {
580 path: PathBuf,
581}
582
583impl RubyProject {
584 pub fn new(path: PathBuf) -> Self {
585 Self { path }
586 }
587
588 fn find_gemspec_file(&self) -> Option<PathBuf> {
590 let dir = self.path.parent().unwrap_or(Path::new("."));
591
592 if let Ok(entries) = fs::read_dir(dir) {
593 for entry in entries.filter_map(Result::ok) {
594 let path = entry.path();
595 if path.extension().is_some_and(|ext| ext == "gemspec") {
596 return Some(path);
597 }
598 }
599 }
600 None
601 }
602
603 fn find_version_rb_file(&self) -> Option<PathBuf> {
605 let dir = self.path.parent().unwrap_or(Path::new("."));
606
607 if let Some(gemspec_path) = self.find_gemspec_file() {
609 if let Ok(content) = fs::read_to_string(gemspec_path) {
610 let name_re = regex::Regex::new(r#"[s\.]name\s*=\s*["']([^"']+)["']"#).unwrap();
612 if let Some(caps) = name_re.captures(&content) {
613 if let Some(name) = caps.get(1) {
614 let version_path = dir.join(name.as_str()).join("version.rb");
615 if version_path.exists() {
616 return Some(version_path);
617 }
618 }
619 }
620 }
621 }
622
623 if let Ok(entries) = fs::read_dir(dir) {
625 for entry in entries.filter_map(Result::ok) {
626 let path = entry.path();
627 if path.is_dir() {
628 let version_path = path.join("version.rb");
629 if version_path.exists() {
630 return Some(version_path);
631 }
632 }
633 }
634 }
635
636 let direct_version_path = dir.join("lib/version.rb");
638 if direct_version_path.exists() {
639 return Some(direct_version_path);
640 }
641
642 None
643 }
644
645 fn update_version_internal(&self, version: &Version, dry_run: bool) -> Result<String> {
646 let gemspec_path = self.find_gemspec_file();
648 let version_rb_path = self.find_version_rb_file();
649
650 let old_version = self.get_version()?;
651 let mut diff = String::new();
652 let mut updated_any = false;
653
654 let prefix = if dry_run { "Would update" } else { "Updated" };
655 diff.push_str(&format!(
656 "{} Ruby project version from {} to {}:\n",
657 prefix, old_version, version
658 ));
659
660 if let Some(ref path) = gemspec_path {
662 let content = fs::read_to_string(path).context("Failed to read gemspec file")?;
663
664 let version_re = regex::Regex::new(r#"(['\"])(\d+\.\d+\.\d+)(['\"]\s*)"#).unwrap();
665
666 if version_re.is_match(&content) {
667 diff.push_str(&format!(" File: {}\n", path.display()));
668
669 let new_content = version_re.replace(&content, |caps: ®ex::Captures| {
670 format!("{}{}{}", &caps[1], version, &caps[3])
671 });
672
673 if !dry_run && new_content != content {
674 fs::write(path, new_content.as_bytes())
675 .context("Failed to write updated gemspec file")?;
676 updated_any = true;
677 }
678 }
679 }
680
681 if let Some(ref path) = version_rb_path {
683 let content = fs::read_to_string(path).context("Failed to read version.rb file")?;
684
685 let version_re = regex::Regex::new(r#"VERSION\s*=\s*['"]([\d\.]+)['"](.*)"#).unwrap();
686
687 if version_re.is_match(&content) {
688 diff.push_str(&format!(" File: {}\n", path.display()));
689
690 let new_content = version_re.replace(&content, |caps: ®ex::Captures| {
691 format!(
692 "VERSION = '{}'{}",
693 version,
694 if let Some(m) = caps.get(2) {
695 m.as_str()
696 } else {
697 ""
698 }
699 )
700 });
701
702 if !dry_run && new_content != content {
703 fs::write(path, new_content.as_bytes())
704 .context("Failed to write updated version.rb file")?;
705 updated_any = true;
706 }
707 }
708 }
709
710 if !updated_any && !dry_run {
711 warn!("Could not update Ruby project version. No version patterns were matched.");
712 diff.push_str(" No version patterns were matched in any files.");
713 }
714
715 Ok(diff)
716 }
717}
718
719impl Project for RubyProject {
720 fn get_version(&self) -> Result<Version> {
721 if let Some(gemspec_path) = self.find_gemspec_file() {
723 let content =
724 fs::read_to_string(gemspec_path).context("Failed to read gemspec file")?;
725
726 let version_re =
727 regex::Regex::new(r#"(?:version|s\.version)\s*=\s*['"]([^'"]+)['"]\s*"#).unwrap();
728
729 if let Some(caps) = version_re.captures(&content) {
730 if let Some(version_match) = caps.get(1) {
731 return Version::parse(version_match.as_str())
732 .context("Failed to parse version from gemspec");
733 }
734 }
735 }
736
737 if let Some(version_rb_path) = self.find_version_rb_file() {
739 let content =
740 fs::read_to_string(version_rb_path).context("Failed to read version.rb file")?;
741
742 let version_re = regex::Regex::new(r#"VERSION\s*=\s*['"]([^'"]+)['"]\s*"#).unwrap();
743
744 if let Some(caps) = version_re.captures(&content) {
745 if let Some(version_match) = caps.get(1) {
746 return Version::parse(version_match.as_str())
747 .context("Failed to parse version from version.rb");
748 }
749 }
750 }
751
752 warn!("Could not find version information in Ruby project files");
753 Ok(Version::new(0, 1, 0))
755 }
756
757 fn update_version(&self, version: &Version) -> Result<()> {
758 self.update_version_internal(version, false)?;
759 Ok(())
760 }
761
762 fn dry_run_update(&self, version: &Version) -> Result<String> {
763 self.update_version_internal(version, true)
764 }
765
766 fn get_file_path(&self) -> &Path {
767 &self.path
768 }
769
770 fn get_files_to_commit(&self) -> Vec<PathBuf> {
771 let mut files = vec![self.path.clone()];
772
773 if let Some(gemspec_path) = self.find_gemspec_file() {
774 files.push(gemspec_path);
775 }
776
777 if let Some(version_rb_path) = self.find_version_rb_file() {
778 files.push(version_rb_path);
779 }
780
781 files
782 }
783
784 fn get_package_manager_update_command(&self) -> Option<String> {
785 let dir = self.path.parent().unwrap_or(Path::new("."));
786
787 if dir.join("Gemfile.lock").exists() {
789 return Some("bundle install".to_string());
790 }
791
792 Some("bundle install".to_string())
794 }
795}