project_version/
project.rs

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    /// Update the version in the project file
11    fn update_version(&self, version: &Version) -> Result<()>;
12
13    /// Preview what would be updated without making changes
14    fn dry_run_update(&self, version: &Version) -> Result<String>;
15
16    /// Get the path to the main project file
17    fn get_file_path(&self) -> &Path;
18
19    /// Get all files that should be committed
20    fn get_files_to_commit(&self) -> Vec<PathBuf>;
21
22    /// Get the package manager update command for this project
23    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    // Check for package.json (Node.js)
32    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    // Check for pyproject.toml (Python)
39    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    // Check for Cargo.toml (Rust)
46    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    // Check for Go module (go.mod)
53    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    // Check for Gemfile (Ruby)
60    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
69// Node.js project (package.json)
70pub struct NodeProject {
71    path: PathBuf,
72}
73
74impl NodeProject {
75    pub fn new(path: PathBuf) -> Self {
76        Self { path }
77    }
78
79    // Internal function that does the actual work, can be dry-run or real update
80    fn update_version_internal(&self, version: &Version, dry_run: bool) -> Result<String> {
81        // Read the original content
82        let content = fs::read_to_string(&self.path).context("Failed to read package.json")?;
83
84        let old_version = self.get_version()?;
85
86        // Using regex for targeted replacement that preserves all formatting
87        // We considered JSON parsing libraries but:
88        // - serde_json (even with preserve_order) loses comments and exact formatting
89        // - json-patch (RFC 6902) doesn't preserve whitespace or formatting
90        // - No suitable Rust crate exists that preserves comments and exact formatting
91        let re = regex::Regex::new(r#"("version"\s*:\s*")([^"]*)(")"#).unwrap();
92        let new_content = re.replace(&content, |caps: &regex::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        // Check for different lock files to detect the package manager
151        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        // Default to npm if no lock file is found
162        Some("npm install".to_string())
163    }
164}
165
166// Python project (pyproject.toml)
167pub struct PythonProject {
168    path: PathBuf,
169}
170
171impl PythonProject {
172    pub fn new(path: PathBuf) -> Self {
173        Self { path }
174    }
175
176    // Helper to find the version location in pyproject.toml
177    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        // Check project.version (PEP 621)
183        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        // Check tool.poetry.version (Poetry)
190        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        // Check other common locations
199        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        // Read the file content
218        let content = fs::read_to_string(&self.path).context("Failed to read pyproject.toml")?;
219
220        // Find all version locations for reporting
221        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            // Parse the TOML with toml_edit to preserve formatting, spacing, and comments
232            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            // Update each known version location
238            let version_str = version.to_string();
239
240            // Check project.version (PEP 621)
241            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            // Check tool.poetry.version
250            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                    // Check tool.setuptools.version
261                    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            // Write the updated TOML
272            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        // Find all version locations
290        let locations = self.find_version_locations(&content)?;
291
292        // Use the first one
293        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        // Check for different lock files and configurations to detect the package manager
319        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        // Check for the presence of uv files or directories
328        if dir.join("uv.lock").exists() || dir.join(".uv").exists() {
329            return Some("uv sync".to_string());
330        }
331
332        // Read the content of the pyproject.toml to detect Python package tool
333        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        // Default to pip
344        if dir.join("requirements.txt").exists() {
345            return Some("pip install -r requirements.txt".to_string());
346        }
347
348        // Return None if we can't confidently determine the package manager
349        None
350    }
351}
352
353// Rust project (Cargo.toml)
354pub 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            // Parse the TOML with toml_edit to preserve formatting, spacing, and comments
378            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            // Update the package.version
384            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            // Write the updated TOML
399            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        // Cargo is the only package manager for Rust
441        Some("cargo update".to_string())
442    }
443}
444
445// Go project (go.mod)
446pub 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: &regex::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        // Fallback: use git tags
547        warn!("Could not find version information in Go files, you may need to manually tag this version");
548
549        // Default to 0.1.0 if we can't determine it
550        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        // Go modules has a specific update command
574        Some("go mod tidy".to_string())
575    }
576}
577
578// Ruby project (Gemfile)
579pub struct RubyProject {
580    path: PathBuf,
581}
582
583impl RubyProject {
584    pub fn new(path: PathBuf) -> Self {
585        Self { path }
586    }
587
588    // Get the version from gemspec file
589    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    // Try to extract version from version.rb file
604    fn find_version_rb_file(&self) -> Option<PathBuf> {
605        let dir = self.path.parent().unwrap_or(Path::new("."));
606
607        // First approach: try to find the project name from gemspec
608        if let Some(gemspec_path) = self.find_gemspec_file() {
609            if let Ok(content) = fs::read_to_string(gemspec_path) {
610                // Try to extract gem name from gemspec
611                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        // Second approach: look for any version.rb file
624        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        // Third approach: direct version.rb in lib
637        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        // First check for gemspec file which usually contains the version
647        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        // Try to update gemspec
661        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: &regex::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        // Try to update version.rb
682        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: &regex::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        // Try to find version in gemspec
722        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        // Try to find version in version.rb
738        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        // Default to 0.1.0 if we can't determine it
754        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        // Check for Gemfile.lock to confirm Bundler is used
788        if dir.join("Gemfile.lock").exists() {
789            return Some("bundle install".to_string());
790        }
791
792        // Default to bundle install if Gemfile exists
793        Some("bundle install".to_string())
794    }
795}