1use std::fs;
15
16use anyhow::{Context, Result};
17use regex::Regex;
18
19#[derive(Debug, Clone, PartialEq, Eq)]
25pub struct RewriteOutcome {
26 pub path: String,
28 pub replacements: usize,
30}
31
32fn 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
44fn 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
66pub 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 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
117pub 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
129pub 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 #[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}