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