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