Skip to main content

provenant/parsers/
uv_lock.rs

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