Skip to main content

provenant/parsers/
dart.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// SPDX-FileCopyrightText: Provenant contributors
3// SPDX-License-Identifier: Apache-2.0
4// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
5
6//! Parser for Dart/Flutter pubspec.yaml and pubspec.lock files.
7//!
8//! Extracts package metadata and dependencies from Dart/Flutter project manifest
9//! and lockfiles using YAML format.
10//!
11//! # Supported Formats
12//! - pubspec.yaml (Dart package manifest)
13//! - pubspec.lock (Dart package lockfile with pinned versions)
14//!
15//! # Key Features
16//! - Dependency extraction from dependencies and dev_dependencies sections
17//! - Direct vs transitive dependency tracking (lockfile)
18//! - Version constraint parsing for Dart's SemVer and range specifiers
19//! - Package URL (purl) generation for Pub packages
20//! - Author/maintainer and homepage extraction
21//!
22//! # Implementation Notes
23//! - Uses YAML parsing via `yaml_serde`
24//! - Lockfile versions are pinned (`is_pinned: Some(true)`)
25//! - Graceful error handling with `warn!()` logs
26//! - Supports both pub.dev and Git-hosted packages
27
28use std::collections::{HashMap, HashSet, VecDeque};
29use std::path::Path;
30
31use crate::parser_warn as warn;
32use crate::parsers::utils::{
33    MAX_ITERATION_COUNT, RecursionGuard, read_file_to_string, truncate_field,
34};
35use packageurl::PackageUrl;
36use yaml_serde::{Mapping, Value};
37
38use crate::models::{
39    DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha256Digest,
40};
41
42use super::PackageParser;
43use super::metadata::ParserMetadata;
44
45const FIELD_NAME: &str = "name";
46const FIELD_VERSION: &str = "version";
47const FIELD_DESCRIPTION: &str = "description";
48const FIELD_HOMEPAGE: &str = "homepage";
49const FIELD_LICENSE: &str = "license";
50const FIELD_REPOSITORY: &str = "repository";
51const FIELD_AUTHOR: &str = "author";
52const FIELD_AUTHORS: &str = "authors";
53const FIELD_DEPENDENCIES: &str = "dependencies";
54const FIELD_DEV_DEPENDENCIES: &str = "dev_dependencies";
55const FIELD_DEPENDENCY_OVERRIDES: &str = "dependency_overrides";
56const FIELD_ENVIRONMENT: &str = "environment";
57const FIELD_ISSUE_TRACKER: &str = "issue_tracker";
58const FIELD_DOCUMENTATION: &str = "documentation";
59const FIELD_EXECUTABLES: &str = "executables";
60const FIELD_PUBLISH_TO: &str = "publish_to";
61const FIELD_ARCHIVE_URL: &str = "archive_url";
62const FIELD_PLATFORMS: &str = "platforms";
63const FIELD_FUNDING: &str = "funding";
64const FIELD_FALSE_SECRETS: &str = "false_secrets";
65const FIELD_SCREENSHOTS: &str = "screenshots";
66const FIELD_TOPICS: &str = "topics";
67const FIELD_IGNORED_ADVISORIES: &str = "ignored_advisories";
68const FIELD_PACKAGES: &str = "packages";
69const FIELD_SDKS: &str = "sdks";
70const FIELD_SDK: &str = "sdk";
71const FIELD_DEPENDENCY: &str = "dependency";
72const FIELD_SHA256: &str = "sha256";
73
74/// Dart pubspec.yaml manifest parser.
75pub struct PubspecYamlParser;
76
77impl PackageParser for PubspecYamlParser {
78    const PACKAGE_TYPE: PackageType = PackageType::Dart;
79
80    fn metadata() -> Vec<ParserMetadata> {
81        vec![ParserMetadata {
82            description: "Dart pubspec.yaml manifest",
83            file_patterns: &["**/pubspec.yaml", "**/pubspec.lock"],
84            package_type: "pub",
85            primary_language: "Dart",
86            documentation_url: Some("https://dart.dev/tools/pub/pubspec"),
87        }]
88    }
89
90    fn extract_packages(path: &Path) -> Vec<PackageData> {
91        let yaml_content = match read_yaml_file(path) {
92            Ok(content) => content,
93            Err(e) => {
94                warn!("Failed to read pubspec.yaml at {:?}: {}", path, e);
95                let mut package_data = default_package_data();
96                package_data.datasource_id = Some(DatasourceId::PubspecYaml);
97                return vec![package_data];
98            }
99        };
100
101        vec![parse_pubspec_yaml(&yaml_content)]
102    }
103
104    fn is_match(path: &Path) -> bool {
105        path.file_name()
106            .and_then(|name| name.to_str())
107            .is_some_and(|name| {
108                name == "pubspec.yaml"
109                    || name.ends_with("-pubspec.yaml")
110                    || name.ends_with(".pubspec.yaml")
111            })
112    }
113}
114
115/// Dart pubspec.lock lockfile parser.
116pub struct PubspecLockParser;
117
118impl PackageParser for PubspecLockParser {
119    const PACKAGE_TYPE: PackageType = PackageType::Pubspec;
120
121    fn extract_packages(path: &Path) -> Vec<PackageData> {
122        let yaml_content = match read_yaml_file(path) {
123            Ok(content) => content,
124            Err(e) => {
125                warn!("Failed to read pubspec.lock at {:?}: {}", path, e);
126                let mut package_data =
127                    default_package_data_with_type(PubspecLockParser::PACKAGE_TYPE.as_str());
128                package_data.datasource_id = Some(DatasourceId::PubspecLock);
129                return vec![package_data];
130            }
131        };
132
133        vec![parse_pubspec_lock(&yaml_content)]
134    }
135
136    fn is_match(path: &Path) -> bool {
137        path.file_name()
138            .and_then(|name| name.to_str())
139            .is_some_and(|name| {
140                name == "pubspec.lock"
141                    || name.ends_with("-pubspec.lock")
142                    || name.ends_with(".pubspec.lock")
143            })
144    }
145}
146
147fn read_yaml_file(path: &Path) -> Result<Value, String> {
148    let content =
149        read_file_to_string(path, None).map_err(|e| format!("Failed to read file: {}", e))?;
150    yaml_serde::from_str(&content).map_err(|e| format!("Failed to parse YAML: {}", e))
151}
152
153fn parse_pubspec_yaml(yaml_content: &Value) -> PackageData {
154    let name = extract_string_field(yaml_content, FIELD_NAME).map(truncate_field);
155    let version = extract_string_field(yaml_content, FIELD_VERSION).map(truncate_field);
156    let description = extract_description_field(yaml_content).map(truncate_field);
157    let homepage_url = extract_string_field(yaml_content, FIELD_HOMEPAGE).map(truncate_field);
158    let raw_license = extract_string_field(yaml_content, FIELD_LICENSE).map(truncate_field);
159    let vcs_url = extract_string_field(yaml_content, FIELD_REPOSITORY).map(truncate_field);
160    let bug_tracking_url =
161        extract_string_field(yaml_content, FIELD_ISSUE_TRACKER).map(truncate_field);
162    let archive_url = extract_string_field(yaml_content, FIELD_ARCHIVE_URL).map(truncate_field);
163
164    let parties = extract_authors(yaml_content);
165
166    let declared_license_expression = None;
167    let declared_license_expression_spdx = None;
168    let license_detections = Vec::new();
169
170    let dependencies = [
171        collect_dependencies(
172            yaml_content,
173            FIELD_DEPENDENCIES,
174            Some("dependencies"),
175            true,
176            false,
177        ),
178        collect_dependencies(
179            yaml_content,
180            FIELD_DEV_DEPENDENCIES,
181            Some("dev_dependencies"),
182            false,
183            true,
184        ),
185        collect_dependencies(
186            yaml_content,
187            FIELD_DEPENDENCY_OVERRIDES,
188            Some("dependency_overrides"),
189            true,
190            false,
191        ),
192        collect_dependencies(
193            yaml_content,
194            FIELD_ENVIRONMENT,
195            Some("environment"),
196            true,
197            false,
198        ),
199    ]
200    .concat();
201
202    let extra_data = build_extra_data(yaml_content);
203    let keywords = extract_string_list_field(yaml_content, FIELD_TOPICS);
204
205    let purl = name
206        .as_ref()
207        .and_then(|name| build_purl(name, version.as_deref()))
208        .map(truncate_field);
209
210    let (api_data_url, repository_homepage_url, repository_download_url) =
211        if let (Some(name_val), Some(version_val)) = (&name, &version) {
212            (
213                Some(truncate_field(format!(
214                    "https://pub.dev/api/packages/{}/versions/{}",
215                    name_val, version_val
216                ))),
217                Some(truncate_field(format!(
218                    "https://pub.dev/packages/{}/versions/{}",
219                    name_val, version_val
220                ))),
221                Some(truncate_field(format!(
222                    "https://pub.dartlang.org/packages/{}/versions/{}.tar.gz",
223                    name_val, version_val
224                ))),
225            )
226        } else {
227            (None, None, None)
228        };
229
230    let download_url = archive_url.or_else(|| repository_download_url.clone());
231
232    PackageData {
233        package_type: Some(PubspecYamlParser::PACKAGE_TYPE),
234        namespace: None,
235        name,
236        version,
237        qualifiers: None,
238        subpath: None,
239        primary_language: Some("dart".to_string()),
240        description,
241        release_date: None,
242        parties,
243        keywords,
244        homepage_url,
245        download_url,
246        size: None,
247        sha1: None,
248        md5: None,
249        sha256: None,
250        sha512: None,
251        bug_tracking_url,
252        code_view_url: None,
253        vcs_url,
254        copyright: None,
255        holder: None,
256        declared_license_expression,
257        declared_license_expression_spdx,
258        license_detections,
259        other_license_expression: None,
260        other_license_expression_spdx: None,
261        other_license_detections: Vec::new(),
262        extracted_license_statement: raw_license,
263        notice_text: None,
264        source_packages: Vec::new(),
265        file_references: Vec::new(),
266        is_private: yaml_content
267            .get(FIELD_PUBLISH_TO)
268            .and_then(Value::as_str)
269            .is_some_and(|value| value.trim() == "none"),
270        is_virtual: false,
271        extra_data,
272        dependencies,
273        repository_homepage_url,
274        repository_download_url,
275        api_data_url,
276        datasource_id: Some(DatasourceId::PubspecYaml),
277        purl,
278    }
279}
280
281fn parse_pubspec_lock(yaml_content: &Value) -> PackageData {
282    let dependencies = extract_lock_dependencies(yaml_content);
283
284    let mut package_data = default_package_data_with_type(PubspecLockParser::PACKAGE_TYPE.as_str());
285    package_data.dependencies = dependencies;
286    package_data.datasource_id = Some(DatasourceId::PubspecLock);
287    package_data
288}
289
290fn extract_lock_dependencies(lock_data: &Value) -> Vec<Dependency> {
291    let mut dependencies = Vec::new();
292
293    if let Some(sdks) = lock_data.get(FIELD_SDKS).and_then(Value::as_mapping) {
294        for (name_value, version_value) in sdks.iter().take(MAX_ITERATION_COUNT) {
295            if let (Some(name), Some(version_str)) = (name_value.as_str(), version_value.as_str()) {
296                let purl = build_dependency_purl(name, None).map(truncate_field);
297                dependencies.push(Dependency {
298                    purl,
299                    extracted_requirement: Some(truncate_field(version_str.to_string())),
300                    scope: Some("sdk".to_string()),
301                    is_runtime: Some(true),
302                    is_optional: Some(false),
303                    is_pinned: Some(false),
304                    is_direct: Some(true),
305                    resolved_package: None,
306                    extra_data: None,
307                });
308            }
309        }
310    } else if let Some(version_str) = lock_data.get(FIELD_SDK).and_then(Value::as_str) {
311        let purl = build_dependency_purl("dart", None).map(truncate_field);
312        dependencies.push(Dependency {
313            purl,
314            extracted_requirement: Some(truncate_field(version_str.to_string())),
315            scope: Some("sdk".to_string()),
316            is_runtime: Some(true),
317            is_optional: Some(false),
318            is_pinned: Some(false),
319            is_direct: Some(true),
320            resolved_package: None,
321            extra_data: None,
322        });
323    }
324
325    let Some(packages) = lock_data.get(FIELD_PACKAGES).and_then(Value::as_mapping) else {
326        return dependencies;
327    };
328
329    let runtime_reachable =
330        reachable_lock_packages(packages, &["direct main", "direct overridden"]);
331    let dev_only_reachable = reachable_lock_packages(packages, &["direct dev"]);
332
333    for (name_value, details_value) in packages.iter().take(MAX_ITERATION_COUNT) {
334        let name = match name_value.as_str() {
335            Some(value) => value,
336            None => continue,
337        };
338        let Some(details) = details_value.as_mapping() else {
339            continue;
340        };
341
342        let version = mapping_get(details, FIELD_VERSION)
343            .and_then(Value::as_str)
344            .map(|value| truncate_field(value.to_string()));
345        let dependency_kind = mapping_get(details, FIELD_DEPENDENCY)
346            .and_then(Value::as_str)
347            .map(|value| truncate_field(value.to_string()));
348        let (is_runtime, is_optional, is_direct) = classify_lock_dependency(
349            name,
350            dependency_kind.as_deref(),
351            &runtime_reachable,
352            &dev_only_reachable,
353        );
354
355        let is_pinned = version
356            .as_ref()
357            .is_some_and(|value| !value.trim().is_empty());
358
359        let purl = build_dependency_purl(name, version.as_deref()).map(truncate_field);
360        let sha256 = extract_sha256(details).and_then(|h| Sha256Digest::from_hex(&h).ok());
361        let resolved_dependencies = extract_lock_package_dependencies(details);
362        let resolved_package = build_resolved_package(
363            name,
364            &version,
365            sha256,
366            extract_lock_descriptor_extra_data(details),
367            resolved_dependencies,
368        );
369
370        dependencies.push(Dependency {
371            purl,
372            extracted_requirement: version.clone().map(truncate_field),
373            scope: dependency_kind,
374            is_runtime: Some(is_runtime),
375            is_optional: Some(is_optional),
376            is_pinned: Some(is_pinned),
377            is_direct: Some(is_direct),
378            resolved_package: Some(Box::new(resolved_package)),
379            extra_data: extract_lock_descriptor_extra_data(details),
380        });
381    }
382
383    dependencies
384}
385
386fn extract_lock_package_dependencies(details: &Mapping) -> Vec<Dependency> {
387    let mut dependencies = Vec::new();
388
389    let Some(dep_map) = mapping_get(details, FIELD_DEPENDENCIES).and_then(Value::as_mapping) else {
390        return dependencies;
391    };
392
393    for (name_value, requirement_value) in dep_map.iter().take(MAX_ITERATION_COUNT) {
394        let name = match name_value.as_str() {
395            Some(value) => value,
396            None => continue,
397        };
398
399        let requirement = match dependency_requirement_from_value(requirement_value) {
400            Some(value) => value,
401            None => continue,
402        };
403        let is_pinned = is_pubspec_version_pinned(&requirement);
404        let purl = if is_pinned {
405            build_dependency_purl(name, Some(requirement.as_str()))
406        } else {
407            build_dependency_purl(name, None)
408        };
409
410        dependencies.push(Dependency {
411            purl: purl.map(truncate_field),
412            extracted_requirement: Some(truncate_field(requirement)),
413            scope: Some(FIELD_DEPENDENCIES.to_string()),
414            is_runtime: Some(true),
415            is_optional: Some(false),
416            is_pinned: Some(is_pinned),
417            is_direct: Some(false),
418            resolved_package: None,
419            extra_data: None,
420        });
421    }
422
423    dependencies
424}
425
426fn extract_sha256(details: &Mapping) -> Option<String> {
427    let direct = mapping_get(details, FIELD_SHA256)
428        .and_then(Value::as_str)
429        .map(|value| value.to_string());
430
431    if direct.is_some() {
432        return direct;
433    }
434
435    mapping_get(details, FIELD_DESCRIPTION)
436        .and_then(Value::as_mapping)
437        .and_then(|desc_map| mapping_get(desc_map, FIELD_SHA256))
438        .and_then(Value::as_str)
439        .map(|value| value.to_string())
440}
441
442fn build_resolved_package(
443    name: &str,
444    version: &Option<String>,
445    sha256: Option<Sha256Digest>,
446    extra_data: Option<HashMap<String, serde_json::Value>>,
447    dependencies: Vec<Dependency>,
448) -> ResolvedPackage {
449    ResolvedPackage {
450        primary_language: Some("dart".to_string()),
451        download_url: None,
452        sha1: None,
453        sha256,
454        sha512: None,
455        md5: None,
456        is_virtual: true,
457        extra_data,
458        dependencies,
459        repository_homepage_url: None,
460        repository_download_url: None,
461        api_data_url: None,
462        datasource_id: None,
463        purl: None,
464        ..ResolvedPackage::new(
465            PubspecLockParser::PACKAGE_TYPE,
466            String::new(),
467            truncate_field(name.to_string()),
468            truncate_field(version.clone().unwrap_or_default()),
469        )
470    }
471}
472
473fn collect_dependencies(
474    yaml_content: &Value,
475    field: &str,
476    scope: Option<&str>,
477    is_runtime: bool,
478    is_optional: bool,
479) -> Vec<Dependency> {
480    let mut dependencies = Vec::new();
481
482    let Some(dep_map) = yaml_content.get(field).and_then(Value::as_mapping) else {
483        return dependencies;
484    };
485
486    for (name_value, requirement_value) in dep_map.iter().take(MAX_ITERATION_COUNT) {
487        let name = match name_value.as_str() {
488            Some(value) => value,
489            None => continue,
490        };
491        let requirement = dependency_requirement_from_value(requirement_value).map(truncate_field);
492        let is_pinned = requirement
493            .as_deref()
494            .is_some_and(is_pubspec_version_pinned);
495        let purl = if is_pinned {
496            build_dependency_purl(name, requirement.as_deref())
497        } else {
498            build_dependency_purl(name, None)
499        };
500
501        dependencies.push(Dependency {
502            purl: purl.map(truncate_field),
503            extracted_requirement: requirement,
504            scope: scope.map(|value| value.to_string()),
505            is_runtime: Some(is_runtime),
506            is_optional: Some(is_optional),
507            is_pinned: Some(is_pinned),
508            is_direct: Some(true),
509            resolved_package: None,
510            extra_data: extract_manifest_dependency_extra_data(requirement_value),
511        });
512    }
513
514    dependencies
515}
516
517fn dependency_requirement_from_value(value: &Value) -> Option<String> {
518    if let Some(value) = value.as_str() {
519        let trimmed = value.trim();
520        if trimmed.is_empty() {
521            return None;
522        }
523        return Some(trimmed.to_string());
524    }
525
526    if let Some(value) = value.as_i64() {
527        return Some(value.to_string());
528    }
529
530    if let Some(value) = value.as_f64() {
531        return Some(value.to_string());
532    }
533
534    if let Some(map) = value.as_mapping() {
535        return format_dependency_mapping(map, &mut RecursionGuard::depth_only());
536    }
537
538    None
539}
540
541fn format_dependency_mapping(map: &Mapping, guard: &mut RecursionGuard<()>) -> Option<String> {
542    if guard.descend() {
543        warn!("Recursion depth exceeded in format_dependency_mapping");
544        return None;
545    }
546
547    let mut parts = Vec::new();
548
549    for (key, value) in map.iter().take(MAX_ITERATION_COUNT) {
550        let Some(key_str) = key.as_str() else {
551            continue;
552        };
553
554        let value_str = if let Some(value) = value.as_str() {
555            value.to_string()
556        } else if let Some(value) = value.as_i64() {
557            value.to_string()
558        } else if let Some(value) = value.as_f64() {
559            value.to_string()
560        } else if let Some(nested) = value.as_mapping() {
561            format_dependency_mapping(nested, guard)?
562        } else {
563            continue;
564        };
565
566        parts.push(format!("{}: {}", key_str, value_str));
567    }
568
569    guard.ascend();
570
571    if parts.is_empty() {
572        None
573    } else {
574        Some(parts.join(", "))
575    }
576}
577
578fn is_pubspec_version_pinned(version: &str) -> bool {
579    let trimmed = version.trim();
580    if trimmed.is_empty() {
581        return false;
582    }
583
584    trimmed
585        .chars()
586        .all(|character| character.is_ascii_digit() || character == '.')
587}
588
589fn build_purl(name: &str, version: Option<&str>) -> Option<String> {
590    build_purl_with_type(PubspecYamlParser::PACKAGE_TYPE.as_str(), name, version)
591}
592
593fn build_dependency_purl(name: &str, version: Option<&str>) -> Option<String> {
594    build_purl_with_type("pubspec", name, version)
595}
596
597fn build_purl_with_type(package_type: &str, name: &str, version: Option<&str>) -> Option<String> {
598    let mut package_url = match PackageUrl::new(package_type, name) {
599        Ok(purl) => purl,
600        Err(e) => {
601            warn!(
602                "Failed to create PackageUrl for {} dependency '{}': {}",
603                package_type, name, e
604            );
605            return None;
606        }
607    };
608
609    if let Some(version) = version
610        && let Err(e) = package_url.with_version(version)
611    {
612        warn!(
613            "Failed to set version '{}' for {} dependency '{}': {}",
614            version, package_type, name, e
615        );
616        return None;
617    }
618
619    Some(package_url.to_string())
620}
621
622fn extract_string_field(yaml_content: &Value, field: &str) -> Option<String> {
623    yaml_content
624        .get(field)
625        .and_then(Value::as_str)
626        .map(|value| value.trim().to_string())
627        .filter(|value| !value.is_empty())
628}
629
630fn extract_description_field(yaml_content: &Value) -> Option<String> {
631    // For description fields, preserve trailing newlines as they are semantically
632    // significant in YAML folded/literal scalars (> or |)
633    yaml_content
634        .get(FIELD_DESCRIPTION)
635        .and_then(Value::as_str)
636        .and_then(|value| {
637            // Only trim leading whitespace, preserve trailing newlines
638            let trimmed = value.trim_start();
639            if trimmed.is_empty() {
640                None
641            } else {
642                Some(trimmed.to_string())
643            }
644        })
645}
646
647fn mapping_get<'a>(map: &'a Mapping, key: &str) -> Option<&'a Value> {
648    map.get(Value::String(key.to_string()))
649}
650
651fn default_package_data() -> PackageData {
652    default_package_data_with_type(PubspecYamlParser::PACKAGE_TYPE.as_str())
653}
654
655fn default_package_data_with_type(package_type: &str) -> PackageData {
656    PackageData {
657        package_type: package_type.parse::<PackageType>().ok(),
658        primary_language: Some("dart".to_string()),
659        ..Default::default()
660    }
661}
662
663fn extract_authors(yaml_content: &Value) -> Vec<crate::models::Party> {
664    use crate::models::Party;
665    let mut parties = Vec::new();
666
667    if let Some(author) = extract_string_field(yaml_content, FIELD_AUTHOR).map(truncate_field) {
668        parties.push(Party {
669            r#type: None,
670            role: Some("author".to_string()),
671            name: Some(author),
672            email: None,
673            url: None,
674            organization: None,
675            organization_url: None,
676            timezone: None,
677        });
678    }
679
680    if let Some(authors_value) = yaml_content.get(FIELD_AUTHORS)
681        && let Some(authors_array) = authors_value.as_sequence()
682    {
683        for author_value in authors_array.iter().take(MAX_ITERATION_COUNT) {
684            if let Some(author_str) = author_value.as_str() {
685                parties.push(Party {
686                    r#type: None,
687                    role: Some("author".to_string()),
688                    name: Some(truncate_field(author_str.to_string())),
689                    email: None,
690                    url: None,
691                    organization: None,
692                    organization_url: None,
693                    timezone: None,
694                });
695            }
696        }
697    }
698
699    parties
700}
701
702fn build_extra_data(
703    yaml_content: &Value,
704) -> Option<std::collections::HashMap<String, serde_json::Value>> {
705    use std::collections::HashMap;
706    let mut extra_data = HashMap::new();
707
708    if let Some(issue_tracker) = extract_string_field(yaml_content, FIELD_ISSUE_TRACKER) {
709        extra_data.insert(
710            FIELD_ISSUE_TRACKER.to_string(),
711            serde_json::Value::String(issue_tracker),
712        );
713    }
714
715    if let Some(documentation) = extract_string_field(yaml_content, FIELD_DOCUMENTATION) {
716        extra_data.insert(
717            FIELD_DOCUMENTATION.to_string(),
718            serde_json::Value::String(documentation),
719        );
720    }
721
722    if let Some(executables) = yaml_content.get(FIELD_EXECUTABLES) {
723        // Convert yaml_serde::Value to serde_json::Value
724        if let Ok(json_value) = serde_json::to_value(executables) {
725            extra_data.insert(FIELD_EXECUTABLES.to_string(), json_value);
726        }
727    }
728
729    if let Some(publish_to) = extract_string_field(yaml_content, FIELD_PUBLISH_TO) {
730        extra_data.insert(
731            FIELD_PUBLISH_TO.to_string(),
732            serde_json::Value::String(publish_to),
733        );
734    }
735
736    for field in [
737        FIELD_PLATFORMS,
738        FIELD_FUNDING,
739        FIELD_FALSE_SECRETS,
740        FIELD_SCREENSHOTS,
741        FIELD_TOPICS,
742        FIELD_IGNORED_ADVISORIES,
743    ] {
744        if let Some(value) = yaml_content.get(field)
745            && let Ok(json_value) = serde_json::to_value(value)
746        {
747            extra_data.insert(field.to_string(), json_value);
748        }
749    }
750
751    if extra_data.is_empty() {
752        None
753    } else {
754        Some(extra_data)
755    }
756}
757
758fn extract_string_list_field(yaml_content: &Value, field: &str) -> Vec<String> {
759    yaml_content
760        .get(field)
761        .and_then(Value::as_sequence)
762        .into_iter()
763        .flatten()
764        .filter_map(Value::as_str)
765        .map(str::trim)
766        .filter(|value| !value.is_empty())
767        .map(|value| truncate_field(value.to_string()))
768        .collect()
769}
770
771fn extract_manifest_dependency_extra_data(
772    requirement_value: &Value,
773) -> Option<HashMap<String, serde_json::Value>> {
774    requirement_value
775        .as_mapping()
776        .and_then(|map| serde_json::to_value(map).ok())
777        .and_then(|json| json.as_object().cloned())
778        .map(|map| map.into_iter().collect())
779}
780
781fn extract_lock_descriptor_extra_data(
782    details: &Mapping,
783) -> Option<HashMap<String, serde_json::Value>> {
784    let mut extra = HashMap::new();
785
786    if let Some(source) = mapping_get(details, "source").and_then(Value::as_str) {
787        extra.insert(
788            "source".to_string(),
789            serde_json::Value::String(source.to_string()),
790        );
791    }
792
793    if let Some(description) = mapping_get(details, FIELD_DESCRIPTION)
794        && let Ok(json_value) = serde_json::to_value(description)
795    {
796        extra.insert("description".to_string(), json_value);
797    }
798
799    if let Some(kind) = mapping_get(details, FIELD_DEPENDENCY).and_then(Value::as_str) {
800        extra.insert(
801            FIELD_DEPENDENCY.to_string(),
802            serde_json::Value::String(kind.to_string()),
803        );
804    }
805
806    (!extra.is_empty()).then_some(extra)
807}
808
809fn reachable_lock_packages(packages: &Mapping, roots: &[&str]) -> HashSet<String> {
810    let mut reachable = HashSet::new();
811    let mut queue = VecDeque::new();
812    let mut iterations = 0usize;
813
814    for (name_value, details_value) in packages.iter().take(MAX_ITERATION_COUNT) {
815        let Some(name) = name_value.as_str() else {
816            continue;
817        };
818        let Some(details) = details_value.as_mapping() else {
819            continue;
820        };
821        let kind = mapping_get(details, FIELD_DEPENDENCY).and_then(Value::as_str);
822        if roots.contains(&kind.unwrap_or_default()) {
823            queue.push_back(truncate_field(name.to_string()));
824        }
825    }
826
827    while let Some(current) = queue.pop_front() {
828        if iterations >= MAX_ITERATION_COUNT {
829            warn!("Iteration count exceeded in reachable_lock_packages BFS");
830            break;
831        }
832        iterations += 1;
833
834        if !reachable.insert(current.clone()) {
835            continue;
836        }
837
838        let Some(details_value) = packages.get(Value::String(current.clone())) else {
839            continue;
840        };
841        let Some(details) = details_value.as_mapping() else {
842            continue;
843        };
844        let Some(dep_map) = mapping_get(details, FIELD_DEPENDENCIES).and_then(Value::as_mapping)
845        else {
846            continue;
847        };
848
849        for dep_name in dep_map
850            .keys()
851            .filter_map(Value::as_str)
852            .take(MAX_ITERATION_COUNT)
853        {
854            queue.push_back(truncate_field(dep_name.to_string()));
855        }
856    }
857
858    reachable
859}
860
861fn classify_lock_dependency(
862    name: &str,
863    dependency_kind: Option<&str>,
864    runtime_reachable: &HashSet<String>,
865    dev_only_reachable: &HashSet<String>,
866) -> (bool, bool, bool) {
867    match dependency_kind {
868        Some("direct main") | Some("direct overridden") => (true, false, true),
869        Some("direct dev") => (false, true, true),
870        Some("transitive") => {
871            if runtime_reachable.contains(name) {
872                (true, false, false)
873            } else if dev_only_reachable.contains(name) {
874                (false, true, false)
875            } else {
876                (true, false, false)
877            }
878        }
879        _ => (true, false, true),
880    }
881}
882
883#[cfg(test)]
884mod is_match_tests {
885    use super::{PubspecLockParser, PubspecYamlParser};
886    use crate::parsers::PackageParser;
887    use std::path::PathBuf;
888
889    #[test]
890    fn test_pubspec_yaml_parser_matches_suffixed_filenames() {
891        assert!(PubspecYamlParser::is_match(&PathBuf::from("pubspec.yaml")));
892        assert!(PubspecYamlParser::is_match(&PathBuf::from(
893            "simple-pubspec.yaml"
894        )));
895        assert!(PubspecYamlParser::is_match(&PathBuf::from(
896            "simple.pubspec.yaml"
897        )));
898        assert!(!PubspecYamlParser::is_match(&PathBuf::from("pubspec.yml")));
899    }
900
901    #[test]
902    fn test_pubspec_lock_parser_matches_suffixed_filenames() {
903        assert!(PubspecLockParser::is_match(&PathBuf::from("pubspec.lock")));
904        assert!(PubspecLockParser::is_match(&PathBuf::from(
905            "dart-pubspec.lock"
906        )));
907        assert!(PubspecLockParser::is_match(&PathBuf::from(
908            "dart.pubspec.lock"
909        )));
910        assert!(!PubspecLockParser::is_match(&PathBuf::from(
911            "pubspec.lock.bak"
912        )));
913    }
914}