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