ricecoder_research/dependency_analyzer/
python_parser.rs1use crate::error::ResearchError;
4use crate::models::Dependency;
5use std::path::Path;
6use tracing::debug;
7
8#[derive(Debug)]
10pub struct PythonParser;
11
12impl PythonParser {
13 pub fn new() -> Self {
15 PythonParser
16 }
17
18 pub fn parse(&self, root: &Path) -> Result<Vec<Dependency>, ResearchError> {
20 let mut dependencies = Vec::new();
21
22 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 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 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 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 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 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 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 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 fn parse_requirement_string(&self, req_str: &str, is_dev: bool) -> Option<Dependency> {
176 let req_str = req_str.trim();
177
178 let req_str = if let Some(bracket_pos) = req_str.find('[') {
180 &req_str[..bracket_pos]
181 } else {
182 req_str
183 };
184
185 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 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}