use std::path::{Path, PathBuf};
use regex::Regex;
use crate::version_file::{CustomVersionFile, VersionFileError};
#[derive(Debug)]
pub struct RegexVersionFile {
path: PathBuf,
pattern: Regex,
}
impl RegexVersionFile {
pub fn new(custom: &CustomVersionFile) -> Result<Self, VersionFileError> {
let pattern = Regex::new(&custom.pattern)
.map_err(|e| VersionFileError::InvalidRegex(format!("invalid regex: {e}")))?;
if pattern.captures_len() < 2 {
return Err(VersionFileError::InvalidRegex(
"regex must contain at least one capture group".to_string(),
));
}
Ok(Self {
path: custom.path.clone(),
pattern,
})
}
pub fn name(&self) -> String {
self.path.display().to_string()
}
pub fn path(&self) -> &Path {
&self.path
}
pub fn detect(&self, content: &str) -> bool {
self.pattern.is_match(content)
}
pub fn read_version(&self, content: &str) -> Option<String> {
self.pattern
.captures(content)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
}
pub fn write_version(
&self,
content: &str,
new_version: &str,
) -> Result<String, VersionFileError> {
let caps = self
.pattern
.captures(content)
.ok_or(VersionFileError::NoVersionField)?;
let group = caps.get(1).ok_or(VersionFileError::NoVersionField)?;
let mut result = String::with_capacity(content.len());
result.push_str(&content[..group.start()]);
result.push_str(new_version);
result.push_str(&content[group.end()..]);
Ok(result)
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn custom(path: &str, pattern: &str) -> CustomVersionFile {
CustomVersionFile {
path: PathBuf::from(path),
pattern: pattern.to_string(),
}
}
#[test]
fn new_with_valid_regex() {
let engine = RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>"));
assert!(engine.is_ok());
}
#[test]
fn new_with_no_capture_group_errors() {
let engine = RegexVersionFile::new(&custom("file.txt", r"version = \d+\.\d+\.\d+"));
assert!(engine.is_err());
let err = engine.unwrap_err().to_string();
assert!(
err.contains("capture group"),
"expected capture group error, got: {err}"
);
}
#[test]
fn new_with_malformed_regex_errors() {
let engine = RegexVersionFile::new(&custom("file.txt", r"(unclosed"));
assert!(engine.is_err());
let err = engine.unwrap_err().to_string();
assert!(
err.contains("invalid regex"),
"expected invalid regex error, got: {err}"
);
}
#[test]
fn detect_positive() {
let engine =
RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>")).unwrap();
assert!(engine.detect("<version>1.2.3</version>"));
}
#[test]
fn detect_negative() {
let engine =
RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>")).unwrap();
assert!(!engine.detect("<name>my-project</name>"));
}
#[test]
fn read_version_extracts_first_capture_group() {
let engine =
RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>")).unwrap();
let version = engine.read_version("<version>1.2.3</version>");
assert_eq!(version, Some("1.2.3".to_string()));
}
#[test]
fn read_version_returns_none_on_no_match() {
let engine =
RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>")).unwrap();
assert_eq!(engine.read_version("<name>foo</name>"), None);
}
#[test]
fn write_version_replaces_preserving_context() {
let engine =
RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>")).unwrap();
let content = "<project>\n <version>1.2.3</version>\n</project>";
let updated = engine.write_version(content, "2.0.0").unwrap();
assert_eq!(updated, "<project>\n <version>2.0.0</version>\n</project>");
}
#[test]
fn write_version_error_on_no_match() {
let engine =
RegexVersionFile::new(&custom("pom.xml", r"<version>(.*?)</version>")).unwrap();
let result = engine.write_version("<name>foo</name>", "1.0.0");
assert!(result.is_err());
}
#[test]
fn xml_version_roundtrip() {
let xml = r#"<?xml version="1.0"?>
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>com.example</groupId>
<artifactId>my-app</artifactId>
<version>1.0.0-SNAPSHOT</version>
</project>"#;
let engine = RegexVersionFile::new(&custom(
"pom.xml",
r"<version>([^<]+)</version>(?s:.)*</project>",
))
.unwrap();
assert!(engine.detect(xml));
let engine2 = RegexVersionFile::new(&custom(
"pom.xml",
r"<artifactId>my-app</artifactId>\s*<version>([^<]+)</version>",
))
.unwrap();
assert_eq!(
engine2.read_version(xml),
Some("1.0.0-SNAPSHOT".to_string())
);
let updated = engine2.write_version(xml, "2.0.0").unwrap();
assert!(updated.contains("<version>2.0.0</version>"));
assert!(updated.contains("<modelVersion>4.0.0</modelVersion>"));
}
#[test]
fn cmake_version_roundtrip() {
let cmake = "cmake_minimum_required(VERSION 3.14)\nproject(myapp VERSION 1.2.3)\n";
let engine = RegexVersionFile::new(&custom(
"CMakeLists.txt",
r"project\(myapp VERSION ([^\)]+)\)",
))
.unwrap();
assert!(engine.detect(cmake));
assert_eq!(engine.read_version(cmake), Some("1.2.3".to_string()));
let updated = engine.write_version(cmake, "3.0.0").unwrap();
assert!(updated.contains("project(myapp VERSION 3.0.0)"));
assert!(updated.contains("cmake_minimum_required(VERSION 3.14)"));
}
#[test]
fn update_version_files_processes_custom_file() {
let dir = tempfile::tempdir().unwrap();
let pom = dir.path().join("pom.xml");
fs::write(&pom, "<project>\n <version>1.0.0</version>\n</project>\n").unwrap();
let custom_files = vec![custom("pom.xml", r"<version>([^<]+)</version>")];
let results =
crate::version_file::update_version_files(dir.path(), "2.0.0", &custom_files).unwrap();
let pom_result = results.iter().find(|r| r.name == "pom.xml");
assert!(pom_result.is_some(), "expected pom.xml in results");
let r = pom_result.unwrap();
assert_eq!(r.old_version, "1.0.0");
assert_eq!(r.new_version, "2.0.0");
let on_disk = fs::read_to_string(&pom).unwrap();
assert!(on_disk.contains("<version>2.0.0</version>"));
}
#[test]
fn update_version_files_skips_missing_custom_file() {
let dir = tempfile::tempdir().unwrap();
let custom_files = vec![custom("pom.xml", r"<version>([^<]+)</version>")];
let results =
crate::version_file::update_version_files(dir.path(), "2.0.0", &custom_files).unwrap();
assert!(
results.iter().all(|r| r.name != "pom.xml"),
"missing file should be skipped"
);
}
#[test]
fn update_version_files_skips_non_matching_regex() {
let dir = tempfile::tempdir().unwrap();
let txt = dir.path().join("version.txt");
fs::write(&txt, "no version here\n").unwrap();
let custom_files = vec![custom("version.txt", r"version = ([^\n]+)")];
let results =
crate::version_file::update_version_files(dir.path(), "2.0.0", &custom_files).unwrap();
assert!(
results.iter().all(|r| r.name != "version.txt"),
"non-matching regex should be skipped"
);
}
}