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