1use std::path::Path;
28
29use crate::parser_warn as warn;
30use packageurl::PackageUrl;
31use serde_json::Value as JsonValue;
32use yaml_serde::Value as YamlValue;
33
34use crate::models::{
35 DatasourceId, Dependency, FileReference, PackageData, PackageType, Party, PartyType,
36};
37use crate::parsers::utils::{MAX_ITERATION_COUNT, read_file_to_string, truncate_field};
38
39use super::PackageParser;
40use super::license_normalization::{
41 DeclaredLicenseMatchMetadata, NormalizedDeclaredLicense, build_declared_license_data,
42 combine_normalized_licenses, empty_declared_license_data, normalize_declared_license_key,
43 normalize_spdx_expression,
44};
45
46const FIELD_NAME: &str = "name";
47const FIELD_VERSION: &str = "version";
48const FIELD_ABSTRACT: &str = "abstract";
49const FIELD_DESCRIPTION: &str = "description";
50const FIELD_LICENSE: &str = "license";
51const FIELD_AUTHOR: &str = "author";
52const FIELD_RESOURCES: &str = "resources";
53const FIELD_PREREQS: &str = "prereqs";
54const FIELD_REQUIRES: &str = "requires";
55const FIELD_BUILD_REQUIRES: &str = "build_requires";
56const FIELD_TEST_REQUIRES: &str = "test_requires";
57const FIELD_CONFIGURE_REQUIRES: &str = "configure_requires";
58
59pub(crate) fn cpan_distribution_name(module: &str) -> String {
72 module.replace("::", "-")
73}
74
75pub struct CpanMetaJsonParser;
76
77impl PackageParser for CpanMetaJsonParser {
78 const PACKAGE_TYPE: PackageType = PackageType::Cpan;
79
80 fn is_match(path: &Path) -> bool {
81 path.file_name().is_some_and(|name| name == "META.json")
82 }
83
84 fn extract_packages(path: &Path) -> Vec<PackageData> {
85 let json = match read_and_parse_json(path) {
86 Ok(json) => json,
87 Err(e) => {
88 warn!("Failed to parse META.json at {:?}: {}", path, e);
89 return vec![default_package_data(DatasourceId::CpanMetaJson)];
90 }
91 };
92
93 let name = json
94 .get(FIELD_NAME)
95 .and_then(|v| v.as_str())
96 .map(|s| truncate_field(s.to_string()));
97
98 let version = extract_version_from_json(&json);
99
100 let description = json
101 .get(FIELD_ABSTRACT)
102 .and_then(|v| v.as_str())
103 .map(|s| truncate_field(s.to_string()));
104
105 let extracted_license_statement = extract_license_from_json(&json);
106 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
107 normalize_cpan_declared_license(
108 json.get(FIELD_LICENSE),
109 extracted_license_statement.as_deref(),
110 );
111 let declared_license_expression = declared_license_expression.map(truncate_field);
112 let declared_license_expression_spdx = declared_license_expression_spdx.map(truncate_field);
113 let parties = extract_parties_from_json(&json);
114 let dependencies = extract_dependencies_from_json(&json);
115 let (homepage_url, vcs_url, code_view_url, bug_tracking_url) =
116 extract_resources_from_json(&json);
117
118 vec![PackageData {
119 package_type: Some(Self::PACKAGE_TYPE),
120 name,
121 version,
122 description,
123 declared_license_expression,
124 declared_license_expression_spdx,
125 license_detections,
126 extracted_license_statement,
127 parties,
128 dependencies,
129 homepage_url,
130 vcs_url,
131 code_view_url,
132 bug_tracking_url,
133 primary_language: Some("Perl".to_string()),
134 datasource_id: Some(DatasourceId::CpanMetaJson),
135 ..Default::default()
136 }]
137 }
138
139 fn metadata() -> Vec<super::metadata::ParserMetadata> {
140 vec![super::metadata::ParserMetadata {
141 description: "CPAN Perl META.json",
142 file_patterns: &["**/META.json"],
143 package_type: "cpan",
144 primary_language: "Perl",
145 documentation_url: Some("https://metacpan.org/pod/CPAN::Meta::Spec"),
146 }]
147 }
148}
149
150pub struct CpanMetaYmlParser;
154
155impl PackageParser for CpanMetaYmlParser {
156 const PACKAGE_TYPE: PackageType = PackageType::Cpan;
157
158 fn is_match(path: &Path) -> bool {
159 path.file_name().is_some_and(|name| name == "META.yml")
160 }
161
162 fn extract_packages(path: &Path) -> Vec<PackageData> {
163 let yaml = match read_and_parse_yaml(path) {
164 Ok(yaml) => yaml,
165 Err(e) => {
166 warn!("Failed to parse META.yml at {:?}: {}", path, e);
167 return vec![default_package_data(DatasourceId::CpanMetaYml)];
168 }
169 };
170
171 let name = yaml
172 .get(FIELD_NAME)
173 .and_then(|v| v.as_str())
174 .map(|s| truncate_field(s.to_string()));
175
176 let version = extract_version_from_yaml(&yaml);
177
178 let description = yaml
179 .get(FIELD_ABSTRACT)
180 .or_else(|| yaml.get(FIELD_DESCRIPTION))
181 .and_then(|v| v.as_str())
182 .map(|s| truncate_field(s.to_string()));
183
184 let extracted_license_statement = extract_license_from_yaml(&yaml);
185 let (declared_license_expression, declared_license_expression_spdx, license_detections) =
186 normalize_cpan_declared_license(
187 yaml.get(YamlValue::String(FIELD_LICENSE.to_string())),
188 extracted_license_statement.as_deref(),
189 );
190 let declared_license_expression = declared_license_expression.map(truncate_field);
191 let declared_license_expression_spdx = declared_license_expression_spdx.map(truncate_field);
192 let parties = extract_parties_from_yaml(&yaml);
193 let dependencies = extract_dependencies_from_yaml(&yaml);
194 let (homepage_url, vcs_url, bug_tracking_url) = extract_resources_from_yaml(&yaml);
195
196 vec![PackageData {
197 package_type: Some(Self::PACKAGE_TYPE),
198 name,
199 version,
200 description,
201 declared_license_expression,
202 declared_license_expression_spdx,
203 license_detections,
204 extracted_license_statement,
205 parties,
206 dependencies,
207 homepage_url,
208 vcs_url,
209 bug_tracking_url,
210 primary_language: Some("Perl".to_string()),
211 datasource_id: Some(DatasourceId::CpanMetaYml),
212 ..Default::default()
213 }]
214 }
215
216 fn metadata() -> Vec<super::metadata::ParserMetadata> {
217 vec![super::metadata::ParserMetadata {
218 description: "CPAN Perl META.yml",
219 file_patterns: &["**/META.yml"],
220 package_type: "cpan",
221 primary_language: "Perl",
222 documentation_url: Some("https://metacpan.org/pod/CPAN::Meta::Spec"),
223 }]
224 }
225}
226
227pub struct CpanManifestParser;
231
232impl PackageParser for CpanManifestParser {
233 const PACKAGE_TYPE: PackageType = PackageType::Cpan;
234
235 fn is_match(path: &Path) -> bool {
236 path.file_name().is_some_and(|name| name == "MANIFEST")
237 }
238
239 fn extract_packages(path: &Path) -> Vec<PackageData> {
240 let content = match read_file_to_string(path, None) {
241 Ok(content) => content,
242 Err(e) => {
243 warn!("Failed to read MANIFEST at {:?}: {}", path, e);
244 return vec![default_package_data(DatasourceId::CpanManifest)];
245 }
246 };
247
248 let file_references = content
249 .lines()
250 .take(MAX_ITERATION_COUNT)
251 .filter(|line| !line.trim().is_empty())
252 .filter(|line| !line.trim().starts_with('#'))
253 .map(|line| {
254 let path = line.split_whitespace().next().unwrap_or(line);
255 FileReference {
256 path: truncate_field(path.to_string()),
257 size: None,
258 sha1: None,
259 md5: None,
260 sha256: None,
261 sha512: None,
262 extra_data: None,
263 }
264 })
265 .collect();
266
267 vec![PackageData {
268 package_type: Some(Self::PACKAGE_TYPE),
269 file_references,
270 primary_language: Some("Perl".to_string()),
271 datasource_id: Some(DatasourceId::CpanManifest),
272 ..Default::default()
273 }]
274 }
275
276 fn metadata() -> Vec<super::metadata::ParserMetadata> {
277 vec![super::metadata::ParserMetadata {
278 description: "CPAN Perl MANIFEST",
279 file_patterns: &["**/MANIFEST"],
280 package_type: "cpan",
281 primary_language: "Perl",
282 documentation_url: Some("https://metacpan.org/pod/Module::Manifest"),
283 }]
284 }
285}
286
287fn default_package_data(datasource_id: DatasourceId) -> PackageData {
288 PackageData {
289 package_type: Some(CpanMetaJsonParser::PACKAGE_TYPE),
290 primary_language: Some("Perl".to_string()),
291 datasource_id: Some(datasource_id),
292 ..Default::default()
293 }
294}
295
296fn read_and_parse_json(path: &Path) -> Result<serde_json::Map<String, JsonValue>, String> {
297 let content =
298 read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
299 let json: JsonValue =
300 serde_json::from_str(&content).map_err(|e| format!("Failed to parse JSON: {}", e))?;
301 json.as_object()
302 .cloned()
303 .ok_or_else(|| "Root JSON is not an object".to_string())
304}
305
306fn read_and_parse_yaml(path: &Path) -> Result<yaml_serde::Mapping, String> {
307 let content =
308 read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
309 let yaml: YamlValue =
310 yaml_serde::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))?;
311 yaml.as_mapping()
312 .cloned()
313 .ok_or_else(|| "Root YAML is not a mapping".to_string())
314}
315
316fn extract_version_from_json(json: &serde_json::Map<String, JsonValue>) -> Option<String> {
317 json.get(FIELD_VERSION).and_then(|v| match v {
318 JsonValue::String(s) => Some(truncate_field(s.clone())),
319 JsonValue::Number(n) => Some(truncate_field(n.to_string())),
320 _ => None,
321 })
322}
323
324fn extract_version_from_yaml(yaml: &yaml_serde::Mapping) -> Option<String> {
325 yaml.get(YamlValue::String(FIELD_VERSION.to_string()))
326 .and_then(|v| match v {
327 YamlValue::String(s) => Some(truncate_field(s.clone())),
328 YamlValue::Number(n) => Some(truncate_field(n.to_string())),
329 _ => None,
330 })
331}
332
333fn extract_license_from_json(json: &serde_json::Map<String, JsonValue>) -> Option<String> {
334 json.get(FIELD_LICENSE).and_then(|v| match v {
335 JsonValue::String(s) => Some(truncate_field(s.clone())),
336 JsonValue::Array(arr) => {
337 let licenses: Vec<String> = arr
338 .iter()
339 .take(MAX_ITERATION_COUNT)
340 .filter_map(|item| item.as_str().map(|s| truncate_field(s.to_string())))
341 .collect();
342 if licenses.is_empty() {
343 None
344 } else {
345 Some(truncate_field(licenses.join(" AND ")))
346 }
347 }
348 _ => None,
349 })
350}
351
352fn extract_license_from_yaml(yaml: &yaml_serde::Mapping) -> Option<String> {
353 yaml.get(YamlValue::String(FIELD_LICENSE.to_string()))
354 .and_then(|v| match v {
355 YamlValue::String(s) => Some(truncate_field(s.clone())),
356 YamlValue::Sequence(arr) => {
357 let licenses: Vec<String> = arr
358 .iter()
359 .take(MAX_ITERATION_COUNT)
360 .filter_map(|item| item.as_str().map(|s| truncate_field(s.to_string())))
361 .collect();
362 if licenses.is_empty() {
363 None
364 } else {
365 Some(truncate_field(licenses.join(" AND ")))
366 }
367 }
368 _ => None,
369 })
370}
371
372fn normalize_cpan_declared_license(
373 raw_license: Option<&impl LicenseValueAdapter>,
374 extracted_license_statement: Option<&str>,
375) -> (
376 Option<String>,
377 Option<String>,
378 Vec<crate::models::LicenseDetection>,
379) {
380 let Some(raw_license) = raw_license else {
381 return empty_declared_license_data();
382 };
383 let normalized = raw_license
384 .license_values()
385 .into_iter()
386 .map(|value| normalize_cpan_license_value(&value))
387 .collect::<Option<Vec<_>>>();
388
389 if let Some(normalized) = normalized
390 && let Some(combined) = combine_normalized_licenses(normalized, " AND ")
391 {
392 return build_declared_license_data(
393 combined,
394 DeclaredLicenseMatchMetadata::single_line(
395 extracted_license_statement.unwrap_or_default(),
396 ),
397 );
398 }
399
400 empty_declared_license_data()
401}
402
403trait LicenseValueAdapter {
404 fn license_values(&self) -> Vec<String>;
405}
406
407impl LicenseValueAdapter for JsonValue {
408 fn license_values(&self) -> Vec<String> {
409 match self {
410 JsonValue::String(value) => vec![truncate_field(value.trim().to_string())],
411 JsonValue::Array(values) => values
412 .iter()
413 .take(MAX_ITERATION_COUNT)
414 .filter_map(|value| value.as_str())
415 .map(str::trim)
416 .filter(|value| !value.is_empty())
417 .map(|s| truncate_field(s.to_string()))
418 .collect(),
419 _ => Vec::new(),
420 }
421 }
422}
423
424impl LicenseValueAdapter for YamlValue {
425 fn license_values(&self) -> Vec<String> {
426 match self {
427 YamlValue::String(value) => vec![truncate_field(value.trim().to_string())],
428 YamlValue::Sequence(values) => values
429 .iter()
430 .take(MAX_ITERATION_COUNT)
431 .filter_map(|value| value.as_str())
432 .map(str::trim)
433 .filter(|value| !value.is_empty())
434 .map(|s| truncate_field(s.to_string()))
435 .collect(),
436 _ => Vec::new(),
437 }
438 }
439}
440
441fn normalize_cpan_license_value(value: &str) -> Option<NormalizedDeclaredLicense> {
442 match value.trim() {
443 "perl_5" | "Perl_5" => Some(NormalizedDeclaredLicense::new(
444 "gpl-1.0-plus OR artistic-perl-1.0",
445 "GPL-1.0-or-later OR Artistic-1.0-Perl",
446 )),
447 "artistic_2" => Some(NormalizedDeclaredLicense::new(
448 "artistic-2.0",
449 "Artistic-2.0",
450 )),
451 "apache_2_0" => Some(NormalizedDeclaredLicense::new("apache-2.0", "Apache-2.0")),
452 other => normalize_spdx_expression(other).or_else(|| normalize_declared_license_key(other)),
453 }
454}
455
456fn extract_parties_from_json(json: &serde_json::Map<String, JsonValue>) -> Vec<Party> {
457 json.get(FIELD_AUTHOR)
458 .and_then(|v| v.as_array())
459 .map_or_else(Vec::new, |authors| {
460 authors
461 .iter()
462 .take(MAX_ITERATION_COUNT)
463 .filter_map(|author| {
464 author.as_str().map(|s| {
465 let (name, email) = parse_author_string(s);
466 Party {
467 r#type: Some(PartyType::Person),
468 role: Some("author".to_string()),
469 name,
470 email,
471 url: None,
472 organization: None,
473 organization_url: None,
474 timezone: None,
475 }
476 })
477 })
478 .collect()
479 })
480}
481
482fn extract_parties_from_yaml(yaml: &yaml_serde::Mapping) -> Vec<Party> {
483 yaml.get(YamlValue::String(FIELD_AUTHOR.to_string()))
484 .and_then(|v| v.as_sequence())
485 .map_or_else(Vec::new, |authors| {
486 authors
487 .iter()
488 .take(MAX_ITERATION_COUNT)
489 .filter_map(|author| {
490 author.as_str().map(|s| {
491 let (name, email) = parse_author_string(s);
492 Party {
493 r#type: Some(PartyType::Person),
494 role: Some("author".to_string()),
495 name,
496 email,
497 url: None,
498 organization: None,
499 organization_url: None,
500 timezone: None,
501 }
502 })
503 })
504 .collect()
505 })
506}
507
508fn parse_author_string(author_str: &str) -> (Option<String>, Option<String>) {
509 if let Some(email_start) = author_str.find('<')
510 && let Some(email_end) = author_str.find('>')
511 && email_start < email_end
512 {
513 let name = author_str[..email_start].trim();
514 let email = author_str[email_start + 1..email_end].trim();
515 return (
516 if name.is_empty() {
517 None
518 } else {
519 Some(truncate_field(name.to_string()))
520 },
521 if email.is_empty() {
522 None
523 } else {
524 Some(truncate_field(email.to_string()))
525 },
526 );
527 }
528 let trimmed = author_str.trim();
529 (
530 if trimmed.is_empty() {
531 None
532 } else {
533 Some(truncate_field(trimmed.to_string()))
534 },
535 None,
536 )
537}
538
539fn extract_resources_from_json(
540 json: &serde_json::Map<String, JsonValue>,
541) -> (
542 Option<String>,
543 Option<String>,
544 Option<String>,
545 Option<String>,
546) {
547 let resources = match json.get(FIELD_RESOURCES).and_then(|v| v.as_object()) {
548 Some(r) => r,
549 None => return (None, None, None, None),
550 };
551
552 let homepage_url = resources
553 .get("homepage")
554 .and_then(|v| v.as_str())
555 .map(|s| truncate_field(s.to_string()));
556
557 let vcs_url = resources.get("repository").and_then(|v| match v {
558 JsonValue::String(s) => Some(truncate_field(s.clone())),
559 JsonValue::Object(obj) => obj
560 .get("url")
561 .and_then(|u| u.as_str())
562 .map(|s| truncate_field(s.to_string())),
563 _ => None,
564 });
565
566 let code_view_url = resources
567 .get("repository")
568 .and_then(|v| v.as_object())
569 .and_then(|obj| {
570 obj.get("web")
571 .and_then(|u| u.as_str())
572 .map(|s| truncate_field(s.to_string()))
573 });
574
575 let bug_tracking_url = resources.get("bugtracker").and_then(|v| match v {
576 JsonValue::String(s) => Some(truncate_field(s.clone())),
577 JsonValue::Object(obj) => obj
578 .get("web")
579 .and_then(|u| u.as_str())
580 .map(|s| truncate_field(s.to_string())),
581 _ => None,
582 });
583
584 (homepage_url, vcs_url, code_view_url, bug_tracking_url)
585}
586
587fn extract_resources_from_yaml(
588 yaml: &yaml_serde::Mapping,
589) -> (Option<String>, Option<String>, Option<String>) {
590 let resources = match yaml
591 .get(YamlValue::String(FIELD_RESOURCES.to_string()))
592 .and_then(|v| v.as_mapping())
593 {
594 Some(r) => r,
595 None => return (None, None, None),
596 };
597
598 let homepage_url = resources
599 .get(YamlValue::String("homepage".to_string()))
600 .and_then(|v| v.as_str())
601 .map(|s| truncate_field(s.to_string()));
602
603 let vcs_url = resources
604 .get(YamlValue::String("repository".to_string()))
605 .and_then(|v| v.as_str())
606 .map(|s| truncate_field(s.to_string()));
607
608 let bug_tracking_url = resources
609 .get(YamlValue::String("bugtracker".to_string()))
610 .and_then(|v| v.as_str())
611 .map(|s| truncate_field(s.to_string()));
612
613 (homepage_url, vcs_url, bug_tracking_url)
614}
615
616fn extract_dependencies_from_json(json: &serde_json::Map<String, JsonValue>) -> Vec<Dependency> {
617 let mut dependencies = Vec::new();
618
619 let prereqs = match json.get(FIELD_PREREQS).and_then(|v| v.as_object()) {
620 Some(p) => p,
621 None => return dependencies,
622 };
623
624 if let Some(runtime) = prereqs.get("runtime").and_then(|v| v.as_object())
626 && let Some(requires) = runtime.get("requires").and_then(|v| v.as_object())
627 {
628 dependencies.extend(extract_dependency_group(requires, "runtime", true, false));
629 }
630
631 if let Some(build) = prereqs.get("build").and_then(|v| v.as_object())
633 && let Some(requires) = build.get("requires").and_then(|v| v.as_object())
634 {
635 dependencies.extend(extract_dependency_group(requires, "build", false, false));
636 }
637
638 if let Some(test) = prereqs.get("test").and_then(|v| v.as_object())
640 && let Some(requires) = test.get("requires").and_then(|v| v.as_object())
641 {
642 dependencies.extend(extract_dependency_group(requires, "test", false, false));
643 }
644
645 if let Some(configure) = prereqs.get("configure").and_then(|v| v.as_object())
647 && let Some(requires) = configure.get("requires").and_then(|v| v.as_object())
648 {
649 dependencies.extend(extract_dependency_group(
650 requires,
651 "configure",
652 false,
653 false,
654 ));
655 }
656
657 dependencies
658}
659
660fn extract_dependencies_from_yaml(yaml: &yaml_serde::Mapping) -> Vec<Dependency> {
661 let mut dependencies = Vec::new();
662
663 if let Some(requires) = yaml
665 .get(YamlValue::String(FIELD_REQUIRES.to_string()))
666 .and_then(|v| v.as_mapping())
667 {
668 dependencies.extend(extract_yaml_dependency_group(
669 requires, "runtime", true, false,
670 ));
671 }
672
673 if let Some(build_requires) = yaml
674 .get(YamlValue::String(FIELD_BUILD_REQUIRES.to_string()))
675 .and_then(|v| v.as_mapping())
676 {
677 dependencies.extend(extract_yaml_dependency_group(
678 build_requires,
679 "build",
680 false,
681 false,
682 ));
683 }
684
685 if let Some(test_requires) = yaml
686 .get(YamlValue::String(FIELD_TEST_REQUIRES.to_string()))
687 .and_then(|v| v.as_mapping())
688 {
689 dependencies.extend(extract_yaml_dependency_group(
690 test_requires,
691 "test",
692 false,
693 false,
694 ));
695 }
696
697 if let Some(configure_requires) = yaml
698 .get(YamlValue::String(FIELD_CONFIGURE_REQUIRES.to_string()))
699 .and_then(|v| v.as_mapping())
700 {
701 dependencies.extend(extract_yaml_dependency_group(
702 configure_requires,
703 "configure",
704 false,
705 false,
706 ));
707 }
708
709 dependencies
710}
711
712fn extract_dependency_group(
713 deps: &serde_json::Map<String, JsonValue>,
714 scope: &str,
715 is_runtime: bool,
716 is_optional: bool,
717) -> Vec<Dependency> {
718 deps.iter()
719 .take(MAX_ITERATION_COUNT)
720 .filter_map(|(name, version)| {
721 if name == "perl" {
722 return None;
723 }
724
725 let purl = PackageUrl::new("cpan", cpan_distribution_name(name))
726 .ok()
727 .map(|p| p.to_string());
728 let purl = purl.map(truncate_field);
729
730 let extracted_requirement = match version {
731 JsonValue::String(s) => Some(truncate_field(s.clone())),
732 JsonValue::Number(n) => Some(truncate_field(n.to_string())),
733 _ => None,
734 };
735
736 Some(Dependency {
737 purl,
738 extracted_requirement,
739 scope: Some(truncate_field(scope.to_string())),
740 is_runtime: Some(is_runtime),
741 is_optional: Some(is_optional),
742 is_pinned: None,
743 is_direct: Some(true),
744 resolved_package: None,
745 extra_data: None,
746 })
747 })
748 .collect()
749}
750
751fn extract_yaml_dependency_group(
752 deps: &yaml_serde::Mapping,
753 scope: &str,
754 is_runtime: bool,
755 is_optional: bool,
756) -> Vec<Dependency> {
757 deps.iter()
758 .take(MAX_ITERATION_COUNT)
759 .filter_map(|(key, value)| {
760 let name = key.as_str()?;
761
762 if name == "perl" {
763 return None;
764 }
765
766 let purl = PackageUrl::new("cpan", cpan_distribution_name(name))
767 .ok()
768 .map(|p| p.to_string());
769 let purl = purl.map(truncate_field);
770
771 let extracted_requirement = match value {
772 YamlValue::String(s) => Some(truncate_field(s.clone())),
773 YamlValue::Number(n) => Some(truncate_field(n.to_string())),
774 _ => None,
775 };
776
777 Some(Dependency {
778 purl,
779 extracted_requirement,
780 scope: Some(truncate_field(scope.to_string())),
781 is_runtime: Some(is_runtime),
782 is_optional: Some(is_optional),
783 is_pinned: None,
784 is_direct: Some(true),
785 resolved_package: None,
786 extra_data: None,
787 })
788 })
789 .collect()
790}