1use 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
72pub 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
113pub 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 yaml_content
632 .get(FIELD_DESCRIPTION)
633 .and_then(Value::as_str)
634 .and_then(|value| {
635 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 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}