Skip to main content

provenant/parsers/
freebsd.rs

1//! Parser for FreeBSD package manifest files.
2//!
3//! Extracts package metadata from FreeBSD compact manifest files (+COMPACT_MANIFEST)
4//! which are JSON/YAML format files containing package information.
5//!
6//! # Supported Formats
7//! - `+COMPACT_MANIFEST` (JSON/YAML format)
8//!
9//! # Key Features
10//! - Package metadata extraction (name, version, description, etc.)
11//! - Complex license logic handling (single/and/or/dual)
12//! - URL construction from origin and architecture fields
13//! - Qualifier extraction (arch, origin)
14//! - Maintainer information parsing
15//!
16//! # Implementation Notes
17//! - Uses `serde_yaml` for parsing (handles both JSON and YAML)
18//! - Implements FreeBSD-specific license logic combining
19//! - Graceful error handling with `warn!()` logs
20
21use std::collections::HashMap;
22use std::path::Path;
23
24use log::warn;
25use serde::Deserialize;
26
27use crate::models::{DatasourceId, PackageData, PackageType, Party};
28use crate::parsers::utils::read_file_to_string;
29
30use super::PackageParser;
31
32const PACKAGE_TYPE: PackageType = PackageType::Freebsd;
33
34fn default_package_data() -> PackageData {
35    PackageData {
36        package_type: Some(PACKAGE_TYPE),
37        datasource_id: Some(DatasourceId::FreebsdCompactManifest),
38        ..Default::default()
39    }
40}
41
42/// Parser for FreeBSD +COMPACT_MANIFEST files
43pub struct FreebsdCompactManifestParser;
44
45impl PackageParser for FreebsdCompactManifestParser {
46    const PACKAGE_TYPE: PackageType = PACKAGE_TYPE;
47
48    fn is_match(path: &Path) -> bool {
49        path.file_name()
50            .and_then(|name| name.to_str())
51            .map(|name| name == "+COMPACT_MANIFEST")
52            .unwrap_or(false)
53    }
54
55    fn extract_packages(path: &Path) -> Vec<PackageData> {
56        let content = match read_file_to_string(path) {
57            Ok(c) => c,
58            Err(e) => {
59                warn!("Failed to read FreeBSD manifest {:?}: {}", path, e);
60                return vec![default_package_data()];
61            }
62        };
63
64        vec![parse_freebsd_manifest(&content)]
65    }
66}
67
68#[derive(Debug, Deserialize)]
69struct FreebsdManifest {
70    name: Option<String>,
71    version: Option<String>,
72    #[serde(rename = "desc")]
73    description: Option<String>,
74    categories: Option<Vec<String>>,
75    www: Option<String>,
76    maintainer: Option<String>,
77    origin: Option<String>,
78    arch: Option<String>,
79    licenses: Option<Vec<String>>,
80    licenselogic: Option<String>,
81}
82
83pub(crate) fn parse_freebsd_manifest(content: &str) -> PackageData {
84    let manifest: FreebsdManifest = match serde_yaml::from_str(content) {
85        Ok(m) => m,
86        Err(e) => {
87            warn!("Failed to parse FreeBSD manifest: {}", e);
88            return default_package_data();
89        }
90    };
91
92    let name = manifest.name.clone();
93    let version = manifest.version.clone();
94    let description = manifest.description;
95    let homepage_url = manifest.www;
96    let keywords = manifest.categories.unwrap_or_default();
97
98    // Build qualifiers from arch and origin
99    let mut qualifiers = HashMap::new();
100    if let Some(ref arch) = manifest.arch {
101        qualifiers.insert("arch".to_string(), arch.clone());
102    }
103    if let Some(ref origin) = manifest.origin {
104        qualifiers.insert("origin".to_string(), origin.clone());
105    }
106
107    // Build parties from maintainer (just an email address)
108    let mut parties = Vec::new();
109    if let Some(maintainer_email) = manifest.maintainer {
110        parties.push(Party {
111            r#type: Some("person".to_string()),
112            role: Some("maintainer".to_string()),
113            name: None,
114            email: Some(maintainer_email),
115            url: None,
116            organization: None,
117            organization_url: None,
118            timezone: None,
119        });
120    }
121
122    // Build extracted_license_statement from licenses and licenselogic
123    let extracted_license_statement =
124        build_license_statement(&manifest.licenses, &manifest.licenselogic);
125
126    // Build code_view_url from origin
127    let code_view_url = manifest
128        .origin
129        .as_ref()
130        .map(|origin| format!("https://svnweb.freebsd.org/ports/head/{}", origin));
131
132    // Build download_url from arch, name, and version
133    let download_url = if let (Some(arch), Some(pkg_name), Some(pkg_version)) =
134        (&manifest.arch, &name, &version)
135    {
136        Some(format!(
137            "https://pkg.freebsd.org/{}/latest/All/{}-{}.txz",
138            arch, pkg_name, pkg_version
139        ))
140    } else {
141        None
142    };
143
144    PackageData {
145        datasource_id: Some(DatasourceId::FreebsdCompactManifest),
146        package_type: Some(PACKAGE_TYPE),
147        name,
148        version,
149        description,
150        homepage_url,
151        keywords,
152        parties,
153        qualifiers: if qualifiers.is_empty() {
154            None
155        } else {
156            Some(qualifiers)
157        },
158        extracted_license_statement,
159        code_view_url,
160        download_url,
161        ..Default::default()
162    }
163}
164
165/// Builds the extracted_license_statement string from licenses and licenselogic.
166///
167/// # Logic:
168/// - `licenselogic: "single"` → single license string (just the first license)
169/// - `licenselogic: "and"` → join licenses with " AND "
170/// - `licenselogic: "or"` or `"dual"` → join licenses with " OR "
171/// - If `licenselogic` is missing or unknown → join with " AND " (default)
172pub(crate) fn build_license_statement(
173    licenses: &Option<Vec<String>>,
174    licenselogic: &Option<String>,
175) -> Option<String> {
176    let license_list = licenses.as_ref()?;
177
178    if license_list.is_empty() {
179        return None;
180    }
181
182    // Filter out empty licenses and trim whitespace
183    let filtered_licenses: Vec<String> = license_list
184        .iter()
185        .filter_map(|lic| {
186            let trimmed = lic.trim();
187            if trimmed.is_empty() {
188                None
189            } else {
190                Some(trimmed.to_string())
191            }
192        })
193        .collect();
194
195    if filtered_licenses.is_empty() {
196        return None;
197    }
198
199    let logic = licenselogic.as_deref().unwrap_or("and");
200
201    match logic {
202        "single" => Some(filtered_licenses[0].clone()),
203        "or" | "dual" => Some(filtered_licenses.join(" OR ")),
204        _ => Some(filtered_licenses.join(" AND ")), // "and" or unknown defaults to AND
205    }
206}
207
208#[cfg(test)]
209mod tests {
210    use super::*;
211    use std::path::PathBuf;
212
213    #[test]
214    fn test_is_match() {
215        assert!(FreebsdCompactManifestParser::is_match(&PathBuf::from(
216            "/path/to/+COMPACT_MANIFEST"
217        )));
218        assert!(FreebsdCompactManifestParser::is_match(&PathBuf::from(
219            "+COMPACT_MANIFEST"
220        )));
221        assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
222            "+MANIFEST"
223        )));
224        assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
225            "COMPACT_MANIFEST"
226        )));
227        assert!(!FreebsdCompactManifestParser::is_match(&PathBuf::from(
228            "package.json"
229        )));
230    }
231
232    #[test]
233    fn test_build_license_statement_single() {
234        let licenses = Some(vec!["GPLv2".to_string()]);
235        let logic = Some("single".to_string());
236        let result = build_license_statement(&licenses, &logic);
237        assert_eq!(result, Some("GPLv2".to_string()));
238    }
239
240    #[test]
241    fn test_build_license_statement_and() {
242        let licenses = Some(vec!["MIT".to_string(), "BSD-2-Clause".to_string()]);
243        let logic = Some("and".to_string());
244        let result = build_license_statement(&licenses, &logic);
245        assert_eq!(result, Some("MIT AND BSD-2-Clause".to_string()));
246    }
247
248    #[test]
249    fn test_build_license_statement_or() {
250        let licenses = Some(vec!["MIT".to_string(), "Apache-2.0".to_string()]);
251        let logic = Some("or".to_string());
252        let result = build_license_statement(&licenses, &logic);
253        assert_eq!(result, Some("MIT OR Apache-2.0".to_string()));
254    }
255
256    #[test]
257    fn test_build_license_statement_dual() {
258        let licenses = Some(vec!["MIT".to_string(), "Apache-2.0".to_string()]);
259        let logic = Some("dual".to_string());
260        let result = build_license_statement(&licenses, &logic);
261        assert_eq!(result, Some("MIT OR Apache-2.0".to_string()));
262    }
263
264    #[test]
265    fn test_build_license_statement_default_and() {
266        let licenses = Some(vec!["MIT".to_string(), "BSD".to_string()]);
267        let logic = None;
268        let result = build_license_statement(&licenses, &logic);
269        assert_eq!(result, Some("MIT AND BSD".to_string()));
270    }
271
272    #[test]
273    fn test_build_license_statement_unknown_defaults_to_and() {
274        let licenses = Some(vec!["MIT".to_string(), "BSD".to_string()]);
275        let logic = Some("unknown".to_string());
276        let result = build_license_statement(&licenses, &logic);
277        assert_eq!(result, Some("MIT AND BSD".to_string()));
278    }
279
280    #[test]
281    fn test_build_license_statement_empty_licenses() {
282        let licenses = Some(vec![]);
283        let logic = Some("and".to_string());
284        let result = build_license_statement(&licenses, &logic);
285        assert_eq!(result, None);
286    }
287
288    #[test]
289    fn test_build_license_statement_no_licenses() {
290        let licenses = None;
291        let logic = Some("and".to_string());
292        let result = build_license_statement(&licenses, &logic);
293        assert_eq!(result, None);
294    }
295
296    #[test]
297    fn test_build_license_statement_filters_empty() {
298        let licenses = Some(vec!["MIT".to_string(), "".to_string(), "  ".to_string()]);
299        let logic = Some("and".to_string());
300        let result = build_license_statement(&licenses, &logic);
301        assert_eq!(result, Some("MIT".to_string()));
302    }
303
304    #[test]
305    fn test_build_license_statement_trims_whitespace() {
306        let licenses = Some(vec!["  MIT  ".to_string(), " Apache-2.0 ".to_string()]);
307        let logic = Some("or".to_string());
308        let result = build_license_statement(&licenses, &logic);
309        assert_eq!(result, Some("MIT OR Apache-2.0".to_string()));
310    }
311}
312
313crate::register_parser!(
314    "FreeBSD +COMPACT_MANIFEST package manifest",
315    &["**/*COMPACT_MANIFEST"],
316    "freebsd",
317    "",
318    Some("https://man.freebsd.org/cgi/man.cgi?query=pkg-create"),
319);