Skip to main content

anodizer_core/
version_files.rs

1//! Tag-time version-string rewriting for repo-committed files that embed the
2//! release version outside `Cargo.toml` (Helm `Chart.yaml`, install docs,
3//! README badges, ...).
4//!
5//! The `tag` command bumps `Cargo.toml` / `Cargo.lock` and creates a bump
6//! commit; files enrolled via the `version_files` config are rewritten in
7//! that same commit so their embedded version never drifts from the tag.
8//!
9//! Rewrites are word-boundary anchored so `0.1.0` does not match inside
10//! `10.1.0`, and cover both the bare (`0.1.0`) and `v`-prefixed (`v0.1.0`)
11//! spellings a file may carry. This module is pure: it reads and writes files
12//! and returns data; it never spawns a subprocess or writes to stdout/stderr.
13
14use std::fs;
15
16use anyhow::{Context, Result};
17use regex::Regex;
18
19/// Outcome of rewriting one enrolled file.
20///
21/// `replacements == 0` means the version string was not found in the file —
22/// not an error here; the caller decides whether to warn (an enrolled file
23/// that does not contain the old version is usually a stale enrollment).
24#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct RewriteOutcome {
26    /// The enrolled file path, exactly as supplied by the caller.
27    pub path: String,
28    /// Number of occurrences rewritten (bare and `v`-prefixed forms combined).
29    pub replacements: usize,
30}
31
32/// Build the word-boundary-anchored matcher for `version`, covering both the
33/// bare form and the `v`-prefixed form. The version is `regex::escape`d so the
34/// `.` separators match literally rather than as the any-character class.
35fn version_regexes(version: &str) -> Result<(Regex, Regex)> {
36    let escaped = regex::escape(version);
37    let bare = Regex::new(&format!(r"\b{escaped}\b"))
38        .with_context(|| format!("failed to build version matcher for {version:?}"))?;
39    let prefixed = Regex::new(&format!(r"\bv{escaped}\b"))
40        .with_context(|| format!("failed to build v-prefixed version matcher for {version:?}"))?;
41    Ok((bare, prefixed))
42}
43
44/// Replace word-boundary occurrences of `old` with `new` in `content`,
45/// covering both the bare and `v`-prefixed forms, and return the rewritten
46/// content plus the number of replacements made.
47///
48/// The `v`-prefixed form is handled first so a `v`-prefixed occurrence is
49/// rewritten to the `v`-prefixed new version in one pass; the `\b` anchor on
50/// the bare matcher then sits between the `v` and the digit, so the bare pass
51/// cannot re-touch an already-rewritten `v`-prefixed occurrence.
52fn rewrite_content(content: &str, old: &str, new: &str) -> Result<(String, usize)> {
53    let (bare_re, prefixed_re) = version_regexes(old)?;
54
55    let prefixed_hits = prefixed_re.find_iter(content).count();
56    let prefixed_replaced = prefixed_re
57        .replace_all(content, format!("v{new}").as_str())
58        .into_owned();
59
60    let bare_hits = bare_re.find_iter(&prefixed_replaced).count();
61    let bare_replaced = bare_re.replace_all(&prefixed_replaced, new).into_owned();
62
63    Ok((bare_replaced, prefixed_hits + bare_hits))
64}
65
66/// Rewrite word-boundary occurrences of `old` with `new` in each file,
67/// covering both the bare and `v`-prefixed forms. Returns one
68/// [`RewriteOutcome`] per enrolled file in input order
69/// (`replacements == 0` means the version was not found — the caller decides
70/// how to warn). When `dry_run` is set, replacement counts are computed but no
71/// file is written.
72///
73/// When `old == new` this is a no-op: every file reports `replacements == 0`
74/// and nothing is written.
75///
76/// Errors if an enrolled file is missing or unreadable, or (outside
77/// `dry_run`) cannot be written.
78pub fn rewrite_version_in_files(
79    files: &[String],
80    old: &str,
81    new: &str,
82    dry_run: bool,
83) -> Result<Vec<RewriteOutcome>> {
84    let mut outcomes = Vec::with_capacity(files.len());
85
86    if old == new {
87        for path in files {
88            // Read to surface a missing/unreadable enrolled file as an error
89            // even when the bump is a no-op, so a stale enrollment is caught.
90            fs::read_to_string(path)
91                .with_context(|| format!("failed to read version file {path}"))?;
92            outcomes.push(RewriteOutcome {
93                path: path.clone(),
94                replacements: 0,
95            });
96        }
97        return Ok(outcomes);
98    }
99
100    for path in files {
101        let content = fs::read_to_string(path)
102            .with_context(|| format!("failed to read version file {path}"))?;
103        let (rewritten, replacements) = rewrite_content(&content, old, new)?;
104        if !dry_run && replacements > 0 {
105            crate::fs_atomic::atomic_write_str(std::path::Path::new(path), &rewritten)
106                .with_context(|| format!("failed to write version file {path}"))?;
107        }
108        outcomes.push(RewriteOutcome {
109            path: path.clone(),
110            replacements,
111        });
112    }
113
114    Ok(outcomes)
115}
116
117/// Whether `content` contains `version` (bare or `v`-prefixed), word-boundary
118/// anchored — the same matcher [`check_version_present`] and
119/// [`rewrite_version_in_files`] apply, exposed so the enrollment-discovery flow
120/// (`anodizer init --version-files`) can probe a candidate file's text with
121/// one shared regex rather than a second copy of the boundary logic.
122///
123/// Errors only if `version` cannot be compiled into a matcher.
124pub fn contains_version(content: &str, version: &str) -> Result<bool> {
125    let (bare_re, prefixed_re) = version_regexes(version)?;
126    Ok(bare_re.is_match(content) || prefixed_re.is_match(content))
127}
128
129/// Read-only check: for each file, whether it currently contains `version`
130/// (bare or `v`-prefixed), word-boundary anchored. Returns one `(path,
131/// present)` pair per file in input order.
132///
133/// Errors if an enrolled file is missing or unreadable.
134pub fn check_version_present(files: &[String], version: &str) -> Result<Vec<(String, bool)>> {
135    let (bare_re, prefixed_re) = version_regexes(version)?;
136    let mut results = Vec::with_capacity(files.len());
137    for path in files {
138        let content = fs::read_to_string(path)
139            .with_context(|| format!("failed to read version file {path}"))?;
140        let present = bare_re.is_match(&content) || prefixed_re.is_match(&content);
141        results.push((path.clone(), present));
142    }
143    Ok(results)
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149    use std::fs;
150    use tempfile::TempDir;
151
152    fn write(dir: &TempDir, name: &str, body: &str) -> String {
153        let path = dir.path().join(name);
154        fs::write(&path, body).unwrap();
155        path.to_string_lossy().into_owned()
156    }
157
158    #[test]
159    fn rewrites_bare_and_v_prefixed() {
160        let dir = TempDir::new().unwrap();
161        let f = write(&dir, "Chart.yaml", "version: 0.1.0\nappVersion: v0.1.0\n");
162        let out =
163            rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0", "0.2.0", false).unwrap();
164        assert_eq!(out[0].replacements, 2);
165        let body = fs::read_to_string(&f).unwrap();
166        assert_eq!(body, "version: 0.2.0\nappVersion: v0.2.0\n");
167    }
168
169    #[test]
170    fn word_boundary_does_not_match_inside_longer_version() {
171        let dir = TempDir::new().unwrap();
172        let f = write(&dir, "doc.md", "use 10.1.0 not 0.1.0\n");
173        let out =
174            rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0", "0.2.0", false).unwrap();
175        assert_eq!(out[0].replacements, 1);
176        assert_eq!(fs::read_to_string(&f).unwrap(), "use 10.1.0 not 0.2.0\n");
177    }
178
179    #[test]
180    fn zero_matches_is_not_an_error() {
181        let dir = TempDir::new().unwrap();
182        let f = write(&dir, "doc.md", "no version here\n");
183        let out =
184            rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0", "0.2.0", false).unwrap();
185        assert_eq!(out[0].replacements, 0);
186        assert_eq!(fs::read_to_string(&f).unwrap(), "no version here\n");
187    }
188
189    #[test]
190    fn dry_run_computes_count_without_writing() {
191        let dir = TempDir::new().unwrap();
192        let f = write(&dir, "doc.md", "v0.1.0\n");
193        let out =
194            rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0", "0.2.0", true).unwrap();
195        assert_eq!(out[0].replacements, 1);
196        assert_eq!(fs::read_to_string(&f).unwrap(), "v0.1.0\n");
197    }
198
199    #[test]
200    fn equal_old_new_is_noop() {
201        let dir = TempDir::new().unwrap();
202        let f = write(&dir, "doc.md", "0.1.0\n");
203        let out =
204            rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0", "0.1.0", false).unwrap();
205        assert_eq!(out[0].replacements, 0);
206        assert_eq!(fs::read_to_string(&f).unwrap(), "0.1.0\n");
207    }
208
209    #[test]
210    fn prerelease_version_with_hyphen_rewrites() {
211        let dir = TempDir::new().unwrap();
212        let f = write(&dir, "doc.md", "tag v0.1.0-beta here\n");
213        let out =
214            rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0-beta", "0.2.0-beta", false)
215                .unwrap();
216        assert_eq!(out[0].replacements, 1);
217        assert_eq!(fs::read_to_string(&f).unwrap(), "tag v0.2.0-beta here\n");
218    }
219
220    /// Pin the raw engine behavior at the prerelease boundary: a hyphen is a
221    /// non-word char, so `\b` sits between the trailing digit and the `-`, and
222    /// the bare `old = "0.1.0"` matcher DOES fire inside `0.1.0-rc1`, rewriting
223    /// the release core and leaving the `-rc1` suffix intact (→ `0.2.0-rc1`).
224    ///
225    /// This is an engine-level edge that production never reaches: at tag time
226    /// `old` is reconstructed by `bare_version_from_tag`, which carries the full
227    /// `0.1.0-rc1` prerelease string, so the bare `0.1.0` form is never the
228    /// `old` applied to a prerelease line. The test exists so that boundary
229    /// behavior can't regress silently if the regex anchoring ever changes.
230    #[test]
231    fn bare_old_matches_release_core_of_a_prerelease_line() {
232        let dir = TempDir::new().unwrap();
233        let f = write(&dir, "doc.md", "pinned at 0.1.0-rc1 today\n");
234        let out =
235            rewrite_version_in_files(std::slice::from_ref(&f), "0.1.0", "0.2.0", false).unwrap();
236        assert_eq!(out[0].replacements, 1);
237        assert_eq!(
238            fs::read_to_string(&f).unwrap(),
239            "pinned at 0.2.0-rc1 today\n"
240        );
241    }
242
243    #[test]
244    fn missing_file_is_an_error() {
245        let dir = TempDir::new().unwrap();
246        let missing = dir.path().join("nope.yaml").to_string_lossy().into_owned();
247        let err = rewrite_version_in_files(&[missing], "0.1.0", "0.2.0", false).unwrap_err();
248        assert!(err.to_string().contains("failed to read version file"));
249    }
250
251    #[test]
252    fn contains_version_matches_bare_and_v_prefixed() {
253        assert!(contains_version("appVersion: 0.1.0\n", "0.1.0").unwrap());
254        assert!(contains_version("tag v0.1.0 here\n", "0.1.0").unwrap());
255    }
256
257    #[test]
258    fn contains_version_respects_word_boundary() {
259        assert!(!contains_version("pinned 10.1.0\n", "0.1.0").unwrap());
260        assert!(!contains_version("no version here\n", "0.1.0").unwrap());
261    }
262
263    #[test]
264    fn check_version_present_reports_per_file() {
265        let dir = TempDir::new().unwrap();
266        let a = write(&dir, "has.md", "v0.1.0\n");
267        let b = write(&dir, "hasnot.md", "10.1.0\n");
268        let res = check_version_present(&[a.clone(), b.clone()], "0.1.0").unwrap();
269        assert_eq!(res, vec![(a, true), (b, false)]);
270    }
271
272    #[test]
273    fn multiple_files_reported_in_input_order() {
274        let dir = TempDir::new().unwrap();
275        let a = write(&dir, "a.md", "0.1.0\n0.1.0\n");
276        let b = write(&dir, "b.md", "nothing\n");
277        let out =
278            rewrite_version_in_files(&[a.clone(), b.clone()], "0.1.0", "0.2.0", false).unwrap();
279        assert_eq!(out[0].path, a);
280        assert_eq!(out[0].replacements, 2);
281        assert_eq!(out[1].path, b);
282        assert_eq!(out[1].replacements, 0);
283    }
284}