Skip to main content

provenant/parsers/
cpan.rs

1// SPDX-FileCopyrightText: Provenant contributors
2// SPDX-License-Identifier: Apache-2.0
3
4//! Parser for CPAN Perl package manifests.
5//!
6//! Extracts package metadata, dependencies, and author information from
7//! CPAN distribution files used by Perl modules.
8//!
9//! # Supported Formats
10//! - META.json (CPAN::Meta::Spec v2.0+)
11//! - META.yml (CPAN::Meta::Spec v1.4)
12//! - MANIFEST (file list)
13//!
14//! # Key Features
15//! - Full metadata extraction from META.json and META.yml (beyond Python stub handlers)
16//! - Dependency extraction for all CPAN dependency scopes (runtime, build, test, configure)
17//! - Author party information extraction
18//! - Repository URL extraction
19//! - File references from MANIFEST
20//!
21//! # Implementation Notes
22//! - Uses serde_json for JSON parsing
23//! - Uses yaml_serde for YAML parsing
24//! - Python reference has stub-only handlers with no parse() method
25//! - This is a BEYOND PARITY implementation - we extract complete metadata
26
27use 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
59/// CPAN META.json parser for CPAN::Meta::Spec v2.0+ metadata.
60///
61/// Extracts complete metadata from META.json files including dependencies
62/// from all scopes (runtime, build, test, configure).
63/// Convert a Perl module name to its CPAN distribution-name form for the PURL.
64///
65/// The cpan purl-spec name is the distribution (e.g. `DBD-SQLite`), not the
66/// module (`DBD::SQLite`), so `::` separators become `-`. Note: for a module
67/// *dependency* the true distribution can differ from the dashed module name
68/// (it requires a registry lookup we can't do statically), so this is a
69/// best-effort approximation. The spec-required uppercase author namespace is
70/// not present in CPAN manifests and is therefore omitted.
71pub(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
150/// CPAN META.yml parser for CPAN::Meta::Spec v1.4 metadata.
151///
152/// Extracts complete metadata from META.yml files with legacy dependency structure.
153pub 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
227/// CPAN MANIFEST parser for module file lists.
228///
229/// Extracts file references from MANIFEST files (simple line-by-line format).
230pub 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    // Extract runtime dependencies
625    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    // Extract build dependencies
632    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    // Extract test dependencies
639    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    // Extract configure dependencies
646    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    // META.yml v1.4 has flat dependency structure
664    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}