pcu/parsers/
requirements.rs1use super::{Dependency, DependencyParser};
2use check_updates_core::VersionSpec;
3use anyhow::{Context, Result};
4use std::fs;
5use std::path::Path;
6
7pub struct RequirementsParser;
9
10impl Default for RequirementsParser {
11 fn default() -> Self {
12 Self::new()
13 }
14}
15
16impl RequirementsParser {
17 pub fn new() -> Self {
18 Self
19 }
20
21 fn parse_line(line: &str, line_number: usize, source_file: &Path) -> Option<Dependency> {
23 let original_line = line.to_string();
24 let line = line.trim();
25
26 if line.is_empty() {
28 return None;
29 }
30
31 if line.starts_with('#') {
33 return None;
34 }
35
36 if line.starts_with("-r ") || line.starts_with("-r\t") {
38 return None;
39 }
40
41 if line.starts_with("--") || line.starts_with('-') {
43 return None;
44 }
45
46 let line_without_marker = if let Some(idx) = line.find(';') {
49 line[..idx].trim()
50 } else {
51 line
52 };
53
54 let line_clean = if let Some(idx) = line_without_marker.find('#') {
56 line_without_marker[..idx].trim()
57 } else {
58 line_without_marker
59 };
60
61 if line_clean.is_empty() {
62 return None;
63 }
64
65 let (package_with_extras, version_str) = Self::split_package_version(line_clean)?;
68
69 let package_name = if let Some(bracket_idx) = package_with_extras.find('[') {
71 package_with_extras[..bracket_idx].trim()
72 } else {
73 package_with_extras
74 };
75
76 let normalized_name = package_name.to_lowercase().replace('_', "-");
78
79 let version_spec = if version_str.is_empty() {
81 VersionSpec::Any
82 } else {
83 match VersionSpec::parse(version_str) {
84 Ok(spec) => spec,
85 Err(_) => {
86 VersionSpec::Complex(version_str.to_string())
88 }
89 }
90 };
91
92 Some(Dependency {
93 name: normalized_name,
94 version_spec,
95 source_file: source_file.to_path_buf(),
96 line_number,
97 original_line,
98 })
99 }
100
101 fn split_package_version(spec: &str) -> Option<(&str, &str)> {
104 let operators = ["==", ">=", "<=", "~=", "!=", ">", "<"];
107
108 let mut first_op_idx = None;
110 for op in &operators {
111 if let Some(idx) = spec.find(op) {
112 let before = &spec[..idx];
114 let open_brackets = before.matches('[').count();
115 let close_brackets = before.matches(']').count();
116
117 if open_brackets == close_brackets {
119 first_op_idx = Some(idx);
120 break;
121 }
122 }
123 }
124
125 if let Some(idx) = first_op_idx {
126 let package = spec[..idx].trim();
127 let version = spec[idx..].trim();
128 Some((package, version))
129 } else {
130 Some((spec.trim(), ""))
132 }
133 }
134}
135
136impl DependencyParser for RequirementsParser {
137 fn parse(&self, path: &Path) -> Result<Vec<Dependency>> {
138 let content = fs::read_to_string(path)
139 .with_context(|| format!("Failed to read requirements file: {path:?}"))?;
140
141 let dependencies: Vec<Dependency> = content
142 .lines()
143 .enumerate()
144 .filter_map(|(idx, line)| {
145 Self::parse_line(line, idx + 1, path)
147 })
148 .collect();
149
150 Ok(dependencies)
151 }
152
153 fn can_parse(&self, path: &Path) -> bool {
154 path.file_name()
155 .and_then(|n| n.to_str())
156 .map(|n| n.starts_with("requirements") && n.ends_with(".txt"))
157 .unwrap_or(false)
158 }
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164 use std::io::Write;
165 use std::path::PathBuf;
166 use tempfile::NamedTempFile;
167
168 #[test]
169 fn test_parse_simple_package() {
170 let mut file = NamedTempFile::new().unwrap();
171 writeln!(file, "requests==2.28.0").unwrap();
172 writeln!(file, "numpy>=1.24.0").unwrap();
173 writeln!(file, "flask").unwrap();
174
175 let parser = RequirementsParser::new();
176 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
177
178 assert_eq!(deps.len(), 3);
179 assert_eq!(deps[0].name, "requests");
180 assert!(matches!(deps[0].version_spec, VersionSpec::Pinned(_)));
181 assert_eq!(deps[1].name, "numpy");
182 assert!(matches!(deps[1].version_spec, VersionSpec::Minimum(_)));
183 assert_eq!(deps[2].name, "flask");
184 assert!(matches!(deps[2].version_spec, VersionSpec::Any));
185 }
186
187 #[test]
188 fn test_parse_with_extras() {
189 let mut file = NamedTempFile::new().unwrap();
190 writeln!(file, "requests[security]>=2.0.0").unwrap();
191 writeln!(file, "celery[redis,msgpack]==5.2.0").unwrap();
192
193 let parser = RequirementsParser::new();
194 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
195
196 assert_eq!(deps.len(), 2);
197 assert_eq!(deps[0].name, "requests");
198 assert_eq!(deps[1].name, "celery");
199 }
200
201 #[test]
202 fn test_parse_with_comments() {
203 let mut file = NamedTempFile::new().unwrap();
204 writeln!(file, "# This is a comment").unwrap();
205 writeln!(file, "requests==2.28.0 # inline comment").unwrap();
206 writeln!(file, "").unwrap();
207 writeln!(file, "numpy>=1.24.0").unwrap();
208
209 let parser = RequirementsParser::new();
210 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
211
212 assert_eq!(deps.len(), 2);
213 assert_eq!(deps[0].name, "requests");
214 assert_eq!(deps[1].name, "numpy");
215 }
216
217 #[test]
218 fn test_parse_with_environment_markers() {
219 let mut file = NamedTempFile::new().unwrap();
220 writeln!(file, "dataclasses>=0.6; python_version < '3.7'").unwrap();
221 writeln!(file, "typing-extensions>=3.7; python_version >= '3.8'").unwrap();
222
223 let parser = RequirementsParser::new();
224 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
225
226 assert_eq!(deps.len(), 2);
227 assert_eq!(deps[0].name, "dataclasses");
228 assert_eq!(deps[1].name, "typing-extensions");
229 }
230
231 #[test]
232 fn test_parse_skip_directives() {
233 let mut file = NamedTempFile::new().unwrap();
234 writeln!(file, "--index-url https://pypi.org/simple").unwrap();
235 writeln!(file, "-r requirements-dev.txt").unwrap();
236 writeln!(file, "requests==2.28.0").unwrap();
237
238 let parser = RequirementsParser::new();
239 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
240
241 assert_eq!(deps.len(), 1);
242 assert_eq!(deps[0].name, "requests");
243 }
244
245 #[test]
246 fn test_parse_complex_version_specs() {
247 let mut file = NamedTempFile::new().unwrap();
248 writeln!(file, "django>=2.0,<3.0").unwrap();
249 writeln!(file, "pytest~=7.0").unwrap();
250 writeln!(file, "click!=8.0.0").unwrap();
251
252 let parser = RequirementsParser::new();
253 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
254
255 assert_eq!(deps.len(), 3);
256 assert_eq!(deps[0].name, "django");
257 assert!(matches!(deps[0].version_spec, VersionSpec::Range { .. }));
258 assert_eq!(deps[1].name, "pytest");
259 assert_eq!(deps[2].name, "click");
260 }
261
262 #[test]
263 fn test_line_numbers() {
264 let mut file = NamedTempFile::new().unwrap();
265 writeln!(file, "# Comment line").unwrap();
266 writeln!(file, "requests==2.28.0").unwrap();
267 writeln!(file, "").unwrap();
268 writeln!(file, "numpy>=1.24.0").unwrap();
269
270 let parser = RequirementsParser::new();
271 let deps = parser.parse(&file.path().to_path_buf()).unwrap();
272
273 assert_eq!(deps.len(), 2);
274 assert_eq!(deps[0].line_number, 2);
275 assert_eq!(deps[1].line_number, 4);
276 }
277
278 #[test]
279 fn test_can_parse() {
280 let parser = RequirementsParser::new();
281 assert!(parser.can_parse(&PathBuf::from("requirements.txt")));
282 assert!(parser.can_parse(&PathBuf::from("requirements-dev.txt")));
283 assert!(parser.can_parse(&PathBuf::from("requirements-test.txt")));
284 assert!(!parser.can_parse(&PathBuf::from("pyproject.toml")));
285 assert!(!parser.can_parse(&PathBuf::from("setup.py")));
286 }
287}