Skip to main content

normalize_manifest/
gradle_libs.rs

1//! Parser for `gradle/libs.versions.toml` files (Gradle version catalog).
2
3use crate::{DeclaredDep, DepKind, ManifestError, ManifestParser, ParsedManifest};
4use std::collections::HashMap;
5use toml::Value;
6
7/// Parser for `gradle/libs.versions.toml` (Gradle version catalog).
8///
9/// Parses `[libraries]` entries and resolves `version.ref` from `[versions]`.
10/// Module format is `group:artifact` — used as the dependency name.
11/// `[bundles]` and `[plugins]` are skipped.
12pub struct GradleLibsParser;
13
14impl ManifestParser for GradleLibsParser {
15    fn filename(&self) -> &'static str {
16        "libs.versions.toml"
17    }
18
19    fn parse(&self, content: &str) -> Result<ParsedManifest, ManifestError> {
20        let toml: Value = content
21            .parse::<Value>()
22            .map_err(|e| ManifestError(e.to_string()))?;
23
24        // Build versions map: alias -> version string
25        let versions: HashMap<String, String> = toml
26            .get("versions")
27            .and_then(|v| v.as_table())
28            .map(|t| {
29                t.iter()
30                    .filter_map(|(k, v)| v.as_str().map(|s| (k.clone(), s.to_string())))
31                    .collect()
32            })
33            .unwrap_or_default();
34
35        let mut deps = Vec::new();
36
37        if let Some(libraries) = toml.get("libraries").and_then(|v| v.as_table()) {
38            for (_alias, entry) in libraries {
39                let Some(table) = entry.as_table() else {
40                    continue;
41                };
42
43                // module = "group:artifact"
44                let Some(module) = table.get("module").and_then(|v| v.as_str()) else {
45                    continue;
46                };
47
48                // Resolve version: version.ref -> versions table, or version = "..."
49                let version_req = if let Some(vref) = table
50                    .get("version")
51                    .and_then(|v| v.as_table())
52                    .and_then(|t| t.get("ref"))
53                    .and_then(|v| v.as_str())
54                {
55                    versions.get(vref).cloned()
56                } else {
57                    table
58                        .get("version")
59                        .and_then(|v| v.as_str())
60                        .map(|s| s.to_string())
61                };
62
63                deps.push(DeclaredDep {
64                    name: module.to_string(),
65                    version_req,
66                    kind: DepKind::Normal,
67                });
68            }
69        }
70
71        Ok(ParsedManifest {
72            ecosystem: "gradle",
73            name: None,
74            version: None,
75            dependencies: deps,
76        })
77    }
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83    use crate::ManifestParser;
84
85    #[test]
86    fn test_gradle_libs_version_catalog() {
87        let content = r#"
88[versions]
89junit = "4.13.2"
90retrofit = "2.9.0"
91
92[libraries]
93junit = { module = "junit:junit", version.ref = "junit" }
94retrofit = { module = "com.squareup.retrofit2:retrofit", version.ref = "retrofit" }
95retrofit-gson = { module = "com.squareup.retrofit2:converter-gson", version = "2.9.0" }
96
97[bundles]
98retrofit = ["retrofit", "retrofit-gson"]
99"#;
100        let m = GradleLibsParser.parse(content).unwrap();
101        assert_eq!(m.ecosystem, "gradle");
102        assert_eq!(m.dependencies.len(), 3);
103
104        let junit = m
105            .dependencies
106            .iter()
107            .find(|d| d.name == "junit:junit")
108            .unwrap();
109        assert_eq!(junit.version_req.as_deref(), Some("4.13.2"));
110        assert_eq!(junit.kind, DepKind::Normal);
111
112        let retrofit = m
113            .dependencies
114            .iter()
115            .find(|d| d.name == "com.squareup.retrofit2:retrofit")
116            .unwrap();
117        assert_eq!(retrofit.version_req.as_deref(), Some("2.9.0"));
118
119        let gson = m
120            .dependencies
121            .iter()
122            .find(|d| d.name == "com.squareup.retrofit2:converter-gson")
123            .unwrap();
124        assert_eq!(gson.version_req.as_deref(), Some("2.9.0"));
125    }
126
127    #[test]
128    fn test_gradle_libs_no_versions_section() {
129        let content = r#"
130[libraries]
131mylib = { module = "com.example:mylib", version = "1.0.0" }
132"#;
133        let m = GradleLibsParser.parse(content).unwrap();
134        assert_eq!(m.dependencies.len(), 1);
135        assert_eq!(m.dependencies[0].name, "com.example:mylib");
136        assert_eq!(m.dependencies[0].version_req.as_deref(), Some("1.0.0"));
137    }
138
139    #[test]
140    fn test_gradle_libs_unresolvable_ref_gives_none() {
141        let content = r#"
142[versions]
143# intentionally empty
144
145[libraries]
146orphan = { module = "com.example:orphan", version.ref = "nonexistent" }
147"#;
148        let m = GradleLibsParser.parse(content).unwrap();
149        assert_eq!(m.dependencies.len(), 1);
150        assert!(m.dependencies[0].version_req.is_none());
151    }
152}