pkg_version_parser/
lib.rs

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
22/// Extracts the version number from package management files of different programming languages.
23///
24/// # Arguments
25/// * `language` - The programming language as a lowercase string (e.g., "python", "typescript")
26/// * `file_path` - Path to the package management file
27///
28/// # Returns
29/// * `Result<String, VersionError>` - The version number if found, or an error
30///
31/// # Example
32/// ```
33/// use pkg_version_parser::get_version;
34///
35/// match get_version("python", "path/to/pyproject.toml") {
36///     Ok(version) => println!("Python project version: {}", version),
37///     Err(e) => eprintln!("Error: {}", e),
38/// }
39/// ```
40pub 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    // Pattern 1: Root level version declaration
107    let root_version_re = Regex::new(r#"(?m)^\s*version\s*=?\s*['"]([^'"]+)['"]"#)
108        .map_err(|e| VersionError::ParseError(e.to_string()))?;
109
110    // Pattern 2: Version inside publishing block
111    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    // Try finding version in root level first
116    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 not found in root level or if it was a variable, try finding in publishing block
126    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 the version is a variable reference (e.g. "$version"),
130            // try to find its definition in the root level
131            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        // Test standard format
203        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        // Test with development status classifier
212        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        // Test invalid TOML
221        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        // Test standard format
238        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        // Test with pre-release version
249        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        // Test invalid JSON
262        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        // Test standard format
281        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        // Test with patch version
293        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        // Test missing version
298        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        // Test standard format with double quotes
311        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        // Test with single quotes
320        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        // Test missing version
325        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        // Test with whitespace variations
333        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        // Test version in publishing block
343        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        // Test root level version
362        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        // Test version with no equals sign
372        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        // Test complex Gradle file with both versions (should return root level version)
382        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        // Test file with no version
400        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        // Test with variable interpolation
413        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        // Test standard format
436        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        // Test with pre-release version
446        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        // Test with build metadata
456        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        // Test invalid TOML
466        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        // Test missing version
478        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        // Test various version formats across different languages
495        let test_cases = vec![
496            // Standard versions
497            ("1.0.0", true),
498            ("1.2.3", true),
499            // Pre-release versions
500            ("1.0.0-alpha", true),
501            ("1.0.0-beta.1", true),
502            ("1.0.0-rc.1", true),
503            // Build metadata
504            ("1.0.0+build.123", true),
505            ("1.0.0-alpha+build.123", true),
506            // Invalid versions should still be parsed as they appear in the file
507            ("invalid.version", true),
508            ("1.0", true),
509            ("1", true),
510        ];
511
512        // Test each version format for each language's typical file
513        for (version, should_parse) in test_cases {
514            // Test in package.json (TypeScript)
515            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            // Test in Cargo.toml (Rust)
526            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}