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