Skip to main content

provenant/parsers/
uv_lock.rs

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