1use log::debug;
2use std::fs;
3use std::path::Path;
4
5fn clean_version(version: &str) -> Option<String> {
7 let mut cleaned = version.trim().to_string();
8 let mut prev_len;
9
10 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 if cleaned.chars().any(|c| c.is_ascii_digit()) {
28 Some(cleaned)
29 } else {
30 None
31 }
32}
33
34pub fn extract_version(project_dir: &Path) -> Result<Option<String>, String> {
44 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 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 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
65fn 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 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
95fn extract_version_from_init_py(project_dir: &Path) -> Result<Option<String>, String> {
97 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 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
124fn 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 for line in content.lines() {
135 let line = line.trim();
136 if line.starts_with("__version__") {
137 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
151fn 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 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 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 fs::write(temp_dir.path().join("**version**"), "3.0.0\n").unwrap();
263
264 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 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 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 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 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}