1use regex::Regex;
2use serde_json::Value;
3use std::fs;
4use std::path::Path;
5use thiserror::Error;
6use toml::Value as TomlValue;
7
8#[derive(Error, Debug)]
9pub enum VersionError {
10 #[error("Invalid language specified")]
11 InvalidLanguage,
12 #[error("File not found")]
13 FileNotFound,
14 #[error("Failed to parse file: {0}")]
15 ParseError(String),
16 #[error("Version not found in file")]
17 VersionNotFound,
18 #[error("IO error: {0}")]
19 IoError(#[from] std::io::Error),
20}
21
22pub fn get_version(language: &str, file_path: impl AsRef<Path>) -> Result<String, VersionError> {
41 match language {
42 "python" => parse_python_version(file_path),
43 "typescript" => parse_typescript_version(file_path),
44 "go" => parse_go_version(file_path),
45 "ruby" => parse_ruby_version(file_path),
46 "java" => parse_java_version(file_path),
47 "rust" => parse_rust_version(file_path),
48 _ => Err(VersionError::InvalidLanguage),
49 }
50}
51
52fn parse_python_version(file_path: impl AsRef<Path>) -> Result<String, VersionError> {
53 let content = fs::read_to_string(file_path).map_err(|_| VersionError::FileNotFound)?;
54
55 let parsed: TomlValue =
56 toml::from_str(&content).map_err(|e| VersionError::ParseError(e.to_string()))?;
57
58 parsed
59 .get("project")
60 .and_then(|project| project.get("version"))
61 .and_then(|version| version.as_str())
62 .map(String::from)
63 .ok_or(VersionError::VersionNotFound)
64}
65
66fn parse_typescript_version(file_path: impl AsRef<Path>) -> Result<String, VersionError> {
67 let content = fs::read_to_string(file_path).map_err(|_| VersionError::FileNotFound)?;
68
69 let parsed: Value =
70 serde_json::from_str(&content).map_err(|e| VersionError::ParseError(e.to_string()))?;
71
72 parsed
73 .get("version")
74 .and_then(|v| v.as_str())
75 .map(String::from)
76 .ok_or(VersionError::VersionNotFound)
77}
78
79fn parse_go_version(file_path: impl AsRef<Path>) -> Result<String, VersionError> {
80 let content = fs::read_to_string(file_path).map_err(|_| VersionError::FileNotFound)?;
81
82 let re = Regex::new(r"go (\d+\.\d+(?:\.\d+)?)")
83 .map_err(|e| VersionError::ParseError(e.to_string()))?;
84
85 re.captures(&content)
86 .and_then(|caps| caps.get(1))
87 .map(|m| m.as_str().to_string())
88 .ok_or(VersionError::VersionNotFound)
89}
90
91fn parse_ruby_version(file_path: impl AsRef<Path>) -> Result<String, VersionError> {
92 let content = fs::read_to_string(file_path).map_err(|_| VersionError::FileNotFound)?;
93
94 let re = Regex::new(r#"ruby\s*["']([\d.]+)["']"#)
95 .map_err(|e| VersionError::ParseError(e.to_string()))?;
96
97 re.captures(&content)
98 .and_then(|caps| caps.get(1))
99 .map(|m| m.as_str().to_string())
100 .ok_or(VersionError::VersionNotFound)
101}
102
103fn parse_java_version(file_path: impl AsRef<Path>) -> Result<String, VersionError> {
104 let content = fs::read_to_string(file_path).map_err(|_| VersionError::FileNotFound)?;
105
106 let root_version_re = Regex::new(r#"(?m)^\s*version\s*=?\s*['"]([^'"]+)['"]"#)
108 .map_err(|e| VersionError::ParseError(e.to_string()))?;
109
110 let publishing_version_re =
112 Regex::new(r#"(?s)publishing\s*\{[^}]*version\s*=\s*['"]([^'"]+)['"]"#)
113 .map_err(|e| VersionError::ParseError(e.to_string()))?;
114
115 if let Some(caps) = root_version_re.captures(&content) {
117 if let Some(version) = caps.get(1) {
118 let version_str = version.as_str();
119 if !version_str.starts_with('$') {
120 return Ok(version_str.to_string());
121 }
122 }
123 }
124
125 if let Some(caps) = publishing_version_re.captures(&content) {
127 if let Some(version) = caps.get(1) {
128 let version_str = version.as_str();
129 if let Some(var_name) = version_str.strip_prefix('$') {
132 let var_pattern = format!(r#"(?m)^\s*{}\s*=?\s*['"]([^'"]+)['"]"#, var_name);
133 let var_re = Regex::new(&var_pattern)
134 .map_err(|e| VersionError::ParseError(e.to_string()))?;
135
136 if let Some(var_caps) = var_re.captures(&content) {
137 if let Some(resolved_version) = var_caps.get(1) {
138 return Ok(resolved_version.as_str().to_string());
139 }
140 }
141 } else {
142 return Ok(version_str.to_string());
143 }
144 }
145 }
146
147 Err(VersionError::VersionNotFound)
148}
149
150fn parse_rust_version(file_path: impl AsRef<Path>) -> Result<String, VersionError> {
151 let content = fs::read_to_string(file_path).map_err(|_| VersionError::FileNotFound)?;
152
153 let parsed: TomlValue =
154 toml::from_str(&content).map_err(|e| VersionError::ParseError(e.to_string()))?;
155
156 parsed
157 .get("package")
158 .and_then(|package| package.get("version"))
159 .and_then(|version| version.as_str())
160 .map(String::from)
161 .ok_or(VersionError::VersionNotFound)
162}
163
164#[cfg(test)]
165mod tests {
166 use super::*;
167 use std::fs::File;
168 use std::io::Write;
169 use tempfile::TempDir;
170
171 fn create_test_file(
172 dir: &TempDir,
173 filename: &str,
174 content: &str,
175 ) -> std::io::Result<std::path::PathBuf> {
176 let file_path = dir.path().join(filename);
177 let mut file = File::create(&file_path)?;
178 file.write_all(content.as_bytes())?;
179 Ok(file_path)
180 }
181
182 #[test]
183 fn test_invalid_language() {
184 let dir = TempDir::new().unwrap();
185 let file_path = dir.path().join("dummy.txt");
186 let result = get_version("invalid", file_path);
187 assert!(matches!(result, Err(VersionError::InvalidLanguage)));
188 }
189
190 #[test]
191 fn test_file_not_found() {
192 let dir = TempDir::new().unwrap();
193 let file_path = dir.path().join("nonexistent.toml");
194 let result = get_version("python", file_path);
195 assert!(matches!(result, Err(VersionError::FileNotFound)));
196 }
197
198 #[test]
199 fn test_python_version() {
200 let dir = TempDir::new().unwrap();
201
202 let content = r#"
204[project]
205name = "example"
206version = "1.2.3"
207"#;
208 let file_path = create_test_file(&dir, "pyproject.toml", content).unwrap();
209 assert_eq!(get_version("python", file_path).unwrap(), "1.2.3");
210
211 let content = r#"
213[project]
214name = "example"
215version = "0.1.0-alpha.1"
216"#;
217 let file_path = create_test_file(&dir, "pyproject.toml", content).unwrap();
218 assert_eq!(get_version("python", file_path).unwrap(), "0.1.0-alpha.1");
219
220 let content = r#"
222[project
223name = "example"
224version = "1.2.3"
225"#;
226 let file_path = create_test_file(&dir, "pyproject.toml", content).unwrap();
227 assert!(matches!(
228 get_version("python", file_path),
229 Err(VersionError::ParseError(_))
230 ));
231 }
232
233 #[test]
234 fn test_typescript_version() {
235 let dir = TempDir::new().unwrap();
236
237 let content = r#"
239{
240 "name": "example",
241 "version": "1.2.3",
242 "dependencies": {}
243}
244"#;
245 let file_path = create_test_file(&dir, "package.json", content).unwrap();
246 assert_eq!(get_version("typescript", file_path).unwrap(), "1.2.3");
247
248 let content = r#"
250{
251 "name": "example",
252 "version": "2.0.0-beta.1"
253}
254"#;
255 let file_path = create_test_file(&dir, "package.json", content).unwrap();
256 assert_eq!(
257 get_version("typescript", file_path).unwrap(),
258 "2.0.0-beta.1"
259 );
260
261 let content = r#"
263{
264 "name": "example",
265 "version": "1.2.3",
266 missing_quote: "value"
267}
268"#;
269 let file_path = create_test_file(&dir, "package.json", content).unwrap();
270 assert!(matches!(
271 get_version("typescript", file_path),
272 Err(VersionError::ParseError(_))
273 ));
274 }
275
276 #[test]
277 fn test_go_version() {
278 let dir = TempDir::new().unwrap();
279
280 let content = r#"
282module example.com/mymodule
283
284go 1.20
285require (
286 github.com/example/pkg v1.0.0
287)
288"#;
289 let file_path = create_test_file(&dir, "go.mod", content).unwrap();
290 assert_eq!(get_version("go", file_path).unwrap(), "1.20");
291
292 let content = "module example.com/mymodule\n\ngo 1.20.5\n";
294 let file_path = create_test_file(&dir, "go.mod", content).unwrap();
295 assert_eq!(get_version("go", file_path).unwrap(), "1.20.5");
296
297 let content = "module example.com/mymodule\n";
299 let file_path = create_test_file(&dir, "go.mod", content).unwrap();
300 assert!(matches!(
301 get_version("go", file_path),
302 Err(VersionError::VersionNotFound)
303 ));
304 }
305
306 #[test]
307 fn test_ruby_version() {
308 let dir = TempDir::new().unwrap();
309
310 let content = r#"
312source 'https://rubygems.org'
313ruby "3.2.0"
314gem 'rails', '7.0.0'
315"#;
316 let file_path = create_test_file(&dir, "Gemfile", content).unwrap();
317 assert_eq!(get_version("ruby", file_path).unwrap(), "3.2.0");
318
319 let content = "source 'https://rubygems.org'\nruby '3.1.2'\n";
321 let file_path = create_test_file(&dir, "Gemfile", content).unwrap();
322 assert_eq!(get_version("ruby", file_path).unwrap(), "3.1.2");
323
324 let content = "source 'https://rubygems.org'\ngem 'rails'\n";
326 let file_path = create_test_file(&dir, "Gemfile", content).unwrap();
327 assert!(matches!(
328 get_version("ruby", file_path),
329 Err(VersionError::VersionNotFound)
330 ));
331
332 let content = "source 'https://rubygems.org'\nruby '3.1.3' \n";
334 let file_path = create_test_file(&dir, "Gemfile", content).unwrap();
335 assert_eq!(get_version("ruby", file_path).unwrap(), "3.1.3");
336 }
337
338 #[test]
339 fn test_java_gradle_versions() {
340 let dir = TempDir::new().unwrap();
341
342 let content = r#"
344plugins {
345 id 'java-library'
346 id 'maven-publish'
347}
348publishing {
349 publications {
350 maven(MavenPublication) {
351 groupId = 'com.example'
352 artifactId = 'library'
353 version = '1.0.15'
354 }
355 }
356}
357"#;
358 let file_path = create_test_file(&dir, "build.gradle", content).unwrap();
359 assert_eq!(get_version("java", file_path).unwrap(), "1.0.15");
360
361 let content = r#"
363plugins {
364 id 'java-library'
365}
366version = '2.1.0'
367"#;
368 let file_path = create_test_file(&dir, "build.gradle", content).unwrap();
369 assert_eq!(get_version("java", file_path).unwrap(), "2.1.0");
370
371 let content = r#"
373plugins {
374 id 'java-library'
375}
376version '3.0.0-SNAPSHOT'
377"#;
378 let file_path = create_test_file(&dir, "build.gradle", content).unwrap();
379 assert_eq!(get_version("java", file_path).unwrap(), "3.0.0-SNAPSHOT");
380
381 let content = r#"
383plugins {
384 id 'java-library'
385 id 'maven-publish'
386}
387version = '4.0.0'
388publishing {
389 publications {
390 maven(MavenPublication) {
391 version = '1.0.15'
392 }
393 }
394}
395"#;
396 let file_path = create_test_file(&dir, "build.gradle", content).unwrap();
397 assert_eq!(get_version("java", file_path).unwrap(), "4.0.0");
398
399 let content = r#"
401plugins {
402 id 'java-library'
403}
404sourceCompatibility = 1.8
405"#;
406 let file_path = create_test_file(&dir, "build.gradle", content).unwrap();
407 assert!(matches!(
408 get_version("java", file_path),
409 Err(VersionError::VersionNotFound)
410 ));
411
412 let content = r#"
414plugins {
415 id 'java-library'
416 id 'maven-publish'
417}
418publishing {
419 publications {
420 maven(MavenPublication) {
421 version = "5.0.1"
422 }
423 }
424}
425version = '5.0.0'
426"#;
427 let file_path = create_test_file(&dir, "build.gradle", content).unwrap();
428 assert_eq!(get_version("java", file_path).unwrap(), "5.0.1");
429 }
430
431 #[test]
432 fn test_rust_version() {
433 let dir = TempDir::new().unwrap();
434
435 let content = r#"
437[package]
438name = "example"
439version = "1.2.3"
440edition = "2021"
441"#;
442 let file_path = create_test_file(&dir, "Cargo.toml", content).unwrap();
443 assert_eq!(get_version("rust", file_path).unwrap(), "1.2.3");
444
445 let content = r#"
447[package]
448name = "example"
449version = "0.1.0-alpha.1"
450edition = "2021"
451"#;
452 let file_path = create_test_file(&dir, "Cargo.toml", content).unwrap();
453 assert_eq!(get_version("rust", file_path).unwrap(), "0.1.0-alpha.1");
454
455 let content = r#"
457[package]
458name = "example"
459version = "1.0.0+build.123"
460edition = "2021"
461"#;
462 let file_path = create_test_file(&dir, "Cargo.toml", content).unwrap();
463 assert_eq!(get_version("rust", file_path).unwrap(), "1.0.0+build.123");
464
465 let content = r#"
467[package
468name = "example"
469version = "1.2.3"
470"#;
471 let file_path = create_test_file(&dir, "Cargo.toml", content).unwrap();
472 assert!(matches!(
473 get_version("rust", file_path),
474 Err(VersionError::ParseError(_))
475 ));
476
477 let content = r#"
479[package]
480name = "example"
481edition = "2021"
482"#;
483 let file_path = create_test_file(&dir, "Cargo.toml", content).unwrap();
484 assert!(matches!(
485 get_version("rust", file_path),
486 Err(VersionError::VersionNotFound)
487 ));
488 }
489
490 #[test]
491 fn test_version_formats() {
492 let dir = TempDir::new().unwrap();
493
494 let test_cases = vec![
496 ("1.0.0", true),
498 ("1.2.3", true),
499 ("1.0.0-alpha", true),
501 ("1.0.0-beta.1", true),
502 ("1.0.0-rc.1", true),
503 ("1.0.0+build.123", true),
505 ("1.0.0-alpha+build.123", true),
506 ("invalid.version", true),
508 ("1.0", true),
509 ("1", true),
510 ];
511
512 for (version, should_parse) in test_cases {
514 let ts_content = format!(r#"{{ "name": "test", "version": "{version}" }}"#);
516 let file_path = create_test_file(&dir, "package.json", &ts_content).unwrap();
517 let result = get_version("typescript", file_path);
518 assert_eq!(
519 result.is_ok(),
520 should_parse,
521 "TypeScript version: {}",
522 version
523 );
524
525 let rust_content = format!(
527 r#"[package]
528name = "test"
529version = "{version}"
530"#
531 );
532 let file_path = create_test_file(&dir, "Cargo.toml", &rust_content).unwrap();
533 let result = get_version("rust", file_path);
534 assert_eq!(result.is_ok(), should_parse, "Rust version: {}", version);
535 }
536 }
537}