use std::fs;
use anyhow::{Context, Result};
use regex::Regex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RewriteOutcome {
pub path: String,
pub replacements: usize,
}
fn version_regexes(version: &str) -> Result<(Regex, Regex)> {
let escaped = regex::escape(version);
let bare = Regex::new(&format!(r"\b{escaped}\b"))
.with_context(|| format!("failed to build version matcher for {version:?}"))?;
let prefixed = Regex::new(&format!(r"\bv{escaped}\b"))
.with_context(|| format!("failed to build v-prefixed version matcher for {version:?}"))?;
Ok((bare, prefixed))
}
fn rewrite_content(content: &str, old: &str, new: &str) -> Result<(String, usize)> {
let (bare_re, prefixed_re) = version_regexes(old)?;
let prefixed_hits = prefixed_re.find_iter(content).count();
let prefixed_replaced = prefixed_re
.replace_all(content, format!("v{new}").as_str())
.into_owned();
let bare_hits = bare_re.find_iter(&prefixed_replaced).count();
let bare_replaced = bare_re.replace_all(&prefixed_replaced, new).into_owned();
Ok((bare_replaced, prefixed_hits + bare_hits))
}
pub fn rewrite_version_in_files(
files: &[String],
old: &str,
new: &str,
dry_run: bool,
) -> Result<Vec<RewriteOutcome>> {
let mut outcomes = Vec::with_capacity(files.len());
if old == new {
for path in files {
fs::read_to_string(path)
.with_context(|| format!("failed to read version file {path}"))?;
outcomes.push(RewriteOutcome {
path: path.clone(),
replacements: 0,
});
}
return Ok(outcomes);
}
for path in files {
let content = fs::read_to_string(path)
.with_context(|| format!("failed to read version file {path}"))?;
let (rewritten, replacements) = rewrite_content(&content, old, new)?;
if !dry_run && replacements > 0 {
crate::fs_atomic::atomic_write_str(std::path::Path::new(path), &rewritten)
.with_context(|| format!("failed to write version file {path}"))?;
}
outcomes.push(RewriteOutcome {
path: path.clone(),
replacements,
});
}
Ok(outcomes)
}
pub fn contains_version(content: &str, version: &str) -> Result<bool> {
let (bare_re, prefixed_re) = version_regexes(version)?;
Ok(bare_re.is_match(content) || prefixed_re.is_match(content))
}
pub fn check_version_present(files: &[String], version: &str) -> Result<Vec<(String, bool)>> {
let (bare_re, prefixed_re) = version_regexes(version)?;
let mut results = Vec::with_capacity(files.len());
for path in files {
let content = fs::read_to_string(path)
.with_context(|| format!("failed to read version file {path}"))?;
let present = bare_re.is_match(&content) || prefixed_re.is_match(&content);
results.push((path.clone(), present));
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
fn write(dir: &TempDir, name: &str, body: &str) -> String {
let path = dir.path().join(name);
fs::write(&path, body).unwrap();
path.to_string_lossy().into_owned()
}
#[test]
fn rewrites_bare_and_v_prefixed() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "Chart.yaml", "version: 0.1.0\nappVersion: v0.1.0\n");
let out =
rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0", "0.2.0", false).unwrap();
assert_eq!(out[0].replacements, 2);
let body = fs::read_to_string(&f).unwrap();
assert_eq!(body, "version: 0.2.0\nappVersion: v0.2.0\n");
}
#[test]
fn word_boundary_does_not_match_inside_longer_version() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "doc.md", "use 10.1.0 not 0.1.0\n");
let out =
rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0", "0.2.0", false).unwrap();
assert_eq!(out[0].replacements, 1);
assert_eq!(fs::read_to_string(&f).unwrap(), "use 10.1.0 not 0.2.0\n");
}
#[test]
fn zero_matches_is_not_an_error() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "doc.md", "no version here\n");
let out =
rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0", "0.2.0", false).unwrap();
assert_eq!(out[0].replacements, 0);
assert_eq!(fs::read_to_string(&f).unwrap(), "no version here\n");
}
#[test]
fn dry_run_computes_count_without_writing() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "doc.md", "v0.1.0\n");
let out =
rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0", "0.2.0", true).unwrap();
assert_eq!(out[0].replacements, 1);
assert_eq!(fs::read_to_string(&f).unwrap(), "v0.1.0\n");
}
#[test]
fn equal_old_new_is_noop() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "doc.md", "0.1.0\n");
let out =
rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0", "0.1.0", false).unwrap();
assert_eq!(out[0].replacements, 0);
assert_eq!(fs::read_to_string(&f).unwrap(), "0.1.0\n");
}
#[test]
fn prerelease_version_with_hyphen_rewrites() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "doc.md", "tag v0.1.0-beta here\n");
let out =
rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0-beta", "0.2.0-beta", false)
.unwrap();
assert_eq!(out[0].replacements, 1);
assert_eq!(fs::read_to_string(&f).unwrap(), "tag v0.2.0-beta here\n");
}
#[test]
fn bare_old_matches_release_core_of_a_prerelease_line() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "doc.md", "pinned at 0.1.0-rc1 today\n");
let out =
rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0", "0.2.0", false).unwrap();
assert_eq!(out[0].replacements, 1);
assert_eq!(
fs::read_to_string(&f).unwrap(),
"pinned at 0.2.0-rc1 today\n"
);
}
#[test]
fn missing_file_is_an_error() {
let dir = TempDir::new().unwrap();
let missing = dir.path().join("nope.yaml").to_string_lossy().into_owned();
let err = rewrite_version_in_files(&[missing], "0.1.0", "0.2.0", false).unwrap_err();
assert!(err.to_string().contains("failed to read version file"));
}
#[test]
fn contains_version_matches_bare_and_v_prefixed() {
assert!(contains_version("appVersion: 0.1.0\n", "0.1.0").unwrap());
assert!(contains_version("tag v0.1.0 here\n", "0.1.0").unwrap());
}
#[test]
fn contains_version_respects_word_boundary() {
assert!(!contains_version("pinned 10.1.0\n", "0.1.0").unwrap());
assert!(!contains_version("no version here\n", "0.1.0").unwrap());
}
#[test]
fn check_version_present_reports_per_file() {
let dir = TempDir::new().unwrap();
let a = write(&dir, "has.md", "v0.1.0\n");
let b = write(&dir, "hasnot.md", "10.1.0\n");
let res = check_version_present(&[a.clone(), b.clone()], "0.1.0").unwrap();
assert_eq!(res, vec![(a, true), (b, false)]);
}
#[test]
fn multiple_files_reported_in_input_order() {
let dir = TempDir::new().unwrap();
let a = write(&dir, "a.md", "0.1.0\n0.1.0\n");
let b = write(&dir, "b.md", "nothing\n");
let out =
rewrite_version_in_files(&[a.clone(), b.clone()], "0.1.0", "0.2.0", false).unwrap();
assert_eq!(out[0].path, a);
assert_eq!(out[0].replacements, 2);
assert_eq!(out[1].path, b);
assert_eq!(out[1].replacements, 0);
}
}