Skip to main content

sr_core/
version_files.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use regex::Regex;
5
6use crate::error::ReleaseError;
7
8/// Trait encapsulating detection, bumping, workspace discovery, and lock file
9/// association for a single ecosystem (Cargo, npm, Python, etc.).
10pub trait VersionFileHandler: Send + Sync {
11    /// Human-readable name, e.g. "Cargo", "npm".
12    fn name(&self) -> &str;
13
14    /// Primary manifest filenames, e.g. `["Cargo.toml"]`.
15    fn manifest_names(&self) -> &[&str];
16
17    /// Associated lock file names, e.g. `["Cargo.lock"]`.
18    fn lock_file_names(&self) -> &[&str];
19
20    /// Does this ecosystem exist in `dir`? Default: any manifest file exists.
21    fn detect(&self, dir: &Path) -> bool {
22        self.manifest_names()
23            .iter()
24            .any(|name| dir.join(name).exists())
25    }
26
27    /// Bump version in the manifest at `path`. Returns additional files that
28    /// were auto-discovered and bumped (e.g. workspace members).
29    fn bump(&self, path: &Path, new_version: &str) -> Result<Vec<PathBuf>, ReleaseError>;
30}
31
32// ---------------------------------------------------------------------------
33// Handler implementations
34// ---------------------------------------------------------------------------
35
36struct 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    /// Custom detection: scan for `*.go` files containing a `Version` variable.
134    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
154// ---------------------------------------------------------------------------
155// Registry & public API
156// ---------------------------------------------------------------------------
157
158/// Return all known version-file handlers.
159pub 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
170/// Auto-detect version files in a directory. Returns relative paths (relative
171/// to `dir`) for every manifest whose ecosystem is detected.
172///
173/// For the Go handler the detected `.go` file containing the Version variable
174/// is returned (not a manifest name).
175pub 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            // Go handler: find the actual .go file with a Version var
183            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
206/// Look up the handler for a given filename.
207fn 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    // Go files: any .go extension
214    if filename.ends_with(".go") {
215        return Some(Box::new(GoHandler));
216    }
217    None
218}
219
220/// Bump the `version` field in the given manifest file.
221///
222/// Returns a list of additional files that were auto-discovered and bumped
223/// (e.g. workspace member manifests). The caller should stage these files.
224///
225/// The file format is auto-detected from the filename:
226/// - `Cargo.toml`          → TOML (`package.version` or `workspace.package.version`)
227/// - `package.json`        → JSON (`.version`)
228/// - `pyproject.toml`      → TOML (`project.version` or `tool.poetry.version`)
229/// - `build.gradle`        → Gradle Groovy DSL (`version = '...'` or `version = "..."`)
230/// - `build.gradle.kts`    → Gradle Kotlin DSL (`version = "..."`)
231/// - `pom.xml`             → Maven (`<version>...</version>`, skipping `<parent>` block)
232/// - `*.go`                → Go (`var/const Version = "..."`)
233///
234/// For workspace roots (Cargo, npm, uv), member manifests are auto-discovered
235/// and bumped without needing to list them in `version_files`.
236pub 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
250/// Given a list of bumped manifest paths, discover associated lock files that exist on disk.
251/// Searches the manifest's directory and ancestors (for monorepo roots).
252/// Returns deduplicated paths.
253pub 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        // Collect lock file names from all handlers that match this manifest
264        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        // Search the manifest's directory and ancestors
272        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            // Stop at repo root (don't traverse beyond .git)
282            if d.join(".git").exists() {
283                break;
284            }
285        }
286    }
287    seen.into_iter().collect()
288}
289
290/// Returns `true` if the given filename is a supported version file.
291pub fn is_supported_version_file(filename: &str) -> bool {
292    handler_for_file(filename).is_some()
293}
294
295/// Compile the Go Version variable regex (used in detection).
296fn go_version_re() -> Regex {
297    Regex::new(r#"(?:var|const)\s+Version\s*(?:string\s*)?=\s*""#).unwrap()
298}
299
300// ---------------------------------------------------------------------------
301// Private bump implementations (unchanged)
302// ---------------------------------------------------------------------------
303
304fn 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        // Also update [workspace.dependencies] entries that are internal path deps
322        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    // Auto-discover and bump workspace member Cargo.toml files
346    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
365/// Bump `package.version` in a workspace member Cargo.toml (skip if using `version.workspace = true`).
366/// Returns `true` if the file was actually modified.
367fn 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    // Skip members that inherit version from workspace
374    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), // No version or uses workspace inheritance — skip
382    }
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    // Extract workspace patterns before mutating
396    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    // Auto-discover and bump workspace member package.json files
418    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
436/// Bump `version` in a member package.json (skip if no version field).
437/// Returns `true` if the file was actually modified.
438fn 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    // Auto-discover uv workspace members
491    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
510/// Bump version in a uv workspace member pyproject.toml (skip if no version field).
511/// Returns `true` if the file was actually modified.
512fn 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); // No version field — skip
529    }
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    // Determine search start: skip past </parent> if present, else after </modelVersion>
552    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
590/// Extract a string array from a nested TOML path (e.g. `["workspace", "members"]`).
591fn 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
611/// Resolve workspace member glob patterns into manifest file paths.
612/// Each glob is resolved relative to `root_dir`, and `manifest_name` is appended
613/// to each matched directory (e.g. "Cargo.toml", "package.json", "pyproject.toml").
614fn 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        // dependency version must not change
800        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        // Parent version must NOT be changed
879        assert!(contents.contains("<version>5.0.0</version>"));
880        // Project version must be changed
881        assert!(contents.contains("<version>2.0.0</version>"));
882        // Verify there are exactly two <version> tags with expected values
883        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        // workspace.package.version should be bumped
916        assert_eq!(
917            doc["workspace"]["package"]["version"].as_str().unwrap(),
918            "2.0.0"
919        );
920        // Internal path deps should have their version bumped
921        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        // External dep version must NOT change
934        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    // --- workspace auto-discovery tests ---
985
986    #[test]
987    fn bump_cargo_workspace_discovers_members() {
988        let dir = tempfile::tempdir().unwrap();
989
990        // Create workspace root
991        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        // Create member with hardcoded version
1008        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        // Create member that uses workspace inheritance (should be skipped)
1021        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        // Root should be bumped
1036        let root_contents = fs::read_to_string(&root).unwrap();
1037        assert!(root_contents.contains("version = \"2.0.0\""));
1038
1039        // Workspace dep should be bumped
1040        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        // Member with hardcoded version should be bumped
1049        let member_contents = fs::read_to_string(&member).unwrap();
1050        assert!(member_contents.contains("version = \"2.0.0\""));
1051
1052        // Member with workspace inheritance should NOT be modified
1053        let inherited_contents = fs::read_to_string(&inherited_member).unwrap();
1054        assert!(inherited_contents.contains("version.workspace = true"));
1055
1056        // Only the hardcoded member should be in extra
1057        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        // Create root package.json with workspaces
1066        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        // Create member
1078        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        // Create member without version (should be skipped)
1090        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        // Root bumped
1104        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        // Member with version bumped
1109        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        // Member without version untouched
1114        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        // Create root pyproject.toml with uv workspace
1127        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        // Create member
1141        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        // Root bumped
1155        let root_contents = fs::read_to_string(&root).unwrap();
1156        assert!(root_contents.contains("version = \"2.0.0\""));
1157
1158        // Member bumped
1159        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    // --- auto-detection tests ---
1184
1185    #[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}