uv_migrator/utils/
version.rs

1use log::debug;
2use std::fs;
3use std::path::Path;
4
5/// Clean and validate a version string
6fn clean_version(version: &str) -> Option<String> {
7    let mut cleaned = version.trim().to_string();
8    let mut prev_len;
9
10    // Keep cleaning until no more changes occur
11    loop {
12        prev_len = cleaned.len();
13        cleaned = cleaned
14            .trim()
15            .trim_matches('"')
16            .trim_matches('\'')
17            .trim_matches(',')
18            .trim()
19            .to_string();
20
21        if cleaned.len() == prev_len {
22            break;
23        }
24    }
25
26    // Basic version validation - should contain at least one number
27    if cleaned.chars().any(|c| c.is_ascii_digit()) {
28        Some(cleaned)
29    } else {
30        None
31    }
32}
33
34/// Extracts the version from setup.py, __init__.py, or **version** file
35///
36/// # Arguments
37///
38/// * `project_dir` - The project directory to search for version information
39///
40/// # Returns
41///
42/// * `Result<Option<String>, String>` - The version if found, None if not found, or an error
43pub fn extract_version(project_dir: &Path) -> Result<Option<String>, String> {
44    // First try to get version from setup.py
45    if let Some(version) = extract_version_from_setup_py(project_dir)? {
46        debug!("Found version in setup.py: {}", version);
47        return Ok(Some(version));
48    }
49
50    // Then try __init__.py files
51    if let Some(version) = extract_version_from_init_py(project_dir)? {
52        debug!("Found version in __init__.py: {}", version);
53        return Ok(Some(version));
54    }
55
56    // Finally, try **version** file
57    if let Some(version) = extract_version_from_version_file(project_dir)? {
58        debug!("Found version in **version** file: {}", version);
59        return Ok(Some(version));
60    }
61
62    Ok(None)
63}
64
65/// Extracts version from setup.py file
66fn extract_version_from_setup_py(project_dir: &Path) -> Result<Option<String>, String> {
67    let setup_py_path = project_dir.join("setup.py");
68    if !setup_py_path.exists() {
69        return Ok(None);
70    }
71
72    let content = fs::read_to_string(&setup_py_path)
73        .map_err(|e| format!("Failed to read setup.py: {}", e))?;
74
75    // Look for version in setup() call
76    if let Some(start_idx) = content.find("setup(") {
77        let bracket_content =
78            crate::migrators::setup_py::SetupPyMigrationSource::extract_setup_content(
79                &content[start_idx..],
80            )?;
81
82        if let Some(version) = crate::migrators::setup_py::SetupPyMigrationSource::extract_parameter(
83            &bracket_content,
84            "version",
85        ) {
86            if let Some(cleaned_version) = clean_version(&version) {
87                return Ok(Some(cleaned_version));
88            }
89        }
90    }
91
92    Ok(None)
93}
94
95/// Extracts version from __init__.py file(s)
96fn extract_version_from_init_py(project_dir: &Path) -> Result<Option<String>, String> {
97    // First, try the direct __init__.py in the project directory
98    let init_path = project_dir.join("__init__.py");
99    if let Some(version) = extract_version_from_init_file(&init_path)? {
100        return Ok(Some(version));
101    }
102
103    // Then, look for package directories
104    for entry in
105        fs::read_dir(project_dir).map_err(|e| format!("Failed to read project directory: {}", e))?
106    {
107        let entry = entry.map_err(|e| format!("Failed to read directory entry: {}", e))?;
108        let path = entry.path();
109        if path.is_dir()
110            && !path
111                .file_name()
112                .is_none_or(|n| n.to_string_lossy().starts_with('.'))
113        {
114            let init_path = path.join("__init__.py");
115            if let Some(version) = extract_version_from_init_file(&init_path)? {
116                return Ok(Some(version));
117            }
118        }
119    }
120
121    Ok(None)
122}
123
124/// Extracts version from a specific __init__.py file
125fn extract_version_from_init_file(init_path: &Path) -> Result<Option<String>, String> {
126    if !init_path.exists() {
127        return Ok(None);
128    }
129
130    let content = fs::read_to_string(init_path)
131        .map_err(|e| format!("Failed to read {}: {}", init_path.display(), e))?;
132
133    // Look for __version__ = "X.Y.Z" pattern
134    for line in content.lines() {
135        let line = line.trim();
136        if line.starts_with("__version__") {
137            // Split by comment character and take first part
138            let parts: Vec<&str> = line.splitn(2, '#').collect();
139            let version_part = parts[0].splitn(2, '=').collect::<Vec<&str>>();
140            if version_part.len() == 2 {
141                if let Some(cleaned_version) = clean_version(version_part[1]) {
142                    return Ok(Some(cleaned_version));
143                }
144            }
145        }
146    }
147
148    Ok(None)
149}
150
151/// Extracts version from **version** file
152fn extract_version_from_version_file(project_dir: &Path) -> Result<Option<String>, String> {
153    let version_path = project_dir.join("**version**");
154    if !version_path.exists() {
155        return Ok(None);
156    }
157
158    let content = fs::read_to_string(&version_path)
159        .map_err(|e| format!("Failed to read **version** file: {}", e))?;
160
161    if let Some(cleaned_version) = clean_version(&content) {
162        Ok(Some(cleaned_version))
163    } else {
164        Ok(None)
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171    use std::fs;
172    use tempfile::TempDir;
173
174    fn create_test_dir() -> TempDir {
175        TempDir::new().unwrap()
176    }
177
178    #[test]
179    fn test_clean_version() {
180        let test_cases = vec![
181            ("1.2.3", Some("1.2.3")),
182            ("\"1.2.3\"", Some("1.2.3")),
183            ("'1.2.3'", Some("1.2.3")),
184            ("1.2.3,", Some("1.2.3")),
185            (" 1.2.3 ", Some("1.2.3")),
186            ("\"1.2.3\",", Some("1.2.3")),
187            ("'1.2.3',", Some("1.2.3")),
188            (" \"1.2.3\", ", Some("1.2.3")),
189            (" '1.2.3', ", Some("1.2.3")),
190            ("__version__,", None),
191            ("", None),
192            ("\"\"", None),
193            ("\",\"", None),
194            ("version", None),
195        ];
196
197        for (input, expected) in test_cases {
198            assert_eq!(
199                clean_version(input),
200                expected.map(String::from),
201                "Failed for input: {:?}",
202                input
203            );
204        }
205    }
206
207    #[test]
208    fn test_extract_version_from_init_py() {
209        let temp_dir = create_test_dir();
210        let pkg_dir = temp_dir.path().join("my_package");
211        fs::create_dir(&pkg_dir).unwrap();
212
213        let init_content = r#"
214from .core import something
215
216__version__ = "1.2.0"
217
218def setup():
219    pass
220"#;
221        fs::write(pkg_dir.join("__init__.py"), init_content).unwrap();
222
223        let version = extract_version(temp_dir.path()).unwrap();
224        assert_eq!(version, Some("1.2.0".to_string()));
225    }
226
227    #[test]
228    fn test_extract_version_from_init_py_single_quotes() {
229        let temp_dir = create_test_dir();
230        let pkg_dir = temp_dir.path().join("my_package");
231        fs::create_dir(&pkg_dir).unwrap();
232
233        let init_content = "__version__ = '1.2.0'";
234        fs::write(pkg_dir.join("__init__.py"), init_content).unwrap();
235
236        let version = extract_version(temp_dir.path()).unwrap();
237        assert_eq!(version, Some("1.2.0".to_string()));
238    }
239
240    #[test]
241    fn test_extract_version_with_multiple_sources() {
242        let temp_dir = create_test_dir();
243
244        // Create setup.py with version
245        let setup_py_content = r#"
246from setuptools import setup
247
248setup(
249    name="test",
250    version="2.0.0",
251    description="Test project"
252)
253"#;
254        fs::write(temp_dir.path().join("setup.py"), setup_py_content).unwrap();
255
256        // Create package with __init__.py
257        let pkg_dir = temp_dir.path().join("my_package");
258        fs::create_dir(&pkg_dir).unwrap();
259        fs::write(pkg_dir.join("__init__.py"), r#"__version__ = "1.2.0""#).unwrap();
260
261        // Create **version** file
262        fs::write(temp_dir.path().join("**version**"), "3.0.0\n").unwrap();
263
264        // Should prefer setup.py version
265        let version = extract_version(temp_dir.path()).unwrap();
266        assert_eq!(version, Some("2.0.0".to_string()));
267    }
268
269    #[test]
270    fn test_extract_version_precedence() {
271        let temp_dir = create_test_dir();
272        let pkg_dir = temp_dir.path().join("my_package");
273        fs::create_dir(&pkg_dir).unwrap();
274
275        // Create only __init__.py and **version**
276        fs::write(pkg_dir.join("__init__.py"), r#"__version__ = "1.2.0""#).unwrap();
277        fs::write(temp_dir.path().join("**version**"), "3.0.0\n").unwrap();
278
279        // Should prefer __init__.py version when setup.py is absent
280        let version = extract_version(temp_dir.path()).unwrap();
281        assert_eq!(version, Some("1.2.0".to_string()));
282    }
283
284    #[test]
285    fn test_extract_version_with_invalid_values() {
286        let temp_dir = create_test_dir();
287        let pkg_dir = temp_dir.path().join("my_package");
288        fs::create_dir(&pkg_dir).unwrap();
289
290        // Test with invalid version string
291        fs::write(
292            pkg_dir.join("__init__.py"),
293            r#"__version__ = "__version__,""#,
294        )
295        .unwrap();
296
297        let version = extract_version(temp_dir.path()).unwrap();
298        assert_eq!(version, None);
299    }
300
301    #[test]
302    fn test_extract_version_with_comma() {
303        let temp_dir = create_test_dir();
304        let pkg_dir = temp_dir.path().join("my_package");
305        fs::create_dir(&pkg_dir).unwrap();
306
307        // Test various combinations of quotes, commas, and comments
308        let test_cases = vec![
309            r#"__version__ = "1.2.0","#,
310            r#"__version__ = '1.2.0',"#,
311            r#"__version__ = "1.2.0", "#,
312            r#"__version__ = "1.2.0",  # Comment"#,
313            r#"__version__ = "1.2.0" # Comment"#,
314            r#"__version__ = '1.2.0'  # With spaces and comment"#,
315            r#"__version__ = "1.2.0",# No space before comment"#,
316        ];
317
318        for test_case in test_cases {
319            fs::write(pkg_dir.join("__init__.py"), test_case).unwrap();
320            let version = extract_version(temp_dir.path()).unwrap();
321            assert_eq!(
322                version,
323                Some("1.2.0".to_string()),
324                "Failed for case: {}",
325                test_case
326            );
327            fs::remove_file(pkg_dir.join("__init__.py")).unwrap();
328        }
329    }
330}