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