Skip to main content

provenant/parsers/
uv_lock.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7use 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}