Skip to main content

provenant/parsers/
dart.rs

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