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