standard_version/
regex_engine.rs1use std::path::{Path, PathBuf};
8
9use regex::Regex;
10
11use crate::version_file::{CustomVersionFile, VersionFileError};
12
13#[derive(Debug)]
18pub struct RegexVersionFile {
19 path: PathBuf,
21 pattern: Regex,
23}
24
25impl RegexVersionFile {
26 pub fn new(custom: &CustomVersionFile) -> Result<Self, VersionFileError> {
33 let pattern = Regex::new(&custom.pattern)
34 .map_err(|e| VersionFileError::InvalidRegex(format!("invalid regex: {e}")))?;
35
36 if pattern.captures_len() < 2 {
37 return Err(VersionFileError::InvalidRegex(
38 "regex must contain at least one capture group".to_string(),
39 ));
40 }
41
42 Ok(Self {
43 path: custom.path.clone(),
44 pattern,
45 })
46 }
47
48 pub fn name(&self) -> String {
50 self.path.display().to_string()
51 }
52
53 pub fn path(&self) -> &Path {
55 &self.path
56 }
57
58 pub fn detect(&self, content: &str) -> bool {
60 self.pattern.is_match(content)
61 }
62
63 pub fn read_version(&self, content: &str) -> Option<String> {
65 self.pattern
66 .captures(content)
67 .and_then(|caps| caps.get(1))
68 .map(|m| m.as_str().to_string())
69 }
70
71 pub fn write_version(
79 &self,
80 content: &str,
81 new_version: &str,
82 ) -> Result<String, VersionFileError> {
83 let caps = self
84 .pattern
85 .captures(content)
86 .ok_or(VersionFileError::NoVersionField)?;
87
88 let group = caps.get(1).ok_or(VersionFileError::NoVersionField)?;
89
90 let mut result = String::with_capacity(content.len());
91 result.push_str(&content[..group.start()]);
92 result.push_str(new_version);
93 result.push_str(&content[group.end()..]);
94
95 Ok(result)
96 }
97}
98
99#[cfg(test)]
104mod tests {
105 use super::*;
106 use std::fs;
107
108 fn custom(path: &str, pattern: &str) -> CustomVersionFile {
109 CustomVersionFile {
110 path: PathBuf::from(path),
111 pattern: pattern.to_string(),
112 }
113 }
114
115 #[test]
118 fn new_with_valid_regex() {
119 let engine = RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>"));
120 assert!(engine.is_ok());
121 }
122
123 #[test]
124 fn new_with_no_capture_group_errors() {
125 let engine = RegexVersionFile::new(&custom("file.txt", r"version = \d+\.\d+\.\d+"));
126 assert!(engine.is_err());
127 let err = engine.unwrap_err().to_string();
128 assert!(
129 err.contains("capture group"),
130 "expected capture group error, got: {err}"
131 );
132 }
133
134 #[test]
135 fn new_with_malformed_regex_errors() {
136 let engine = RegexVersionFile::new(&custom("file.txt", r"(unclosed"));
137 assert!(engine.is_err());
138 let err = engine.unwrap_err().to_string();
139 assert!(
140 err.contains("invalid regex"),
141 "expected invalid regex error, got: {err}"
142 );
143 }
144
145 #[test]
148 fn detect_positive() {
149 let engine =
150 RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>")).unwrap();
151 assert!(engine.detect("<version>1.2.3</version>"));
152 }
153
154 #[test]
155 fn detect_negative() {
156 let engine =
157 RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>")).unwrap();
158 assert!(!engine.detect("<name>my-project</name>"));
159 }
160
161 #[test]
164 fn read_version_extracts_first_capture_group() {
165 let engine =
166 RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>")).unwrap();
167 let version = engine.read_version("<version>1.2.3</version>");
168 assert_eq!(version, Some("1.2.3".to_string()));
169 }
170
171 #[test]
172 fn read_version_returns_none_on_no_match() {
173 let engine =
174 RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>")).unwrap();
175 assert_eq!(engine.read_version("<name>foo</name>"), None);
176 }
177
178 #[test]
181 fn write_version_replaces_preserving_context() {
182 let engine =
183 RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>")).unwrap();
184 let content = "<project>\n <version>1.2.3</version>\n</project>";
185 let updated = engine.write_version(content, "2.0.0").unwrap();
186 assert_eq!(updated, "<project>\n <version>2.0.0</version>\n</project>");
187 }
188
189 #[test]
190 fn write_version_error_on_no_match() {
191 let engine =
192 RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>")).unwrap();
193 let result = engine.write_version("<name>foo</name>", "1.0.0");
194 assert!(result.is_err());
195 }
196
197 #[test]
200 fn xml_version_roundtrip() {
201 let xml = r#"<?xml version="1.0"?>
202<project>
203 <modelVersion>4.0.0</modelVersion>
204 <groupId>com.example</groupId>
205 <artifactId>my-app</artifactId>
206 <version>1.0.0-SNAPSHOT</version>
207</project>"#;
208
209 let engine = RegexVersionFile::new(&custom(
210 "pom.xml",
211 r"<version>([^<]+)</version>(?s:.)*</project>",
212 ))
213 .unwrap();
214 assert!(engine.detect(xml));
215 let engine2 = RegexVersionFile::new(&custom(
218 "pom.xml",
219 r"<artifactId>my-app</artifactId>\s*<version>([^<]+)</version>",
220 ))
221 .unwrap();
222 assert_eq!(
223 engine2.read_version(xml),
224 Some("1.0.0-SNAPSHOT".to_string())
225 );
226 let updated = engine2.write_version(xml, "2.0.0").unwrap();
227 assert!(updated.contains("<version>2.0.0</version>"));
228 assert!(updated.contains("<modelVersion>4.0.0</modelVersion>"));
229 }
230
231 #[test]
234 fn cmake_version_roundtrip() {
235 let cmake = "cmake_minimum_required(VERSION 3.14)\nproject(myapp VERSION 1.2.3)\n";
236 let engine = RegexVersionFile::new(&custom(
237 "CMakeLists.txt",
238 r"project\(myapp VERSION ([^\)]+)\)",
239 ))
240 .unwrap();
241 assert!(engine.detect(cmake));
242 assert_eq!(engine.read_version(cmake), Some("1.2.3".to_string()));
243 let updated = engine.write_version(cmake, "3.0.0").unwrap();
244 assert!(updated.contains("project(myapp VERSION 3.0.0)"));
245 assert!(updated.contains("cmake_minimum_required(VERSION 3.14)"));
246 }
247
248 #[test]
251 fn update_version_files_processes_custom_file() {
252 let dir = tempfile::tempdir().unwrap();
253 let pom = dir.path().join("pom.xml");
254 fs::write(&pom, "<project>\n <version>1.0.0</version>\n</project>\n").unwrap();
255
256 let custom_files = vec![custom("pom.xml", r"<version>([^<]+)</version>")];
257 let results =
258 crate::version_file::update_version_files(dir.path(), "2.0.0", &custom_files).unwrap();
259
260 let pom_result = results.iter().find(|r| r.name == "pom.xml");
262 assert!(pom_result.is_some(), "expected pom.xml in results");
263 let r = pom_result.unwrap();
264 assert_eq!(r.old_version, "1.0.0");
265 assert_eq!(r.new_version, "2.0.0");
266
267 let on_disk = fs::read_to_string(&pom).unwrap();
268 assert!(on_disk.contains("<version>2.0.0</version>"));
269 }
270
271 #[test]
272 fn update_version_files_skips_missing_custom_file() {
273 let dir = tempfile::tempdir().unwrap();
274 let custom_files = vec![custom("pom.xml", r"<version>([^<]+)</version>")];
276 let results =
277 crate::version_file::update_version_files(dir.path(), "2.0.0", &custom_files).unwrap();
278 assert!(
279 results.iter().all(|r| r.name != "pom.xml"),
280 "missing file should be skipped"
281 );
282 }
283
284 #[test]
285 fn update_version_files_skips_non_matching_regex() {
286 let dir = tempfile::tempdir().unwrap();
287 let txt = dir.path().join("version.txt");
288 fs::write(&txt, "no version here\n").unwrap();
289
290 let custom_files = vec![custom("version.txt", r"version = ([^\n]+)")];
291 let results =
292 crate::version_file::update_version_files(dir.path(), "2.0.0", &custom_files).unwrap();
293 assert!(
294 results.iter().all(|r| r.name != "version.txt"),
295 "non-matching regex should be skipped"
296 );
297 }
298}