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