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