1use std::collections::{HashMap, HashSet, VecDeque};
7use std::path::Path;
8
9use crate::parser_warn as warn;
10use packageurl::PackageUrl;
11use serde_json::Value as JsonValue;
12use toml::Value as TomlValue;
13use toml::map::Map as TomlMap;
14
15use crate::models::{
16 DatasourceId, Dependency, PackageData, PackageType, ResolvedPackage, Sha256Digest,
17};
18use crate::parsers::python::read_toml_file;
19use crate::parsers::utils::{MAX_ITERATION_COUNT, RecursionGuard, truncate_field};
20
21use super::PackageParser;
22
23const FIELD_PACKAGE: &str = "package";
24const FIELD_NAME: &str = "name";
25const FIELD_VERSION: &str = "version";
26const FIELD_SOURCE: &str = "source";
27const FIELD_DEPENDENCIES: &str = "dependencies";
28const FIELD_OPTIONAL_DEPENDENCIES: &str = "optional-dependencies";
29const FIELD_DEV_DEPENDENCIES: &str = "dev-dependencies";
30const FIELD_METADATA: &str = "metadata";
31const FIELD_REQUIRES_DIST: &str = "requires-dist";
32const FIELD_REQUIRES_DEV: &str = "requires-dev";
33const FIELD_METADATA_OPTIONAL_DEPENDENCIES: &str = "optional-dependencies";
34const FIELD_MARKER: &str = "marker";
35const FIELD_EXTRA: &str = "extra";
36const FIELD_SPECIFIER: &str = "specifier";
37const FIELD_REVISION: &str = "revision";
38const FIELD_REQUIRES_PYTHON: &str = "requires-python";
39const FIELD_RESOLUTION_MARKERS: &str = "resolution-markers";
40const FIELD_MANIFEST: &str = "manifest";
41
42pub struct UvLockParser;
43
44#[derive(Clone, Debug, Default)]
45struct DirectDependencyInfo {
46 extracted_requirement: Option<String>,
47 scope: Option<String>,
48 is_runtime: bool,
49 is_optional: bool,
50 extra_data: Option<HashMap<String, JsonValue>>,
51 source_key: Option<String>,
52}
53
54#[derive(Clone, Debug)]
55struct DependencyEdge {
56 name: String,
57 extracted_requirement: Option<String>,
58 scope: Option<String>,
59 is_runtime: bool,
60 is_optional: bool,
61 source_key: Option<String>,
62 extra_data: Option<HashMap<String, JsonValue>>,
63}
64
65impl PackageParser for UvLockParser {
66 const PACKAGE_TYPE: PackageType = PackageType::Pypi;
67
68 fn is_match(path: &Path) -> bool {
69 path.file_name()
70 .and_then(|name| name.to_str())
71 .is_some_and(|name| name == "uv.lock")
72 }
73
74 fn extract_packages(path: &Path) -> Vec<PackageData> {
75 let toml_content = match read_toml_file(path) {
76 Ok(content) => content,
77 Err(e) => {
78 warn!("Failed to read uv.lock at {:?}: {}", path, e);
79 return vec![default_package_data()];
80 }
81 };
82
83 vec![parse_uv_lock(&toml_content)]
84 }
85
86 fn metadata() -> Vec<super::metadata::ParserMetadata> {
87 vec![super::metadata::ParserMetadata {
88 description: "uv lockfile",
89 file_patterns: &["**/uv.lock"],
90 package_type: "pypi",
91 primary_language: "Python",
92 documentation_url: Some("https://docs.astral.sh/uv/concepts/projects/layout/"),
93 }]
94 }
95}
96
97fn parse_uv_lock(toml_content: &TomlValue) -> PackageData {
98 let packages = toml_content
99 .get(FIELD_PACKAGE)
100 .and_then(TomlValue::as_array)
101 .cloned()
102 .unwrap_or_default();
103
104 if packages.is_empty() {
105 return default_package_data();
106 }
107
108 let package_tables: Vec<&TomlMap<String, TomlValue>> = packages
109 .iter()
110 .take(MAX_ITERATION_COUNT)
111 .filter_map(TomlValue::as_table)
112 .collect();
113
114 if package_tables.is_empty() {
115 return default_package_data();
116 }
117
118 let root_index = find_root_package_index(&package_tables);
119 let package_lookup = build_package_lookup(&package_tables);
120
121 let direct_infos = root_index
122 .and_then(|index| package_tables.get(index).copied())
123 .map(collect_root_direct_dependencies)
124 .unwrap_or_default();
125
126 let runtime_roots: Vec<(String, Option<String>)> = direct_infos
127 .iter()
128 .filter(|(_, info)| info.is_runtime)
129 .map(|(name, info)| (name.clone(), info.source_key.clone()))
130 .collect();
131 let dev_roots: Vec<(String, Option<String>)> = direct_infos
132 .iter()
133 .filter(|(_, info)| !info.is_runtime && !info.is_optional)
134 .map(|(name, info)| (name.clone(), info.source_key.clone()))
135 .collect();
136 let optional_roots: Vec<(String, Option<String>)> = direct_infos
137 .iter()
138 .filter(|(_, info)| info.is_optional)
139 .map(|(name, info)| (name.clone(), info.source_key.clone()))
140 .collect();
141
142 let runtime_reachable =
143 collect_reachable_packages(&package_tables, &package_lookup, &runtime_roots, false);
144 let dev_reachable =
145 collect_reachable_packages(&package_tables, &package_lookup, &dev_roots, true);
146 let optional_reachable =
147 collect_reachable_packages(&package_tables, &package_lookup, &optional_roots, true);
148
149 let mut package_data = default_package_data();
150 package_data.extra_data = build_lock_extra_data(toml_content);
151
152 if let Some(index) = root_index
153 && let Some(root_table) = package_tables.get(index)
154 {
155 package_data.name = root_table
156 .get(FIELD_NAME)
157 .and_then(TomlValue::as_str)
158 .map(normalize_pypi_name);
159 package_data.version = root_table
160 .get(FIELD_VERSION)
161 .and_then(TomlValue::as_str)
162 .map(|value| truncate_field(value.to_string()));
163 package_data.is_virtual =
164 package_source_table(root_table).is_some_and(|source| source.contains_key("virtual"));
165 package_data.purl = package_data
166 .name
167 .as_deref()
168 .and_then(|name| create_pypi_purl(name, package_data.version.as_deref()));
169 }
170
171 package_data.dependencies = package_tables
172 .iter()
173 .enumerate()
174 .filter(|(index, _)| Some(*index) != root_index)
175 .filter_map(|(_, package_table)| {
176 build_top_level_dependency(
177 package_table,
178 root_index.is_none(),
179 &direct_infos,
180 &runtime_reachable,
181 &dev_reachable,
182 &optional_reachable,
183 &package_lookup,
184 )
185 })
186 .collect();
187
188 package_data
189}
190
191fn build_top_level_dependency(
192 package_table: &TomlMap<String, TomlValue>,
193 no_root_package: bool,
194 direct_infos: &HashMap<String, DirectDependencyInfo>,
195 runtime_reachable: &HashSet<String>,
196 dev_reachable: &HashSet<String>,
197 optional_reachable: &HashSet<String>,
198 package_lookup: &HashMap<String, Vec<usize>>,
199) -> Option<Dependency> {
200 let name = package_table
201 .get(FIELD_NAME)
202 .and_then(TomlValue::as_str)
203 .map(normalize_pypi_name)?;
204 let version = package_table
205 .get(FIELD_VERSION)
206 .and_then(TomlValue::as_str)
207 .map(|value| truncate_field(value.to_string()))?;
208
209 let direct_info = direct_infos.get(&name);
210 let is_direct = direct_info.is_some();
211 let is_runtime = if no_root_package {
212 true
213 } else if let Some(info) = direct_info {
214 info.is_runtime
215 } else if runtime_reachable.contains(&name) {
216 true
217 } else {
218 !dev_reachable.contains(&name) && !optional_reachable.contains(&name)
219 };
220 let is_optional = direct_info.is_some_and(|info| info.is_optional)
221 || (!is_direct && optional_reachable.contains(&name) && !runtime_reachable.contains(&name));
222
223 Some(Dependency {
224 purl: create_pypi_purl(&name, Some(&version)).map(truncate_field),
225 extracted_requirement: direct_info
226 .and_then(|info| info.extracted_requirement.clone())
227 .map(truncate_field),
228 scope: direct_info
229 .and_then(|info| info.scope.clone())
230 .map(truncate_field),
231 is_runtime: Some(is_runtime),
232 is_optional: Some(is_optional),
233 is_pinned: Some(true),
234 is_direct: Some(is_direct),
235 resolved_package: Some(Box::new(build_resolved_package(
236 package_table,
237 package_lookup,
238 ))),
239 extra_data: direct_info.and_then(|info| info.extra_data.clone()),
240 })
241}
242
243fn build_resolved_package(
244 package_table: &TomlMap<String, TomlValue>,
245 package_lookup: &HashMap<String, Vec<usize>>,
246) -> ResolvedPackage {
247 let name = package_table
248 .get(FIELD_NAME)
249 .and_then(TomlValue::as_str)
250 .map(normalize_pypi_name)
251 .unwrap_or_default();
252 let version = package_table
253 .get(FIELD_VERSION)
254 .and_then(TomlValue::as_str)
255 .map(|value| truncate_field(value.to_string()))
256 .unwrap_or_default();
257
258 let (_, repository_download_url, api_data_url, purl) =
259 build_pypi_urls(Some(&name), Some(&version));
260 let repository_homepage_url =
261 Some(truncate_field(format!("https://pypi.org/project/{}", name)));
262 let (download_url, sha256) = extract_artifact_metadata(package_table);
263
264 let download_url = download_url.map(truncate_field);
265
266 ResolvedPackage {
267 primary_language: Some("Python".to_string()),
268 download_url,
269 sha1: None,
270 sha256: sha256.and_then(|h| Sha256Digest::from_hex(&h).ok()),
271 sha512: None,
272 md5: None,
273 is_virtual: true,
274 extra_data: build_package_extra_data(package_table),
275 dependencies: collect_package_dependency_edges(package_table)
276 .into_iter()
277 .map(|edge| edge_to_dependency(edge, package_lookup))
278 .collect(),
279 repository_homepage_url,
280 repository_download_url: repository_download_url.map(truncate_field),
281 api_data_url: api_data_url.map(truncate_field),
282 datasource_id: Some(DatasourceId::PypiUvLock),
283 purl: purl.map(truncate_field),
284 ..ResolvedPackage::new(UvLockParser::PACKAGE_TYPE, String::new(), name, version)
285 }
286}
287
288fn edge_to_dependency(
289 edge: DependencyEdge,
290 package_lookup: &HashMap<String, Vec<usize>>,
291) -> Dependency {
292 let is_pinned = edge
293 .source_key
294 .as_ref()
295 .map(|_| !package_lookup.contains_key(&edge.name))
296 .unwrap_or(false);
297
298 Dependency {
299 purl: create_pypi_purl(&edge.name, None).map(truncate_field),
300 extracted_requirement: edge.extracted_requirement.map(truncate_field),
301 scope: edge.scope.map(truncate_field),
302 is_runtime: Some(edge.is_runtime),
303 is_optional: Some(edge.is_optional),
304 is_pinned: Some(is_pinned),
305 is_direct: Some(true),
306 resolved_package: None,
307 extra_data: edge.extra_data,
308 }
309}
310
311fn collect_root_direct_dependencies(
312 root_table: &TomlMap<String, TomlValue>,
313) -> HashMap<String, DirectDependencyInfo> {
314 let mut infos = HashMap::new();
315 let metadata = root_table.get(FIELD_METADATA).and_then(TomlValue::as_table);
316 let runtime_requirements = metadata
317 .and_then(|metadata| metadata.get(FIELD_REQUIRES_DIST))
318 .map(parse_requirement_metadata_array)
319 .unwrap_or_default();
320 let dev_requirements = metadata
321 .and_then(|metadata| metadata.get(FIELD_REQUIRES_DEV))
322 .and_then(TomlValue::as_table)
323 .map(parse_requirement_metadata_table)
324 .unwrap_or_default();
325 let optional_requirements = metadata
326 .and_then(|metadata| metadata.get(FIELD_METADATA_OPTIONAL_DEPENDENCIES))
327 .and_then(TomlValue::as_table)
328 .map(parse_requirement_metadata_table)
329 .unwrap_or_default();
330
331 for edge in collect_dependency_edges_from_array(
332 root_table
333 .get(FIELD_DEPENDENCIES)
334 .and_then(TomlValue::as_array),
335 None,
336 true,
337 false,
338 runtime_requirements.get("__runtime__"),
339 ) {
340 merge_direct_dependency_info(&mut infos, edge);
341 }
342
343 if let Some(optional_table) = root_table
344 .get(FIELD_OPTIONAL_DEPENDENCIES)
345 .and_then(TomlValue::as_table)
346 {
347 for (group, value) in optional_table.iter().take(MAX_ITERATION_COUNT) {
348 let requirement_map = optional_requirements.get(group);
349 for edge in collect_dependency_edges_from_array(
350 value.as_array(),
351 Some(group.to_string()),
352 false,
353 true,
354 requirement_map,
355 )
356 .into_iter()
357 .take(MAX_ITERATION_COUNT)
358 {
359 merge_direct_dependency_info(&mut infos, edge);
360 }
361 }
362 }
363
364 if let Some(dev_table) = root_table
365 .get(FIELD_DEV_DEPENDENCIES)
366 .and_then(TomlValue::as_table)
367 {
368 for (group, value) in dev_table.iter().take(MAX_ITERATION_COUNT) {
369 let requirement_map = dev_requirements.get(group);
370 for edge in collect_dependency_edges_from_array(
371 value.as_array(),
372 Some(group.to_string()),
373 false,
374 false,
375 requirement_map,
376 )
377 .into_iter()
378 .take(MAX_ITERATION_COUNT)
379 {
380 merge_direct_dependency_info(&mut infos, edge);
381 }
382 }
383 }
384
385 infos
386}
387
388fn merge_direct_dependency_info(
389 infos: &mut HashMap<String, DirectDependencyInfo>,
390 edge: DependencyEdge,
391) {
392 let name = edge.name.clone();
393 let new_info = direct_info_from_edge(edge);
394
395 if let Some(existing) = infos.get_mut(&name) {
396 existing.is_runtime |= new_info.is_runtime;
397 existing.is_optional &= new_info.is_optional;
398
399 if existing.extracted_requirement.is_none() {
400 existing.extracted_requirement = new_info.extracted_requirement.clone();
401 }
402
403 existing.scope = merge_scope(existing.scope.as_ref(), new_info.scope.as_ref());
404 existing.extra_data =
405 merge_optional_json_maps(existing.extra_data.take(), new_info.extra_data);
406
407 if existing.source_key != new_info.source_key {
408 existing.source_key = None;
409 }
410 } else {
411 infos.insert(name, new_info);
412 }
413}
414
415fn merge_scope(current: Option<&String>, new: Option<&String>) -> Option<String> {
416 match (current, new) {
417 (None, None) => None,
418 (None, Some(_)) | (Some(_), None) => None,
419 (Some(left), Some(right)) if left == right => Some(left.clone()),
420 _ => None,
421 }
422}
423
424fn merge_optional_json_maps(
425 current: Option<HashMap<String, JsonValue>>,
426 new: Option<HashMap<String, JsonValue>>,
427) -> Option<HashMap<String, JsonValue>> {
428 match (current, new) {
429 (None, None) => None,
430 (Some(map), None) | (None, Some(map)) => Some(map),
431 (Some(mut current), Some(new)) => {
432 for (key, value) in new {
433 current.entry(key).or_insert(value);
434 }
435 Some(current)
436 }
437 }
438}
439
440fn direct_info_from_edge(edge: DependencyEdge) -> DirectDependencyInfo {
441 DirectDependencyInfo {
442 extracted_requirement: edge.extracted_requirement,
443 scope: edge.scope,
444 is_runtime: edge.is_runtime,
445 is_optional: edge.is_optional,
446 extra_data: edge.extra_data,
447 source_key: edge.source_key,
448 }
449}
450
451fn collect_package_dependency_edges(
452 package_table: &TomlMap<String, TomlValue>,
453) -> Vec<DependencyEdge> {
454 let mut edges = Vec::new();
455
456 edges.extend(collect_dependency_edges_from_array(
457 package_table
458 .get(FIELD_DEPENDENCIES)
459 .and_then(TomlValue::as_array),
460 None,
461 true,
462 false,
463 None,
464 ));
465
466 if let Some(optional_table) = package_table
467 .get(FIELD_OPTIONAL_DEPENDENCIES)
468 .and_then(TomlValue::as_table)
469 {
470 for (group, value) in optional_table.iter().take(MAX_ITERATION_COUNT) {
471 edges.extend(
472 collect_dependency_edges_from_array(
473 value.as_array(),
474 Some(group.to_string()),
475 false,
476 true,
477 None,
478 )
479 .into_iter()
480 .take(MAX_ITERATION_COUNT),
481 );
482 }
483 }
484
485 if let Some(dev_table) = package_table
486 .get(FIELD_DEV_DEPENDENCIES)
487 .and_then(TomlValue::as_table)
488 {
489 for (group, value) in dev_table.iter().take(MAX_ITERATION_COUNT) {
490 edges.extend(
491 collect_dependency_edges_from_array(
492 value.as_array(),
493 Some(group.to_string()),
494 false,
495 false,
496 None,
497 )
498 .into_iter()
499 .take(MAX_ITERATION_COUNT),
500 );
501 }
502 }
503
504 edges
505}
506
507fn collect_dependency_edges_from_array(
508 values: Option<&Vec<TomlValue>>,
509 scope: Option<String>,
510 is_runtime: bool,
511 is_optional: bool,
512 requirement_map: Option<&HashMap<String, String>>,
513) -> Vec<DependencyEdge> {
514 values
515 .into_iter()
516 .flatten()
517 .filter_map(|value| {
518 build_dependency_edge(
519 value,
520 scope.clone(),
521 is_runtime,
522 is_optional,
523 requirement_map,
524 )
525 })
526 .collect()
527}
528
529fn build_dependency_edge(
530 value: &TomlValue,
531 scope: Option<String>,
532 is_runtime: bool,
533 is_optional: bool,
534 requirement_map: Option<&HashMap<String, String>>,
535) -> Option<DependencyEdge> {
536 let table = value.as_table()?;
537 let name = table
538 .get(FIELD_NAME)
539 .and_then(TomlValue::as_str)
540 .map(normalize_pypi_name)?;
541
542 let mut extra_data = HashMap::new();
543 if let Some(marker) = table.get(FIELD_MARKER).and_then(TomlValue::as_str) {
544 extra_data.insert(
545 FIELD_MARKER.to_string(),
546 JsonValue::String(marker.to_string()),
547 );
548 }
549 if let Some(extra_value) = table.get(FIELD_EXTRA) {
550 let json_value = toml_value_to_json(extra_value);
551 extra_data.insert(FIELD_EXTRA.to_string(), json_value);
552 }
553
554 let source_key = table
555 .get(FIELD_SOURCE)
556 .and_then(TomlValue::as_table)
557 .and_then(source_table_key);
558 if let Some(source) = table.get(FIELD_SOURCE) {
559 extra_data.insert(FIELD_SOURCE.to_string(), toml_value_to_json(source));
560 }
561
562 let extracted_requirement = requirement_map
563 .and_then(|map| map.get(&name).cloned().map(truncate_field))
564 .or_else(|| {
565 table
566 .get(FIELD_SPECIFIER)
567 .and_then(TomlValue::as_str)
568 .map(|value| truncate_field(value.to_string()))
569 });
570
571 Some(DependencyEdge {
572 name,
573 extracted_requirement,
574 scope,
575 is_runtime,
576 is_optional,
577 source_key,
578 extra_data: (!extra_data.is_empty()).then_some(extra_data),
579 })
580}
581
582fn parse_requirement_metadata_array(value: &TomlValue) -> HashMap<String, HashMap<String, String>> {
583 let mut grouped = HashMap::new();
584 let runtime = value
585 .as_array()
586 .map(|values| parse_requirement_entries(values))
587 .unwrap_or_default();
588 grouped.insert("__runtime__".to_string(), runtime);
589 grouped
590}
591
592fn parse_requirement_metadata_table(
593 table: &TomlMap<String, TomlValue>,
594) -> HashMap<String, HashMap<String, String>> {
595 table
596 .iter()
597 .map(|(group, value)| {
598 (
599 group.to_string(),
600 value
601 .as_array()
602 .map(|values| parse_requirement_entries(values))
603 .unwrap_or_default(),
604 )
605 })
606 .collect()
607}
608
609fn parse_requirement_entries(values: &[TomlValue]) -> HashMap<String, String> {
610 values
611 .iter()
612 .take(MAX_ITERATION_COUNT)
613 .filter_map(|value| {
614 let table = value.as_table()?;
615 let name = table
616 .get(FIELD_NAME)
617 .and_then(TomlValue::as_str)
618 .map(normalize_pypi_name)?;
619 let specifier = table
620 .get(FIELD_SPECIFIER)
621 .and_then(TomlValue::as_str)
622 .map(|value| truncate_field(value.to_string()))?;
623 Some((name, specifier))
624 })
625 .collect()
626}
627
628fn collect_reachable_packages(
629 package_tables: &[&TomlMap<String, TomlValue>],
630 package_lookup: &HashMap<String, Vec<usize>>,
631 roots: &[(String, Option<String>)],
632 include_non_runtime_edges: bool,
633) -> HashSet<String> {
634 let mut visited = HashSet::new();
635 let mut queue: VecDeque<(String, Option<String>)> = roots.iter().cloned().collect();
636 let mut iterations: usize = 0;
637
638 while let Some((name, source_key)) = queue.pop_front() {
639 iterations += 1;
640 if iterations > MAX_ITERATION_COUNT {
641 warn!(
642 "collect_reachable_packages exceeded MAX_ITERATION_COUNT ({})",
643 MAX_ITERATION_COUNT
644 );
645 break;
646 }
647 let Some(index) =
648 match_package_index(package_tables, package_lookup, &name, source_key.as_deref())
649 else {
650 continue;
651 };
652
653 let Some(package_table) = package_tables.get(index) else {
654 continue;
655 };
656
657 let package_name = package_table
658 .get(FIELD_NAME)
659 .and_then(TomlValue::as_str)
660 .map(normalize_pypi_name)
661 .unwrap_or(name);
662
663 if !visited.insert(package_name.clone()) {
664 continue;
665 }
666
667 let edges = if include_non_runtime_edges {
668 collect_package_dependency_edges(package_table)
669 } else {
670 collect_dependency_edges_from_array(
671 package_table
672 .get(FIELD_DEPENDENCIES)
673 .and_then(TomlValue::as_array),
674 None,
675 true,
676 false,
677 None,
678 )
679 };
680
681 for edge in edges {
682 queue.push_back((edge.name, edge.source_key));
683 }
684 }
685
686 visited
687}
688
689fn build_package_lookup(
690 package_tables: &[&TomlMap<String, TomlValue>],
691) -> HashMap<String, Vec<usize>> {
692 let mut lookup: HashMap<String, Vec<usize>> = HashMap::new();
693 for (index, package_table) in package_tables.iter().enumerate() {
694 if let Some(name) = package_table
695 .get(FIELD_NAME)
696 .and_then(TomlValue::as_str)
697 .map(normalize_pypi_name)
698 {
699 lookup.entry(name).or_default().push(index);
700 }
701 }
702 lookup
703}
704
705fn match_package_index(
706 package_tables: &[&TomlMap<String, TomlValue>],
707 package_lookup: &HashMap<String, Vec<usize>>,
708 name: &str,
709 source_key: Option<&str>,
710) -> Option<usize> {
711 let candidates = package_lookup.get(name)?;
712 if candidates.len() == 1 {
713 return candidates.first().copied();
714 }
715
716 let source_key = source_key?;
717 candidates.iter().copied().find(|index| {
718 package_tables
719 .get(*index)
720 .and_then(|table| package_source_table(table))
721 .and_then(source_table_key)
722 .as_deref()
723 == Some(source_key)
724 })
725}
726
727fn find_root_package_index(package_tables: &[&TomlMap<String, TomlValue>]) -> Option<usize> {
728 if let Some(index) = package_tables.iter().position(|table| {
729 package_source_table(table)
730 .and_then(local_source_path)
731 .is_some_and(|path| path == ".")
732 }) {
733 return Some(index);
734 }
735
736 package_tables.iter().position(|table| {
737 package_source_table(table)
738 .is_some_and(|source| source.contains_key("editable") || source.contains_key("virtual"))
739 })
740}
741
742fn local_source_path(source_table: &TomlMap<String, TomlValue>) -> Option<&str> {
743 source_table
744 .get("virtual")
745 .and_then(TomlValue::as_str)
746 .or_else(|| source_table.get("editable").and_then(TomlValue::as_str))
747}
748
749fn build_lock_extra_data(toml_content: &TomlValue) -> Option<HashMap<String, JsonValue>> {
750 let mut extra_data = HashMap::new();
751
752 if let Some(version) = toml_content
753 .get(FIELD_VERSION)
754 .and_then(TomlValue::as_integer)
755 {
756 extra_data.insert(
757 "lockfile_version".to_string(),
758 JsonValue::String(version.to_string()),
759 );
760 }
761
762 if let Some(revision) = toml_content
763 .get(FIELD_REVISION)
764 .and_then(TomlValue::as_integer)
765 {
766 extra_data.insert(
767 FIELD_REVISION.to_string(),
768 JsonValue::String(revision.to_string()),
769 );
770 }
771
772 if let Some(requires_python) = toml_content
773 .get(FIELD_REQUIRES_PYTHON)
774 .and_then(TomlValue::as_str)
775 {
776 extra_data.insert(
777 "requires_python".to_string(),
778 JsonValue::String(requires_python.to_string()),
779 );
780 }
781
782 if let Some(markers) = toml_content.get(FIELD_RESOLUTION_MARKERS) {
783 extra_data.insert(
784 FIELD_RESOLUTION_MARKERS.to_string(),
785 toml_value_to_json(markers),
786 );
787 }
788
789 if let Some(manifest) = toml_content.get(FIELD_MANIFEST) {
790 extra_data.insert(FIELD_MANIFEST.to_string(), toml_value_to_json(manifest));
791 }
792
793 (!extra_data.is_empty()).then_some(extra_data)
794}
795
796fn build_package_extra_data(
797 package_table: &TomlMap<String, TomlValue>,
798) -> Option<HashMap<String, JsonValue>> {
799 let mut extra_data = HashMap::new();
800
801 if let Some(source) = package_table.get(FIELD_SOURCE) {
802 extra_data.insert(FIELD_SOURCE.to_string(), toml_value_to_json(source));
803 }
804
805 if let Some(metadata) = package_table.get(FIELD_METADATA) {
806 extra_data.insert(FIELD_METADATA.to_string(), toml_value_to_json(metadata));
807 }
808
809 (!extra_data.is_empty()).then_some(extra_data)
810}
811
812fn extract_artifact_metadata(
813 package_table: &TomlMap<String, TomlValue>,
814) -> (Option<String>, Option<String>) {
815 if let Some(sdist_table) = package_table.get("sdist").and_then(TomlValue::as_table) {
816 let download_url = sdist_table
817 .get("url")
818 .and_then(TomlValue::as_str)
819 .map(|value| truncate_field(value.to_string()));
820 let sha256 = sdist_table
821 .get("hash")
822 .and_then(TomlValue::as_str)
823 .and_then(strip_sha256_prefix);
824 if download_url.is_some() || sha256.is_some() {
825 return (download_url, sha256);
826 }
827 }
828
829 let wheel_table = package_table
830 .get("wheels")
831 .and_then(TomlValue::as_array)
832 .and_then(|wheels| wheels.first())
833 .and_then(TomlValue::as_table);
834
835 let download_url = wheel_table
836 .and_then(|table| table.get("url"))
837 .and_then(TomlValue::as_str)
838 .map(|value| truncate_field(value.to_string()));
839 let sha256 = wheel_table
840 .and_then(|table| table.get("hash"))
841 .and_then(TomlValue::as_str)
842 .and_then(strip_sha256_prefix);
843
844 (download_url, sha256)
845}
846
847fn strip_sha256_prefix(value: &str) -> Option<String> {
848 value.strip_prefix("sha256:").map(|hash| hash.to_string())
849}
850
851fn package_source_table(
852 package_table: &TomlMap<String, TomlValue>,
853) -> Option<&TomlMap<String, TomlValue>> {
854 package_table
855 .get(FIELD_SOURCE)
856 .and_then(TomlValue::as_table)
857}
858
859fn source_table_key(source_table: &TomlMap<String, TomlValue>) -> Option<String> {
860 ["registry", "editable", "virtual", "git"]
861 .into_iter()
862 .find_map(|key| {
863 source_table
864 .get(key)
865 .and_then(TomlValue::as_str)
866 .map(|value| format!("{}:{}", key, value))
867 })
868}
869
870fn build_pypi_urls(
871 name: Option<&str>,
872 version: Option<&str>,
873) -> (
874 Option<String>,
875 Option<String>,
876 Option<String>,
877 Option<String>,
878) {
879 let repository_homepage_url =
880 name.map(|value| truncate_field(format!("https://pypi.org/project/{}", value)));
881
882 let repository_download_url = name.and_then(|value| {
883 version.map(|ver| {
884 truncate_field(format!(
885 "https://pypi.org/packages/source/{}/{}/{}-{}.tar.gz",
886 &value[..1.min(value.len())],
887 value,
888 value,
889 ver
890 ))
891 })
892 });
893
894 let api_data_url = name.map(|value| {
895 if let Some(ver) = version {
896 truncate_field(format!("https://pypi.org/pypi/{}/{}/json", value, ver))
897 } else {
898 truncate_field(format!("https://pypi.org/pypi/{}/json", value))
899 }
900 });
901
902 let purl = name.and_then(|value| create_pypi_purl(value, version));
903
904 (
905 repository_homepage_url,
906 repository_download_url,
907 api_data_url,
908 purl,
909 )
910}
911
912fn normalize_pypi_name(name: &str) -> String {
913 truncate_field(name.trim().to_ascii_lowercase())
914}
915
916fn create_pypi_purl(name: &str, version: Option<&str>) -> Option<String> {
917 if name.contains('[') || name.contains(']') {
918 return Some(truncate_field(build_manual_pypi_purl(name, version)));
919 }
920
921 if let Ok(mut purl) = PackageUrl::new(UvLockParser::PACKAGE_TYPE.as_str(), name) {
922 if let Some(version) = version
923 && purl.with_version(version).is_err()
924 {
925 return None;
926 }
927 return Some(truncate_field(purl.to_string()));
928 }
929
930 Some(truncate_field(build_manual_pypi_purl(name, version)))
931}
932
933fn build_manual_pypi_purl(name: &str, version: Option<&str>) -> String {
934 let encoded_name = name.replace('[', "%5b").replace(']', "%5d");
935 let mut purl = format!("pkg:pypi/{}", encoded_name);
936 if let Some(version) = version
937 && !version.is_empty()
938 {
939 purl.push('@');
940 purl.push_str(version);
941 }
942 purl
943}
944
945fn toml_value_to_json(value: &TomlValue) -> JsonValue {
946 toml_value_to_json_recursive(value, &mut RecursionGuard::depth_only())
947}
948
949fn toml_value_to_json_recursive(value: &TomlValue, guard: &mut RecursionGuard<()>) -> JsonValue {
950 if guard.descend() {
951 warn!("toml_value_to_json exceeded recursion depth limit");
952 return JsonValue::Null;
953 }
954
955 let result = match value {
956 TomlValue::String(value) => JsonValue::String(value.clone()),
957 TomlValue::Integer(value) => JsonValue::String(value.to_string()),
958 TomlValue::Float(value) => JsonValue::String(value.to_string()),
959 TomlValue::Boolean(value) => JsonValue::Bool(*value),
960 TomlValue::Datetime(value) => JsonValue::String(value.to_string()),
961 TomlValue::Array(values) => JsonValue::Array(
962 values
963 .iter()
964 .map(|v| toml_value_to_json_recursive(v, guard))
965 .collect(),
966 ),
967 TomlValue::Table(values) => JsonValue::Object(
968 values
969 .iter()
970 .map(|(key, value)| (key.clone(), toml_value_to_json_recursive(value, guard)))
971 .collect(),
972 ),
973 };
974 guard.ascend();
975 result
976}
977
978fn default_package_data() -> PackageData {
979 PackageData {
980 package_type: Some(UvLockParser::PACKAGE_TYPE),
981 primary_language: Some("Python".to_string()),
982 datasource_id: Some(DatasourceId::PypiUvLock),
983 ..Default::default()
984 }
985}