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