Skip to main content

provenant/parsers/
dart.rs

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