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