use regex::Regex;
use serde_json::Value;
use std::fs;
use std::path::Path;
use thiserror::Error;
use toml::Value as TomlValue;
#[derive(Error, Debug)]
pub enum VersionError {
#[error("Invalid language specified")]
InvalidLanguage,
#[error("File not found")]
FileNotFound,
#[error("Failed to parse file: {0}")]
ParseError(String),
#[error("Version not found in file")]
VersionNotFound,
#[error("IO error: {0}")]
IoError(#[from] std::io::Error),
}
pub fn get_version(language: &str, file_path: impl AsRef<Path>) -> Result<String, VersionError> {
match language {
"python" => parse_python_version(file_path),
"typescript" => parse_typescript_version(file_path),
"go" => parse_go_version(file_path),
"ruby" => parse_ruby_version(file_path),
"java" => parse_java_version(file_path),
"rust" => parse_rust_version(file_path),
_ => Err(VersionError::InvalidLanguage),
}
}
fn parse_python_version(file_path: impl AsRef<Path>) -> Result<String, VersionError> {
let content = fs::read_to_string(file_path).map_err(|_| VersionError::FileNotFound)?;
let parsed: TomlValue =
toml::from_str(&content).map_err(|e| VersionError::ParseError(e.to_string()))?;
parsed
.get("project")
.and_then(|project| project.get("version"))
.and_then(|version| version.as_str())
.map(String::from)
.ok_or(VersionError::VersionNotFound)
}
fn parse_typescript_version(file_path: impl AsRef<Path>) -> Result<String, VersionError> {
let content = fs::read_to_string(file_path).map_err(|_| VersionError::FileNotFound)?;
let parsed: Value =
serde_json::from_str(&content).map_err(|e| VersionError::ParseError(e.to_string()))?;
parsed
.get("version")
.and_then(|v| v.as_str())
.map(String::from)
.ok_or(VersionError::VersionNotFound)
}
fn parse_go_version(file_path: impl AsRef<Path>) -> Result<String, VersionError> {
let content = fs::read_to_string(file_path).map_err(|_| VersionError::FileNotFound)?;
let re = Regex::new(r"go (\d+\.\d+(?:\.\d+)?)")
.map_err(|e| VersionError::ParseError(e.to_string()))?;
re.captures(&content)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
.ok_or(VersionError::VersionNotFound)
}
fn parse_ruby_version(file_path: impl AsRef<Path>) -> Result<String, VersionError> {
let content = fs::read_to_string(file_path).map_err(|_| VersionError::FileNotFound)?;
let re = Regex::new(r#"ruby\s*["']([\d.]+)["']"#)
.map_err(|e| VersionError::ParseError(e.to_string()))?;
re.captures(&content)
.and_then(|caps| caps.get(1))
.map(|m| m.as_str().to_string())
.ok_or(VersionError::VersionNotFound)
}
fn parse_java_version(file_path: impl AsRef<Path>) -> Result<String, VersionError> {
let content = fs::read_to_string(file_path).map_err(|_| VersionError::FileNotFound)?;
let root_version_re = Regex::new(r#"(?m)^\s*version\s*=?\s*['"]([^'"]+)['"]"#)
.map_err(|e| VersionError::ParseError(e.to_string()))?;
let publishing_version_re =
Regex::new(r#"(?s)publishing\s*\{[^}]*version\s*=\s*['"]([^'"]+)['"]"#)
.map_err(|e| VersionError::ParseError(e.to_string()))?;
if let Some(caps) = root_version_re.captures(&content) {
if let Some(version) = caps.get(1) {
let version_str = version.as_str();
if !version_str.starts_with('$') {
return Ok(version_str.to_string());
}
}
}
if let Some(caps) = publishing_version_re.captures(&content) {
if let Some(version) = caps.get(1) {
let version_str = version.as_str();
if let Some(var_name) = version_str.strip_prefix('$') {
let var_pattern = format!(r#"(?m)^\s*{}\s*=?\s*['"]([^'"]+)['"]"#, var_name);
let var_re = Regex::new(&var_pattern)
.map_err(|e| VersionError::ParseError(e.to_string()))?;
if let Some(var_caps) = var_re.captures(&content) {
if let Some(resolved_version) = var_caps.get(1) {
return Ok(resolved_version.as_str().to_string());
}
}
} else {
return Ok(version_str.to_string());
}
}
}
Err(VersionError::VersionNotFound)
}
fn parse_rust_version(file_path: impl AsRef<Path>) -> Result<String, VersionError> {
let content = fs::read_to_string(file_path).map_err(|_| VersionError::FileNotFound)?;
let parsed: TomlValue =
toml::from_str(&content).map_err(|e| VersionError::ParseError(e.to_string()))?;
parsed
.get("package")
.and_then(|package| package.get("version"))
.and_then(|version| version.as_str())
.map(String::from)
.ok_or(VersionError::VersionNotFound)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::File;
use std::io::Write;
use tempfile::TempDir;
fn create_test_file(
dir: &TempDir,
filename: &str,
content: &str,
) -> std::io::Result<std::path::PathBuf> {
let file_path = dir.path().join(filename);
let mut file = File::create(&file_path)?;
file.write_all(content.as_bytes())?;
Ok(file_path)
}
#[test]
fn test_invalid_language() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("dummy.txt");
let result = get_version("invalid", file_path);
assert!(matches!(result, Err(VersionError::InvalidLanguage)));
}
#[test]
fn test_file_not_found() {
let dir = TempDir::new().unwrap();
let file_path = dir.path().join("nonexistent.toml");
let result = get_version("python", file_path);
assert!(matches!(result, Err(VersionError::FileNotFound)));
}
#[test]
fn test_python_version() {
let dir = TempDir::new().unwrap();
let content = r#"
[project]
name = "example"
version = "1.2.3"
"#;
let file_path = create_test_file(&dir, "pyproject.toml", content).unwrap();
assert_eq!(get_version("python", file_path).unwrap(), "1.2.3");
let content = r#"
[project]
name = "example"
version = "0.1.0-alpha.1"
"#;
let file_path = create_test_file(&dir, "pyproject.toml", content).unwrap();
assert_eq!(get_version("python", file_path).unwrap(), "0.1.0-alpha.1");
let content = r#"
[project
name = "example"
version = "1.2.3"
"#;
let file_path = create_test_file(&dir, "pyproject.toml", content).unwrap();
assert!(matches!(
get_version("python", file_path),
Err(VersionError::ParseError(_))
));
}
#[test]
fn test_typescript_version() {
let dir = TempDir::new().unwrap();
let content = r#"
{
"name": "example",
"version": "1.2.3",
"dependencies": {}
}
"#;
let file_path = create_test_file(&dir, "package.json", content).unwrap();
assert_eq!(get_version("typescript", file_path).unwrap(), "1.2.3");
let content = r#"
{
"name": "example",
"version": "2.0.0-beta.1"
}
"#;
let file_path = create_test_file(&dir, "package.json", content).unwrap();
assert_eq!(
get_version("typescript", file_path).unwrap(),
"2.0.0-beta.1"
);
let content = r#"
{
"name": "example",
"version": "1.2.3",
missing_quote: "value"
}
"#;
let file_path = create_test_file(&dir, "package.json", content).unwrap();
assert!(matches!(
get_version("typescript", file_path),
Err(VersionError::ParseError(_))
));
}
#[test]
fn test_go_version() {
let dir = TempDir::new().unwrap();
let content = r#"
module example.com/mymodule
go 1.20
require (
github.com/example/pkg v1.0.0
)
"#;
let file_path = create_test_file(&dir, "go.mod", content).unwrap();
assert_eq!(get_version("go", file_path).unwrap(), "1.20");
let content = "module example.com/mymodule\n\ngo 1.20.5\n";
let file_path = create_test_file(&dir, "go.mod", content).unwrap();
assert_eq!(get_version("go", file_path).unwrap(), "1.20.5");
let content = "module example.com/mymodule\n";
let file_path = create_test_file(&dir, "go.mod", content).unwrap();
assert!(matches!(
get_version("go", file_path),
Err(VersionError::VersionNotFound)
));
}
#[test]
fn test_ruby_version() {
let dir = TempDir::new().unwrap();
let content = r#"
source 'https://rubygems.org'
ruby "3.2.0"
gem 'rails', '7.0.0'
"#;
let file_path = create_test_file(&dir, "Gemfile", content).unwrap();
assert_eq!(get_version("ruby", file_path).unwrap(), "3.2.0");
let content = "source 'https://rubygems.org'\nruby '3.1.2'\n";
let file_path = create_test_file(&dir, "Gemfile", content).unwrap();
assert_eq!(get_version("ruby", file_path).unwrap(), "3.1.2");
let content = "source 'https://rubygems.org'\ngem 'rails'\n";
let file_path = create_test_file(&dir, "Gemfile", content).unwrap();
assert!(matches!(
get_version("ruby", file_path),
Err(VersionError::VersionNotFound)
));
let content = "source 'https://rubygems.org'\nruby '3.1.3' \n";
let file_path = create_test_file(&dir, "Gemfile", content).unwrap();
assert_eq!(get_version("ruby", file_path).unwrap(), "3.1.3");
}
#[test]
fn test_java_gradle_versions() {
let dir = TempDir::new().unwrap();
let content = r#"
plugins {
id 'java-library'
id 'maven-publish'
}
publishing {
publications {
maven(MavenPublication) {
groupId = 'com.example'
artifactId = 'library'
version = '1.0.15'
}
}
}
"#;
let file_path = create_test_file(&dir, "build.gradle", content).unwrap();
assert_eq!(get_version("java", file_path).unwrap(), "1.0.15");
let content = r#"
plugins {
id 'java-library'
}
version = '2.1.0'
"#;
let file_path = create_test_file(&dir, "build.gradle", content).unwrap();
assert_eq!(get_version("java", file_path).unwrap(), "2.1.0");
let content = r#"
plugins {
id 'java-library'
}
version '3.0.0-SNAPSHOT'
"#;
let file_path = create_test_file(&dir, "build.gradle", content).unwrap();
assert_eq!(get_version("java", file_path).unwrap(), "3.0.0-SNAPSHOT");
let content = r#"
plugins {
id 'java-library'
id 'maven-publish'
}
version = '4.0.0'
publishing {
publications {
maven(MavenPublication) {
version = '1.0.15'
}
}
}
"#;
let file_path = create_test_file(&dir, "build.gradle", content).unwrap();
assert_eq!(get_version("java", file_path).unwrap(), "4.0.0");
let content = r#"
plugins {
id 'java-library'
}
sourceCompatibility = 1.8
"#;
let file_path = create_test_file(&dir, "build.gradle", content).unwrap();
assert!(matches!(
get_version("java", file_path),
Err(VersionError::VersionNotFound)
));
let content = r#"
plugins {
id 'java-library'
id 'maven-publish'
}
publishing {
publications {
maven(MavenPublication) {
version = "5.0.1"
}
}
}
version = '5.0.0'
"#;
let file_path = create_test_file(&dir, "build.gradle", content).unwrap();
assert_eq!(get_version("java", file_path).unwrap(), "5.0.1");
}
#[test]
fn test_rust_version() {
let dir = TempDir::new().unwrap();
let content = r#"
[package]
name = "example"
version = "1.2.3"
edition = "2021"
"#;
let file_path = create_test_file(&dir, "Cargo.toml", content).unwrap();
assert_eq!(get_version("rust", file_path).unwrap(), "1.2.3");
let content = r#"
[package]
name = "example"
version = "0.1.0-alpha.1"
edition = "2021"
"#;
let file_path = create_test_file(&dir, "Cargo.toml", content).unwrap();
assert_eq!(get_version("rust", file_path).unwrap(), "0.1.0-alpha.1");
let content = r#"
[package]
name = "example"
version = "1.0.0+build.123"
edition = "2021"
"#;
let file_path = create_test_file(&dir, "Cargo.toml", content).unwrap();
assert_eq!(get_version("rust", file_path).unwrap(), "1.0.0+build.123");
let content = r#"
[package
name = "example"
version = "1.2.3"
"#;
let file_path = create_test_file(&dir, "Cargo.toml", content).unwrap();
assert!(matches!(
get_version("rust", file_path),
Err(VersionError::ParseError(_))
));
let content = r#"
[package]
name = "example"
edition = "2021"
"#;
let file_path = create_test_file(&dir, "Cargo.toml", content).unwrap();
assert!(matches!(
get_version("rust", file_path),
Err(VersionError::VersionNotFound)
));
}
#[test]
fn test_version_formats() {
let dir = TempDir::new().unwrap();
let test_cases = vec![
("1.0.0", true),
("1.2.3", true),
("1.0.0-alpha", true),
("1.0.0-beta.1", true),
("1.0.0-rc.1", true),
("1.0.0+build.123", true),
("1.0.0-alpha+build.123", true),
("invalid.version", true),
("1.0", true),
("1", true),
];
for (version, should_parse) in test_cases {
let ts_content = format!(r#"{{ "name": "test", "version": "{version}" }}"#);
let file_path = create_test_file(&dir, "package.json", &ts_content).unwrap();
let result = get_version("typescript", file_path);
assert_eq!(
result.is_ok(),
should_parse,
"TypeScript version: {}",
version
);
let rust_content = format!(
r#"[package]
name = "test"
version = "{version}"
"#
);
let file_path = create_test_file(&dir, "Cargo.toml", &rust_content).unwrap();
let result = get_version("rust", file_path);
assert_eq!(result.is_ok(), should_parse, "Rust version: {}", version);
}
}
}