ricecoder_research/dependency_analyzer/
python_parser.rs

1//! Python dependency parser for pyproject.toml and requirements.txt
2
3use crate::error::ResearchError;
4use crate::models::Dependency;
5use std::path::Path;
6use tracing::debug;
7
8/// Parses Python dependencies from pyproject.toml and requirements.txt
9#[derive(Debug)]
10pub struct PythonParser;
11
12impl PythonParser {
13    /// Creates a new PythonParser
14    pub fn new() -> Self {
15        PythonParser
16    }
17
18    /// Parses dependencies from pyproject.toml or requirements.txt
19    pub fn parse(&self, root: &Path) -> Result<Vec<Dependency>, ResearchError> {
20        let mut dependencies = Vec::new();
21
22        // Try pyproject.toml first
23        let pyproject_path = root.join("pyproject.toml");
24        if pyproject_path.exists() {
25            debug!("Parsing Python dependencies from {:?}", pyproject_path);
26            if let Ok(mut deps) = self.parse_pyproject(&pyproject_path) {
27                dependencies.append(&mut deps);
28            }
29        }
30
31        // Try requirements.txt
32        let requirements_path = root.join("requirements.txt");
33        if requirements_path.exists() {
34            debug!("Parsing Python dependencies from {:?}", requirements_path);
35            if let Ok(mut deps) = self.parse_requirements(&requirements_path) {
36                dependencies.append(&mut deps);
37            }
38        }
39
40        Ok(dependencies)
41    }
42
43    /// Parses dependencies from pyproject.toml
44    fn parse_pyproject(&self, path: &Path) -> Result<Vec<Dependency>, ResearchError> {
45        let content =
46            std::fs::read_to_string(path).map_err(|e| ResearchError::DependencyParsingFailed {
47                language: "Python".to_string(),
48                path: Some(path.to_path_buf()),
49                reason: format!("Failed to read pyproject.toml: {}", e),
50            })?;
51
52        let pyproject: toml::Value =
53            toml::from_str(&content).map_err(|e| ResearchError::DependencyParsingFailed {
54                language: "Python".to_string(),
55                path: Some(path.to_path_buf()),
56                reason: format!("Failed to parse pyproject.toml: {}", e),
57            })?;
58
59        let mut dependencies = Vec::new();
60
61        // Parse project dependencies
62        if let Some(project) = pyproject.get("project") {
63            if let Some(deps) = project.get("dependencies").and_then(|d| d.as_array()) {
64                for dep_str in deps {
65                    if let Some(dep_str) = dep_str.as_str() {
66                        if let Some(dep) = self.parse_requirement_string(dep_str, false) {
67                            dependencies.push(dep);
68                        }
69                    }
70                }
71            }
72
73            // Parse optional dependencies
74            if let Some(optional) = project
75                .get("optional-dependencies")
76                .and_then(|o| o.as_table())
77            {
78                for (_group, deps) in optional {
79                    if let Some(deps_array) = deps.as_array() {
80                        for dep_str in deps_array {
81                            if let Some(dep_str) = dep_str.as_str() {
82                                if let Some(dep) = self.parse_requirement_string(dep_str, false) {
83                                    dependencies.push(dep);
84                                }
85                            }
86                        }
87                    }
88                }
89            }
90        }
91
92        // Parse poetry dependencies
93        if let Some(tool) = pyproject.get("tool") {
94            if let Some(poetry) = tool.get("poetry") {
95                if let Some(deps) = poetry.get("dependencies").and_then(|d| d.as_table()) {
96                    for (name, value) in deps {
97                        if name != "python" {
98                            if let Some(version) = value.as_str() {
99                                dependencies.push(Dependency {
100                                    name: name.clone(),
101                                    version: version.to_string(),
102                                    constraints: Some(version.to_string()),
103                                    is_dev: false,
104                                });
105                            } else if let Some(table) = value.as_table() {
106                                if let Some(version) = table.get("version").and_then(|v| v.as_str())
107                                {
108                                    dependencies.push(Dependency {
109                                        name: name.clone(),
110                                        version: version.to_string(),
111                                        constraints: Some(version.to_string()),
112                                        is_dev: false,
113                                    });
114                                }
115                            }
116                        }
117                    }
118                }
119
120                if let Some(deps) = poetry.get("dev-dependencies").and_then(|d| d.as_table()) {
121                    for (name, value) in deps {
122                        if let Some(version) = value.as_str() {
123                            dependencies.push(Dependency {
124                                name: name.clone(),
125                                version: version.to_string(),
126                                constraints: Some(version.to_string()),
127                                is_dev: true,
128                            });
129                        } else if let Some(table) = value.as_table() {
130                            if let Some(version) = table.get("version").and_then(|v| v.as_str()) {
131                                dependencies.push(Dependency {
132                                    name: name.clone(),
133                                    version: version.to_string(),
134                                    constraints: Some(version.to_string()),
135                                    is_dev: true,
136                                });
137                            }
138                        }
139                    }
140                }
141            }
142        }
143
144        Ok(dependencies)
145    }
146
147    /// Parses dependencies from requirements.txt
148    fn parse_requirements(&self, path: &Path) -> Result<Vec<Dependency>, ResearchError> {
149        let content =
150            std::fs::read_to_string(path).map_err(|e| ResearchError::DependencyParsingFailed {
151                language: "Python".to_string(),
152                path: Some(path.to_path_buf()),
153                reason: format!("Failed to read requirements.txt: {}", e),
154            })?;
155
156        let mut dependencies = Vec::new();
157
158        for line in content.lines() {
159            let line = line.trim();
160
161            // Skip comments and empty lines
162            if line.is_empty() || line.starts_with('#') {
163                continue;
164            }
165
166            if let Some(dep) = self.parse_requirement_string(line, false) {
167                dependencies.push(dep);
168            }
169        }
170
171        Ok(dependencies)
172    }
173
174    /// Parses a single requirement string (e.g., "package>=1.0,<2.0")
175    fn parse_requirement_string(&self, req_str: &str, is_dev: bool) -> Option<Dependency> {
176        let req_str = req_str.trim();
177
178        // Handle extras syntax: package[extra]>=1.0
179        let req_str = if let Some(bracket_pos) = req_str.find('[') {
180            &req_str[..bracket_pos]
181        } else {
182            req_str
183        };
184
185        // Split on operators
186        let operators = [">=", "<=", "==", "!=", "~=", ">", "<"];
187        let mut name = req_str;
188        let mut version = String::new();
189        let mut constraints = None;
190
191        for op in &operators {
192            if let Some(pos) = req_str.find(op) {
193                name = &req_str[..pos];
194                version = req_str[pos..].to_string();
195                constraints = Some(version.clone());
196                break;
197            }
198        }
199
200        if name.is_empty() {
201            return None;
202        }
203
204        if version.is_empty() {
205            version = "*".to_string();
206        }
207
208        Some(Dependency {
209            name: name.to_string(),
210            version,
211            constraints,
212            is_dev,
213        })
214    }
215
216    /// Checks if Python manifest files exist
217    pub fn has_manifest(&self, root: &Path) -> bool {
218        root.join("pyproject.toml").exists() || root.join("requirements.txt").exists()
219    }
220}
221
222impl Default for PythonParser {
223    fn default() -> Self {
224        Self::new()
225    }
226}
227
228#[cfg(test)]
229mod tests {
230    use super::*;
231    use std::fs;
232    use tempfile::TempDir;
233
234    #[test]
235    fn test_python_parser_creation() {
236        let parser = PythonParser::new();
237        assert!(true);
238    }
239
240    #[test]
241    fn test_python_parser_no_manifest() {
242        let parser = PythonParser::new();
243        let temp_dir = TempDir::new().unwrap();
244        let result = parser.parse(temp_dir.path()).unwrap();
245        assert!(result.is_empty());
246    }
247
248    #[test]
249    fn test_python_parser_requirements_txt() {
250        let parser = PythonParser::new();
251        let temp_dir = TempDir::new().unwrap();
252
253        let requirements = r#"
254requests>=2.28.0
255django==4.1.0
256pytest>=7.0
257# This is a comment
258numpy>=1.20,<2.0
259"#;
260
261        fs::write(temp_dir.path().join("requirements.txt"), requirements).unwrap();
262
263        let deps = parser.parse(temp_dir.path()).unwrap();
264        assert_eq!(deps.len(), 4);
265
266        let requests = deps.iter().find(|d| d.name == "requests").unwrap();
267        assert_eq!(requests.version, ">=2.28.0");
268    }
269
270    #[test]
271    fn test_python_parser_pyproject_toml() {
272        let parser = PythonParser::new();
273        let temp_dir = TempDir::new().unwrap();
274
275        let pyproject = r#"
276[project]
277name = "test"
278dependencies = [
279    "requests>=2.28.0",
280    "django==4.1.0"
281]
282
283[project.optional-dependencies]
284dev = ["pytest>=7.0"]
285"#;
286
287        fs::write(temp_dir.path().join("pyproject.toml"), pyproject).unwrap();
288
289        let deps = parser.parse(temp_dir.path()).unwrap();
290        assert!(deps.len() >= 2);
291
292        let requests = deps.iter().find(|d| d.name == "requests").unwrap();
293        assert_eq!(requests.version, ">=2.28.0");
294    }
295
296    #[test]
297    fn test_python_parser_has_manifest() {
298        let parser = PythonParser::new();
299        let temp_dir = TempDir::new().unwrap();
300
301        assert!(!parser.has_manifest(temp_dir.path()));
302
303        fs::write(temp_dir.path().join("requirements.txt"), "").unwrap();
304        assert!(parser.has_manifest(temp_dir.path()));
305    }
306}