use std::fs;
use std::path::Path;
use anyhow::{Context, Result, bail};
use regex::Regex;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RewriteOutcome {
pub path: String,
pub anchor: Option<String>,
pub replacements: usize,
pub matched_regions: Option<usize>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileRewrite {
pub path: String,
pub anchor: Option<String>,
pub old: String,
pub new: String,
pub owner: String,
}
pub fn anchor_regex(path: &str, anchor: &str, version: &str) -> Result<Regex> {
if !anchor.contains("{version}") {
bail!(
"version_files anchor for {path} must contain the {{version}} placeholder (got {anchor:?})"
);
}
let pattern = anchor.replace("{version}", ®ex::escape(version));
Regex::new(&pattern)
.map_err(|e| anyhow::anyhow!("version_files anchor for {path} is not a valid regex: {e}"))
}
fn occurrence_regex(version: &str) -> Result<Regex> {
let escaped = regex::escape(version);
Regex::new(&format!(r"\bv?{escaped}\b"))
.with_context(|| format!("failed to build version matcher for {version:?}"))
}
struct Edit {
start: usize,
end: usize,
text: String,
}
fn occurrences_in<'a>(
text: &'a str,
region: (usize, usize),
occurrence: &'a Regex,
) -> impl Iterator<Item = (usize, usize, &'a str)> + 'a {
let (start, end) = region;
let mut pos = start;
std::iter::from_fn(move || {
let m = occurrence.find_at(text, pos)?;
if m.end() > end || m.end() == pos {
return None;
}
pos = m.end();
Some((m.start(), m.end(), m.as_str()))
})
}
fn claim_occurrences(
text: &str,
region: (usize, usize),
occurrence: &Regex,
new: &str,
claimed: &mut Vec<(usize, usize)>,
edits: &mut Vec<Edit>,
) -> usize {
let mut count = 0;
for (start, end, matched) in occurrences_in(text, region, occurrence) {
if claimed.iter().any(|(cs, ce)| start < *ce && *cs < end) {
continue;
}
claimed.push((start, end));
edits.push(Edit {
start,
end,
text: if matched.starts_with('v') {
format!("v{new}")
} else {
new.to_string()
},
});
count += 1;
}
count
}
fn apply_edits(original: &str, mut edits: Vec<Edit>) -> String {
edits.sort_by_key(|e| e.start);
let mut out = String::with_capacity(original.len());
let mut cursor = 0usize;
for edit in edits {
out.push_str(&original[cursor..edit.start]);
out.push_str(&edit.text);
cursor = edit.end;
}
out.push_str(&original[cursor..]);
out
}
pub fn rewrite_version_in_files(
root: &Path,
rewrites: &[FileRewrite],
dry_run: bool,
) -> Result<Vec<RewriteOutcome>> {
let mut groups: Vec<(String, Vec<usize>)> = Vec::new();
for (idx, rewrite) in rewrites.iter().enumerate() {
match groups.iter_mut().find(|(path, _)| path == &rewrite.path) {
Some((_, indices)) => indices.push(idx),
None => groups.push((rewrite.path.clone(), vec![idx])),
}
}
let mut counts: Vec<(usize, Option<usize>)> = vec![(0, None); rewrites.len()];
let mut pending: Vec<(String, String)> = Vec::new();
for (path, mut indices) in groups {
let original = fs::read_to_string(root.join(&path))
.with_context(|| format!("failed to read version file {path}"))?;
indices.sort_by_key(|idx| {
(
rewrites[*idx].anchor.is_none(),
std::cmp::Reverse(rewrites[*idx].old.len()),
)
});
let mut claimed: Vec<(usize, usize)> = Vec::new();
let mut edits: Vec<Edit> = Vec::new();
for idx in indices {
let rewrite = &rewrites[idx];
let regions = match rewrite.anchor.as_deref() {
None => None,
Some(anchor) => {
let re = anchor_regex(&rewrite.path, anchor, &rewrite.old)?;
let regions: Vec<(usize, usize)> = re
.find_iter(&original)
.map(|m| (m.start(), m.end()))
.collect();
if regions.is_empty() {
bail!(
"version_files: crate '{}' enrolled {} with match {:?} but it matched \
nothing (expected version {}); fix the anchor or remove the enrollment",
rewrite.owner,
rewrite.path,
anchor,
rewrite.old,
);
}
Some(regions)
}
};
if rewrite.old == rewrite.new {
counts[idx] = (0, regions.as_ref().map(Vec::len));
continue;
}
let occurrence = occurrence_regex(&rewrite.old)?;
match regions {
None => {
let replacements = claim_occurrences(
&original,
(0, original.len()),
&occurrence,
&rewrite.new,
&mut claimed,
&mut edits,
);
counts[idx] = (replacements, None);
}
Some(regions) => {
let mut replacements = 0;
for (start, end) in ®ions {
replacements += claim_occurrences(
&original,
(*start, *end),
&occurrence,
&rewrite.new,
&mut claimed,
&mut edits,
);
}
counts[idx] = (replacements, Some(regions.len()));
}
}
}
if !edits.is_empty() {
pending.push((path, apply_edits(&original, edits)));
}
}
if !dry_run {
for (path, body) in pending {
crate::fs_atomic::atomic_write_str(&root.join(&path), &body)
.with_context(|| format!("failed to write version file {path}"))?;
}
}
Ok(rewrites
.iter()
.zip(counts)
.map(
|(rewrite, (replacements, matched_regions))| RewriteOutcome {
path: rewrite.path.clone(),
anchor: rewrite.anchor.clone(),
replacements,
matched_regions,
},
)
.collect())
}
pub fn contains_version(content: &str, version: &str) -> Result<bool> {
Ok(occurrence_regex(version)?.is_match(content))
}
pub fn check_version_present(
root: &Path,
entries: &[(String, Option<String>)],
version: &str,
) -> Result<Vec<(String, bool)>> {
let occurrence = occurrence_regex(version)?;
let mut results = Vec::with_capacity(entries.len());
for (path, anchor) in entries {
let content = fs::read_to_string(root.join(path))
.with_context(|| format!("failed to read version file {path}"))?;
let present = match anchor {
Some(anchor) => anchor_regex(path, anchor, version)?
.find_iter(&content)
.any(|m| {
occurrences_in(&content, (m.start(), m.end()), &occurrence)
.next()
.is_some()
}),
None => occurrence.is_match(&content),
};
results.push((path.clone(), present));
}
Ok(results)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_helpers::test_sources::{production_half, rust_sources};
use std::fs;
use tempfile::TempDir;
#[test]
fn rust_sources_skips_a_gated_tests_directory() {
let dir = TempDir::new().unwrap();
fs::write(
dir.path().join("mod.rs"),
"#[cfg(test)]\nmod tests;\npub fn build() {}\n",
)
.unwrap();
fs::write(dir.path().join("engine.rs"), "pub fn run() {}\n").unwrap();
fs::create_dir(dir.path().join("tests")).unwrap();
fs::write(dir.path().join("tests/mod.rs"), "fn fixture() {}\n").unwrap();
let mut found = rust_sources(dir.path());
found.sort();
assert_eq!(
found,
vec![dir.path().join("engine.rs"), dir.path().join("mod.rs")],
"a gated `tests/` module directory is not production source"
);
}
#[test]
fn every_version_matcher_is_built_by_one_of_the_named_builders() {
let repo_root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()
.and_then(|p| p.parent())
.expect("repo root above crates/core");
let population = [
"crates/core/src/version_files.rs",
"crates/cli/src/commands/tag",
"crates/cli/src/commands/check",
"crates/cli/src/commands/version_files_resolve.rs",
"crates/stage-build/src/version_sync.rs",
];
let mut sources: Vec<std::path::PathBuf> = Vec::new();
for entry in population {
let path = repo_root.join(entry);
if path.is_dir() {
sources.extend(rust_sources(&path));
} else {
assert!(
path.is_file(),
"population entry missing: {}",
path.display()
);
sources.push(path);
}
}
let mut owners: Vec<(String, String)> = Vec::new();
for source in &sources {
let text = fs::read_to_string(source).expect("read source");
let lines: Vec<&str> = production_half(&text).lines().collect();
for (i, line) in lines.iter().enumerate() {
if !line.contains("Regex::new") && !line.contains("RegexBuilder") {
continue;
}
let start = i.saturating_sub(4);
let window = lines[start..(i + 3).min(lines.len())].join(" ");
let builds_from_a_version = window.contains(r"\d+\.\d+")
|| window.contains("{version}")
|| window.contains("version")
|| window.contains("escaped");
if !builds_from_a_version {
continue;
}
let owner = lines[..=i]
.iter()
.rev()
.find_map(|l| item_name(l))
.unwrap_or_else(|| panic!("no owning item for {}:{}", source.display(), i + 1));
owners.push((
crate::path_util::display_under_root(repo_root, source),
owner,
));
}
}
owners.sort();
let expected = vec![
(
"crates/cli/src/commands/tag/rollback/tags.rs".to_string(),
"LOCKSTEP_TAG_RE".to_string(),
),
(
"crates/cli/src/commands/tag/rollback/tags.rs".to_string(),
"PER_CRATE_TAG_RE".to_string(),
),
(
"crates/core/src/version_files.rs".to_string(),
"anchor_regex".to_string(),
),
(
"crates/core/src/version_files.rs".to_string(),
"occurrence_regex".to_string(),
),
];
assert_eq!(
owners, expected,
"a version matcher outside `occurrence_regex` / `anchor_regex` (or the two \
tag-grammar validators) means `tag` and `check` can disagree again"
);
}
fn item_name(line: &str) -> Option<String> {
let trimmed = line.trim_start();
for keyword in ["fn ", "static "] {
if let Some(rest) = trimmed
.strip_prefix(keyword)
.or_else(|| trimmed.split_once(&format!(" {keyword}")).map(|(_, r)| r))
{
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if !name.is_empty() {
return Some(name);
}
}
}
None
}
fn write(dir: &TempDir, name: &str, body: &str) -> String {
fs::write(dir.path().join(name), body).unwrap();
name.to_string()
}
fn read(dir: &TempDir, name: &str) -> String {
fs::read_to_string(dir.path().join(name)).unwrap()
}
fn bare(path: &str, old: &str, new: &str) -> FileRewrite {
FileRewrite {
path: path.to_string(),
anchor: None,
old: old.to_string(),
new: new.to_string(),
owner: "app".to_string(),
}
}
fn anchored(path: &str, anchor: &str, old: &str, new: &str) -> FileRewrite {
FileRewrite {
anchor: Some(anchor.to_string()),
..bare(path, old, new)
}
}
#[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(dir.path(), &[bare(&f, "0.1.0", "0.2.0")], false).unwrap();
assert_eq!(out[0].replacements, 2);
let body = read(&dir, &f);
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(dir.path(), &[bare(&f, "0.1.0", "0.2.0")], false).unwrap();
assert_eq!(out[0].replacements, 1);
assert_eq!(read(&dir, &f), "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(dir.path(), &[bare(&f, "0.1.0", "0.2.0")], false).unwrap();
assert_eq!(out[0].replacements, 0);
assert_eq!(read(&dir, &f), "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(dir.path(), &[bare(&f, "0.1.0", "0.2.0")], true).unwrap();
assert_eq!(out[0].replacements, 1);
assert_eq!(read(&dir, &f), "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(dir.path(), &[bare(&f, "0.1.0", "0.1.0")], false).unwrap();
assert_eq!(out[0].replacements, 0);
assert_eq!(read(&dir, &f), "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(dir.path(), &[bare(&f, "0.1.0-beta", "0.2.0-beta")], false)
.unwrap();
assert_eq!(out[0].replacements, 1);
assert_eq!(read(&dir, &f), "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(dir.path(), &[bare(&f, "0.1.0", "0.2.0")], false).unwrap();
assert_eq!(out[0].replacements, 1);
assert_eq!(read(&dir, &f), "pinned at 0.2.0-rc1 today\n");
}
#[test]
fn missing_file_is_an_error() {
let dir = TempDir::new().unwrap();
let missing = "nope.yaml".to_string();
let err = rewrite_version_in_files(dir.path(), &[bare(&missing, "0.1.0", "0.2.0")], false)
.unwrap_err();
let err = err.to_string();
assert!(
err.contains("failed to read version file nope.yaml"),
"err: {err}"
);
assert!(
!err.contains(&dir.path().to_string_lossy().into_owned()),
"err leaked the resolved absolute path: {err}"
);
}
#[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(dir.path(), &[(a.clone(), None), (b.clone(), None)], "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(
dir.path(),
&[bare(&a, "0.1.0", "0.2.0"), bare(&b, "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);
}
#[test]
fn anchor_regex_escapes_version_and_keeps_quantifiers() {
let re = anchor_regex("values.yaml", r"pin-\d{2}: v{version}", "0.7.0").unwrap();
assert!(re.is_match("pin-42: v0.7.0"));
assert!(!re.is_match("pin-42: v0X7.0"));
assert!(!re.is_match("pin-4: v0.7.0"));
}
#[test]
fn anchor_regex_requires_version_placeholder() {
let err = anchor_regex("values.yaml", r"image:.*", "0.7.0")
.unwrap_err()
.to_string();
assert!(err.contains("values.yaml"), "err: {err}");
assert!(err.contains("{version} placeholder"), "err: {err}");
assert!(err.contains("image:.*"), "err: {err}");
}
#[test]
fn anchor_regex_rejects_an_invalid_regex() {
let err = anchor_regex("values.yaml", r"image:[.*v{version}", "0.7.0")
.unwrap_err()
.to_string();
assert!(err.contains("is not a valid regex"), "err: {err}");
}
#[test]
fn anchored_rewrite_touches_only_its_region() {
let dir = TempDir::new().unwrap();
let f = write(
&dir,
"values.yaml",
"operator:\n image: ghcr.io/x/operator:v0.7.0\ncsi:\n image: ghcr.io/x/csi:v0.7.0\n",
);
let out = rewrite_version_in_files(
dir.path(),
&[anchored(
&f,
r"operator:\s+image:.*:v{version}",
"0.7.0",
"0.8.0",
)],
false,
)
.unwrap();
assert_eq!(out[0].replacements, 1);
assert_eq!(out[0].matched_regions, Some(1));
assert_eq!(
read(&dir, &f),
"operator:\n image: ghcr.io/x/operator:v0.8.0\ncsi:\n image: ghcr.io/x/csi:v0.7.0\n"
);
}
#[test]
fn anchored_rewrite_applies_to_every_match() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "doc.md", "pin: v0.7.0\nother: 0.7.0\npin: v0.7.0\n");
let out = rewrite_version_in_files(
dir.path(),
&[anchored(&f, r"pin: v{version}", "0.7.0", "0.8.0")],
false,
)
.unwrap();
assert_eq!(out[0].replacements, 2);
assert_eq!(out[0].matched_regions, Some(2));
assert_eq!(read(&dir, &f), "pin: v0.8.0\nother: 0.7.0\npin: v0.8.0\n");
}
#[test]
fn anchored_zero_match_errors_before_writing() {
let dir = TempDir::new().unwrap();
let good = write(&dir, "good.md", "pin: v0.7.0\n");
let bad = write(&dir, "bad.md", "nothing to see\n");
let err = rewrite_version_in_files(
dir.path(),
&[
bare(&good, "0.7.0", "0.8.0"),
anchored(&bad, r"pin: v{version}", "0.7.0", "0.8.0"),
],
false,
)
.unwrap_err()
.to_string();
assert!(err.contains("crate 'app'"), "err: {err}");
assert!(err.contains("bad.md"), "err: {err}");
assert!(err.contains("matched nothing"), "err: {err}");
assert!(
!err.contains(&dir.path().to_string_lossy().into_owned()),
"err leaked the resolved absolute path: {err}"
);
assert_eq!(read(&dir, &good), "pin: v0.7.0\n");
}
#[test]
fn bare_entry_zero_match_still_warns_not_errors() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "doc.md", "nothing to see\n");
let out =
rewrite_version_in_files(dir.path(), &[bare(&f, "0.7.0", "0.8.0")], false).unwrap();
assert_eq!(out[0].replacements, 0);
assert_eq!(out[0].matched_regions, None);
}
#[test]
fn bare_and_anchored_on_one_file_each_rewrite_once() {
let dir = TempDir::new().unwrap();
let path = write(&dir, "chart.yaml", "pin: v1.2.3\nother: 1.2.3\n");
let outcomes = rewrite_version_in_files(
dir.path(),
&[
bare(&path, "1.2.3", "1.2.4"),
anchored(&path, r"pin: v{version}", "1.2.3", "1.2.4"),
],
false,
)
.unwrap();
assert_eq!(read(&dir, &path), "pin: v1.2.4\nother: 1.2.4\n");
assert_eq!(outcomes[0].replacements, 1, "bare: {outcomes:?}");
assert_eq!(outcomes[1].replacements, 1, "anchored: {outcomes:?}");
assert_eq!(outcomes[1].matched_regions, Some(1));
}
#[test]
fn bare_listed_first_still_leaves_the_anchor_its_region() {
let dir = TempDir::new().unwrap();
let path = write(&dir, "chart.yaml", "pin: v0.9.0\nother: 0.9.0\n");
let outcomes = rewrite_version_in_files(
dir.path(),
&[
bare(&path, "0.9.0", "0.10.0"),
anchored(&path, r"pin: v{version}", "0.9.0", "0.10.0"),
],
false,
)
.unwrap();
assert_eq!(read(&dir, &path), "pin: v0.10.0\nother: 0.10.0\n");
assert_eq!(outcomes[1].replacements, 1, "anchor starved: {outcomes:?}");
}
#[test]
fn longest_old_applied_first_within_a_file() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "doc.md", "a 0.1.0-rc1 b 0.1.0\n");
let out = rewrite_version_in_files(
dir.path(),
&[bare(&f, "0.1.0", "0.5.0"), bare(&f, "0.1.0-rc1", "0.9.9")],
false,
)
.unwrap();
assert_eq!(read(&dir, &f), "a 0.9.9 b 0.5.0\n");
assert_eq!(out[0].replacements, 1);
assert_eq!(out[1].replacements, 1);
}
#[test]
fn an_anchored_region_keeps_the_word_boundary_of_the_whole_file() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "versions.txt", "tool 10.7.0\n");
let out = rewrite_version_in_files(
dir.path(),
&[anchored(&f, r"{version}", "0.7.0", "0.8.0")],
false,
)
.unwrap();
assert_eq!(out[0].replacements, 0);
assert_eq!(out[0].matched_regions, Some(1));
assert_eq!(read(&dir, &f), "tool 10.7.0\n");
}
#[test]
fn an_anchored_presence_check_keeps_the_word_boundary_of_the_whole_file() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "versions.txt", "tool 10.7.0\n");
let res = check_version_present(
dir.path(),
&[(f.clone(), Some(r"{version}".to_string()))],
"0.7.0",
)
.unwrap();
assert_eq!(res, vec![(f, false)]);
}
#[test]
fn a_noop_bump_still_catches_a_stale_anchor() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "values.yaml", "pin: v0.7.0\n");
let err = rewrite_version_in_files(
dir.path(),
&[anchored(&f, r"gone: v{version}", "0.7.0", "0.7.0")],
false,
)
.unwrap_err();
assert!(
err.to_string().contains("matched nothing"),
"unexpected error: {err}"
);
}
#[test]
fn a_noop_bump_reports_the_regions_its_anchor_selected() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "values.yaml", "pin: v0.7.0\nalt: v0.7.0\n");
let out = rewrite_version_in_files(
dir.path(),
&[anchored(&f, r"v{version}", "0.7.0", "0.7.0")],
false,
)
.unwrap();
assert_eq!(out[0].replacements, 0);
assert_eq!(
out[0].matched_regions,
Some(2),
"`matched_regions: None` documents a BARE entry, so an anchored \
no-op must not report it"
);
}
#[test]
fn check_version_present_scopes_to_anchor() {
let dir = TempDir::new().unwrap();
let f = write(&dir, "values.yaml", "pin: v0.7.0\nloose: 0.9.0\n");
let res = check_version_present(
dir.path(),
&[(f.clone(), Some(r"pin: v{version}".to_string()))],
"0.7.0",
)
.unwrap();
assert_eq!(res, vec![(f.clone(), true)]);
let res = check_version_present(
dir.path(),
&[(f.clone(), Some(r"pin: v{version}".to_string()))],
"0.9.0",
)
.unwrap();
assert_eq!(res, vec![(f, false)]);
}
}