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