1use crate::models::{DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage};
25use crate::parser_warn as warn;
26use packageurl::PackageUrl;
27use std::collections::HashMap;
28use std::path::Path;
29
30use super::PackageParser;
31use super::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
32
33pub struct GradleLockfileParser;
37
38impl PackageParser for GradleLockfileParser {
39 const PACKAGE_TYPE: PackageType = PackageType::Maven;
40
41 fn is_match(path: &Path) -> bool {
42 path.file_name()
43 .and_then(|name| name.to_str())
44 .is_some_and(|name| name == "gradle.lockfile")
45 }
46
47 fn extract_packages(path: &Path) -> Vec<PackageData> {
48 let content = match read_file_to_string(path, None) {
49 Ok(c) => c,
50 Err(e) => {
51 warn!("Failed to read gradle.lockfile at {:?}: {}", path, e);
52 return vec![default_package_data()];
53 }
54 };
55
56 let dependencies = extract_dependencies(&content);
57
58 vec![PackageData {
59 package_type: Some(Self::PACKAGE_TYPE),
60 namespace: None,
61 name: None,
62 version: None,
63 qualifiers: None,
64 subpath: None,
65 primary_language: None,
66 description: None,
67 release_date: None,
68 parties: Vec::new(),
69 keywords: Vec::new(),
70 homepage_url: None,
71 download_url: None,
72 size: None,
73 sha1: None,
74 md5: None,
75 sha256: None,
76 sha512: None,
77 bug_tracking_url: None,
78 code_view_url: None,
79 vcs_url: None,
80 copyright: None,
81 holder: None,
82 declared_license_expression: None,
83 declared_license_expression_spdx: None,
84 license_detections: Vec::new(),
85 other_license_expression: None,
86 other_license_expression_spdx: None,
87 other_license_detections: Vec::new(),
88 extracted_license_statement: None,
89 notice_text: None,
90 source_packages: Vec::new(),
91 file_references: Vec::new(),
92 is_private: false,
93 is_virtual: false,
94 extra_data: None,
95 dependencies,
96 repository_homepage_url: None,
97 repository_download_url: None,
98 api_data_url: None,
99 datasource_id: Some(DatasourceId::GradleLockfile),
100 purl: None,
101 }]
102 }
103}
104
105fn extract_dependencies(content: &str) -> Vec<Dependency> {
107 let mut dependencies = Vec::new();
108
109 for line in content.lines().take(MAX_ITERATION_COUNT) {
110 let line = line.trim();
111
112 if line.is_empty() || line.starts_with('#') {
114 continue;
115 }
116
117 if let Some(dep) = parse_dependency_line(line) {
119 dependencies.push(dep);
120 }
121 }
122
123 dependencies
124}
125
126fn parse_dependency_line(line: &str) -> Option<Dependency> {
131 let (gav_part, configurations_part) = line.split_once('=')?;
133
134 if gav_part == "empty" {
135 return None;
136 }
137
138 let configurations: Vec<String> = configurations_part
139 .split(',')
140 .map(str::trim)
141 .filter(|value| !value.is_empty())
142 .map(|v| truncate_field(v.to_string()))
143 .collect();
144
145 let parts: Vec<&str> = gav_part.split(':').collect();
147 if parts.len() != 3 {
148 return None;
149 }
150
151 let group = truncate_field(parts[0].to_string());
152 let artifact = truncate_field(parts[1].to_string());
153 let version = truncate_field(parts[2].to_string());
154
155 let purl = PackageUrl::new("maven", &artifact).ok().and_then(|mut p| {
157 p.with_namespace(&group).ok()?;
158 p.with_version(&version).ok()?;
159 Some(truncate_field(p.to_string()))
160 });
161
162 let mut extra_data: Option<HashMap<String, serde_json::Value>> = None;
164 if !group.is_empty() || !artifact.is_empty() {
165 let mut map = HashMap::new();
166 if !group.is_empty() {
167 map.insert(
168 "group".to_string(),
169 serde_json::Value::String(group.clone()),
170 );
171 }
172 if !artifact.is_empty() {
173 map.insert(
174 "artifact".to_string(),
175 serde_json::Value::String(artifact.clone()),
176 );
177 }
178 if !configurations.is_empty() {
179 map.insert(
180 "configurations".to_string(),
181 serde_json::Value::Array(
182 configurations
183 .iter()
184 .cloned()
185 .map(serde_json::Value::String)
186 .collect(),
187 ),
188 );
189 }
190 extra_data = Some(map);
191 }
192
193 let resolved_package = ResolvedPackage {
195 primary_language: None,
196 download_url: None,
197 sha1: None,
198 sha256: None,
199 sha512: None,
200 md5: None,
201 is_virtual: false,
202 extra_data: None,
203 dependencies: Vec::new(),
204 repository_homepage_url: None,
205 repository_download_url: None,
206 api_data_url: None,
207 datasource_id: Some(DatasourceId::GradleLockfile),
208 purl: purl.clone(),
209 ..ResolvedPackage::new(PackageType::Maven, group, artifact, version)
210 };
211
212 Some(Dependency {
213 purl,
214 extracted_requirement: None,
215 scope: None,
216 is_pinned: Some(true),
217 is_direct: None,
218 is_optional: None,
219 is_runtime: None,
220 resolved_package: Some(Box::new(resolved_package)),
221 extra_data,
222 })
223}
224
225fn default_package_data() -> PackageData {
227 PackageData {
228 package_type: Some(GradleLockfileParser::PACKAGE_TYPE),
229 datasource_id: Some(DatasourceId::GradleLockfile),
230 ..Default::default()
231 }
232}
233
234#[cfg(test)]
235mod tests {
236 use super::*;
237
238 #[test]
239 fn test_is_match_gradle_lockfile() {
240 assert!(GradleLockfileParser::is_match(Path::new("gradle.lockfile")));
241 assert!(GradleLockfileParser::is_match(Path::new(
242 "/path/to/gradle.lockfile"
243 )));
244 }
245
246 #[test]
247 fn test_is_match_not_gradle_lockfile() {
248 assert!(!GradleLockfileParser::is_match(Path::new("package.json")));
249 assert!(!GradleLockfileParser::is_match(Path::new("Cargo.lock")));
250 assert!(!GradleLockfileParser::is_match(Path::new("gradle.lock")));
251 }
252
253 #[test]
254 fn test_parse_dependency_line_simple() {
255 let line = "com.example:my-lib:1.0.0=compileClasspath,runtimeClasspath";
256 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
257
258 assert_eq!(
259 dep.resolved_package.as_ref().unwrap().name,
260 "my-lib".to_string()
261 );
262 assert_eq!(
263 dep.resolved_package.as_ref().unwrap().version,
264 "1.0.0".to_string()
265 );
266 assert_eq!(
267 dep.resolved_package.as_ref().unwrap().namespace,
268 "com.example".to_string()
269 );
270 assert_eq!(
271 dep.resolved_package.as_ref().unwrap().package_type,
272 PackageType::Maven
273 );
274 }
275
276 #[test]
277 fn test_parse_dependency_line_complex_group() {
278 let line = "org.springframework.boot:spring-boot-starter-web:2.7.0=compileClasspath";
279 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
280
281 assert_eq!(
282 dep.resolved_package.as_ref().unwrap().name,
283 "spring-boot-starter-web".to_string()
284 );
285 assert_eq!(
286 dep.resolved_package.as_ref().unwrap().version,
287 "2.7.0".to_string()
288 );
289 assert_eq!(
290 dep.resolved_package.as_ref().unwrap().namespace,
291 "org.springframework.boot".to_string()
292 );
293 }
294
295 #[test]
296 fn test_parse_dependency_line_with_single_configuration() {
297 let line = "com.example:my-lib:1.0.0=runtimeClasspath";
298 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
299
300 assert_eq!(
301 dep.resolved_package.as_ref().unwrap().name,
302 "my-lib".to_string()
303 );
304 assert_eq!(
305 dep.resolved_package.as_ref().unwrap().version,
306 "1.0.0".to_string()
307 );
308 }
309
310 #[test]
311 fn test_parse_dependency_line_invalid_format() {
312 let line = "com.example:my-lib=abc123";
314 assert!(parse_dependency_line(line).is_none());
315
316 let line = "com.example:my-lib:1.0.0";
318 assert!(parse_dependency_line(line).is_none());
319 }
320
321 #[test]
322 fn test_extract_dependencies_multiple_lines() {
323 let content = "com.example:lib1:1.0.0=compileClasspath\ncom.example:lib2:2.0.0=runtimeClasspath\ncom.test:lib3:3.0.0=testRuntimeClasspath";
324 let deps = extract_dependencies(content);
325
326 assert_eq!(deps.len(), 3);
327 assert_eq!(deps[0].resolved_package.as_ref().unwrap().name, "lib1");
328 assert_eq!(deps[1].resolved_package.as_ref().unwrap().name, "lib2");
329 assert_eq!(deps[2].resolved_package.as_ref().unwrap().name, "lib3");
330 }
331
332 #[test]
333 fn test_extract_dependencies_with_comments_and_empty_lines() {
334 let content = "# This is a comment\ncom.example:lib1:1.0.0=compileClasspath\n\n# Another comment\ncom.example:lib2:2.0.0=runtimeClasspath\n";
335 let deps = extract_dependencies(content);
336
337 assert_eq!(deps.len(), 2);
338 assert_eq!(deps[0].resolved_package.as_ref().unwrap().name, "lib1");
339 assert_eq!(deps[1].resolved_package.as_ref().unwrap().name, "lib2");
340 }
341
342 #[test]
343 fn test_extract_dependencies_empty_file() {
344 let content = "";
345 let deps = extract_dependencies(content);
346
347 assert_eq!(deps.len(), 0);
348 }
349
350 #[test]
351 fn test_extract_dependencies_only_comments() {
352 let content = "# Comment 1\n# Comment 2\n# Comment 3";
353 let deps = extract_dependencies(content);
354
355 assert_eq!(deps.len(), 0);
356 }
357
358 #[test]
359 fn test_extract_first_package_returns_correct_package_type() {
360 let content = "com.example:lib:1.0.0=compileClasspath";
361 let deps = extract_dependencies(content);
362
363 assert!(!deps.is_empty());
364 assert_eq!(
365 deps[0].resolved_package.as_ref().unwrap().package_type,
366 PackageType::Maven
367 );
368 }
369
370 #[test]
371 fn test_parse_dependency_generates_purl() {
372 let line = "com.google.guava:guava:30.1-jre=runtimeClasspath";
373 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
374
375 assert!(dep.purl.is_some());
376 let purl = dep.purl.unwrap();
377 assert!(purl.contains("maven"));
378 assert!(purl.contains("guava"));
379 assert!(purl.contains("30.1-jre"));
380 }
381
382 #[test]
383 fn test_parse_dependency_extra_data_contains_group_and_artifact() {
384 let line =
385 "org.junit.jupiter:junit-jupiter-api:5.8.0=testRuntimeClasspath,compileClasspath";
386 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
387
388 assert!(dep.extra_data.is_some());
389 let extra = dep.extra_data.unwrap();
390 assert!(extra.contains_key("group"));
391 assert!(extra.contains_key("artifact"));
392 assert!(extra.contains_key("configurations"));
393 }
394
395 #[test]
396 fn test_extract_dependencies_malformed_lines_ignored() {
397 let content = "com.example:lib1:1.0.0=compileClasspath\ninvalid-line\ncom.example:lib2:2.0.0=runtimeClasspath";
398 let deps = extract_dependencies(content);
399
400 assert_eq!(deps.len(), 2);
402 assert_eq!(deps[0].resolved_package.as_ref().unwrap().name, "lib1");
403 assert_eq!(deps[1].resolved_package.as_ref().unwrap().name, "lib2");
404 }
405
406 #[test]
407 fn test_dependency_has_correct_flags() {
408 let line = "com.example:lib:1.0.0=compileClasspath";
409 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
410
411 assert_eq!(dep.is_pinned, Some(true));
412 assert_eq!(dep.is_optional, None);
413 assert_eq!(dep.is_runtime, None);
414 }
415
416 #[test]
417 fn test_parse_dependency_line_preserves_configurations_not_runtime_semantics() {
418 let line = "com.example:my-lib:1.0.0=compileClasspath,runtimeClasspath";
419 let dep = parse_dependency_line(line).expect("Failed to parse dependency");
420
421 assert_eq!(dep.is_runtime, None);
422 assert_eq!(dep.is_optional, None);
423 assert_eq!(dep.is_direct, None);
424
425 let extra = dep.extra_data.as_ref().expect("expected extra_data");
426 assert_eq!(
427 extra.get("configurations"),
428 Some(&serde_json::json!(["compileClasspath", "runtimeClasspath"]))
429 );
430 }
431
432 #[test]
433 fn test_parse_dependency_line_skips_empty_configuration_marker() {
434 assert!(parse_dependency_line("empty=annotationProcessor").is_none());
435 }
436}
437
438crate::register_parser!(
439 "Gradle lockfile",
440 &["**/gradle.lockfile"],
441 "maven",
442 "Java",
443 Some("https://docs.gradle.org/current/userguide/dependency_locking.html"),
444);