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