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