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