1use std::collections::HashMap;
22use std::path::Path;
23
24use log::warn;
25use packageurl::PackageUrl;
26use serde::Deserialize;
27
28use crate::models::{DatasourceId, PackageData, PackageType, Party};
29use crate::parsers::utils::read_file_to_string;
30
31use super::PackageParser;
32use super::license_normalization::{
33 DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_data,
34 combine_normalized_licenses, empty_declared_license_data, normalize_declared_license_key,
35};
36
37const PACKAGE_TYPE: PackageType = PackageType::Freebsd;
38
39fn default_package_data() -> PackageData {
40 PackageData {
41 package_type: Some(PACKAGE_TYPE),
42 datasource_id: Some(DatasourceId::FreebsdCompactManifest),
43 ..Default::default()
44 }
45}
46
47pub struct FreebsdCompactManifestParser;
49
50impl PackageParser for FreebsdCompactManifestParser {
51 const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
52
53 fn is_match(path: &Path) -> bool {
54 path.file_name()
55 .and_then(|name| name.to_str())
56 .map(|name| name == "+COMPACT_MANIFEST")
57 .unwrap_or(false)
58 }
59
60 fn extract_packages(path: &Path) -> Vec<PackageData> {
61 let content = match read_file_to_string(path) {
62 Ok(c) => c,
63 Err(e) => {
64 warn!("Failed to read FreeBSD manifest {:?}: {}", path, e);
65 return vec![default_package_data()];
66 }
67 };
68
69 vec![parse_freebsd_manifest(&content)]
70 }
71}
72
73#[derive(Debug, Deserialize)]
74struct FreebsdManifest {
75 name: Option<String>,
76 version: Option<String>,
77 #[serde(rename = "desc")]
78 description: Option<String>,
79 categories: Option<Vec<String>>,
80 www: Option<String>,
81 maintainer: Option<String>,
82 origin: Option<String>,
83 arch: Option<String>,
84 licenses: Option<Vec<String>>,
85 licenselogic: Option<String>,
86}
87
88pub(crate) fn parse_freebsd_manifest(content: &str) -> PackageData {
89 let manifest: FreebsdManifest = match serde_yaml::from_str(content) {
90 Ok(m) => m,
91 Err(e) => {
92 warn!("Failed to parse FreeBSD manifest: {}", e);
93 return default_package_data();
94 }
95 };
96
97 let name = manifest.name.clone();
98 let version = manifest.version.clone();
99 let description = manifest.description;
100 let homepage_url = manifest.www;
101 let keywords = manifest.categories.unwrap_or_default();
102
103 let mut qualifiers = HashMap::new();
105 if let Some(ref arch) = manifest.arch {
106 qualifiers.insert("arch".to_string(), arch.clone());
107 }
108 if let Some(ref origin) = manifest.origin {
109 qualifiers.insert("origin".to_string(), origin.clone());
110 }
111
112 let mut parties = Vec::new();
114 if let Some(maintainer_email) = manifest.maintainer {
115 parties.push(Party {
116 r#type: Some("person".to_string()),
117 role: Some("maintainer".to_string()),
118 name: None,
119 email: Some(maintainer_email),
120 url: None,
121 organization: None,
122 organization_url: None,
123 timezone: None,
124 });
125 }
126
127 let extracted_license_statement =
129 build_license_statement(&manifest.licenses, &manifest.licenselogic);
130 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
131 build_freebsd_license_data(
132 manifest.licenses.as_deref(),
133 manifest.licenselogic.as_deref(),
134 extracted_license_statement.as_deref(),
135 );
136
137 let code_view_url = manifest
139 .origin
140 .as_ref()
141 .map(|origin| format!("https://svnweb.freebsd.org/ports/head/{}", origin));
142
143 let download_url = if let (Some(arch), Some(pkg_name), Some(pkg_version)) =
145 (&manifest.arch, &name, &version)
146 {
147 Some(format!(
148 "https://pkg.freebsd.org/{}/latest/All/{}-{}.txz",
149 arch, pkg_name, pkg_version
150 ))
151 } else {
152 None
153 };
154
155 let purl = name.as_ref().and_then(|pkg_name| {
156 build_freebsd_purl(
157 pkg_name,
158 version.as_deref(),
159 manifest.arch.as_deref(),
160 manifest.origin.as_deref(),
161 )
162 });
163
164 PackageData {
165 datasource_id: Some(DatasourceId::FreebsdCompactManifest),
166 package_type: Some(PACKAGE_TYPE),
167 name,
168 version,
169 description,
170 homepage_url,
171 keywords,
172 parties,
173 qualifiers: if qualifiers.is_empty() {
174 None
175 } else {
176 Some(qualifiers)
177 },
178 declared_license_expression,
179 declared_license_expression_spdx,
180 license_detections,
181 extracted_license_statement,
182 code_view_url,
183 download_url,
184 purl,
185 ..Default::default()
186 }
187}
188
189pub(crate) fn build_freebsd_purl(
190 name: &str,
191 version: Option<&str>,
192 arch: Option<&str>,
193 origin: Option<&str>,
194) -> Option<String> {
195 let mut purl = PackageUrl::new(PACKAGE_TYPE.as_str(), name).ok()?;
196
197 if let Some(version) = version {
198 purl.with_version(version).ok()?;
199 }
200
201 if let Some(arch) = arch {
202 purl.add_qualifier("arch", arch).ok()?;
203 }
204
205 if let Some(origin) = origin {
206 purl.add_qualifier("origin", origin).ok()?;
207 }
208
209 Some(purl.to_string())
210}
211
212pub(crate) fn build_freebsd_license_data(
213 licenses: Option<&[String]>,
214 licenselogic: Option<&str>,
215 matched_text: Option<&str>,
216) -> (
217 Option<String>,
218 Option<String>,
219 Vec<crate::models::LicenseDetection>,
220) {
221 let Some(licenses) = licenses else {
222 return empty_declared_license_data();
223 };
224
225 let normalized: Vec<_> = licenses
226 .iter()
227 .filter_map(|license| normalize_freebsd_license_name(license))
228 .collect();
229
230 if normalized.is_empty() {
231 return empty_declared_license_data();
232 }
233
234 let combined = match licenselogic.unwrap_or("and") {
235 "single" => normalized.into_iter().next(),
236 "or" | "dual" => combine_normalized_licenses(normalized, " OR "),
237 _ => combine_normalized_licenses(normalized, " AND "),
238 };
239
240 let Some(combined) = combined else {
241 return empty_declared_license_data();
242 };
243
244 build_declared_license_data(
245 combined,
246 DeclaredLicenseMatchMetadata::single_line(matched_text.unwrap_or_default()),
247 )
248}
249
250fn normalize_freebsd_license_name(license: &str) -> Option<NormalizedDeclaredLicense> {
251 match license.trim() {
252 "GPLv2" => Some(NormalizedDeclaredLicense::new("gpl-2.0", "GPL-2.0-only")),
253 "GPLv3" => Some(NormalizedDeclaredLicense::new("gpl-3.0", "GPL-3.0-only")),
254 "BSD3CLAUSE" => Some(NormalizedDeclaredLicense::new("bsd-new", "BSD-3-Clause")),
255 "PSFL" => Some(NormalizedDeclaredLicense::new("psf-2.0", "PSF-2.0")),
256 "RUBY" => Some(NormalizedDeclaredLicense::new("ruby", "Ruby")),
257 other => normalize_declared_license_key(other),
258 }
259}
260
261pub(crate) fn build_license_statement(
269 licenses: &Option<Vec<String>>,
270 licenselogic: &Option<String>,
271) -> Option<String> {
272 let license_list = licenses.as_ref()?;
273
274 if license_list.is_empty() {
275 return None;
276 }
277
278 let filtered_licenses: Vec<String> = license_list
280 .iter()
281 .filter_map(|lic| {
282 let trimmed = lic.trim();
283 if trimmed.is_empty() {
284 None
285 } else {
286 Some(trimmed.to_string())
287 }
288 })
289 .collect();
290
291 if filtered_licenses.is_empty() {
292 return None;
293 }
294
295 let logic = licenselogic.as_deref().unwrap_or("and");
296
297 match logic {
298 "single" => Some(filtered_licenses[0].clone()),
299 "or" | "dual" => Some(filtered_licenses.join(" OR ")),
300 _ => Some(filtered_licenses.join(" AND ")), }
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307 use std::path::PathBuf;
308
309 #[test]
310 fn test_is_match() {
311 assert!(FreebsdCompactManifestParser::is_match(&PathBuf::from(
312 "/path/to/+COMPACT_MANIFEST"
313 )));
314 assert!(FreebsdCompactManifestParser::is_match(&PathBuf::from(
315 "+COMPACT_MANIFEST"
316 )));
317 assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
318 "+MANIFEST"
319 )));
320 assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
321 "COMPACT_MANIFEST"
322 )));
323 assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
324 "package.json"
325 )));
326 }
327
328 #[test]
329 fn test_build_license_statement_single() {
330 let licenses = Some(vec!["GPLv2".to_string()]);
331 let logic = Some("single".to_string());
332 let result = build_license_statement(&licenses, &logic);
333 assert_eq!(result, Some("GPLv2".to_string()));
334 }
335
336 #[test]
337 fn test_build_license_statement_and() {
338 let licenses = Some(vec!["MIT".to_string(), "BSD-2-Clause".to_string()]);
339 let logic = Some("and".to_string());
340 let result = build_license_statement(&licenses, &logic);
341 assert_eq!(result, Some("MIT AND BSD-2-Clause".to_string()));
342 }
343
344 #[test]
345 fn test_build_license_statement_or() {
346 let licenses = Some(vec!["MIT".to_string(), "Apache-2.0".to_string()]);
347 let logic = Some("or".to_string());
348 let result = build_license_statement(&licenses, &logic);
349 assert_eq!(result, Some("MIT OR Apache-2.0".to_string()));
350 }
351
352 #[test]
353 fn test_build_license_statement_dual() {
354 let licenses = Some(vec!["MIT".to_string(), "Apache-2.0".to_string()]);
355 let logic = Some("dual".to_string());
356 let result = build_license_statement(&licenses, &logic);
357 assert_eq!(result, Some("MIT OR Apache-2.0".to_string()));
358 }
359
360 #[test]
361 fn test_build_license_statement_default_and() {
362 let licenses = Some(vec!["MIT".to_string(), "BSD".to_string()]);
363 let logic = None;
364 let result = build_license_statement(&licenses, &logic);
365 assert_eq!(result, Some("MIT AND BSD".to_string()));
366 }
367
368 #[test]
369 fn test_build_license_statement_unknown_defaults_to_and() {
370 let licenses = Some(vec!["MIT".to_string(), "BSD".to_string()]);
371 let logic = Some("unknown".to_string());
372 let result = build_license_statement(&licenses, &logic);
373 assert_eq!(result, Some("MIT AND BSD".to_string()));
374 }
375
376 #[test]
377 fn test_build_license_statement_empty_licenses() {
378 let licenses = Some(vec![]);
379 let logic = Some("and".to_string());
380 let result = build_license_statement(&licenses, &logic);
381 assert_eq!(result, None);
382 }
383
384 #[test]
385 fn test_build_license_statement_no_licenses() {
386 let licenses = None;
387 let logic = Some("and".to_string());
388 let result = build_license_statement(&licenses, &logic);
389 assert_eq!(result, None);
390 }
391
392 #[test]
393 fn test_build_license_statement_filters_empty() {
394 let licenses = Some(vec!["MIT".to_string(), "".to_string(), " ".to_string()]);
395 let logic = Some("and".to_string());
396 let result = build_license_statement(&licenses, &logic);
397 assert_eq!(result, Some("MIT".to_string()));
398 }
399
400 #[test]
401 fn test_build_license_statement_trims_whitespace() {
402 let licenses = Some(vec![" MIT ".to_string(), " Apache-2.0 ".to_string()]);
403 let logic = Some("or".to_string());
404 let result = build_license_statement(&licenses, &logic);
405 assert_eq!(result, Some("MIT OR Apache-2.0".to_string()));
406 }
407}
408
409crate::register_parser!(
410 "FreeBSD +COMPACT_MANIFEST package manifest",
411 &["**/*COMPACT_MANIFEST"],
412 "freebsd",
413 "",
414 Some("https://man.freebsd.org/cgi/man.cgi?query=pkg-create"),
415);