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