Skip to main content

provenant/parsers/
vcpkg.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4use std::collections::HashMap;
5use std::path::Path;
6
7use crate::parser_warn as warn;
8use packageurl::PackageUrl;
9use serde_json::Value;
10
11use crate::models::{DatasourceId, Dependency, PackageData, PackageType, Party};
12use crate::parsers::utils::{MAX_ITERATION_COUNT, split_name_email, truncate_field};
13
14use super::PackageParser;
15
16pub struct VcpkgManifestParser;
17
18impl PackageParser for VcpkgManifestParser {
19    const PACKAGE_TYPE: PackageType = PackageType::Vcpkg;
20
21    fn is_match(path: &Path) -> bool {
22        path.file_name().and_then(|name| name.to_str()) == Some("vcpkg.json")
23    }
24
25    fn extract_packages(path: &Path) -> Vec<PackageData> {
26        let content = match crate::parsers::utils::read_file_to_string(path, None) {
27            Ok(content) => content,
28            Err(e) => {
29                warn!("Failed to read vcpkg.json at {:?}: {}", path, e);
30                return vec![default_package_data()];
31            }
32        };
33
34        let json: Value = match serde_json::from_str(&content) {
35            Ok(json) => json,
36            Err(e) => {
37                warn!("Failed to parse vcpkg.json at {:?}: {}", path, e);
38                return vec![default_package_data()];
39            }
40        };
41
42        vec![parse_vcpkg_manifest(path, &json)]
43    }
44}
45
46fn default_package_data() -> PackageData {
47    PackageData {
48        package_type: Some(PackageType::Vcpkg),
49        datasource_id: Some(DatasourceId::VcpkgJson),
50        ..Default::default()
51    }
52}
53
54fn parse_vcpkg_manifest(path: &Path, json: &Value) -> PackageData {
55    let name = get_non_empty_string(json, "name").map(truncate_field);
56    let version = manifest_version(json).map(truncate_field);
57    let description = get_string_or_array(json, "description").map(truncate_field);
58    let homepage_url = get_non_empty_string(json, "homepage").map(truncate_field);
59    let extracted_license_statement = get_string_or_array(json, "license").map(truncate_field);
60    let parties = extract_maintainers(json);
61    let dependencies = extract_dependencies(json);
62    let extra_data = build_extra_data(path, json);
63
64    PackageData {
65        package_type: Some(PackageType::Vcpkg),
66        namespace: None,
67        name: name.clone(),
68        version: version.clone(),
69        primary_language: Some("C++".to_string()),
70        description,
71        parties,
72        homepage_url,
73        extracted_license_statement,
74        is_private: name.is_none(),
75        dependencies,
76        extra_data,
77        datasource_id: Some(DatasourceId::VcpkgJson),
78        purl: name
79            .as_deref()
80            .and_then(|name| build_vcpkg_purl(name, version.as_deref()))
81            .map(truncate_field),
82        ..default_package_data()
83    }
84}
85
86fn manifest_version(json: &Value) -> Option<String> {
87    let version = [
88        "version",
89        "version-semver",
90        "version-date",
91        "version-string",
92    ]
93    .into_iter()
94    .find_map(|field| get_non_empty_string(json, field));
95
96    match (version, json.get("port-version").and_then(Value::as_i64)) {
97        (Some(version), Some(port_version)) if port_version > 0 => {
98            Some(format!("{}#{}", version, port_version))
99        }
100        (version, _) => version,
101    }
102}
103
104fn extract_maintainers(json: &Value) -> Vec<Party> {
105    let Some(value) = json.get("maintainers") else {
106        return Vec::new();
107    };
108
109    let maintainers: Vec<String> = match value {
110        Value::String(s) => vec![s.clone()],
111        Value::Array(values) => values
112            .iter()
113            .take(MAX_ITERATION_COUNT)
114            .filter_map(Value::as_str)
115            .map(ToOwned::to_owned)
116            .collect(),
117        _ => Vec::new(),
118    };
119
120    maintainers
121        .into_iter()
122        .map(|entry| {
123            let (name, email) = split_name_email(&entry);
124            Party {
125                r#type: Some("person".to_string()),
126                role: Some("maintainer".to_string()),
127                name: name.map(truncate_field),
128                email: email.map(truncate_field),
129                url: None,
130                organization: None,
131                organization_url: None,
132                timezone: None,
133            }
134        })
135        .collect()
136}
137
138fn extract_dependencies(json: &Value) -> Vec<Dependency> {
139    let mut dependencies: Vec<Dependency> = json
140        .get("dependencies")
141        .and_then(Value::as_array)
142        .map(|deps| {
143            deps.iter()
144                .take(MAX_ITERATION_COUNT)
145                .filter_map(parse_dependency_entry)
146                .collect()
147        })
148        .unwrap_or_default();
149
150    if let Some(features) = json.get("features").and_then(Value::as_object) {
151        for (feature_name, feature_value) in features.iter().take(MAX_ITERATION_COUNT) {
152            let Some(feature_dependencies) =
153                feature_value.get("dependencies").and_then(Value::as_array)
154            else {
155                continue;
156            };
157
158            for dependency in feature_dependencies
159                .iter()
160                .take(MAX_ITERATION_COUNT)
161                .filter_map(parse_dependency_entry)
162                .map(|mut dependency| {
163                    let mut extra_data = dependency.extra_data.take().unwrap_or_default();
164                    extra_data.insert(
165                        "feature".to_string(),
166                        Value::String(feature_name.to_string()),
167                    );
168                    dependency.extra_data = Some(extra_data);
169                    dependency
170                })
171            {
172                dependencies.push(dependency);
173            }
174        }
175    }
176
177    dependencies
178}
179
180fn parse_dependency_entry(value: &Value) -> Option<Dependency> {
181    match value {
182        Value::String(name) => Some(Dependency {
183            purl: build_vcpkg_purl(name, None).map(truncate_field),
184            extracted_requirement: Some(truncate_field(name.clone())),
185            scope: Some("dependencies".to_string()),
186            is_runtime: Some(true),
187            is_optional: Some(false),
188            is_pinned: Some(false),
189            is_direct: Some(true),
190            resolved_package: None,
191            extra_data: None,
192        }),
193        Value::Object(obj) => {
194            let name = obj.get("name").and_then(Value::as_str)?.trim();
195            if name.is_empty() {
196                return None;
197            }
198
199            let extracted_requirement = obj
200                .get("version>=")
201                .and_then(Value::as_str)
202                .map(|v| truncate_field(v.to_owned()))
203                .or_else(|| Some(truncate_field(name.to_string())));
204
205            let host = obj.get("host").and_then(Value::as_bool).unwrap_or(false);
206            let mut extra = HashMap::new();
207            for field in [
208                "version>=",
209                "features",
210                "default-features",
211                "host",
212                "platform",
213            ] {
214                if let Some(field_value) = obj.get(field) {
215                    extra.insert(field.to_string(), field_value.clone());
216                }
217            }
218
219            Some(Dependency {
220                purl: build_vcpkg_purl(name, None).map(truncate_field),
221                extracted_requirement,
222                scope: Some("dependencies".to_string()),
223                is_runtime: Some(!host),
224                is_optional: Some(false),
225                is_pinned: Some(false),
226                is_direct: Some(true),
227                resolved_package: None,
228                extra_data: (!extra.is_empty()).then_some(extra),
229            })
230        }
231        _ => None,
232    }
233}
234
235fn build_extra_data(path: &Path, json: &Value) -> Option<HashMap<String, Value>> {
236    let mut extra = HashMap::new();
237    for field in [
238        "builtin-baseline",
239        "overrides",
240        "supports",
241        "default-features",
242        "features",
243        "configuration",
244        "vcpkg-configuration",
245        "documentation",
246    ] {
247        if let Some(value) = json.get(field) {
248            extra.insert(field.to_string(), value.clone());
249        }
250    }
251
252    if !extra.contains_key("configuration")
253        && !extra.contains_key("vcpkg-configuration")
254        && let Some(config) = read_sibling_configuration(path)
255    {
256        extra.insert("configuration".to_string(), config);
257    }
258
259    (!extra.is_empty()).then_some(extra)
260}
261
262fn read_sibling_configuration(path: &Path) -> Option<Value> {
263    let sibling_path = path.with_file_name("vcpkg-configuration.json");
264    let content = crate::parsers::utils::read_file_to_string(&sibling_path, None).ok()?;
265    match serde_json::from_str(&content) {
266        Ok(value) => Some(value),
267        Err(e) => {
268            warn!(
269                "Failed to parse sibling vcpkg-configuration.json at {:?}: {}",
270                sibling_path, e
271            );
272            None
273        }
274    }
275}
276
277fn get_non_empty_string(json: &Value, field: &str) -> Option<String> {
278    json.get(field)
279        .and_then(Value::as_str)
280        .map(str::trim)
281        .filter(|value| !value.is_empty())
282        .map(|value| value.to_string())
283}
284
285fn get_string_or_array(json: &Value, field: &str) -> Option<String> {
286    match json.get(field) {
287        Some(Value::String(s)) if !s.trim().is_empty() => Some(s.trim().to_string()),
288        Some(Value::Array(values)) => {
289            let collected: Vec<_> = values
290                .iter()
291                .filter_map(Value::as_str)
292                .map(str::trim)
293                .filter(|s| !s.is_empty())
294                .collect();
295            (!collected.is_empty()).then(|| collected.join("\n"))
296        }
297        _ => None,
298    }
299}
300
301fn build_vcpkg_purl(name: &str, version: Option<&str>) -> Option<String> {
302    let mut purl = PackageUrl::new("generic", name).ok()?;
303    purl.with_namespace("vcpkg").ok()?;
304    if let Some(version) = version {
305        purl.with_version(version).ok()?;
306    }
307    Some(purl.to_string())
308}
309
310crate::register_parser!(
311    "vcpkg manifest file",
312    &["**/vcpkg.json"],
313    "vcpkg",
314    "",
315    Some("https://learn.microsoft.com/en-us/vcpkg/reference/vcpkg-json"),
316);