1use std::collections::HashMap;
20use std::path::Path;
21
22use crate::parser_warn as warn;
23use crate::parsers::utils::{
24 CappedIterExt, capped_iteration_limit, read_file_to_string, truncate_field,
25};
26use serde_json::json;
27
28use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
29
30use super::PackageParser;
31use super::license_normalization::{
32 DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_data,
33 empty_declared_license_data, normalize_declared_license_key, normalize_spdx_expression,
34};
35
36const PACKAGE_TYPE: PackageType = PackageType::Cpan;
37
38pub struct CpanDistIniParser;
39
40impl PackageParser for CpanDistIniParser {
41 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
42
43 fn is_match(path: &Path) -> bool {
44 path.to_str().is_some_and(|p| p.ends_with("/dist.ini"))
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 dist.ini file {:?}: {}", path, e);
52 return vec![PackageData {
53 package_type: Some(PACKAGE_TYPE),
54 primary_language: Some("Perl".to_string()),
55 datasource_id: Some(DatasourceId::CpanDistIni),
56 ..Default::default()
57 }];
58 }
59 };
60
61 vec![parse_dist_ini(&content)]
62 }
63
64 fn metadata() -> Vec<super::metadata::ParserMetadata> {
65 vec![super::metadata::ParserMetadata {
66 description: "CPAN Perl dist.ini",
67 file_patterns: &["*/dist.ini"],
68 package_type: "cpan",
69 primary_language: "Perl",
70 documentation_url: Some("https://metacpan.org/pod/Dist::Zilla::Tutorial"),
71 }]
72 }
73}
74
75pub(crate) fn parse_dist_ini(content: &str) -> PackageData {
76 let (root_fields, sections) = parse_ini_structure(content);
77
78 let name = root_fields
79 .get("name")
80 .map(|s| truncate_field(s.replace('-', "::")));
81 let version = root_fields.get("version").cloned().map(truncate_field);
82 let description = root_fields.get("abstract").cloned().map(truncate_field);
83 let extracted_license_statement = root_fields.get("license").cloned().map(truncate_field);
84 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
85 extracted_license_statement
86 .as_deref()
87 .and_then(normalize_cpan_dist_ini_license)
88 .map(|normalized| {
89 build_declared_license_data(
90 normalized,
91 DeclaredLicenseMatchMetadata::single_line(
92 extracted_license_statement.as_deref().unwrap_or_default(),
93 ),
94 )
95 })
96 .unwrap_or_else(empty_declared_license_data);
97 let copyright_holder = root_fields
98 .get("copyright_holder")
99 .cloned()
100 .map(truncate_field);
101
102 let parties = parse_author(&root_fields);
103 let dependencies = parse_dependencies(§ions);
104
105 let mut extra_data = HashMap::new();
106 if let Some(holder) = copyright_holder {
107 extra_data.insert("copyright_holder".to_string(), json!(holder));
108 }
109 if let Some(year) = root_fields.get("copyright_year") {
110 extra_data.insert("copyright_year".to_string(), json!(year));
111 }
112
113 PackageData {
114 package_type: Some(PACKAGE_TYPE),
115 namespace: Some("cpan".to_string()),
116 name,
117 version,
118 description,
119 declared_license_expression,
120 declared_license_expression_spdx,
121 license_detections,
122 extracted_license_statement,
123 parties,
124 dependencies,
125 extra_data: if extra_data.is_empty() {
126 None
127 } else {
128 Some(extra_data)
129 },
130 datasource_id: Some(DatasourceId::CpanDistIni),
131 primary_language: Some("Perl".to_string()),
132 ..Default::default()
133 }
134}
135
136fn normalize_cpan_dist_ini_license(value: &str) -> Option<NormalizedDeclaredLicense> {
137 match value.trim() {
138 "Perl_5" => Some(NormalizedDeclaredLicense::new(
139 "gpl-1.0-plus OR artistic-perl-1.0",
140 "GPL-1.0-or-later OR Artistic-1.0-Perl",
141 )),
142 other => normalize_spdx_expression(other).or_else(|| normalize_declared_license_key(other)),
143 }
144}
145
146fn parse_ini_structure(
147 content: &str,
148) -> (
149 HashMap<String, String>,
150 HashMap<String, HashMap<String, String>>,
151) {
152 let mut root_fields = HashMap::new();
153 let mut sections: HashMap<String, HashMap<String, String>> = HashMap::new();
154 let mut current_section: Option<String> = None;
155
156 for line in content.lines().capped("dist.ini lines") {
157 let line = line.trim();
158
159 if line.is_empty() || line.starts_with(';') || line.starts_with('#') {
160 continue;
161 }
162
163 if line.starts_with('[') && line.ends_with(']') {
164 current_section = Some(line[1..line.len() - 1].to_string());
165 continue;
166 }
167
168 if let Some((key, value)) = line.split_once('=') {
169 let key = key.trim().to_string();
170 let value = truncate_field(value.trim().to_string());
171
172 if let Some(section_name) = ¤t_section {
173 sections
174 .entry(section_name.clone())
175 .or_default()
176 .insert(key, value);
177 } else {
178 root_fields.insert(key, value);
179 }
180 }
181 }
182
183 (root_fields, sections)
184}
185
186fn parse_author(fields: &HashMap<String, String>) -> Vec<Party> {
187 fields
188 .get("author")
189 .map(|author_str| {
190 if let Some((name, email)) = parse_author_string(author_str) {
191 vec![Party {
192 role: Some("author".to_string()),
193 name: Some(name),
194 email: Some(email),
195 r#type: None,
196 url: None,
197 organization: None,
198 organization_url: None,
199 timezone: None,
200 }]
201 } else {
202 vec![Party {
203 role: Some("author".to_string()),
204 name: Some(truncate_field(author_str.clone())),
205 r#type: None,
206 email: None,
207 url: None,
208 organization: None,
209 organization_url: None,
210 timezone: None,
211 }]
212 }
213 })
214 .unwrap_or_default()
215}
216
217fn parse_author_string(s: &str) -> Option<(String, String)> {
218 if let Some(start) = s.find('<')
219 && let Some(end) = s.find('>')
220 {
221 let name = truncate_field(s[..start].trim().to_string());
222 let email = truncate_field(s[start + 1..end].trim().to_string());
223 return Some((name, email));
224 }
225 None
226}
227
228fn parse_dependencies(sections: &HashMap<String, HashMap<String, String>>) -> Vec<Dependency> {
229 let mut dependencies = Vec::new();
230
231 let mut sorted_sections: Vec<_> = sections.iter().collect();
232 sorted_sections.sort_by_key(|(left_name, _)| *left_name);
233
234 let sections_limit = capped_iteration_limit(sorted_sections.len(), "dist.ini prereq sections");
235 for (section_name, fields) in sorted_sections.iter().take(sections_limit) {
236 let Some(scope) = classify_prereq_scope(section_name) else {
237 continue;
238 };
239
240 let mut sorted_fields: Vec<_> = fields.iter().collect();
241 sorted_fields.sort_by_key(|(left_name, _)| *left_name);
242
243 let fields_limit = capped_iteration_limit(sorted_fields.len(), "dist.ini prereq modules");
244 for (module_name, version_req) in sorted_fields.iter().take(fields_limit) {
245 let purl = truncate_field(format!(
246 "pkg:cpan/{}",
247 crate::parsers::cpan::cpan_distribution_name(module_name)
248 ));
249 let extracted_requirement = if version_req.as_str() == "0" || version_req.is_empty() {
250 None
251 } else {
252 Some(truncate_field(version_req.to_string()))
253 };
254
255 dependencies.push(Dependency {
256 purl: Some(purl),
257 scope: Some(scope.clone()),
258 extracted_requirement,
259 is_runtime: Some(scope == "runtime"),
260 is_optional: Some(false),
261 is_pinned: None,
262 is_direct: None,
263 resolved_package: None,
264 extra_data: None,
265 });
266 }
267 }
268
269 dependencies
270}
271
272fn classify_prereq_scope(section_name: &str) -> Option<String> {
273 if !section_name.starts_with("Prereq") {
274 return None;
275 }
276
277 if section_name.contains("TestRequires") || section_name.contains("Test") {
278 Some("test".to_string())
279 } else if section_name.contains("BuildRequires") || section_name.contains("Build") {
280 Some("build".to_string())
281 } else if section_name.contains("ConfigureRequires") || section_name.contains("Configure") {
282 Some("configure".to_string())
283 } else {
284 Some("runtime".to_string())
285 }
286}