Skip to main content

standard_version/
regex_engine.rs

1//! Regex-based version file engine for user-defined `[[version_files]]`.
2//!
3//! Implements version detection, reading, and writing for arbitrary files
4//! matched by path and a regex pattern whose first capture group contains
5//! the version string.
6
7use std::path::{Path, PathBuf};
8
9use regex::Regex;
10
11use crate::version_file::{CustomVersionFile, VersionFileError};
12
13/// A version file engine driven by a user-supplied regex.
14///
15/// The regex must contain at least one capture group. The first capture
16/// group is treated as the version string for both reading and writing.
17#[derive(Debug)]
18pub struct RegexVersionFile {
19    /// Path to the file, relative to the repository root.
20    path: PathBuf,
21    /// Compiled regex pattern.
22    pattern: Regex,
23}
24
25impl RegexVersionFile {
26    /// Create a new engine from a [`CustomVersionFile`] config entry.
27    ///
28    /// # Errors
29    ///
30    /// Returns [`VersionFileError::InvalidRegex`] if the pattern fails to
31    /// compile or contains no capture groups.
32    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    /// Human-readable name (the file path as a string).
49    pub fn name(&self) -> String {
50        self.path.display().to_string()
51    }
52
53    /// The file path relative to the repository root.
54    pub fn path(&self) -> &Path {
55        &self.path
56    }
57
58    /// Check if `content` contains a match for the regex pattern.
59    pub fn detect(&self, content: &str) -> bool {
60        self.pattern.is_match(content)
61    }
62
63    /// Extract the version string from the first capture group.
64    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    /// Return updated content with the first capture group replaced by
72    /// `new_version`, preserving all surrounding text.
73    ///
74    /// # Errors
75    ///
76    /// Returns [`VersionFileError::NoVersionField`] if the regex does not
77    /// match `content`.
78    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// ---------------------------------------------------------------------------
100// Tests
101// ---------------------------------------------------------------------------
102
103#[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    // --- constructor ---
116
117    #[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    // --- detect ---
146
147    #[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    // --- read_version ---
162
163    #[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    // --- write_version ---
179
180    #[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    // --- XML-like pattern ---
198
199    #[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        // Note: this matches modelVersion first; use a more specific pattern
216        // in practice. Let's test with the specific artifact version pattern.
217        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    // --- CMake-like pattern ---
232
233    #[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    // --- integration via update_version_files ---
249
250    #[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        // Find the custom file result (there may also be built-in results).
261        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        // No pom.xml on disk.
275        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}