Skip to main content

provenant/parsers/
vcpkg.rs

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